@open-mercato/ui 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82

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 (49) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/backend/AppShell.js +66 -7
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/CrudForm.js +14 -8
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/DataTable.js +108 -11
  7. package/dist/backend/DataTable.js.map +2 -2
  8. package/dist/backend/forms/ActionsDropdown.js +35 -5
  9. package/dist/backend/forms/ActionsDropdown.js.map +2 -2
  10. package/dist/backend/forms/index.js.map +2 -2
  11. package/dist/backend/icons/lucideRegistry.generated.js +6 -0
  12. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  13. package/dist/backend/injection/spotIds.js +2 -0
  14. package/dist/backend/injection/spotIds.js.map +2 -2
  15. package/dist/backend/inputs/DurationInput.js +182 -0
  16. package/dist/backend/inputs/DurationInput.js.map +7 -0
  17. package/dist/backend/inputs/EventSelect.js.map +2 -2
  18. package/dist/backend/inputs/index.js +1 -0
  19. package/dist/backend/inputs/index.js.map +2 -2
  20. package/dist/backend/nav/navBadges.js +38 -0
  21. package/dist/backend/nav/navBadges.js.map +7 -0
  22. package/dist/primitives/segmented-control.js +4 -4
  23. package/dist/primitives/segmented-control.js.map +1 -1
  24. package/dist/primitives/sheet.js +21 -11
  25. package/dist/primitives/sheet.js.map +2 -2
  26. package/dist/primitives/textarea.js +54 -4
  27. package/dist/primitives/textarea.js.map +2 -2
  28. package/package.json +3 -3
  29. package/src/backend/AppShell.tsx +106 -2
  30. package/src/backend/CrudForm.tsx +28 -9
  31. package/src/backend/DataTable.tsx +176 -22
  32. package/src/backend/__tests__/CrudForm.density.test.tsx +113 -0
  33. package/src/backend/__tests__/DataTable.propBulkActions.test.tsx +200 -0
  34. package/src/backend/__tests__/DataTable.render.test.tsx +76 -0
  35. package/src/backend/__tests__/navBadges.test.ts +57 -0
  36. package/src/backend/forms/ActionsDropdown.tsx +73 -6
  37. package/src/backend/forms/index.ts +1 -1
  38. package/src/backend/icons/lucideRegistry.generated.tsx +6 -0
  39. package/src/backend/injection/spotIds.ts +8 -0
  40. package/src/backend/inputs/DurationInput.tsx +207 -0
  41. package/src/backend/inputs/EventSelect.tsx +2 -0
  42. package/src/backend/inputs/__tests__/DurationInput.test.tsx +220 -0
  43. package/src/backend/inputs/index.ts +1 -0
  44. package/src/backend/nav/navBadges.ts +61 -0
  45. package/src/primitives/__tests__/segmented-control.test.tsx +2 -2
  46. package/src/primitives/__tests__/textarea.test.tsx +50 -0
  47. package/src/primitives/segmented-control.tsx +7 -7
  48. package/src/primitives/sheet.tsx +7 -1
  49. package/src/primitives/textarea.tsx +77 -4
