@open-mercato/ui 0.6.8-develop.7100.1.fbf66fca35 → 0.7.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 (71) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/records/ActivityCard.js +3 -4
  3. package/dist/ai/records/ActivityCard.js.map +2 -2
  4. package/dist/ai/records/DealCard.js +3 -4
  5. package/dist/ai/records/DealCard.js.map +2 -2
  6. package/dist/ai/records/ProductCard.js +2 -3
  7. package/dist/ai/records/ProductCard.js.map +2 -2
  8. package/dist/backend/AppShell.js +106 -129
  9. package/dist/backend/AppShell.js.map +2 -2
  10. package/dist/backend/DataTable.js +9 -22
  11. package/dist/backend/DataTable.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +20 -26
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/icons/lucideRegistry.generated.js +0 -4
  15. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  16. package/dist/backend/injection/useRegisteredComponent.js +6 -24
  17. package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
  18. package/dist/backend/inputs/ComboboxInput.js +4 -8
  19. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +9 -9
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -36
  23. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  24. package/dist/backend/utils/crud.js.map +2 -2
  25. package/dist/backend/utils/useAutoDiscoveredFields.js +8 -4
  26. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  27. package/dist/primitives/alert.js +1 -1
  28. package/dist/primitives/alert.js.map +2 -2
  29. package/dist/primitives/icon-button.js +1 -20
  30. package/dist/primitives/icon-button.js.map +2 -2
  31. package/dist/primitives/pagination.js +9 -14
  32. package/dist/primitives/pagination.js.map +2 -2
  33. package/dist/primitives/rich-editor.js +7 -14
  34. package/dist/primitives/rich-editor.js.map +2 -2
  35. package/dist/utils/format.js +5 -5
  36. package/dist/utils/format.js.map +2 -2
  37. package/package.json +4 -5
  38. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +0 -2
  39. package/src/ai/records/ActivityCard.tsx +3 -4
  40. package/src/ai/records/DealCard.tsx +3 -4
  41. package/src/ai/records/ProductCard.tsx +2 -3
  42. package/src/backend/AppShell.tsx +58 -89
  43. package/src/backend/DataTable.tsx +12 -46
  44. package/src/backend/__tests__/AppShell.test.tsx +1 -162
  45. package/src/backend/__tests__/DataTable.render.test.tsx +0 -37
  46. package/src/backend/__tests__/NotesSection.test.tsx +0 -89
  47. package/src/backend/detail/NotesSection.tsx +67 -92
  48. package/src/backend/icons/lucideRegistry.generated.tsx +0 -4
  49. package/src/backend/injection/useRegisteredComponent.tsx +8 -49
  50. package/src/backend/inputs/ComboboxInput.tsx +5 -21
  51. package/src/backend/inputs/LookupSelect.tsx +14 -10
  52. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +0 -142
  53. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +40 -63
  54. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +11 -14
  55. package/src/backend/utils/__tests__/useAutoDiscoveredFields.groupLabels.test.tsx +59 -0
  56. package/src/backend/utils/crud.ts +0 -2
  57. package/src/backend/utils/useAutoDiscoveredFields.ts +10 -4
  58. package/src/primitives/__tests__/alert.test.tsx +1 -41
  59. package/src/primitives/__tests__/icon-button.test.tsx +0 -70
  60. package/src/primitives/__tests__/notification.test.tsx +1 -1
  61. package/src/primitives/__tests__/pagination.test.tsx +0 -54
  62. package/src/primitives/__tests__/rich-editor.test.tsx +0 -43
  63. package/src/primitives/alert.tsx +2 -5
  64. package/src/primitives/icon-button.tsx +1 -20
  65. package/src/primitives/pagination.tsx +9 -40
  66. package/src/primitives/rich-editor.tsx +4 -9
  67. package/src/utils/__tests__/format.test.ts +14 -27
  68. package/src/utils/format.ts +5 -10
  69. package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +0 -122
  70. package/src/backend/__tests__/DataTable.headerWrap.test.tsx +0 -111
  71. package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +0 -305
@@ -396,148 +396,6 @@ describe('ComboboxInput — eager label resolution', () => {
396
396
  rerender(<ComboboxInput value="b" onChange={() => {}} resolveLabel={resolveLabel} />)
397
397
  await waitFor(() => expect(getInput(container).value).toBe('Label-b'))
398
398
  })
