@open-mercato/ui 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

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 (67) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/records/ActivityCard.js +4 -3
  3. package/dist/ai/records/ActivityCard.js.map +2 -2
  4. package/dist/ai/records/DealCard.js +4 -3
  5. package/dist/ai/records/DealCard.js.map +2 -2
  6. package/dist/ai/records/ProductCard.js +3 -2
  7. package/dist/ai/records/ProductCard.js.map +2 -2
  8. package/dist/backend/AppShell.js +129 -106
  9. package/dist/backend/AppShell.js.map +2 -2
  10. package/dist/backend/DataTable.js +20 -7
  11. package/dist/backend/DataTable.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +26 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/icons/lucideRegistry.generated.js +4 -0
  15. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  16. package/dist/backend/injection/useRegisteredComponent.js +24 -6
  17. package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
  18. package/dist/backend/inputs/ComboboxInput.js +8 -4
  19. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +8 -8
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js +36 -30
  23. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  24. package/dist/backend/utils/crud.js.map +2 -2
  25. package/dist/primitives/alert.js +1 -1
  26. package/dist/primitives/alert.js.map +2 -2
  27. package/dist/primitives/icon-button.js +20 -1
  28. package/dist/primitives/icon-button.js.map +2 -2
  29. package/dist/primitives/pagination.js +14 -9
  30. package/dist/primitives/pagination.js.map +2 -2
  31. package/dist/primitives/rich-editor.js +14 -7
  32. package/dist/primitives/rich-editor.js.map +2 -2
  33. package/dist/utils/format.js +5 -5
  34. package/dist/utils/format.js.map +2 -2
  35. package/package.json +5 -4
  36. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +2 -0
  37. package/src/ai/records/ActivityCard.tsx +4 -3
  38. package/src/ai/records/DealCard.tsx +4 -3
  39. package/src/ai/records/ProductCard.tsx +3 -2
  40. package/src/backend/AppShell.tsx +89 -58
  41. package/src/backend/DataTable.tsx +44 -10
  42. package/src/backend/__tests__/AppShell.test.tsx +162 -1
  43. package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
  44. package/src/backend/__tests__/DataTable.headerWrap.test.tsx +111 -0
  45. package/src/backend/__tests__/DataTable.render.test.tsx +37 -0
  46. package/src/backend/__tests__/NotesSection.test.tsx +89 -0
  47. package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
  48. package/src/backend/detail/NotesSection.tsx +92 -67
  49. package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
  50. package/src/backend/injection/useRegisteredComponent.tsx +49 -8
  51. package/src/backend/inputs/ComboboxInput.tsx +21 -5
  52. package/src/backend/inputs/LookupSelect.tsx +8 -8
  53. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +142 -0
  54. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
  55. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +14 -11
  56. package/src/backend/utils/crud.ts +2 -0
  57. package/src/primitives/__tests__/alert.test.tsx +41 -1
  58. package/src/primitives/__tests__/icon-button.test.tsx +70 -0
  59. package/src/primitives/__tests__/notification.test.tsx +1 -1
  60. package/src/primitives/__tests__/pagination.test.tsx +54 -0
  61. package/src/primitives/__tests__/rich-editor.test.tsx +43 -0
  62. package/src/primitives/alert.tsx +5 -2
  63. package/src/primitives/icon-button.tsx +20 -1
  64. package/src/primitives/pagination.tsx +40 -9
  65. package/src/primitives/rich-editor.tsx +9 -4
  66. package/src/utils/__tests__/format.test.ts +27 -14
  67. package/src/utils/format.ts +10 -5
@@ -215,3 +215,78 @@ describe('LookupSelect keyboard accessibility', () => {
215
215
  expect(view.getByText('Start typing to search.')).toBeInTheDocument()
216
216
  })
217
217
  })