@@ -0,0 +1,207 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '@open-mercato/shared/lib/utils'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Input } from '../../primitives/input'
7
+ import { Button } from '../../primitives/button'
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from '../../primitives/select'
15
+
16
+ export type DurationUnit = 'seconds' | 'minutes' | 'hours' | 'days'
17
+
18
+ export type ParsedSingleUnitDuration = { amount: number; unit: DurationUnit }
19
+
20
+ const singleUnitPatterns: Array<{ regex: RegExp; unit: DurationUnit }> = [
21
+ { regex: /^PT(\d+)S$/, unit: 'seconds' },
22
+ { regex: /^PT(\d+)M$/, unit: 'minutes' },
23
+ { regex: /^PT(\d+)H$/, unit: 'hours' },
24
+ { regex: /^P(\d+)D$/, unit: 'days' },
25
+ ]
26
+
27
+ export function parseSingleUnitDuration(value: string): ParsedSingleUnitDuration | null {
28
+ for (const { regex, unit } of singleUnitPatterns) {
29
+ const match = value.match(regex)
30
+ if (match) {
31
+ const amount = parseInt(match[1], 10)
32
+ if (!isNaN(amount)) return { amount, unit }
33
+ }
34
+ }
35
+ return null
36
+ }
37
+
38
+ export function formatSingleUnitDuration(amount: number, unit: DurationUnit): string {
39
+ switch (unit) {
40
+ case 'seconds':
41
+ return `PT${amount}S`
42
+ case 'minutes':
43
+ return `PT${amount}M`
44
+ case 'hours':
45
+ return `PT${amount}H`
46
+ case 'days':
47
+ return `P${amount}D`
48
+ }
49
+ }
50
+
51
+ export function isCompositeCompatibleDuration(value: string | null | undefined): boolean {
52
+ if (!value) return true
53
+ if (value.includes('{{')) return false
54
+ return parseSingleUnitDuration(value) !== null
55
+ }
56
+
57
+ export type DurationInputProps = {
58
+ value?: string
59
+ onChange: (value: string) => void
60
+ disabled?: boolean
61
+ id?: string
62
+ 'aria-label'?: string
63
+ className?: string
64
+ }
65
+
66
+ const durationUnits: DurationUnit[] = ['seconds', 'minutes', 'hours', 'days']
67
+
68
+ export function DurationInput({
69
+ value,
70
+ onChange,
71
+ disabled = false,
72
+ id,
73
+ 'aria-label': ariaLabel,
74
+ className,
75
+ }: DurationInputProps) {
76
+ const t = useT()
77
+ const currentValue = value ?? ''
78
+ const parsed = React.useMemo(() => parseSingleUnitDuration(currentValue), [currentValue])
79
+ const compositeCompatible = isCompositeCompatibleDuration(currentValue)
80
+
81
+ const [mode, setMode] = React.useState<'composite' | 'raw'>(() =>
82
+ compositeCompatible ? 'composite' : 'raw'
83
+ )
84
+ const [unit, setUnit] = React.useState<DurationUnit>(parsed?.unit ?? 'minutes')
85
+
86
+ React.useEffect(() => {
87
+ if (!isCompositeCompatibleDuration(currentValue)) setMode('raw')
88
+ }, [currentValue])
89
+
90
+ React.useEffect(() => {
91
+ if (parsed && parsed.unit !== unit) setUnit(parsed.unit)
92
+ }, [parsed, unit])
93
+
94
+ const amountLabel = ariaLabel ?? t('ui.durationInput.amountLabel', 'Duration amount')
95
+ const unitLabel = t('ui.durationInput.unitLabel', 'Duration unit')
96
+ const rawLabel = ariaLabel ?? t('ui.durationInput.rawLabel', 'Duration expression')
97
+ const unitOptionLabels: Record<DurationUnit, string> = {
98
+ seconds: t('ui.durationInput.units.seconds', 'Seconds'),
99
+ minutes: t('ui.durationInput.units.minutes', 'Minutes'),
100
+ hours: t('ui.durationInput.units.hours', 'Hours'),
101
+ days: t('ui.durationInput.units.days', 'Days'),
102
+ }
103
+
104
+ const handleAmountChange = React.useCallback(
105
+ (event: React.ChangeEvent<HTMLInputElement>) => {
106
+ if (disabled) return
107
+ const raw = event.target.value
108
+ if (raw === '') {
109
+ onChange('')
110
+ return
111
+ }
112
+ const amount = parseInt(raw, 10)
113
+ if (isNaN(amount) || amount < 0) return
114
+ onChange(formatSingleUnitDuration(amount, unit))
115
+ },
116
+ [disabled, onChange, unit]
117
+ )
118
+
119
+ const handleUnitChange = React.useCallback(
120
+ (nextUnit: string) => {
121
+ if (disabled) return
122
+ const resolved = durationUnits.find((candidate) => candidate === nextUnit)
123
+ if (!resolved) return
124
+ setUnit(resolved)
125
+ if (parsed) onChange(formatSingleUnitDuration(parsed.amount, resolved))
126
+ },
127
+ [disabled, onChange, parsed]
128
+ )
129
+
130
+ const handleRawChange = React.useCallback(
131
+ (event: React.ChangeEvent<HTMLInputElement>) => {
132
+ if (disabled) return
133
+ onChange(event.target.value)
134
+ },
135
+ [disabled, onChange]
136
+ )
137
+
138
+ const switchToRaw = React.useCallback(() => {
139
+ setMode('raw')
140
+ }, [])
141
+
142
+ const switchToComposite = React.useCallback(() => {
143
+ if (!isCompositeCompatibleDuration(currentValue)) return
144
+ setMode('composite')
145
+ }, [currentValue])
146
+
147
+ if (mode === 'raw') {
148
+ return (
149
+ <div className={cn('flex items-center gap-2', className)}>
150
+ <Input
151
+ id={id}
152
+ type="text"
153
+ value={currentValue}
154
+ onChange={handleRawChange}
155
+ disabled={disabled}
156
+ aria-label={rawLabel}
157
+ className="flex-1"
158
+ />
159
+ <Button
160
+ type="button"
161
+ variant="ghost"
162
+ size="sm"
163
+ onClick={switchToComposite}
164
+ disabled={disabled || !compositeCompatible}
165
+ >
166
+ {t('ui.durationInput.switchToComposite', 'Use picker')}
167
+ </Button>
168
+ </div>
169
+ )
170
+ }
171
+
172
+ return (
173
+ <div className={cn('flex items-center gap-2', className)}>
174
+ <Input
175
+ id={id}
176
+ type="number"
177
+ min={0}
178
+ value={parsed ? String(parsed.amount) : ''}
179
+ onChange={handleAmountChange}
180
+ disabled={disabled}
181
+ aria-label={amountLabel}
182
+ className="w-24"
183
+ />
184
+ <Select value={unit} onValueChange={handleUnitChange} disabled={disabled}>
185
+ <SelectTrigger aria-label={unitLabel} className="w-32">
186
+ <SelectValue />
187
+ </SelectTrigger>
188
+ <SelectContent>
189
+ {durationUnits.map((option) => (
190
+ <SelectItem key={option} value={option}>
191
+ {unitOptionLabels[option]}
192
+ </SelectItem>
193
+ ))}
194
+ </SelectContent>
195
+ </Select>
196
+ <Button
197
+ type="button"
198
+ variant="ghost"
199
+ size="sm"
200
+ onClick={switchToRaw}
201
+ disabled={disabled}
202
+ >
203
+ {t('ui.durationInput.switchToRaw', 'Edit as text')}
204
+ </Button>
205
+ </div>
206
+ )
207
+ }
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { useMemo } from 'react'
5
5
  import { useQuery } from '@tanstack/react-query'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import type { EventPayloadSchema } from '@open-mercato/shared/modules/events'
