@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
@@ -74,6 +74,16 @@ function defaultFilter(value: string, search: string, keywords?: string[]): numb
74
74
  return 0;
75
75
  }
76
76
 
77
+ function getTextContent(node: React.ReactNode): string {
78
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
79
+ if (Array.isArray(node)) return node.map(getTextContent).join(' ');
80
+ if (React.isValidElement(node)) {
81
+ const childProps = node.props as { children?: React.ReactNode };
82
+ return getTextContent(childProps.children);
83
+ }
84
+ return '';
85
+ }
86
+
77
87
  // ============================================
78
88
  // Context
79
89
  // ============================================
@@ -95,6 +105,7 @@ interface CommandContextValue {
95
105
  loop: boolean;
96
106
  listRef: React.RefObject<HTMLDivElement | null>;
97
107
  visibleCount: number;
108
+ listId: string;
98
109
  }
99
110
 
100
111
  const CommandContext = React.createContext<CommandContextValue | null>(null);
@@ -150,6 +161,8 @@ function CommandRoot({
150
161
  const [items, setItems] = React.useState<Map<string, ItemRegistration>>(new Map());
151
162
  const [activeId, setActiveId] = React.useState<string | null>(null);
152
163
  const listRef = React.useRef<HTMLDivElement | null>(null);
164
+ const generatedListId = React.useId();
165
+ const listId = `command-list-${generatedListId}`;
153
166
 
154
167
  const setSearch = React.useCallback(
155
168
  (value: string) => {
@@ -213,8 +226,9 @@ function CommandRoot({
213
226
  loop,
214
227
  listRef,
215
228
  visibleCount,
229
+ listId,
216
230
  }),
217
- [search, setSearch, filter, scores, registerItem, unregisterItem, activeId, loop, visibleCount]
231
+ [search, setSearch, filter, scores, registerItem, unregisterItem, activeId, loop, visibleCount, listId]
218
232
  );
219
233
 
220
234
  return (
@@ -230,8 +244,13 @@ function CommandRoot({
230
244
  );
231
245
  }
232
246
 
233
- function CommandInput({ className, ...htmlProps }: CommandInputProps) {
234
- const { search, setSearch, listRef, setActiveId, activeId, loop } = useCommandContext();
247
+ function CommandInput({
248
+ className,
249
+ onChange,
250
+ onKeyDown,
251
+ ...htmlProps
252
+ }: CommandInputProps) {
253
+ const { search, setSearch, listRef, setActiveId, activeId, loop, listId } = useCommandContext();
235
254
  const inputRef = React.useRef<HTMLInputElement>(null);
236
255
 
237
256
  const getEnabledItems = React.useCallback(() => {
@@ -243,7 +262,7 @@ function CommandInput({ className, ...htmlProps }: CommandInputProps) {
243
262
  }, [listRef]);
244
263
 
245
264
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
246
- htmlProps.onKeyDown?.(event);
265
+ onKeyDown?.(event);
247
266
  if (event.defaultPrevented) return;
248
267
 
249
268
  const items = getEnabledItems();
@@ -307,32 +326,36 @@ function CommandInput({ className, ...htmlProps }: CommandInputProps) {
307
326
  type="text"
308
327
  role="combobox"
309
328
  aria-expanded={true}
310
- aria-controls="command-list"
329
+ aria-controls={listId}
311
330
  aria-autocomplete="list"
312
331
  aria-activedescendant={activeId ?? undefined}
313
332
  autoComplete="off"
314
333
  autoCorrect="off"
315
334
  spellCheck={false}
316
335
  value={search}
317
- onChange={(e) => setSearch(e.target.value)}
336
+ {...htmlProps}
337
+ onChange={(e) => {
338
+ onChange?.(e);
339
+ if (e.defaultPrevented) return;
340
+ setSearch(e.target.value);
341
+ }}
318
342
  onKeyDown={handleKeyDown}
319
343
  className={[styles.input, className].filter(Boolean).join(' ')}
320
- {...htmlProps}
321
344
  />
322
345
  </div>
323
346
  );
324
347
  }
325
348
 
326
349
  function CommandList({ children, className, ...htmlProps }: CommandListProps) {
327
- const { listRef } = useCommandContext();
350
+ const { listRef, listId } = useCommandContext();
328
351
 
329
352
  return (
330
353
  <div
331
354
  ref={listRef}
332
- id="command-list"
355
+ {...htmlProps}
356
+ id={listId}
333
357
  role="listbox"
334
358
  className={[styles.list, className].filter(Boolean).join(' ')}
335
- {...htmlProps}
336
359
  >
337
360
  {children}
338
361
  </div>
@@ -346,6 +369,10 @@ function CommandItem({
346
369
  disabled = false,
347
370
  onItemSelect,
348
371
  className,
372
+ onClick,
373
+ onKeyDown,
374
+ onMouseEnter,
375
+ style,
349
376
  ...htmlProps
350
377
  }: CommandItemProps) {
351
378
  const { scores, registerItem, unregisterItem, activeId, setActiveId } = useCommandContext();
@@ -356,8 +383,7 @@ function CommandItem({
356
383
  // Extract text content for filtering if no value prop
357
384
  const textValue = React.useMemo(() => {
358
385
  if (valueProp) return valueProp;
359
- if (typeof children === 'string') return children;
360
- return '';
386
+ return getTextContent(children).trim();
361
387
  }, [valueProp, children]);
362
388
 
363
389
  // Register with context
@@ -377,12 +403,20 @@ function CommandItem({
377
403
  }
378
404
  }, [isActive]);
379
405
 
380
- const handleClick = () => {
406
+ const activateItem = () => {
381
407
  if (disabled) return;
382
408
  onItemSelect?.();
383
409
  };
384
410
 
385
- const handleMouseEnter = () => {
411
+ const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
412
+ onClick?.(event);
413
+ if (event.defaultPrevented) return;
414
+ activateItem();
415
+ };
416
+
417
+ const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
418
+ onMouseEnter?.(event);
419
+ if (event.defaultPrevented) return;
386
420
  if (!disabled) {
387
421
  setActiveId(itemId);
388
422
  }
@@ -400,7 +434,14 @@ function CommandItem({
400
434
  data-active={isActive || undefined}
401
435
  data-disabled={disabled || undefined}
402
436
  onClick={handleClick}
403
- onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }}
437
+ onKeyDown={(e) => {
438
+ onKeyDown?.(e);
439
+ if (e.defaultPrevented) return;
440
+ if (e.key === 'Enter' || e.key === ' ') {
441
+ e.preventDefault();
442
+ activateItem();
443
+ }
444
+ }}
404
445
  onMouseEnter={handleMouseEnter}
405
446
  className={[
406
447
  styles.item,
@@ -410,7 +451,7 @@ function CommandItem({
410
451
  ]
411
452
  .filter(Boolean)
412
453
  .join(' ')}
413
- style={{ display: isVisible ? undefined : 'none' }}
454
+ style={{ ...style, display: isVisible ? undefined : 'none' }}
414
455
  >
415
456
  {children}
416
457
  </div>
@@ -431,14 +472,16 @@ function CommandGroup({ children, heading, className, ...htmlProps }: CommandGro
431
472
  setHasVisibleChildren(anyVisible);
432
473
  }, [scores]);
433
474
 
475
+ const { style, ...restHtmlProps } = htmlProps;
476
+
434
477
  return (
435
478
  <div
436
479
  ref={groupRef}
437
- {...htmlProps}
480
+ {...restHtmlProps}
438
481
  role="group"
439
482
  aria-labelledby={heading ? labelId : undefined}
440
483
  className={[styles.group, className].filter(Boolean).join(' ')}
441
- style={{ display: hasVisibleChildren ? undefined : 'none' }}
484
+ style={{ ...style, display: hasVisibleChildren ? undefined : 'none' }}
442
485
  >
443
486
  {heading && (
444
487
  <div id={labelId} className={styles.groupHeading}>
@@ -41,9 +41,18 @@ describe('DataTable', () => {
41
41
  });
42
42
 
43
43
  it('shows empty state message when data is empty', () => {
44
- render(<DataTable columns={columns} data={[]} emptyMessage="Nothing here" aria-label="People" />);
44
+ render(
45
+ <DataTable
46
+ columns={columns}
47
+ data={[]}
48
+ emptyMessage="Nothing here"
49
+ caption="People Table"
50
+ aria-label="People"
51
+ />
52
+ );
45
53
  expect(screen.getByText('Nothing here')).toBeInTheDocument();
46
- expect(screen.queryByRole('table')).not.toBeInTheDocument();
54
+ expect(screen.getByRole('table', { name: /people/i })).toBeInTheDocument();
55
+ expect(screen.getByText('People Table')).toBeInTheDocument();
47
56
  });
48
57
 
49
58
  it('defaults to "No data available" empty message', () => {
@@ -270,8 +270,28 @@ function DataTableRoot<T>({
270
270
 
271
271
  if (isEmpty) {
272
272
  return (
273
- <div className={styles.emptyState}>
274
- <span className={styles.emptyMessage}>{emptyMessage}</span>
273
+ <div className={[styles.wrapper, bordered && styles.bordered].filter(Boolean).join(' ')}>
274
+ <table
275
+ {...htmlProps}
276
+ className={rootClasses}
277
+ aria-label={ariaLabel}
278
+ aria-describedby={ariaDescribedBy}
279
+ >
280
+ {caption && (
281
+ <caption className={captionHidden ? styles.captionHidden : styles.caption}>
282
+ {caption}
283
+ </caption>
284
+ )}
285
+ <tbody className={styles.tbody}>
286
+ <tr className={styles.row}>
287
+ <td className={styles.td} colSpan={Math.max(columns.length, 1)}>
288
+ <div className={styles.emptyState}>
289
+ <span className={styles.emptyMessage}>{emptyMessage}</span>
290
+ </div>
291
+ </td>
292
+ </tr>
293
+ </tbody>
294
+ </table>
275
295
  </div>
276
296
  );
277
297
  }
@@ -175,6 +175,38 @@ describe('DatePicker', () => {
175
175
 
176
176
  expect(onSelect).toHaveBeenCalledWith(presetDate);
177
177
  });
178
+
179
+ it('preset forwards html props', async () => {
180
+ const user = userEvent.setup();
181
+ const onSelect = vi.fn();
182
+ const onPresetClick = vi.fn();
183
+ const presetDate = new Date(2025, 5, 2);
184
+
185
+ render(
186
+ <DatePicker onSelect={onSelect}>
187
+ <DatePicker.Trigger placeholder="Pick a date" />
188
+ <DatePicker.Content>
189
+ <DatePicker.Preset
190
+ id="preset-june-2"
191
+ data-testid="preset"
192
+ aria-label="Preset June 2"
193
+ onClick={onPresetClick}
194
+ date={presetDate}
195
+ >
196
+ June 2nd
197
+ </DatePicker.Preset>
198
+ <DatePicker.Calendar />
199
+ </DatePicker.Content>
200
+ </DatePicker>
201
+ );
202
+
203
+ await user.click(screen.getByRole('button', { name: /pick a date/i }));
204
+ await user.click(screen.getByTestId('preset'));
205
+
206
+ expect(screen.getByTestId('preset')).toHaveAttribute('id', 'preset-june-2');
207
+ expect(onPresetClick).toHaveBeenCalled();
208
+ expect(onSelect).toHaveBeenCalledWith(presetDate);
209
+ });
178
210
  });
179
211
 
180
212
  describe('keyboard', () => {
@@ -216,6 +248,53 @@ describe('DatePicker', () => {
216
248
  expect(screen.getByRole('button')).toHaveTextContent('Apr 01, 2025');
217
249
  expect(screen.getByRole('button')).toHaveTextContent('Apr 07, 2025');
218
250
  });
251
+
252
+ it('requests close after single selection when open is controlled', async () => {
253
+ const user = userEvent.setup();
254
+ const onOpenChange = vi.fn();
255
+ const onSelect = vi.fn();
256
+
257
+ render(
258
+ <DatePicker open onOpenChange={onOpenChange} onSelect={onSelect}>
259
+ <DatePicker.Trigger placeholder="Pick a date" />
260
+ <DatePicker.Content>
261
+ <DatePicker.Calendar />
262
+ </DatePicker.Content>
263
+ </DatePicker>
264
+ );
265
+
266
+ await screen.findByRole('grid');
267
+
268
+ const dayButtons = screen.getAllByRole('gridcell');
269
+ const day10 = dayButtons.find((cell) => {
270
+ const btn = cell.querySelector('button');
271
+ return btn?.textContent === '10';
272
+ });
273
+
274
+ await user.click(day10!.querySelector('button')!);
275
+
276
+ await waitFor(() => {
277
+ expect(onSelect).toHaveBeenCalledWith(expect.any(Date));
278
+ expect(onOpenChange).toHaveBeenCalledWith(false);
279
+ }, { timeout: 500 });
280
+ });
281
+ });
282
+
283
+ describe('forms', () => {
284
+ it('serializes selected date to a date-only hidden input value', () => {
285
+ render(
286
+ <DatePicker name="appointment" selected={new Date(2025, 0, 15)}>
287
+ <DatePicker.Trigger />
288
+ <DatePicker.Content>
289
+ <DatePicker.Calendar />
290
+ </DatePicker.Content>
291
+ </DatePicker>
292
+ );
293
+
294
+ const input = document.querySelector<HTMLInputElement>('input[type="hidden"][name="appointment"]');
295
+ expect(input).toBeTruthy();
296
+ expect(input?.value).toBe('2025-01-15');
297
+ });
219
298
  });
220
299
 
221
300
  describe('multi-month', () => {
@@ -67,13 +67,12 @@ export interface DatePickerCalendarProps {
67
67
  className?: string;
68
68
  }
69
69
 
70
- export interface DatePickerPresetProps {
70
+ export interface DatePickerPresetProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
71
71
  children: React.ReactNode;
72
72
  /** Date to select (single mode) */
73
73
  date?: Date;
74
74
  /** Range to select (range mode) */
75
75
  range?: DateRange;
76
- className?: string;
77
76
  }
78
77
 
79
78
  // ============================================
@@ -226,6 +225,14 @@ function defaultFormatRange(range: DateRange): string {
226
225
  return `${range.from.toLocaleDateString()} - ${range.to.toLocaleDateString()}`;
227
226
  }
228
227
 
228
+ function formatDateForHiddenInput(date?: Date): string {
229
+ if (!date) return '';
230
+ const year = date.getFullYear();
231
+ const month = String(date.getMonth() + 1).padStart(2, '0');
232
+ const day = String(date.getDate()).padStart(2, '0');
233
+ return `${year}-${month}-${day}`;
234
+ }
235
+
229
236
  // ============================================
230
237
  // ClassNames mapping (built lazily)
231
238
  // ============================================
@@ -327,15 +334,14 @@ function DatePickerRoot({
327
334
  }
328
335
  onSelect?.(date);
329
336
 
330
- // Auto-close after single selection (uncontrolled)
331
- if (!isControlledOpen && date) {
337
+ // Auto-close after single selection (controlled and uncontrolled).
338
+ if (date) {
332
339
  setTimeout(() => {
333
- setInternalOpen(false);
334
- onOpenChange?.(false);
340
+ handleOpenChange(false);
335
341
  }, 150);
336
342
  }
337
343
  },
338
- [selectedProp, onSelect, isControlledOpen, onOpenChange]
344
+ [selectedProp, onSelect, handleOpenChange]
339
345
  );
340
346
 
341
347
  const setSelectedRange = React.useCallback(
@@ -412,10 +418,10 @@ function DatePickerRoot({
412
418
  type="hidden"
413
419
  name={name}
414
420
  value={
415
- mode === 'single'
416
- ? (contextValue.selected?.toISOString() ?? '')
421
+ mode === 'single'
422
+ ? formatDateForHiddenInput(contextValue.selected ?? undefined)
417
423
  : contextValue.selectedRange
418
- ? `${contextValue.selectedRange.from?.toISOString() ?? ''},${contextValue.selectedRange.to?.toISOString() ?? ''}`
424
+ ? `${formatDateForHiddenInput(contextValue.selectedRange.from)},${formatDateForHiddenInput(contextValue.selectedRange.to)}`
419
425
  : ''
420
426
  }
421
427
  />
@@ -555,20 +561,29 @@ function DatePickerCalendar({ numberOfMonths: numberOfMonthsProp, className }: D
555
561
  );
556
562
  }
557
563
 
558
- function DatePickerPreset({ children, date, range, className }: DatePickerPresetProps) {
564
+ function DatePickerPreset({
565
+ children,
566
+ date,
567
+ range,
568
+ className,
569
+ onClick,
570
+ ...htmlProps
571
+ }: DatePickerPresetProps) {
559
572
  const ctx = useDatePickerContext();
560
573
  const classes = [styles.preset, className].filter(Boolean).join(' ');
561
574
 
562
- const handleClick = React.useCallback(() => {
575
+ const handleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
576
+ onClick?.(event);
577
+ if (event.defaultPrevented) return;
563
578
  if (ctx.mode === 'single' && date) {
564
579
  ctx.setSelected(date);
565
580
  } else if (ctx.mode === 'range' && range) {
566
581
  ctx.setSelectedRange(range);
567
582
  }
568
- }, [ctx, date, range]);
583
+ }, [ctx, date, range, onClick]);
569
584
 
570
585
  return (
571
- <button type="button" className={classes} onClick={handleClick}>
586
+ <button type="button" {...htmlProps} className={classes} onClick={handleClick}>
572
587
  {children}
573
588
  </button>
574
589
  );
@@ -71,6 +71,29 @@ describe('Dialog', () => {
71
71
  });
72
72
  });
73
73
 
74
+ it('forwards html props to trigger, title, description, and close', async () => {
75
+ const user = userEvent.setup();
76
+ render(
77
+ <Dialog>
78
+ <Dialog.Trigger id="dialog-trigger">Open</Dialog.Trigger>
79
+ <Dialog.Content>
80
+ <Dialog.Title id="dialog-title">Dialog Title</Dialog.Title>
81
+ <Dialog.Description id="dialog-description">Dialog Description</Dialog.Description>
82
+ <Dialog.Close data-testid="dialog-close" />
83
+ </Dialog.Content>
84
+ </Dialog>
85
+ );
86
+
87
+ expect(screen.getByRole('button', { name: /open/i })).toHaveAttribute('id', 'dialog-trigger');
88
+ await user.click(screen.getByRole('button', { name: /open/i }));
89
+
90
+ await waitFor(() => {
91
+ expect(screen.getByText('Dialog Title')).toHaveAttribute('id', 'dialog-title');
92
+ expect(screen.getByText('Dialog Description')).toHaveAttribute('id', 'dialog-description');
93
+ expect(screen.getByTestId('dialog-close')).toBeInTheDocument();
94
+ });
95
+ });
96
+
74
97
  it('renders compound sub-components (Header, Body, Footer)', async () => {
75
98
  renderDialog({ defaultOpen: true });
76
99
 
@@ -33,14 +33,12 @@ export interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement>
33
33
  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
34
34
  }
35
35
 
36
- export interface DialogTitleProps {
36
+ export interface DialogTitleProps extends Omit<React.HTMLAttributes<HTMLElement>, 'children'> {
37
37
  children: React.ReactNode;
38
- className?: string;
39
38
  }
40
39
 
41
- export interface DialogDescriptionProps {
40
+ export interface DialogDescriptionProps extends Omit<React.HTMLAttributes<HTMLElement>, 'children'> {
42
41
  children: React.ReactNode;
43
- className?: string;
44
42
  }
45
43
 
46
44
  export interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -55,16 +53,14 @@ export interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement>
55
53
  children: React.ReactNode;
56
54
  }
57
55
 
58
- export interface DialogTriggerProps {
56
+ export interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
59
57
  children: React.ReactNode;
60
58
  asChild?: boolean;
61
- className?: string;
62
59
  }
63
60
 
64
- export interface DialogCloseProps {
61
+ export interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
65
62
  children?: React.ReactNode;
66
63
  asChild?: boolean;
67
- className?: string;
68
64
  }
69
65
 
70
66
  // ============================================
@@ -118,17 +114,18 @@ function DialogTrigger({
118
114
  children,
119
115
  asChild,
120
116
  className,
117
+ ...htmlProps
121
118
  }: DialogTriggerProps) {
122
119
  if (asChild) {
123
120
  return (
124
- <BaseDialog.Trigger className={className} render={children as React.ReactElement}>
121
+ <BaseDialog.Trigger {...htmlProps} className={className} render={children as React.ReactElement}>
125
122
  {null}
126
123
  </BaseDialog.Trigger>
127
124
  );
128
125
  }
129
126
 
130
127
  return (
131
- <BaseDialog.Trigger className={className}>
128
+ <BaseDialog.Trigger {...htmlProps} className={className}>
132
129
  {children}
133
130
  </BaseDialog.Trigger>
134
131
  );
@@ -161,15 +158,15 @@ function DialogHeader({ children, className, ...htmlProps }: DialogHeaderProps)
161
158
  return <div {...htmlProps} className={classes}>{children}</div>;
162
159
  }
163
160
 
164
- function DialogTitle({ children, className }: DialogTitleProps) {
161
+ function DialogTitle({ children, className, ...htmlProps }: DialogTitleProps) {
165
162
  const classes = [styles.title, className].filter(Boolean).join(' ');
166
- return <BaseDialog.Title className={classes}>{children}</BaseDialog.Title>;
163
+ return <BaseDialog.Title {...htmlProps} className={classes}>{children}</BaseDialog.Title>;
167
164
  }
168
165
 
169
- function DialogDescription({ children, className }: DialogDescriptionProps) {
166
+ function DialogDescription({ children, className, ...htmlProps }: DialogDescriptionProps) {
170
167
  const classes = [styles.description, className].filter(Boolean).join(' ');
171
168
  return (
172
- <BaseDialog.Description className={classes}>
169
+ <BaseDialog.Description {...htmlProps} className={classes}>
173
170
  {children}
174
171
  </BaseDialog.Description>
175
172
  );
@@ -185,11 +182,12 @@ function DialogFooter({ children, className, ...htmlProps }: DialogFooterProps)
185
182
  return <div {...htmlProps} className={classes}>{children}</div>;
186
183
  }
187
184
 
188
- function DialogClose({ children, asChild, className }: DialogCloseProps) {
185
+ function DialogClose({ children, asChild, className, ...htmlProps }: DialogCloseProps) {
189
186
  // If no children, render the default X close button
190
187
  if (!children) {
191
188
  return (
192
189
  <BaseDialog.Close
190
+ {...htmlProps}
193
191
  data-dialog-close
194
192
  aria-label="Close dialog"
195
193
  className={[styles.close, className].filter(Boolean).join(' ')}
@@ -202,6 +200,7 @@ function DialogClose({ children, asChild, className }: DialogCloseProps) {
202
200
  if (asChild) {
203
201
  return (
204
202
  <BaseDialog.Close
203
+ {...htmlProps}
205
204
  data-dialog-close
206
205
  className={className}
207
206
  render={children as React.ReactElement}
@@ -212,7 +211,7 @@ function DialogClose({ children, asChild, className }: DialogCloseProps) {
212
211
  }
213
212
 
214
213
  return (
215
- <BaseDialog.Close data-dialog-close className={className}>
214
+ <BaseDialog.Close {...htmlProps} data-dialog-close className={className}>
216
215
  {children}
217
216
  </BaseDialog.Close>
218
217
  );
@@ -66,6 +66,33 @@ describe('Drawer', () => {
66
66
  });
67
67
  });
68
68
 
69
+ it('forwards html props to trigger, title, description, and close', async () => {
70
+ const user = userEvent.setup();
71
+ render(
72
+ <Drawer>
73
+ <Drawer.Trigger id="drawer-trigger">Open</Drawer.Trigger>
74
+ <Drawer.Content>
75
+ <Drawer.Header>
76
+ <Drawer.Title id="drawer-title">Drawer Title</Drawer.Title>
77
+ <Drawer.Close data-testid="drawer-close" />
78
+ </Drawer.Header>
79
+ <Drawer.Body>
80
+ <Drawer.Description id="drawer-description">Drawer Description</Drawer.Description>
81
+ </Drawer.Body>
82
+ </Drawer.Content>
83
+ </Drawer>
84
+ );
85
+
86
+ expect(screen.getByRole('button', { name: /open/i })).toHaveAttribute('id', 'drawer-trigger');
87
+ await user.click(screen.getByRole('button', { name: /open/i }));
88
+
89
+ await waitFor(() => {
90
+ expect(screen.getByText('Drawer Title')).toHaveAttribute('id', 'drawer-title');
91
+ expect(screen.getByText('Drawer Description')).toHaveAttribute('id', 'drawer-description');
92
+ expect(screen.getByTestId('drawer-close')).toBeInTheDocument();
93
+ });
94
+ });
95
+
69
96
  it('renders compound sub-components (Header, Body, Footer)', async () => {
70
97
  renderDrawer({ defaultOpen: true });
71
98