218
+
219
+ // `disabled` used to gate only the search box, so a caller that locked the
220
+ // control still shipped a live option list: the selected card kept its click and
221
+ // Enter/Space handlers, "Clear selection" stayed reachable, and the action slot
222
+ // could still create a new record. Issue #5248 depended on `disabled` meaning
223
+ // "no interaction at all", so every one of those paths is pinned here.
224
+ describe('LookupSelect disabled', () => {
225
+ const SELECTED = [{ id: 'product-1', title: 'Product One' }]
226
+
227
+ function renderDisabled(onChange: (next: string | null) => void) {
228
+ return render(
229
+ <LookupSelect
230
+ value="product-1"
231
+ onChange={onChange}
232
+ options={SELECTED}
233
+ disabled
234
+ actionSlot={
235
+ <button type="button" data-testid="quick-create">
236
+ Create
237
+ </button>
238
+ }
239
+ clearLabel="Clear selection"
240
+ />,
241
+ )
242
+ }
243
+
244
+ it('still shows the current selection so the value stays readable', () => {
245
+ renderDisabled(() => {})
246
+ expect(screen.getByRole('option')).toHaveTextContent('Product One')
247
+ })
248
+
249
+ it('ignores clicks on the option row', () => {
250
+ const onChange = jest.fn()
251
+ renderDisabled(onChange)
252
+
253
+ fireEvent.click(screen.getByRole('option'))
254
+
255
+ expect(onChange).not.toHaveBeenCalled()
256
+ })
257
+
258
+ it('ignores Enter and Space on the option row and keeps it out of the tab order', () => {
259
+ const onChange = jest.fn()
260
+ renderDisabled(onChange)
261
+ const option = screen.getByRole('option')
262
+
263
+ fireEvent.keyDown(option, { key: 'Enter' })
264
+ fireEvent.keyDown(option, { key: ' ' })
265
+
266
+ expect(onChange).not.toHaveBeenCalled()
267
+ expect(option).toHaveAttribute('tabindex', '-1')
268
+ expect(option).toHaveAttribute('aria-disabled', 'true')
269
+ })
270
+
271
+ it('hides the clear-selection button so the value cannot be nulled', () => {
272
+ renderDisabled(() => {})
273
+ expect(screen.queryByRole('button', { name: /clear selection/i })).toBeNull()
274
+ })
275
+
276
+ it('hides the action slot so no new record can be created into a locked field', () => {
277
+ renderDisabled(() => {})
278
+ expect(screen.queryByTestId('quick-create')).toBeNull()
279
+ })
280
+
281
+ it('keeps the search box disabled and its keyboard path inert', () => {
282
+ const onChange = jest.fn()
283
+ const { container } = renderDisabled(onChange)
284
+ const input = getInput(container)
285
+
286
+ expect(input.disabled).toBe(true)
287
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
288
+ fireEvent.keyDown(input, { key: 'Enter' })
289
+
290
+ expect(onChange).not.toHaveBeenCalled()
291
+ })
292
+ })
@@ -1579,11 +1579,10 @@ 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 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">
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">
1585
1584
  {/* Brand block — same classes as AppShell brand tile */}
1586
- <div className="mb-2">
1585
+ <div className="mb-2 px-3">
1587
1586
  <div className="flex items-center gap-3 rounded-xl p-3">
1588
1587
  <Image
1589
1588
  src="/open-mercato.svg"
@@ -1596,11 +1595,13 @@ function SidebarPreview({
1596
1595
  </div>
1597
1596
  </div>
1598
1597
  {/* Search input mock — same container styling as the real sidebar */}
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>
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>
1604
1605
  </div>
1605
1606
  {groups.length === 0 ? (
1606
1607
  <p className="px-2 text-sm text-muted-foreground">
@@ -1612,7 +1613,10 @@ function SidebarPreview({
1612
1613
  const visibleItems = group.items.filter((item) => item.hidden !== true)
1613
1614
  if (visibleItems.length === 0) return null
1614
1615
  return (
1615
- <div key={resolveGroupKey(group)}>
1616
+ <div
1617
+ key={resolveGroupKey(group)}
1618
+ className={`px-3 ${gi < groups.length - 1 ? 'border-b pb-2' : ''}`}
1619
+ >
1616
1620
  <div className="w-full px-1 justify-between flex text-xs font-medium uppercase tracking-wider text-muted-foreground/70 py-1">
1617
1621
  <span>{group.name}</span>
1618
1622
  </div>
@@ -1641,7 +1645,6 @@ function SidebarPreview({
1641
1645
  )
1642
1646
  })}
1643
1647
  </div>
1644
- {gi < groups.length - 1 ? <div className="my-2 border-t -ml-3 -mr-4" /> : null}
1645
1648
  </div>
1646
1649
  )
1647
1650
  })}
@@ -6,6 +6,8 @@ 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
9
11
  }