7
8
  import { apiCall } from '../utils/apiCall'
8
9
  import {
9
10
  Select,
@@ -26,6 +27,7 @@ export interface EventDefinition {
26
27
  module?: string
27
28
  entity?: string
28
29
  excludeFromTriggers?: boolean
30
+ payloadSchema?: EventPayloadSchema
29
31
  }
30
32
 
31
33
  export interface EventSelectProps {
@@ -0,0 +1,220 @@
1
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
2
+ useT: () => (_key: string, fallback: string) => fallback,
3
+ }))
4
+
5
+ import * as React from 'react'
6
+ import { render, fireEvent } from '@testing-library/react'
7
+ import {
8
+ DurationInput,
9
+ formatSingleUnitDuration,
10
+ isCompositeCompatibleDuration,
11
+ parseSingleUnitDuration,
12
+ } from '../DurationInput'
13
+
14
+ describe('formatSingleUnitDuration', () => {
15
+ it('serializes seconds to PT30S', () => {
16
+ expect(formatSingleUnitDuration(30, 'seconds')).toBe('PT30S')
17
+ })
18
+
19
+ it('serializes minutes to PT5M', () => {
20
+ expect(formatSingleUnitDuration(5, 'minutes')).toBe('PT5M')
21
+ })
22
+
23
+ it('serializes hours to PT2H', () => {
24
+ expect(formatSingleUnitDuration(2, 'hours')).toBe('PT2H')
25
+ })
26
+
27
+ it('serializes days to P1D', () => {
28
+ expect(formatSingleUnitDuration(1, 'days')).toBe('P1D')
29
+ })
30
+ })
31
+
32
+ describe('parseSingleUnitDuration', () => {
33
+ it('parses PT30S', () => {
34
+ expect(parseSingleUnitDuration('PT30S')).toEqual({ amount: 30, unit: 'seconds' })
35
+ })
36
+
37
+ it('parses PT5M', () => {
38
+ expect(parseSingleUnitDuration('PT5M')).toEqual({ amount: 5, unit: 'minutes' })
39
+ })
40
+
41
+ it('parses PT2H', () => {
42
+ expect(parseSingleUnitDuration('PT2H')).toEqual({ amount: 2, unit: 'hours' })
43
+ })
44
+
45
+ it('parses P1D', () => {
46
+ expect(parseSingleUnitDuration('P1D')).toEqual({ amount: 1, unit: 'days' })
47
+ })
48
+
49
+ it('round-trips every unit', () => {
50
+ for (const serialized of ['PT30S', 'PT5M', 'PT2H', 'P1D']) {
51
+ const parsed = parseSingleUnitDuration(serialized)
52
+ expect(parsed).not.toBeNull()
53
+ if (parsed) {
54
+ expect(formatSingleUnitDuration(parsed.amount, parsed.unit)).toBe(serialized)
55
+ }
56
+ }
57
+ })
58
+
59
+ it('rejects interpolation templates', () => {
60
+ expect(parseSingleUnitDuration('{{context.x}}')).toBeNull()
61
+ })
62
+
63
+ it('rejects multi-unit ISO durations', () => {
64
+ expect(parseSingleUnitDuration('PT1H30M')).toBeNull()
65
+ })
66
+
67
+ it('rejects the simple form', () => {
68
+ expect(parseSingleUnitDuration('5m')).toBeNull()
69
+ })
70
+
71
+ it('rejects garbage', () => {
72
+ expect(parseSingleUnitDuration('not-a-duration')).toBeNull()
73
+ })
74
+
75
+ it('rejects the empty string', () => {
76
+ expect(parseSingleUnitDuration('')).toBeNull()
77
+ })
78
+ })
79
+
80
+ describe('isCompositeCompatibleDuration', () => {
81
+ it('treats empty and undefined values as composite-compatible', () => {
82
+ expect(isCompositeCompatibleDuration('')).toBe(true)
83
+ expect(isCompositeCompatibleDuration(undefined)).toBe(true)
84
+ expect(isCompositeCompatibleDuration(null)).toBe(true)
85
+ })
86
+
87
+ it('accepts single-unit ISO values', () => {
88
+ expect(isCompositeCompatibleDuration('PT30S')).toBe(true)
89
+ expect(isCompositeCompatibleDuration('PT5M')).toBe(true)
90
+ expect(isCompositeCompatibleDuration('PT2H')).toBe(true)
91
+ expect(isCompositeCompatibleDuration('P1D')).toBe(true)
92
+ })
93
+
94
+ it('rejects templates, multi-unit values, simple form, and garbage', () => {
95
+ expect(isCompositeCompatibleDuration('{{context.x}}')).toBe(false)
96
+ expect(isCompositeCompatibleDuration('PT1H30M')).toBe(false)
97
+ expect(isCompositeCompatibleDuration('5m')).toBe(false)
98
+ expect(isCompositeCompatibleDuration('garbage')).toBe(false)
99
+ })
100
+ })
101
+
102
+ describe('DurationInput', () => {
103
+ it('renders composite mode with the parsed amount for a single-unit value', () => {
104
+ const { getByLabelText } = render(<DurationInput value="PT5M" onChange={jest.fn()} />)
105
+ expect((getByLabelText('Duration amount') as HTMLInputElement).value).toBe('5')
106
+ expect(getByLabelText('Duration unit')).toBeInTheDocument()
107
+ })
108
+
109
+ it('renders empty composite mode when value is empty', () => {
110
+ const { getByLabelText } = render(<DurationInput value="" onChange={jest.fn()} />)
111
+ expect((getByLabelText('Duration amount') as HTMLInputElement).value).toBe('')
112
+ })
113
+
114
+ it('serializes amount edits with the unit derived from the current value', () => {
115
+ const onChange = jest.fn()
116
+ const { getByLabelText } = render(<DurationInput value="PT2H" onChange={onChange} />)
117
+ fireEvent.change(getByLabelText('Duration amount'), { target: { value: '3' } })
118
+ expect(onChange).toHaveBeenCalledWith('PT3H')
119
+ })
120
+
121
+ it('serializes day values with the P prefix and no T segment', () => {
122
+ const onChange = jest.fn()
123
+ const { getByLabelText } = render(<DurationInput value="P1D" onChange={onChange} />)
124
+ fireEvent.change(getByLabelText('Duration amount'), { target: { value: '2' } })
125
+ expect(onChange).toHaveBeenCalledWith('P2D')
126
+ })
127
+
128
+ it('emits an empty string when the amount is cleared', () => {
129
+ const onChange = jest.fn()
130
+ const { getByLabelText } = render(<DurationInput value="PT5M" onChange={onChange} />)
131
+ fireEvent.change(getByLabelText('Duration amount'), { target: { value: '' } })
132
+ expect(onChange).toHaveBeenCalledWith('')
133
+ })
134
+
135
+ it('falls back to raw mode for interpolation templates', () => {
136
+ const { getByLabelText } = render(
137
+ <DurationInput value="{{context.timeout}}" onChange={jest.fn()} />
138
+ )
139
+ expect((getByLabelText('Duration expression') as HTMLInputElement).value).toBe(
140
+ '{{context.timeout}}'
141
+ )
142
+ })
143
+
144
+ it('falls back to raw mode for multi-unit ISO values', () => {
145
+ const { getByLabelText } = render(<DurationInput value="PT1H30M" onChange={jest.fn()} />)
146
+ expect((getByLabelText('Duration expression') as HTMLInputElement).value).toBe('PT1H30M')
147
+ })
148
+
149
+ it('falls back to raw mode for the simple form', () => {
150
+ const { getByLabelText } = render(<DurationInput value="5m" onChange={jest.fn()} />)
151
+ expect((getByLabelText('Duration expression') as HTMLInputElement).value).toBe('5m')
152
+ })
153
+
154
+ it('falls back to raw mode for unparseable values', () => {
155
+ const { getByLabelText } = render(<DurationInput value="garbage" onChange={jest.fn()} />)
156
+ expect((getByLabelText('Duration expression') as HTMLInputElement).value).toBe('garbage')
157
+ })
158
+
159
+ it('passes raw edits through unchanged', () => {
160
+ const onChange = jest.fn()
161
+ const { getByLabelText } = render(<DurationInput value="PT1H30M" onChange={onChange} />)
162
+ fireEvent.change(getByLabelText('Duration expression'), {
163
+ target: { value: '{{context.deadline}}' },
164
+ })
165
+ expect(onChange).toHaveBeenCalledWith('{{context.deadline}}')
166
+ })
167
+
168
+ it('switches composite to raw keeping the serialized string', () => {
169
+ const onChange = jest.fn()
170
+ const { getByLabelText, getByRole } = render(
171
+ <DurationInput value="PT5M" onChange={onChange} />
172
+ )
173
+ fireEvent.click(getByRole('button', { name: 'Edit as text' }))
174
+ expect((getByLabelText('Duration expression') as HTMLInputElement).value).toBe('PT5M')
175
+ expect(onChange).not.toHaveBeenCalled()
176
+ })
177
+
178
+ it('switches raw back to composite when the value parses to a single unit', () => {
179
+ const { getByLabelText, getByRole } = render(
180
+ <DurationInput value="PT5M" onChange={jest.fn()} />
181
+ )
182
+ fireEvent.click(getByRole('button', { name: 'Edit as text' }))
183
+ fireEvent.click(getByRole('button', { name: 'Use picker' }))
184
+ expect((getByLabelText('Duration amount') as HTMLInputElement).value).toBe('5')
185
+ })
186
+
187
+ it('disables the composite toggle while the raw value is not single-unit parseable', () => {
188
+ const { getByRole } = render(<DurationInput value="PT1H30M" onChange={jest.fn()} />)
189
+ expect(
190
+ (getByRole('button', { name: 'Use picker' }) as HTMLButtonElement).disabled
191
+ ).toBe(true)
192
+ })
193
+
194
+ it('allows switching back to composite from raw mode with an empty value', () => {
195
+ const { getByLabelText, getByRole } = render(<DurationInput value="" onChange={jest.fn()} />)
196
+ fireEvent.click(getByRole('button', { name: 'Edit as text' }))
197
+ fireEvent.click(getByRole('button', { name: 'Use picker' }))
198
+ expect((getByLabelText('Duration amount') as HTMLInputElement).value).toBe('')
199
+ })
200
+
201
+ it('honors a custom aria-label on the primary input in both modes', () => {
202
+ const { getByLabelText, getByRole } = render(
203
+ <DurationInput value="PT5M" onChange={jest.fn()} aria-label="Deadline" />
204
+ )
205
+ expect((getByLabelText('Deadline') as HTMLInputElement).value).toBe('5')
206
+ fireEvent.click(getByRole('button', { name: 'Edit as text' }))
207
+ expect((getByLabelText('Deadline') as HTMLInputElement).value).toBe('PT5M')
208
+ })
209
+
210
+ it('disables inputs and ignores edits when disabled', () => {
211
+ const onChange = jest.fn()
212
+ const { getByLabelText, getByRole } = render(
213
+ <DurationInput value="PT5M" onChange={onChange} disabled />
214
+ )
215
+ expect((getByLabelText('Duration amount') as HTMLInputElement).disabled).toBe(true)
216
+ expect(
217
+ (getByRole('button', { name: 'Edit as text' }) as HTMLButtonElement).disabled
218
+ ).toBe(true)
219
+ })
220
+ })
@@ -9,3 +9,4 @@ export * from './TimeInput'
9
9
  export * from './TimePicker'
