@fragments-sdk/ui 0.12.0 → 0.13.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 (123) hide show
  1. package/dist/components/Accordion/index.cjs +11 -4
  2. package/dist/components/Accordion/index.cjs.map +1 -1
  3. package/dist/components/Accordion/index.d.ts +3 -3
  4. package/dist/components/Accordion/index.d.ts.map +1 -1
  5. package/dist/components/Accordion/index.js +11 -4
  6. package/dist/components/Accordion/index.js.map +1 -1
  7. package/dist/components/Collapsible/index.cjs +45 -10
  8. package/dist/components/Collapsible/index.cjs.map +1 -1
  9. package/dist/components/Collapsible/index.d.ts +6 -12
  10. package/dist/components/Collapsible/index.d.ts.map +1 -1
  11. package/dist/components/Collapsible/index.js +45 -10
  12. package/dist/components/Collapsible/index.js.map +1 -1
  13. package/dist/components/Combobox/index.cjs +18 -9
  14. package/dist/components/Combobox/index.cjs.map +1 -1
  15. package/dist/components/Combobox/index.d.ts +8 -12
  16. package/dist/components/Combobox/index.d.ts.map +1 -1
  17. package/dist/components/Combobox/index.js +18 -9
  18. package/dist/components/Combobox/index.js.map +1 -1
  19. package/dist/components/Command/index.cjs +54 -21
  20. package/dist/components/Command/index.cjs.map +1 -1
  21. package/dist/components/Command/index.d.ts +2 -2
  22. package/dist/components/Command/index.d.ts.map +1 -1
  23. package/dist/components/Command/index.js +54 -21
  24. package/dist/components/Command/index.js.map +1 -1
  25. package/dist/components/DataTable/index.cjs +13 -1
  26. package/dist/components/DataTable/index.cjs.map +1 -1
  27. package/dist/components/DataTable/index.d.ts.map +1 -1
  28. package/dist/components/DataTable/index.js +13 -1
  29. package/dist/components/DataTable/index.js.map +1 -1
  30. package/dist/components/DatePicker/index.d.ts +2 -3
  31. package/dist/components/DatePicker/index.d.ts.map +1 -1
  32. package/dist/components/Dialog/index.cjs +12 -9
  33. package/dist/components/Dialog/index.cjs.map +1 -1
  34. package/dist/components/Dialog/index.d.ts +8 -12
  35. package/dist/components/Dialog/index.d.ts.map +1 -1
  36. package/dist/components/Dialog/index.js +12 -9
  37. package/dist/components/Dialog/index.js.map +1 -1
  38. package/dist/components/Drawer/index.cjs +12 -9
  39. package/dist/components/Drawer/index.cjs.map +1 -1
  40. package/dist/components/Drawer/index.d.ts +8 -12
  41. package/dist/components/Drawer/index.d.ts.map +1 -1
  42. package/dist/components/Drawer/index.js +12 -9
  43. package/dist/components/Drawer/index.js.map +1 -1
  44. package/dist/components/Menu/index.cjs +30 -16
  45. package/dist/components/Menu/index.cjs.map +1 -1
  46. package/dist/components/Menu/index.d.ts +17 -25
  47. package/dist/components/Menu/index.d.ts.map +1 -1
  48. package/dist/components/Menu/index.js +30 -16
  49. package/dist/components/Menu/index.js.map +1 -1
  50. package/dist/components/NavigationMenu/NavigationMenuContext.cjs.map +1 -1
  51. package/dist/components/NavigationMenu/NavigationMenuContext.d.ts +1 -0
  52. package/dist/components/NavigationMenu/NavigationMenuContext.d.ts.map +1 -1
  53. package/dist/components/NavigationMenu/NavigationMenuContext.js.map +1 -1
  54. package/dist/components/NavigationMenu/index.cjs +43 -11
  55. package/dist/components/NavigationMenu/index.cjs.map +1 -1
  56. package/dist/components/NavigationMenu/index.d.ts.map +1 -1
  57. package/dist/components/NavigationMenu/index.js +43 -11
  58. package/dist/components/NavigationMenu/index.js.map +1 -1
  59. package/dist/components/NavigationMenu/useNavigationMenu.cjs +2 -0
  60. package/dist/components/NavigationMenu/useNavigationMenu.cjs.map +1 -1
  61. package/dist/components/NavigationMenu/useNavigationMenu.d.ts +1 -0
  62. package/dist/components/NavigationMenu/useNavigationMenu.d.ts.map +1 -1
  63. package/dist/components/NavigationMenu/useNavigationMenu.js +2 -0
  64. package/dist/components/NavigationMenu/useNavigationMenu.js.map +1 -1
  65. package/dist/components/Popover/index.cjs +11 -10
  66. package/dist/components/Popover/index.cjs.map +1 -1
  67. package/dist/components/Popover/index.d.ts +8 -12
  68. package/dist/components/Popover/index.d.ts.map +1 -1
  69. package/dist/components/Popover/index.js +11 -10
  70. package/dist/components/Popover/index.js.map +1 -1
  71. package/dist/components/Select/index.cjs +7 -6
  72. package/dist/components/Select/index.cjs.map +1 -1
  73. package/dist/components/Select/index.d.ts +6 -9
  74. package/dist/components/Select/index.d.ts.map +1 -1
  75. package/dist/components/Select/index.js +7 -6
  76. package/dist/components/Select/index.js.map +1 -1
  77. package/dist/components/Sidebar/index.cjs +71 -24
  78. package/dist/components/Sidebar/index.cjs.map +1 -1
  79. package/dist/components/Sidebar/index.d.ts +21 -33
  80. package/dist/components/Sidebar/index.d.ts.map +1 -1
  81. package/dist/components/Sidebar/index.js +71 -24
  82. package/dist/components/Sidebar/index.js.map +1 -1
  83. package/dist/components/Tooltip/index.cjs +12 -6
  84. package/dist/components/Tooltip/index.cjs.map +1 -1
  85. package/dist/components/Tooltip/index.d.ts.map +1 -1
  86. package/dist/components/Tooltip/index.js +12 -6
  87. package/dist/components/Tooltip/index.js.map +1 -1
  88. package/dist/datepicker.cjs +24 -10
  89. package/dist/datepicker.cjs.map +1 -1
  90. package/dist/datepicker.js +24 -10
  91. package/dist/datepicker.js.map +1 -1
  92. package/fragments.json +1 -1
  93. package/package.json +2 -2
  94. package/src/components/Accordion/Accordion.test.tsx +33 -0
  95. package/src/components/Accordion/index.tsx +10 -3
  96. package/src/components/Collapsible/Collapsible.test.tsx +41 -0
  97. package/src/components/Collapsible/index.tsx +53 -16
  98. package/src/components/Combobox/Combobox.test.tsx +55 -0
  99. package/src/components/Combobox/index.tsx +23 -17
  100. package/src/components/Command/Command.test.tsx +93 -0
  101. package/src/components/Command/index.tsx +61 -18
  102. package/src/components/DataTable/DataTable.test.tsx +11 -2
  103. package/src/components/DataTable/index.tsx +22 -2
  104. package/src/components/DatePicker/DatePicker.test.tsx +79 -0
  105. package/src/components/DatePicker/index.tsx +29 -14
  106. package/src/components/Dialog/Dialog.test.tsx +23 -0
  107. package/src/components/Dialog/index.tsx +15 -16
  108. package/src/components/Drawer/Drawer.test.tsx +27 -0
  109. package/src/components/Drawer/index.tsx +15 -16
  110. package/src/components/Menu/index.tsx +35 -30
  111. package/src/components/NavigationMenu/NavigationMenu.fragment.tsx +1 -1
  112. package/src/components/NavigationMenu/NavigationMenu.test.tsx +40 -4
  113. package/src/components/NavigationMenu/NavigationMenuContext.ts +3 -0
  114. package/src/components/NavigationMenu/index.tsx +49 -13
  115. package/src/components/NavigationMenu/useNavigationMenu.ts +4 -0
  116. package/src/components/Popover/Popover.test.tsx +23 -0
  117. package/src/components/Popover/index.tsx +15 -18
  118. package/src/components/Select/Select.test.tsx +41 -0
  119. package/src/components/Select/index.tsx +10 -12
  120. package/src/components/Sidebar/Sidebar.test.tsx +83 -4
  121. package/src/components/Sidebar/index.tsx +87 -45
  122. package/src/components/Tooltip/Tooltip.test.tsx +17 -0
  123. package/src/components/Tooltip/index.tsx +46 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fragments-sdk/ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "description": "Customizable UI components built on Base UI headless primitives",
