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 (
+ <>
+
+
+ >
+ );
+};
+
+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]}