10
10
  export * from './DateTimePicker'
11
11
  export * from './DatePicker'
12
+ export * from './DurationInput'
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ /**
6
+ * Live counts rendered on sidebar nav items ("3 waiting for you").
7
+ *
8
+ * Why this is a client-side store rather than a field on the chrome payload:
9
+ * `/api/auth/admin/nav` is cached for 30 minutes behind a module-surface
10
+ * fingerprint, so a per-user count served through it would be both stale and
11
+ * cache-poisoning. Core owns the capability and the rendering; whichever module
12
+ * owns the number mounts a headless widget into `shell:sidebar:badges` and
13
+ * publishes here.
14
+ *
15
+ * Counts are advisory: a nav item renders exactly what it is given, and an
16
+ * absent or zero count renders nothing at all — never a "0" chip.
17
+ */
18
+ export type NavBadgeTone = 'default' | 'attention'
19
+
20
+ export type NavBadge = {
21
+ count: number
22
+ tone?: NavBadgeTone
23
+ /** Accessible label; falls back to the raw count when omitted. */
24
+ label?: string
25
+ }
26
+
27
+ const badges = new Map<string, NavBadge>()
28
+ const listeners = new Set<() => void>()
29
+
30
+ function emit(): void {
31
+ for (const listener of listeners) listener()
32
+ }
33
+
34
+ export function setNavBadge(href: string, badge: NavBadge | null): void {
35
+ if (!href) return
36
+ if (!badge || !Number.isFinite(badge.count) || badge.count <= 0) {
37
+ if (badges.delete(href)) emit()
38
+ return
39
+ }
40
+ const current = badges.get(href)
41
+ if (current && current.count === badge.count && current.tone === badge.tone && current.label === badge.label) return
42
+ badges.set(href, badge)
43
+ emit()
44
+ }
45
+
46
+ export function clearNavBadge(href: string): void {
47
+ setNavBadge(href, null)
48
+ }
49
+
50
+ function subscribe(listener: () => void): () => void {
51
+ listeners.add(listener)
52
+ return () => {
53
+ listeners.delete(listener)
54
+ }
55
+ }
56
+
57
+ export function useNavBadge(href: string): NavBadge | null {
58
+ const getSnapshot = React.useCallback(() => badges.get(href) ?? null, [href])
59
+ const getServerSnapshot = React.useCallback(() => null, [])
60
+ return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
61
+ }
@@ -74,7 +74,7 @@ describe('SegmentedControl', () => {
74
74
  const { container } = render(<Controlled />)
75
75
  const root = container.querySelector('[data-slot="segmented-control"]') as HTMLElement
76
76
  expect(root.className).toContain('h-8')
77
- expect(root.className).toContain('rounded-full')
77
+ expect(root.className).toContain('rounded-lg')
78
78
  const item = container.querySelector('[data-slot="segmented-control-item"]') as HTMLElement
79
79
  expect(item.className).toContain('h-7')
80
80
  expect(item.className).toContain('text-sm')
@@ -123,7 +123,7 @@ describe('SegmentedControl', () => {
123
123
  )
124
124
  const root = container.querySelector('[data-slot="segmented-control"]') as HTMLElement
125
125
  expect(root.className).toContain('custom-class')
126
- expect(root.className).toContain('rounded-full')
126
+ expect(root.className).toContain('rounded-lg')
127
127
  expect(root.className).toContain('bg-muted')
128
128
  })
129
129
  })