10
12
 
11
13
  export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'
@@ -189,7 +189,47 @@ 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('P')
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
+ })
193
233
  })
194
234
 
195
235
  describe('legacy variant prop (BC)', () => {
@@ -38,3 +38,73 @@ 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('P')
29
+ expect(screen.getByText('On the latest deal').tagName).toBe('DIV')
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,3 +297,57 @@ 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
+ })
@@ -347,6 +347,49 @@ describe('RichEditor — custom variant + compound API', () => {
347
347
  })
348
348
  })
349
349
 
350
+ // jsdom has no layout engine, so the page-overflow bug this guards against
351
+ // (the hidden measurement row is wider than its column and grows the document)
352
+ // cannot be reproduced by measuring here. What these tests pin instead is the
353
+ // containment structure the fix relies on: the measurement row sits inside its
354
+ // own clipping layer, and the visible toolbar sits outside it so the clip never
355
+ // swallows the toolbar's own drop shadow.
356
+ describe('RichEditor — auto toolbar measurement row containment', () => {
357
+ const renderAutoToolbar = () => {
358
+ const { container } = render(<RichEditor value="" onChange={jest.fn()} variant="full" />)
359
+ const clip = container.querySelector('[data-slot="rich-editor-toolbar-measure-clip"]')!
360
+ const toolbar = container.querySelector('[data-slot="rich-editor-toolbar"]')!
361
+ return { clip, toolbar }
362
+ }
363
+
364
+ it('clips the hidden measurement row in its own layer so it cannot widen the page', () => {
365
+ const { clip } = renderAutoToolbar()
366
+ expect(clip).not.toBeNull()
367
+ expect(clip.getAttribute('aria-hidden')).toBe('true')
368
+ expect(clip.className).toContain('absolute')
369
+ expect(clip.className).toContain('inset-0')
370
+ expect(clip.className).toContain('overflow-hidden')
371
+
372
+ const measureRow = clip.firstElementChild!
373
+ expect(measureRow.className).toContain('invisible')
374
+ expect(measureRow.className).toContain('absolute')
375
+ expect(measureRow.className).toContain('flex-nowrap')
376
+ expect(measureRow.childElementCount).toBeGreaterThan(0)
377
+ })
378
+
379
+ it('keeps the visible toolbar outside the clipping layer so shadow-xs still paints', () => {
380
+ const { clip, toolbar } = renderAutoToolbar()
381
+ expect(clip.contains(toolbar)).toBe(false)
382
+ expect(toolbar.className).toContain('shadow-xs')
383
+ })
384
+
385
+ it('leaves the toolbar wrapper unclipped', () => {
386
+ const { toolbar } = renderAutoToolbar()
387
+ const wrapper = toolbar.parentElement!
388
+ expect(wrapper.className).toContain('relative')
389
+ expect(wrapper.className).not.toContain('overflow-hidden')
390
+ })
391
+ })
392
+
350
393
  describe('RichEditorDropdownButton + RichEditorTextDropdown', () => {
351
394
  it("RichEditorDropdownButton renders inside <RichEditor variant='custom'>", () => {
352
395
  render(
@@ -252,9 +252,12 @@ const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTM
252
252
  AlertTitle.displayName = 'AlertTitle'
253
253
 
254
254
  // Figma "Paragraph/Small": Inter Regular 14 / 20.
255
- const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
255
+ // Renders a <div>, not a <p>: callers routinely nest paragraphs, lists and other
256
+ // block elements inside the description, which is invalid inside <p> and makes
257
+ // React fail hydration with "In HTML, <p> cannot be a descendant of <p>".
258
+ const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
256
259
  ({ className, ...props }, ref) => (
257
- <p ref={ref} className={cn('text-sm leading-5', className)} {...props} />
260
+ <div ref={ref} className={cn('text-sm leading-5', className)} {...props} />
258
261
  ),
259
262
  )
260
263
  AlertDescription.displayName = 'AlertDescription'
@@ -4,7 +4,26 @@ import { cva, type VariantProps } from 'class-variance-authority'
4
4
  import { cn } from '@open-mercato/shared/lib/utils'
5
5
 
6
6
  const iconButtonVariants = cva(
7
- "inline-flex items-center justify-center cursor-pointer transition-all outline-none disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-disabled disabled:shadow-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:shadow-focus aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary-hover",
7
+ // The pressed surface is repeated under `dark:` on purpose: the outline and
8
+ // ghost variants ship `dark:bg-input/30` / `dark:hover:bg-accent/50`, which
9
+ // otherwise outrank the un-prefixed `aria-pressed:` background while the
10
+ // `aria-pressed:text-primary-foreground` still applies — leaving a dark icon
11
+ // on a dark surface (invisible pressed star/bell buttons on the dark theme).
12
+ //
13
+ // Every pressed rule is additionally gated on `not-disabled:` because a toggle
14
+ // can be pressed AND disabled at the same time (an active editor tool while the
15
+ // document is read-only). Without the gate the pressed rules outrank the
16
+ // `disabled:` surface — `dark:aria-pressed:bg-primary` is more specific than
17
+ // `disabled:bg-bg-disabled` — and a control the user cannot operate renders
18
+ // as a primary, actionable button.
19
+ //
20
+ // The gate MUST stay `not-disabled:` (`&:not(:disabled)`) and not `enabled:`
21
+ // (`&:enabled`): the CSS `:enabled` pseudo-class only matches form elements,
22
+ // so on an `asChild` host — this primitive is routinely rendered onto a link —
23
+ // no pressed rule could ever match and the pressed state would not paint at
24
+ // all. `:not(:disabled)` gates the same way on a real `<button>` while leaving
25
+ // a non-form host styled.
26
+ "inline-flex items-center justify-center cursor-pointer transition-all outline-none disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-disabled disabled:shadow-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:shadow-focus not-disabled:aria-pressed:bg-primary not-disabled:aria-pressed:text-primary-foreground not-disabled:aria-pressed:hover:bg-primary-hover dark:not-disabled:aria-pressed:bg-primary dark:not-disabled:aria-pressed:hover:bg-primary-hover",
8
27
  {
9
28
  variants: {
10
29
  variant: {
@@ -151,6 +151,20 @@ export type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {
151
151
  pageSize: number
152
152
  /** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */
153
153
  total: number
154
+ /**
155
+ * `total` is a floor, not an exact count (a capped list count,
156
+ * `OM_LIST_COUNT_CAP`). The derived page count then only bounds the page
157
+ * *list*: the current page is never clamped down to it, the last-page jump
158
+ * is suppressed (it would present the floor as the end of the data), and
159
+ * Next stays available past the floor when `hasNextPage` says so.
160
+ */
161
+ totalIsCapped?: boolean
162
+ /**
163
+ * Caller-provided "a next page exists" signal for the capped case, typically
164
+ * short-page detection (the current page came back full). Ignored when
165
+ * `totalIsCapped` is false; when omitted, Next ends at the known floor.
166
+ */
167
+ hasNextPage?: boolean
154
168
  /** Called when the user changes page. */
155
169
  onPageChange: (next: number) => void
156
170
  /** Called when the user changes page size. Optional — when omitted,
@@ -188,6 +202,8 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
188
202
  page,
189
203
  pageSize,
190
204
  total,
205
+ totalIsCapped = false,
206
+ hasNextPage,
191
207
  onPageChange,
192
208
  onPageSizeChange,
193
209
  pageSizeOptions = [10, 25, 50, 100],
@@ -208,26 +224,37 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
208
224
  const resolvedFormatPageInfo =
209
225
  formatPageInfo ??
210
226
  ((p: number, total: number) =>
211
- t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))
227
+ totalIsCapped
228
+ ? t('ui.pagination.info.pageOfCapped', 'Page {page} of {total}+', { page: p, total })
229
+ : t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))
212
230
  const resolvedFormatPageSizeLabel =
213
231
  formatPageSizeLabel ??
214
232
  ((size: number) =>
215
233
  t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))
216
234
  const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))
217
- const safePage = Math.min(Math.max(1, page), totalPages)
235
+ // A capped total is a floor: never clamp the current page down to the
236
+ // derived count — a page past the floor holds reachable rows.
237
+ const safePage = totalIsCapped
238
+ ? Math.max(1, page)
239
+ : Math.min(Math.max(1, page), totalPages)
240
+ const listPages = Math.max(totalPages, safePage)
241
+ const canGoNext = totalIsCapped
242
+ ? (hasNextPage ?? safePage < listPages)
243
+ : safePage < totalPages
218
244
  const items = React.useMemo(
219
- () => buildPaginationItems(safePage, totalPages, siblingCount, boundaryCount),
220
- [safePage, totalPages, siblingCount, boundaryCount],
245
+ () => buildPaginationItems(safePage, listPages, siblingCount, boundaryCount),
246
+ [safePage, listPages, siblingCount, boundaryCount],
221
247
  )
222
248
  const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)
223
249
 
224
250
  const goTo = React.useCallback(
225
251
  (next: number) => {
226
252
  if (disabled) return
227
- const bounded = Math.min(Math.max(1, next), totalPages)
253
+ const upperBound = totalIsCapped ? Math.max(listPages, safePage + 1) : totalPages
254
+ const bounded = Math.min(Math.max(1, next), upperBound)
228
255
  if (bounded !== safePage) onPageChange(bounded)
229
256
  },
230
- [disabled, onPageChange, safePage, totalPages],
257
+ [disabled, onPageChange, safePage, totalPages, totalIsCapped, listPages],
231
258
  )
232
259
 
233
260
  return (
@@ -243,7 +270,9 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
243
270
  data-slot="pagination-info"
244
271
  className="shrink-0 text-sm text-muted-foreground tabular-nums"
245
272
  >
246
- {resolvedFormatPageInfo(safePage, totalPages)}
273
+ {/* When capped, report the best-known floor: a deep page proves at
274
+ least that many pages exist. */}
275
+ {resolvedFormatPageInfo(safePage, totalIsCapped ? listPages : totalPages)}
247
276
  </div>
248
277
  ) : (
249
278
  <div />
@@ -324,14 +353,16 @@ export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(
324
353
  type="button"
325
354
  data-slot="pagination-next"
326
355
  aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}
327
- disabled={disabled || safePage >= totalPages}
356
+ disabled={disabled || !canGoNext}
328
357
  onClick={() => goTo(safePage + 1)}
329
358
  className={cn(navButtonVariants())}
330
359
  >
331
360
  <ChevronRight aria-hidden="true" className="size-5" />
332
361
  </button>
333
362
  ) : null}
334
- {showFirstLast ? (
363
+ {/* The last-page jump is suppressed for capped totals: it would land
364
+ on the floor page while presenting itself as the end of the data. */}
365
+ {showFirstLast && !totalIsCapped ? (
335
366
  <button
336
367
  type="button"
337
368
  data-slot="pagination-last"
@@ -1543,12 +1543,17 @@ function RichEditorAutoToolbar({
1543
1543
  return (
1544
1544
  <div className="relative w-full">
1545
1545
  <div
1546
- ref={measureRef}
1547
1546
  aria-hidden="true"
1548
- className="pointer-events-none invisible absolute left-0 top-0 flex flex-row flex-nowrap items-center gap-0.5"
1547
+ data-slot="rich-editor-toolbar-measure-clip"
1548
+ className="pointer-events-none absolute inset-0 overflow-hidden"
1549
1549
  >
1550
- {items.map(renderToolbarItem)}
1551
- <RichEditorDropdownButton icon={<MoreVertical />} ariaLabel={labels.more} showChevron={false} menu={<div />} />
1550
+ <div
1551
+ ref={measureRef}
1552
+ className="invisible absolute left-0 top-0 flex flex-row flex-nowrap items-center gap-0.5"
1553
+ >
1554
+ {items.map(renderToolbarItem)}
1555
+ <RichEditorDropdownButton icon={<MoreVertical />} ariaLabel={labels.more} showChevron={false} menu={<div />} />
1556
+ </div>
1552
1557
  </div>
1553
1558
  <RichEditorToolbar ref={containerRef} className="w-full flex-nowrap overflow-hidden">
1554
1559
  {visibleItems.map(renderToolbarItem)}
@@ -1,5 +1,17 @@
1
1
  import { formatCurrency, formatDate } from '../format'
2
2
 
3
+ // Every assertion below pins the locale explicitly (#5105). Without it these tests read the
4
+ // runtime's default locale, so they pass in en-US and fail on any contributor machine whose
5
+ // default is something else.
6
+ const EN = 'en-US'
7
+ const PL = 'pl-PL'
8
+
9
+ // A date-time literal without an offset is parsed as *local* time, so it lands on the same
10
+ // calendar day in every timezone and both the month and the day can be asserted exactly. The
11
+ // instant form cannot: midday UTC keeps the month stable but still shifts the day at the
12
+ // extremes (UTC+14 / UTC-12), which is why the assertions below no longer use one.
13
+ const LOCAL_MIDDAY = '2026-06-09T12:00:00'
14
+
3
15
  describe('formatCurrency', () => {
4
16
  it('returns null for empty input', () => {
5
17
  expect(formatCurrency(null)).toBeNull()
@@ -16,21 +28,25 @@ describe('formatCurrency', () => {
16
28
  })
17
29
 
18
30
  it('formats a numeric value with an ISO currency code', () => {
19
- const result = formatCurrency(1234.5, 'usd')
20
- expect(result).toMatch(/1,234\.5/)
21
- expect(result).toMatch(/\$|USD/)
31
+ expect(formatCurrency(1234.5, 'usd', EN)).toBe('$1,234.50')
22
32
  })
23
33
 
24
34
  it('formats a numeric string the same as a number', () => {
25
- expect(formatCurrency('1234.5', 'USD')).toBe(formatCurrency(1234.5, 'USD'))
35
+ expect(formatCurrency('1234.5', 'USD', EN)).toBe(formatCurrency(1234.5, 'USD', EN))
26
36
  })
27
37
 
28
38
  it('falls back to a plain number without a currency code', () => {
29
- expect(formatCurrency(1000)).toBe(new Intl.NumberFormat().format(1000))
39
+ expect(formatCurrency(1000, null, EN)).toBe('1,000')
30
40
  })
31
41
 
32
42
  it('ignores currency codes that are not three characters', () => {
33
- expect(formatCurrency(1000, 'US')).toBe(new Intl.NumberFormat().format(1000))
43
+ expect(formatCurrency(1000, 'US', EN)).toBe('1,000')
44
+ })
45
+
46
+ it('formats in the requested locale rather than the runtime default', () => {
47
+ expect(formatCurrency(1234.5, 'USD', PL)).toMatch(/^1234,50/)
48
+ expect(formatCurrency(1234.5, 'USD', PL)).toMatch(/USD/)
49
+ expect(formatCurrency(1000, null, PL)).toBe('1000')
34
50
  })
35
51
  })
36
52
 
@@ -46,13 +62,10 @@ describe('formatDate', () => {
46
62
  })
47
63
 
48
64
  it('formats a valid ISO date as a localized short date', () => {
49
- const result = formatDate('2026-06-09T00:00:00.000Z')
50
- expect(result).toEqual(
51
- new Date('2026-06-09T00:00:00.000Z').toLocaleDateString(undefined, {
52
- year: 'numeric',
53
- month: 'short',
54
- day: 'numeric',
55
- }),
56
- )
65
+ expect(formatDate(LOCAL_MIDDAY, EN)).toBe('Jun 9, 2026')
66
+ })
67
+
68
+ it('formats in the requested locale rather than the runtime default', () => {
69
+ expect(formatDate(LOCAL_MIDDAY, PL)).toBe('9 cze 2026')
57
70
  })
58
71
  })