6
6
  "author": "Conan McNicholl",
@@ -230,7 +230,7 @@
230
230
  "vite": "^6.0.0",
231
231
  "vitest": "^2.1.8",
232
232
  "vitest-axe": "^0.1.0",
233
- "@fragments-sdk/cli": "0.10.0"
233
+ "@fragments-sdk/cli": "0.10.1"
234
234
  },
235
235
  "files": [
236
236
  "src",
@@ -148,6 +148,22 @@ describe('Accordion', () => {
148
148
  expect(trigger).toHaveAttribute('aria-expanded', 'false');
149
149
  });
150
150
 
151
+ it('emits undefined when a single collapsible accordion fully closes', async () => {
152
+ const user = userEvent.setup();
153
+ const onValueChange = vi.fn();
154
+
155
+ renderAccordion({
156
+ type: 'single',
157
+ collapsible: true,
158
+ defaultValue: 'one',
159
+ onValueChange,
160
+ });
161
+
162
+ await user.click(screen.getByRole('button', { name: /item one/i }));
163
+
164
+ expect(onValueChange).toHaveBeenCalledWith(undefined);
165
+ });
166
+
151
167
  it('non-collapsible single type prevents full collapse', async () => {
152
168
  const user = userEvent.setup();
153
169
  renderAccordion({ type: 'single', collapsible: false, defaultValue: 'one' });
@@ -160,6 +176,23 @@ describe('Accordion', () => {
160
176
  expect(trigger).toHaveAttribute('aria-expanded', 'true');
161
177
  });
162
178
 
179
+ it('forwards html props to trigger and content', async () => {
180
+ const user = userEvent.setup();
181
+ render(
182
+ <Accordion>
183
+ <Accordion.Item value="one">
184
+ <Accordion.Trigger data-testid="trigger" data-track="accordion-trigger">Item One</Accordion.Trigger>
185
+ <Accordion.Content data-testid="content" aria-label="Accordion panel">Content One</Accordion.Content>
186
+ </Accordion.Item>
187
+ </Accordion>
188
+ );
189
+
190
+ await user.click(screen.getByTestId('trigger'));
191
+
192
+ expect(screen.getByTestId('trigger')).toHaveAttribute('data-track', 'accordion-trigger');
193
+ expect(screen.getByTestId('content')).toHaveAttribute('aria-label', 'Accordion panel');
194
+ });
195
+
163
196
  it('has no accessibility violations', async () => {
164
197
  const { container } = renderAccordion({ defaultValue: 'one' });
165
198
  await expectNoA11yViolations(container, {
@@ -19,7 +19,7 @@ export interface AccordionProps extends Omit<React.HTMLAttributes<HTMLDivElement
19
19
  /** Default value for uncontrolled usage */
20
20
  defaultValue?: AccordionValue;
21
21
  /** Callback when value changes */
22
- onValueChange?: (value: AccordionValue) => void;
22
+ onValueChange?: (value: AccordionValue | undefined) => void;
23
23
  /** Whether items can be fully collapsed (only for type="single") */
24
24
  collapsible?: boolean;
25
25
  /**
@@ -137,7 +137,7 @@ function AccordionRoot({
137
137
  }
138
138
 
139
139
  if (onValueChange) {
140
- onValueChange(type === 'single' ? (newItems[0] ?? '') : newItems);
140
+ onValueChange(type === 'single' ? newItems[0] : newItems);
141
141
  }
142
142
  }, [type, currentOpenItems, collapsible, controlledOpenItems, onValueChange]);
143
143
 
@@ -186,11 +186,15 @@ function AccordionItem({
186
186
  function AccordionTrigger({
187
187
  children,
188
188
  className,
189
+ onClick,
190
+ ...htmlProps
189
191
  }: AccordionTriggerProps) {
190
192
  const { toggle, headingLevel } = useAccordionContext();
191
193
  const { value, isOpen, disabled, triggerId, contentId } = useAccordionItemContext();
192
194
 
193
- const handleClick = () => {
195
+ const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
196
+ onClick?.(event);
197
+ if (event.defaultPrevented) return;
194
198
  if (!disabled) {
195
199
  toggle(value);
196
200
  }
@@ -204,6 +208,7 @@ function AccordionTrigger({
204
208
  return (
205
209
  <HeadingTag className={styles.heading}>
206
210
  <BaseCollapsible.Trigger
211
+ {...htmlProps}
207
212
  id={triggerId}
208
213
  className={classes}
209
214
  onClick={handleClick}
@@ -237,6 +242,7 @@ function AccordionTrigger({
237
242
  function AccordionContent({
238
243
  children,
239
244
  className,
245
+ ...htmlProps
240
246
  }: AccordionContentProps) {
241
247
  const { isOpen, triggerId, contentId } = useAccordionItemContext();
242
248
 
@@ -244,6 +250,7 @@ function AccordionContent({
244
250
 
245
251
  return (
246
252
  <BaseCollapsible.Panel
253
+ {...htmlProps}
247
254
  id={contentId}
248
255
  className={classes}
249
256
  data-state={isOpen ? 'open' : 'closed'}
@@ -96,6 +96,47 @@ describe('Collapsible', () => {
96
96
  expect(screen.queryByText('Collapsible content here')).not.toBeInTheDocument();
97
97
  });
98
98
 
99
+ it('composes child handlers when trigger uses asChild', async () => {
100
+ const user = userEvent.setup();
101
+ const childClick = vi.fn();
102
+ const childKeyDown = vi.fn();
103
+
104
+ render(
105
+ <Collapsible>
106
+ <Collapsible.Trigger asChild>
107
+ <button onClick={childClick} onKeyDown={childKeyDown}>Toggle</button>
108
+ </Collapsible.Trigger>
109
+ <Collapsible.Content>Collapsible content here</Collapsible.Content>
110
+ </Collapsible>
111
+ );
112
+
113
+ const trigger = screen.getByRole('button', { name: /toggle/i });
114
+ await user.click(trigger);
115
+ expect(childClick).toHaveBeenCalled();
116
+ expect(screen.getByText('Collapsible content here')).toBeInTheDocument();
117
+
118
+ await user.keyboard('{Enter}');
119
+ expect(childKeyDown).toHaveBeenCalled();
120
+ });
121
+
122
+ it('forwards html props to root, trigger, and content', async () => {
123
+ const user = userEvent.setup();
124
+ render(
125
+ <Collapsible data-testid="root" data-track="collapsible-root">
126
+ <Collapsible.Trigger data-testid="trigger" aria-label="Toggle section">Toggle</Collapsible.Trigger>
127
+ <Collapsible.Content data-testid="content" data-panel="details">
128
+ Collapsible content here
129
+ </Collapsible.Content>
130
+ </Collapsible>
131
+ );
132
+
133
+ await user.click(screen.getByTestId('trigger'));
134
+
135
+ expect(screen.getByTestId('root')).toHaveAttribute('data-track', 'collapsible-root');
136
+ expect(screen.getByTestId('trigger')).toHaveAttribute('aria-label', 'Toggle section');
137
+ expect(screen.getByTestId('content')).toHaveAttribute('data-panel', 'details');
138
+ });
139
+
99
140
  it('has no accessibility violations', async () => {
100
141
  const { container } = renderCollapsible({ defaultOpen: true });
101
142
  await expectNoA11yViolations(container);
@@ -3,6 +3,17 @@
3
3
  import React, { useState, useCallback, useId, createContext, useContext } from 'react';
4
4
  import styles from './Collapsible.module.scss';
5
5
 
6
+ function composeEventHandlers<E extends { defaultPrevented: boolean }>(
7
+ userHandler: ((event: E) => void) | undefined,
8
+ internalHandler: (event: E) => void
9
+ ) {
10
+ return (event: E) => {
11
+ userHandler?.(event);
12
+ if (event.defaultPrevented) return;
13
+ internalHandler(event);
14
+ };
15
+ }
16
+
6
17
  // Context for sharing state between compound components
7
18
  interface CollapsibleContextValue {
8
19
  isOpen: boolean;
@@ -23,7 +34,7 @@ function useCollapsibleContext() {
23
34
  }
24
35
 
25
36
  // Root component
26
- export interface CollapsibleRootProps {
37
+ export interface CollapsibleRootProps extends React.HTMLAttributes<HTMLDivElement> {
27
38
  children: React.ReactNode;
28
39
  /** Whether the collapsible is initially open */
29
40
  defaultOpen?: boolean;
@@ -33,8 +44,6 @@ export interface CollapsibleRootProps {
33
44
  onOpenChange?: (open: boolean) => void;
34
45
  /** Whether the collapsible is disabled */
35
46
  disabled?: boolean;
36
- /** Additional class name */
37
- className?: string;
38
47
  }
39
48
 
40
49
  function CollapsibleRoot({
@@ -44,6 +53,7 @@ function CollapsibleRoot({
44
53
  onOpenChange,
45
54
  disabled = false,
46
55
  className,
56
+ ...htmlProps
47
57
  }: CollapsibleRootProps) {
48
58
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
49
59
  const isControlled = controlledOpen !== undefined;
@@ -78,6 +88,7 @@ function CollapsibleRoot({
78
88
  return (
79
89
  <CollapsibleContext.Provider value={contextValue}>
80
90
  <div
91
+ {...htmlProps}
81
92
  className={`${styles.root} ${isOpen ? styles.open : ''} ${disabled ? styles.disabled : ''} ${className || ''}`}
82
93
  data-state={isOpen ? 'open' : 'closed'}
83
94
  data-disabled={disabled || undefined}
@@ -89,10 +100,8 @@ function CollapsibleRoot({
89
100
  }
90
101
 
91
102
  // Trigger component
92
- export interface CollapsibleTriggerProps {
103
+ export interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
93
104
  children: React.ReactNode;
94
- /** Additional class name */
95
- className?: string;
96
105
  /** Show chevron indicator */
97
106
  showChevron?: boolean;
98
107
  /** Chevron position */
@@ -107,6 +116,9 @@ function CollapsibleTrigger({
107
116
  showChevron = true,
108
117
  chevronPosition = 'end',
109
118
  asChild = false,
119
+ onClick,
120
+ onKeyDown,
121
+ ...htmlProps
110
122
  }: CollapsibleTriggerProps) {
111
123
  const { isOpen, toggle, contentId, triggerId, disabled } = useCollapsibleContext();
112
124
 
@@ -117,6 +129,10 @@ function CollapsibleTrigger({
117
129
  }
118
130
  };
119
131
 
132
+ const handleClick = () => {
133
+ toggle();
134
+ };
135
+
120
136
  const chevronIcon = showChevron && (
121
137
  <svg
122
138
  className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
@@ -137,26 +153,47 @@ function CollapsibleTrigger({
137
153
  );
138
154
 
139
155
  if (asChild && React.isValidElement(children)) {
156
+ const childProps = children.props as {
157
+ className?: string;
158
+ onClick?: (event: React.MouseEvent<HTMLElement>) => void;
159
+ onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
160
+ };
161
+
140
162
  return React.cloneElement(children as React.ReactElement<any>, {
141
- id: triggerId,
163
+ ...htmlProps,
164
+ id: (htmlProps.id as string | undefined) ?? triggerId,
142
165
  'aria-expanded': isOpen,
143
166
  'aria-controls': contentId,
144
167
  'aria-disabled': disabled || undefined,
145
- onClick: toggle,
146
- onKeyDown: handleKeyDown,
168
+ onClick: composeEventHandlers(
169
+ (event: React.MouseEvent<HTMLElement>) => {
170
+ childProps.onClick?.(event);
171
+ onClick?.(event as unknown as React.MouseEvent<HTMLButtonElement>);
172
+ },
173
+ () => handleClick()
174
+ ),
175
+ onKeyDown: composeEventHandlers(
176
+ (event: React.KeyboardEvent<HTMLElement>) => {
177
+ childProps.onKeyDown?.(event);
178
+ onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLButtonElement>);
179
+ },
180
+ (event) => handleKeyDown(event as unknown as React.KeyboardEvent)
181
+ ),
182
+ className: [className, childProps.className].filter(Boolean).join(' '),
147
183
  });
148
184
  }
149
185
 
150
186
  return (
151
187
  <button
188
+ {...htmlProps}
152
189
  type="button"
153
- id={triggerId}
190
+ id={(htmlProps.id as string | undefined) ?? triggerId}
154
191
  className={`${styles.trigger} ${className || ''}`}
155
192
  aria-expanded={isOpen}
156
193
  aria-controls={contentId}
157
194
  aria-disabled={disabled || undefined}
158
- onClick={toggle}
159
- onKeyDown={handleKeyDown}
195
+ onClick={composeEventHandlers(onClick, handleClick)}
196
+ onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
160
197
  disabled={disabled}
161
198
  >
162
199
  {chevronPosition === 'start' && chevronIcon}
@@ -167,10 +204,8 @@ function CollapsibleTrigger({
167
204
  }
168
205
 
169
206
  // Content component
170
- export interface CollapsibleContentProps {
207
+ export interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {
171
208
  children: React.ReactNode;
172
- /** Additional class name */
173
- className?: string;
174
209
  /** Force mount content even when closed (useful for animations) */
175
210
  forceMount?: boolean;
176
211
  }
@@ -179,6 +214,7 @@ function CollapsibleContent({
179
214
  children,
180
215
  className,
181
216
  forceMount = false,
217
+ ...htmlProps
182
218
  }: CollapsibleContentProps) {
183
219
  const { isOpen, contentId, triggerId } = useCollapsibleContext();
184
220
 
@@ -189,7 +225,8 @@ function CollapsibleContent({
189
225
 
190
226
  return (
191
227
  <div
192
- id={contentId}
228
+ {...htmlProps}
229
+ id={(htmlProps.id as string | undefined) ?? contentId}
193
230
  role="region"
194
231
  aria-labelledby={triggerId}
195
232
  className={`${styles.content} ${isOpen ? styles.contentOpen : styles.contentClosed} ${className || ''}`}
@@ -79,6 +79,26 @@ describe('Combobox', () => {
79
79
  expect(await screen.findByText('Frameworks')).toBeInTheDocument();
80
80
  });
81
81
 
82
+ it('forwards html props to item and labels', async () => {
83
+ const user = userEvent.setup();
84
+ render(
85
+ <Combobox placeholder="Search">
86
+ <Combobox.Input />
87
+ <Combobox.Content>
88
+ <Combobox.Group id="framework-group">
89
+ <Combobox.GroupLabel id="framework-group-label">Frameworks</Combobox.GroupLabel>
90
+ <Combobox.Item id="react-option" value="react">React</Combobox.Item>
91
+ </Combobox.Group>
92
+ </Combobox.Content>
93
+ </Combobox>
94
+ );
95
+
96
+ await user.click(screen.getByRole('combobox'));
97
+ expect(await screen.findByRole('option', { name: 'React' })).toHaveAttribute('id', 'react-option');
98
+ expect(screen.getByText('Frameworks')).toHaveAttribute('id', 'framework-group-label');
99
+ expect(screen.getByText('Frameworks').closest('#framework-group')).toBeInTheDocument();
100
+ });
101
+
82
102
  it('supports multiple selection mode', async () => {
83
103
  const user = userEvent.setup();
84
104
  const onChange = vi.fn();
@@ -90,6 +110,22 @@ describe('Combobox', () => {
90
110
  expect(onChange).toHaveBeenCalled();
91
111
  });
92
112
 
113
+ it('uses text content for non-string item labels', async () => {
114
+ render(
115
+ <Combobox multiple value={['react']} defaultOpen>
116
+ <Combobox.Input />
117
+ <Combobox.Content>
118
+ <Combobox.Item value="react">
119
+ <span>React</span>
120
+ </Combobox.Item>
121
+ </Combobox.Content>
122
+ </Combobox>
123
+ );
124
+
125
+ expect((await screen.findAllByText('React')).length).toBeGreaterThan(0);
126
+ expect(screen.queryByText('[object Object]')).not.toBeInTheDocument();
127
+ });
128
+
93
129
  it('has no accessibility violations', async () => {
94
130
  const { container } = render(
95
131
  <Combobox placeholder="Search...">
@@ -199,4 +235,23 @@ describe('Combobox', () => {
199
235
  expect(screen.queryByRole('option')).not.toBeInTheDocument();
200
236
  });
201
237
  });
238
+
239
+ it('updates chip label when selected option is removed', async () => {
240
+ const renderDemo = (showReact: boolean) => (
241
+ <Combobox multiple value={['react']} defaultOpen>
242
+ <Combobox.Input />
243
+ <Combobox.Content>
244
+ {showReact && <Combobox.Item value="react">React</Combobox.Item>}
245
+ <Combobox.Item value="vue">Vue</Combobox.Item>
246
+ </Combobox.Content>
247
+ </Combobox>
248
+ );
249
+
250
+ const { rerender } = render(renderDemo(true));
251
+
252
+ await screen.findAllByText('React');
253
+ rerender(renderDemo(false));
254
+
255
+ expect(await screen.findByText('react')).toBeInTheDocument();
256
+ });
202
257
  });
@@ -46,26 +46,22 @@ export interface ComboboxContentProps extends React.HTMLAttributes<HTMLDivElemen
46
46
  maxVisibleItems?: number;
47
47
  }
48
48
 
49
- export interface ComboboxItemProps {
49
+ export interface ComboboxItemProps extends Omit<React.HTMLAttributes<HTMLElement>, 'children'> {
50
50
  children: React.ReactNode;
51
51
  value: string;
52
52
  disabled?: boolean;
53
- className?: string;
54
53
  }
55
54
 
56
- export interface ComboboxEmptyProps {
55
+ export interface ComboboxEmptyProps extends React.HTMLAttributes<HTMLElement> {
57
56
  children: React.ReactNode;
58
- className?: string;
59
57
  }
60
58
 
61
- export interface ComboboxGroupProps {
59
+ export interface ComboboxGroupProps extends React.HTMLAttributes<HTMLElement> {
62
60
  children: React.ReactNode;
63
- className?: string;
64
61
  }
65
62
 
66
- export interface ComboboxGroupLabelProps {
63
+ export interface ComboboxGroupLabelProps extends React.HTMLAttributes<HTMLElement> {
67
64
  children: React.ReactNode;
68
- className?: string;
69
65
  }
70
66
 
71
67
  // ============================================
@@ -150,6 +146,15 @@ const ComboboxContext = React.createContext<ComboboxContextValue>({
150
146
  incrementItemsVersion: () => {},
151
147
  });
152
148
 
149
+ function getNodeText(node: React.ReactNode): string {
150
+ if (node == null || typeof node === 'boolean') return '';
151
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
152
+ if (Array.isArray(node)) return node.map(getNodeText).join('');
153
+ if (React.isValidElement(node))
154
+ return getNodeText((node.props as { children?: React.ReactNode }).children);
155
+ return '';
156
+ }
157
+
153
158
  // ============================================
154
159
  // Components
155
160
  // ============================================
@@ -337,24 +342,25 @@ function ComboboxContent({
337
342
  );
338
343
  }
339
344
 
340
- function ComboboxItem({ children, value, disabled, className }: ComboboxItemProps) {
345
+ function ComboboxItem({ children, value, disabled, className, ...htmlProps }: ComboboxItemProps) {
341
346
  const { itemsRef, incrementItemsVersion } = React.useContext(ComboboxContext);
342
347
  const classes = [styles.item, className].filter(Boolean).join(' ');
343
348
 
344
349
  // Register this item's label in the registry so the input can display it
345
- const label = typeof children === 'string' ? children : String(children);
350
+ const label = React.useMemo(() => getNodeText(children).trim() || value, [children, value]);
346
351
  React.useEffect(() => {
347
352
  const items = itemsRef.current;
348
353
  items.set(value, label);
349
354
  incrementItemsVersion();
350
355
  return () => {
351
356
  items.delete(value);
357
+ incrementItemsVersion();
352
358
  };
353
359
  // itemsRef is a stable ref, incrementItemsVersion is a stable callback
354
360
  }, [itemsRef, incrementItemsVersion, value, label]);
355
361
 
356
362
  return (
357
- <BaseCombobox.Item value={value} disabled={disabled} className={classes}>
363
+ <BaseCombobox.Item {...htmlProps} value={value} disabled={disabled} className={classes}>
358
364
  {children}
359
365
  <BaseCombobox.ItemIndicator className={styles.itemIndicator}>
360
366
  <CheckIcon />
@@ -363,19 +369,19 @@ function ComboboxItem({ children, value, disabled, className }: ComboboxItemProp
363
369
  );
364
370
  }
365
371
 
366
- function ComboboxEmpty({ children, className }: ComboboxEmptyProps) {
372
+ function ComboboxEmpty({ children, className, ...htmlProps }: ComboboxEmptyProps) {
367
373
  const classes = [styles.empty, className].filter(Boolean).join(' ');
368
- return <BaseCombobox.Empty className={classes}>{children}</BaseCombobox.Empty>;
374
+ return <BaseCombobox.Empty {...htmlProps} className={classes}>{children}</BaseCombobox.Empty>;
369
375
  }
370
376
 
371
- function ComboboxGroup({ children, className }: ComboboxGroupProps) {
377
+ function ComboboxGroup({ children, className, ...htmlProps }: ComboboxGroupProps) {
372
378
  const classes = [styles.group, className].filter(Boolean).join(' ');
373
- return <BaseCombobox.Group className={classes}>{children}</BaseCombobox.Group>;
379
+ return <BaseCombobox.Group {...htmlProps} className={classes}>{children}</BaseCombobox.Group>;
374
380
  }
375
381
 
376
- function ComboboxGroupLabel({ children, className }: ComboboxGroupLabelProps) {
382
+ function ComboboxGroupLabel({ children, className, ...htmlProps }: ComboboxGroupLabelProps) {
377
383
  const classes = [styles.groupLabel, className].filter(Boolean).join(' ');
378
- return <BaseCombobox.GroupLabel className={classes}>{children}</BaseCombobox.GroupLabel>;
384
+ return <BaseCombobox.GroupLabel {...htmlProps} className={classes}>{children}</BaseCombobox.GroupLabel>;
379
385
  }
380
386
 
381
387
  // ============================================
@@ -355,6 +355,99 @@ describe('Command', () => {
355
355
  });
356
356
  });
357
357
 
358
+ it('uses unique list ids for multiple command instances', () => {
359
+ render(
360
+ <>
361
+ <Command>
362
+ <Command.Input placeholder="First search" />
363
+ <Command.List>
364
+ <Command.Item onItemSelect={() => {}}>One</Command.Item>
365
+ </Command.List>
366
+ </Command>
367
+ <Command>
368
+ <Command.Input placeholder="Second search" />
369
+ <Command.List>
370
+ <Command.Item onItemSelect={() => {}}>Two</Command.Item>
371
+ </Command.List>
372
+ </Command>
373
+ </>
374
+ );
375
+
376
+ const inputs = [
377
+ screen.getByPlaceholderText('First search'),
378
+ screen.getByPlaceholderText('Second search'),
379
+ ];
380
+ const listIds = inputs.map((input) => input.getAttribute('aria-controls'));
381
+
382
+ expect(listIds[0]).toBeTruthy();
383
+ expect(listIds[1]).toBeTruthy();
384
+ expect(listIds[0]).not.toBe(listIds[1]);
385
+ expect(document.getElementById(listIds[0]!)).toBeInTheDocument();
386
+ expect(document.getElementById(listIds[1]!)).toBeInTheDocument();
387
+ });
388
+
389
+ it('filters rich-label items using extracted text content', async () => {
390
+ const user = userEvent.setup();
391
+ render(
392
+ <Command>
393
+ <Command.Input placeholder="Search..." />
394
+ <Command.List>
395
+ <Command.Item onItemSelect={() => {}}>
396
+ <span>Open</span> File
397
+ </Command.Item>
398
+ <Command.Item onItemSelect={() => {}}>Save</Command.Item>
399
+ </Command.List>
400
+ </Command>
401
+ );
402
+
403
+ await user.type(screen.getByPlaceholderText('Search...'), 'open');
404
+
405
+ await waitFor(() => {
406
+ expect(screen.getByText('Save')).not.toBeVisible();
407
+ expect(screen.getByText('Open')).toBeVisible();
408
+ });
409
+ });
410
+
411
+ it('composes item and group html props without dropping handlers/styles', async () => {
412
+ const user = userEvent.setup();
413
+ const itemClick = vi.fn();
414
+ const itemKeyDown = vi.fn();
415
+ const itemMouseEnter = vi.fn();
416
+
417
+ render(
418
+ <Command>
419
+ <Command.Input placeholder="Search..." />
420
+ <Command.List>
421
+ <Command.Group heading="Files" data-testid="group" style={{ opacity: 0.5 }}>
422
+ <Command.Item
423
+ onItemSelect={() => {}}
424
+ data-testid="item"
425
+ onClick={itemClick}
426
+ onKeyDown={itemKeyDown}
427
+ onMouseEnter={itemMouseEnter}
428
+ tabIndex={0}
429
+ style={{ color: 'rgb(255, 0, 0)' }}
430
+ >
431
+ Open File
432
+ </Command.Item>
433
+ </Command.Group>
434
+ </Command.List>
435
+ </Command>
436
+ );
437
+
438
+ const item = screen.getByTestId('item');
439
+ await user.hover(item);
440
+ await user.click(item);
441
+ item.focus();
442
+ await user.keyboard('{Enter}');
443
+
444
+ expect(itemMouseEnter).toHaveBeenCalled();
445
+ expect(itemClick).toHaveBeenCalled();
446
+ expect(itemKeyDown).toHaveBeenCalled();
447
+ expect(item).toHaveStyle({ color: 'rgb(255, 0, 0)' });
448
+ expect(screen.getByTestId('group')).toHaveStyle({ opacity: '0.5' });
449
+ });
450
+
358
451
  it('has no accessibility violations', async () => {
359
452
  const { container } = renderCommand();
360
453