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:
,
- });
-
- 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: () => (
-
- ),
- CheckCircledIcon: () => (
-
- ),
- StreakIcon: () => (
-
- ),
-}));
-
-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:
,
- });
-
- 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: () => (
-
- ),
- ArrowChevronDownIcon: () => (
-
- ),
- DataTransferVerticalIcon: () => (
-
- ),
- CalendarIcon: () => (
-
- ),
- EarthIcon: () => (
-
- ),
-}));
-
-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: () => (
-
- ),
- ArrowChevronDownIcon: () => (
-
- ),
- DataTransferVerticalIcon: () => (
-
- ),
- CalendarIcon: () => (
-
- ),
- EarthIcon: () => (
-
- ),
-}));
-
-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: () => (
@@ -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: () => (
@@ -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 `