@@ -0,0 +1,50 @@
1
+ import * as React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { Textarea } from '../textarea'
4
+
5
+ function stubScrollHeight(element: HTMLElement, value: number) {
6
+ Object.defineProperty(element, 'scrollHeight', { configurable: true, value })
7
+ }
8
+
9
+ describe('Textarea', () => {
10
+ it('keeps the manual resize handle when autoResize is off', () => {
11
+ render(<Textarea aria-label="notes" />)
12
+ const field = screen.getByLabelText('notes')
13
+ expect(field.className).toContain('resize-y')
14
+ expect(field.className).not.toContain('resize-none')
15
+ })
16
+
17
+ it('drops the manual resize handle when autoResize is on', () => {
18
+ render(<Textarea aria-label="notes" autoResize />)
19
+ const field = screen.getByLabelText('notes')
20
+ expect(field.className).toContain('resize-none')
21
+ })
22
+
23
+ it('forwards the ref while owning the element internally', () => {
24
+ const ref = React.createRef<HTMLTextAreaElement>()
25
+ render(<Textarea aria-label="notes" autoResize ref={ref} />)
26
+ expect(ref.current).toBe(screen.getByLabelText('notes'))
27
+ })
28
+
29
+ it('grows to fit content and stops scrolling below the row cap', () => {
30
+ render(<Textarea aria-label="notes" autoResize maxRows={4} defaultValue="" />)
31
+ const field = screen.getByLabelText('notes') as HTMLTextAreaElement
32
+ stubScrollHeight(field, 60)
33
+ fireEvent.change(field, { target: { value: 'two\nlines' } })
34
+ expect(field.style.overflowY).toBe('hidden')
35
+ expect(field.style.height).not.toBe('auto')
36
+ })
37
+
38
+ it('scrolls once the content exceeds the row cap', () => {
39
+ render(<Textarea aria-label="notes" autoResize maxRows={2} defaultValue="" />)
40
+ const field = screen.getByLabelText('notes') as HTMLTextAreaElement
41
+ stubScrollHeight(field, 4000)
42
+ fireEvent.change(field, { target: { value: 'a'.repeat(4000) } })
43
+ expect(field.style.overflowY).toBe('auto')
44
+ })
45
+
46
+ it('still renders the character counter', () => {
47
+ render(<Textarea aria-label="notes" autoResize showCount maxLength={10} defaultValue="abc" />)
48
+ expect(screen.getByText('3/10')).toBeInTheDocument()
49
+ })
50
+ })
@@ -7,9 +7,9 @@ import { cva, type VariantProps } from 'class-variance-authority'
7
7
  import { cn } from '@open-mercato/shared/lib/utils'
