paris 0.21.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +1 -1
  3. package/src/helpers/OpenChangeEffect.tsx +21 -0
  4. package/src/helpers/useControllableState.test.ts +88 -0
  5. package/src/helpers/useControllableState.ts +59 -0
  6. package/src/stories/accordionselect/AccordionSelect.test.tsx +72 -0
  7. package/src/stories/accordionselect/AccordionSelect.tsx +22 -12
  8. package/src/stories/checkbox/Checkbox.test.tsx +53 -0
  9. package/src/stories/checkbox/Checkbox.tsx +21 -6
  10. package/src/stories/combobox/Combobox.test.tsx +111 -0
  11. package/src/stories/combobox/Combobox.tsx +192 -137
  12. package/src/stories/drawer/Drawer.module.scss +56 -15
  13. package/src/stories/drawer/Drawer.stories.tsx +287 -109
  14. package/src/stories/drawer/Drawer.test.tsx +486 -11
  15. package/src/stories/drawer/Drawer.tsx +366 -240
  16. package/src/stories/drawer/DrawerActions.tsx +28 -0
  17. package/src/stories/drawer/DrawerBottomPanel.tsx +55 -0
  18. package/src/stories/drawer/DrawerContext.tsx +31 -0
  19. package/src/stories/drawer/DrawerPage.tsx +37 -0
  20. package/src/stories/drawer/DrawerPageContext.tsx +35 -0
  21. package/src/stories/drawer/DrawerPaginationContext.tsx +22 -0
  22. package/src/stories/drawer/DrawerProgressBar.tsx +72 -0
  23. package/src/stories/drawer/DrawerSlotContext.tsx +172 -0
  24. package/src/stories/drawer/DrawerTitle.tsx +35 -0
  25. package/src/stories/drawer/index.ts +9 -0
  26. package/src/stories/menu/Menu.test.tsx +43 -0
  27. package/src/stories/menu/Menu.tsx +13 -2
  28. package/src/stories/popover/Popover.tsx +8 -5
  29. package/src/stories/select/Select.module.scss +1 -1
  30. package/src/stories/select/Select.test.tsx +108 -0
  31. package/src/stories/select/Select.tsx +121 -92
  32. package/src/test/render.tsx +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # paris
2
2
 
3
+ ## 0.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - eac5768: Add controlled/uncontrolled hybrid state support to Select, Combobox, Checkbox, AccordionSelect, and Popover via a shared `useControllableState` hook. Components now accept a `defaultValue` (or `defaultChecked` for Checkbox) prop for uncontrolled usage while preserving full backwards compatibility with existing controlled APIs. Combobox internal dual-state bug is also fixed.
8
+ - 5fa2efb: Add compound component API for Drawer with paginated page transitions, progress bar, and slot components.
9
+
10
+ **New components:** `DrawerPage`, `DrawerTitle`, `DrawerActions`, `DrawerBottomPanel`, `DrawerProgressBar`
11
+
12
+ **New hooks:** `useDrawer()`, `useDrawerPagination()`, `useIsPageActive()`
13
+
14
+ **New Drawer props:**
15
+
16
+ - `pageTransition` — animated page transitions (`'none'` | `'crossfade'` | `'slide'`)
17
+ - `progressBar` — show an animated progress bar at the top of the bottom panel, with customizable `fill`, `track`, and `height`
18
+ - `onAfterClose` — callback fired after the drawer's exit animation completes
19
+
20
+ Slot components use `createPortal` to render into Drawer chrome (title bar, actions, bottom panel) from within page content, preserving React context (forms, state). Bottom panel slots support `mode="replace|append"` with priority ordering and automatic separator borders.
21
+
22
+ Backward compatible with existing `<div key="...">` pagination pattern.
23
+
24
+ **Breaking:** Removed `bottomPanel` and `bottomPanelPadding` props. Use `<DrawerBottomPanel>` as a child instead:
25
+
26
+ ```diff
27
+ - <Drawer bottomPanel={<Content />}>
28
+ + <Drawer>
29
+ + <DrawerBottomPanel><Content /></DrawerBottomPanel>
30
+ ```
31
+
32
+ ## 0.21.3
33
+
34
+ ### Patch Changes
35
+
36
+ - d4b6239: fix(select,combobox): fix portaled dropdown width and alignment
37
+
38
+ - Use correct CSS variables (`--button-width`, `--input-width`) for portaled dropdown width in Select and Combobox since Headless UI v2.2.x no longer sets `--anchor-width`
39
+ - Measure actual pixel offset between container and input to align Combobox dropdown with the input container, not the input element
40
+ - Prevent `ComboboxButton` wrapper from intercepting keyboard events (e.g. space key) meant for the input
41
+
3
42
  ## 0.21.2
