diff --git a/.github/workflows/node-ci-lint.yml b/.github/workflows/node-ci-lint.yml new file mode 100644 index 000000000..688aafcb7 --- /dev/null +++ b/.github/workflows/node-ci-lint.yml @@ -0,0 +1,73 @@ +# This reusable workflow is executed to run linting checks for the project +name: Run lint for the project + +on: + workflow_call: + +jobs: + lint: + name: Lint + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + node-version: [20.x] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install & cache node_modules + uses: Khan/actions@shared-node-cache-v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Get All Changed Files + uses: Khan/actions@get-changed-files-v2 + id: changed + + - id: js-ts-files + name: Find .js, .ts changed files + uses: Khan/actions@filter-files-v1 + with: + changed-files: ${{ steps.changed.outputs.files }} + extensions: '.js,.jsx,.ts,.tsx' + + - id: eslint-reset + uses: Khan/actions@filter-files-v1 + name: Files that would trigger a full eslint run + with: + changed-files: ${{ steps.changed.outputs.files }} + files: '.eslintrc.js,yarn.lock,.eslintignore' + + - id: typecheck-reset + uses: Khan/actions@filter-files-v1 + name: Files that would trigger a typecheck run + with: + changed-files: ${{ steps.changed.outputs.files }} + files: '.yarn.lock' + + # Linting / type checking + - name: Eslint + uses: Khan/actions@full-or-limited-v0 + with: + full-trigger: ${{ steps.eslint-reset.outputs.filtered }} + full: yarn lint:ci . + limited-trigger: ${{ steps.js-ts-files.outputs.filtered }} + limited: yarn lint:ci {} + + - name: Typecheck + if: always() # always run this check until we update the eslint config + # if: steps.js-ts-files.outputs.filtered != '[]' || steps.typecheck-reset.outputs.filtered != '[]' + run: yarn typecheck + + - name: Build .js bundles + run: yarn build + + - name: Build .d.ts types + run: yarn build:types + + - name: Check package.json files + run: node utils/publish/pre-publish-check-ci.js diff --git a/.github/workflows/node-ci-pr.yml b/.github/workflows/node-ci-pr.yml index db262aa33..da64659d5 100644 --- a/.github/workflows/node-ci-pr.yml +++ b/.github/workflows/node-ci-pr.yml @@ -65,99 +65,14 @@ jobs: lint: name: Lint needs: prime_cache_primary - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] - node-version: [20.x] - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install & cache node_modules - uses: Khan/actions@shared-node-cache-v2 - with: - node-version: ${{ matrix.node-version }} - - - name: Get All Changed Files - uses: Khan/actions@get-changed-files-v2 - id: changed - - - id: js-ts-files - name: Find .js, .ts changed files - uses: Khan/actions@filter-files-v1 - with: - changed-files: ${{ steps.changed.outputs.files }} - extensions: '.js,.jsx,.ts,.tsx' - - - id: eslint-reset - uses: Khan/actions@filter-files-v1 - name: Files that would trigger a full eslint run - with: - changed-files: ${{ steps.changed.outputs.files }} - files: '.eslintrc.js,yarn.lock,.eslintignore' - - - id: typecheck-reset - uses: Khan/actions@filter-files-v1 - name: Files that would trigger a typecheck run - with: - changed-files: ${{ steps.changed.outputs.files }} - files: '.yarn.lock' - - # Linting / type checking - - name: Eslint - uses: Khan/actions@full-or-limited-v0 - with: - full-trigger: ${{ steps.eslint-reset.outputs.filtered }} - full: yarn lint:ci . - limited-trigger: ${{ steps.js-ts-files.outputs.filtered }} - limited: yarn lint:ci {} - - - name: Typecheck - if: always() # always run this check until we update the eslint config - # if: steps.js-ts-files.outputs.filtered != '[]' || steps.typecheck-reset.outputs.filtered != '[]' - run: yarn typecheck - - - name: Build .js bundles - run: yarn build - - - name: Build .d.ts types - run: yarn build:types - - - name: Check package.json files - run: node utils/publish/pre-publish-check-ci.js + uses: ./.github/workflows/node-ci-lint.yml test: name: Test needs: prime_cache_primary - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] - node-version: [20.x] - shard: ["1/2", "2/2"] - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install & cache node_modules - uses: Khan/actions@shared-node-cache-v2 - with: - node-version: ${{ matrix.node-version }} - # Testing and coverage - - name: Run jest tests with coverage - run: yarn coverage:ci --shard ${{ matrix.shard }} - - name: Upload Coverage - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/coverage-final.json + uses: ./.github/workflows/node-ci-test.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} check_builds: name: Check build sizes diff --git a/.github/workflows/node-ci-push.yml b/.github/workflows/node-ci-push.yml index fdee67b89..de11eb423 100644 --- a/.github/workflows/node-ci-push.yml +++ b/.github/workflows/node-ci-push.yml @@ -11,8 +11,9 @@ on: # 1. Prime caches for primary configuration (ubuntu on node 14). # This way the next two jobs can run in parallel but rely on this primed # cache. -# 2. Coverage -# 3. Chromatic autoApprove on squash commits +# 2. Lint +# 3. Test (with coverage) +# 4. Chromatic autoApprove on squash commits # # For pushes directly to a branch, we assume a PR has been used with wider # checks, this just makes sure our coverage data is up-to-date. @@ -35,36 +36,14 @@ jobs: with: node-version: ${{ matrix.node-version }} + lint: + name: Lint + needs: prime_cache_primary + uses: ./.github/workflows/node-ci-lint.yml - coverage: - needs: [prime_cache_primary] - name: Gather coverage - env: - CI: true - runs-on: ${{ matrix.os }} - strategy: - matrix: - # Use a matrix as it means we get the version info in the job name - # which is very helpful. - os: [ubuntu-latest] - node-version: [20.x] - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - # Cache and install dependencies - - name: Install & cache node_modules - uses: Khan/actions@shared-node-cache-v2 - with: - node-version: ${{ matrix.node-version }} - - - name: Run Jest with coverage - run: yarn coverage:ci - - name: Upload Coverage - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/coverage-final.json + test: + name: Test + needs: prime_cache_primary + uses: ./.github/workflows/node-ci-test.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/node-ci-test.yml b/.github/workflows/node-ci-test.yml new file mode 100644 index 000000000..275bc6a9b --- /dev/null +++ b/.github/workflows/node-ci-test.yml @@ -0,0 +1,37 @@ +# This reusable workflow is executed to run test checks for the project +name: Run tests for the project + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: true + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + node-version: [20.x] + shard: ["1/2", "2/2"] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install & cache node_modules + uses: Khan/actions@shared-node-cache-v2 + with: + node-version: ${{ matrix.node-version }} + # Testing and coverage + - name: Run jest tests with coverage + run: yarn coverage:ci --shard ${{ matrix.shard }} + - name: Upload Coverage + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/coverage-final.json diff --git a/__docs__/wonder-blocks-birthday-picker/birthday-picker.stories.tsx b/__docs__/wonder-blocks-birthday-picker/birthday-picker.stories.tsx index 98f1bd642..e9e8ddc31 100644 --- a/__docs__/wonder-blocks-birthday-picker/birthday-picker.stories.tsx +++ b/__docs__/wonder-blocks-birthday-picker/birthday-picker.stories.tsx @@ -32,6 +32,9 @@ const meta: Meta = { excludeDecorators: true, }, }, + viewport: { + defaultViewport: "large", + }, }, decorators: [(Story): React.ReactElement => {Story()}], }; @@ -172,3 +175,22 @@ export const BirthdayPickerVerticalWithError: StoryComponentType = { }, }, }; + +export const BirthdayPickerMobile: StoryComponentType = { + args: { + onChange: (date?: string | null) => { + // eslint-disable-next-line no-console + console.log("Date selected: ", date); + }, + }, + parameters: { + docs: { + description: { + story: "A BirthdayPicker will reflow on small screens to stack controls rather than position them side-by-side.", + }, + }, + viewport: { + defaultViewport: "small", + }, + }, +}; diff --git a/__docs__/wonder-blocks-dropdown/combobox.stories.tsx b/__docs__/wonder-blocks-dropdown/combobox.stories.tsx index c22659639..7f9fbf613 100644 --- a/__docs__/wonder-blocks-dropdown/combobox.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/combobox.stories.tsx @@ -396,3 +396,35 @@ export const AutoCompleteMultiSelect: Story = { }, }, }; + +/** + * This `Combobox` is in an error state. Selecting any option will clear the + * error state by updating the `error` prop to `false`. + * + * **NOTE:** We internally apply the correct `aria-invalid` attribute based on + * the `error` prop. + */ + +export const Error: Story = { + render: function Render(args: PropsFor) { + const [error, setError] = React.useState(args.error); + const [value, setValue] = React.useState(args.value); + + return ( + { + setValue(newValue); + setError(newValue !== "" ? false : true); + action("onChange")(newValue); + }} + /> + ); + }, + args: { + children: items, + error: true, + }, +}; diff --git a/__docs__/wonder-blocks-form/checkbox-group.stories.tsx b/__docs__/wonder-blocks-form/checkbox-group.stories.tsx index 4b9a0091f..120cf52b2 100644 --- a/__docs__/wonder-blocks-form/checkbox-group.stories.tsx +++ b/__docs__/wonder-blocks-form/checkbox-group.stories.tsx @@ -26,6 +26,15 @@ export default { type StoryComponentType = StoryObj; +/** + * `CheckboxGroup` is a component that groups multiple `Choice` components + * together. It is used to allow users to select multiple options from a list. + * + * Note that by using a `label` prop, the `CheckboxGroup` component will render + * a `legend` as the first child of the `fieldset` element. This is important to + * include as it ensures that Screen Readers can correctly identify and announce + * the group of checkboxes. + */ export const Default: StoryComponentType = { render: (args) => { return ( diff --git a/__docs__/wonder-blocks-form/labeled-text-field.argtypes.ts b/__docs__/wonder-blocks-form/labeled-text-field.argtypes.ts index 27ff0e1e5..b74b277e9 100644 --- a/__docs__/wonder-blocks-form/labeled-text-field.argtypes.ts +++ b/__docs__/wonder-blocks-form/labeled-text-field.argtypes.ts @@ -82,8 +82,22 @@ export default { }, }, + name: { + description: "Provide a name for the TextField.", + table: { + type: { + summary: "string", + }, + }, + control: { + type: "text", + }, + }, + disabled: { - description: "Makes a read-only input field that cannot be focused.", + description: `Whether the input should be disabled. Defaults to false. + If the disabled prop is set to \`true\`, LabeledTextField will have disabled + styling and will not be interactable.`, table: { type: { summary: "boolean", diff --git a/__docs__/wonder-blocks-form/labeled-text-field.stories.tsx b/__docs__/wonder-blocks-form/labeled-text-field.stories.tsx index f2830e4a9..06c387218 100644 --- a/__docs__/wonder-blocks-form/labeled-text-field.stories.tsx +++ b/__docs__/wonder-blocks-form/labeled-text-field.stories.tsx @@ -525,6 +525,16 @@ ErrorLight.parameters = { }, }; +/** + * If the disabled prop is set to `true`, LabeledTextField will have disabled styling + * and will not be interactable. + * + * Note: The `disabled` prop sets the `aria-disabled` attribute to `true` + * instead of setting the `disabled` attribute. This is so that the component + * remains focusable while communicating to screen readers that it is disabled. + * This `disabled` prop will also set the `readonly` attribute to prevent + * typing in the field. + */ export const Disabled: StoryComponentType = () => ( ; +/** + * `RadioGroup` is a component that groups multiple `Choice` components + * together. It is used to allow users to select a single option from a list. + * + * Note that by using a `label` prop, the `RadioGroup` component will render + * a `legend` as the first child of the `fieldset` element. This is important to + * include as it ensures that Screen Readers can correctly identify and announce + * the group of radio buttons. + */ export const Default: StoryComponentType = { render: (args) => { return ( diff --git a/__docs__/wonder-blocks-form/text-area-variants.stories.tsx b/__docs__/wonder-blocks-form/text-area-variants.stories.tsx index 43d645f51..b1d691b3f 100644 --- a/__docs__/wonder-blocks-form/text-area-variants.stories.tsx +++ b/__docs__/wonder-blocks-form/text-area-variants.stories.tsx @@ -164,5 +164,6 @@ const styles = StyleSheet.create({ }, scenario: { gap: spacing.small_12, + overflow: "hidden", }, }); diff --git a/__docs__/wonder-blocks-form/text-field-variants.stories.tsx b/__docs__/wonder-blocks-form/text-field-variants.stories.tsx new file mode 100644 index 000000000..fdadea4f3 --- /dev/null +++ b/__docs__/wonder-blocks-form/text-field-variants.stories.tsx @@ -0,0 +1,169 @@ +import * as React from "react"; +import {StyleSheet} from "aphrodite"; +import type {Meta, StoryObj} from "@storybook/react"; + +import {View} from "@khanacademy/wonder-blocks-core"; +import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {LabelLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography"; +import {TextField} from "@khanacademy/wonder-blocks-form"; + +/** + * The following stories are used to generate the pseudo states for the + * TextField component. This is only used for visual testing in Chromatic. + * + * Note: Error state is not shown on initial render if the TextField value is empty. + */ +export default { + title: "Packages / Form / TextField / All Variants", + parameters: { + docs: { + autodocs: false, + }, + }, +} as Meta; + +type StoryComponentType = StoryObj; + +const longText = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."; +const longTextWithNoWordBreak = + "Loremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtemporincididuntutlaboreetdoloremagnaaliqua"; + +const states = [ + { + label: "Default", + props: {}, + }, + { + label: "Disabled", + props: {disabled: true}, + }, + { + label: "Error", + props: {validate: () => "Error"}, + }, +]; +const States = (props: { + light: boolean; + label: string; + value?: string; + placeholder?: string; +}) => { + return ( + + + {props.label} + + + {states.map((scenario) => { + return ( + + + {scenario.label} + + {}} + {...props} + {...scenario.props} + /> + + ); + })} + + + ); +}; + +const AllVariants = () => ( + + {[false, true].map((light) => { + return ( + + + + + + + + + + ); + })} + +); + +export const Default: StoryComponentType = { + render: AllVariants, +}; + +/** + * There are currently no hover styles. + */ +export const Hover: StoryComponentType = { + render: AllVariants, + parameters: {pseudo: {hover: true}}, +}; + +export const Focus: StoryComponentType = { + render: AllVariants, + parameters: {pseudo: {focusVisible: true}}, +}; + +export const HoverFocus: StoryComponentType = { + name: "Hover + Focus", + render: AllVariants, + parameters: {pseudo: {hover: true, focusVisible: true}}, +}; + +/** + * There are currently no active styles. + */ +export const Active: StoryComponentType = { + render: AllVariants, + parameters: {pseudo: {active: true}}, +}; + +const styles = StyleSheet.create({ + darkDefault: { + backgroundColor: color.darkBlue, + }, + statesContainer: { + padding: spacing.medium_16, + }, + scenarios: { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: spacing.xxxLarge_64, + flexWrap: "wrap", + }, + scenario: { + gap: spacing.small_12, + overflow: "hidden", + }, +}); diff --git a/__docs__/wonder-blocks-form/text-field.argtypes.ts b/__docs__/wonder-blocks-form/text-field.argtypes.ts index 3af083960..fc16bcd23 100644 --- a/__docs__/wonder-blocks-form/text-field.argtypes.ts +++ b/__docs__/wonder-blocks-form/text-field.argtypes.ts @@ -70,7 +70,9 @@ export default { }, disabled: { - description: "Makes a read-only input field that cannot be focused.", + description: `Whether the input should be disabled. Defaults to false. + If the disabled prop is set to \`true\`, TextField will have disabled + styling and will not be interactable.`, table: { type: { summary: "boolean", diff --git a/__docs__/wonder-blocks-form/text-field.stories.tsx b/__docs__/wonder-blocks-form/text-field.stories.tsx index 86b54b629..ba18858c2 100644 --- a/__docs__/wonder-blocks-form/text-field.stories.tsx +++ b/__docs__/wonder-blocks-form/text-field.stories.tsx @@ -603,6 +603,16 @@ ErrorLight.parameters = { }, }; +/** + * If the disabled prop is set to `true`, TextField will have disabled styling + * and will not be interactable. + * + * Note: The `disabled` prop sets the `aria-disabled` attribute to `true` + * instead of setting the `disabled` attribute. This is so that the component + * remains focusable while communicating to screen readers that it is disabled. + * This `disabled` prop will also set the `readonly` attribute to prevent + * typing in the field. + */ export const Disabled: StoryComponentType = () => ( ( +
{ + e.preventDefault(); + console.log("form submitted"); + action("form submitted")(e); + }} + > + + + Search:{" "} + {}} + /> + + + +
+ ), + parameters: { + chromatic: { + // We are testing the form submission, not UI changes. + disableSnapshot: true, + }, + }, +}; + const styles = StyleSheet.create({ dark: { backgroundColor: color.darkBlue, @@ -366,6 +406,7 @@ const styles = StyleSheet.create({ width: spacing.xxxLarge_64, }, row: { + display: "flex", flexDirection: "row", gap: spacing.medium_16, alignItems: "center", diff --git a/__docs__/wonder-blocks-search-field/search-field.argtypes.ts b/__docs__/wonder-blocks-search-field/search-field.argtypes.ts index 7e6a79323..c5f7b00e1 100644 --- a/__docs__/wonder-blocks-search-field/search-field.argtypes.ts +++ b/__docs__/wonder-blocks-search-field/search-field.argtypes.ts @@ -1,6 +1,5 @@ export default { clearAriaLabel: { - description: `ARIA label for the clear button. Defaults to "Clear search".`, type: {name: "string", required: false}, table: { type: { @@ -13,8 +12,6 @@ export default { }, }, id: { - description: `The unique identifier for the input. If one is not - provided, a unique id will be generated.`, type: {name: "string", required: false}, table: { type: { @@ -26,7 +23,6 @@ export default { }, }, value: { - description: "The text input value.", type: {name: "string", required: true}, table: { type: { @@ -36,8 +32,6 @@ export default { control: {type: "text"}, }, name: { - description: `The name for the input control. This is submitted along - with the form data.`, type: {name: "string", required: false}, table: { type: { @@ -49,9 +43,6 @@ export default { }, }, placeholder: { - description: `Provide hints or examples of what to enter. - This shows up as a grayed out text in the field before - a value is entered.`, type: {name: "string", required: false}, table: { type: { @@ -63,7 +54,6 @@ export default { }, }, autoFocus: { - description: "Whether this field should autofocus on page load.", type: {name: "boolean", required: false}, table: { type: { @@ -78,8 +68,6 @@ export default { }, }, disabled: { - description: `Makes a read-only input field that cannot be focused. - Defaults to false.`, type: {name: "boolean", required: false}, table: { type: { @@ -94,8 +82,6 @@ export default { }, }, light: { - description: - "Change the default focus ring color to fit a dark background.", type: {name: "boolean", required: false}, table: { type: { @@ -110,7 +96,6 @@ export default { }, }, style: { - description: "Custom styles for the main wrapper.", table: { type: { summary: "Style", @@ -124,7 +109,6 @@ export default { }, }, testId: { - description: "Test ID used for e2e testing.", type: {name: "string", required: false}, table: { type: { @@ -136,7 +120,6 @@ export default { }, }, onChange: { - description: "Called when the value has changed.", table: { type: { summary: "function", @@ -145,8 +128,6 @@ export default { }, }, onClick: { - description: `Handler that is triggered when this component is clicked. - For example, use this to adjust focus in parent component.`, table: { type: { summary: "function", @@ -155,7 +136,14 @@ export default { }, }, onKeyDown: { - description: "Called when a key is pressed.", + table: { + type: { + summary: "function", + }, + category: "Events", + }, + }, + onKeyUp: { table: { type: { summary: "function", @@ -164,7 +152,6 @@ export default { }, }, onFocus: { - description: "Called when the element has been focused.", table: { type: { summary: "function", @@ -173,7 +160,6 @@ export default { }, }, onBlur: { - description: "Called when the element has been blurred.", table: { type: { summary: "function", diff --git a/__docs__/wonder-blocks-testing/exports.mock-fetch.mdx b/__docs__/wonder-blocks-testing/exports.mock-fetch.mdx index 7c0c37134..582c373aa 100644 --- a/__docs__/wonder-blocks-testing/exports.mock-fetch.mdx +++ b/__docs__/wonder-blocks-testing/exports.mock-fetch.mdx @@ -16,10 +16,11 @@ The `mockFetch` function provides an API to easily mock `fetch()` responses. It Besides being a function that fits the `fetch()` signature, the return value of `mockFetch()` has an API to customize the behavior of that function. Used in conjunction with the `RespondWith` API, this can create a variety of responses for tests and stories. -| Function | Purpose | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `mockOperation` | When called, any request that matches the defined mock will respond with the given response. | +| Function | Purpose | +| - | - | +| `mockOperation` | When called, any request that matches the defined mock will respond with the given response. | | `mockOperationOnce` | When called, the first request that matches the defined mock will respond with the given response. The mock is only used once. | +| `configure` | This allows you to configure the behavior of the mock fetch function. | Both of these functions have the same signature: @@ -30,6 +31,25 @@ type FetchMockOperationFn = ( ) => FetchMockFn; ``` -# Operation Matching + +## Configuration + +The `configure` function allows you to configure the behavior of the mocked fetch function. It takes a partial configuration and applies that to the existing +configuration. This changes the behavior of all calls to the mocked function. + +The full configuration is: + +```ts +{ + hardFailOnUnmockedRequests: boolean; +} +``` + +| Configuration Key | Purpose | +| - | - | +| `hardFailOnUnmockedRequests` | When set to `true`, any unmocked request will throw an error, causing tests to fail. When set to `false`, unmocked requests will reject, which in turn gets handled by the relevant error handling in the code under test - this is the default behavior and is usually what you want so that you don't need to mock every single request that may be invoked during your tests. | + + +## Operation Matching The `FetchMockOperation` type is either of type `string` or `RegExp`. When specified as a string, the URL of the request must match the string exactly. When specified as a regular expression, the URL of the request must match the regular expression. diff --git a/__docs__/wonder-blocks-testing/exports.mock-gql-fetch.mdx b/__docs__/wonder-blocks-testing/exports.mock-gql-fetch.mdx index 07b5f099a..807a71681 100644 --- a/__docs__/wonder-blocks-testing/exports.mock-gql-fetch.mdx +++ b/__docs__/wonder-blocks-testing/exports.mock-gql-fetch.mdx @@ -16,10 +16,11 @@ The `mockGqlFetch` function provides an API to easily mock GraphQL responses for Besides being a function that fits the `GqlFetchFn` signature, the return value of `mockGqlFetch()` has an API to customize the behavior of that function. Used in conjunction with the `RespondWith` API, this can create a variety of GraphQL responses for testing and stories. -| Function | Purpose | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mockOperation` | When called, any GraphQL operation that matches the defined mock operation will respond with the given response. | +| Function | Purpose | +| - | - | +| `mockOperation` | When called, any GraphQL operation that matches the defined mock operation will respond with the given response. | | `mockOperationOnce` | When called, the first GraphQL operation that matches the defined mock operation will respond with the given response. The mock is only used once. | +| `configure` | This allows you to configure the behavior of the mock fetch function. | Both of these functions have the same signature: @@ -35,7 +36,24 @@ type GqlMockOperationFn = < ) => GqlFetchMockFn; ``` -# Operation Matching +## Configuration + +The `configure` function allows you to configure the behavior of the mocked fetch function. It takes a partial configuration and applies that to the existing +configuration. This changes the behavior of all calls to the mocked function. + +The full configuration is: + +```ts +{ + hardFailOnUnmockedRequests: boolean; +} +``` + +| Configuration Key | Purpose | +| - | - | +| `hardFailOnUnmockedRequests` | When set to `true`, any unmocked request will throw an error, causing tests to fail. When set to `false`, unmocked requests will reject, which in turn gets handled by the relevant error handling in the code under test - this is the default behavior and is usually what you want so that you don't need to mock every single request that may be invoked during your tests. | + +## Operation Matching The `matchOperation` parameter given to a `mockOperation` or `mockOperationOnce` function is a `GqlMockOperation` defining the actual GraphQL operation to be matched by the mock. The variables and context of the mocked operation change how the mock is matched against requests. diff --git a/packages/wonder-blocks-banner/CHANGELOG.md b/packages/wonder-blocks-banner/CHANGELOG.md index 85ec5297f..fbf02176a 100644 --- a/packages/wonder-blocks-banner/CHANGELOG.md +++ b/packages/wonder-blocks-banner/CHANGELOG.md @@ -1,5 +1,19 @@ # @khanacademy/wonder-blocks-banner +## 3.1.12 + +### Patch Changes + +- Updated dependencies [75da0046] + - @khanacademy/wonder-blocks-icon-button@5.6.0 + +## 3.1.11 + +### Patch Changes + +- Updated dependencies [3463bde3] + - @khanacademy/wonder-blocks-icon-button@5.5.0 + ## 3.1.10 ### Patch Changes diff --git a/packages/wonder-blocks-banner/package.json b/packages/wonder-blocks-banner/package.json index 0e8d53130..716e9ce31 100644 --- a/packages/wonder-blocks-banner/package.json +++ b/packages/wonder-blocks-banner/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-banner", - "version": "3.1.10", + "version": "3.1.12", "design": "v1", "description": "Banner components for Wonder Blocks.", "main": "dist/index.js", @@ -19,7 +19,7 @@ "@khanacademy/wonder-blocks-button": "^6.3.10", "@khanacademy/wonder-blocks-core": "^7.0.1", "@khanacademy/wonder-blocks-icon": "^4.1.5", - "@khanacademy/wonder-blocks-icon-button": "^5.4.1", + "@khanacademy/wonder-blocks-icon-button": "^5.6.0", "@khanacademy/wonder-blocks-link": "^6.1.8", "@khanacademy/wonder-blocks-tokens": "^2.0.1", "@khanacademy/wonder-blocks-typography": "^2.1.16" diff --git a/packages/wonder-blocks-birthday-picker/CHANGELOG.md b/packages/wonder-blocks-birthday-picker/CHANGELOG.md index 85b0214b9..945eba18b 100644 --- a/packages/wonder-blocks-birthday-picker/CHANGELOG.md +++ b/packages/wonder-blocks-birthday-picker/CHANGELOG.md @@ -1,5 +1,42 @@ # @khanacademy/wonder-blocks-birthday-picker +## 2.0.87 + +### Patch Changes + +- a32b0779: Add responsive default to BirthdayPicker + +## 2.0.86 + +### Patch Changes + +- Updated dependencies [0b3a28a7] + - @khanacademy/wonder-blocks-dropdown@5.6.0 + +## 2.0.85 + +### Patch Changes + +- @khanacademy/wonder-blocks-dropdown@5.5.6 + +## 2.0.84 + +### Patch Changes + +- @khanacademy/wonder-blocks-dropdown@5.5.5 + +## 2.0.83 + +### Patch Changes + +- @khanacademy/wonder-blocks-dropdown@5.5.4 + +## 2.0.82 + +### Patch Changes + +- @khanacademy/wonder-blocks-dropdown@5.5.3 + ## 2.0.81 ### Patch Changes diff --git a/packages/wonder-blocks-birthday-picker/package.json b/packages/wonder-blocks-birthday-picker/package.json index 8ef73efef..84b151439 100644 --- a/packages/wonder-blocks-birthday-picker/package.json +++ b/packages/wonder-blocks-birthday-picker/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-birthday-picker", - "version": "2.0.81", + "version": "2.0.87", "design": "v1", "publishConfig": { "access": "public" @@ -15,7 +15,7 @@ "dependencies": { "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", - "@khanacademy/wonder-blocks-dropdown": "^5.5.2", + "@khanacademy/wonder-blocks-dropdown": "^5.6.0", "@khanacademy/wonder-blocks-icon": "^4.1.5", "@khanacademy/wonder-blocks-layout": "^2.2.1", "@khanacademy/wonder-blocks-tokens": "^2.0.1", diff --git a/packages/wonder-blocks-birthday-picker/src/components/birthday-picker.tsx b/packages/wonder-blocks-birthday-picker/src/components/birthday-picker.tsx index 8a64b9c26..a53f6f8d0 100644 --- a/packages/wonder-blocks-birthday-picker/src/components/birthday-picker.tsx +++ b/packages/wonder-blocks-birthday-picker/src/components/birthday-picker.tsx @@ -1,5 +1,6 @@ import moment from "moment"; // NOTE: DO NOT use named imports; 'moment' does not support named imports import * as React from "react"; +import {StyleSheet} from "aphrodite"; import {StyleType, View} from "@khanacademy/wonder-blocks-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; @@ -103,6 +104,8 @@ const FIELD_MIN_WIDTH_FULL = 110; // See: https://www.figma.com/file/uJZi9ZvuEz5N8GJ3HqKFAa/(2021)-Account-records?node-id=20%3A398 const FIELD_MIN_WIDTH_MONTH_YEAR = 167; +const FIELD_MIN_WIDTH_DAY = 100; + /** * Birthday Picker. Similar to a datepicker, but specifically for birthdates. * We don't want to show a calendar in this case as it can be quite tedious to @@ -129,6 +132,27 @@ const FIELD_MIN_WIDTH_MONTH_YEAR = 167; * /> * ``` */ + +/* [WB-1655] Update with media query tokens */ +const xsMin = "520px"; + +const screenSizes = { + small: `@media (max-width: ${xsMin})`, +}; + +const defaultStyles = StyleSheet.create({ + wrapper: { + flexDirection: "row", + [screenSizes.small]: { + flexDirection: "column", + }, + }, + input: { + [screenSizes.small]: { + minWidth: "100%", + }, + }, +}); export default class BirthdayPicker extends React.Component { /** * Strings used for placeholders and error message. These are used this way @@ -273,10 +297,7 @@ export default class BirthdayPicker extends React.Component { renderMonth(): React.ReactNode { const {disabled, monthYearOnly, dropdownStyle} = this.props; const {month} = this.state; - const minWidth = monthYearOnly - ? FIELD_MIN_WIDTH_MONTH_YEAR - : FIELD_MIN_WIDTH_FULL; - + const minWidth = this.getMonthYearWidth(monthYearOnly); return ( { placeholder={this.labels.month} onChange={this.handleMonthChange} selectedValue={month} - style={{minWidth, ...dropdownStyle}} + style={[{minWidth}, defaultStyles.input, dropdownStyle]} testId="birthday-picker-month" > {/* eslint-disable-next-line import/no-named-as-default-member */} @@ -315,7 +336,13 @@ export default class BirthdayPicker extends React.Component { placeholder={this.labels.day} onChange={this.handleDayChange} selectedValue={day} - style={{minWidth: 100, ...dropdownStyle}} + style={[ + { + minWidth: FIELD_MIN_WIDTH_DAY, + }, + defaultStyles.input, + dropdownStyle, + ]} testId="birthday-picker-day" > {Array.from(Array(31)).map((_, day) => ( @@ -330,12 +357,17 @@ export default class BirthdayPicker extends React.Component { ); } + getMonthYearWidth(monthYearOnly: boolean | undefined): number { + return monthYearOnly + ? FIELD_MIN_WIDTH_MONTH_YEAR + : FIELD_MIN_WIDTH_FULL; + } + renderYear(): React.ReactNode { const {disabled, monthYearOnly, dropdownStyle} = this.props; const {year} = this.state; - const minWidth = monthYearOnly - ? FIELD_MIN_WIDTH_MONTH_YEAR - : FIELD_MIN_WIDTH_FULL; + + const minWidth = this.getMonthYearWidth(monthYearOnly); return ( { placeholder={this.labels.year} onChange={this.handleYearChange} selectedValue={year} - style={{minWidth, ...dropdownStyle}} + style={[{minWidth}, defaultStyles.input, dropdownStyle]} // Allows displaying the dropdown options without truncating // them when the user zooms in the browser. dropdownStyle={{minWidth: 150}} @@ -369,7 +401,7 @@ export default class BirthdayPicker extends React.Component { <> {this.renderMonth()} diff --git a/packages/wonder-blocks-core/package.json b/packages/wonder-blocks-core/package.json index 157441dcf..99198087c 100644 --- a/packages/wonder-blocks-core/package.json +++ b/packages/wonder-blocks-core/package.json @@ -24,7 +24,7 @@ }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1", - "@khanacademy/wonder-blocks-testing-core": "^1.0.2" + "@khanacademy/wonder-blocks-testing-core": "^1.1.0" }, "author": "", "license": "MIT" diff --git a/packages/wonder-blocks-data/package.json b/packages/wonder-blocks-data/package.json index d407e5ac4..1d58fdf75 100644 --- a/packages/wonder-blocks-data/package.json +++ b/packages/wonder-blocks-data/package.json @@ -23,7 +23,7 @@ "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1", "@khanacademy/wonder-stuff-testing": "^3.0.1", - "@khanacademy/wonder-blocks-testing-core": "^1.0.2" + "@khanacademy/wonder-blocks-testing-core": "^1.1.0" }, "author": "", "license": "MIT" diff --git a/packages/wonder-blocks-dropdown/CHANGELOG.md b/packages/wonder-blocks-dropdown/CHANGELOG.md index 4adc472ff..8b64d1288 100644 --- a/packages/wonder-blocks-dropdown/CHANGELOG.md +++ b/packages/wonder-blocks-dropdown/CHANGELOG.md @@ -1,5 +1,43 @@ # @khanacademy/wonder-blocks-dropdown +## 5.6.0 + +### Minor Changes + +- 0b3a28a7: - Combobox: Add error prop to support aria-invalid and styling changes. + - TextField: Modify aria-invalid order to be overriden by the caller. + +### Patch Changes + +- @khanacademy/wonder-blocks-search-field@2.3.3 + +## 5.5.6 + +### Patch Changes + +- @khanacademy/wonder-blocks-search-field@2.3.2 + +## 5.5.5 + +### Patch Changes + +- @khanacademy/wonder-blocks-modal@5.1.14 +- @khanacademy/wonder-blocks-search-field@2.3.1 + +## 5.5.4 + +### Patch Changes + +- Updated dependencies [659a031d] + - @khanacademy/wonder-blocks-search-field@2.3.0 + - @khanacademy/wonder-blocks-modal@5.1.13 + +## 5.5.3 + +### Patch Changes + +- @khanacademy/wonder-blocks-search-field@2.2.27 + ## 5.5.2 ### Patch Changes diff --git a/packages/wonder-blocks-dropdown/package.json b/packages/wonder-blocks-dropdown/package.json index a6ce6c48c..ffd62f909 100644 --- a/packages/wonder-blocks-dropdown/package.json +++ b/packages/wonder-blocks-dropdown/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-dropdown", - "version": "5.5.2", + "version": "5.6.0", "design": "v1", "description": "Dropdown variants for Wonder Blocks.", "main": "dist/index.js", @@ -21,9 +21,9 @@ "@khanacademy/wonder-blocks-core": "^7.0.1", "@khanacademy/wonder-blocks-icon": "^4.1.5", "@khanacademy/wonder-blocks-layout": "^2.2.1", - "@khanacademy/wonder-blocks-modal": "^5.1.12", + "@khanacademy/wonder-blocks-modal": "^5.1.14", "@khanacademy/wonder-blocks-pill": "^2.5.1", - "@khanacademy/wonder-blocks-search-field": "^2.2.26", + "@khanacademy/wonder-blocks-search-field": "^2.3.3", "@khanacademy/wonder-blocks-timing": "^5.0.2", "@khanacademy/wonder-blocks-tokens": "^2.0.1", "@khanacademy/wonder-blocks-typography": "^2.1.16" diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx index 92bb7719d..934aa4a10 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx @@ -6,6 +6,7 @@ import {PointerEventsCheckLevel, userEvent} from "@testing-library/user-event"; import Combobox from "../combobox"; import OptionItem from "../option-item"; import {defaultComboboxLabels} from "../../util/constants"; +import {MaybeValueOrValues} from "../../util/types"; const doRender = (element: React.ReactElement) => { render(element, {wrapper: RenderStateRoot}); @@ -58,7 +59,9 @@ describe("Combobox", () => { ); // Act - await userEvent.click(screen.getByRole("button")); + await userEvent.click( + screen.getByRole("button", {name: /toggle listbox/i}), + ); // Assert await screen.findByRole("listbox", {hidden: true}); @@ -96,11 +99,15 @@ describe("Combobox", () => {
, ); - await userEvent.click(screen.getByRole("button")); + await userEvent.click( + screen.getByRole("button", {name: /toggle listbox/i}), + ); await screen.findByRole("listbox", {hidden: true}); // Act - await userEvent.click(screen.getByRole("button")); + await userEvent.click( + screen.getByRole("button", {name: /toggle listbox/i}), + ); // Assert expect( @@ -214,7 +221,9 @@ describe("Combobox", () => { , ); - await userEvent.click(screen.getByRole("button")); + await userEvent.click( + screen.getByRole("button", {name: /toggle listbox/i}), + ); await screen.findByRole("listbox", {hidden: true}); // Act @@ -296,6 +305,133 @@ describe("Combobox", () => { expect(screen.getByRole("combobox")).not.toHaveFocus(); }); + describe("dismiss button", () => { + it("should clear the value when the user presses the clear button (x) via Mouse", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // Act + await userEvent.click( + screen.getByRole("button", {name: /clear selection/i}), + ); + + // Assert + expect(screen.getByRole("combobox")).toHaveValue(""); + }); + + it("should clear the value when the user presses the clear button (x) via Keyboard", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // focus the combobox + await userEvent.tab(); + + // Act + // Focus the clear button, then press Enter + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + + // Assert + expect(screen.getByRole("combobox")).toHaveValue(""); + }); + }); + + describe("error", () => { + it("should use aria-invalid=false by default", () => { + // Arrange + + // Act + doRender( + + + + + , + ); + + // Assert + expect(screen.getByRole("combobox")).toHaveAttribute( + "aria-invalid", + "false", + ); + }); + + it("should use aria-invalid=true if error is true", async () => { + // Arrange + const userEvent = doRender( + + + + + , + ); + + // Act + await userEvent.tab(); + + // Assert + expect(screen.getByRole("combobox")).toHaveAttribute( + "aria-invalid", + "true", + ); + }); + + it("should mark the combobox as aria-invalid=false when the value is valid", async () => { + // Arrange + const UnderTest = () => { + const [value, setValue] = + React.useState(""); + // empty value should mark the combobox as invalid + const [error, setError] = React.useState(value === ""); + + return ( + { + if (newValue) { + setValue(newValue); + } + + setError(newValue === ""); + }} + > + + + + + ); + }; + + const userEvent = doRender(); + + // Act + await userEvent.type( + screen.getByRole("combobox"), + "option 1{Enter}", + ); + + // Assert + expect(screen.getByRole("combobox")).toHaveAttribute( + "aria-invalid", + "false", + ); + }); + }); + describe("autoComplete", () => { it("should filter the options when typing", async () => { // Arrange @@ -767,6 +903,29 @@ describe("Combobox", () => { defaultComboboxLabels.noItems, ); }); + + // TODO (WB-1757.2): Enable this test once the LiveRegion component + // is refactored. + it.skip("should announce when the current selected value is cleared", async () => { + // Arrange + doRender( + + + + + , + ); + + // Act + await userEvent.click( + screen.getByRole("button", {name: /clear selection/i}), + ); + + // Assert + expect(screen.getByRole("log")).toHaveTextContent( + defaultComboboxLabels.selectionCleared, + ); + }); }); }); }); diff --git a/packages/wonder-blocks-dropdown/src/components/combobox-live-region.tsx b/packages/wonder-blocks-dropdown/src/components/combobox-live-region.tsx index dab16c725..8f63c91ee 100644 --- a/packages/wonder-blocks-dropdown/src/components/combobox-live-region.tsx +++ b/packages/wonder-blocks-dropdown/src/components/combobox-live-region.tsx @@ -31,6 +31,7 @@ type Props = { | "liveRegionMultipleSelectionTotal" | "noItems" | "selected" + | "selectionCleared" | "unselected" >; @@ -61,6 +62,7 @@ type Props = { testId?: string; }; +// TODO (WB-1757.2): Refactor this component to use hooks + Context. /** * A component that announces focus changes to Screen Readers. * @@ -81,6 +83,7 @@ export function ComboboxLiveRegion({ defaultComboboxLabels.liveRegionMultipleSelectionTotal, noItems: defaultComboboxLabels.noItems, selected: defaultComboboxLabels.selected, + selectionCleared: defaultComboboxLabels.selectionCleared, unselected: defaultComboboxLabels.unselected, }, selectedLabels, @@ -117,6 +120,16 @@ export function ComboboxLiveRegion({ setMessage(newMessage); } + // Announce when the single-select value is cleared. + // NOTE: It only applies after the user has selected an option. + if ( + selectionType === "single" && + !selected && + lastSelectedValue.current + ) { + setMessage(labels.selectionCleared); + } + lastSelectedValue.current = selected; if (selectionType === "multiple" && !opened) { diff --git a/packages/wonder-blocks-dropdown/src/components/combobox.tsx b/packages/wonder-blocks-dropdown/src/components/combobox.tsx index 0db79ab2b..4e014de61 100644 --- a/packages/wonder-blocks-dropdown/src/components/combobox.tsx +++ b/packages/wonder-blocks-dropdown/src/components/combobox.tsx @@ -2,6 +2,7 @@ import {StyleSheet} from "aphrodite"; import * as React from "react"; import caretDownIcon from "@phosphor-icons/core/regular/caret-down.svg"; +import xIcon from "@phosphor-icons/core/regular/x.svg"; import { StyleType, @@ -62,6 +63,12 @@ type Props = { */ disabled?: boolean; + /** + * Whether this component is in an error state. + * If true, adds `aria-invalid` to the combobox element. + */ + error?: boolean; + /** * The unique identifier of the combobox element. */ @@ -143,6 +150,7 @@ export default function Combobox({ autoComplete, children, disabled, + error, id, labels = defaultComboboxLabels, onChange, @@ -410,6 +418,38 @@ export default function Combobox({ [selected, setSelected], ); + const handleTextFieldChange = React.useCallback( + (value: string) => { + setInputValue(value); + let filteredItems = renderList; + if (autoComplete === "list") { + filteredItems = filterItems(value); + + // Update the list of options to display the + // filtered items. + setCurrentOptions(filteredItems); + } + + focusOnFilteredItem(filteredItems, value); + }, + [autoComplete, filterItems, focusOnFilteredItem, renderList], + ); + + const handleClearClick = React.useCallback( + (e: React.SyntheticEvent) => { + e.stopPropagation(); + // TODO (WB-1757.2): Add Screen Reader Announcements for when the + // selection is cleared. + + // Reset the combobox value. + setInputValue(""); + setSelected(""); + onChange?.(""); + comboboxRef.current?.focus(); + }, + [onChange, setSelected], + ); + React.useEffect(() => { // Focus on the combobox input when the dropdown is opened. if (openState) { @@ -466,6 +506,7 @@ export default function Combobox({ styles.wrapper, isListboxFocused && styles.focused, disabled && styles.disabled, + !disabled && error && styles.error, ]} > { - setInputValue(value); - let filteredItems = renderList; - if (autoComplete === "list") { - filteredItems = filterItems(value); - - // Update the list of options to display the - // filtered items. - setCurrentOptions(filteredItems); - } - - focusOnFilteredItem(filteredItems, value); - }} + onChange={handleTextFieldChange} disabled={disabled} onFocus={() => { updateOpenState(true); @@ -521,11 +550,12 @@ export default function Combobox({ updateOpenState(false); handleBlur(); }} - aria-controls={controlledWidget} onKeyDown={onKeyDown} aria-activedescendant={currentActiveDescendant} aria-autocomplete={autoComplete} + aria-controls={controlledWidget} aria-expanded={openState} + aria-invalid={!!error} ref={comboboxRef} // We don't want the browser to suggest autocompletions as // the combobox is already providing suggestions. @@ -533,6 +563,18 @@ export default function Combobox({ role="combobox" /> + {inputValue && !disabled && ( + + )} + `${total} results available.`, noItems: "No results", selected: (labels: string) => `${labels} selected`, + selectionCleared: "Selection cleared", unselected: (labels: string) => `${labels} not selected`, }; diff --git a/packages/wonder-blocks-dropdown/src/util/types.ts b/packages/wonder-blocks-dropdown/src/util/types.ts index 7f434f259..46f8a5194 100644 --- a/packages/wonder-blocks-dropdown/src/util/types.ts +++ b/packages/wonder-blocks-dropdown/src/util/types.ts @@ -63,6 +63,11 @@ export type MaybeValueOrValues = MaybeString | Array; * The labels for the combobox component. */ export type ComboboxLabels = { + /** + * Label for the "Clear" button that removes a selected item. Only used in + * single-select mode. + */ + clearSelection: string; /** * Label for when the listbox changes to the closed state. */ @@ -113,6 +118,13 @@ export type ComboboxLabels = { */ selected: (labels: string) => string; + /** + * Label for when the user removes a selected item. + * + * NOTE: This usually happens when the clear selection button is pressed. + */ + selectionCleared: string; + /** * Label for when item(s) is/are unselected. */ diff --git a/packages/wonder-blocks-form/CHANGELOG.md b/packages/wonder-blocks-form/CHANGELOG.md index dff8fe393..e6cbc73e1 100644 --- a/packages/wonder-blocks-form/CHANGELOG.md +++ b/packages/wonder-blocks-form/CHANGELOG.md @@ -1,5 +1,27 @@ # @khanacademy/wonder-blocks-form +## 4.10.1 + +### Patch Changes + +- 8c861955: Modify `RadioGroup` and `CheckboxGroup` to append `legend` as the first child in `fieldset`, so the accessibility tree can associate the legend contents with the fieldset group and announce its label correctly +- 0b3a28a7: - Combobox: Add error prop to support aria-invalid and styling changes. + - TextField: Modify aria-invalid order to be overriden by the caller. + +## 4.10.0 + +### Minor Changes + +- 7a98815b: LabeledTextField: Adds `name` prop for the `TextField` component + +## 4.9.4 + +### Patch Changes + +- 61dc4448: Allow `TextField` to be focusable when disabled. It now sets `aria-disabled` instead of the `disabled` attribute based on the `disabled` prop. This makes it so screenreaders will continue to communicate that the component is disabled, while allowing focus on the disabled component. Focus styling is also added to the disabled state. +- 2dfd5eb6: - Update `TextField` state styling so that it is consistent with other components like `TextArea`, `SingleSelect`, `MultiSelect` (especially the focus styling). The styling also now uses CSS pseudo-classes for easier testing in Chromatic and debugging in browsers. + - `TextField` and `TextArea` state styling has also been updated so that any outline styles outside of the component are now applied within the component to prevent cropped focus outlines in places where an ancestor element has `overflow: hidden`. + ## 4.9.3 ### Patch Changes diff --git a/packages/wonder-blocks-form/package.json b/packages/wonder-blocks-form/package.json index 873ae9249..c4adacbb6 100644 --- a/packages/wonder-blocks-form/package.json +++ b/packages/wonder-blocks-form/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-form", - "version": "4.9.3", + "version": "4.10.1", "design": "v1", "description": "Form components for Wonder Blocks.", "main": "dist/index.js", diff --git a/packages/wonder-blocks-form/src/components/__tests__/checkbox-group.test.tsx b/packages/wonder-blocks-form/src/components/__tests__/checkbox-group.test.tsx index e2d3cf33d..c3234d410 100644 --- a/packages/wonder-blocks-form/src/components/__tests__/checkbox-group.test.tsx +++ b/packages/wonder-blocks-form/src/components/__tests__/checkbox-group.test.tsx @@ -6,6 +6,29 @@ import CheckboxGroup from "../checkbox-group"; import Choice from "../choice"; describe("CheckboxGroup", () => { + describe("a11y", () => { + it("should associate the label with the fieldset", async () => { + // Arrange, Act + render( + {}} + selectedValues={[]} + > + + + + , + ); + + const fieldset = screen.getByRole("group", {name: /test label/i}); + + // Assert + expect(fieldset).toBeInTheDocument(); + }); + }); + describe("behavior", () => { const TestComponent = ({errorMessage}: {errorMessage?: string}) => { const [selectedValues, setSelectedValue] = React.useState([ diff --git a/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx b/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx index e3986aac1..8c86af68c 100644 --- a/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx +++ b/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx @@ -3,7 +3,6 @@ import {render, screen, fireEvent} from "@testing-library/react"; import {userEvent} from "@testing-library/user-event"; import {StyleSheet} from "aphrodite"; -import {color} from "@khanacademy/wonder-blocks-tokens"; import LabeledTextField from "../labeled-text-field"; describe("LabeledTextField", () => { @@ -174,7 +173,7 @@ describe("LabeledTextField", () => { // Assert const input = await screen.findByRole("textbox"); - expect(input).toBeDisabled(); + expect(input).toHaveAttribute("aria-disabled", "true"); }); it("ariaDescribedby prop sets aria-describedby", async () => { @@ -382,8 +381,9 @@ describe("LabeledTextField", () => { expect(input).toBeInTheDocument(); }); - it("light prop is passed to textfield", async () => { + it("name prop is passed to input", async () => { // Arrange + const name = "test-name"; // Act render( @@ -391,17 +391,13 @@ describe("LabeledTextField", () => { label="Label" value="" onChange={() => {}} - light={true} + name={name} />, ); - const textField = await screen.findByRole("textbox"); - textField.focus(); - // Assert - expect(textField).toHaveStyle({ - boxShadow: `0px 0px 0px 1px ${color.blue}, 0px 0px 0px 2px ${color.white}`, - }); + const input = await screen.findByRole("textbox"); + expect(input).toHaveAttribute("name", name); }); it("style prop is passed to fieldheading", async () => { diff --git a/packages/wonder-blocks-form/src/components/__tests__/radio-group.test.tsx b/packages/wonder-blocks-form/src/components/__tests__/radio-group.test.tsx index e2a5e21c3..b832143c6 100644 --- a/packages/wonder-blocks-form/src/components/__tests__/radio-group.test.tsx +++ b/packages/wonder-blocks-form/src/components/__tests__/radio-group.test.tsx @@ -34,6 +34,29 @@ describe("RadioGroup", () => { ); }; + describe("a11y", () => { + it("should associate the label with the fieldset", async () => { + // Arrange, Act + render( + {}} + selectedValue="a" + > + + + + , + ); + + const fieldset = screen.getByRole("group", {name: /test label/i}); + + // Assert + expect(fieldset).toBeInTheDocument(); + }); + }); + describe("behavior", () => { it("selects only one item at a time", async () => { // Arrange, Act diff --git a/packages/wonder-blocks-form/src/components/__tests__/text-field.test.tsx b/packages/wonder-blocks-form/src/components/__tests__/text-field.test.tsx index fc333ae52..446d5ec35 100644 --- a/packages/wonder-blocks-form/src/components/__tests__/text-field.test.tsx +++ b/packages/wonder-blocks-form/src/components/__tests__/text-field.test.tsx @@ -158,24 +158,6 @@ describe("TextField", () => { expect(input).toBeInTheDocument(); }); - it("disabled prop disables the input element", async () => { - // Arrange - render( - {}} - disabled={true} - />, - ); - const input = await screen.findByRole("textbox"); - - // Act - - // Assert - expect(input).toBeDisabled(); - }); - it("onChange is called when value changes", async () => { // Arrange const handleOnChange = jest.fn(); @@ -559,4 +541,170 @@ describe("TextField", () => { // Assert expect(searchField).not.toHaveFocus(); }); + + describe("Disabled state", () => { + it("disabled prop sets the aria-disabled attribute on the input element", async () => { + // Arrange + render( + {}} + disabled={true} + />, + ); + const input = await screen.findByRole("textbox"); + + // Act + + // Assert + expect(input).toHaveAttribute("aria-disabled", "true"); + }); + + it("should set the aria-disabled attribute when the disabled prop is false", async () => { + // Arrange + render( + {}} />, + ); + + // Act + + // Assert + const input = await screen.findByRole("textbox"); + expect(input).toHaveAttribute("aria-disabled", "false"); + }); + + it("should set the aria-disabled attribute to false if the disabled prop is not provided", async () => { + // Arrange + render( {}} />); + + // Act + + // Assert + const input = await screen.findByRole("textbox"); + expect(input).toHaveAttribute("aria-disabled", "false"); + }); + + it("should not set the disabled attribute when the disabled prop is true", async () => { + // Arrange + render( + {}} />, + ); + + // Act + + // Assert + const input = await screen.findByRole("textbox"); + expect(input).not.toHaveAttribute("disabled"); + }); + + it("should set the readonly attribute if the disabled prop is true", async () => { + // Arrange + render( + {}} disabled={true} />, + ); + + // Act + + // Assert + const input = await screen.findByRole("textbox"); + expect(input).toHaveAttribute("readonly"); + }); + + it("should not call the onChange prop when the input value changes and it is disabled", async () => { + // Arrange + const onChangeMock = jest.fn(); + render( + , + ); + + // Act + // Type one letter + const letterToType = "X"; + await userEvent.type( + await screen.findByRole("textbox"), + letterToType, + ); + + // Assert + expect(onChangeMock).not.toHaveBeenCalled(); + }); + + it("should not call the onKeyDown prop when a key is typed in the input and it is disabled", async () => { + // Arrange + const handleOnKeyDown = jest.fn(); + + render( + {}} + onKeyDown={handleOnKeyDown} + disabled={true} + />, + ); + + // Act + await userEvent.type(await screen.findByRole("textbox"), "{enter}"); + + // Assert + expect(handleOnKeyDown).not.toHaveBeenCalled(); + }); + + it("should continue to call the onFocus prop when the input is focused and it is disabled", async () => { + // Arrange + const handleOnFocus = jest.fn(); + + render( + {}} + onFocus={handleOnFocus} + disabled={true} + />, + ); + + // Act + await userEvent.tab(); + + // Assert + expect(handleOnFocus).toHaveBeenCalledTimes(1); + }); + + it("should continue to call the onBlur prop when the input is blurred and it is disabled", async () => { + // Arrange + const handleOnBlur = jest.fn(); + + render( + {}} + onBlur={handleOnBlur} + disabled={true} + />, + ); + // Tab to focus on input + await userEvent.tab(); + + // Act + // Tab to move focus away + await userEvent.tab(); + + // Assert + expect(handleOnBlur).toHaveBeenCalledTimes(1); + }); + + it("should be focusable if it is disabled", async () => { + // Arrange + render( + {}} disabled={true} />, + ); + + // Act + await userEvent.tab(); + + // Assert + const input = await screen.findByRole("textbox"); + expect(input).toHaveFocus(); + }); + }); }); diff --git a/packages/wonder-blocks-form/src/components/checkbox-group.tsx b/packages/wonder-blocks-form/src/components/checkbox-group.tsx index 72091a8a8..1424f5f08 100644 --- a/packages/wonder-blocks-form/src/components/checkbox-group.tsx +++ b/packages/wonder-blocks-form/src/components/checkbox-group.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import {View, addStyle} from "@khanacademy/wonder-blocks-core"; +import {addStyle} from "@khanacademy/wonder-blocks-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {spacing} from "@khanacademy/wonder-blocks-tokens"; import {LabelMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography"; @@ -130,43 +130,44 @@ const CheckboxGroup = React.forwardRef(function CheckboxGroup( const allChildren = React.Children.toArray(children).filter(Boolean); return ( - - {/* We have a View here because fieldset cannot be used with flexbox*/} - - {label && ( - - {label} - - )} - {description && ( - - {description} - - )} - {errorMessage && ( - {errorMessage} - )} - {(label || description || errorMessage) && ( - - )} + + {label && ( + + {label} + + )} + {description && ( + + {description} + + )} + {errorMessage && ( + {errorMessage} + )} + {(label || description || errorMessage) && ( + + )} - {allChildren.map((child, index) => { - // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactChild | ReactFragment | ReactPortal'. - const {style, value} = child.props; - const checked = selectedValues.includes(value); - // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call. - return React.cloneElement(child, { - checked: checked, - error: !!errorMessage, - groupName: groupName, - id: `${groupName}-${value}`, - key: value, - onChange: () => handleChange(value, checked), - style: [index > 0 && styles.defaultLineGap, style], - variant: "checkbox", - }); - })} - + {allChildren.map((child, index) => { + // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactChild | ReactFragment | ReactPortal'. + const {style, value} = child.props; + const checked = selectedValues.includes(value); + // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call. + return React.cloneElement(child, { + checked: checked, + error: !!errorMessage, + groupName: groupName, + id: `${groupName}-${value}`, + key: value, + onChange: () => handleChange(value, checked), + style: [index > 0 && styles.defaultLineGap, style], + variant: "checkbox", + }); + })} ); }); diff --git a/packages/wonder-blocks-form/src/components/group-styles.ts b/packages/wonder-blocks-form/src/components/group-styles.ts index d8a01ff33..6ce5e91c9 100644 --- a/packages/wonder-blocks-form/src/components/group-styles.ts +++ b/packages/wonder-blocks-form/src/components/group-styles.ts @@ -6,6 +6,8 @@ import type {StyleDeclaration} from "aphrodite"; const styles: StyleDeclaration = StyleSheet.create({ fieldset: { + display: "flex", + flexDirection: "column", border: "none", padding: 0, margin: 0, diff --git a/packages/wonder-blocks-form/src/components/labeled-text-field.tsx b/packages/wonder-blocks-form/src/components/labeled-text-field.tsx index cf7873fe1..abda285bc 100644 --- a/packages/wonder-blocks-form/src/components/labeled-text-field.tsx +++ b/packages/wonder-blocks-form/src/components/labeled-text-field.tsx @@ -30,7 +30,15 @@ type CommonProps = { */ value: string; /** - * Makes a read-only input field that cannot be focused. Defaults to false. + * Whether the input should be disabled. Defaults to false. + * If the disabled prop is set to `true`, LabeledTextField will have disabled + * styling and will not be interactable. + * + * Note: The `disabled` prop sets the `aria-disabled` attribute to `true` + * instead of setting the `disabled` attribute. This is so that the component + * remains focusable while communicating to screen readers that it is disabled. + * This `disabled` prop will also set the `readonly` attribute to prevent + * typing in the field. */ disabled: boolean; /** @@ -118,6 +126,10 @@ type CommonProps = { * Specifies if the TextField allows autocomplete. */ autoComplete?: string; + /** + * Provide a name for the TextField. + */ + name?: string; }; type OtherInputProps = CommonProps & { @@ -220,6 +232,7 @@ class LabeledTextField extends React.Component { autoComplete, forwardedRef, ariaDescribedby, + name, // NOTE: We are not using this prop, but we need to remove it from // `otherProps` so it doesn't override the `handleValidate` function // call. We use `otherProps` due to a limitation in TypeScript where @@ -267,6 +280,7 @@ class LabeledTextField extends React.Component { readOnly={readOnly} autoComplete={autoComplete} ref={forwardedRef} + name={name} {...otherProps} /> } diff --git a/packages/wonder-blocks-form/src/components/radio-group.tsx b/packages/wonder-blocks-form/src/components/radio-group.tsx index c529bd2d2..33f9e06d5 100644 --- a/packages/wonder-blocks-form/src/components/radio-group.tsx +++ b/packages/wonder-blocks-form/src/components/radio-group.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import {View, addStyle} from "@khanacademy/wonder-blocks-core"; +import {addStyle} from "@khanacademy/wonder-blocks-core"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {spacing} from "@khanacademy/wonder-blocks-tokens"; import {LabelMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography"; @@ -115,43 +115,44 @@ const RadioGroup = React.forwardRef(function RadioGroup( const allChildren = React.Children.toArray(children).filter(Boolean); return ( - - {/* We have a View here because fieldset cannot be used with flexbox*/} - - {label && ( - - {label} - - )} - {description && ( - - {description} - - )} - {errorMessage && ( - {errorMessage} - )} - {(label || description || errorMessage) && ( - - )} + + {label && ( + + {label} + + )} + {description && ( + + {description} + + )} + {errorMessage && ( + {errorMessage} + )} + {(label || description || errorMessage) && ( + + )} - {allChildren.map((child, index) => { - // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactChild | ReactFragment | ReactPortal'. - const {style, value} = child.props; - const checked = selectedValue === value; - // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call. - return React.cloneElement(child, { - checked: checked, - error: !!errorMessage, - groupName: groupName, - id: `${groupName}-${value}`, - key: value, - onChange: () => onChange(value), - style: [index > 0 && styles.defaultLineGap, style], - variant: "radio", - }); - })} - + {allChildren.map((child, index) => { + // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactChild | ReactFragment | ReactPortal'. + const {style, value} = child.props; + const checked = selectedValue === value; + // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call. + return React.cloneElement(child, { + checked: checked, + error: !!errorMessage, + groupName: groupName, + id: `${groupName}-${value}`, + key: value, + onChange: () => onChange(value), + style: [index > 0 && styles.defaultLineGap, style], + variant: "radio", + }); + })} ); }); diff --git a/packages/wonder-blocks-form/src/components/text-area.tsx b/packages/wonder-blocks-form/src/components/text-area.tsx index 5cb6be067..35bf9e222 100644 --- a/packages/wonder-blocks-form/src/components/text-area.tsx +++ b/packages/wonder-blocks-form/src/components/text-area.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {CSSProperties, Falsy, StyleSheet} from "aphrodite"; +import {StyleSheet} from "aphrodite"; import { AriaProps, @@ -256,7 +256,7 @@ const TextArea = React.forwardRef( } }); - const getStyles = (): (CSSProperties | Falsy)[] => { + const getStyles = (): StyleType => { // Base styles are the styles that apply regardless of light mode const baseStyles = [ styles.textarea, @@ -284,7 +284,7 @@ const TextArea = React.forwardRef( data-testid={testId} ref={ref} className={className} - style={[...getStyles(), style]} + style={[getStyles(), style]} value={value} onChange={handleChange} placeholder={placeholder} @@ -338,7 +338,9 @@ const styles = StyleSheet.create({ ":focus-visible": { borderColor: color.blue, outline: `1px solid ${color.blue}`, - outlineOffset: 0, // Explicitly set outline offset to 0 because Safari sets a default offset + // Negative outline offset so it focus outline is not cropped off if + // an ancestor element has overflow: hidden + outlineOffset: "-2px", }, }, disabled: { @@ -350,8 +352,8 @@ const styles = StyleSheet.create({ }, cursor: "not-allowed", ":focus-visible": { - outline: "none", - boxShadow: `0 0 0 1px ${color.white}, 0 0 0 3px ${color.offBlack32}`, + outline: `2px solid ${color.offBlack32}`, + outlineOffset: "-3px", }, }, error: { @@ -376,10 +378,9 @@ const styles = StyleSheet.create({ }, lightFocus: { ":focus-visible": { - outline: `1px solid ${color.blue}`, - outlineOffset: 0, // Explicitly set outline offset to 0 because Safari sets a default offset - borderColor: color.blue, - boxShadow: `0px 0px 0px 2px ${color.blue}, 0px 0px 0px 3px ${color.white}`, + outline: `3px solid ${color.blue}`, + outlineOffset: "-4px", + borderColor: color.white, }, }, lightDisabled: { @@ -392,22 +393,22 @@ const styles = StyleSheet.create({ cursor: "not-allowed", ":focus-visible": { borderColor: mix(color.white32, color.blue), - outline: "none", - boxShadow: `0 0 0 1px ${color.offBlack32}, 0 0 0 3px ${color.fadedBlue}`, + outline: `3px solid ${color.fadedBlue}`, + outlineOffset: "-4px", }, }, lightError: { background: color.fadedRed8, - border: `1px solid ${color.red}`, - boxShadow: `0px 0px 0px 1px ${color.red}, 0px 0px 0px 2px ${color.white}`, + border: `1px solid ${color.white}`, + outline: `2px solid ${color.red}`, + outlineOffset: "-3px", color: color.offBlack, "::placeholder": { color: color.offBlack64, }, ":focus-visible": { - outlineColor: color.red, - borderColor: color.red, - boxShadow: `0px 0px 0px 2px ${color.red}, 0px 0px 0px 3px ${color.white}`, + outline: `3px solid ${color.red}`, + outlineOffset: "-4px", }, }, }); diff --git a/packages/wonder-blocks-form/src/components/text-field.tsx b/packages/wonder-blocks-form/src/components/text-field.tsx index f1b2bcb6b..27375e0f1 100644 --- a/packages/wonder-blocks-form/src/components/text-field.tsx +++ b/packages/wonder-blocks-form/src/components/text-field.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import {StyleSheet} from "aphrodite"; import {IDProvider, addStyle} from "@khanacademy/wonder-blocks-core"; -import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {border, color, mix, spacing} from "@khanacademy/wonder-blocks-tokens"; import {styles as typographyStyles} from "@khanacademy/wonder-blocks-typography"; import type {StyleType, AriaProps} from "@khanacademy/wonder-blocks-core"; @@ -34,7 +34,15 @@ type CommonProps = AriaProps & { */ name?: string; /** - * Makes a read-only input field that cannot be focused. Defaults to false. + * Whether the input should be disabled. Defaults to false. + * If the disabled prop is set to `true`, TextField will have disabled + * styling and will not be interactable. + * + * Note: The `disabled` prop sets the `aria-disabled` attribute to `true` + * instead of setting the `disabled` attribute. This is so that the component + * remains focusable while communicating to screen readers that it is disabled. + * This `disabled` prop will also set the `readonly` attribute to prevent + * typing in the field. */ disabled: boolean; /** @@ -152,10 +160,6 @@ type State = { * Displayed when the validation fails. */ error: string | null | undefined; - /** - * The user focuses on this field. - */ - focused: boolean; }; /** @@ -178,7 +182,6 @@ class TextField extends React.Component { state: State = { error: null, - focused: false, }; componentDidMount() { @@ -222,22 +225,38 @@ class TextField extends React.Component { event, ) => { const {onFocus} = this.props; - this.setState({focused: true}, () => { - if (onFocus) { - onFocus(event); - } - }); + if (onFocus) { + onFocus(event); + } }; handleBlur: (event: React.FocusEvent) => unknown = ( event, ) => { const {onBlur} = this.props; - this.setState({focused: false}, () => { - if (onBlur) { - onBlur(event); - } - }); + if (onBlur) { + onBlur(event); + } + }; + + getStyles = (): StyleType => { + const {disabled, light} = this.props; + const {error} = this.state; + // Base styles are the styles that apply regardless of light mode + const baseStyles = [styles.input, typographyStyles.LabelMedium]; + const defaultStyles = [ + styles.default, + !disabled && styles.defaultFocus, + disabled && styles.disabled, + !!error && styles.error, + ]; + const lightStyles = [ + styles.light, + !disabled && styles.lightFocus, + disabled && styles.lightDisabled, + !!error && styles.lightError, + ]; + return [...baseStyles, ...(light ? lightStyles : defaultStyles)]; }; render(): React.ReactNode { @@ -249,7 +268,6 @@ class TextField extends React.Component { disabled, onKeyDown, placeholder, - light, style, testId, readOnly, @@ -259,6 +277,7 @@ class TextField extends React.Component { // The following props are being included here to avoid // passing them down to the otherProps spread /* eslint-disable @typescript-eslint/no-unused-vars */ + light, onFocus, onBlur, onValidate, @@ -274,41 +293,24 @@ class TextField extends React.Component { {(uniqueId) => ( )} @@ -320,12 +322,10 @@ const styles = StyleSheet.create({ input: { width: "100%", height: 40, - borderRadius: 4, + borderRadius: border.radius.medium_4, boxSizing: "border-box", paddingLeft: spacing.medium_16, margin: 0, - outline: "none", - boxShadow: "none", }, default: { background: color.white, @@ -335,6 +335,15 @@ const styles = StyleSheet.create({ color: color.offBlack64, }, }, + defaultFocus: { + ":focus-visible": { + borderColor: color.blue, + outline: `1px solid ${color.blue}`, + // Negative outline offset so it focus outline is not cropped off if + // an ancestor element has overflow: hidden + outlineOffset: "-2px", + }, + }, error: { background: color.fadedRed8, border: `1px solid ${color.red}`, @@ -342,28 +351,66 @@ const styles = StyleSheet.create({ "::placeholder": { color: color.offBlack64, }, + ":focus-visible": { + outlineColor: color.red, + borderColor: color.red, + }, }, disabled: { background: color.offWhite, border: `1px solid ${color.offBlack16}`, color: color.offBlack64, "::placeholder": { - color: color.offBlack32, + color: color.offBlack64, + }, + cursor: "not-allowed", + ":focus-visible": { + outline: `2px solid ${color.offBlack32}`, + outlineOffset: "-3px", }, }, - focused: { + light: { background: color.white, - border: `1px solid ${color.blue}`, + border: `1px solid ${color.offBlack16}`, color: color.offBlack, "::placeholder": { color: color.offBlack64, }, }, - defaultLight: { - boxShadow: `0px 0px 0px 1px ${color.blue}, 0px 0px 0px 2px ${color.white}`, + lightFocus: { + ":focus-visible": { + outline: `3px solid ${color.blue}`, + outlineOffset: "-4px", + borderColor: color.white, + }, + }, + lightDisabled: { + backgroundColor: "transparent", + border: `1px solid ${color.white32}`, + color: color.white64, + "::placeholder": { + color: color.white64, + }, + cursor: "not-allowed", + ":focus-visible": { + borderColor: mix(color.white32, color.blue), + outline: `3px solid ${color.fadedBlue}`, + outlineOffset: "-4px", + }, }, - errorLight: { - boxShadow: `0px 0px 0px 1px ${color.red}, 0px 0px 0px 2px ${color.white}`, + lightError: { + background: color.fadedRed8, + border: `1px solid ${color.white}`, + outline: `2px solid ${color.red}`, + outlineOffset: "-3px", + color: color.offBlack, + "::placeholder": { + color: color.offBlack64, + }, + ":focus-visible": { + outline: `3px solid ${color.red}`, + outlineOffset: "-4px", + }, }, }); diff --git a/packages/wonder-blocks-i18n/CHANGELOG.md b/packages/wonder-blocks-i18n/CHANGELOG.md index 600112dbc..746bd1420 100644 --- a/packages/wonder-blocks-i18n/CHANGELOG.md +++ b/packages/wonder-blocks-i18n/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/wonder-blocks-i18n +## 3.1.2 + +### Patch Changes + +- d7330053: Fix plural forms configuration for Khmer locale + ## 3.1.1 ### Patch Changes diff --git a/packages/wonder-blocks-i18n/package.json b/packages/wonder-blocks-i18n/package.json index 83a4731b7..5199b1cea 100644 --- a/packages/wonder-blocks-i18n/package.json +++ b/packages/wonder-blocks-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-i18n", - "version": "3.1.1", + "version": "3.1.2", "design": "v1", "publishConfig": { "access": "public" diff --git a/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts b/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts index e5b2a757b..971e025bc 100644 --- a/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts +++ b/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts @@ -624,6 +624,24 @@ describe("i18n", () => { }); }); + describe("other no plurals locale (using km)", () => { + it("should return other for 0", () => { + // Arrange + + // Act + const result = ngettext( + { + lang: "km", + messages: ["Other"], + }, + 0, + ); + + // Assert + expect(result).toEqual("Other"); + }); + }); + describe("multiple plurals local (using pl)", () => { it("should return second plural form for 0", () => { // Arrange diff --git a/packages/wonder-blocks-i18n/src/functions/plural-forms.ts b/packages/wonder-blocks-i18n/src/functions/plural-forms.ts index 3d7171481..2f04e573b 100644 --- a/packages/wonder-blocks-i18n/src/functions/plural-forms.ts +++ b/packages/wonder-blocks-i18n/src/functions/plural-forms.ts @@ -74,7 +74,7 @@ export const allPluralForms: PluralFormsMap = { "ja": likeJapanese, "ka": likeEnglish, "kk": likeEnglish, - "km": likeEnglish, + "km": likeJapanese, "kn": likeEnglish, "ko": likeJapanese, "ky": likeJapanese, diff --git a/packages/wonder-blocks-icon-button/CHANGELOG.md b/packages/wonder-blocks-icon-button/CHANGELOG.md index 4c3b4e664..2d83cfbb2 100644 --- a/packages/wonder-blocks-icon-button/CHANGELOG.md +++ b/packages/wonder-blocks-icon-button/CHANGELOG.md @@ -1,5 +1,17 @@ # @khanacademy/wonder-blocks-icon-button +## 5.6.0 + +### Minor Changes + +- 75da0046: Adds `id` prop and fixes `type` prop to set 'button' as default value. + +## 5.5.0 + +### Minor Changes + +- 3463bde3: Add type=submit prop to allow submitting forms with the button + ## 5.4.1 ### Patch Changes diff --git a/packages/wonder-blocks-icon-button/package.json b/packages/wonder-blocks-icon-button/package.json index a783de115..28c648c63 100644 --- a/packages/wonder-blocks-icon-button/package.json +++ b/packages/wonder-blocks-icon-button/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-icon-button", - "version": "5.4.1", + "version": "5.6.0", "design": "v1", "publishConfig": { "access": "public" diff --git a/packages/wonder-blocks-icon-button/src/components/__tests__/icon-button.test.tsx b/packages/wonder-blocks-icon-button/src/components/__tests__/icon-button.test.tsx index 6145231b8..3130af5aa 100644 --- a/packages/wonder-blocks-icon-button/src/components/__tests__/icon-button.test.tsx +++ b/packages/wonder-blocks-icon-button/src/components/__tests__/icon-button.test.tsx @@ -183,6 +183,23 @@ describe("IconButton", () => { expect(onClickMock).not.toBeCalled(); }); + it("sets the 'id' prop on the underlying element", async () => { + // Arrange + render( + , + ); + + // Act + const button = await screen.findByRole("button"); + + // Assert + expect(button).toHaveAttribute("id", "icon-button"); + }); + it("sets the 'target' prop on the underlying element", async () => { // Arrange render( @@ -320,4 +337,62 @@ describe("IconButton", () => { expect(onClickMock).toHaveBeenCalledTimes(1); }); }); + + describe("type", () => { + it("should set type attribute to 'button' by default", async () => { + // Arrange + render(); + + // Act + const button = await screen.findByRole("button"); + + // Assert + expect(button).toHaveAttribute("type", "button"); + }); + + it("should submit button within form via click", async () => { + // Arrange + const submitFnMock = jest.fn(); + render( +
+ + , + ); + + // Act + const button = await screen.findByRole("button"); + await userEvent.click(button); + + // Assert + expect(submitFnMock).toHaveBeenCalled(); + }); + + it("should submit button within form via keyboard", async () => { + // Arrange + const submitFnMock = jest.fn(); + render( +
+ + , + ); + + // Act + const button = await screen.findByRole("button"); + await userEvent.type(button, "{enter}"); + + // Assert + expect(submitFnMock).toHaveBeenCalled(); + }); + + it("should submit button doesn't break if it's not in a form", async () => { + // Arrange + render(); + + // Act + expect(async () => { + // Assert + await userEvent.click(await screen.findByRole("button")); + }).not.toThrow(); + }); + }); }); diff --git a/packages/wonder-blocks-icon-button/src/components/icon-button-core.tsx b/packages/wonder-blocks-icon-button/src/components/icon-button-core.tsx index 1139cb745..65fe14342 100644 --- a/packages/wonder-blocks-icon-button/src/components/icon-button-core.tsx +++ b/packages/wonder-blocks-icon-button/src/components/icon-button-core.tsx @@ -93,6 +93,7 @@ const IconButtonCore: React.ForwardRefExoticComponent< skipClientNav, style, testId, + type = "button", ...restProps } = props; const {theme, themeName} = useScopedTheme(IconButtonThemeContext); @@ -142,7 +143,7 @@ const IconButtonCore: React.ForwardRefExoticComponent< } else { return ( > & { + /** + * A unique identifier for the IconButton. + */ + id?: string; /** * A Phosphor icon asset (imported as a static SVG file). */ @@ -40,6 +44,10 @@ export type SharedProps = Partial> & { * Test ID used for e2e testing. */ testId?: string; + /** + * Used for icon buttons within
s. + */ + type?: "submit"; /** * Size of the icon button. * One of `xsmall` (16 icon, 20 target), `small` (24, 32), `medium` (24, 40), @@ -181,6 +189,7 @@ export const IconButton: React.ForwardRefExoticComponent< skipClientNav, tabIndex, target, + type, ...sharedProps } = props; @@ -219,6 +228,7 @@ export const IconButton: React.ForwardRefExoticComponent< tabIndex={tabIndex} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} + type={type} /> ); diff --git a/packages/wonder-blocks-modal/CHANGELOG.md b/packages/wonder-blocks-modal/CHANGELOG.md index 6cf6c090b..3039fb4fc 100644 --- a/packages/wonder-blocks-modal/CHANGELOG.md +++ b/packages/wonder-blocks-modal/CHANGELOG.md @@ -1,5 +1,19 @@ # @khanacademy/wonder-blocks-modal +## 5.1.14 + +### Patch Changes + +- Updated dependencies [75da0046] + - @khanacademy/wonder-blocks-icon-button@5.6.0 + +## 5.1.13 + +### Patch Changes + +- Updated dependencies [3463bde3] + - @khanacademy/wonder-blocks-icon-button@5.5.0 + ## 5.1.12 ### Patch Changes diff --git a/packages/wonder-blocks-modal/package.json b/packages/wonder-blocks-modal/package.json index 53d83bf97..fd38ed05e 100644 --- a/packages/wonder-blocks-modal/package.json +++ b/packages/wonder-blocks-modal/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-modal", - "version": "5.1.12", + "version": "5.1.14", "design": "v2", "publishConfig": { "access": "public" @@ -18,7 +18,7 @@ "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-breadcrumbs": "^2.2.7", "@khanacademy/wonder-blocks-core": "^7.0.1", - "@khanacademy/wonder-blocks-icon-button": "^5.4.1", + "@khanacademy/wonder-blocks-icon-button": "^5.6.0", "@khanacademy/wonder-blocks-layout": "^2.2.1", "@khanacademy/wonder-blocks-theming": "^2.0.4", "@khanacademy/wonder-blocks-timing": "^5.0.2", diff --git a/packages/wonder-blocks-popover/CHANGELOG.md b/packages/wonder-blocks-popover/CHANGELOG.md index 996833547..d61e1c876 100644 --- a/packages/wonder-blocks-popover/CHANGELOG.md +++ b/packages/wonder-blocks-popover/CHANGELOG.md @@ -1,5 +1,23 @@ # @khanacademy/wonder-blocks-popover +## 3.3.2 + +### Patch Changes + +- Updated dependencies [75da0046] + - @khanacademy/wonder-blocks-icon-button@5.6.0 + - @khanacademy/wonder-blocks-modal@5.1.14 + - @khanacademy/wonder-blocks-tooltip@2.5.2 + +## 3.3.1 + +### Patch Changes + +- Updated dependencies [3463bde3] + - @khanacademy/wonder-blocks-icon-button@5.5.0 + - @khanacademy/wonder-blocks-modal@5.1.13 + - @khanacademy/wonder-blocks-tooltip@2.5.1 + ## 3.3.0 ### Minor Changes diff --git a/packages/wonder-blocks-popover/package.json b/packages/wonder-blocks-popover/package.json index d01abb3d9..3534d53c1 100644 --- a/packages/wonder-blocks-popover/package.json +++ b/packages/wonder-blocks-popover/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-popover", - "version": "3.3.0", + "version": "3.3.2", "design": "v1", "publishConfig": { "access": "public" @@ -17,10 +17,10 @@ "dependencies": { "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", - "@khanacademy/wonder-blocks-icon-button": "^5.4.1", - "@khanacademy/wonder-blocks-modal": "^5.1.12", + "@khanacademy/wonder-blocks-icon-button": "^5.6.0", + "@khanacademy/wonder-blocks-modal": "^5.1.14", "@khanacademy/wonder-blocks-tokens": "^2.0.1", - "@khanacademy/wonder-blocks-tooltip": "^2.5.0", + "@khanacademy/wonder-blocks-tooltip": "^2.5.2", "@khanacademy/wonder-blocks-typography": "^2.1.16" }, "peerDependencies": { diff --git a/packages/wonder-blocks-search-field/CHANGELOG.md b/packages/wonder-blocks-search-field/CHANGELOG.md index e2c7d41ce..0cca5b21f 100644 --- a/packages/wonder-blocks-search-field/CHANGELOG.md +++ b/packages/wonder-blocks-search-field/CHANGELOG.md @@ -1,5 +1,46 @@ # @khanacademy/wonder-blocks-search-field +## 2.3.3 + +### Patch Changes + +- Updated dependencies [8c861955] +- Updated dependencies [0b3a28a7] + - @khanacademy/wonder-blocks-form@4.10.1 + +## 2.3.2 + +### Patch Changes + +- Updated dependencies [7a98815b] + - @khanacademy/wonder-blocks-form@4.10.0 + +## 2.3.1 + +### Patch Changes + +- Updated dependencies [75da0046] + - @khanacademy/wonder-blocks-icon-button@5.6.0 + +## 2.3.0 + +### Minor Changes + +- 659a031d: Add onKeyUp prop to the `SearchField` component + +### Patch Changes + +- Updated dependencies [3463bde3] + - @khanacademy/wonder-blocks-icon-button@5.5.0 + +## 2.2.27 + +### Patch Changes + +- Updated dependencies [61dc4448] +- Updated dependencies [2dfd5eb6] + - @khanacademy/wonder-blocks-form@4.9.4 + ## 2.2.26 ### Patch Changes diff --git a/packages/wonder-blocks-search-field/package.json b/packages/wonder-blocks-search-field/package.json index 3c1e4903f..1a6a90fc1 100644 --- a/packages/wonder-blocks-search-field/package.json +++ b/packages/wonder-blocks-search-field/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-search-field", - "version": "2.2.26", + "version": "2.3.3", "design": "v1", "description": "Search Field components for Wonder Blocks.", "main": "dist/index.js", @@ -17,9 +17,9 @@ "dependencies": { "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", - "@khanacademy/wonder-blocks-form": "^4.9.3", + "@khanacademy/wonder-blocks-form": "^4.10.1", "@khanacademy/wonder-blocks-icon": "^4.1.5", - "@khanacademy/wonder-blocks-icon-button": "^5.4.1", + "@khanacademy/wonder-blocks-icon-button": "^5.6.0", "@khanacademy/wonder-blocks-tokens": "^2.0.1", "@khanacademy/wonder-blocks-typography": "^2.1.16" }, diff --git a/packages/wonder-blocks-search-field/src/components/__tests__/search-field.test.tsx b/packages/wonder-blocks-search-field/src/components/__tests__/search-field.test.tsx index a58080a16..de2f2a829 100644 --- a/packages/wonder-blocks-search-field/src/components/__tests__/search-field.test.tsx +++ b/packages/wonder-blocks-search-field/src/components/__tests__/search-field.test.tsx @@ -418,4 +418,27 @@ describe("SearchField", () => { // Assert expect(searchField).not.toHaveFocus(); }); + + it("onKeyDown is called after keyboard key press", async () => { + // Arrange + const handleOnKeyDown = jest.fn( + (event: React.KeyboardEvent) => { + return event.key; + }, + ); + + render( + {}} + onKeyDown={handleOnKeyDown} + />, + ); + + // Act + await userEvent.type(await screen.findByRole("textbox"), "{enter}"); + + // Assert + expect(handleOnKeyDown).toHaveReturnedWith("Enter"); + }); }); diff --git a/packages/wonder-blocks-search-field/src/components/search-field.tsx b/packages/wonder-blocks-search-field/src/components/search-field.tsx index 7c46f0128..b8416b71a 100644 --- a/packages/wonder-blocks-search-field/src/components/search-field.tsx +++ b/packages/wonder-blocks-search-field/src/components/search-field.tsx @@ -72,6 +72,10 @@ type Props = AriaProps & { * Called when a key is pressed. */ onKeyDown?: (event: React.KeyboardEvent) => unknown; + /** + * Called when a key is released. + */ + onKeyUp?: (event: React.KeyboardEvent) => unknown; /** * Called when the element has been focused. */ diff --git a/packages/wonder-blocks-testing-core/CHANGELOG.md b/packages/wonder-blocks-testing-core/CHANGELOG.md index 9b552884a..da4d41075 100644 --- a/packages/wonder-blocks-testing-core/CHANGELOG.md +++ b/packages/wonder-blocks-testing-core/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/wonder-blocks-testing-core +## 1.1.0 + +### Minor Changes + +- 16565a85: Add support for hard fails to the request mocking features + ## 1.0.2 ### Patch Changes diff --git a/packages/wonder-blocks-testing-core/package.json b/packages/wonder-blocks-testing-core/package.json index 30f34835b..f81b7bc21 100644 --- a/packages/wonder-blocks-testing-core/package.json +++ b/packages/wonder-blocks-testing-core/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-testing-core", - "version": "1.0.2", + "version": "1.1.0", "design": "v1", "publishConfig": { "access": "public" diff --git a/packages/wonder-blocks-testing-core/src/__tests__/mock-requester.test.ts b/packages/wonder-blocks-testing-core/src/__tests__/mock-requester.test.ts index 0f5ef6e55..3f412d4d9 100644 --- a/packages/wonder-blocks-testing-core/src/__tests__/mock-requester.test.ts +++ b/packages/wonder-blocks-testing-core/src/__tests__/mock-requester.test.ts @@ -35,7 +35,17 @@ describe("#mockRequester", () => { ); }); - it("should throw with helpful details formatted by operationToString if no matching mock is found", async () => { + it("should provide a configuration API", () => { + // Arrange + + // Act + const result = mockRequester(jest.fn(), jest.fn()); + + // Assert + expect(result).toHaveProperty("configure", expect.any(Function)); + }); + + it("should reject with helpful details formatted by operationToString if no matching mock is found", async () => { // Arrange const mockFn = mockRequester( jest.fn(), @@ -209,4 +219,56 @@ describe("#mockRequester", () => { await expect(result).resolves.toBe("TWO"); }); }); + + describe("configure", () => { + it("should reject promise on unmocked requests by default", async () => { + // Arrange + const matcher = jest.fn().mockReturnValue(false); + const operationToString = jest.fn(); + const mockFn = mockRequester(matcher, operationToString); + + // Act + const result = mockFn("DO SOMETHING"); + + // Assert + await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(` + "No matching mock response found for request: + undefined" + `); + }); + + it("should cause hard fail on unmocked requests when hardFailOnUnmockedRequests is set to true", () => { + // Arrange + const matcher = jest.fn().mockReturnValue(false); + const operationToString = jest.fn(); + const mockFn = mockRequester(matcher, operationToString); + + // Act + mockFn.configure({hardFailOnUnmockedRequests: true}); + const underTest = () => mockFn("DO SOMETHING"); + + // Assert + expect(underTest).toThrowErrorMatchingInlineSnapshot(` + "No matching mock response found for request: + undefined" + `); + }); + + it("should reject promise on unmocked requests when hardFailOnUnmockedRequests is set to false ", async () => { + // Arrange + const matcher = jest.fn().mockReturnValue(false); + const operationToString = jest.fn(); + const mockFn = mockRequester(matcher, operationToString); + + // Act + mockFn.configure({hardFailOnUnmockedRequests: false}); + const result = mockFn("DO SOMETHING"); + + // Assert + await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(` + "No matching mock response found for request: + undefined" + `); + }); + }); }); diff --git a/packages/wonder-blocks-testing-core/src/fetch/mock-fetch.ts b/packages/wonder-blocks-testing-core/src/fetch/mock-fetch.ts index f3a30ddd1..54f12cae8 100644 --- a/packages/wonder-blocks-testing-core/src/fetch/mock-fetch.ts +++ b/packages/wonder-blocks-testing-core/src/fetch/mock-fetch.ts @@ -6,7 +6,7 @@ import type {FetchMockFn, FetchMockOperation} from "./types"; * A mock for the fetch function passed to GqlRouter. */ export const mockFetch = (): FetchMockFn => - mockRequester( + mockRequester( fetchRequestMatchesMock, // NOTE(somewhatabstract): The indentation is expected on the lines // here. diff --git a/packages/wonder-blocks-testing-core/src/fetch/types.ts b/packages/wonder-blocks-testing-core/src/fetch/types.ts index dc56f1634..a25fc978f 100644 --- a/packages/wonder-blocks-testing-core/src/fetch/types.ts +++ b/packages/wonder-blocks-testing-core/src/fetch/types.ts @@ -1,14 +1,65 @@ import type {MockResponse} from "../respond-with"; +import {ConfigureFn} from "../types"; export type FetchMockOperation = RegExp | string; -type FetchMockOperationFn = ( - operation: FetchMockOperation, - response: MockResponse, -) => FetchMockFn; +interface FetchMockOperationFn { + ( + /** + * The operation to match. + * + * This is a string for an exact match, or a regex. This is compared to + * to the URL of the fetch request to determine if it is a matching + * request. + */ + operation: FetchMockOperation, + + /** + * The response to return when the operation is matched. + */ + response: MockResponse, + ): FetchMockFn; +} export type FetchMockFn = { + /** + * The mock fetch function. + * + * This function is a drop-in replacement for the fetch function. You should + * not need to call this function directly. Just replace the normal fetch + * function implementation with this. + */ (input: RequestInfo, init?: RequestInit): Promise; + + /** + * Mock a fetch operation. + * + * This adds a response for a given mocked operation. Regardless of how + * many times this mock is matched, it will be used. + * + * @returns The mock fetch function for chaining. + */ mockOperation: FetchMockOperationFn; + + /** + * Mock a fetch operation once. + * + * This adds a response for a given mocked operation that will only be used + * once and discarded. + * + * @returns The mock fetch function for chaining. + */ mockOperationOnce: FetchMockOperationFn; + + /** + * Configure the mock fetch function with the given configuration. + * + * This function is provided as a convenience to allow for configuring the + * mock fetch function in a fluent manner. The configuration is applied + * to all mocks for a given fetch function; the last configuration applied + * will be the one that is used for all mocked operations. + * + * @returns The mock fetch function for chaining. + */ + configure: ConfigureFn; }; diff --git a/packages/wonder-blocks-testing-core/src/index.ts b/packages/wonder-blocks-testing-core/src/index.ts index 6cac2335f..210301fa1 100644 --- a/packages/wonder-blocks-testing-core/src/index.ts +++ b/packages/wonder-blocks-testing-core/src/index.ts @@ -15,6 +15,8 @@ export type { OperationMock, OperationMatcher, MockOperationFn, + MockConfiguration, + ConfigureFn, } from "./types"; // Test harness framework diff --git a/packages/wonder-blocks-testing-core/src/mock-requester.ts b/packages/wonder-blocks-testing-core/src/mock-requester.ts index 736eb158b..7b8ceafb7 100644 --- a/packages/wonder-blocks-testing-core/src/mock-requester.ts +++ b/packages/wonder-blocks-testing-core/src/mock-requester.ts @@ -1,23 +1,30 @@ import type {MockResponse} from "./respond-with"; -import type {OperationMock, OperationMatcher, MockFn} from "./types"; +import type { + OperationMock, + OperationMatcher, + MockFn, + MockConfiguration, +} from "./types"; /** * A generic mock request function for using when mocking fetch or gqlFetch. */ -export const mockRequester = ( +export const mockRequester = ( operationMatcher: OperationMatcher, operationToString: (...args: Array) => string, -): MockFn => { +): MockFn => { // We want this to work in jest and in fixtures to make life easy for folks. // This is the array of mocked operations that we will traverse and // manipulate. const mocks: Array> = []; - // What we return has to be a drop in replacement for the mocked function - // which is how folks will then use this mock. - const mockFn: MockFn = ( + const configuration: MockConfiguration = { + hardFailOnUnmockedRequests: false, + }; + + const getMatchingMock = ( ...args: Array - ): Promise => { + ): OperationMock | null => { // Iterate our mocked operations and find the first one that matches. for (const mock of mocks) { if (mock.onceOnly && mock.used) { @@ -26,24 +33,46 @@ export const mockRequester = ( } if (operationMatcher(mock.operation, ...args)) { mock.used = true; - return mock.response(); + return mock; } } + return null; + }; - // Default is to reject with some helpful info on what request - // we rejected. + // What we return has to be a drop in replacement for the mocked function + // which is how folks will then use this mock. + const mockFn: MockFn = ( + ...args: Array + ): Promise => { + const matchingMock = getMatchingMock(...args); + if (matchingMock) { + return matchingMock.response(); + } + + // If we get here, there is no match. const operation = operationToString(...args); - return Promise.reject( + const noMatchError = new Error(`No matching mock response found for request: - ${operation}`), - ); + ${operation}`); + if (configuration.hardFailOnUnmockedRequests) { + // When we are set to hard fail, we do what Apollo's MockLink + // does and throw an error immediately. This catastrophically fails + // test cases when a request wasn't matched, which can be brutal + // in some cases, though is also helpful for debugging. + throw noMatchError; + } + + // Our default is to return a rejected promise so that errors + // are handled by the code under test rather than hard failing + // everything. + return Promise.reject(noMatchError); }; const addMockedOperation = ( operation: TOperation, - response: MockResponse, + response: MockResponse, onceOnly: boolean, - ): MockFn => { + ): MockFn => { const mockResponse = () => response.toPromise(); mocks.push({ operation, @@ -56,13 +85,22 @@ export const mockRequester = ( mockFn.mockOperation = ( operation: TOperation, - response: MockResponse, - ): MockFn => addMockedOperation(operation, response, false); + response: MockResponse, + ): MockFn => + addMockedOperation(operation, response, false); mockFn.mockOperationOnce = ( operation: TOperation, - response: MockResponse, - ): MockFn => addMockedOperation(operation, response, true); + response: MockResponse, + ): MockFn => + addMockedOperation(operation, response, true); + + mockFn.configure = ( + config: Partial, + ): MockFn => { + Object.assign(configuration, config); + return mockFn; + }; return mockFn; }; diff --git a/packages/wonder-blocks-testing-core/src/types.ts b/packages/wonder-blocks-testing-core/src/types.ts index baa41e69a..52f9fc2f8 100644 --- a/packages/wonder-blocks-testing-core/src/types.ts +++ b/packages/wonder-blocks-testing-core/src/types.ts @@ -14,11 +14,46 @@ export type GraphQLJson> = }>; }; -export type MockFn = { +export interface MockFn { + /** + * The mock fetch function. + * + * This function is a drop-in replacement for the fetch function being + * mocked. It is recommended that a more strongly-typed definition is + * provided in the consuming codebase, as this definition is intentionally + * loose to allow for mocking any fetch operation. + */ (...args: Array): Promise; - mockOperation: MockOperationFn; - mockOperationOnce: MockOperationFn; -}; + + /** + * Mock a fetch operation. + * + * This adds a response for a given mocked operation of the given type. + * Matches are determined by the operation matcher provided to the + * mockRequester function that creates the mock fetch function. + */ + mockOperation: MockOperationFn; + + /** + * Mock a fetch operation once. + * + * This adds a response for a given mocked operation of the given type that + * will only be used once and discarded. Matches are determined by the + * operation matcher provided to the mockRequester function that creates the + * mock fetch function. + */ + mockOperationOnce: MockOperationFn; + + /** + * Configure the mock fetch function with the given configuration. + * + * This function is provided as a convenience to allow for configuring the + * mock fetch function in a fluent manner. The configuration is applied + * to all mocks for a given fetch function; the last configuration applied + * will be the one that is used for all mocked operations. + */ + configure: ConfigureFn; +} export type OperationMock = { operation: TOperation; @@ -32,9 +67,41 @@ export type OperationMatcher = ( ...args: Array ) => boolean; -export type MockOperationFn = < +export type MockOperationFn = < TOperation extends TOperationType, >( operation: TOperation, - response: MockResponse, -) => MockFn; + response: MockResponse, +) => MockFn; + +/** + * Configuration options for mocked fetches. + */ +export type MockConfiguration = { + /** + * If true, any requests that don't match a mock will throw an error + * immediately on the request being made; otherwise, if false, unmatched + * requests will return a rejected promise. + * + * Defaults to false. When true, this is akin to the Apollo MockLink + * behavior that throws upon the request being. This is useful as it will + * clearly fail a test early, indicating that a request was not mocked. + * However, that mode requires all requests to be mocked, which can be + * cumbersome and unncessary. Having unmocked requests return a rejected + * promise is more flexible and allows for more granular control over + * mocking, allowing developers to mock only the requests they care about + * and let the error handling of their code deal with the rejected promises. + */ + hardFailOnUnmockedRequests: boolean; +}; + +export interface ConfigureFn { + /** + * Configure the mock fetch function with the given configuration. + * + * @param config The configuration changes to apply to the mock fetch + * function. + * @returns The mock fetch function . + */ + (config: Partial): MockFn; +} diff --git a/packages/wonder-blocks-testing/CHANGELOG.md b/packages/wonder-blocks-testing/CHANGELOG.md index 79a4f1ce0..91055930a 100644 --- a/packages/wonder-blocks-testing/CHANGELOG.md +++ b/packages/wonder-blocks-testing/CHANGELOG.md @@ -1,5 +1,20 @@ # @khanacademy/wonder-blocks-testing +## 13.0.0 + +### Major Changes + +- eb807af8: When mocking GraphQL, consider explicit undefined values in a request to be equivalent to missing keys in a mock + +### Minor Changes + +- 16565a85: Add support for hard fails to the request mocking features + +### Patch Changes + +- Updated dependencies [16565a85] + - @khanacademy/wonder-blocks-testing-core@1.1.0 + ## 12.0.1 ### Patch Changes diff --git a/packages/wonder-blocks-testing/package.json b/packages/wonder-blocks-testing/package.json index 036288bc6..e45a47b75 100644 --- a/packages/wonder-blocks-testing/package.json +++ b/packages/wonder-blocks-testing/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-testing", - "version": "12.0.1", + "version": "13.0.0", "design": "v1", "publishConfig": { "access": "public" @@ -16,7 +16,7 @@ "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", "@khanacademy/wonder-blocks-data": "^13.0.12", - "@khanacademy/wonder-blocks-testing-core": "^1.0.2" + "@khanacademy/wonder-blocks-testing-core": "^1.1.0" }, "peerDependencies": { "@khanacademy/wonder-stuff-core": "^1.2.2", diff --git a/packages/wonder-blocks-testing/src/gql/__tests__/gql-request-matches-mock.test.ts b/packages/wonder-blocks-testing/src/gql/__tests__/gql-request-matches-mock.test.ts index a087d741c..a46cc4f7f 100644 --- a/packages/wonder-blocks-testing/src/gql/__tests__/gql-request-matches-mock.test.ts +++ b/packages/wonder-blocks-testing/src/gql/__tests__/gql-request-matches-mock.test.ts @@ -45,7 +45,7 @@ describe("#gqlRequestMatchesMock", () => { expect(result).toBe(false); }); - it.each([{foo: "bar"}, {foo: "baz", anExtra: "property"}, null])( + it.each([{foo: undefined}, {foo: "baz", anExtra: "property"}, null])( "should return false if variables don't match", (variables: any) => { // Arrange @@ -158,6 +158,7 @@ describe("#gqlRequestMatchesMock", () => { }, { foo: "bar", + baz: undefined, }, {my: "context"}, ); diff --git a/packages/wonder-blocks-testing/src/gql/__tests__/mock-gql-fetch.test.tsx b/packages/wonder-blocks-testing/src/gql/__tests__/mock-gql-fetch.test.tsx index b36cf637f..1637f4168 100644 --- a/packages/wonder-blocks-testing/src/gql/__tests__/mock-gql-fetch.test.tsx +++ b/packages/wonder-blocks-testing/src/gql/__tests__/mock-gql-fetch.test.tsx @@ -2,7 +2,10 @@ import * as React from "react"; import {render, screen, waitFor} from "@testing-library/react"; import {GqlRouter, useGql} from "@khanacademy/wonder-blocks-data"; -import {RespondWith} from "@khanacademy/wonder-blocks-testing-core"; +import { + RespondWith, + testHarness, +} from "@khanacademy/wonder-blocks-testing-core"; import {mockGqlFetch} from "../mock-gql-fetch"; describe("#mockGqlFetch", () => { @@ -41,6 +44,49 @@ describe("#mockGqlFetch", () => { ); }); + it("should throw an error when there are no mocks and hardFailOnUnmockedRequests is true", () => { + // Arrange + jest.spyOn(console, "error").mockImplementation(() => { + /* react will log an error - this keeps the output clean */ + }); + const mockFetch = mockGqlFetch(); + mockFetch.configure({hardFailOnUnmockedRequests: true}); + const RenderError = () => { + const [result, setResult] = React.useState(null); + const gqlFetch = useGql(); + React.useEffect(() => { + gqlFetch({ + type: "query", + id: "getMyStuff", + }).catch((e: any) => { + setResult(e.message); + }); + }, [gqlFetch]); + + return
{result}
; + }; + const captureError = jest.fn(); + const Harnessed = testHarness(RenderError, { + boundary: captureError, + }); + + // Act + render( + + + , + ); + const result = captureError.mock.calls[0][0]; + + // Assert + expect(result).toMatchInlineSnapshot(` + [Error: No matching mock response found for request: + Operation: query getMyStuff + Variables: None + Context: {}] + `); + }); + it("should provide data when response gives data", async () => { // Arrange const mockFetch = mockGqlFetch(); diff --git a/packages/wonder-blocks-testing/src/gql/gql-request-matches-mock.ts b/packages/wonder-blocks-testing/src/gql/gql-request-matches-mock.ts index 03b28f361..76ae3ae13 100644 --- a/packages/wonder-blocks-testing/src/gql/gql-request-matches-mock.ts +++ b/packages/wonder-blocks-testing/src/gql/gql-request-matches-mock.ts @@ -1,14 +1,11 @@ import type {GqlOperation, GqlContext} from "@khanacademy/wonder-blocks-data"; import type {GqlMockOperation} from "./types"; -const safeHasOwnProperty = (obj: any, prop: string): boolean => - Object.prototype.hasOwnProperty.call(obj, prop); - // TODO(somewhatabstract, FEI-4268): use a third-party library to do this and // possibly make it also support the jest `jest.objectContaining` type matching // to simplify mock declaration (note that it would need to work in regular // tests and stories/fixtures). -const areObjectsEqual = (a: any, b: any): boolean => { +const areObjectsEquivalent = (a: any, b: any): boolean => { if (a === b) { return true; } @@ -18,14 +15,18 @@ const areObjectsEqual = (a: any, b: any): boolean => { if (typeof a !== "object" || typeof b !== "object") { return false; } + + // Now, we need to compare the values of the objects. + // We can't just compare key sets as we want to consider an explicit + // key with an undefined value to be the same as a missing key. + // It makes for a nicer API when defining mocks. const aKeys = Object.keys(a); const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) { - return false; - } - for (let i = 0; i < aKeys.length; i++) { - const key = aKeys[i]; - if (!safeHasOwnProperty(b, key) || !areObjectsEqual(a[key], b[key])) { + + const allKeys = new Set([...aKeys, ...bKeys]); + + for (const key of allKeys) { + if (!areObjectsEquivalent(a[key], b[key])) { return false; } } @@ -52,7 +53,7 @@ export const gqlRequestMatchesMock = ( // we just assume it matches everything. if (mock.variables != null) { // Variables have to match. - if (!areObjectsEqual(mock.variables, variables)) { + if (!areObjectsEquivalent(mock.variables, variables)) { return false; } } @@ -61,7 +62,7 @@ export const gqlRequestMatchesMock = ( // we just assume it matches everything. if (mock.context != null) { // Context has to match. - if (!areObjectsEqual(mock.context, context)) { + if (!areObjectsEquivalent(mock.context, context)) { return false; } } diff --git a/packages/wonder-blocks-testing/src/gql/mock-gql-fetch.ts b/packages/wonder-blocks-testing/src/gql/mock-gql-fetch.ts index 291d2a8ac..ae270b0c9 100644 --- a/packages/wonder-blocks-testing/src/gql/mock-gql-fetch.ts +++ b/packages/wonder-blocks-testing/src/gql/mock-gql-fetch.ts @@ -1,4 +1,7 @@ -import {mockRequester} from "@khanacademy/wonder-blocks-testing-core"; +import { + GraphQLJson, + mockRequester, +} from "@khanacademy/wonder-blocks-testing-core"; import {gqlRequestMatchesMock} from "./gql-request-matches-mock"; import type {GqlFetchMockFn, GqlMockOperation} from "./types"; @@ -6,7 +9,7 @@ import type {GqlFetchMockFn, GqlMockOperation} from "./types"; * A mock for the fetch function passed to GqlRouter. */ export const mockGqlFetch = (): GqlFetchMockFn => - mockRequester>( + mockRequester, GraphQLJson>( gqlRequestMatchesMock, // Note that the identation at the start of each line is important. // TODO(somewhatabstract): Make a stringify that indents each line of diff --git a/packages/wonder-blocks-testing/src/gql/types.ts b/packages/wonder-blocks-testing/src/gql/types.ts index e069a64e4..433609b09 100644 --- a/packages/wonder-blocks-testing/src/gql/types.ts +++ b/packages/wonder-blocks-testing/src/gql/types.ts @@ -1,9 +1,16 @@ import type {GqlOperation, GqlContext} from "@khanacademy/wonder-blocks-data"; import type { + ConfigureFn, GraphQLJson, MockResponse, } from "@khanacademy/wonder-blocks-testing-core"; +/** + * A GraphQL operation to be mocked. + * + * This is used to specify what a request must match in order for a mock to + * be used. + */ export type GqlMockOperation< TData extends Record, TVariables extends Record, @@ -14,22 +21,77 @@ export type GqlMockOperation< context?: TContext; }; -type GqlMockOperationFn = < - TData extends Record, - TVariables extends Record, - TContext extends GqlContext, - TResponseData extends GraphQLJson, ->( - operation: GqlMockOperation, - response: MockResponse, -) => GqlFetchMockFn; +interface GqlMockOperationFn { + < + TData extends Record, + TVariables extends Record, + TContext extends GqlContext, + TResponseData extends GraphQLJson, + >( + /** + * The operation to match. + */ + operation: GqlMockOperation, + /** + * The response to return when the operation is matched. + */ + response: MockResponse, + ): GqlFetchMockFn; +} -export type GqlFetchMockFn = { +export interface GqlFetchMockFn { + /** + * The mock fetch function. + * + * This function is a drop-in replacement for the gqlFetch function used + * by Wonder Blocks Data. You should not need to call this function + * directly. Just pass this in places where you would pass a gqlFetch + * function, as provided by the GqlRouter. + */ ( operation: GqlOperation, variables: Record | null | undefined, context: GqlContext, ): Promise; + + /** + * Mock a fetch operation. + * + * This adds a response for a given mocked operation. Operations are + * matched greedily, so if only the GraphQL operation is provided, then + * all requests for that operation will be matched, regardless of + * variables or context. + * + * Regardless of how many times this mock is matched, it will be used. + * + * @returns The mock fetch function for chaining. + */ mockOperation: GqlMockOperationFn; + + /** + * Mock a fetch operation once. + * + * This adds a response for a given mocked operation. Operations are + * matched greedily, so if only the GraphQL operation is provided, then + * all requests for that operation will be matched, regardless of + * variables or context. + * + * Once the added mock is used, it will be discarded and no longer match + * any requests. + * + * @returns The mock fetch function for chaining. + */ mockOperationOnce: GqlMockOperationFn; -}; + + /** + * Configure the mock fetch function with the given configuration. + * + * This function is provided as a convenience to allow for configuring the + * mock fetch function in a fluent manner. The configuration is applied + * to all mocks for a given fetch function; the last configuration applied + * will be the one that is used for all mocked operations. + * + * @returns The mock fetch function for chaining. + */ + configure: ConfigureFn, GraphQLJson>; +} diff --git a/packages/wonder-blocks-tooltip/CHANGELOG.md b/packages/wonder-blocks-tooltip/CHANGELOG.md index 3e235d71a..884e007aa 100644 --- a/packages/wonder-blocks-tooltip/CHANGELOG.md +++ b/packages/wonder-blocks-tooltip/CHANGELOG.md @@ -1,5 +1,17 @@ # @khanacademy/wonder-blocks-tooltip +## 2.5.2 + +### Patch Changes + +- @khanacademy/wonder-blocks-modal@5.1.14 + +## 2.5.1 + +### Patch Changes + +- @khanacademy/wonder-blocks-modal@5.1.13 + ## 2.5.0 ### Minor Changes diff --git a/packages/wonder-blocks-tooltip/package.json b/packages/wonder-blocks-tooltip/package.json index 9e7824cc9..982390927 100644 --- a/packages/wonder-blocks-tooltip/package.json +++ b/packages/wonder-blocks-tooltip/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-tooltip", - "version": "2.5.0", + "version": "2.5.2", "design": "v1", "publishConfig": { "access": "public" @@ -18,7 +18,7 @@ "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", "@khanacademy/wonder-blocks-layout": "^2.2.1", - "@khanacademy/wonder-blocks-modal": "^5.1.12", + "@khanacademy/wonder-blocks-modal": "^5.1.14", "@khanacademy/wonder-blocks-tokens": "^2.0.1", "@khanacademy/wonder-blocks-typography": "^2.1.16" },