@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/ai/records/ActivityCard.js +4 -3
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +4 -3
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +3 -2
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/backend/AppShell.js +129 -106
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +20 -7
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +26 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +4 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useRegisteredComponent.js +24 -6
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +8 -4
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +8 -8
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +36 -30
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/crud.js.map +2 -2
- package/dist/primitives/alert.js +1 -1
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/icon-button.js +20 -1
- package/dist/primitives/icon-button.js.map +2 -2
- package/dist/primitives/pagination.js +14 -9
- package/dist/primitives/pagination.js.map +2 -2
- package/dist/primitives/rich-editor.js +14 -7
- package/dist/primitives/rich-editor.js.map +2 -2
- package/dist/utils/format.js +5 -5
- package/dist/utils/format.js.map +2 -2
- package/package.json +5 -4
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +2 -0
- package/src/ai/records/ActivityCard.tsx +4 -3
- package/src/ai/records/DealCard.tsx +4 -3
- package/src/ai/records/ProductCard.tsx +3 -2
- package/src/backend/AppShell.tsx +89 -58
- package/src/backend/DataTable.tsx +44 -10
- package/src/backend/__tests__/AppShell.test.tsx +162 -1
- package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
- package/src/backend/__tests__/DataTable.headerWrap.test.tsx +111 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +37 -0
- package/src/backend/__tests__/NotesSection.test.tsx +89 -0
- package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
- package/src/backend/detail/NotesSection.tsx +92 -67
- package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
- package/src/backend/injection/useRegisteredComponent.tsx +49 -8
- package/src/backend/inputs/ComboboxInput.tsx +21 -5
- package/src/backend/inputs/LookupSelect.tsx +8 -8
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +142 -0
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +14 -11
- package/src/backend/utils/crud.ts +2 -0
- package/src/primitives/__tests__/alert.test.tsx +41 -1
- package/src/primitives/__tests__/icon-button.test.tsx +70 -0
- package/src/primitives/__tests__/notification.test.tsx +1 -1
- package/src/primitives/__tests__/pagination.test.tsx +54 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +43 -0
- package/src/primitives/alert.tsx +5 -2
- package/src/primitives/icon-button.tsx +20 -1
- package/src/primitives/pagination.tsx +40 -9
- package/src/primitives/rich-editor.tsx +9 -4
- package/src/utils/__tests__/format.test.ts +27 -14
- 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
|
|
1583
|
-
|
|
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="
|
|
1600
|
-
<
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
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
|
|
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('
|
|
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('
|
|
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(
|
package/src/primitives/alert.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
220
|
-
[safePage,
|
|
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
|
|
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
|
-
{
|
|
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 ||
|
|
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
|
-
{
|
|
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
|
-
|
|
1547
|
+
data-slot="rich-editor-toolbar-measure-clip"
|
|
1548
|
+
className="pointer-events-none absolute inset-0 overflow-hidden"
|
|
1549
1549
|
>
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
})
|