399
-
400
- it('shows a default value that arrives after autoFocus already took the field', async () => {
401
- const options = [{ value: 'wh-1', label: 'Central warehouse' }]
402
- const { container, rerender } = render(
403
- <ComboboxInput value="" onChange={() => {}} autoFocus seedOptions={options} />,
404
- )
405
- expect(document.activeElement).toBe(getInput(container))
406
- rerender(<ComboboxInput value="wh-1" onChange={() => {}} autoFocus seedOptions={options} />)
407
- await waitFor(() => expect(getInput(container).value).toBe('Central warehouse'))
408
- })
409
-
410
- // Guards the opposite direction of the case above: it passes on both the old
411
- // focus-based guard and the current typing-based one, so it is a behavior pin
412
- // rather than a regression test for any specific bug.
413
- it('does not overwrite the query while the user is typing into a focused field', async () => {
414
- const options = [
415
- { value: 'red', label: 'Red' },
416
- { value: 'green', label: 'Green' },
417
- ]
418
- const { container, rerender } = render(
419
- <ComboboxInput value="red" onChange={() => {}} seedOptions={options} />,
420
- )
421
- await waitFor(() => expect(getInput(container).value).toBe('Red'))
422
- const input = getInput(container)
423
- act(() => {
424
- input.focus()
425
- fireEvent.change(input, { target: { value: 'gre' } })
426
- })
427
- rerender(<ComboboxInput value="green" onChange={() => {}} seedOptions={options} />)
428
- expect(getInput(container).value).toBe('gre')
429
- })
430
-
431
- // The regression the typing-based guard originally introduced: `selectValue` clears
432
- // `userTypedRef` while the field keeps focus, so a follow-up load that no longer
433
- // carries the picked option let the self-mapping placeholder overwrite the label with
434
- // the raw record id. Fails on the intermediate implementation, passes on this one.
435
- it('keeps a picked label when a follow-up load drops the option from the list', async () => {
436
- const loadSuggestions = jest.fn(async (query?: string) =>
437
- query === 'WH-B' ? [{ value: 'wh-b', label: 'Warehouse B (WH-B)' }] : [],
438
- )
439
- function Controlled() {
440
- const [value, setValue] = React.useState('')
441
- return (
442
- <ComboboxInput
443
- value={value}
444
- onChange={setValue}
445
- loadSuggestions={loadSuggestions}
446
- allowCustomValues={false}
447
- />
448
- )
449
- }
450
- const { container } = render(<Controlled />)
451
- const input = getInput(container)
452
- act(() => {
453
- input.focus()
454
- fireEvent.change(input, { target: { value: 'WH-B' } })
455
- })
456
-
457
- const option = await screen.findByRole('option', { name: /Warehouse B \(WH-B\)/ })
458
- act(() => {
459
- fireEvent.click(option)
460
- })
461
- expect(getInput(container).value).toBe('Warehouse B (WH-B)')
462
- expect(document.activeElement).toBe(getInput(container))
463
-
464
- // Picking rewrote the query to the label, which re-fires the debounced loader; the
465
- // route cannot match a composite label, so the option list comes back empty.
466
- await waitFor(() => expect(loadSuggestions).toHaveBeenCalledWith('Warehouse B (WH-B)'))
467
- await act(async () => {
468
- await new Promise((res) => setTimeout(res, 250))
469
- })
470
- expect(getInput(container).value).toBe('Warehouse B (WH-B)')
471
- })
472
-
473
- it('adopts a real label that arrives while the field is focused', async () => {
474
- const { container, rerender } = render(
475
- <ComboboxInput value="wh-1" onChange={() => {}} seedOptions={[]} />,
476
- )
477
- const input = getInput(container)
478
- act(() => {
479
- input.focus()
480
- })
481
- expect(getInput(container).value).toBe('wh-1')
482
-
483
- rerender(
484
- <ComboboxInput
485
- value="wh-1"
486
- onChange={() => {}}
487
- seedOptions={[{ value: 'wh-1', label: 'Central warehouse' }]}
488
- />,
489
- )
490
- await waitFor(() => expect(getInput(container).value).toBe('Central warehouse'))
491
- })
492
-
493
- it('does not resurrect the previous label when the parent keeps the value after a clear', () => {
494
- const onChange = jest.fn()
495
- const { container } = render(
496
- <ComboboxInput
497
- value="red"
498
- onChange={onChange}
499
- clearable
500
- seedOptions={[{ value: 'red', label: 'Red' }]}
501
- />,
502
- )
503
- expect(getInput(container).value).toBe('Red')
504
-
505
- act(() => {
506
- fireEvent.click(screen.getByRole('button', { name: /clear value/i }))
507
- })
508
-
509
- expect(onChange).toHaveBeenCalledWith('')
510
- expect(getInput(container).value).toBe('')
511
- })
512
-
513
- it('resumes syncing after a blur, so a later external change is not mistaken for typing', async () => {
514
- const options = [
515
- { value: 'red', label: 'Red' },
516
- { value: 'green', label: 'Green' },
517
- ]
518
- const { container, rerender } = render(
519
- <ComboboxInput value="red" onChange={() => {}} seedOptions={options} allowCustomValues={false} />,
520
- )
521
- const input = getInput(container)
522
- act(() => {
523
- input.focus()
524
- fireEvent.change(input, { target: { value: 'gre' } })
525
- })
526
- expect(getInput(container).value).toBe('gre')
527
-
528
- act(() => {
529
- fireEvent.blur(input)
530
- })
531
- await waitFor(() => expect(getInput(container).value).toBe('Red'))
532
-
533
- act(() => {
534
- getInput(container).focus()
535
- })
536
- rerender(
537
- <ComboboxInput value="green" onChange={() => {}} seedOptions={options} allowCustomValues={false} />,
538
- )
539
- await waitFor(() => expect(getInput(container).value).toBe('Green'))
540
- })
541
399
  })
