diff --git a/.changeset/quiet-forms-move.md b/.changeset/quiet-forms-move.md new file mode 100644 index 0000000000..4eb5b626af --- /dev/null +++ b/.changeset/quiet-forms-move.md @@ -0,0 +1,5 @@ +--- +'@skillsoft/gamut': patch +--- + +Migrate Form stories into packages/gamut and fix the SelectDropdown "Remove all" button, which was focusable but `aria-hidden`. Screen readers now announce it by its label. diff --git a/packages/gamut-agent-tools/skills/gamut-forms/SKILL.md b/packages/gamut-agent-tools/skills/gamut-forms/SKILL.md index 9ebcc8659b..cfadc4e034 100644 --- a/packages/gamut-agent-tools/skills/gamut-forms/SKILL.md +++ b/packages/gamut-agent-tools/skills/gamut-forms/SKILL.md @@ -29,13 +29,13 @@ For typical product forms, prefer `GridForm` (declarative `fields`, `LayoutGrid` ## `Radio` and `RadioGroup` -`packages/gamut/src/Form/inputs/Radio.tsx` · `packages/gamut/src/Form/inputs/RadioGroup.tsx` · reference: `packages/styleguide/src/lib/Atoms/FormInputs/Radio/Radio.mdx` +`packages/gamut/src/Form/inputs/Radio/Radio.tsx` · `packages/gamut/src/Form/inputs/Radio/RadioGroup.tsx` · reference: `packages/gamut/src/Form/inputs/Radio/Radio.stories.tsx` ### No `variant`/`size` props — state is derived, not chosen `Radio` does not take a consumer-facing `variant` or `size` prop. Its visual state is derived internally from the booleans you already pass: -- `error?: boolean` and `disabled?: boolean` (from `BaseInputProps`) feed a `conditionalStyleState(error, disabled)` helper that resolves to `'error' | 'disabled' | undefined`, which is then applied via `variant()` (from `@skillsoft/gamut-styles`) on both the input and its label (`conditionalRadioInputStyles` / `conditionalRadioLabelStyles` in `Form/styles/Radio-styles.ts`). +- `error?: boolean` and `disabled?: boolean` (from `BaseInputProps`) feed a `conditionalStyleState(error, disabled)` helper that resolves to `'error' | 'disabled' | undefined`, which is then applied via `variant()` (from `@skillsoft/gamut-styles`) on both the input and its label (`conditionalRadioInputStyles` / `conditionalRadioLabelStyles` in `Form/inputs/Radio/styles.ts`). - `error` wins over `disabled` when both are true — do not pass both expecting a combined visual; pick the one that matches the actual field state. - There is no size scale — all Radio inputs render at a fixed 20×20 indicator. If a design calls for a different size, that's a design deviation to flag, not a prop to reach for. @@ -43,7 +43,7 @@ For typical product forms, prefer `GridForm` (declarative `fields`, `LayoutGrid` The real `` (`RadioInput`) is styled with `screenReaderOnly` from `@skillsoft/gamut-styles` — it stays in the accessibility tree and keeps native keyboard/focus/change behavior, but is visually hidden. The circle indicator the user sees is rendered on the sibling `RadioLabel`'s `::before` (outer ring) and `::after` (fill dot) pseudo-elements, driven by CSS sibling selectors keyed to the hidden input's `:checked`, `:hover`, and `:focus` state (`InputSelectors.CHECKED_AFTER`, `HOVER_FOCUS_BEFORE`, etc.). -Do not rebuild this with a `className`/inline-`style` override or a separate decorative `` — that duplicates state the hidden input already owns and drifts out of sync on focus/hover/checked. If a design needs a different indicator look, change `Radio-styles.ts`, not the call site. +Do not rebuild this with a `className`/inline-`style` override or a separate decorative `` — that duplicates state the hidden input already owns and drifts out of sync on focus/hover/checked. If a design needs a different indicator look, change `Form/inputs/Radio/styles.ts`, not the call site. ### Group and label wiring diff --git a/packages/gamut/src/Form/__fixtures__/utils.tsx b/packages/gamut/src/Form/__fixtures__/utils.tsx deleted file mode 100644 index 9abe023d71..0000000000 --- a/packages/gamut/src/Form/__fixtures__/utils.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { - CalendarIcon, - DataTransferVerticalIcon, - EarthIcon, -} from '@skillsoft/gamut-icons'; -import { fireEvent } from '@testing-library/dom'; -import { act } from 'react'; - -export const DOWN_ARROW = { keyCode: 40 }; - -export const selectOptions = ['red', 'yellow', 'green']; - -export const selectOptionsObject = { - red: 'red', - yellow: 'yellow', - green: 'green', - blue: 'blue', - teal: 'teal', - orange: 'orange', -}; - -export const optionsIconsArray = [ - { - label: 'Data Transfer Vertical Icon', - value: 'one', - icon: DataTransferVerticalIcon, - }, - { label: 'Calendar Icon', value: 'two', icon: CalendarIcon }, - { label: 'Earth Icon', value: 'three', icon: EarthIcon }, -]; - -export const groupedOptions = [ - { - label: 'Fruits', - options: [ - { label: 'Apple', value: 'apple' }, - { label: 'Banana', value: 'banana' }, - { label: 'Orange', value: 'orange' }, - ], - }, - { - label: 'Vegetables', - options: [ - { label: 'Carrot', value: 'carrot' }, - { label: 'Broccoli', value: 'broccoli' }, - { label: 'Spinach', value: 'spinach' }, - ], - }, -]; - -export const groupedOptionsWithIcons = [ - { - label: 'Data Icons', - options: [ - { - label: 'Data Transfer Vertical Icon', - value: 'data-transfer', - icon: DataTransferVerticalIcon, - }, - { - label: 'Calendar Icon', - value: 'calendar', - icon: CalendarIcon, - }, - ], - }, - { - label: 'Navigation Icons', - options: [ - { - label: 'Earth Icon', - value: 'earth', - icon: EarthIcon, - }, - ], - }, -]; - -export const groupedOptionsWithTitles = [ - { - label: 'Fruits', - options: [ - { label: 'Apple', value: 'apple', subtitle: 'Red and crunchy' }, - { label: 'Banana', value: 'banana', subtitle: 'Yellow and sweet' }, - { label: 'Orange', value: 'orange', subtitle: 'Citrus and juicy' }, - ], - }, - { - label: 'Vegetables', - options: [ - { label: 'Carrot', value: 'carrot', subtitle: 'Orange and healthy' }, - { - label: 'Broccoli', - value: 'broccoli', - subtitle: 'Green and nutritious', - }, - { label: 'Spinach', value: 'spinach', subtitle: 'Leafy and iron-rich' }, - ], - }, -]; - -export const optionsWithAbbreviations = [ - { - label: 'United States of America', - value: 'usa', - abbreviation: 'USA', - }, - { - label: 'United Kingdom', - value: 'uk', - abbreviation: 'UK', - }, - { - label: 'Canada', - value: 'canada', - abbreviation: 'CA', - }, - { - label: 'Australia', - value: 'australia', - abbreviation: 'AU', - }, -]; - -export const openDropdown = async (view: any) => { - await act(() => { - fireEvent.keyDown(view.getByRole('combobox'), DOWN_ARROW); - return Promise.resolve(); - }); -}; diff --git a/packages/gamut/src/Form/__tests__/__snapshots__/utils.test.tsx.snap b/packages/gamut/src/Form/__snapshots__/utils.test.tsx.snap similarity index 100% rename from packages/gamut/src/Form/__tests__/__snapshots__/utils.test.tsx.snap rename to packages/gamut/src/Form/__snapshots__/utils.test.tsx.snap diff --git a/packages/gamut/src/Form/__tests__/Checkbox.test.tsx b/packages/gamut/src/Form/__tests__/Checkbox.test.tsx deleted file mode 100644 index 4804e41384..0000000000 --- a/packages/gamut/src/Form/__tests__/Checkbox.test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import { act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { Checkbox } from '../inputs/Checkbox'; -import { createExternalLabel } from './testUtils'; - -const onChange = jest.fn(); - -const renderView = setupRtl(Checkbox, { - htmlFor: 'some-label', - label: 'Some label', - onChange, -}); - -describe('', () => { - it('sets the input checked state when the prop is passed', () => { - const { view } = renderView({ checked: true }); - - view.getByRole('checkbox', { checked: true }); - }); - - it('calls the onChange callback when the input changes', () => { - const onChange = jest.fn(); - - const { view } = renderView({ onChange }); - - const checkbox = view.getByRole('checkbox'); - fireEvent.click(checkbox); - - expect(onChange).toHaveBeenCalled(); - }); - it('sets the input indeterminate state when the prop is passed', () => { - const { view } = renderView({ indeterminate: true }); - - const checkbox = view.getByRole('checkbox'); - - expect(checkbox).toHaveProperty('indeterminate', true); - }); - - it('checked overrides indeterminate when both are true', () => { - const { view } = renderView({ indeterminate: true, checked: true } as any); - - const checkbox = view.getByRole('checkbox', { checked: true }); - expect(checkbox).toHaveProperty('indeterminate', false); - }); - - it('does not set indeterminate state when the prop is false', () => { - const { view } = renderView({ indeterminate: false }); - - const checkbox = view.getByRole('checkbox'); - expect(checkbox).toHaveProperty('indeterminate', false); - }); - - it('accepts JSX in the label', () => { - const { view } = renderView({ - label: my cat, - }); - - view.getByAltText('my cat'); - }); - - it('accepts an aria-label', () => { - const { view } = renderView({ - 'aria-label': 'i am a labeled checkbox', - }); - expect(view.getByLabelText('i am a labeled checkbox')).toHaveAttribute( - 'type', - 'checkbox' - ); - }); - - it('renders an infotip', async () => { - const info = 'small info'; - const { view } = renderView({ - label: 'label', - infotip: { info: 'small info' }, - }); - const tip = view.getByText(info); - - expect(tip).not.toBeVisible(); - - await act(async () => { - await userEvent.click(view.getByRole('button')); - }); - - expect(tip).toBeVisible(); - }); - - describe('InfoTip accessibility', () => { - const info = 'helpful information'; - const labelText = 'Checkbox Label'; - - it('automatically links InfoTip to Checkbox label via aria-labelledby', () => { - const { view } = renderView({ - label: labelText, - infotip: { info }, - }); - - view.getByRole('button', { name: labelText }); - }); - - it('uses explicit ariaLabel when provided', () => { - const ariaLabel = 'Custom label'; - const { view } = renderView({ - label: labelText, - infotip: { info, ariaLabel }, - }); - - view.getByRole('button', { name: ariaLabel }); - expect( - view.queryByRole('button', { name: labelText }) - ).not.toBeInTheDocument(); - }); - - it('uses explicit ariaLabelledby when provided', () => { - const externalLabelId = 'external-label-id'; - const externalLabelText = 'External Label'; - const { view } = renderView({ - label: labelText, - infotip: { info, ariaLabelledby: externalLabelId }, - }); - - createExternalLabel(view, externalLabelId, externalLabelText); - - view.getByRole('button', { name: externalLabelText }); - expect( - view.queryByRole('button', { name: labelText }) - ).not.toBeInTheDocument(); - }); - - it('links InfoTip to label with JSX label content', () => { - const { view } = renderView({ - label: {labelText}, - infotip: { info }, - }); - - view.getByRole('button', { name: labelText }); - }); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/FormGroup.test.tsx b/packages/gamut/src/Form/__tests__/FormGroup.test.tsx deleted file mode 100644 index 0a0c26ba4e..0000000000 --- a/packages/gamut/src/Form/__tests__/FormGroup.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { FormGroup } from '../elements/FormGroup'; -import { Input } from '../inputs/Input'; - -const renderView = setupRtl(FormGroup, { - children: , -}); - -const label = 'up dog'; -const htmlFor = 'up-dog'; -const optionalLabelText = `${label} (optional)`; - -describe('FormGroup', () => { - describe('when htmlFor is provided', () => { - it('renders Label as a label', () => { - const { view } = renderView({ label, htmlFor }); - - view.getByLabelText(optionalLabelText); - }); - }); - describe('when htmlFor is not provided', () => { - it('renders Label as a div', () => { - const { view } = renderView({ label }); - - expect(view.queryByLabelText(optionalLabelText)).toBeNull(); - view.getByText(/up dog/); - }); - }); - - it('adds an asterisk to required labels', () => { - const { view } = renderView({ - label, - htmlFor, - required: true, - }); - - view.getByLabelText('up dog*'); - }); - - it('does not add an asterisk to solo field labels', () => { - const { view } = renderView({ - label, - htmlFor, - required: true, - isSoloField: true, - }); - - view.getByLabelText('up dog'); - }); - - it('renders description', () => { - const { view } = renderView({ - label: 'up dog', - description: - "i don't know what up dog is and at this point i'm too afraid to ask.", - }); - - view.getByText( - "i don't know what up dog is and at this point i'm too afraid to ask." - ); - }); - - it('renders error text when there is an error', () => { - const { view } = renderView({ - label: 'up dog', - error: 'there is no up dog here...', - }); - expect(view.getByRole('alert')).toHaveTextContent( - 'there is no up dog here...' - ); - }); - - describe('infotip accessibility', () => { - const info = 'helpful information'; - const ariaLabel = 'Custom label'; - - it('automatically labels InfoTip button by the field label when label is a string', () => { - const { view } = renderView({ label, htmlFor, infotip: { info } }); - - view.getByRole('button', { name: new RegExp(label) }); - }); - - it('uses explicit ariaLabel when provided', () => { - const { view } = renderView({ - label, - htmlFor, - infotip: { info, ariaLabel }, - }); - - view.getByRole('button', { name: ariaLabel }); - }); - - it('uses explicit ariaLabelledby when provided', () => { - const externalLabelId = 'external-label-id'; - const externalLabelText = 'External Label'; - - const { view } = renderView({ - label, - htmlFor, - infotip: { info, ariaLabelledby: externalLabelId }, - children: ( - <> - {externalLabelText} - - - ), - }); - - view.getByRole('button', { name: externalLabelText }); - }); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/Input.test.tsx b/packages/gamut/src/Form/__tests__/Input.test.tsx deleted file mode 100644 index 3fec00badc..0000000000 --- a/packages/gamut/src/Form/__tests__/Input.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { StreakIcon } from '@skillsoft/gamut-icons'; -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { Input } from '../inputs/Input'; - -jest.mock('@skillsoft/gamut-icons', () => ({ - AlertIcon: () => ( - - Alert Icon - - ), - CheckCircledIcon: () => ( - - Check Circled Icon - - ), - StreakIcon: () => ( - - Streak Icon - - ), -})); - -const renderView = setupRtl(Input, { 'aria-label': 'input' }); - -describe('Input', () => { - it('renders a text input type when the type is text', () => { - const { view } = renderView({ type: 'text' }); - view.getByRole('textbox'); - }); - - it('renders a number input type', () => { - const { view } = renderView({ type: 'number' }); - expect(view.getByLabelText('input')).toHaveAttribute('type', 'number'); - }); - - it('renders a file input type', () => { - const { view } = renderView({ type: 'file' }); - expect(view.getByLabelText('input')).toHaveAttribute('type', 'file'); - }); - - it('renders a correct defaultValue for text', () => { - const { view } = renderView({ - type: 'text', - defaultValue: 'default value', - }); - - view.getByDisplayValue('default value'); - }); - - it('renders a correct defaultValue for numbers', () => { - const { view } = renderView({ - type: 'number', - defaultValue: 13, - }); - - view.getByDisplayValue(13); - }); - - it('renders an AlertIcon when error is true', () => { - const { view } = renderView({ - type: 'number', - defaultValue: 13, - error: true, - }); - - view.getByTitle('Alert Icon'); - }); - - it('renders a CheckCircledIcon when valid is true', () => { - const { view } = renderView({ - type: 'number', - defaultValue: 13, - valid: true, - }); - - view.getByTitle('Check Circled Icon'); - }); - - it('renders a custom gamut-icon when valid is true', () => { - const { view } = renderView({ - type: 'number', - defaultValue: 13, - icon: StreakIcon, - }); - - view.getByTitle('Streak Icon'); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/Radio.test.tsx b/packages/gamut/src/Form/__tests__/Radio.test.tsx deleted file mode 100644 index 8d2b1c2047..0000000000 --- a/packages/gamut/src/Form/__tests__/Radio.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import { act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { Radio } from '../inputs/Radio'; -import { createExternalLabel } from './testUtils'; - -const onChangeCallback = jest.fn(); -const testid = 'my-test-id'; - -const renderView = setupRtl(Radio, { - htmlFor: 'some-label', - checked: true, - onChange: onChangeCallback, - value: 'a', - 'data-testid': 'my-test-id', -}); - -describe('', () => { - it('sets the input checked state when the prop is passed', () => { - const { view } = renderView(); - view.getByRole('radio', { checked: true }); - }); - - it('calls the onChange callback when the input changes', () => { - const { view } = renderView({ checked: false }); - - fireEvent.click(view.getByRole('radio', { checked: false })); - - expect(onChangeCallback).toHaveBeenCalled(); - }); - - it('accepts JSX in the label', () => { - const { view } = renderView({ - label: my cat, - }); - - view.getByAltText('my cat'); - }); - - it('renders an infotip', async () => { - const info = 'small info'; - const { view } = renderView({ - label: 'label', - infotip: { info: 'small info' }, - }); - const tip = view.getByText(info); - - expect(tip).not.toBeVisible(); - - await act(async () => { - await userEvent.click(view.getByRole('button')); - }); - - expect(tip).toBeVisible(); - }); - - it('accepts additional props not specified by the component', () => { - const { view } = renderView(); - - expect(view.getByRole('radio', { checked: true })).toHaveAttribute( - 'data-testid', - testid - ); - }); - - describe('InfoTip accessibility', () => { - const info = 'helpful information'; - const labelText = 'Radio Label'; - - it('automatically links InfoTip to Radio label via aria-labelledby', () => { - const { view } = renderView({ - label: labelText, - infotip: { info }, - }); - - view.getByRole('button', { name: labelText }); - }); - - it('uses explicit ariaLabel when provided', () => { - const ariaLabel = 'Custom label'; - const { view } = renderView({ - label: labelText, - infotip: { info, ariaLabel }, - }); - - view.getByRole('button', { name: ariaLabel }); - expect( - view.queryByRole('button', { name: labelText }) - ).not.toBeInTheDocument(); - }); - - it('uses explicit ariaLabelledby when provided', () => { - const externalLabelId = 'external-label-id'; - const externalLabelText = 'External Label'; - const { view } = renderView({ - label: labelText, - infotip: { info, ariaLabelledby: externalLabelId }, - }); - - createExternalLabel(view, externalLabelId, externalLabelText); - - view.getByRole('button', { name: externalLabelText }); - expect( - view.queryByRole('button', { name: labelText }) - ).not.toBeInTheDocument(); - }); - - it('links InfoTip to label with JSX label content', () => { - const { view } = renderView({ - label: {labelText}, - infotip: { info }, - }); - - view.getByRole('button', { name: labelText }); - }); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/RadioGroup.test.tsx b/packages/gamut/src/Form/__tests__/RadioGroup.test.tsx deleted file mode 100644 index 35c98b5b75..0000000000 --- a/packages/gamut/src/Form/__tests__/RadioGroup.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; - -import { Radio } from '../inputs/Radio'; -import { RadioGroup } from '../inputs/RadioGroup'; - -const renderView = setupRtl(RadioGroup, { - htmlForPrefix: 'what-salad-maker-do-you-prefer', - name: 'what-salad-maker-do-you-prefer', - 'data-testid': 'my-test-id', - children: [ - , - , - ], -}); - -describe('', () => { - const createComponent = () => { - const onChange = jest.fn(); - const { view } = renderView({ onChange }); - - const firstInput = view.getAllByRole('radio')[0]; - - return { firstInput, onChange, view }; - }; - - it('sets the id and name props on the child', () => { - const { firstInput } = createComponent(); - - expect(firstInput).toHaveAttribute( - 'id', - 'what-salad-maker-do-you-prefer-0' - ); - - expect(firstInput).toHaveAttribute( - 'name', - 'what-salad-maker-do-you-prefer' - ); - }); - - it('sets the onChange prop on the child', () => { - const { firstInput, onChange } = createComponent(); - - fireEvent.click(firstInput); - - expect(onChange).toHaveBeenCalled(); - }); - - it('sets any additional props on the outer div', () => { - const { view } = createComponent(); - - view.getByTestId('my-test-id'); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/Select.test.tsx b/packages/gamut/src/Form/__tests__/Select.test.tsx deleted file mode 100644 index 1c541f736e..0000000000 --- a/packages/gamut/src/Form/__tests__/Select.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { Select } from '../inputs/Select'; - -const selectOptions = ['red', 'yellow', 'green']; -const defaultProps = { - options: selectOptions, - id: 'colors', -}; - -const selectOptionsObject = { - redKey: 'red', - greenKey: 'green', - yellowKey: 'yellow', -}; - -const renderView = setupRtl(Select, { - options: selectOptions, - id: 'colors', -}); - -describe('Select', () => { - it('sets the id prop on the select tag', () => { - const { view } = renderView(); - - expect(view.getByRole('combobox')).toHaveAttribute('id', 'colors'); - }); - - it('renders the same number of options as options', () => { - const { view } = renderView(); - - expect(view.getAllByRole('option')).toHaveLength(3); - }); - - it('sets the key of option tags using the form of `${id}-${value}` when the prop id is passed', () => { - const { view } = renderView(); - - const keyWithId = `${defaultProps.id}-${selectOptions[0]}`; - - view.getByTestId(keyWithId); - }); - - it('renders options when options is an object', () => { - const { view } = renderView({ options: selectOptionsObject }); - - expect(view.getAllByRole('option')).toHaveLength( - Object.keys(selectOptionsObject).length - ); - }); - - it('renders options as an object with the correct label + value', () => { - const { view } = renderView({ options: selectOptionsObject }); - - expect(view.getByRole('option', { name: 'yellow' })).toHaveValue( - 'yellowKey' - ); - - expect(view.getAllByRole('option')[0]).toHaveTextContent('red'); - }); - - it('sets the key of option tags using the form of `${id}-${value}` when the prop id is passed and options is an object', () => { - const { view } = renderView({ options: selectOptionsObject }); - - const keyWithId = `${defaultProps.id}-redKey`; - - view.getByTestId(keyWithId); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx deleted file mode 100644 index 764688ceb1..0000000000 --- a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx +++ /dev/null @@ -1,1289 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import { fireEvent } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import { act, useState } from 'react'; - -import { - openDropdown, - optionsIconsArray, - optionsWithAbbreviations, - selectOptions, - selectOptionsObject, -} from '../__fixtures__/utils'; -import { SelectDropdown } from '../SelectDropdown'; -import { DEFAULT_SELECT_DROPDOWN_TRANSLATIONS } from '../SelectDropdown/core/translations'; - -const ToggleValidationMessageHarness = () => { - const [hasCustomMessage, setHasCustomMessage] = useState(true); - - return ( - <> - - - - ); -}; - -const CreatableMultiHarness = () => { - const [options, setOptions] = useState(['Apple', 'Banana']); - - return ( - - setOptions((prev) => [...prev, inputValue]) - } - /> - ); -}; - -const ControlledCreatableMultiHarness = ({ - onChange, - onCreateOption, -}: { - onChange?: jest.Mock; - onCreateOption?: jest.Mock; -}) => { - const [options, setOptions] = useState(['Apple', 'Banana']); - const [value, setValue] = useState(['Apple']); - - return ( - { - setValue(selected.map((option) => option.value)); - - if (meta.action === 'create-option' && meta.option) { - setOptions((prev) => [...prev, meta.option.value]); - } - - onChange?.(selected, meta); - }} - onCreateOption={onCreateOption} - /> - ); -}; - -const renderToggleValidationMessage = setupRtl( - ToggleValidationMessageHarness, - {} -); -const renderCreatableMulti = setupRtl(CreatableMultiHarness, {}); -const renderControlledCreatableMulti = setupRtl( - ControlledCreatableMultiHarness, - {} -); - -/** There is a state pollution issue with SelectDropdown and jest which is why these are broken up into their own file. - * Ticket to fix: https://skillsoftdev.atlassian.net/browse/GM-1297 - */ - -jest.mock('@skillsoft/gamut-icons', () => ({ - ...jest.requireActual<{}>('@skillsoft/gamut-icons'), - MiniChevronDownIcon: () => ( - - Mini Chevron Down Icon - - ), - ArrowChevronDownIcon: () => ( - - Arrow Chevron Down Icon - - ), - DataTransferVerticalIcon: () => ( - - Data Transfer Vertical Icon - - ), - CalendarIcon: () => ( - - Calendar Icon - - ), - EarthIcon: () => ( - - Earth Icon - - ), -})); - -const renderView = setupRtl(SelectDropdown, { - options: selectOptions, - name: 'colors', -}); - -describe('SelectDropdown', () => { - describe('default', () => { - it('sets the id prop on the select tag', () => { - const { view } = renderView(); - - expect(view.getByRole('combobox')).toHaveAttribute('id', 'colors'); - }); - - it.each([ - ['array', selectOptions], - ['object', selectOptionsObject], - ])('renders options when options is an %s', async (_, options) => { - const { view } = renderView({ options }); - - await openDropdown(view); - - view.getByText('green'); - }); - - it('renders a small dropdown when size is "small"', () => { - const { view } = renderView({ size: 'small' }); - view.getByTitle('Mini Chevron Down Icon'); - }); - - it('renders a medium dropdown when size is "medium"', () => { - const { view } = renderView({ size: 'medium' }); - view.getByTitle('Arrow Chevron Down Icon'); - }); - - it('renders a medium dropdown by default', () => { - const { view } = renderView(); - view.getByTitle('Arrow Chevron Down Icon'); - }); - - it('renders a dropdown with the correct maxHeight when shownOptionsLimit is specified', async () => { - const { view } = renderView({ shownOptionsLimit: 4 }); - - await openDropdown(view); - - expect(view.getByRole('listbox')).toHaveStyle({ maxHeight: '12rem' }); - }); - it('renders a dropdown with the correct maxHeight when shownOptionsLimit is specified + size is "small"', async () => { - const { view } = renderView({ - size: 'small', - shownOptionsLimit: 4, - }); - - await openDropdown(view); - - expect(view.getByRole('listbox')).toHaveStyle({ maxHeight: '8rem' }); - }); - - it('renders a dropdown with icons', async () => { - const { view } = renderView({ options: optionsIconsArray }); - - await openDropdown(view); - - optionsIconsArray.forEach((icon) => expect(view.getByTitle(icon.label))); - }); - - it('displays icon in selected value when option has icon', async () => { - const { view } = renderView({ - options: optionsIconsArray, - value: 'one', - }); - - expect( - view.getByTitle('Data Transfer Vertical Icon') - ).toBeInTheDocument(); - const selectedValueContainer = view.getByRole('combobox').closest('div'); - expect(selectedValueContainer).toHaveTextContent( - 'Data Transfer Vertical Icon' - ); - }); - - it('function passed to onInputChanges is called on input change', async () => { - const onInputChange = jest.fn(); - const { view } = renderView({ onInputChange }); - - await openDropdown(view); - - await act(async () => { - await userEvent.click(view.getByText('red')); - }); - - expect(onInputChange).toHaveBeenCalled(); - }); - - it('works with multiple selection', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - multiple: true, - onChange, - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('red')); - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('green')); - }); - - view.getByText('red'); - view.getByText('green'); - - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenNthCalledWith( - 1, - [ - { - label: 'red', - value: 'red', - }, - ], - { - action: 'select-option', - } - ); - expect(onChange).toHaveBeenNthCalledWith( - 2, - [ - { - label: 'red', - value: 'red', - }, - { - label: 'green', - value: 'green', - }, - ], - { - action: 'select-option', - } - ); - }); - - it('displays abbreviations in multiselect mode', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - multiple: true, - options: optionsWithAbbreviations, - onChange, - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('United States of America')); - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('United Kingdom')); - }); - - view.getByText('USA'); - view.getByText('UK'); - - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenNthCalledWith( - 1, - [ - { - label: 'United States of America', - value: 'usa', - abbreviation: 'USA', - key: 'usa', - size: undefined, - }, - ], - { - action: 'select-option', - option: undefined, - } - ); - expect(onChange).toHaveBeenNthCalledWith( - 2, - [ - { - label: 'United States of America', - value: 'usa', - abbreviation: 'USA', - key: 'usa', - size: undefined, - }, - { - label: 'United Kingdom', - value: 'uk', - abbreviation: 'UK', - key: 'uk', - size: undefined, - }, - ], - { - action: 'select-option', - option: undefined, - } - ); - }); - - describe('inputProps functionality', () => { - it('should apply combobox and hidden props in non-searchable mode (default)', () => { - const { view } = renderView({ - isSearchable: false, - inputProps: { - hidden: { - 'data-form-field': 'test-field', - 'data-hidden-attr': 'hidden-value', - }, - combobox: { - 'data-testid': 'non-searchable-combobox', - 'data-custom-attr': 'custom-value', - }, - }, - }); - - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute( - 'data-testid', - 'non-searchable-combobox' - ); - expect(comboboxInput).toHaveAttribute( - 'data-custom-attr', - 'custom-value' - ); - - const hiddenInput = view.container.querySelector( - 'input[type="hidden"][data-form-field="test-field"]' - ); - expect(hiddenInput).toHaveAttribute('data-form-field', 'test-field'); - expect(hiddenInput).toHaveAttribute('data-hidden-attr', 'hidden-value'); - }); - - it('should apply combobox and hidden props in searchable mode', () => { - const { view } = renderView({ - isSearchable: true, - inputProps: { - hidden: { - 'data-form-field': 'searchable-field', - 'data-hidden-attr': 'searchable-hidden-value', - }, - combobox: { - 'data-testid': 'searchable-combobox', - 'data-custom-attr': 'searchable-custom-value', - }, - }, - }); - - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute( - 'data-testid', - 'searchable-combobox' - ); - expect(comboboxInput).toHaveAttribute( - 'data-custom-attr', - 'searchable-custom-value' - ); - - const hiddenInput = view.container.querySelector( - 'input[type="hidden"][data-form-field="searchable-field"]' - ); - expect(hiddenInput).toHaveAttribute( - 'data-form-field', - 'searchable-field' - ); - expect(hiddenInput).toHaveAttribute( - 'data-hidden-attr', - 'searchable-hidden-value' - ); - }); - - it('should work with only combobox props (no hidden props)', () => { - const { view } = renderView({ - isSearchable: false, - inputProps: { - combobox: { - 'data-testid': 'combobox-only', - 'data-aria-label': 'Custom combobox label', - }, - }, - }); - - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute('data-testid', 'combobox-only'); - expect(comboboxInput).toHaveAttribute( - 'data-aria-label', - 'Custom combobox label' - ); - }); - - it('should work with only hidden props (no combobox props)', () => { - const { view } = renderView({ - isSearchable: true, - inputProps: { - hidden: { - 'data-form-field': 'hidden-only', - 'data-validation': 'required', - }, - }, - }); - - const comboboxInput = view.getByRole('combobox'); - // Should not have any combobox-specific attributes - expect(comboboxInput).not.toHaveAttribute('data-form-field'); - expect(comboboxInput).not.toHaveAttribute('data-validation'); - - const hiddenInput = view.container.querySelector( - 'input[type="hidden"][data-form-field="hidden-only"]' - ); - expect(hiddenInput).toHaveAttribute('data-form-field', 'hidden-only'); - expect(hiddenInput).toHaveAttribute('data-validation', 'required'); - }); - - it('should handle multiple data attributes in combobox props', () => { - const { view } = renderView({ - isSearchable: false, - inputProps: { - combobox: { - 'data-testid': 'multi-attr-test', - 'data-cy': 'combobox-element', - 'data-analytics': 'user-interaction', - 'data-tracking': 'form-field', - 'aria-describedby': 'help-text', - }, - }, - }); - - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute('data-testid', 'multi-attr-test'); - expect(comboboxInput).toHaveAttribute('data-cy', 'combobox-element'); - expect(comboboxInput).toHaveAttribute( - 'data-analytics', - 'user-interaction' - ); - expect(comboboxInput).toHaveAttribute('data-tracking', 'form-field'); - expect(comboboxInput).toHaveAttribute('aria-describedby', 'help-text'); - }); - - it('should handle boolean and number values in combobox props', () => { - const { view } = renderView({ - isSearchable: true, - inputProps: { - combobox: { - 'data-testid': 'type-test', - 'data-boolean-true': true, - 'data-boolean-false': false, - 'data-number': 42, - 'data-zero': 0, - }, - }, - }); - - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute('data-testid', 'type-test'); - expect(comboboxInput).toHaveAttribute('data-boolean-true', 'true'); - expect(comboboxInput).toHaveAttribute('data-boolean-false', 'false'); - expect(comboboxInput).toHaveAttribute('data-number', '42'); - expect(comboboxInput).toHaveAttribute('data-zero', '0'); - }); - }); - - describe('validationMessage', () => { - afterEach(() => { - jest.useRealTimers(); - }); - - it('renders custom node text in place of the default "No options" state', async () => { - jest.useFakeTimers(); - - const { view } = renderView({ - options: [], - validationMessage: 'No fruits available', - }); - - await openDropdown(view); - - act(() => { - jest.advanceTimersByTime(500); - }); - - // Also announced via a standalone live region for screen readers. - expect(view.getAllByText('No fruits available')).toHaveLength(2); - expect(view.queryByText('No options')).not.toBeInTheDocument(); - }); - - describe('screen reader announcement', () => { - it('announces the validation message via a standalone live region when there are no options', async () => { - jest.useFakeTimers(); - - const { view } = renderView({ - options: [], - validationMessage: 'Enter at least 3 characters.', - }); - - await openDropdown(view); - - act(() => { - jest.advanceTimersByTime(500); - }); - - // Only one status region should have announcement text - react-select's - // own internal live region stays silent when there are no options, so a - // duplicate, non-empty announcement here would mean it started firing too. - const statusRegions = view.getAllByRole('status'); - const announced = statusRegions.filter( - (region) => region.textContent === 'Enter at least 3 characters.' - ); - expect(announced).toHaveLength(1); - }); - - it('stays silent once options are present', async () => { - jest.useFakeTimers(); - - const { view } = renderView(); - - await openDropdown(view); - - act(() => { - jest.advanceTimersByTime(500); - }); - - expect(view.getByRole('status', { name: '' })).toBeInTheDocument(); - }); - - it('announces react-select\'s default "No options" text when no validationMessage is set', async () => { - jest.useFakeTimers(); - - const { view } = renderView({ options: [] }); - - await openDropdown(view); - view.getByText('No options'); - - act(() => { - jest.advanceTimersByTime(500); - }); - - const statusRegions = view.getAllByRole('status'); - const announced = statusRegions.filter( - (region) => region.textContent === 'No options' - ); - expect(announced).toHaveLength(1); - }); - - it('falls back to announcing the default "No options" text if validationMessage is removed', async () => { - jest.useFakeTimers(); - - const { view } = renderToggleValidationMessage(); - - await openDropdown(view); - - act(() => { - jest.advanceTimersByTime(500); - }); - - expect( - view - .getAllByRole('status') - .some( - (region) => - region.textContent === 'Enter at least 3 characters.' - ) - ).toBe(true); - - act(() => { - fireEvent.click( - view.getByRole('button', { name: 'disable custom message' }) - ); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - - expect( - view - .getAllByRole('status') - .some((region) => region.textContent === 'No options') - ).toBe(true); - }); - - it('announces the validation message when react-select filters a non-empty options list down to zero matches', async () => { - jest.useFakeTimers(); - - const { view } = renderView({ - isSearchable: true, - options: selectOptions, - validationMessage: 'Enter at least 3 characters.', - }); - - await openDropdown(view); - - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'zzz' }, - }); - }); - - act(() => { - jest.advanceTimersByTime(500); - }); - - const statusRegions = view.getAllByRole('status'); - const announced = statusRegions.filter( - (region) => region.textContent === 'Enter at least 3 characters.' - ); - expect(announced).toHaveLength(1); - }); - - it('switches the announcement when the validation reason changes, without leaving the field', async () => { - jest.useFakeTimers(); - - // Mirrors the "creatable-validated-dropdown" story: one reason for - // input that's too short, a different reason for input that's long - // enough but still matches nothing. - const validate = (inputValue: string) => - inputValue.trim().length < 3 - ? 'Enter at least 3 characters.' - : undefined; - - const { view } = renderView({ - isSearchable: true, - options: selectOptions, - validationMessage: ({ inputValue }: { inputValue: string }) => - validate(inputValue) ?? 'No matching fruit', - }); - - const getAnnounced = (text: string) => - view - .getAllByRole('status') - .filter((region) => region.textContent === text); - - await openDropdown(view); - - // Too short - first reason. - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'z' }, - }); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - expect(getAnnounced('Enter at least 3 characters.')).toHaveLength(1); - expect(getAnnounced('No matching fruit')).toHaveLength(0); - - // Long enough, but still zero matches - second, different reason. - // No unmount/remount happens here since NoOptionsMessage stays - // mounted throughout (options are zero both before and after); - // only its text changes. - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'zzz' }, - }); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - expect(getAnnounced('Enter at least 3 characters.')).toHaveLength(0); - expect(getAnnounced('No matching fruit')).toHaveLength(1); - }); - - it('re-announces the same message after the field is left and revisited', async () => { - jest.useFakeTimers(); - - const { view } = renderView({ - isSearchable: true, - options: selectOptions, - validationMessage: 'Enter at least 3 characters.', - }); - - const getAnnouncedStatusRegions = () => - view - .getAllByRole('status') - .filter( - (region) => - region.textContent === 'Enter at least 3 characters.' - ); - - await openDropdown(view); - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'zz' }, - }); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - - expect(getAnnouncedStatusRegions()).toHaveLength(1); - - // Leaving the field clears the announcement immediately (not - // debounced), so a later identical announcement still produces a - // real DOM mutation for AT live-region behavior to pick up on. - act(() => { - fireEvent.focusOut(view.getByRole('combobox')); - }); - - expect(getAnnouncedStatusRegions()).toHaveLength(0); - - await openDropdown(view); - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'zz' }, - }); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - - expect(getAnnouncedStatusRegions()).toHaveLength(1); - }); - - it('clears the announcement as soon as the menu closes, even when validationMessage keeps producing the same text', async () => { - jest.useFakeTimers(); - - const { view } = renderView({ - options: [], - validationMessage: 'No fruits available', - }); - - const getAnnouncedStatusRegions = () => - view - .getAllByRole('status') - .filter((region) => region.textContent === 'No fruits available'); - - await openDropdown(view); - act(() => { - jest.advanceTimersByTime(500); - }); - expect(getAnnouncedStatusRegions()).toHaveLength(1); - - // Escape closes the menu without changing the options list, so - // `validationMessage` would still resolve to the same text - - // clearing must be driven by the menu closing, not by the message - // text changing. - act(() => { - fireEvent.keyDown(view.getByRole('combobox'), { key: 'Escape' }); - }); - - expect(getAnnouncedStatusRegions()).toHaveLength(0); - }); - - it('still calls a consumer-supplied onMenuClose when clearing the announcement', async () => { - jest.useFakeTimers(); - const onMenuClose = jest.fn(); - - const { view } = renderView({ - isSearchable: true, - options: selectOptions, - validationMessage: 'Enter at least 3 characters.', - onMenuClose, - }); - - const getAnnouncedStatusRegions = () => - view - .getAllByRole('status') - .filter( - (region) => - region.textContent === 'Enter at least 3 characters.' - ); - - await openDropdown(view); - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'zz' }, - }); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - expect(getAnnouncedStatusRegions()).toHaveLength(1); - - act(() => { - fireEvent.keyDown(view.getByRole('combobox'), { key: 'Escape' }); - }); - - expect(getAnnouncedStatusRegions()).toHaveLength(0); - expect(onMenuClose).toHaveBeenCalledTimes(1); - }); - - it('keeps announcing from the same stable live-region node across multiple announcements', async () => { - jest.useFakeTimers(); - - const { view } = renderView({ - isSearchable: true, - options: ['Apple', 'Banana', 'Cherry'], - validationMessage: ({ inputValue }: { inputValue: string }) => - inputValue ? `${inputValue} is a fruit` : 'No matching fruit', - }); - - const getAnnouncedStatusRegions = (text: string) => - view - .getAllByRole('status') - .filter((region) => region.textContent === text); - - await openDropdown(view); - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'd' }, - }); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - const [firstAnnouncement] = getAnnouncedStatusRegions('d is a fruit'); - expect(firstAnnouncement).toBeInTheDocument(); - // VoiceOver/Safari only track a live region it discovered on its - // first mount - swapping in a fresh DOM node for later - // announcements makes it stop hearing them, so the node itself - // must stay the same object across announcements. - const liveRegionNode = view.getByRole('status'); - - act(() => { - fireEvent.focusOut(view.getByRole('combobox')); - }); - - await openDropdown(view); - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'z' }, - }); - }); - act(() => { - jest.advanceTimersByTime(500); - }); - - const [secondAnnouncement] = - getAnnouncedStatusRegions('z is a fruit'); - expect(secondAnnouncement).toBeInTheDocument(); - expect(secondAnnouncement).toBe(liveRegionNode); - }); - }); - }); - }); - - describe('isCreatable', () => { - it('shows the "Add" row when input does not match any existing option', async () => { - const { view } = renderView({ isCreatable: true }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'purple'); - }); - - expect(view.getByText('Add "purple"')).toBeInTheDocument(); - }); - - it('does not show the "Add" row when input matches an existing option', async () => { - const { view } = renderView({ isCreatable: true }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'red'); - }); - - expect(view.queryByText('Add "red"')).not.toBeInTheDocument(); - }); - - it('fires onCreateOption with the typed value when the "Add" row is selected', async () => { - const onCreateOption = jest.fn(); - const { view } = renderView({ isCreatable: true, onCreateOption }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'purple'); - }); - - await act(async () => { - await userEvent.click(view.getByText('Add "purple"')); - }); - - expect(onCreateOption).toHaveBeenCalledWith('purple'); - }); - - it('fires onChange with create-option when the "Add" row is selected in multi mode', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - isCreatable: true, - multiple: true, - onChange, - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('red')); - }); - - const combobox = view.getByRole('combobox'); - - await act(async () => { - await userEvent.type(combobox, 'purple'); - }); - - await act(async () => { - await userEvent.click(view.getByText('Add "purple"')); - }); - - expect(onChange).toHaveBeenLastCalledWith( - [ - { label: 'red', value: 'red' }, - { label: 'purple', value: 'purple', __isNew__: true }, - ], - expect.objectContaining({ - action: 'create-option', - option: expect.objectContaining({ value: 'purple' }), - }) - ); - }); - - it('respects a custom formatCreateLabel', async () => { - const { view } = renderView({ - isCreatable: true, - formatCreateLabel: (v: string) => `Create tag: "${v}"`, - }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'purple'); - }); - - expect(view.getByText('Create tag: "purple"')).toBeInTheDocument(); - }); - - it('hides the "Add" row when isValidNewOption returns false', async () => { - const { view } = renderView({ - isCreatable: true, - isValidNewOption: () => false, - }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'anything'); - }); - - expect(view.queryByText('Add "anything"')).not.toBeInTheDocument(); - }); - - it('places the "Add" row first when createOptionPosition is "first"', async () => { - const { view } = renderView({ - isCreatable: true, - createOptionPosition: 'first', - }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'e'); - }); - - const optionLabels = view - .getAllByRole('option') - .map((o) => o.textContent); - - expect(optionLabels[0]).toBe('Add "e"'); - }); - - it('places the "Add" row last by default', async () => { - const { view } = renderView({ isCreatable: true }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'e'); - }); - - const optionLabels = view - .getAllByRole('option') - .map((o) => o.textContent); - - expect(optionLabels[optionLabels.length - 1]).toBe('Add "e"'); - }); - - it('clears the typed text when the input blurs', async () => { - const { view } = renderView({ isCreatable: true }); - - const combobox = view.getByRole('combobox'); - - await act(async () => { - await userEvent.type(combobox, 'pur'); - }); - - await act(async () => { - fireEvent.focusOut(combobox); - }); - - expect(combobox).toHaveValue(''); - }); - - it('forwards onInputChange to the consumer when the input blurs', async () => { - const onInputChange = jest.fn(); - const { view } = renderView({ isCreatable: true, onInputChange }); - - const combobox = view.getByRole('combobox'); - - await act(async () => { - await userEvent.type(combobox, 'pur'); - }); - - onInputChange.mockClear(); - - await act(async () => { - fireEvent.focusOut(combobox); - }); - - expect(onInputChange).toHaveBeenCalledWith('', { - action: 'input-blur', - prevInputValue: 'pur', - }); - }); - - it('clears the typed text after an option is created', async () => { - const { view } = renderView({ isCreatable: true }); - - const combobox = view.getByRole('combobox'); - - await act(async () => { - await userEvent.type(combobox, 'purple'); - }); - - await act(async () => { - await userEvent.click(view.getByText('Add "purple"')); - }); - - expect(combobox).toHaveValue(''); - }); - - it('keeps existing multi selections when a new option is created', async () => { - const { view } = renderCreatableMulti(); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('Apple')); - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('Banana')); - }); - - const combobox = view.getByRole('combobox'); - - await act(async () => { - await userEvent.type(combobox, 'Cherry'); - }); - - await act(async () => { - await userEvent.click(view.getByText('Add "Cherry"')); - }); - - expect(view.getByText('Apple')).toBeInTheDocument(); - expect(view.getByText('Banana')).toBeInTheDocument(); - expect(view.getByText('Cherry')).toBeInTheDocument(); - }); - - it('keeps controlled multi selections when a new option is created', async () => { - const onChange = jest.fn(); - const { view } = renderControlledCreatableMulti({ onChange }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('Banana')); - }); - - const combobox = view.getByRole('combobox'); - - await act(async () => { - await userEvent.type(combobox, 'Cherry'); - }); - - await act(async () => { - await userEvent.click(view.getByText('Add "Cherry"')); - }); - - expect(view.getByText('Apple')).toBeInTheDocument(); - expect(view.getByText('Banana')).toBeInTheDocument(); - expect(view.getByText('Cherry')).toBeInTheDocument(); - - expect(onChange).toHaveBeenLastCalledWith( - expect.arrayContaining([ - expect.objectContaining({ value: 'Apple' }), - expect.objectContaining({ value: 'Banana' }), - expect.objectContaining({ value: 'Cherry' }), - ]), - expect.objectContaining({ action: 'create-option' }) - ); - }); - - describe('validationMessage', () => { - it('supports a function that receives the current input value', async () => { - const { view } = renderView({ - isCreatable: true, - isValidNewOption: () => false, - options: [], - validationMessage: ({ inputValue }: { inputValue: string }) => - `No match for "${inputValue}"`, - }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'kiwi'); - }); - - expect(view.getByText('No match for "kiwi"')).toBeInTheDocument(); - }); - }); - }); - - describe('Translations', () => { - it('renders the default placeholder when translations is omitted', () => { - const { view } = renderView(); - - view.getByText(DEFAULT_SELECT_DROPDOWN_TRANSLATIONS.placeholder); - }); - - it('renders a custom placeholder from translations', () => { - const placeholder = 'Elige una opción'; - const { view } = renderView({ - translations: { placeholder }, - }); - - view.getByText(placeholder); - }); - - it('renders the default "No options" text when translations is omitted', async () => { - const { view } = renderView({ options: [] }); - - await openDropdown(view); - - view.getByText( - DEFAULT_SELECT_DROPDOWN_TRANSLATIONS.validationMessage as string - ); - }); - - it('renders a custom validationMessage from translations', async () => { - const validationMessage = 'Sin opciones'; - const { view } = renderView({ - options: [], - translations: { validationMessage }, - }); - - await openDropdown(view); - - view.getByText(validationMessage); - expect(view.queryByText('No options')).toBeNull(); - }); - - it('supports a function validationMessage that receives the current input', async () => { - const validationMessage = ({ inputValue }: { inputValue: string }) => - `Sin resultados para "${inputValue}"`; - const { view } = renderView({ - isSearchable: true, - options: selectOptions, - translations: { - validationMessage, - }, - }); - - await openDropdown(view); - act(() => { - fireEvent.change(view.getByRole('combobox'), { - target: { value: 'zzz' }, - }); - }); - - view.getByText(validationMessage({ inputValue: 'zzz' })); - }); - - it('renders a custom creatable label from translations', async () => { - const formatCreateLabel = (v: string) => `Crear "${v}"`; - const { view } = renderView({ - isCreatable: true, - translations: { formatCreateLabel }, - }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'purple'); - }); - - view.getByText(formatCreateLabel('purple')); - }); - - it('uses default remove/clear-all aria-labels when translations is omitted', async () => { - const { view } = renderView({ multiple: true }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('red')); - }); - - view.getByLabelText( - DEFAULT_SELECT_DROPDOWN_TRANSLATIONS.removeOptionLabel('red') - ); - // react-select forces aria-hidden onto the clear-indicator, so query the - // clear-all button by its aria-label attribute rather than by role. - view.getByLabelText(DEFAULT_SELECT_DROPDOWN_TRANSLATIONS.removeAllLabel); - }); - - it('applies custom remove/clear-all aria-labels from translations', async () => { - const removeOptionLabel = (label: string) => `Quitar ${label}`; - const removeAllLabel = 'Quitar todo'; - const { view } = renderView({ - multiple: true, - translations: { - removeOptionLabel, - removeAllLabel, - }, - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('red')); - }); - - view.getByLabelText(removeOptionLabel('red')); - view.getByLabelText(removeAllLabel); - }); - - it('merges partial translations with defaults', async () => { - const placeholder = 'Elige'; - const { view } = renderView({ - options: [], - translations: { placeholder }, - }); - - // Overridden key. - view.getByText(placeholder); - - // Untouched key still uses the English default. - await openDropdown(view); - view.getByText( - DEFAULT_SELECT_DROPDOWN_TRANSLATIONS.validationMessage as string - ); - }); - - it('lets an explicit placeholder prop win over the translations value', () => { - const placeholder = 'Explicit placeholder'; - const translatedPlaceholder = 'Translated placeholder'; - const { view } = renderView({ - placeholder, - translations: { placeholder: translatedPlaceholder }, - }); - - view.getByText(placeholder); - expect(view.queryByText(translatedPlaceholder)).toBeNull(); - }); - - it('lets an explicit formatCreateLabel prop win over the translations value', async () => { - const formatCreateLabel = (v: string) => `Prop "${v}"`; - const translatedFormatCreateLabel = (v: string) => `Translated "${v}"`; - const { view } = renderView({ - isCreatable: true, - formatCreateLabel, - translations: { - formatCreateLabel: translatedFormatCreateLabel, - }, - }); - - await act(async () => { - await userEvent.type(view.getByRole('combobox'), 'purple'); - }); - - view.getByText(formatCreateLabel('purple')); - expect( - view.queryByText(translatedFormatCreateLabel('purple')) - ).toBeNull(); - }); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/SelectDropdownEmpty.test.tsx b/packages/gamut/src/Form/__tests__/SelectDropdownEmpty.test.tsx deleted file mode 100644 index 76d7e998b5..0000000000 --- a/packages/gamut/src/Form/__tests__/SelectDropdownEmpty.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; - -import { SelectDropdown } from '../SelectDropdown'; - -const renderView = setupRtl(SelectDropdown, { - options: [], - name: 'colors', -}); - -/** There is a state pollution issue with SelectDropdown and jest which is why these are broken up into their own file. - * Ticket to fix: https://skillsoftdev.atlassian.net/browse/GM-1297 - */ - -describe('SelectDropdown edge cases', () => { - it('handles empty options gracefully', () => { - const { view } = renderView({ - value: 'any', - }); - - // Should not crash and should show placeholder - view.getByText('Select an option'); - }); - - it('handles undefined options gracefully', () => { - const { view } = renderView({ - options: undefined, - value: 'any', - }); - - // Should not crash and should show placeholder - view.getByText('Select an option'); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/SelectDropdownGrouped.test.tsx b/packages/gamut/src/Form/__tests__/SelectDropdownGrouped.test.tsx deleted file mode 100644 index 55c51cf1ac..0000000000 --- a/packages/gamut/src/Form/__tests__/SelectDropdownGrouped.test.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import { setupRtl } from '@skillsoft/gamut-tests'; -import userEvent from '@testing-library/user-event'; -import { act } from 'react'; - -import { - groupedOptions, - groupedOptionsWithIcons, - groupedOptionsWithTitles, - openDropdown, -} from '../__fixtures__/utils'; -import { SelectDropdown } from '../SelectDropdown'; - -/** There is a state pollution issue with SelectDropdown and jest which is why these are broken up into their own file. - * Ticket to fix: https://skillsoftdev.atlassian.net/browse/GM-1297 - */ - -jest.mock('@skillsoft/gamut-icons', () => ({ - ...jest.requireActual<{}>('@skillsoft/gamut-icons'), - MiniChevronDownIcon: () => ( - - Mini Chevron Down Icon - - ), - ArrowChevronDownIcon: () => ( - - Arrow Chevron Down Icon - - ), - DataTransferVerticalIcon: () => ( - - Data Transfer Vertical Icon - - ), - CalendarIcon: () => ( - - Calendar Icon - - ), - EarthIcon: () => ( - - Earth Icon - - ), -})); - -const renderView = setupRtl(SelectDropdown, { - options: groupedOptions, - name: 'colors', -}); - -describe('SelectDropdown Grouped Options', () => { - it('renders grouped options with group labels', async () => { - const { view } = renderView({ options: groupedOptions }); - - await openDropdown(view); - - view.getByText('Fruits'); - view.getByText('Vegetables'); - - // Check that options within groups are rendered - const fruitOptions = ['Apple', 'Banana', 'Orange']; - const vegetableOptions = ['Carrot', 'Broccoli', 'Spinach']; - - [...fruitOptions, ...vegetableOptions].forEach((option) => { - view.getByText(option); - }); - }); - - it('allows selection of options from grouped options', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - options: groupedOptions, - onChange, - }); - - await openDropdown(view); - - await act(async () => { - await userEvent.click(view.getByText('Apple')); - }); - - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ - label: 'Apple', - value: 'apple', - }), - expect.objectContaining({ - action: 'select-option', - option: { label: 'Apple', value: 'apple' }, - }) - ); - }); - - it('allows selection of options from different groups', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - options: groupedOptions, - onChange, - }); - - await openDropdown(view); - - await act(async () => { - await userEvent.click(view.getByText('Carrot')); - }); - - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ - label: 'Carrot', - value: 'carrot', - }), - expect.objectContaining({ - action: 'select-option', - option: { label: 'Carrot', value: 'carrot' }, - }) - ); - }); - - it('displays the selected value correctly when value is set', () => { - const { view } = renderView({ - options: groupedOptions, - value: 'banana', - }); - - view.getByText('Banana'); - }); - - it('renders grouped options with icons', async () => { - const { view } = renderView({ options: groupedOptionsWithIcons }); - - await openDropdown(view); - - view.getByText('Data Icons'); - view.getByText('Navigation Icons'); - - const iconOptions = [ - 'Data Transfer Vertical Icon', - 'Calendar Icon', - 'Earth Icon', - ]; - - iconOptions.forEach((icon) => { - expect(view.getAllByText(icon)).toHaveLength(2); // title and span - }); - }); - - it('allows selection of options with icons from grouped options', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - options: groupedOptionsWithIcons, - onChange, - }); - - await openDropdown(view); - - await act(async () => { - await userEvent.click( - view.getByRole('option', { - name: /Data Transfer Vertical Icon/, - }) - ); - }); - - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ - label: 'Data Transfer Vertical Icon', - value: 'data-transfer', - icon: expect.any(Function), - }), - expect.objectContaining({ - action: 'select-option', - option: { - label: 'Data Transfer Vertical Icon', - value: 'data-transfer', - icon: expect.any(Function), - }, - }) - ); - }); - - it('renders grouped options with subtitles', async () => { - const { view } = renderView({ options: groupedOptionsWithTitles }); - - await openDropdown(view); - - view.getByText('Fruits'); - view.getByText('Vegetables'); - - const fruitOptionsWithSubtitles = [ - { option: 'Apple', subtitle: 'Red and crunchy' }, - { option: 'Banana', subtitle: 'Yellow and sweet' }, - ]; - const vegetableOptionsWithSubtitles = [ - { option: 'Carrot', subtitle: 'Orange and healthy' }, - ]; - - [...fruitOptionsWithSubtitles, ...vegetableOptionsWithSubtitles].forEach( - ({ option, subtitle }) => { - view.getByText(option); - view.getByText(subtitle); - } - ); - }); - - it('allows selection of options with subtitles from grouped options', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - options: groupedOptionsWithTitles, - onChange, - }); - - await openDropdown(view); - - await act(async () => { - await userEvent.click(view.getByText('Apple')); - }); - - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ - label: 'Apple', - value: 'apple', - subtitle: 'Red and crunchy', - }), - expect.objectContaining({ - action: 'select-option', - option: { label: 'Apple', value: 'apple', subtitle: 'Red and crunchy' }, - }) - ); - }); - - it('works with multiple selection for grouped options', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - options: groupedOptions, - multiple: true, - onChange, - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('Apple')); - }); - - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('Carrot')); - }); - - view.getByText('Apple'); - view.getByText('Carrot'); - - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenNthCalledWith( - 1, - [ - { - label: 'Apple', - value: 'apple', - }, - ], - { - action: 'select-option', - } - ); - expect(onChange).toHaveBeenNthCalledWith( - 2, - [ - { - label: 'Apple', - value: 'apple', - }, - { - label: 'Carrot', - value: 'carrot', - }, - ], - { - action: 'select-option', - } - ); - }); - - it('handles disabled options in grouped options', async () => { - const groupedOptionsWithDisabled = [ - { - label: 'Fruits', - options: [ - { label: 'Apple', value: 'apple' }, - { label: 'Banana', value: 'banana', disabled: true }, - { label: 'Orange', value: 'orange' }, - ], - }, - ]; - - const { view } = renderView({ options: groupedOptionsWithDisabled }); - - await openDropdown(view); - - const disabledOption = view.getByText('Banana'); - expect(disabledOption.closest('[role="option"]')).toHaveAttribute( - 'aria-disabled', - 'true' - ); - }); -}); diff --git a/packages/gamut/src/Form/__tests__/testUtils.ts b/packages/gamut/src/Form/__tests__/testUtils.ts deleted file mode 100644 index 1cefd00096..0000000000 --- a/packages/gamut/src/Form/__tests__/testUtils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { RenderResult } from '@testing-library/react'; - -/** - * Creates and appends an external label element to the test container. - * This is useful for testing ariaLabelledby functionality where an InfoTip - * references an external label element. - * - * @param view - The render result from testing-library - * @param id - The ID of the external label element (used in ariaLabelledby) - * @param text - The text content of the external label - * @returns The created span element - * - * @example - * ```ts - * const { view } = renderView({ - * infotip: { info, ariaLabelledby: 'external-label-id' }, - * }); - * - * createExternalLabel(view, 'external-label-id', 'External Label'); - * view.getByRole('button', { name: 'External Label' }); - * ``` - */ -export const createExternalLabel = ( - view: RenderResult, - id: string, - text: string -): HTMLSpanElement => { - const externalLabel = document.createElement('span'); - externalLabel.id = id; - externalLabel.textContent = text; - view.container.appendChild(externalLabel); - return externalLabel; -}; diff --git a/packages/styleguide/src/lib/Atoms/FormElements/Form/Form.stories.tsx b/packages/gamut/src/Form/elements/Form.stories.tsx similarity index 65% rename from packages/styleguide/src/lib/Atoms/FormElements/Form/Form.stories.tsx rename to packages/gamut/src/Form/elements/Form.stories.tsx index e1efbe2e74..1ceebb0e93 100644 --- a/packages/styleguide/src/lib/Atoms/FormElements/Form/Form.stories.tsx +++ b/packages/gamut/src/Form/elements/Form.stories.tsx @@ -1,7 +1,9 @@ -import { Form } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { Form } from './Form'; + const meta: Meta = { + title: 'Inputs & forms/Form/Form', component: Form, args: {}, }; @@ -13,4 +15,7 @@ export const Default: Story = { args: { children: 'This is an empty form', }, + play: ({ canvas }) => { + canvas.getByText('This is an empty form'); + }, }; diff --git a/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.stories.tsx b/packages/gamut/src/Form/elements/FormGroup.stories.tsx similarity index 71% rename from packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.stories.tsx rename to packages/gamut/src/Form/elements/FormGroup.stories.tsx index 4e32c16443..60b4b70c92 100644 --- a/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.stories.tsx +++ b/packages/gamut/src/Form/elements/FormGroup.stories.tsx @@ -1,10 +1,15 @@ -import { Box, FormGroup, Input } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from 'storybook/test'; import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; -import { infotipNestedArgTypes } from '~styleguide/argTypes'; +import { infotipNestedArgTypes } from '~storybook/argTypes'; + +import { Box } from '../../Box'; +import { Input } from '../inputs/Input'; +import { FormGroup } from './FormGroup'; const meta: TypeWithDeepControls> = { + title: 'Inputs & forms/Form/FormGroup', component: FormGroup, argTypes: { ...infotipNestedArgTypes }, }; @@ -17,6 +22,9 @@ export const Default: Story = { label: "Don't you label me!", description: 'This is a form group, it is pretty darn cool', }, + play: ({ canvas }) => { + canvas.getByText('This is a form group, it is pretty darn cool'); + }, }; export const FormGroupHtmlFor: Story = { @@ -25,6 +33,9 @@ export const FormGroupHtmlFor: Story = { ), + play: ({ canvas }) => { + canvas.getByLabelText('I am a label!! (optional)'); + }, }; export const FormGroupDiv: Story = { @@ -36,6 +47,10 @@ export const FormGroupDiv: Story = { ), + play: async ({ canvas, canvasElement }) => { + await expect(canvasElement.querySelector('label')).toBeNull(); + canvas.getByText(/I look like a label, but I'm actually a div/); + }, }; export const DefaultState: Story = { @@ -46,9 +61,26 @@ export const DefaultState: Story = { htmlFor: 'required1', children: , }, + play: ({ canvas }) => { + canvas.getByLabelText('I am required!!*'); + }, }; -export const Error: Story = { +export const SoloField: Story = { + args: { + label: 'I am a solo field', + required: true, + isSoloField: true, + htmlFor: 'solo-field', + children: , + }, + play: ({ canvas }) => { + canvas.getByLabelText('I am a solo field'); + }, +}; + +export const ErrorState: Story = { + name: 'Error', args: { required: true, label: 'I am also required!!', @@ -57,6 +89,11 @@ export const Error: Story = { htmlFor: 'required-error', children: , }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('alert')).toHaveTextContent( + 'You messed up dude.' + ); + }, }; export const Valid: Story = { @@ -130,4 +167,9 @@ export const InfoTipAutoLabelling: Story = { ), + play: ({ canvas }) => { + canvas.getByRole('button', { name: /Auto-labelling/ }); + canvas.getByRole('button', { name: 'Email privacy information' }); + canvas.getByRole('button', { name: 'Custom label for InfoTip button' }); + }, }; diff --git a/packages/styleguide/src/lib/Atoms/FormElements/FormGroupDescription/FormGroupDescription.stories.tsx b/packages/gamut/src/Form/elements/FormGroupDescription.stories.tsx similarity index 72% rename from packages/styleguide/src/lib/Atoms/FormElements/FormGroupDescription/FormGroupDescription.stories.tsx rename to packages/gamut/src/Form/elements/FormGroupDescription.stories.tsx index 8cf25cc4c9..94f3c5ea96 100644 --- a/packages/styleguide/src/lib/Atoms/FormElements/FormGroupDescription/FormGroupDescription.stories.tsx +++ b/packages/gamut/src/Form/elements/FormGroupDescription.stories.tsx @@ -1,7 +1,9 @@ -import { FormGroupDescription } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { FormGroupDescription } from './FormGroupDescription'; + const meta: Meta = { + title: 'Inputs & forms/Form/FormGroupDescription', component: FormGroupDescription, args: {}, }; diff --git a/packages/styleguide/src/lib/Atoms/FormElements/FormGroupLabel/FormGroupLabel.stories.tsx b/packages/gamut/src/Form/elements/FormGroupLabel.stories.tsx similarity index 85% rename from packages/styleguide/src/lib/Atoms/FormElements/FormGroupLabel/FormGroupLabel.stories.tsx rename to packages/gamut/src/Form/elements/FormGroupLabel.stories.tsx index 8d9542415a..dbb8386a58 100644 --- a/packages/styleguide/src/lib/Atoms/FormElements/FormGroupLabel/FormGroupLabel.stories.tsx +++ b/packages/gamut/src/Form/elements/FormGroupLabel.stories.tsx @@ -1,10 +1,12 @@ -import { FormGroupLabel } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; -import { infotipNestedArgTypes } from '~styleguide/argTypes'; +import { infotipNestedArgTypes } from '~storybook/argTypes'; + +import { FormGroupLabel } from './FormGroupLabel'; const meta: TypeWithDeepControls> = { + title: 'Inputs & forms/Form/FormGroupLabel', component: FormGroupLabel, args: { children: 'I am a label' }, argTypes: { diff --git a/packages/styleguide/src/lib/Atoms/FormElements/FormRequiredText/FormRequiredText.stories.tsx b/packages/gamut/src/Form/elements/FormRequiredText.stories.tsx similarity index 71% rename from packages/styleguide/src/lib/Atoms/FormElements/FormRequiredText/FormRequiredText.stories.tsx rename to packages/gamut/src/Form/elements/FormRequiredText.stories.tsx index 3f0e37db88..17428bad1e 100644 --- a/packages/styleguide/src/lib/Atoms/FormElements/FormRequiredText/FormRequiredText.stories.tsx +++ b/packages/gamut/src/Form/elements/FormRequiredText.stories.tsx @@ -1,7 +1,9 @@ -import { FormRequiredText } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { FormRequiredText } from './FormRequiredText'; + const meta: Meta = { + title: 'Inputs & forms/Form/FormRequiredText', component: FormRequiredText, args: {}, }; diff --git a/packages/gamut/src/Form/index.tsx b/packages/gamut/src/Form/index.tsx index 7301e9103c..a963a33b92 100644 --- a/packages/gamut/src/Form/index.tsx +++ b/packages/gamut/src/Form/index.tsx @@ -5,13 +5,11 @@ export * from './elements/FormGroupDescription'; export * from './elements/FormRequiredText'; export * from './inputs/Checkbox'; export * from './inputs/Select'; -export * from './SelectDropdown'; +export * from './inputs/SelectDropdown'; export * from './inputs/Input'; export * from './elements/Form'; export * from './inputs/TextArea'; export * from './inputs/Radio'; -export * from './inputs/RadioGroup'; -export * from './inputs/types'; export * from './types'; /* For custom inputs that should look like Input, such as third-party hosted diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/Checkbox/Checkbox.stories.tsx b/packages/gamut/src/Form/inputs/Checkbox/Checkbox.stories.tsx similarity index 62% rename from packages/styleguide/src/lib/Atoms/FormInputs/Checkbox/Checkbox.stories.tsx rename to packages/gamut/src/Form/inputs/Checkbox/Checkbox.stories.tsx index 5f864b9073..8e2a34f20e 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/Checkbox/Checkbox.stories.tsx +++ b/packages/gamut/src/Form/inputs/Checkbox/Checkbox.stories.tsx @@ -1,22 +1,21 @@ -import type { CheckboxProps } from '@skillsoft/gamut'; -import { - Anchor, - Box, - Checkbox, - Column, - FlexBox, - LayoutGrid, - Text, -} from '@skillsoft/gamut'; import { MiniStarIcon } from '@skillsoft/gamut-icons'; import { Background } from '@skillsoft/gamut-styles'; import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; +import { expect, fn, screen, waitFor } from 'storybook/test'; import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; -import { infotipNestedArgTypes } from '~styleguide/argTypes'; +import { infotipNestedArgTypes } from '~storybook/argTypes'; + +import { Anchor } from '../../../Anchor'; +import { Box, FlexBox } from '../../../Box'; +import { Column, LayoutGrid } from '../../../Layout'; +import { Text } from '../../../Typography'; +import type { CheckboxProps } from './Checkbox'; +import { Checkbox } from './Checkbox'; const meta: TypeWithDeepControls> = { + title: 'Inputs & forms/Checkbox', component: Checkbox, args: { htmlFor: 'example-checkbox', @@ -44,6 +43,11 @@ export const Unchecked: Story = { label: 'unchecked', name: 'example-unchecked', checked: false, + indeterminate: false, + }, + play: async ({ canvas }) => { + const checkbox = canvas.getByRole('checkbox', { checked: false }); + await expect(checkbox).toHaveProperty('indeterminate', false); }, }; @@ -54,6 +58,9 @@ export const Checked: Story = { name: 'example-checked', checked: true, }, + play: ({ canvas }) => { + canvas.getByRole('checkbox', { checked: true }); + }, }; export const Indeterminate: Story = { @@ -61,8 +68,32 @@ export const Indeterminate: Story = { htmlFor: 'indeterminate', label: 'indeterminate', name: 'example-indeterminate', + checked: false, indeterminate: true, }, + play: async ({ canvas }) => { + const checkbox = canvas.getByRole('checkbox'); + await expect(checkbox).toHaveProperty('indeterminate', true); + }, +}; + +export const CheckedOverridesIndeterminate: Story = { + render: () => ( + + ), + play: async ({ canvas }) => { + const checkbox = canvas.getByRole('checkbox', { checked: true }); + await expect(checkbox).toHaveProperty('indeterminate', false); + }, }; export const Disabled: Story = { @@ -97,9 +128,6 @@ export const Multiline: Story = { }; export const SpacingTight: Story = { - args: { - spacing: 'tight', - }, render: () => ( Tight Spacing @@ -127,6 +155,11 @@ export const SpacingTight: Story = { export const LabelsAsReactNodes: Story = { args: {}, + play: ({ canvas }) => { + canvas.getByText('a string'); + canvas.getByRole('checkbox', { name: 'a node' }); + canvas.getByRole('link', { name: 'click' }); + }, render: () => ( Labels as React Nodes @@ -184,6 +217,71 @@ export const InfoTip: Story = { }, checked: false, }, + play: async ({ canvas, userEvent }) => { + const info = + 'This is an infotip that provides additional information about the checkbox.'; + const trigger = canvas.getByRole('button', { + name: 'Checkbox with InfoTip', + }); + + await expect(screen.queryByText(info)).toBeNull(); + await userEvent.click(trigger); + await waitFor(() => expect(screen.getByText(info)).toBeVisible()); + }, +}; + +export const InfoTipLabelling: Story = { + render: () => ( + + + Auto Checkbox Label} + name="checkbox-auto" + /> + + + + + + + External Checkbox Label + + + + + ), + play: async ({ canvas }) => { + canvas.getByRole('button', { name: 'Auto Checkbox Label' }); + canvas.getByRole('button', { name: 'Custom checkbox aria label' }); + await expect( + canvas.queryByRole('button', { name: 'With ariaLabel' }) + ).toBeNull(); + canvas.getByRole('button', { name: 'External Checkbox Label' }); + await expect( + canvas.queryByRole('button', { name: 'With ariaLabelledBy' }) + ).toBeNull(); + }, }; type CustomCheckboxProps = Omit< @@ -194,17 +292,19 @@ type CustomCheckboxProps = Omit< label: string; }; -export const CustomCheckbox: React.FC = ({ +const ControllableCheckbox: React.FC = ({ htmlFor, label, defaultChecked = false, disabled, + onChange, ...rest }) => { const [currentChecked, setCurrentChecked] = useState(defaultChecked); - const changeHandler = () => { + const changeHandler = (event: React.ChangeEvent) => { setCurrentChecked(!currentChecked); + onChange?.(event); }; return ( @@ -219,19 +319,41 @@ export const CustomCheckbox: React.FC = ({ ); }; -export const ControlledCheckbox: React.FC = () => { - return ( +export const CustomCheckbox: Story = { + args: { + onChange: fn(), + }, + render: (args) => ( + + ), + play: async ({ args, canvas, userEvent }) => { + const checkbox = canvas.getByRole('checkbox'); + await expect(checkbox).not.toBeChecked(); + + await userEvent.click(checkbox); + await expect(checkbox).toBeChecked(); + await expect(args.onChange).toHaveBeenCalled(); + }, +}; + +export const ControlledCheckbox: Story = { + render: () => ( - - { - @@ -248,7 +370,7 @@ export const ControlledCheckbox: React.FC = () => { - ); + ), }; export const NestedCheckboxes: Story = { diff --git a/packages/gamut/src/Form/inputs/Checkbox.tsx b/packages/gamut/src/Form/inputs/Checkbox/Checkbox.tsx similarity index 95% rename from packages/gamut/src/Form/inputs/Checkbox.tsx rename to packages/gamut/src/Form/inputs/Checkbox/Checkbox.tsx index 85def60b7e..5a44c19759 100644 --- a/packages/gamut/src/Form/inputs/Checkbox.tsx +++ b/packages/gamut/src/Form/inputs/Checkbox/Checkbox.tsx @@ -9,10 +9,12 @@ import type { StyleProps } from '@skillsoft/variance'; import type { InputHTMLAttributes } from 'react'; import { forwardRef, useEffect, useRef } from 'react'; -import { FlexBox } from '../../Box'; -import { InfoTip } from '../../Tip/InfoTip'; -import type { InfoTipSubComponentProps } from '../../Tip/InfoTip/type-utils'; -import { useInfotipProps } from '../../Tip/InfoTip/type-utils'; +import { FlexBox } from '../../../Box'; +import { InfoTip } from '../../../Tip/InfoTip'; +import type { InfoTipSubComponentProps } from '../../../Tip/InfoTip/type-utils'; +import { useInfotipProps } from '../../../Tip/InfoTip/type-utils'; +import { InputWrapper } from '../../styles'; +import type { BaseInputProps } from '../../types'; import { checkboxElement, checkboxElementStates, @@ -21,10 +23,8 @@ import { checkboxLabelStates, checkboxPadding, checkboxTextStates, - InputWrapper, polyline, -} from '../styles'; -import type { BaseInputProps } from '../types'; +} from './styles'; import type { CheckboxCheckedUnion, CheckboxLabelUnion } from './types'; export type CheckboxTextProps = StyleProps; diff --git a/packages/gamut/src/Form/inputs/Checkbox/index.tsx b/packages/gamut/src/Form/inputs/Checkbox/index.tsx new file mode 100644 index 0000000000..7dc6807c72 --- /dev/null +++ b/packages/gamut/src/Form/inputs/Checkbox/index.tsx @@ -0,0 +1,2 @@ +export * from './Checkbox'; +export * from './types'; diff --git a/packages/gamut/src/Form/styles/Checkbox-styles.ts b/packages/gamut/src/Form/inputs/Checkbox/styles.ts similarity index 94% rename from packages/gamut/src/Form/styles/Checkbox-styles.ts rename to packages/gamut/src/Form/inputs/Checkbox/styles.ts index 655f0f01fc..f47d2a17f6 100644 --- a/packages/gamut/src/Form/styles/Checkbox-styles.ts +++ b/packages/gamut/src/Form/inputs/Checkbox/styles.ts @@ -1,6 +1,10 @@ import { system, theme, transitionConcat } from '@skillsoft/gamut-styles'; -import { formBaseStyles, formFieldBaseDisabledStyles, InputSelectors } from '.'; +import { + formBaseStyles, + formFieldBaseDisabledStyles, + InputSelectors, +} from '../../styles'; export const checkboxLabel = system.css({ display: 'flex', diff --git a/packages/gamut/src/Form/inputs/types.tsx b/packages/gamut/src/Form/inputs/Checkbox/types.tsx similarity index 100% rename from packages/gamut/src/Form/inputs/types.tsx rename to packages/gamut/src/Form/inputs/Checkbox/types.tsx diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/Input/Input.stories.tsx b/packages/gamut/src/Form/inputs/Input.stories.tsx similarity index 71% rename from packages/styleguide/src/lib/Atoms/FormInputs/Input/Input.stories.tsx rename to packages/gamut/src/Form/inputs/Input.stories.tsx index 41a7d01c05..e266fd7fd3 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/Input/Input.stories.tsx +++ b/packages/gamut/src/Form/inputs/Input.stories.tsx @@ -1,8 +1,12 @@ -import { FormGroup, Input } from '@skillsoft/gamut'; import * as icons from '@skillsoft/gamut-icons'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from 'storybook/test'; + +import { FormGroup } from '../elements/FormGroup'; +import { Input } from './Input'; const meta: Meta = { + title: 'Inputs & forms/Input', component: Input, args: { id: 'example-input', @@ -24,6 +28,9 @@ type Story = StoryObj; export const Default: Story = { args: {}, + play: async ({ canvas }) => { + await expect(canvas.queryByRole('img', { hidden: true })).toBeNull(); + }, }; export const Text: Story = { @@ -33,6 +40,11 @@ export const Text: Story = { type: 'text', required: true, }, + play: async ({ canvas }) => { + const input = canvas.getByRole('textbox'); + await expect(input).toHaveValue('Text'); + await expect(canvas.queryByRole('img', { hidden: true })).toBeNull(); + }, }; export const Number: Story = { @@ -41,6 +53,11 @@ export const Number: Story = { type: 'number', defaultValue: 1, }, + play: async ({ canvas }) => { + const input = canvas.getByRole('spinbutton'); + await expect(input).toHaveAttribute('type', 'number'); + await expect(input).toHaveValue(1); + }, }; export const File: Story = { @@ -50,6 +67,12 @@ export const File: Story = { value: '', onChange: () => {}, }, + play: async ({ canvasElement }) => { + await expect(canvasElement.querySelector('#example-file')).toHaveAttribute( + 'type', + 'file' + ); + }, }; export const Error: Story = { @@ -58,6 +81,13 @@ export const Error: Story = { defaultValue: 'Error', error: true, }, + play: async ({ canvas }) => { + const icon = canvas.getByRole('img', { hidden: true }); + await expect(icon.querySelector('mask')).toHaveAttribute( + 'id', + expect.stringMatching(/^AlertIcon-mask-/) + ); + }, }; export const Validated: Story = { @@ -67,6 +97,13 @@ export const Validated: Story = { defaultValue: 'Verified Text', valid: true, }, + play: async ({ canvas }) => { + const icon = canvas.getByRole('img', { hidden: true }); + await expect(icon.querySelector('mask')).toHaveAttribute( + 'id', + expect.stringMatching(/^CheckCircledIcon-mask-/) + ); + }, }; export const Placeholder: Story = { @@ -92,6 +129,13 @@ export const CustomIcon: Story = { defaultValue: 'Hello...', icon: icons.ViewIcon, }, + play: async ({ canvas }) => { + const icon = canvas.getByRole('img', { hidden: true }); + await expect(icon.querySelector('mask')).toHaveAttribute( + 'id', + expect.stringMatching(/^ViewIcon-mask-/) + ); + }, }; export const FormGroupDefault: Story = { diff --git a/packages/gamut/src/Form/inputs/Radio/Radio.stories.tsx b/packages/gamut/src/Form/inputs/Radio/Radio.stories.tsx new file mode 100644 index 0000000000..3eb3130432 --- /dev/null +++ b/packages/gamut/src/Form/inputs/Radio/Radio.stories.tsx @@ -0,0 +1,255 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { ChangeEvent } from 'react'; +import { useState } from 'react'; +import { expect, fn, screen, waitFor } from 'storybook/test'; +import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; + +import { infotipNestedArgTypes } from '~storybook/argTypes'; + +import { FlexBox } from '../../../Box'; +import { Text } from '../../../Typography'; +import { FormGroup } from '../../elements/FormGroup'; +import { Radio } from './Radio'; +import { RadioGroup } from './RadioGroup'; + +const meta: TypeWithDeepControls> = { + title: 'Inputs & forms/Radio', + component: Radio, + args: { + htmlFor: 'example-radio', + label: 'Option 1', + name: 'example-radio', + }, + argTypes: { + ...infotipNestedArgTypes, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const RadioGroupComponent: Story = { + args: { + onChange: fn(), + }, + render: (args) => { + const RadioGroupExample = () => { + const [selected, setSelected] = useState('option-1'); + + const changeHandler = (event: ChangeEvent) => { + setSelected(event.target.value); + args.onChange?.(event); + }; + + return ( + + + + + ); + }; + + return ; + }, + play: async ({ args, canvas, userEvent }) => { + canvas.getByTestId('radio-group'); + + const option1 = canvas.getByRole('radio', { name: 'Option 1' }); + await expect(option1).toHaveAttribute('id', 'example-radio-0'); + await expect(option1).toHaveAttribute('name', 'example-radio'); + await expect(option1).toBeChecked(); + + await userEvent.click(canvas.getByRole('radio', { name: 'Option 2' })); + await expect(args.onChange).toHaveBeenCalled(); + await expect(canvas.getByRole('radio', { name: 'Option 2' })).toBeChecked(); + }, +}; + +export const RadioGroupWithInfoTips: Story = { + render: () => ( + + + + + + + + ), + play: ({ canvas }) => { + canvas.getByRole('button', { name: /Select an option/ }); + canvas.getByRole('button', { name: 'Option with InfoTip' }); + }, +}; + +export const Checked: Story = { + args: { + htmlFor: 'example-checked', + name: 'example-checked', + label: 'Checked', + checked: true, + }, + play: ({ canvas }) => { + canvas.getByRole('radio', { checked: true }); + }, +}; + +export const Disabled: Story = { + args: { + htmlFor: 'example-disabled', + name: 'example-disabled', + label: 'Disabled', + disabled: true, + }, +}; + +export const DisabledChecked: Story = { + args: { + htmlFor: 'example-disabled-checked', + name: 'example-disabled-checked', + label: 'Disabled and Checked', + disabled: true, + checked: true, + }, +}; + +export const ErrorState: Story = { + name: 'Error', + args: { + htmlFor: 'example-error', + label: 'Error', + name: 'example-error', + error: true, + }, +}; + +export const CustomLabel: Story = { + args: { + infotip: { + emphasis: 'high', + info: 'This is an infotip', + placement: 'floating', + }, + htmlFor: 'example-custom', + name: 'example-custom', + label: 'Option with infotip', + }, + play: async ({ canvas, userEvent }) => { + const trigger = canvas.getByRole('button', { name: 'Option with infotip' }); + + await expect(screen.queryByText('This is an infotip')).toBeNull(); + await userEvent.click(trigger); + await waitFor(() => + expect(screen.getByText('This is an infotip')).toBeVisible() + ); + }, +}; + +export const JsxLabel: Story = { + render: () => ( + } + name="example-jsx" + value="jsx" + /> + ), + play: async ({ canvas }) => { + canvas.getByAltText('Badge anatomy'); + await expect(canvas.getByRole('radio')).toHaveAttribute( + 'data-testid', + 'radio-passthrough' + ); + }, +}; + +export const InfoTipLabelling: Story = { + render: () => ( + + Auto Radio Label} + name="radio-auto" + value="auto" + /> + +
+ + External Radio Label + + +
+ + ), + play: async ({ canvas }) => { + canvas.getByRole('button', { name: 'Auto Radio Label' }); + canvas.getByRole('button', { name: 'Custom radio aria label' }); + await expect( + canvas.queryByRole('button', { name: 'With ariaLabel' }) + ).toBeNull(); + canvas.getByRole('button', { name: 'External Radio Label' }); + await expect( + canvas.queryByRole('button', { name: 'With ariaLabelledBy' }) + ).toBeNull(); + }, +}; diff --git a/packages/gamut/src/Form/inputs/Radio.tsx b/packages/gamut/src/Form/inputs/Radio/Radio.tsx similarity index 89% rename from packages/gamut/src/Form/inputs/Radio.tsx rename to packages/gamut/src/Form/inputs/Radio/Radio.tsx index 7c72d75130..3403501207 100644 --- a/packages/gamut/src/Form/inputs/Radio.tsx +++ b/packages/gamut/src/Form/inputs/Radio/Radio.tsx @@ -5,18 +5,18 @@ import type { InputHTMLAttributes, ReactNode } from 'react'; import type * as React from 'react'; import { forwardRef } from 'react'; -import { FlexBox } from '../../Box'; -import { InfoTip } from '../../Tip/InfoTip'; -import type { InfoTipSubComponentProps } from '../../Tip/InfoTip/type-utils'; -import { useInfotipProps } from '../../Tip/InfoTip/type-utils'; +import { FlexBox } from '../../../Box'; +import { InfoTip } from '../../../Tip/InfoTip'; +import type { InfoTipSubComponentProps } from '../../../Tip/InfoTip/type-utils'; +import { useInfotipProps } from '../../../Tip/InfoTip/type-utils'; +import { InputWrapper } from '../../styles'; +import type { BaseInputProps } from '../../types'; import { conditionalRadioInputStyles, conditionalRadioLabelStyles, - InputWrapper, radioInput, radioLabel, -} from '../styles'; -import type { BaseInputProps } from '../types'; +} from './styles'; export type RadioProps = InputHTMLAttributes & Omit & { diff --git a/packages/gamut/src/Form/inputs/RadioGroup.tsx b/packages/gamut/src/Form/inputs/Radio/RadioGroup.tsx similarity index 93% rename from packages/gamut/src/Form/inputs/RadioGroup.tsx rename to packages/gamut/src/Form/inputs/Radio/RadioGroup.tsx index fc8c983466..1158c35f29 100644 --- a/packages/gamut/src/Form/inputs/RadioGroup.tsx +++ b/packages/gamut/src/Form/inputs/Radio/RadioGroup.tsx @@ -2,7 +2,7 @@ import type { HTMLAttributes, InputHTMLAttributes } from 'react'; import { cloneElement } from 'react'; import * as React from 'react'; -import type { BaseInputProps } from '../types'; +import type { BaseInputProps } from '../../types'; export type RadioGroupProps = Omit, 'onChange'> & Pick, 'onChange' | 'disabled'> & diff --git a/packages/gamut/src/Form/inputs/Radio/index.tsx b/packages/gamut/src/Form/inputs/Radio/index.tsx new file mode 100644 index 0000000000..e047ad0922 --- /dev/null +++ b/packages/gamut/src/Form/inputs/Radio/index.tsx @@ -0,0 +1,2 @@ +export * from './Radio'; +export * from './RadioGroup'; diff --git a/packages/gamut/src/Form/styles/Radio-styles.ts b/packages/gamut/src/Form/inputs/Radio/styles.ts similarity index 98% rename from packages/gamut/src/Form/styles/Radio-styles.ts rename to packages/gamut/src/Form/inputs/Radio/styles.ts index cdbf38f535..06f6439204 100644 --- a/packages/gamut/src/Form/styles/Radio-styles.ts +++ b/packages/gamut/src/Form/inputs/Radio/styles.ts @@ -10,7 +10,7 @@ import { formBaseComponentStyles, formFieldBaseDisabledStyles, InputSelectors, -} from '.'; +} from '../../styles'; const consistentLabelStyles = { content: '""', diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/Select/Select.stories.tsx b/packages/gamut/src/Form/inputs/Select.stories.tsx similarity index 65% rename from packages/styleguide/src/lib/Atoms/FormInputs/Select/Select.stories.tsx rename to packages/gamut/src/Form/inputs/Select.stories.tsx index a4dd92c26e..0dd43f8ce6 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/Select/Select.stories.tsx +++ b/packages/gamut/src/Form/inputs/Select.stories.tsx @@ -1,9 +1,13 @@ -import { FormGroup, Select } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from 'storybook/test'; + +import { FormGroup } from '../elements/FormGroup'; +import { Select } from './Select'; const fruitOptions = ['Apple', 'Banana', 'Cherry', 'Dragonfruit', 'Eggplant']; const meta: Meta = { + title: 'Inputs & forms/Select', component: Select, args: { id: 'example-select', @@ -23,6 +27,29 @@ type Story = StoryObj; export const Default: Story = { args: {}, + play: async ({ canvas }) => { + const select = canvas.getByRole('combobox'); + await expect(select).toHaveAttribute('id', 'example-select'); + await expect(canvas.getAllByRole('option')).toHaveLength( + fruitOptions.length + ); + canvas.getByTestId('example-select-Apple'); + }, +}; + +export const ObjectOptions: Story = { + args: { + id: 'colors', + options: { redKey: 'red', greenKey: 'green', yellowKey: 'yellow' }, + }, + play: async ({ canvas }) => { + await expect(canvas.getAllByRole('option')).toHaveLength(3); + await expect(canvas.getAllByRole('option')[0]).toHaveTextContent('red'); + await expect(canvas.getByRole('option', { name: 'yellow' })).toHaveValue( + 'yellowKey' + ); + canvas.getByTestId('colors-redKey'); + }, }; export const Base: Story = { diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/SelectDropdown.stories.tsx similarity index 50% rename from packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/SelectDropdown.stories.tsx index adc15ce511..08eb37affe 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx +++ b/packages/gamut/src/Form/inputs/SelectDropdown/SelectDropdown.stories.tsx @@ -1,21 +1,38 @@ import { - Box, - DataList, - FlexBox, - FormGroup, - SelectDropdown, - Text, -} from '@skillsoft/gamut'; -import { RadarIcon, ResponsiveIcon, RocketIcon } from '@skillsoft/gamut-icons'; + CalendarIcon, + DataTransferVerticalIcon, + EarthIcon, + RadarIcon, + ResponsiveIcon, + RocketIcon, +} from '@skillsoft/gamut-icons'; import type { Meta, StoryObj } from '@storybook/react'; import { useRef, useState } from 'react'; import type { InputActionMeta } from 'react-select'; +import { expect, fireEvent, fn, waitFor, within } from 'storybook/test'; import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; +import { Box, FlexBox } from '../../../Box'; +import { StrokeButton } from '../../../Button'; +import { DataList } from '../../../DataList'; +import { Text } from '../../../Typography'; +import { FormGroup } from '../../elements/FormGroup'; +import { SelectDropdown } from '.'; +import type { + MultiSelectDropdownProps, + SelectDropdownProps, + SingleSelectDropdownProps, +} from './types'; + const fruitOptions = ['Apple', 'Banana', 'Cherry', 'Dragonfruit', 'Eggplant']; const meta: TypeWithDeepControls> = { + title: 'Inputs & forms/SelectDropdown', component: SelectDropdown, + parameters: { + // This is to prevent the component description from being displayed in the docs + docs: { extractComponentDescription: () => null }, + }, args: { id: 'example-select', name: 'example-select', @@ -29,12 +46,13 @@ const meta: TypeWithDeepControls> = { value: { control: 'select', options: fruitOptions, + table: { + type: { summary: 'string | string[]' }, + }, }, - // Deprecated top-level props — hidden in favor of their `translations` equivalents. placeholder: { table: { disable: true } }, formatCreateLabel: { table: { disable: true } }, validationMessage: { table: { disable: true } }, - // Expose the nested `translations` keys as deep controls rather than the parent object. translations: { table: { disable: true } }, 'translations.placeholder': { control: 'text', @@ -94,8 +112,61 @@ const meta: TypeWithDeepControls> = { export default meta; type Story = StoryObj; +type PlayContext = Parameters>[0]; + +const pickOption = async ( + { canvas, userEvent }: PlayContext, + name: string | RegExp +) => { + await userEvent.click(canvas.getByRole('combobox')); + await userEvent.click(await canvas.findByRole('option', { name })); +}; + +const queryIcon = (root: HTMLElement, iconName: string) => + root.querySelector(`mask[id^="${iconName}-mask-"]`); + +const queryHiddenInput = (root: HTMLElement, formField: string) => + root.querySelector(`input[type="hidden"][data-form-field="${formField}"]`); + +const waitPastAnnouncementDebounce = () => + new Promise((resolve) => setTimeout(resolve, 500)); + +const expectAnnouncementCleared = (status: HTMLElement) => + waitFor(() => expect(status).toBeEmptyDOMElement(), { timeout: 200 }); + +const createKeepsSelections: NonNullable = async (context) => { + const { args, canvas, userEvent } = context; + + await pickOption(context, 'Apple'); + await pickOption(context, 'Banana'); + await userEvent.type(canvas.getByRole('combobox'), 'Kiwi'); + await userEvent.click( + await canvas.findByRole('option', { name: 'Add "Kiwi"' }) + ); + + await expect(args.onChange).toHaveBeenLastCalledWith( + [ + { label: 'Apple', value: 'Apple' }, + { label: 'Banana', value: 'Banana' }, + { label: 'Kiwi', value: 'Kiwi', __isNew__: true }, + ], + expect.objectContaining({ + action: 'create-option', + option: expect.objectContaining({ value: 'Kiwi' }), + }) + ); + + await canvas.findByRole('button', { name: 'Remove Apple' }); + await canvas.findByRole('button', { name: 'Remove Banana' }); + await canvas.findByRole('button', { name: 'Remove Kiwi' }); +}; export const Base: Story = { + parameters: { + a11y: { + config: { rules: [{ id: 'aria-required-children', enabled: false }] }, + }, + }, args: { name: 'base-dropdown', options: [], @@ -106,20 +177,275 @@ export const Base: Story = { ), + play: async ({ canvas, canvasElement, userEvent }) => { + /* + * TODO: Re-enable aria-required-children once the empty menu is fixed. With + * no options to show, react-select renders the "No options" message inside + * the role="listbox" MenuList with no option children. Fix by rendering the + * message outside the listbox (e.g. a custom MenuList that drops + * role="listbox" when there are no options) — the message is already + * announced via SelectDropdown's own live region. + */ + await expect(canvas.getByRole('combobox')).toHaveAttribute( + 'id', + 'base-dropdown' + ); + await expect( + queryIcon(canvasElement, 'ArrowChevronDownIcon') + ).not.toBeNull(); + await expect(queryIcon(canvasElement, 'MiniChevronDownIcon')).toBeNull(); + + await canvas.findByText('all about that test'); + await userEvent.click(canvas.getByRole('combobox')); + await within(await canvas.findByRole('listbox')).findByText('No options'); + await within(canvas.getByRole('status')).findByText('No options'); + }, +}; + +export const ValueWithEmptyOptions: Story = { + parameters: { + docs: { + description: { + story: + 'A `value` that matches no option falls back to the placeholder when `options` is empty.', + }, + }, + }, + args: { + 'aria-label': 'Empty options', + id: 'empty-options', + name: 'empty-options', + options: [], + value: 'any', + }, + play: ({ canvas }) => { + canvas.getByText('Select an option'); + }, +}; + +export const ValueWithUndefinedOptions: Story = { + parameters: { + docs: { + description: { + story: + 'A `value` that matches no option falls back to the placeholder when `options` is undefined.', + }, + }, + }, + args: { + 'aria-label': 'Undefined options', + id: 'undefined-options', + name: 'undefined-options', + options: undefined, + value: 'any', + }, + play: ({ canvas }) => { + canvas.getByText('Select an option'); + }, +}; + +export const OpenAndSelect: Story = { + args: { + id: 'open-and-select', + name: 'open-and-select', + options: ['Apple', 'Banana', 'Cherry'], + 'aria-label': 'Select a fruit', + onChange: fn(), + }, + render: (args) => ( + + + + ), + play: async ({ args, canvas, userEvent }) => { + const combobox = canvas.getByRole('combobox'); + + await userEvent.click(combobox); + await expect(combobox).toHaveAttribute('aria-expanded', 'true'); + await userEvent.click( + await canvas.findByRole('option', { name: 'Banana' }) + ); + + await expect(combobox).toHaveAttribute('aria-expanded', 'false'); + await expect(canvas.queryByRole('listbox')).toBeNull(); + await expect(args.onChange).toHaveBeenCalledWith( + expect.objectContaining({ label: 'Banana', value: 'Banana' }), + expect.objectContaining({ + action: 'select-option', + option: expect.objectContaining({ value: 'Banana' }), + }) + ); + canvas.getByText('Banana'); + }, }; export const Searchable: Story = { + parameters: { + a11y: { + config: { rules: [{ id: 'aria-required-children', enabled: false }] }, + }, + }, args: { name: 'searchable-dropdown', isSearchable: true, options: ['wow', 'wowee', 'wooooooow'], - translations: { placeholder: 'i am searchable, it is cool' }, + translations: { + placeholder: 'i am searchable, it is cool', + validationMessage: ({ inputValue }) => `No results for "${inputValue}"`, + }, + onInputChange: fn(), }, render: (args) => ( ), + play: async ({ args, canvas, userEvent }) => { + // TODO: Re-enable aria-required-children once the empty menu is fixed; see Base. + const combobox = canvas.getByRole('combobox'); + const status = canvas.getByRole('status'); + + await userEvent.type(combobox, 'wowe'); + await expect(args.onInputChange).toHaveBeenCalledWith( + 'wowe', + expect.objectContaining({ action: 'input-change' }) + ); + const options = await canvas.findAllByRole('option'); + await expect(options.map((option) => option.textContent)).toEqual([ + 'wowee', + ]); + await waitPastAnnouncementDebounce(); + await expect(status).toBeEmptyDOMElement(); + + await userEvent.keyboard('{Enter}'); + await expect(combobox).toHaveAttribute('aria-expanded', 'false'); + await canvas.findByText('wowee'); + + await userEvent.type(combobox, 'zzz'); + await within(await canvas.findByRole('listbox')).findByText( + 'No results for "zzz"' + ); + await within(status).findByText('No results for "zzz"'); + + await userEvent.type(combobox, '{Backspace}'); + await within(status).findByText('No results for "zz"'); + await expect(within(status).queryByText('No results for "zzz"')).toBeNull(); + + await userEvent.tab(); + await expectAnnouncementCleared(status); + + await userEvent.type(combobox, 'zz'); + await within(status).findByText('No results for "zz"'); + await expect(canvas.getByRole('status')).toBe(status); + }, +}; + +export const NoOptionsAnnouncement: Story = { + parameters: { + docs: { + description: { + story: + 'When there are no options to show, the menu message (the default "No options" or a custom `validationMessage`) is also announced through a polite live region, and cleared as soon as the menu closes.', + }, + }, + }, + args: { + name: 'no-options-announcement', + 'aria-label': 'Select a fruit', + options: [], + translations: { validationMessage: 'No fruits available' }, + onMenuClose: fn(), + }, + render: (args) => { + const NoOptionsAnnouncementContent = () => { + const [hasCustomMessage, setHasCustomMessage] = useState(true); + + return ( + + + setHasCustomMessage(false)} + > + Use default message + + + + + ); + }; + + return ; + }, + play: async ({ args, canvas, userEvent }) => { + const combobox = canvas.getByRole('combobox'); + const status = canvas.getByRole('status'); + + await userEvent.click(combobox); + await within(await canvas.findByRole('listbox')).findByText( + 'No fruits available' + ); + await within(status).findByText('No fruits available'); + await expect(canvas.getAllByText('No fruits available')).toHaveLength(2); + await expect(canvas.queryByText('No options')).toBeNull(); + + await userEvent.keyboard('{Escape}'); + await expectAnnouncementCleared(status); + await expect(args.onMenuClose).toHaveBeenCalledTimes(1); + + await userEvent.click(combobox); + await within(status).findByText('No fruits available'); + + await fireEvent.click( + canvas.getByRole('button', { name: 'Use default message' }) + ); + await within(status).findByText('No options'); + await within(canvas.getByRole('listbox')).findByText('No options'); + await expect(combobox).toHaveAttribute('aria-expanded', 'true'); + await expect(canvas.queryByText('No fruits available')).toBeNull(); + + await userEvent.keyboard('{Escape}'); + await expectAnnouncementCleared(status); + await expect(args.onMenuClose).toHaveBeenCalledTimes(2); + }, +}; + +export const ObjectOptions: Story = { + parameters: { + docs: { + description: { + story: + '`options` can also be an object map of `value: label` pairs instead of an array.', + }, + }, + }, + args: { + id: 'object-options', + name: 'object-options', + options: { red: 'Red', yellow: 'Yellow', green: 'Green' }, + onChange: fn(), + }, + render: (args) => ( + + + + ), + play: async (context) => { + const { args, canvas } = context; + + await pickOption(context, 'Green'); + await canvas.findByText('Green'); + await expect(args.onChange).toHaveBeenCalledWith( + { key: 'object-options-green', label: 'Green', value: 'green' }, + expect.objectContaining({ + action: 'select-option', + option: expect.objectContaining({ value: 'green' }), + }) + ); + }, }; export const Disabled: Story = { @@ -129,9 +455,13 @@ export const Disabled: Story = { disabled: true, translations: { placeholder: 'Disabled' }, }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('combobox')).toBeDisabled(); + }, }; -export const Error: Story = { +export const ErrorState: Story = { + name: 'Error', args: { name: 'error-dropdown-standalone', options: ['Small', 'Quite little'], @@ -157,6 +487,17 @@ export const Small: Story = { ), + play: async ({ canvas, canvasElement, userEvent }) => { + await expect( + queryIcon(canvasElement, 'MiniChevronDownIcon') + ).not.toBeNull(); + await expect(queryIcon(canvasElement, 'ArrowChevronDownIcon')).toBeNull(); + + await userEvent.click(canvas.getByRole('combobox')); + await expect(await canvas.findByRole('listbox')).toHaveStyle({ + maxHeight: '192px', + }); + }, }; export const SmallSearchable: Story = { @@ -260,6 +601,20 @@ export const DisabledOptions: Story = { ), + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('combobox')); + const disabledOption = await canvas.findByRole('option', { + name: 'I am disabled', + }); + await expect(disabledOption).toHaveAttribute('aria-disabled', 'true'); + + await userEvent.click(disabledOption); + await canvas.findByRole('listbox'); + + await userEvent.click(canvas.getByRole('option', { name: 'I am not' })); + await expect(canvas.queryByRole('listbox')).toBeNull(); + await canvas.findByText('I am not'); + }, }; export const Subtitles: Story = { @@ -363,6 +718,18 @@ export const Icons: Story = { ), + play: async ({ canvas, canvasElement, userEvent }) => { + await userEvent.click(canvas.getByRole('combobox')); + const listbox = await canvas.findByRole('listbox'); + await expect(queryIcon(listbox, 'RocketIcon')).not.toBeNull(); + await expect(queryIcon(listbox, 'RadarIcon')).not.toBeNull(); + await expect(queryIcon(listbox, 'ResponsiveIcon')).not.toBeNull(); + + await userEvent.click(canvas.getByRole('option', { name: 'ohai' })); + await expect(canvas.queryByRole('listbox')).toBeNull(); + await expect(queryIcon(canvasElement, 'RocketIcon')).not.toBeNull(); + await expect(queryIcon(canvasElement, 'RadarIcon')).toBeNull(); + }, }; export const AbbreviatedInput: Story = { @@ -414,6 +781,87 @@ export const AbbreviatedInput: Story = { ), }; +export const AbbreviatedMultiSelect: Story = { + args: { + id: 'abbreviated-multi', + name: 'abbreviated-multi', + multiple: true, + options: [ + { + label: 'United States of America', + abbreviation: 'USA', + value: 'us', + }, + { + label: 'United Kingdom', + abbreviation: 'UK', + value: 'uk', + }, + { + label: 'Canada', + abbreviation: 'CA', + value: 'ca', + }, + ], + onChange: fn(), + }, + render: (args) => ( + + + + + + Selected tags show abbreviations; the dropdown shows full labels + + + ), + play: async (context) => { + // TODO: Fails the a11y scan until the "Remove all" aria-hidden bug is fixed; see createKeepsSelections. + const { args, canvas } = context; + + await pickOption(context, 'United States of America'); + await pickOption(context, 'United Kingdom'); + + await canvas.findByText('USA'); + await canvas.findByText('UK'); + await expect(args.onChange).toHaveBeenCalledTimes(2); + await expect(args.onChange).toHaveBeenNthCalledWith( + 1, + [ + { + abbreviation: 'USA', + key: 'abbreviated-multi-us', + label: 'United States of America', + value: 'us', + }, + ], + { action: 'select-option' } + ); + await expect(args.onChange).toHaveBeenNthCalledWith( + 2, + [ + { + abbreviation: 'USA', + key: 'abbreviated-multi-us', + label: 'United States of America', + value: 'us', + }, + { + abbreviation: 'UK', + key: 'abbreviated-multi-uk', + label: 'United Kingdom', + value: 'uk', + }, + ], + { action: 'select-option' } + ); + }, +}; + export const AbbreviatedSmallSize: Story = { args: { id: 'abbreviated-small', @@ -597,6 +1045,7 @@ export const GroupDividers: Story = { ], }, ], + onChange: fn(), }, render: (args) => ( @@ -609,6 +1058,45 @@ export const GroupDividers: Story = { ), + play: async (context) => { + const { args, canvas, userEvent } = context; + + await userEvent.click(canvas.getByRole('combobox')); + const listbox = await canvas.findByRole('listbox'); + await within(listbox).findByText('The King of Chess'); + await within(listbox).findByText('By leaps and bounds'); + await expect( + within(listbox).getByRole('option', { name: /^king@chess\.com/ }) + ).toHaveAttribute('aria-disabled', 'true'); + await expect( + within(listbox).getByRole('option', { name: /^knight@chess\.com/ }) + ).toHaveAttribute('aria-disabled', 'true'); + + await userEvent.click( + within(listbox).getByRole('option', { name: /^pawn@chess\.com/ }) + ); + await expect(args.onChange).toHaveBeenCalledWith( + expect.objectContaining({ label: 'pawn@chess.com', value: 'pawn' }), + expect.objectContaining({ + action: 'select-option', + option: expect.objectContaining({ value: 'pawn' }), + }) + ); + await canvas.findByText('pawn@chess.com'); + + await pickOption(context, /^bishop@chess\.com/); + await expect(args.onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ value: 'bishop', subtitle: 'Bishop Chess' }), + expect.objectContaining({ + action: 'select-option', + option: expect.objectContaining({ + value: 'bishop', + subtitle: 'Bishop Chess', + }), + }) + ); + await canvas.findByText('bishop@chess.com'); + }, }; export const GroupLabels: Story = { @@ -658,6 +1146,7 @@ export const GroupLabels: Story = { ], }, ], + onChange: fn(), }, render: (args) => ( @@ -670,6 +1159,135 @@ export const GroupLabels: Story = { ), + play: async (context) => { + const { args, canvas, userEvent } = context; + + await userEvent.click(canvas.getByRole('combobox')); + const listbox = await canvas.findByRole('listbox'); + await within(listbox).findByText('Group 1'); + await within(listbox).findByText('Group 2'); + [ + /^king@chess\.com/, + /^queen@chess\.com/, + /^bishop@chess\.com/, + /^knight@chess\.com/, + /^pawn@chess\.com/, + ].forEach((name) => within(listbox).getByRole('option', { name })); + await userEvent.keyboard('{Escape}'); + + await pickOption(context, /^queen@chess\.com/); + await pickOption(context, /^pawn@chess\.com/); + + await expect(args.onChange).toHaveBeenCalledTimes(2); + await expect(args.onChange).toHaveBeenNthCalledWith( + 1, + [ + { + label: 'queen@chess.com', + rightLabel: 'The Queen of Chess', + value: 'queen', + }, + ], + { action: 'select-option' } + ); + await expect(args.onChange).toHaveBeenNthCalledWith( + 2, + [ + { + label: 'queen@chess.com', + rightLabel: 'The Queen of Chess', + value: 'queen', + }, + { label: 'pawn@chess.com', value: 'pawn', disabled: false }, + ], + { action: 'select-option' } + ); + await canvas.findByRole('button', { name: 'Remove queen@chess.com' }); + await canvas.findByRole('button', { name: 'Remove pawn@chess.com' }); + }, +}; + +export const GroupedIcons: Story = { + args: { + id: 'grouped-icons-dropdown', + name: 'grouped-icons-dropdown', + 'aria-label': 'Select an icon', + options: [ + { + label: 'Data icons', + options: [ + { + label: 'Data transfer', + value: 'data-transfer', + icon: DataTransferVerticalIcon, + }, + { label: 'Calendar', value: 'calendar', icon: CalendarIcon }, + ], + }, + { + label: 'Navigation icons', + options: [{ label: 'Earth', value: 'earth', icon: EarthIcon }], + }, + ], + value: 'calendar', + onChange: fn(), + }, + render: (args) => { + const GroupedIconsContent = () => { + const [value, setValue] = useState(args.value as string | undefined); + const onChange = args.onChange as SingleSelectDropdownProps['onChange']; + + return ( + + { + setValue(option?.value); + onChange?.(option, meta); + }} + /> + + ); + }; + + return ; + }, + play: async ({ args, canvas, canvasElement, userEvent }) => { + await canvas.findByText('Calendar'); + await expect(queryIcon(canvasElement, 'CalendarIcon')).not.toBeNull(); + + await userEvent.click(canvas.getByRole('combobox')); + const listbox = await canvas.findByRole('listbox'); + await within(listbox).findByText('Data icons'); + await within(listbox).findByText('Navigation icons'); + await expect(queryIcon(listbox, 'DataTransferVerticalIcon')).not.toBeNull(); + await expect(queryIcon(listbox, 'CalendarIcon')).not.toBeNull(); + await expect(queryIcon(listbox, 'EarthIcon')).not.toBeNull(); + + await userEvent.click( + within(listbox).getByRole('option', { name: 'Data transfer' }) + ); + await expect(args.onChange).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Data transfer', + value: 'data-transfer', + icon: DataTransferVerticalIcon, + }), + expect.objectContaining({ + action: 'select-option', + option: expect.objectContaining({ + value: 'data-transfer', + icon: DataTransferVerticalIcon, + }), + }) + ); + await canvas.findByText('Data transfer'); + await expect( + queryIcon(canvasElement, 'DataTransferVerticalIcon') + ).not.toBeNull(); + }, }; export const ShownOptionsDefault: Story = { @@ -735,6 +1353,12 @@ export const ShownOptionsThree: Story = { ), + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('combobox')); + await expect(await canvas.findByRole('listbox')).toHaveStyle({ + maxHeight: '144px', + }); + }, }; export const IndependentWidths: Story = { @@ -988,6 +1612,7 @@ export const MultipleSelect: Story = { value: 'zzz', }, ], + onChange: fn(), }, render: (args) => ( @@ -1000,6 +1625,38 @@ export const MultipleSelect: Story = { ), + play: async (context) => { + const { args, canvas, userEvent } = context; + + await pickOption(context, 'Select Me'); + await pickOption(context, 'Multi Select'); + + await expect(args.onChange).toHaveBeenCalledTimes(2); + await expect(args.onChange).toHaveBeenNthCalledWith( + 1, + [{ key: 'multi-dropdown-xxx', label: 'Select Me', value: 'xxx' }], + { action: 'select-option' } + ); + await expect(args.onChange).toHaveBeenNthCalledWith( + 2, + [ + { key: 'multi-dropdown-xxx', label: 'Select Me', value: 'xxx' }, + { key: 'multi-dropdown-yyy', label: 'Multi Select', value: 'yyy' }, + ], + { action: 'select-option' } + ); + + await userEvent.click( + canvas.getByRole('button', { name: 'Remove Select Me' }) + ); + await expect( + canvas.queryByRole('button', { name: 'Remove Select Me' }) + ).toBeNull(); + await canvas.findByRole('button', { name: 'Remove Multi Select' }); + + await userEvent.click(canvas.getByLabelText('Remove all selected')); + await canvas.findByText('Select an option'); + }, }; export const MultipleSelectControlled: Story = { @@ -1069,6 +1726,21 @@ export const MultipleSelectControlled: Story = { return ; }, + play: async (context) => { + // TODO: Fails the a11y scan until the "Remove all" aria-hidden bug is fixed; see createKeepsSelections. + const { canvas, userEvent } = context; + + await canvas.findByText('No languages selected yet.'); + + await pickOption(context, 'JavaScript'); + await pickOption(context, 'Python'); + await canvas.findByText('Selected: javascript, python'); + + await userEvent.click( + canvas.getByRole('button', { name: 'Remove JavaScript' }) + ); + await canvas.findByText('Selected: python'); + }, }; export const Creatable: Story = { @@ -1101,8 +1773,9 @@ export const Creatable: Story = { args: { name: 'creatable-dropdown', isCreatable: true, - createOptionPosition: 'last', translations: { placeholder: 'Select or type to add…' }, + onCreateOption: fn(), + onInputChange: fn(), }, render: (args) => { const CreatableContent = () => { @@ -1119,9 +1792,10 @@ export const Creatable: Story = { {...args} id="creatable-dropdown" options={options} - onCreateOption={(inputValue) => - setOptions((prev) => [...prev, inputValue]) - } + onCreateOption={(inputValue) => { + args.onCreateOption?.(inputValue); + setOptions((prev) => [...prev, inputValue]); + }} /> @@ -1130,6 +1804,69 @@ export const Creatable: Story = { return ; }, + play: async ({ args, canvas, canvasElement, userEvent }) => { + const combobox = canvas.getByRole('combobox'); + + await userEvent.type(combobox, 'pur'); + await userEvent.click(canvasElement); + await expect(combobox).toHaveValue(''); + await expect(args.onInputChange).toHaveBeenCalledWith('', { + action: 'input-blur', + prevInputValue: 'pur', + }); + + await userEvent.type(combobox, 'e'); + const options = await canvas.findAllByRole('option'); + await expect(options.map((option) => option.textContent)).toEqual([ + 'Apple', + 'Cherry', + 'Add "e"', + ]); + + await userEvent.clear(combobox); + await userEvent.type(combobox, 'Apple'); + await canvas.findByRole('option', { name: 'Apple' }); + await expect( + canvas.queryByRole('option', { name: 'Add "Apple"' }) + ).toBeNull(); + + await userEvent.clear(combobox); + await userEvent.type(combobox, 'Kiwi'); + await userEvent.click( + await canvas.findByRole('option', { name: 'Add "Kiwi"' }) + ); + await expect(args.onCreateOption).toHaveBeenCalledTimes(1); + await expect(args.onCreateOption).toHaveBeenCalledWith('Kiwi'); + await expect(combobox).toHaveValue(''); + await canvas.findByText('Kiwi'); + + await userEvent.click(combobox); + await canvas.findByRole('option', { name: 'Kiwi' }); + }, +}; + +export const CreatableOptionFirst: Story = { + args: { + name: 'creatable-option-first', + isCreatable: true, + createOptionPosition: 'first', + options: ['Apple', 'Banana', 'Cherry'], + translations: { placeholder: 'Select or type to add…' }, + }, + render: (args) => ( + + + + ), + play: async ({ canvas, userEvent }) => { + await userEvent.type(canvas.getByRole('combobox'), 'e'); + const options = await canvas.findAllByRole('option'); + await expect(options.map((option) => option.textContent)).toEqual([ + 'Add "e"', + 'Apple', + 'Cherry', + ]); + }, }; export const CreatableMultiUncontrolled: Story = { @@ -1165,7 +1902,8 @@ Use this when you only need the final value on submit and no other part of the U }, }, }, - render: () => { + args: { onChange: fn() }, + render: (args) => { const CreatableMultiUncontrolledContent = () => { const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); @@ -1182,6 +1920,7 @@ Use this when you only need the final value on submit and no other part of the U name="creatable-multi-uncontrolled" options={options} translations={{ placeholder: 'Select or type to add…' }} + onChange={args.onChange as MultiSelectDropdownProps['onChange']} onCreateOption={(inputValue) => setOptions((prev) => [...prev, inputValue]) } @@ -1193,6 +1932,7 @@ Use this when you only need the final value on submit and no other part of the U return ; }, + play: createKeepsSelections, }; export const CreatableMulti: Story = { @@ -1236,10 +1976,12 @@ const [value, setValue] = useState([]); }, }, }, - render: () => { + args: { onChange: fn() }, + render: (args) => { const CreatableMultiContent = () => { const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); const [value, setValue] = useState([]); + const onChange = args.onChange as MultiSelectDropdownProps['onChange']; return ( @@ -1261,6 +2003,8 @@ const [value, setValue] = useState([]); if (meta.action === 'create-option' && meta.option) { setOptions((prev) => [...prev, meta.option.value]); } + + onChange?.(selected, meta); }} /> @@ -1270,6 +2014,7 @@ const [value, setValue] = useState([]); return ; }, + play: createKeepsSelections, }; export const CreatableWithValidation: Story = { @@ -1350,9 +2095,6 @@ const handleInputChange = ( const [error, setError] = useState(); const lastInputRef = useRef(''); - // Three distinct validation reasons, each with its own message - only - // one can apply to a given inputValue, so validationMessage always - // shows the single most relevant reason instead of stacking them. const validate = (inputValue: string) => { const trimmed = inputValue.trim(); if (trimmed.length < 3) return 'Enter at least 3 characters.'; @@ -1394,9 +2136,6 @@ const handleInputChange = ( translations={{ placeholder: 'Type at least 3 letters to add…', validationMessage: ({ inputValue }) => - // Falls back to "No matching fruit" once the input clears - // both validation reasons above but still doesn't match an - // option. validate(inputValue) ?? 'No matching fruit', }} onChange={() => { @@ -1417,19 +2156,50 @@ const handleInputChange = ( return ; }, + play: async ({ canvas, userEvent }) => { + const combobox = canvas.getByRole('combobox'); + + await userEvent.type(combobox, 'ki'); + await expect(await canvas.findByRole('alert')).toHaveTextContent( + 'Enter at least 3 characters.' + ); + await within(await canvas.findByRole('listbox')).findByText( + 'Enter at least 3 characters.' + ); + + await userEvent.type(combobox, '1'); + await expect(canvas.getByRole('alert')).toHaveTextContent( + 'Only letters are allowed.' + ); + await within(canvas.getByRole('listbox')).findByText( + 'Only letters are allowed.' + ); + await expect(canvas.queryByRole('option', { name: /^Add/ })).toBeNull(); + + await userEvent.clear(combobox); + await userEvent.type(combobox, 'Kiwi'); + await expect(canvas.queryByRole('alert')).toBeNull(); + + await userEvent.click( + await canvas.findByRole('option', { name: 'Add "Kiwi"' }) + ); + await canvas.findByText('Kiwi'); + }, }; export const CustomInputProps: Story = { args: { options: ['inspect me to see my inputProps', 'yes I am!', ':)'], inputProps: { - hidden: { 'data-form-field': 'what' }, + hidden: { 'data-form-field': 'what', 'data-validation': 'required' }, combobox: { 'data-testid': 'custom-select', 'data-cy': 'custom-dropdown', + 'data-analytics': 'user-interaction', + 'aria-describedby': 'custom-input-props-help', }, }, - id: 'what', + id: 'custom-input-props', name: 'what', }, render: (args) => ( @@ -1437,8 +2207,233 @@ export const CustomInputProps: Story = { + + I describe the combobox via inputProps + + + ), + play: async ({ canvas, canvasElement }) => { + const combobox = canvas.getByRole('combobox'); + await expect(combobox).toHaveAttribute('data-testid', 'custom-select'); + await expect(combobox).toHaveAttribute('data-cy', 'custom-dropdown'); + await expect(combobox).toHaveAttribute( + 'data-analytics', + 'user-interaction' + ); + await expect(combobox).toHaveAccessibleDescription( + 'I describe the combobox via inputProps' + ); + await expect(combobox).not.toHaveAttribute('data-form-field'); + await expect(combobox).not.toHaveAttribute('data-validation'); + + const hiddenInput = queryHiddenInput(canvasElement, 'what'); + await expect(hiddenInput).toHaveAttribute('data-validation', 'required'); + await expect(hiddenInput).not.toHaveAttribute('data-cy'); + }, +}; + +export const CustomInputPropsDistinctIds: Story = { + args: { + id: 'custom-input-props-distinct', + name: 'custom-input-props-distinct-name', + options: ['inspect me to see my inputProps', 'yes I am!', ':)'], + inputProps: { + hidden: { + 'data-form-field': 'test-field', + 'data-hidden-attr': 'hidden-value', + }, + combobox: { + 'data-testid': 'non-searchable-combobox', + 'data-custom-attr': 'custom-value', + }, + }, + }, + render: (args) => ( + + + + ), + play: async ({ canvas, canvasElement }) => { + const combobox = canvas.getByRole('combobox'); + await expect(combobox).toHaveAttribute( + 'data-testid', + 'non-searchable-combobox' + ); + await expect(combobox).toHaveAttribute('data-custom-attr', 'custom-value'); + await expect(combobox).not.toHaveAttribute('data-form-field'); + await expect(combobox).not.toHaveAttribute('data-hidden-attr'); + + const hiddenInput = queryHiddenInput(canvasElement, 'test-field'); + await expect(hiddenInput).toHaveAttribute( + 'data-hidden-attr', + 'hidden-value' + ); + await expect(hiddenInput).toHaveAttribute( + 'name', + 'custom-input-props-distinct-name' + ); + await expect(hiddenInput).not.toHaveAttribute('data-testid'); + await expect(hiddenInput).not.toHaveAttribute('data-custom-attr'); + }, +}; + +type ComboboxInputProps = NonNullable< + SelectDropdownProps['inputProps'] +>['combobox']; + +const nonStringComboboxProps = { + 'data-boolean-true': true, + 'data-boolean-false': false, + 'data-number': 42, + 'data-zero': 0, +} as unknown as ComboboxInputProps; + +const expectNonStringComboboxProps = async (combobox: HTMLElement) => { + await expect(combobox).toHaveAttribute('data-boolean-true', 'true'); + await expect(combobox).toHaveAttribute('data-boolean-false', 'false'); + await expect(combobox).toHaveAttribute('data-number', '42'); + await expect(combobox).toHaveAttribute('data-zero', '0'); +}; + +export const CustomInputPropsComboboxOnly: Story = { + args: { + id: 'custom-input-props-combobox-only', + name: 'custom-input-props-combobox-only-name', + options: ['inspect me to see my inputProps', 'yes I am!', ':)'], + inputProps: { + combobox: { + 'data-testid': 'combobox-only', + 'data-aria-label': 'Custom combobox label', + 'data-cy': 'combobox-element', + 'data-analytics': 'user-interaction', + 'data-tracking': 'form-field', + 'aria-describedby': 'combobox-only-help', + ...nonStringComboboxProps, + }, + }, + }, + render: (args) => ( + + + + + + I describe the combobox via inputProps + + + ), + play: async ({ canvas, canvasElement }) => { + const combobox = canvas.getByRole('combobox'); + await expect(combobox).toHaveAttribute('data-testid', 'combobox-only'); + await expect(combobox).toHaveAttribute( + 'data-aria-label', + 'Custom combobox label' + ); + await expect(combobox).toHaveAttribute('data-cy', 'combobox-element'); + await expect(combobox).toHaveAttribute( + 'data-analytics', + 'user-interaction' + ); + await expect(combobox).toHaveAttribute('data-tracking', 'form-field'); + await expect(combobox).toHaveAccessibleDescription( + 'I describe the combobox via inputProps' + ); + await expectNonStringComboboxProps(combobox); + await expect( + canvasElement.querySelector('input[type="hidden"][data-testid]') + ).toBeNull(); + }, +}; + +export const CustomInputPropsSearchable: Story = { + args: { + id: 'custom-input-props-searchable', + name: 'custom-input-props-searchable', + isSearchable: true, + options: ['inspect me to see my inputProps', 'yes I am!', ':)'], + inputProps: { + hidden: { + 'data-form-field': 'searchable-field', + 'data-hidden-attr': 'searchable-hidden-value', + }, + combobox: { + 'data-testid': 'searchable-select', + 'data-cy': 'searchable-dropdown', + ...nonStringComboboxProps, + }, + }, + }, + render: (args) => ( + + + + + + ), + play: async ({ canvas, canvasElement }) => { + const combobox = canvas.getByRole('combobox'); + await expect(combobox).toHaveAttribute('data-testid', 'searchable-select'); + await expect(combobox).toHaveAttribute('data-cy', 'searchable-dropdown'); + await expectNonStringComboboxProps(combobox); + await expect(combobox).not.toHaveAttribute('data-form-field'); + await expect(combobox).not.toHaveAttribute('data-hidden-attr'); + + const hiddenInput = queryHiddenInput(canvasElement, 'searchable-field'); + await expect(hiddenInput).toHaveAttribute( + 'data-hidden-attr', + 'searchable-hidden-value' + ); + await expect(hiddenInput).not.toHaveAttribute('data-testid'); + await expect(hiddenInput).not.toHaveAttribute('data-cy'); + }, +}; + +export const CustomInputPropsHiddenOnly: Story = { + args: { + id: 'custom-input-props-hidden-only', + name: 'custom-input-props-hidden-only', + isSearchable: true, + options: ['inspect me to see my inputProps', 'yes I am!', ':)'], + inputProps: { + hidden: { + 'data-form-field': 'hidden-only', + 'data-validation': 'required', + }, + }, + }, + render: (args) => ( + + + + + + ), + play: async ({ canvas, canvasElement }) => { + const combobox = canvas.getByRole('combobox'); + await expect(combobox).not.toHaveAttribute('data-form-field'); + await expect(combobox).not.toHaveAttribute('data-validation'); + + const hiddenInput = queryHiddenInput(canvasElement, 'hidden-only'); + await expect(hiddenInput).toHaveAttribute('data-validation', 'required'); + }, }; export const Translations: Story = { @@ -1460,6 +2455,123 @@ export const Translations: Story = { ), + play: async (context) => { + // TODO: Fails the a11y scan until the "Remove all" aria-hidden bug is fixed; see createKeepsSelections. + const { canvas, userEvent } = context; + + await canvas.findByText('Elige una opción'); + + await pickOption(context, 'Manzana'); + await canvas.findByRole('button', { name: 'Quitar Manzana' }); + await canvas.findByLabelText('Quitar todo'); + + await pickOption(context, 'Plátano'); + await pickOption(context, 'Cereza'); + await userEvent.click(canvas.getByRole('combobox')); + await within(await canvas.findByRole('listbox')).findByText('Sin opciones'); + + await userEvent.type(canvas.getByRole('combobox'), 'Kiwi'); + await canvas.findByRole('option', { name: 'Añadir "Kiwi"' }); + }, +}; + +export const DeprecatedPropsPrecedence: Story = { + parameters: { + docs: { + description: { + story: + 'The deprecated top-level `placeholder` and `formatCreateLabel` props still work, and take precedence over their `translations` equivalents.', + }, + }, + }, + args: { + name: 'deprecated-props-dropdown', + isCreatable: true, + options: ['Apple', 'Banana', 'Cherry'], + placeholder: 'Explicit placeholder', + formatCreateLabel: (inputValue) => `Create tag: "${inputValue}"`, + translations: { + placeholder: 'Translated placeholder', + formatCreateLabel: (inputValue) => `Translated "${inputValue}"`, + }, + }, + render: (args) => ( + + + + ), + play: async ({ canvas, userEvent }) => { + await canvas.findByText('Explicit placeholder'); + await expect(canvas.queryByText('Translated placeholder')).toBeNull(); + + await userEvent.type(canvas.getByRole('combobox'), 'Kiwi'); + await canvas.findByRole('option', { name: 'Create tag: "Kiwi"' }); + await expect( + canvas.queryByRole('option', { name: 'Translated "Kiwi"' }) + ).toBeNull(); + }, +}; + +export const DeprecatedValidationMessagePrecedence: Story = { + parameters: { + docs: { + description: { + story: + 'The deprecated top-level `validationMessage` prop still works as a string or a function, and takes precedence over `translations.validationMessage`.', + }, + }, + }, + render: () => ( + + + + `Translated no match for "${inputValue}"`, + }} + validationMessage={({ inputValue }) => + `Explicit no match for "${inputValue}"` + } + /> + + ), + play: async ({ canvas, userEvent }) => { + const stringCombobox = canvas.getByRole('combobox', { + name: 'String validation message', + }); + await userEvent.click(stringCombobox); + await within(await canvas.findByRole('listbox')).findByText( + 'Explicit no options' + ); + await expect(canvas.queryByText('Translated no options')).toBeNull(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.queryByRole('listbox')).toBeNull(); + + const functionCombobox = canvas.getByRole('combobox', { + name: 'Function validation message', + }); + await userEvent.type(functionCombobox, 'zzz'); + await within(await canvas.findByRole('listbox')).findByText( + 'Explicit no match for "zzz"' + ); + await expect( + canvas.queryByText('Translated no match for "zzz"') + ).toBeNull(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.queryByRole('listbox')).toBeNull(); + }, }; export const Default: Story = { diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/SelectDropdown.tsx similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/SelectDropdown.tsx diff --git a/packages/gamut/src/Form/SelectDropdown/core/constants.ts b/packages/gamut/src/Form/inputs/SelectDropdown/core/constants.ts similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/core/constants.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/core/constants.ts diff --git a/packages/gamut/src/Form/SelectDropdown/core/styles.ts b/packages/gamut/src/Form/inputs/SelectDropdown/core/styles.ts similarity index 99% rename from packages/gamut/src/Form/SelectDropdown/core/styles.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/core/styles.ts index c35c652766..cccb777405 100644 --- a/packages/gamut/src/Form/SelectDropdown/core/styles.ts +++ b/packages/gamut/src/Form/inputs/SelectDropdown/core/styles.ts @@ -7,14 +7,14 @@ import { tagBaseStyles, tagLabelFontSize, tagLabelPadding, -} from '../../../Tag/styles'; +} from '../../../../Tag/styles'; import { formBaseComponentStyles, formBaseFieldStylesObject, formFieldDisabledStyles, formFieldPaddingStyles, InputSelectors, -} from '../../styles'; +} from '../../../styles'; import type { ControlState, OptionState } from '../types'; import type { BaseSelectComponentProps } from '../types/styles'; diff --git a/packages/gamut/src/Form/SelectDropdown/core/translations.ts b/packages/gamut/src/Form/inputs/SelectDropdown/core/translations.ts similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/core/translations.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/core/translations.ts diff --git a/packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/core/utils.test.tsx similarity index 99% rename from packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/core/utils.test.tsx index 4c1a357fe1..2797552074 100644 --- a/packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx +++ b/packages/gamut/src/Form/inputs/SelectDropdown/core/utils.test.tsx @@ -1,12 +1,12 @@ -import type { SelectOptionBase } from '../../utils'; +import type { SelectOptionBase } from '../../../utils'; +import type { ExtendedOption, SelectDropdownGroup } from '../types'; import { filterValueFromOptions, getCreatedOptionValue, isOptionGroup, isOptionsGrouped, removeValueFromSelectedOptions, -} from '../core/utils'; -import type { ExtendedOption, SelectDropdownGroup } from '../types'; +} from './utils'; describe('SelectDropdown Utils', () => { describe('getCreatedOptionValue', () => { diff --git a/packages/gamut/src/Form/SelectDropdown/core/utils.ts b/packages/gamut/src/Form/inputs/SelectDropdown/core/utils.ts similarity index 97% rename from packages/gamut/src/Form/SelectDropdown/core/utils.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/core/utils.ts index 16b273ae2d..43ee70ba60 100644 --- a/packages/gamut/src/Form/SelectDropdown/core/utils.ts +++ b/packages/gamut/src/Form/inputs/SelectDropdown/core/utils.ts @@ -1,7 +1,7 @@ import type { ActionMeta, Options as OptionsType } from 'react-select'; -import { isDefined } from '../../../utils/nullish'; -import type { SelectOptionBase } from '../../utils'; +import { isDefined } from '../../../../utils/nullish'; +import type { SelectOptionBase } from '../../../utils'; import type { BaseOnChangeProps, ExtendedOption, diff --git a/packages/gamut/src/Form/SelectDropdown/elements/constants.ts b/packages/gamut/src/Form/inputs/SelectDropdown/elements/constants.ts similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/elements/constants.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/elements/constants.ts diff --git a/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/elements/containers.tsx similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/elements/containers.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/elements/containers.tsx diff --git a/packages/gamut/src/Form/SelectDropdown/elements/controls.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/elements/controls.tsx similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/elements/controls.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/elements/controls.tsx diff --git a/packages/gamut/src/Form/SelectDropdown/elements/index.ts b/packages/gamut/src/Form/inputs/SelectDropdown/elements/index.ts similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/elements/index.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/elements/index.ts diff --git a/packages/gamut/src/Form/SelectDropdown/elements/multi-value.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/elements/multi-value.tsx similarity index 96% rename from packages/gamut/src/Form/SelectDropdown/elements/multi-value.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/elements/multi-value.tsx index e2c9dacdc7..5ca5f207d7 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/multi-value.tsx +++ b/packages/gamut/src/Form/inputs/SelectDropdown/elements/multi-value.tsx @@ -90,7 +90,8 @@ const CustomStyledRemoveAllDiv = styled('div')( export const RemoveAllButton = (props: SizedIndicatorProps) => { const { getStyles, - innerProps: { ...restInnerProps }, + // react-select marks its clear indicator aria-hidden, but ours is focusable + innerProps: { 'aria-hidden': _ariaHidden, ...restInnerProps }, selectProps: { size }, } = props; diff --git a/packages/gamut/src/Form/SelectDropdown/elements/options.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/elements/options.tsx similarity index 98% rename from packages/gamut/src/Form/SelectDropdown/elements/options.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/elements/options.tsx index 17b011b7d4..aeef639823 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/options.tsx +++ b/packages/gamut/src/Form/inputs/SelectDropdown/elements/options.tsx @@ -2,7 +2,7 @@ import { CheckIcon } from '@skillsoft/gamut-icons'; import type { SingleValueProps } from 'react-select'; import { components as SelectDropdownElements } from 'react-select'; -import { Box, FlexBox } from '../../../Box'; +import { Box, FlexBox } from '../../../../Box'; import type { CustomSelectComponentProps, ExtendedOption, diff --git a/packages/gamut/src/Form/SelectDropdown/hooks/__tests__/useNoOptionsAnnouncement.test.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/hooks/useNoOptionsAnnouncement.test.tsx similarity index 98% rename from packages/gamut/src/Form/SelectDropdown/hooks/__tests__/useNoOptionsAnnouncement.test.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/hooks/useNoOptionsAnnouncement.test.tsx index 56260c508e..a1a4c494d6 100644 --- a/packages/gamut/src/Form/SelectDropdown/hooks/__tests__/useNoOptionsAnnouncement.test.tsx +++ b/packages/gamut/src/Form/inputs/SelectDropdown/hooks/useNoOptionsAnnouncement.test.tsx @@ -1,7 +1,7 @@ import { act, render, renderHook } from '@testing-library/react'; import type { ReactNode } from 'react'; -import { useNoOptionsAnnouncement } from '../useNoOptionsAnnouncement'; +import { useNoOptionsAnnouncement } from './useNoOptionsAnnouncement'; /** * react-select's own `NoOptionsMessage` (rendered by `noOptionsMessageComponent`) diff --git a/packages/gamut/src/Form/SelectDropdown/hooks/useNoOptionsAnnouncement.ts b/packages/gamut/src/Form/inputs/SelectDropdown/hooks/useNoOptionsAnnouncement.ts similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/hooks/useNoOptionsAnnouncement.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/hooks/useNoOptionsAnnouncement.ts diff --git a/packages/gamut/src/Form/SelectDropdown/hooks/useSelectHandlers.ts b/packages/gamut/src/Form/inputs/SelectDropdown/hooks/useSelectHandlers.ts similarity index 98% rename from packages/gamut/src/Form/SelectDropdown/hooks/useSelectHandlers.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/hooks/useSelectHandlers.ts index 11f558726b..a58ccada74 100644 --- a/packages/gamut/src/Form/SelectDropdown/hooks/useSelectHandlers.ts +++ b/packages/gamut/src/Form/inputs/SelectDropdown/hooks/useSelectHandlers.ts @@ -3,7 +3,7 @@ import type * as React from 'react'; import { useCallback, useEffect, useState } from 'react'; import type { ActionMeta, Options as OptionsType } from 'react-select'; -import type { SelectOptionBase } from '../../utils'; +import type { SelectOptionBase } from '../../../utils'; import { ON_CHANGE_ACTION } from '../core/constants'; import { filterValueFromOptions, diff --git a/packages/gamut/src/Form/SelectDropdown/hooks/useSelectOptions.ts b/packages/gamut/src/Form/inputs/SelectDropdown/hooks/useSelectOptions.ts similarity index 94% rename from packages/gamut/src/Form/SelectDropdown/hooks/useSelectOptions.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/hooks/useSelectOptions.ts index 368283841e..7b13885511 100644 --- a/packages/gamut/src/Form/SelectDropdown/hooks/useSelectOptions.ts +++ b/packages/gamut/src/Form/inputs/SelectDropdown/hooks/useSelectOptions.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; -import type { SelectOptionBase } from '../../utils'; -import { parseOptions } from '../../utils'; +import type { SelectOptionBase } from '../../../utils'; +import { parseOptions } from '../../../utils'; import { isOptionsGrouped } from '../core/utils'; import type { SelectDropdownGroup, diff --git a/packages/gamut/src/Form/SelectDropdown/index.tsx b/packages/gamut/src/Form/inputs/SelectDropdown/index.tsx similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/index.tsx rename to packages/gamut/src/Form/inputs/SelectDropdown/index.tsx diff --git a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts b/packages/gamut/src/Form/inputs/SelectDropdown/types/component-props.ts similarity index 99% rename from packages/gamut/src/Form/SelectDropdown/types/component-props.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/types/component-props.ts index c331b3ca78..e214c2dbe2 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts +++ b/packages/gamut/src/Form/inputs/SelectDropdown/types/component-props.ts @@ -1,7 +1,7 @@ import type { Ref, SelectHTMLAttributes } from 'react'; import type { Options as OptionsType, Props as NamedProps } from 'react-select'; -import type { SelectComponentProps } from '../../inputs/Select'; +import type { SelectComponentProps } from '../../Select'; import type { SelectDropdownTranslations, ValidationMessage, diff --git a/packages/gamut/src/Form/SelectDropdown/types/index.ts b/packages/gamut/src/Form/inputs/SelectDropdown/types/index.ts similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/types/index.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/types/index.ts diff --git a/packages/gamut/src/Form/SelectDropdown/types/internal.ts b/packages/gamut/src/Form/inputs/SelectDropdown/types/internal.ts similarity index 97% rename from packages/gamut/src/Form/SelectDropdown/types/internal.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/types/internal.ts index 8d3ef4bf9f..d8e3bb5361 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/internal.ts +++ b/packages/gamut/src/Form/inputs/SelectDropdown/types/internal.ts @@ -1,7 +1,7 @@ import type * as React from 'react'; import type { DropdownIndicatorProps, GroupBase } from 'react-select'; -import type { SelectOptionBase } from '../../utils'; +import type { SelectOptionBase } from '../../../utils'; import type { SelectDropdownTranslations } from '../core/translations'; import type { OptionStrict } from './options'; import type { SelectDropdownSizes, SharedProps } from './styles'; diff --git a/packages/gamut/src/Form/SelectDropdown/types/options.ts b/packages/gamut/src/Form/inputs/SelectDropdown/types/options.ts similarity index 96% rename from packages/gamut/src/Form/SelectDropdown/types/options.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/types/options.ts index e8f1167f76..9103959306 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/options.ts +++ b/packages/gamut/src/Form/inputs/SelectDropdown/types/options.ts @@ -2,7 +2,7 @@ import type { GamutIconProps } from '@skillsoft/gamut-icons'; import type * as React from 'react'; import type { GroupBase } from 'react-select'; -import type { SelectOptions } from '../../inputs/Select'; +import type { SelectOptions } from '../../Select'; import type { SelectDropdownSizes } from './styles'; /** diff --git a/packages/gamut/src/Form/SelectDropdown/types/styles.ts b/packages/gamut/src/Form/inputs/SelectDropdown/types/styles.ts similarity index 100% rename from packages/gamut/src/Form/SelectDropdown/types/styles.ts rename to packages/gamut/src/Form/inputs/SelectDropdown/types/styles.ts diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/TextArea/TextArea.stories.tsx b/packages/gamut/src/Form/inputs/TextArea.stories.tsx similarity index 70% rename from packages/styleguide/src/lib/Atoms/FormInputs/TextArea/TextArea.stories.tsx rename to packages/gamut/src/Form/inputs/TextArea.stories.tsx index 87bc323627..993029a2e6 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/TextArea/TextArea.stories.tsx +++ b/packages/gamut/src/Form/inputs/TextArea.stories.tsx @@ -1,7 +1,11 @@ -import { FormGroup, TextArea } from '@skillsoft/gamut'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect } from 'storybook/test'; + +import { FormGroup } from '../elements/FormGroup'; +import { TextArea } from './TextArea'; const meta: Meta = { + title: 'Inputs & forms/TextArea', component: TextArea, args: { id: 'example-input', @@ -22,6 +26,10 @@ type Story = StoryObj; export const Default: Story = { args: {}, + play: async ({ canvas }) => { + const textarea = canvas.getByRole('textbox', { name: 'Enter text' }); + await expect(textarea).toHaveValue('Some text'); + }, }; export const Disabled: Story = { diff --git a/packages/gamut/src/Form/styles/shared-system-props.ts b/packages/gamut/src/Form/styles.ts similarity index 100% rename from packages/gamut/src/Form/styles/shared-system-props.ts rename to packages/gamut/src/Form/styles.ts diff --git a/packages/gamut/src/Form/styles/index.ts b/packages/gamut/src/Form/styles/index.ts deleted file mode 100644 index 758c4a1fd6..0000000000 --- a/packages/gamut/src/Form/styles/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './shared-system-props'; -export * from './Checkbox-styles'; -export * from './Radio-styles'; -export * from '../SelectDropdown/core/styles'; diff --git a/packages/gamut/src/Form/__tests__/utils.test.tsx b/packages/gamut/src/Form/utils.test.tsx similarity index 93% rename from packages/gamut/src/Form/__tests__/utils.test.tsx rename to packages/gamut/src/Form/utils.test.tsx index cda1177880..774f4c330e 100644 --- a/packages/gamut/src/Form/__tests__/utils.test.tsx +++ b/packages/gamut/src/Form/utils.test.tsx @@ -1,4 +1,4 @@ -import { parseOptions, parseSelectOptions } from '../utils'; +import { parseOptions, parseSelectOptions } from './utils'; describe(parseSelectOptions, () => { it('creates an option list', () => { diff --git a/packages/gamut/src/Form/utils.tsx b/packages/gamut/src/Form/utils.tsx index 683e2247c4..d54c0a4c24 100644 --- a/packages/gamut/src/Form/utils.tsx +++ b/packages/gamut/src/Form/utils.tsx @@ -6,7 +6,7 @@ import type { IconOption, OptionStrict, SelectDropdownSizes, -} from './SelectDropdown/types'; +} from './inputs/SelectDropdown/types'; export interface SelectOptionBase extends OptionStrict, SelectDropdownSizes { key?: string; diff --git a/packages/styleguide/src/lib/Atoms/FormElements/About.mdx b/packages/styleguide/src/lib/Atoms/FormElements/About.mdx deleted file mode 100644 index 95bf1c514f..0000000000 --- a/packages/styleguide/src/lib/Atoms/FormElements/About.mdx +++ /dev/null @@ -1,46 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks'; - -import { - AboutHeader, - addParentPath, - LinkTo, - TableOfContents, -} from '~styleguide/blocks'; - -import { parameters as formParameters } from './Form/Form.mdx'; -import { parameters as formGroupParameters } from './FormGroup/FormGroup.mdx'; -import { parameters as formGroupDescriptionParameters } from './FormGroupDescription/FormGroupDescription.mdx'; -import { parameters as formGroupLabelParameters } from './FormGroupLabel/FormGroupLabel.mdx'; -import { parameters as formRequiredTextParameters } from './FormRequiredText/FormRequiredText.mdx'; - -export const parameters = { - id: 'Atoms/FormElements', - title: 'FormElements', - subtitle: - 'Ancillary descriptions for input atoms tied 1:1 with values to be submitted in a form.', -}; - - - - - -You should generally never use these directly as a Gamut consumer. While our `Form` atoms compose the structure of these forms, we strongly recommend only creating forms with using `GridForm` or `ConnectedForm`. You should only use the form element atoms as individual elements if your interface doesn’t need to support the technical functions of a form. - -Both of our Form organisms provide the following benefits: - -1. **Accessibility**: All our forms handle accessibility styling and behaviors, passing tests out-of-the-box. -2. **Functionality**: Validation and submission logic is handled by the [react-hook-form](https://react-hook-form.com) library - -We recommend GridForm if you have a form that sticks to a 12x12 visual grid with consistent vertical rhythm and grid spacing. - -For everything else, we recommend ConnectedForm. - - diff --git a/packages/styleguide/src/lib/Atoms/FormElements/Form/Form.mdx b/packages/styleguide/src/lib/Atoms/FormElements/Form/Form.mdx deleted file mode 100644 index d0ee6ad449..0000000000 --- a/packages/styleguide/src/lib/Atoms/FormElements/Form/Form.mdx +++ /dev/null @@ -1,35 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as FormStories from './Form.stories'; - -export const parameters = { - title: 'Form', - subtitle: - 'A customizable `
` element for wrapping other form elements', - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Form/elements/Form.tsx', - }, -}; - - - - - -## Usage - -This is `Form` atom is the base component for other forms like the organisms: GridForm and ConnectedForm. - -### Best practices: - -- You should not use this component directly, instead use GridForm or ConnectedForm depending on your needs. - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.mdx b/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.mdx deleted file mode 100644 index 309d379d20..0000000000 --- a/packages/styleguide/src/lib/Atoms/FormElements/FormGroup/FormGroup.mdx +++ /dev/null @@ -1,82 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as FormGroupStories from './FormGroup.stories'; - -export const parameters = { - title: 'FormGroup', - subtitle: `A label and description wrapper for a form element that provides conditional styling.`, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Form/elements/FormGroup.tsx', - }, -}; - - - - - -## Usage - -Use `FormGroup` to provide a label and description for a form element. - -## Specifications - -### `htmlFor` - -It is best to provide `htmlFor` to both the FormGroup + inner form element to make it super accessible. If this is not provided, the FormLabel will default to `
`. If an `htmlFor` is provided, it will be a `