@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/backend/AppShell.js +66 -7
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +14 -8
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +108 -11
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/forms/ActionsDropdown.js +35 -5
- package/dist/backend/forms/ActionsDropdown.js.map +2 -2
- package/dist/backend/forms/index.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +6 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/spotIds.js +2 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/inputs/DurationInput.js +182 -0
- package/dist/backend/inputs/DurationInput.js.map +7 -0
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/index.js +1 -0
- package/dist/backend/inputs/index.js.map +2 -2
- package/dist/backend/nav/navBadges.js +38 -0
- package/dist/backend/nav/navBadges.js.map +7 -0
- package/dist/primitives/segmented-control.js +4 -4
- package/dist/primitives/segmented-control.js.map +1 -1
- package/dist/primitives/sheet.js +21 -11
- package/dist/primitives/sheet.js.map +2 -2
- package/dist/primitives/textarea.js +54 -4
- package/dist/primitives/textarea.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +106 -2
- package/src/backend/CrudForm.tsx +28 -9
- package/src/backend/DataTable.tsx +176 -22
- package/src/backend/__tests__/CrudForm.density.test.tsx +113 -0
- package/src/backend/__tests__/DataTable.propBulkActions.test.tsx +200 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +76 -0
- package/src/backend/__tests__/navBadges.test.ts +57 -0
- package/src/backend/forms/ActionsDropdown.tsx +73 -6
- package/src/backend/forms/index.ts +1 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +6 -0
- package/src/backend/injection/spotIds.ts +8 -0
- package/src/backend/inputs/DurationInput.tsx +207 -0
- package/src/backend/inputs/EventSelect.tsx +2 -0
- package/src/backend/inputs/__tests__/DurationInput.test.tsx +220 -0
- package/src/backend/inputs/index.ts +1 -0
- package/src/backend/nav/navBadges.ts +61 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +2 -2
- package/src/primitives/__tests__/textarea.test.tsx +50 -0
- package/src/primitives/segmented-control.tsx +7 -7
- package/src/primitives/sheet.tsx +7 -1
- 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
|
+
})
|
|
@@ -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-
|
|
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-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
//
|
|
51
|
-
//
|
|
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-
|
|
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-
|
|
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 ' +
|
package/src/primitives/sheet.tsx
CHANGED
|
@@ -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
|
|
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
|