From cc58309f498a316a1f7a8bbc9068d490e953a1a4 Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Mon, 9 Nov 2026 22:30:50 -0500 Subject: [PATCH] feat: add exclusive options to set fields Add support for `exclusiveOptions` on set fields (listbox and select variants). When an exclusive option is selected, all other options are disabled until it is deselected. Selecting an exclusive option deselects all other options. Upgrade @douglasneuroinformatics/libui-form-types to ^1.6.0. Closes #127 Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01W53iUSBaRj1XfMJBUUzmod --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/components/Form/Form.test.tsx | 37 +++ .../Form/SetField/SetField.spec.tsx | 214 ++++++++++++++++++ .../Form/SetField/SetField.stories.tsx | 58 +++++ src/components/Form/SetField/SetField.tsx | 30 ++- .../Form/SetField/SetFieldListbox.tsx | 4 +- .../Form/SetField/SetFieldSelect.tsx | 9 +- 8 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 src/components/Form/SetField/SetField.spec.tsx diff --git a/package.json b/package.json index 9eb0e9b..4130d0c 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@douglasneuroinformatics/libjs": "^3.2.1", - "@douglasneuroinformatics/libui-form-types": "^1.5.0", + "@douglasneuroinformatics/libui-form-types": "^1.6.0", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cf3fe3..d0c2436 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,8 +14,8 @@ importers: specifier: ^3.2.1 version: 3.2.1(neverthrow@8.2.0)(zod@3.25.76) '@douglasneuroinformatics/libui-form-types': - specifier: ^1.5.0 - version: 1.5.0(@types/react@19.2.17) + specifier: ^1.6.0 + version: 1.6.0(@types/react@19.2.17) '@radix-ui/react-accordion': specifier: ^1.2.3 version: 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -506,9 +506,9 @@ packages: neverthrow: ^8.2.0 zod: ^3.25.67 || 4.x - '@douglasneuroinformatics/libui-form-types@1.5.0': + '@douglasneuroinformatics/libui-form-types@1.6.0': resolution: - { integrity: sha512-OvraW/G9kwwMoVTXjDJmBtXMM+uwk/JqRX7/Extek6rBaznStAN3prR2fXiz7lnYA3sRluMSBgEH5uMU4lVg/A== } + { integrity: sha512-0KDOdj/ODDN7XRRWFI+SD/GBJKekzvY534j0TUewZhMSeuslGAFA3H3oqA0qz12heUDTtga0qOKGvHb7QhXvBA== } peerDependencies: '@types/react': 19.x peerDependenciesMeta: @@ -6842,7 +6842,7 @@ snapshots: type-fest: 4.41.0 zod: 3.25.76 - '@douglasneuroinformatics/libui-form-types@1.5.0(@types/react@19.2.17)': + '@douglasneuroinformatics/libui-form-types@1.6.0(@types/react@19.2.17)': dependencies: type-fest: 4.41.0 optionalDependencies: diff --git a/src/components/Form/Form.test.tsx b/src/components/Form/Form.test.tsx index 176c047..be37550 100644 --- a/src/components/Form/Form.test.tsx +++ b/src/components/Form/Form.test.tsx @@ -362,4 +362,41 @@ describe('Form', () => { expect(screen.queryByTestId('datetime-time-input')).not.toBeInTheDocument(); }); }); + + describe('set field exclusive options', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should pass exclusiveOptions from the content to the rendered field', async () => { + render( +
+ ); + const headache = screen.getByRole('checkbox', { name: 'Headache' }); + const none = screen.getByRole('checkbox', { name: 'None of the above' }); + await userEvent.click(headache); + await userEvent.click(none); + expect(headache).toHaveAttribute('data-state', 'unchecked'); + expect(headache).toBeDisabled(); + expect(screen.getByRole('checkbox', { name: 'Nausea' })).toBeDisabled(); + expect(none).toBeEnabled(); + fireEvent.submit(screen.getByTestId(testid)); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit).toHaveBeenCalledWith({ symptoms: new Set(['none']) }); + }); + }); }); diff --git a/src/components/Form/SetField/SetField.spec.tsx b/src/components/Form/SetField/SetField.spec.tsx new file mode 100644 index 0000000..1d6e4d2 --- /dev/null +++ b/src/components/Form/SetField/SetField.spec.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; + +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { SetField } from './SetField.tsx'; + +import type { SetFieldProps } from './SetField.tsx'; + +type Option = 'a' | 'b' | 'c' | 'd'; + +type TestSetFieldProps = Pick, 'disabled' | 'exclusiveOptions' | 'readOnly' | 'variant'> & { + initialValue?: Option[]; +}; + +const options: { [K in Option]: string } = { a: 'Option A', b: 'Option B', c: 'Option C', d: 'Option D' }; + +const TestSetField = ({ initialValue, ...props }: TestSetFieldProps) => { + const [error, setError] = useState(); + const [value, setValue] = useState | undefined>(initialValue ? new Set(initialValue) : undefined); + return ( + <> + + + {Array.from(value ?? []) + .sort() + .join(',')} + + + ); +}; + +const expectValue = (expected: Option[]) => { + expect(screen.getByTestId('value')).toHaveTextContent(new RegExp(`^${expected.join(',')}$`)); +}; + +describe('SetField', () => { + describe('listbox', () => { + const getOption = (option: Option) => screen.getByRole('checkbox', { name: options[option] }); + + it('should render a checkbox for each option', () => { + render(); + expect(screen.getAllByRole('checkbox')).toHaveLength(4); + }); + + it('should allow selecting all options together if there are no exclusive options', async () => { + render(); + for (const option of ['a', 'b', 'c', 'd'] as const) { + await userEvent.click(getOption(option)); + expect(getOption(option)).toBeEnabled(); + } + expectValue(['a', 'b', 'c', 'd']); + }); + + it('should disable the other options when an exclusive option is selected', async () => { + render(); + await userEvent.click(getOption('c')); + expect(getOption('c')).toBeEnabled(); + expect(getOption('a')).toBeDisabled(); + expect(getOption('b')).toBeDisabled(); + expect(getOption('d')).toBeDisabled(); + await userEvent.click(getOption('a')); + expectValue(['c']); + }); + + it('should enable the other options when the exclusive option is deselected', async () => { + render(); + await userEvent.click(getOption('c')); + await userEvent.click(getOption('c')); + expect(getOption('a')).toBeEnabled(); + await userEvent.click(getOption('a')); + expectValue(['a']); + }); + + it('should deselect the other options when an exclusive option is selected', async () => { + render(); + await userEvent.click(getOption('a')); + await userEvent.click(getOption('b')); + expectValue(['a', 'b']); + await userEvent.click(getOption('c')); + expectValue(['c']); + expect(getOption('a')).toBeDisabled(); + }); + + it('should make each exclusive option exclusive of the other exclusive options', async () => { + render(); + await userEvent.click(getOption('c')); + expect(getOption('d')).toBeDisabled(); + await userEvent.click(getOption('c')); + await userEvent.click(getOption('d')); + expectValue(['d']); + expect(getOption('c')).toBeDisabled(); + }); + + it('should not modify a value that already breaks the rule', async () => { + render(); + expectValue(['a', 'c']); + expect(getOption('a')).toBeDisabled(); + expect(getOption('c')).toBeEnabled(); + await userEvent.click(getOption('c')); + expectValue(['a']); + expect(getOption('b')).toBeEnabled(); + }); + + it('should allow deselecting exclusive options that were selected together', async () => { + render(); + expect(getOption('c')).toBeEnabled(); + expect(getOption('d')).toBeEnabled(); + await userEvent.click(getOption('c')); + expectValue(['d']); + }); + + it.each(['disabled', 'readOnly'] as const)('should disable all options if the field is %s', (prop) => { + render(); + for (const checkbox of screen.getAllByRole('checkbox')) { + expect(checkbox).toBeDisabled(); + } + }); + }); + + describe('select', () => { + const openMenu = async () => { + await userEvent.click(screen.getByRole('button')); + return screen.findByRole('menu'); + }; + const getOption = (option: Option) => screen.getByRole('menuitemcheckbox', { name: options[option] }); + + it('should render a menu item for each option', async () => { + render(); + await openMenu(); + expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(4); + }); + + it('should allow selecting all options together if there are no exclusive options', async () => { + render(); + await openMenu(); + for (const option of ['a', 'b', 'c', 'd'] as const) { + await userEvent.click(getOption(option)); + expect(getOption(option)).not.toHaveAttribute('aria-disabled', 'true'); + } + expectValue(['a', 'b', 'c', 'd']); + }); + + it('should disable the other options when an exclusive option is selected', async () => { + render(); + await openMenu(); + await userEvent.click(getOption('c')); + expect(getOption('c')).not.toHaveAttribute('aria-disabled', 'true'); + expect(getOption('a')).toHaveAttribute('aria-disabled', 'true'); + expect(getOption('b')).toHaveAttribute('aria-disabled', 'true'); + expect(getOption('d')).toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(getOption('a'), { pointerEventsCheck: 0 }); + expectValue(['c']); + }); + + it('should enable the other options when the exclusive option is deselected', async () => { + render(); + await openMenu(); + await userEvent.click(getOption('c')); + await userEvent.click(getOption('c')); + expect(getOption('a')).not.toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(getOption('a')); + expectValue(['a']); + }); + + it('should deselect the other options when an exclusive option is selected', async () => { + render(); + await openMenu(); + await userEvent.click(getOption('a')); + await userEvent.click(getOption('b')); + expectValue(['a', 'b']); + await userEvent.click(getOption('c')); + expectValue(['c']); + expect(getOption('a')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should make each exclusive option exclusive of the other exclusive options', async () => { + render(); + await openMenu(); + await userEvent.click(getOption('c')); + expect(getOption('d')).toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(getOption('c')); + await userEvent.click(getOption('d')); + expectValue(['d']); + expect(getOption('c')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should not modify a value that already breaks the rule', async () => { + render(); + await openMenu(); + expectValue(['a', 'c']); + expect(getOption('a')).toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(getOption('c')); + expectValue(['a']); + expect(getOption('b')).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it.each(['disabled', 'readOnly'] as const)('should disable the trigger if the field is %s', (prop) => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + }); +}); diff --git a/src/components/Form/SetField/SetField.stories.tsx b/src/components/Form/SetField/SetField.stories.tsx index 286aa16..167fa45 100644 --- a/src/components/Form/SetField/SetField.stories.tsx +++ b/src/components/Form/SetField/SetField.stories.tsx @@ -6,6 +6,8 @@ import { SetField } from './SetField.tsx'; type Fruit = 'apple' | 'banana' | 'blueberry' | 'mango'; +type FruitOrNone = 'none' | Fruit; + type Story = StoryObj>; export default { component: SetField } as Meta; @@ -61,3 +63,59 @@ export const Listbox: Story = { } ] }; + +export const WithExclusiveOption: StoryObj> = { + decorators: [ + (Story) => { + const [value, setValue] = useState | undefined>(); + return ( + + ); + } + ] +}; + +export const SelectWithExclusiveOption: StoryObj> = { + decorators: [ + (Story) => { + const [value, setValue] = useState | undefined>(); + return ( + + ); + } + ] +}; diff --git a/src/components/Form/SetField/SetField.tsx b/src/components/Form/SetField/SetField.tsx index 94e55c7..76359e5 100644 --- a/src/components/Form/SetField/SetField.tsx +++ b/src/components/Form/SetField/SetField.tsx @@ -18,11 +18,35 @@ export const SetField = (props: SetFieldProps) => } }, [props.value]); + const exclusiveOptions = new Set(props.exclusiveOptions); + + /** + * An option is disabled when a different option that is exclusive is selected. A selected exclusive + * option is never disabled, so that the user can always deselect it (e.g., if several exclusive + * options were set programmatically). + */ + const isOptionDisabled = (option: T) => { + if (!props.value || (props.value.has(option) && exclusiveOptions.has(option))) { + return false; + } + for (const selected of props.value) { + if (selected !== option && exclusiveOptions.has(selected)) { + return true; + } + } + return false; + }; + const handleCheckedChange = (option: T, isChecked: boolean) => { if (isChecked) { const updatedValue = new Set(props.value); updatedValue.delete(option); props.setValue(updatedValue); + } else if (isOptionDisabled(option)) { + return; + } else if (exclusiveOptions.has(option)) { + // selecting an exclusive option deselects all other options + props.setValue(new Set([option])); } else { const updatedValue = new Set(props.value); updatedValue.add(option); @@ -30,8 +54,10 @@ export const SetField = (props: SetFieldProps) => } }; + const variantProps = { isOptionDisabled, onCheckedChange: handleCheckedChange }; + return match(props) - .with({ variant: 'select' }, (props) => ) - .with({ variant: 'listbox' }, (props) => ) + .with({ variant: 'select' }, (props) => ) + .with({ variant: 'listbox' }, (props) => ) .exhaustive(); }; diff --git a/src/components/Form/SetField/SetFieldListbox.tsx b/src/components/Form/SetField/SetFieldListbox.tsx index ab05d6b..91c5e60 100644 --- a/src/components/Form/SetField/SetFieldListbox.tsx +++ b/src/components/Form/SetField/SetFieldListbox.tsx @@ -5,6 +5,7 @@ import { FieldGroup } from '../FieldGroup/FieldGroup.tsx'; import type { SetFieldProps } from './SetField.tsx'; export type SetFieldListboxProps = SetFieldProps & { + isOptionDisabled: (option: T) => boolean; onCheckedChange: (option: T, isChecked: boolean) => void; }; @@ -12,6 +13,7 @@ export const SetFieldListbox = ({ description, disabled, error, + isOptionDisabled, label, name, onCheckedChange, @@ -30,7 +32,7 @@ export const SetFieldListbox = ({ { onCheckedChange(option as T, !checked); diff --git a/src/components/Form/SetField/SetFieldSelect.tsx b/src/components/Form/SetField/SetFieldSelect.tsx index e13233e..f4ceda6 100644 --- a/src/components/Form/SetField/SetFieldSelect.tsx +++ b/src/components/Form/SetField/SetFieldSelect.tsx @@ -5,6 +5,7 @@ import { FieldGroup } from '../FieldGroup/FieldGroup.tsx'; import type { SetFieldProps } from './SetField.tsx'; export type SetFieldSelectProps = SetFieldProps & { + isOptionDisabled: (option: T) => boolean; onCheckedChange: (option: T, isChecked: boolean) => void; }; @@ -12,6 +13,7 @@ export const SetFieldSelect = ({ description, disabled, error, + isOptionDisabled, label, name, onCheckedChange, @@ -42,13 +44,18 @@ export const SetFieldSelect = ({ {Object.keys(options).map((option) => { const checked = value.has(option as T); + const isDisabled = isOptionDisabled(option as T); return ( { event.preventDefault(); - onCheckedChange(option as T, value.has(option as T)); + if (isDisabled) { + return; + } + onCheckedChange(option as T, checked); }} > {options[option as T]}