8
8
 
9
9
  /**
10
- * iOS-style segmented control per Figma `Switch / Chart / Cryptocurrency`
11
- * (component set id `199963:1442` in DS Open Mercato). Renders a single
12
- * track with N items where exactly one is selected at a time. Selecting
10
+ * Segmented control per Figma DS Open Mercato (node `2604:114`): a muted
11
+ * rounded-rectangle track with a raised white card for the selected segment.
12
+ * Renders a single track with N items where exactly one is selected at a time. Selecting
13
13
  * a new item fires `onValueChange`.
14
14
  *
15
15
  * Use for **mutually-exclusive view state** — list page filters like
@@ -47,8 +47,8 @@ const SegmentedControlContext = React.createContext<SegmentedControlContextValue
47
47
  })
48
48
 
49
49
  const trackVariants = cva(
50
- // Pill-shaped track with subtle inner padding so selected items render
51
- // a smaller inner pill (the iOS slide-thumb effect). Track is muted —
50
+ // Rounded-rectangle track (DS Open Mercato node 2604:114) with subtle inner
51
+ // padding so the selected item renders as a smaller inner card. Track is muted —
52
52
  // selected item raises with bg-background + shadow-sm. We use full
53
53
  // `bg-muted` (not /40) so the contrast between track and a selected
54
54
  // bg-background item stays visible in the light theme; in dark mode
@@ -59,7 +59,7 @@ const trackVariants = cva(
59
59
  // sm → track h-7 (28px) − 2px border − 2px padding (p-px ×2) = 24px → matches item h-6
60
60
  // Using `p-0.5` (2px each side = 4px total) instead breaks both sizes by
61
61
  // 2px so the selected pill clips top and bottom against the track border.
62
- 'inline-flex w-fit gap-0 rounded-full border border-input bg-muted p-px transition-colors',
62
+ 'inline-flex w-fit gap-0 rounded-lg border border-input bg-muted p-px transition-colors',
63
63
  {
64
64
  variants: {
65
65
  size: {
@@ -85,7 +85,7 @@ const itemVariants = cva(
85
85
  // shadow, important when the surrounding theme is high-key and the
86
86
  // shadow alone is hard to read). Unselected text is muted; hover
87
87
  // only nudges color (no bg change — keeps the track flat).
88
- 'inline-flex items-center justify-center rounded-full font-medium ' +
88
+ 'inline-flex items-center justify-center rounded-md font-medium ' +
89
89
  'transition-all outline-none focus-visible:shadow-focus ' +
90
90
  'disabled:cursor-not-allowed disabled:opacity-50 ' +
91
91
  'data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:font-semibold data-[state=checked]:shadow-sm ' +
@@ -19,6 +19,7 @@ const SheetOverlay = React.forwardRef<
19
19
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20
20
  >(({ className, ...props }, ref) => (
21
21
  <DialogPrimitive.Overlay
22
+ data-slot="sheet-overlay"
22
23
  className={cn(
23
24
  'fixed inset-x-0 bottom-0 top-[var(--topbar-height,0px)] z-overlay bg-black/40 backdrop-blur-sm',
24
25
  'data-[state=open]:animate-in data-[state=closed]:animate-out',
@@ -89,7 +90,12 @@ const SheetContent = React.forwardRef<
89
90
  >(({ side = 'right', className, children, hideClose = false, closeLabel = 'Close', ...props }, ref) => (
90
91
  <SheetPortal>
91
92
  <SheetOverlay />
92
- <DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
93
+ <DialogPrimitive.Content
94
+ ref={ref}
95
+ data-slot="sheet-content"
96
+ className={cn(sheetVariants({ side }), className)}
97
+ {...props}
98
+ >
93
99
  {children}
94
100
  {!hideClose ? (
95
101
  <DialogPrimitive.Close