542
400
 
543
401
  describe('ComboboxInput accessibility', () => {
@@ -162,79 +162,56 @@ describe('LookupSelect keyboard accessibility', () => {
162
162
  expect(input.value).toBe('')
163
163
  expect(escapeSpy).not.toHaveBeenCalled()
164
164
  })
165
- })
166
-
167
- // `disabled` used to gate only the search box, so a caller that locked the
168
- // control still shipped a live option list: the selected card kept its click and
169
- // Enter/Space handlers, "Clear selection" stayed reachable, and the action slot
170
- // could still create a new record. Issue #5248 depended on `disabled` meaning
171
- // "no interaction at all", so every one of those paths is pinned here.
172
- describe('LookupSelect disabled', () => {
173
- const SELECTED = [{ id: 'product-1', title: 'Product One' }]
174
165
 
175
- function renderDisabled(onChange: (next: string | null) => void) {
176
- return render(
177
- <LookupSelect
178
- value="product-1"
179
- onChange={onChange}
180
- options={SELECTED}
181
- disabled
182
- actionSlot={
183
- <button type="button" data-testid="quick-create">
184
- Create
185
- </button>
186
- }
187
- clearLabel="Clear selection"
188
- />,
166
+ // Issue #5456 item 3: a `minQuery` of 0 makes `shouldSearch` true for the empty
167
+ // query, so the full option list renders before any interaction. Callers that
168
+ // want the collapsed "start typing" affordance must keep minQuery >= 1.
169
+ it('renders pre-expanded with minQuery=0 and collapsed with minQuery=1', async () => {
170
+ const expanded = render(
171
+ <LookupSelect value={null} onChange={() => {}} fetchItems={async () => ITEMS} minQuery={0} />,
189
172
  )
190
- }
173
+ expect(await expanded.findAllByRole('option')).toHaveLength(2)
174
+ expanded.unmount()
191
175
 
192
- it('still shows the current selection so the value stays readable', () => {
193
- renderDisabled(() => {})
194
- expect(screen.getByRole('option')).toHaveTextContent('Product One')
195
- })
196
-
197
- it('ignores clicks on the option row', () => {
198
- const onChange = jest.fn()
199
- renderDisabled(onChange)
200
-
201
- fireEvent.click(screen.getByRole('option'))
176
+ const collapsed = render(
177
+ <LookupSelect value={null} onChange={() => {}} fetchItems={async () => ITEMS} minQuery={1} />,
178
+ )
179
+ expect(collapsed.queryByRole('listbox')).toBeNull()
180
+ expect(collapsed.getByText('Start typing to search.')).toBeInTheDocument()
202
181
 
203
- expect(onChange).not.toHaveBeenCalled()
182
+ fireEvent.change(getInput(collapsed.container), { target: { value: 'F' } })
183
+ expect(await collapsed.findAllByRole('option')).toHaveLength(2)
204
184
  })
205
185
 
206
- it('ignores Enter and Space on the option row and keeps it out of the tab order', () => {
186
+ // A selection must never be invisible. Before this, `shouldSearch` only made an
187
+ // exception for a set `value` when the caller ALSO passed `options`, so a
188
+ // minQuery >= 1 lookup without that prop collapsed over its own selection: no
189
+ // selected row, no checkmark and no clear control — the user could not see or
190
+ // undo what was chosen (review of #5481, order-line Status field).
191
+ it('keeps a made selection visible while collapsed, without an options prop', async () => {
207
192
  const onChange = jest.fn()
208
- renderDisabled(onChange)
209
- const option = screen.getByRole('option')
210
-
211
- fireEvent.keyDown(option, { key: 'Enter' })
212
- fireEvent.keyDown(option, { key: ' ' })
213
-
214
- expect(onChange).not.toHaveBeenCalled()
215
- expect(option).toHaveAttribute('tabindex', '-1')
216
- expect(option).toHaveAttribute('aria-disabled', 'true')
217
- })
193
+ const view = render(
194
+ <LookupSelect
195
+ value="plot-1"
196
+ onChange={onChange}
197
+ fetchItems={async () => ITEMS}
198
+ minQuery={1}
199
+ />,
200
+ )
218
201
 
219
- it('hides the clear-selection button so the value cannot be nulled', () => {
220
- renderDisabled(() => {})
221
- expect(screen.queryByRole('button', { name: /clear selection/i })).toBeNull()
222
- })
202
+ const selected = await view.findByRole('option', { selected: true })
203
+ expect(selected).toHaveTextContent(ITEMS[0].title)
223
204
 
224
- it('hides the action slot so no new record can be created into a locked field', () => {
225
- renderDisabled(() => {})
226
- expect(screen.queryByTestId('quick-create')).toBeNull()
205
+ const clear = view.getByRole('button', { name: 'Clear selection' })
206
+ fireEvent.click(clear)
207
+ expect(onChange).toHaveBeenCalledWith(null)
227
208
  })
228
209
 
229
- it('keeps the search box disabled and its keyboard path inert', () => {
230
- const onChange = jest.fn()
231
- const { container } = renderDisabled(onChange)
232
- const input = getInput(container)
233
-
234
- expect(input.disabled).toBe(true)
235
- fireEvent.keyDown(input, { key: 'ArrowDown' })
236
- fireEvent.keyDown(input, { key: 'Enter' })
237
-
238
- expect(onChange).not.toHaveBeenCalled()
210
+ it('stays collapsed at minQuery >= 1 once the selection is cleared', async () => {
211
+ const view = render(
212
+ <LookupSelect value={null} onChange={() => {}} fetchItems={async () => ITEMS} minQuery={1} />,
213
+ )
214
+ expect(view.queryByRole('listbox')).toBeNull()
215
+ expect(view.getByText('Start typing to search.')).toBeInTheDocument()
239
216
  })
240
217
  })
@@ -1579,10 +1579,11 @@ function SidebarPreview({
1579
1579
 
1580
1580
  return (
1581
1581
  <div className="relative w-[240px] overflow-hidden rounded-xl border bg-background shadow-sm">
1582
- {/* Match AppShell's full-width group boxes with padding applied by each inner region. */}
1583
- <div className="flex flex-col gap-3 py-4">
1582
+ {/* Match AppShell's outer aside: border-r, py-4, px-3 — minus border-r since the
1583
+ card border already serves that purpose, plus rounded so it reads as a preview tile. */}
1584
+ <div className="flex flex-col gap-3 px-3 py-4">
1584
1585
  {/* Brand block — same classes as AppShell brand tile */}
1585
- <div className="mb-2 px-3">
1586
+ <div className="mb-2">
1586
1587
  <div className="flex items-center gap-3 rounded-xl p-3">
1587
1588
  <Image
1588
1589
  src="/open-mercato.svg"
@@ -1595,13 +1596,11 @@ function SidebarPreview({
1595
1596
  </div>
1596
1597
  </div>
1597
1598
  {/* Search input mock — same container styling as the real sidebar */}
1598
- <div className="px-3">
1599
- <div className="mb-2 flex items-center gap-2 rounded-lg border border-border bg-background pl-2.5 pr-2 py-2 shadow-sm">
1600
- <Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
1601
- <span className="min-w-0 flex-1 text-sm text-muted-foreground/70 truncate">
1602
- {t('appShell.sidebarCustomizationPreviewSearchPlaceholder', 'Search...')}
1603
- </span>
1604
- </div>
1599
+ <div className="mb-2 flex items-center gap-2 rounded-lg border border-border bg-background pl-2.5 pr-2 py-2 shadow-sm">
1600
+ <Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
1601
+ <span className="min-w-0 flex-1 text-sm text-muted-foreground/70 truncate">
1602
+ {t('appShell.sidebarCustomizationPreviewSearchPlaceholder', 'Search...')}
1603
+ </span>
1605
1604
  </div>
1606
1605
  {groups.length === 0 ? (
1607
1606
  <p className="px-2 text-sm text-muted-foreground">
@@ -1613,10 +1612,7 @@ function SidebarPreview({
1613
1612
  const visibleItems = group.items.filter((item) => item.hidden !== true)
1614
1613
  if (visibleItems.length === 0) return null
1615
1614
  return (
1616
- <div
1617
- key={resolveGroupKey(group)}
1618
- className={`px-3 ${gi < groups.length - 1 ? 'border-b pb-2' : ''}`}
1619
- >
1615
+ <div key={resolveGroupKey(group)}>
1620
1616
  <div className="w-full px-1 justify-between flex text-xs font-medium uppercase tracking-wider text-muted-foreground/70 py-1">
1621
1617
  <span>{group.name}</span>
1622
1618
  </div>
@@ -1645,6 +1641,7 @@ function SidebarPreview({
1645
1641
  )
1646
1642
  })}
1647
1643
  </div>
1644
+ {gi < groups.length - 1 ? <div className="my-2 border-t -ml-3 -mr-4" /> : null}
1648
1645
  </div>
1649
1646
  )
1650
1647
  })}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import * as React from 'react'
5
+ import { renderHook } from '@testing-library/react'
6
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
7
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
8
+ import { useAutoDiscoveredFields } from '../useAutoDiscoveredFields'
9
+ import type { CustomFieldDefDto } from '../customFieldDefs'
10
+
11
+ type Row = { id: string; name: string }
12
+
13
+ const COLUMNS: ColumnDef<Row>[] = [
14
+ { accessorKey: 'name', header: 'Name' },
15
+ { accessorKey: 'grouped', header: 'Grouped', meta: { columnChooserGroup: 'Shipping' } },
16
+ ]
17
+
18
+ const CUSTOM_FIELD_DEFS = [
19
+ { key: 'note', label: 'Note', kind: 'text' },
20
+ ] as unknown as CustomFieldDefDto[]
21
+
22
+ function wrapperFor(locale: string, dict: Record<string, string>) {
23
+ return function Wrapper({ children }: { children: React.ReactNode }) {
24
+ return <I18nProvider locale={locale} dict={dict}>{children}</I18nProvider>
25
+ }
26
+ }
27
+
28
+ function renderFields(locale: string, dict: Record<string, string>) {
29
+ return renderHook(
30
+ () => useAutoDiscoveredFields<Row>({ columns: COLUMNS, customFieldDefs: CUSTOM_FIELD_DEFS }),
31
+ { wrapper: wrapperFor(locale, dict) },
32
+ ).result.current
33
+ }
34
+
35
+ describe('useAutoDiscoveredFields group labels', () => {
36
+ it('falls back to English group labels when no translation is registered', () => {
37
+ const { columnChooserFields } = renderFields('en', {})
38
+ expect(columnChooserFields.find((field) => field.key === 'name')?.group).toBe('Columns')
39
+ expect(columnChooserFields.find((field) => field.key === 'cf_note')?.group).toBe('Custom Fields')
40
+ })
41
+
42
+ it('translates the default and custom-field group labels', () => {
43
+ const { columnChooserFields, advancedFilterFields } = renderFields('pl', {
44
+ 'ui.columnChooser.defaultGroup': 'Kolumny',
45
+ 'ui.columnChooser.customFieldsGroup': 'Pola niestandardowe',
46
+ })
47
+ expect(columnChooserFields.find((field) => field.key === 'name')?.group).toBe('Kolumny')
48
+ expect(columnChooserFields.find((field) => field.key === 'cf_note')?.group).toBe('Pola niestandardowe')
49
+ expect(advancedFilterFields.find((field) => field.key === 'cf_note')?.group).toBe('Pola niestandardowe')
50
+ })
51
+
52
+ it('keeps explicit column-chooser groups untouched', () => {
53
+ const { columnChooserFields } = renderFields('pl', {
54
+ 'ui.columnChooser.defaultGroup': 'Kolumny',
55
+ 'ui.columnChooser.customFieldsGroup': 'Pola niestandardowe',
56
+ })
57
+ expect(columnChooserFields.find((field) => field.key === 'grouped')?.group).toBe('Shipping')
58
+ })
59
+ })
@@ -6,8 +6,6 @@ export type ListResponse<T> = {
6
6
  page: number
7
7
  pageSize: number
8
8
  totalPages: number
9
- /** Present (true) when the server capped the count: `total`/`totalPages` are floors. */
10
- totalIsCapped?: boolean
11
9
  }
12
10
 
13
11
  export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'
@@ -2,6 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import type { RowData } from '@tanstack/react-table'
4
4
  import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
5
+ import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
5
6
  import type { FilterFieldDef as AdvancedFilterFieldDef, FilterFieldType, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'
6
7
  import type { ColumnChooserField } from '../columns/ColumnChooserPanel'
7
8
  import type { CustomFieldDefDto } from './customFieldDefs'
@@ -61,6 +62,11 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
61
62
  columns,
62
63
  customFieldDefs,
63
64
  }: UseAutoDiscoveredFieldsInput<T>): UseAutoDiscoveredFieldsResult {
65
+ // Optional translator: this hook is a public entry point third-party modules
66
+ // render, and it worked without an I18nProvider before it needed labels.
67
+ const t = useOptionalT()
68
+ const defaultGroupLabel = t?.('ui.columnChooser.defaultGroup', 'Columns') ?? 'Columns'
69
+ const customFieldsGroupLabel = t?.('ui.columnChooser.customFieldsGroup', 'Custom Fields') ?? 'Custom Fields'
64
70
  return React.useMemo(() => {
65
71
  const filterFields: AdvancedFilterFieldDef[] = []
66
72
  const chooserFields: ColumnChooserField[] = []
@@ -99,7 +105,7 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
99
105
  chooserFields.push({
100
106
  key: accessorKey,
101
107
  label,
102
- group: meta?.columnChooserGroup ?? 'Columns',
108
+ group: meta?.columnChooserGroup ?? defaultGroupLabel,
103
109
  alwaysVisible: meta?.alwaysVisible ?? i === 0,
104
110
  defaultVisible: true,
105
111
  })
@@ -118,7 +124,7 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
118
124
  key: filterKey,
119
125
  label: def.label || def.key,
120
126
  type,
121
- group: 'Custom Fields',
127
+ group: customFieldsGroupLabel,
122
128
  }
123
129
  if (type === 'select' && Array.isArray(def.options) && def.options.length) {
124
130
  field.options = normalizeCustomFieldFilterOptions(def.options)
@@ -130,12 +136,12 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
130
136
  chooserFields.push({
131
137
  key: filterKey,
132
138
  label: def.label || def.key,
133
- group: def.group?.title ?? 'Custom Fields',
139
+ group: def.group?.title ?? customFieldsGroupLabel,
134
140
  defaultVisible: false,
135
141
  })
136
142
  }
137
143
  }
138
144
 
139
145
  return { advancedFilterFields: filterFields, columnChooserFields: chooserFields }
140
- }, [columns, customFieldDefs])
146
+ }, [columns, customFieldDefs, defaultGroupLabel, customFieldsGroupLabel])
141
147
  }
@@ -189,47 +189,7 @@ describe('Alert primitive', () => {
189
189
  </Alert>,
190
190
  )
191
191
  expect(screen.getByText('Saved').tagName).toBe('H5')
192
- expect(screen.getByText('Your changes were saved.').tagName).toBe('DIV')
193
- })
194
-
195
- describe('AlertDescription accepts block-level children', () => {
196
- it('renders a div so nested paragraphs stay valid HTML', () => {
197
- const { container } = render(
198
- <Alert status="information">
199
- <AlertDescription>
200
- <p>First paragraph.</p>
201
- <p>Second paragraph.</p>
202
- </AlertDescription>
203
- </Alert>,
204
- )
205
- expect(screen.getByText('First paragraph.').parentElement?.tagName).toBe('DIV')
206
- expect(container.querySelectorAll('p p')).toHaveLength(0)
207
- })
208
-
209
- it('keeps a nested list out of a paragraph', () => {
210
- const { container } = render(
211
- <Alert status="warning">
212
- <AlertDescription>
213
- <p>These records changed:</p>
214
- <ul>
215
- <li>r-1</li>
216
- </ul>
217
- </AlertDescription>
218
- </Alert>,
219
- )
220
- expect(container.querySelectorAll('p ul')).toHaveLength(0)
221
- expect(container.querySelectorAll('p p')).toHaveLength(0)
222
- })
223
-
224
- it('forwards a ref to the rendered div', () => {
225
- const ref = React.createRef<HTMLDivElement>()
226
- render(
227
- <Alert>
228
- <AlertDescription ref={ref}>Body</AlertDescription>
229
- </Alert>,
230
- )
231
- expect(ref.current).toBeInstanceOf(HTMLDivElement)
232
- })
192
+ expect(screen.getByText('Your changes were saved.').tagName).toBe('P')
233
193
  })
234
194
 
235
195
  describe('legacy variant prop (BC)', () => {
@@ -38,73 +38,3 @@ describe('IconButton solid variants', () => {
38
38
  expect(className).not.toContain('dark:bg-input')
39
39
  })
40
40
  })
41
-
42
- describe('IconButton pressed state', () => {
43
- // The pressed surface must be restated under `dark:` for the same reason:
44
- // `variant="outline"` (`dark:bg-input/30`) and `variant="ghost"`
45
- // (`dark:hover:bg-accent/50`) otherwise outrank `aria-pressed:bg-primary`
46
- // while `aria-pressed:text-primary-foreground` still applies, leaving a
47
- // dark icon on a dark surface (invisible favorite/watch toggles).
48
- it.each(['outline', 'ghost'] as const)('keeps the primary fill on a pressed %s button in dark mode', (variant) => {
49
- const classes = iconButtonVariants({ variant })
50
- expect(classes).toContain('not-disabled:aria-pressed:bg-primary')
51
- expect(classes).toContain('dark:not-disabled:aria-pressed:bg-primary')
52
- expect(classes).toContain('dark:not-disabled:aria-pressed:hover:bg-primary-hover')
53
- })
54
-
55
- // A toggle can be pressed and disabled at once — an active editor tool on a
56
- // read-only document. The pressed rules are more specific than the
57
- // `disabled:` surface, so without a not-disabled gate the disabled control
58
- // renders as a primary, actionable button in both themes.
59
- it.each(['outline', 'ghost'] as const)(
60
- 'never lets the pressed surface outrank the disabled surface on a %s button',
61
- (variant) => {
62
- const classes = iconButtonVariants({ variant })
63
- expect(classes).toContain('disabled:bg-bg-disabled')
64
- expect(classes).toContain('disabled:text-text-disabled')
65
- for (const pressed of classes.split(' ').filter((cls) => cls.includes('aria-pressed:'))) {
66
- expect(pressed).toContain('not-disabled:aria-pressed:')
67
- }
68
- },
69
- )
70
-
71
- it('renders a pressed disabled toggle with only not-disabled-gated pressed rules', () => {
72
- const { getByRole } = render(
73
- <IconButton variant="outline" aria-label="Highlight" aria-pressed disabled>
74
- <svg />
75
- </IconButton>,
76
- )
77
- const classes = getByRole('button').className.split(' ')
78
- expect(classes).toContain('disabled:bg-bg-disabled')
79
- expect(classes).not.toContain('aria-pressed:bg-primary')
80
- expect(classes).not.toContain('dark:aria-pressed:bg-primary')
81
- expect(classes).toContain('not-disabled:aria-pressed:bg-primary')
82
- })
83
-
84
- // `enabled:` compiles to `&:enabled`, which the CSS spec restricts to form
85
- // elements. `IconButton` supports `asChild` and is rendered onto links across
86
- // the app (MessagesIcon, RoleAssignmentRow, DealsSection), so an `enabled:`
87
- // gate would silently drop the pressed state on every one of those hosts.
88
- // `not-disabled:` (`&:not(:disabled)`) matches a non-form host just fine.
89
- it('keeps the pressed surface on an asChild anchor host', () => {
90
- const { getByRole } = render(
91
- <IconButton variant="ghost" asChild aria-pressed>
92
- <a href="/inbox" aria-label="Messages">
93
- <svg />
94
- </a>
95
- </IconButton>,
96
- )
97
- const anchor = getByRole('link')
98
- const classes = anchor.className.split(' ')
99
- expect(anchor.tagName).toBe('A')
100
- expect(classes).toContain('not-disabled:aria-pressed:bg-primary')
101
- expect(classes).toContain('not-disabled:aria-pressed:text-primary-foreground')
102
- expect(classes).toContain('dark:not-disabled:aria-pressed:bg-primary')
103
- // The whole point of the fix: no pressed rule may be gated on `:enabled`,
104
- // which can never match an anchor.
105
- for (const pressed of classes.filter((cls) => cls.includes('aria-pressed:'))) {
106
- expect(pressed).not.toContain('enabled:aria-pressed:')
107
- expect(pressed).toContain('not-disabled:aria-pressed:')
108
- }
109
- })
110
- })
@@ -26,7 +26,7 @@ describe('Notification primitive', () => {
26
26
  it('renders title as AlertTitle and description below', () => {
27
27
  render(<Notification title="John commented" description="On the latest deal" />)
28
28
  expect(screen.getByText('John commented').tagName).toBe('H5')
29
- expect(screen.getByText('On the latest deal').tagName).toBe('DIV')
29
+ expect(screen.getByText('On the latest deal').tagName).toBe('P')
30
30
  })
31
31
 
32
32
  it('description gets opacity-70 (DS opacity scale; was opacity-72 in early v5, snapped to scale per .ai/ds-rules.md)', () => {
@@ -297,57 +297,3 @@ describe('Pagination', () => {
297
297
  expect(ref.current?.getAttribute('data-slot')).toBe('pagination')
298
298
  })
299
299
  })
300
-
301
- describe('Pagination with a capped total (totalIsCapped)', () => {
302
- // 100 rows / pageSize 10 → a floor of 10 pages; the real set is larger.
303
- const capped = { page: 1, pageSize: 10, total: 100, totalIsCapped: true, onPageChange: () => {} }
304
-
305
- it('renders the capped page info with a trailing plus', () => {
306
- const { container } = render(<Pagination {...capped} />)
307
- const info = container.querySelector('[data-slot="pagination-info"]')
308
- expect(info?.textContent).toBe('Page 1 of 10+')
309
- })
310
-
311
- it('suppresses the last-page jump — it would present the floor as the end of the data', () => {
312
- const { container } = render(<Pagination {...capped} />)
313
- expect(container.querySelector('[data-slot="pagination-last"]')).toBeNull()
314
- expect(container.querySelector('[data-slot="pagination-first"]')).not.toBeNull()
315
- })
316
-
317
- it('keeps Next enabled past the floor while hasNextPage is true', () => {
318
- const onPageChange = jest.fn()
319
- const { container } = render(
320
- <Pagination {...capped} page={10} hasNextPage onPageChange={onPageChange} />,
321
- )
322
- const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
323
- expect(next.disabled).toBe(false)
324
- fireEvent.click(next)
325
- expect(onPageChange).toHaveBeenCalledWith(11)
326
- })
327
-
328
- it('disables Next at the floor when hasNextPage reports a short page', () => {
329
- const { container } = render(<Pagination {...capped} page={10} hasNextPage={false} />)
330
- const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
331
- expect(next.disabled).toBe(true)
332
- })
333
-
334
- it('never clamps a deep-linked page down to the floor', () => {
335
- const { container } = render(<Pagination {...capped} page={37} hasNextPage />)
336
- const current = container.querySelector('[data-slot="pagination-page"][data-state="on"]')
337
- expect(current?.textContent).toBe('37')
338
- const info = container.querySelector('[data-slot="pagination-info"]')
339
- expect(info?.textContent).toBe('Page 37 of 37+')
340
- })
341
-
342
- it('keeps exact-total behavior byte-identical when the flag is absent', () => {
343
- const onPageChange = jest.fn()
344
- const { container } = render(
345
- <Pagination page={10} pageSize={10} total={100} onPageChange={onPageChange} />,
346
- )
347
- const next = container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement
348
- expect(next.disabled).toBe(true)
349
- expect(container.querySelector('[data-slot="pagination-last"]')).not.toBeNull()
350
- const info = container.querySelector('[data-slot="pagination-info"]')
351
- expect(info?.textContent).toBe('Page 10 of 10')
352
- })
353
- })