4
43
 
5
44
  ### Patch Changes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "paris",
3
3
  "author": "Sanil Chawla <sanil@slingshot.fm> (https://sanil.co)",
4
4
  "description": "Paris is Slingshot's React design system. It's a collection of reusable components, design tokens, and guidelines that help us build consistent, accessible, and performant user interfaces.",
5
- "version": "0.21.2",
5
+ "version": "0.22.0",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -0,0 +1,21 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * A renderless component that fires a callback when an `open` boolean changes.
5
+ * Designed to sit inside a Headless UI render-prop child to bridge
6
+ * the library's internal open state to a consumer-facing `onOpenChange` callback.
7
+ *
8
+ * Does not fire on the initial mount — only on subsequent changes.
9
+ */
10
+ export function OpenChangeEffect({ open, onOpenChange }: { open: boolean; onOpenChange?: (open: boolean) => void }) {
11
+ const previousOpen = useRef(open);
12
+
13
+ useEffect(() => {
14
+ if (previousOpen.current !== open) {
15
+ previousOpen.current = open;
16
+ onOpenChange?.(open);
17
+ }
18
+ }, [open, onOpenChange]);
19
+
20
+ return null;
21
+ }
@@ -0,0 +1,88 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { useControllableState } from './useControllableState';
3
+
4
+ describe('useControllableState', () => {
5
+ describe('uncontrolled mode', () => {
6
+ it('initializes to defaultValue when value is undefined', () => {
7
+ const { result } = renderHook(() => useControllableState({ defaultValue: 'hello' }));
8
+ expect(result.current[0]).toBe('hello');
9
+ });
10
+
11
+ it('updates internal state via setter', () => {
12
+ const { result } = renderHook(() => useControllableState({ defaultValue: 'a' }));
13
+ act(() => result.current[1]('b'));
14
+ expect(result.current[0]).toBe('b');
15
+ });
16
+
17
+ it('supports functional updates', () => {
18
+ const { result } = renderHook(() => useControllableState({ defaultValue: 1 }));
19
+ act(() => result.current[1]((prev) => prev + 1));
20
+ expect(result.current[0]).toBe(2);
21
+ });
22
+
23
+ it('calls onChange when setter is invoked', () => {
24
+ const onChange = vi.fn();
25
+ const { result } = renderHook(() => useControllableState({ defaultValue: 'a', onChange }));
26
+ act(() => result.current[1]('b'));
27
+ expect(onChange).toHaveBeenCalledWith('b');
28
+ });
29
+ });
30
+
31
+ describe('controlled mode', () => {
32
+ it('uses the provided value', () => {
33
+ const { result } = renderHook(() => useControllableState({ value: 'controlled', defaultValue: 'default' }));
34
+ expect(result.current[0]).toBe('controlled');
35
+ });
36
+
37
+ it('reflects value changes from props', () => {
38
+ const { result, rerender } = renderHook(
39
+ ({ value }) => useControllableState({ value, defaultValue: 'default' }),
40
+ { initialProps: { value: 'a' as string | undefined } },
41
+ );
42
+ expect(result.current[0]).toBe('a');
43
+
44
+ rerender({ value: 'b' });
45
+ expect(result.current[0]).toBe('b');
46
+ });
47
+
48
+ it('treats null as controlled (not uncontrolled)', () => {
49
+ const { result } = renderHook(() =>
50
+ useControllableState<string | null>({ value: null, defaultValue: 'default' }),
51
+ );
52
+ expect(result.current[0]).toBe(null);
53
+ });
54
+
55
+ it('calls onChange but does not update internal state', () => {
56
+ const onChange = vi.fn();
57
+ const { result } = renderHook(() =>
58
+ useControllableState({ value: 'locked', defaultValue: 'default', onChange }),
59
+ );
60
+ act(() => result.current[1]('new'));
61
+ expect(onChange).toHaveBeenCalledWith('new');
62
+ expect(result.current[0]).toBe('locked');
63
+ });
64
+ });
65
+
66
+ describe('dev warning', () => {
67
+ it('warns when both value and defaultValue are provided', () => {
68
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
69
+ renderHook(() => useControllableState({ value: 'a', defaultValue: 'b' }));
70
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining('received both `value` and `defaultValue`'));
71
+ spy.mockRestore();
72
+ });
73
+
74
+ it('does not warn when only value is provided', () => {
75
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
76
+ renderHook(() => useControllableState({ value: 'a' }));
77
+ expect(spy).not.toHaveBeenCalled();
78
+ spy.mockRestore();
79
+ });
80
+
81
+ it('does not warn when only defaultValue is provided', () => {
82
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
83
+ renderHook(() => useControllableState({ defaultValue: 'a' }));
84
+ expect(spy).not.toHaveBeenCalled();
85
+ spy.mockRestore();
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,59 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ type UseControllableStateProps<T> = {
4
+ value?: T;
5
+ defaultValue?: T;
6
+ onChange?: (value: T) => void;
7
+ };
8
+
9
+ export function useControllableState<T>({
10
+ value: controlledValue,
11
+ defaultValue,
12
+ onChange,
13
+ }: UseControllableStateProps<T>): [T, (next: T | ((prev: T) => T)) => void] {
14
+ const isControlled = controlledValue !== undefined;
15
+ const [internalValue, setInternalValue] = useState(defaultValue);
16
+
17
+ const hasWarned = useRef(false);
18
+ if (process.env.NODE_ENV !== 'production' && !hasWarned.current) {
19
+ hasWarned.current = true;
20
+ if (controlledValue !== undefined && defaultValue !== undefined) {
21
+ console.warn(
22
+ '[Paris] A component received both `value` and `defaultValue`. ' +
23
+ 'A component can be either controlled or uncontrolled, not both. ' +
24
+ 'Decide between using `value` (controlled) or `defaultValue` (uncontrolled) ' +
25
+ 'and remove the other. Defaulting to controlled mode.',
26
+ );
27
+ }
28
+ }
29
+
30
+ const resolvedValue = isControlled ? controlledValue : internalValue;
31
+
32
+ const onChangeRef = useRef(onChange);
33
+ onChangeRef.current = onChange;
34
+
35
+ const setValue = useCallback(
36
+ (next: T | ((prev: T) => T)) => {
37
+ if (typeof next === 'function') {
38
+ const updater = next as (prev: T) => T;
39
+ if (!isControlled) {
40
+ setInternalValue((prev) => {
41
+ const nextValue = updater(prev as T);
42
+ onChangeRef.current?.(nextValue);
43
+ return nextValue;
44
+ });
45
+ } else {
46
+ onChangeRef.current?.(updater(controlledValue));
47
+ }
48
+ } else {
49
+ if (!isControlled) {
50
+ setInternalValue(next);
51
+ }
52
+ onChangeRef.current?.(next);
53
+ }
54
+ },
55
+ [isControlled, controlledValue],
56
+ );
57
+
58
+ return [resolvedValue as T, setValue];
59
+ }
@@ -249,4 +249,76 @@ describe('AccordionSelect', () => {
249
249
  expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
250
250
  expect(screen.getByText('In a garden, under the stars')).toBeInTheDocument();
251
251
  });
252
+
253
+ describe('uncontrolled selection', () => {
254
+ it('renders with defaultValue', () => {
255
+ render(<AccordionSelect options={options} defaultValue="champagne" />);
256
+ expect(screen.getByText('In an alleyway, drinking champagne')).toBeInTheDocument();
257
+ });
258
+
259
+ it('renders with placeholder when no defaultValue', () => {
260
+ render(<AccordionSelect options={options} placeholder="Where were we?" />);
261
+ expect(screen.getByText('Where were we?')).toBeInTheDocument();
262
+ });
263
+
264
+ it('updates selection without external state', async () => {
265
+ const { user, container } = render(<AccordionSelect options={options} defaultValue="champagne" />);
266
+
267
+ const header = container.querySelector('[role="button"][tabindex="0"]') as HTMLElement;
268
+ await user.click(header);
269
+
270
+ await waitFor(() => {
271
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
272
+ });
273
+
274
+ await user.click(screen.getByText('On a rooftop, watching the sunset'));
275
+
276
+ await waitFor(() => {
277
+ expect(header).toHaveTextContent('On a rooftop, watching the sunset');
278
+ });
279
+ });
280
+
281
+ it('calls onChange in uncontrolled mode', async () => {
282
+ const handleChange = vi.fn();
283
+ const { user, container } = render(
284
+ <AccordionSelect options={options} defaultValue="champagne" onChange={handleChange} />,
285
+ );
286
+
287
+ const header = container.querySelector('[role="button"][tabindex="0"]') as HTMLElement;
288
+ await user.click(header);
289
+
290
+ await waitFor(() => {
291
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
292
+ });
293
+
294
+ await user.click(screen.getByText('On a rooftop, watching the sunset'));
295
+
296
+ expect(handleChange).toHaveBeenCalledWith(expect.objectContaining({ id: 'rooftop' }));
297
+ });
298
+
299
+ it('renders disabled option as selected when defaultValue points to it', async () => {
300
+ const disabledOptions = [
301
+ ...options,
302
+ { id: 'nowhere', node: 'Nowhere, it was all a dream', disabled: true },
303
+ ];
304
+ const { user, container } = render(<AccordionSelect options={disabledOptions} defaultValue="nowhere" />);
305
+
306
+ // Header shows the disabled option's text
307
+ const allMatches = screen.getAllByText('Nowhere, it was all a dream');
308
+ expect(allMatches.length).toBeGreaterThanOrEqual(1);
309
+
310
+ // Open and verify the option button is disabled
311
+ const header = container.querySelector('[role="button"][tabindex="0"]') as HTMLElement;
312
+ await user.click(header);
313
+
314
+ await waitFor(() => {
315
+ // Find the option button (not the header text)
316
+ const optionButtons = container.querySelectorAll('button[data-selected]');
317
+ const disabledButton = Array.from(optionButtons).find((btn) => btn.textContent?.includes('Nowhere'));
318
+ expect(disabledButton).toBeTruthy();
319
+ expect(disabledButton).toBeDisabled();
320
+ expect(disabledButton).toHaveAttribute('data-selected', 'true');
321
+ });
322
+ });
323
+ });
252
324
  });
@@ -3,7 +3,8 @@
3
3
  import { clsx } from 'clsx';
4
4
  import { AnimatePresence, motion } from 'framer-motion';
5
5
  import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
6
- import { useCallback, useEffect, useRef, useState } from 'react';
6
+ import { useEffect, useRef } from 'react';
7
+ import { useControllableState } from '../../helpers/useControllableState';
7
8
  import { Check, ChevronRight, Icon } from '../icon';
8
9
  import { TextWhenString } from '../utility';
9
10
  import styles from './AccordionSelect.module.scss';
@@ -37,6 +38,10 @@ export type AccordionSelectProps<T = Record<string, unknown>> = {
37
38
  * The currently selected option ID.
38
39
  */
39
40
  value?: string | null;
41
+ /**
42
+ * The initial selected option ID for uncontrolled mode. If `value` is provided, this is ignored.
43
+ */
44
+ defaultValue?: string | null;
40
45
  /**
41
46
  * Called when the user selects an option.
42
47
  */
@@ -109,6 +114,7 @@ export type AccordionSelectProps<T = Record<string, unknown>> = {
109
114
  export const AccordionSelect: FC<AccordionSelectProps> = ({
110
115
  options,
111
116
  value,
117
+ defaultValue,
112
118
  onChange,
113
119
  renderSelected,
114
120
  renderOption,
@@ -121,19 +127,23 @@ export const AccordionSelect: FC<AccordionSelectProps> = ({
121
127
  label,
122
128
  overrides,
123
129
  }) => {
124
- const [internalOpen, setInternalOpen] = useState(false);
125
- const open = controlledOpen ?? internalOpen;
130
+ const [open, setOpen] = useControllableState({
131
+ value: controlledOpen,
132
+ defaultValue: false,
133
+ onChange: onOpenChange,
134
+ });
126
135
  const rootRef = useRef<HTMLDivElement>(null);
127
136
 
128
- const setOpen = useCallback(
129
- (nextOpen: boolean) => {
130
- setInternalOpen(nextOpen);
131
- onOpenChange?.(nextOpen);
137
+ const [resolvedValue, setResolvedValue] = useControllableState<string | null>({
138
+ value,
139
+ defaultValue,
140
+ onChange: (id) => {
141
+ const option = options.find((o) => o.id === id);
142
+ if (option) onChange?.(option);
132
143
  },
133
- [onOpenChange],
134
- );
144
+ });
135
145
 
136
- const selectedOption = options.find((o) => o.id === value);
146
+ const selectedOption = options.find((o) => o.id === resolvedValue);
137
147
 
138
148
  useEffect(() => {
139
149
  if (!closeOnClickOutside || !open) {
@@ -207,7 +217,7 @@ export const AccordionSelect: FC<AccordionSelectProps> = ({
207
217
  className={clsx(styles.dropdownContent, overrides?.dropdownContent?.className)}
208
218
  >
209
219
  {options.map((option) => {
210
- const isOptionSelected = option.id === value;
220
+ const isOptionSelected = option.id === resolvedValue;
211
221
  return (
212
222
  <button
213
223
  key={option.id}
@@ -217,7 +227,7 @@ export const AccordionSelect: FC<AccordionSelectProps> = ({
217
227
  {...overrides?.option}
218
228
  className={clsx(styles.option, overrides?.option?.className)}
219
229
  onClick={() => {
220
- onChange?.(option);
230
+ setResolvedValue(option.id);
221
231
  if (closeOnSelect) setOpen(false);
222
232
  }}
223
233
  >
@@ -480,6 +480,59 @@ describe('Checkbox', () => {
480
480
  });
481
481
  });
482
482
 
483
+ // ─── Uncontrolled mode ───────────────────────────────────────────
484
+
485
+ describe('uncontrolled mode', () => {
486
+ it('renders unchecked when defaultChecked is false', () => {
487
+ render(<Checkbox defaultChecked={false}>Label</Checkbox>);
488
+ expect(screen.getByRole('checkbox')).not.toBeChecked();
489
+ });
490
+
491
+ it('renders checked when defaultChecked is true', () => {
492
+ render(<Checkbox defaultChecked={true}>Label</Checkbox>);
493
+ expect(screen.getByRole('checkbox')).toBeChecked();
494
+ });
495
+
496
+ it('toggles without external state', async () => {
497
+ const { user } = render(<Checkbox defaultChecked={false}>Toggle</Checkbox>);
498
+
499
+ const checkbox = screen.getByRole('checkbox');
500
+ expect(checkbox).not.toBeChecked();
501
+
502
+ await user.click(checkbox);
503
+ expect(checkbox).toBeChecked();
504
+
505
+ await user.click(checkbox);
506
+ expect(checkbox).not.toBeChecked();
507
+ });
508
+
509
+ it('calls onChange in uncontrolled mode', async () => {
510
+ const handleChange = vi.fn();
511
+ const { user } = render(
512
+ <Checkbox defaultChecked={false} onChange={handleChange}>
513
+ Toggle
514
+ </Checkbox>,
515
+ );
516
+
517
+ await user.click(screen.getByRole('checkbox'));
518
+ expect(handleChange).toHaveBeenCalledWith(true);
519
+ });
520
+
521
+ it('toggles switch kind without external state', async () => {
522
+ const { user } = render(
523
+ <Checkbox kind="switch" defaultChecked={false}>
524
+ Switch
525
+ </Checkbox>,
526
+ );
527
+
528
+ const switchEl = screen.getByRole('switch');
529
+ expect(switchEl).not.toBeChecked();
530
+
531
+ await user.click(switchEl);
532
+ expect(switchEl).toBeChecked();
533
+ });
534
+ });
535
+
483
536
  // ─── Edge cases ──────────────────────────────────────────────────
484
537
 
485
538
  describe('edge cases', () => {
@@ -1,8 +1,10 @@
1
+ 'use client';
1
2
  import { Switch } from '@headlessui/react';
2
3
  import * as RadixCheckbox from '@radix-ui/react-checkbox';
3
4
  import { clsx } from 'clsx';
4
5
  import type { FC, ReactNode } from 'react';
5
6
  import { useId } from 'react';
7
+ import { useControllableState } from '../../helpers/useControllableState';
6
8
  import { Check, Icon } from '../icon';
7
9
  import { TextWhenString, VisuallyHidden } from '../utility';
8
10
  import styles from './Checkbox.module.scss';
@@ -19,6 +21,8 @@ export type CheckboxProps = {
19
21
  * @default false
20
22
  */
21
23
  hideLabel?: boolean;
24
+ /** The initial checked state for uncontrolled mode. If `checked` is provided, this is ignored. */
25
+ defaultChecked?: boolean;
22
26
  /** The contents of the Checkbox. */
23
27
  children?: ReactNode | ReactNode[];
24
28
  } & Omit<React.ComponentPropsWithoutRef<'label'>, 'onChange' | 'children'>;
@@ -38,6 +42,7 @@ export type CheckboxProps = {
38
42
  export const Checkbox: FC<CheckboxProps> = ({
39
43
  kind = 'default',
40
44
  checked,
45
+ defaultChecked,
41
46
  onChange,
42
47
  disabled,
43
48
  hideLabel = false,
@@ -46,18 +51,28 @@ export const Checkbox: FC<CheckboxProps> = ({
46
51
  ...props
47
52
  }) => {
48
53
  const inputID = useId();
54
+ const [resolvedChecked, setResolvedChecked] = useControllableState({
55
+ value: checked,
56
+ defaultValue: defaultChecked,
57
+ onChange: onChange as ((value: boolean) => void) | undefined,
58
+ });
49
59
  return (
50
60
  <label
51
61
  htmlFor={inputID}
52
- className={clsx(styles.container, disabled && styles.disabled, className, checked && styles.checked)}
62
+ className={clsx(
63
+ styles.container,
64
+ disabled && styles.disabled,
65
+ className,
66
+ resolvedChecked && styles.checked,
67
+ )}
53
68
  {...props}
54
69
  >
55
70
  {(kind === 'default' || kind === 'surface' || kind === 'panel') && (
56
71
  <RadixCheckbox.Root
57
72
  id={inputID}
58
73
  className={clsx(styles.root, styles[kind])}
59
- checked={checked}
60
- onCheckedChange={onChange}
74
+ checked={resolvedChecked}
75
+ onCheckedChange={(v) => setResolvedChecked(!!v)}
61
76
  data-disabled={disabled}
62
77
  aria-details={typeof children === 'string' ? children : undefined}
63
78
  >
@@ -89,14 +104,14 @@ export const Checkbox: FC<CheckboxProps> = ({
89
104
  )}
90
105
  {kind === 'switch' && (
91
106
  <Switch
92
- checked={checked}
93
- onChange={onChange}
107
+ checked={resolvedChecked}
108
+ onChange={setResolvedChecked}
94
109
  className={styles.switchContainer}
95
110
  data-disabled={disabled}
96
111
  id={inputID}
97
112
  aria-details={typeof children === 'string' ? children : undefined}
98
113
  >
99
- <span aria-hidden="true" className={clsx(styles.knob, checked && styles.knobChecked)} />
114
+ <span aria-hidden="true" className={clsx(styles.knob, resolvedChecked && styles.knobChecked)} />
100
115
  </Switch>
101
116
  )}
102
117
  {(kind === 'default' || kind === 'switch') && !hideLabel && (
@@ -161,4 +161,115 @@ describe('Combobox', () => {
161
161
  expect(screen.getByText('Add "New Artist"')).toBeInTheDocument();
162
162
  });
163
163
  });
164
+
165
+ describe('uncontrolled mode', () => {
166
+ it('renders with placeholder when no defaultValue', () => {
167
+ render(<Combobox options={options} placeholder="Search..." />);
168
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
169
+ });
170
+
171
+ it('renders with defaultValue', () => {
172
+ render(
173
+ <Combobox options={options} defaultValue={{ id: '1', node: 'Mia Dolan' }} placeholder="Search..." />,
174
+ );
175
+ expect(screen.getByDisplayValue('Mia Dolan')).toBeInTheDocument();
176
+ });
177
+
178
+ it('selects an option without external state', async () => {
179
+ const { user } = render(<Combobox options={options} defaultValue={null} placeholder="Search..." />);
180
+ const input = screen.getByPlaceholderText('Search...');
181
+ await user.click(input);
182
+
183
+ await waitFor(() => {
184
+ expect(screen.getByText('Amy Brandt')).toBeInTheDocument();
185
+ });
186
+
187
+ await user.click(screen.getByText('Amy Brandt'));
188
+
189
+ await waitFor(() => {
190
+ expect(screen.getByDisplayValue('Amy Brandt')).toBeInTheDocument();
191
+ });
192
+ });
193
+
194
+ it('calls onChange in uncontrolled mode', async () => {
195
+ const handleChange = vi.fn();
196
+ const { user } = render(
197
+ <Combobox options={options} defaultValue={null} onChange={handleChange} placeholder="Search..." />,
198
+ );
199
+ const input = screen.getByPlaceholderText('Search...');
200
+ await user.click(input);
201
+
202
+ await waitFor(() => {
203
+ expect(screen.getByText('Amy Brandt')).toBeInTheDocument();
204
+ });
205
+
206
+ await user.click(screen.getByText('Amy Brandt'));
207
+ expect(handleChange).toHaveBeenCalledWith(expect.objectContaining({ id: '3', node: 'Amy Brandt' }));
208
+ });
209
+
210
+ it('clears selection in uncontrolled mode', async () => {
211
+ const { user, container } = render(
212
+ <Combobox options={options} defaultValue={{ id: '1', node: 'Mia Dolan' }} placeholder="Search..." />,
213
+ );
214
+
215
+ expect(screen.getByDisplayValue('Mia Dolan')).toBeInTheDocument();
216
+
217
+ const clearButton = container.querySelector('button[aria-details="Clear"]');
218
+ expect(clearButton).toBeInTheDocument();
219
+ await user.click(clearButton!);
220
+
221
+ await waitFor(() => {
222
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
223
+ expect(screen.queryByDisplayValue('Mia Dolan')).not.toBeInTheDocument();
224
+ });
225
+ });
226
+
227
+ it('renders with defaultValue using non-string node', () => {
228
+ const optionsWithNode = [
229
+ { id: '1', node: <span data-testid="custom-node">Custom Mia</span> },
230
+ { id: '2', node: 'Sebastian Wilder' },
231
+ ];
232
+ render(
233
+ <Combobox
234
+ options={optionsWithNode}
235
+ defaultValue={{ id: '1', node: <span data-testid="custom-node">Custom Mia</span> }}
236
+ placeholder="Search..."
237
+ />,
238
+ );
239
+ expect(screen.getByTestId('custom-node')).toBeInTheDocument();
240
+ });
241
+ });
242
+
243
+ describe('onOpenChange', () => {
244
+ it('calls onOpenChange when the dropdown opens', async () => {
245
+ const handleOpenChange = vi.fn();
246
+ const { user } = render(
247
+ <Combobox options={options} onOpenChange={handleOpenChange} placeholder="Search..." />,
248
+ );
249
+
250
+ await user.click(screen.getByPlaceholderText('Search...'));
251
+
252
+ await waitFor(() => {
253
+ expect(handleOpenChange).toHaveBeenCalledWith(true);
254
+ });
255
+ });
256
+
257
+ it('calls onOpenChange when the dropdown closes after selection', async () => {
258
+ const handleOpenChange = vi.fn();
259
+ const { user } = render(<ControlledCombobox onOpenChange={handleOpenChange} />);
260
+
261
+ const input = screen.getByPlaceholderText('Search...');
262
+ await user.click(input);
263
+
264
+ await waitFor(() => {
265
+ expect(screen.getByText('Amy Brandt')).toBeInTheDocument();
266
+ });
267
+
268
+ await user.click(screen.getByText('Amy Brandt'));
269
+
270
+ await waitFor(() => {
271
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
272
+ });
273
+ });
274
+ });
164
275
  });