@open-mercato/ui 0.6.1-develop.3278.1.a4d26475e4 → 0.6.1-develop.3291.1.6fad645fd0
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/AGENTS.md +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/utils/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.js.map +2 -2
- package/dist/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- package/src/backend/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +4 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/website-input.tsx +72 -0
|
@@ -3,6 +3,7 @@ import { FileCode, Globe, Lock } from 'lucide-react'
|
|
|
3
3
|
import { type CrudField } from '../CrudForm'
|
|
4
4
|
import { IconButton } from '../../primitives/icon-button'
|
|
5
5
|
import { Input } from '../../primitives/input'
|
|
6
|
+
import { EmailInput } from '../../primitives/email-input'
|
|
6
7
|
import { Label } from '../../primitives/label'
|
|
7
8
|
import {
|
|
8
9
|
Select,
|
|
@@ -191,9 +192,8 @@ function ComposeModeFields({ compose }: ComposeProps) {
|
|
|
191
192
|
{compose.visibility === 'public' ? (
|
|
192
193
|
<>
|
|
193
194
|
<Label htmlFor="messages-compose-external-email">{compose.t('messages.externalEmail', 'External email')}</Label>
|
|
194
|
-
<
|
|
195
|
+
<EmailInput
|
|
195
196
|
id="messages-compose-external-email"
|
|
196
|
-
type="email"
|
|
197
197
|
value={compose.externalEmail}
|
|
198
198
|
onChange={(event) => compose.setExternalEmail(event.target.value)}
|
|
199
199
|
placeholder={compose.t('messages.placeholders.externalEmail', 'name@example.com')}
|
|
@@ -124,12 +124,19 @@ export function normalizeCrudServerError(err: unknown): NormalizedCrudServerErro
|
|
|
124
124
|
|
|
125
125
|
if (typeof current !== 'object') continue
|
|
126
126
|
|
|
127
|
+
const errorField = (current as any).error
|
|
128
|
+
const nestedErrorMessage =
|
|
129
|
+
errorField && typeof errorField === 'object' &&
|
|
130
|
+
typeof (errorField as any).message === 'string' &&
|
|
131
|
+
(errorField as any).message.trim()
|
|
132
|
+
? ((errorField as any).message as string)
|
|
133
|
+
: undefined
|
|
127
134
|
const candidateMessage =
|
|
128
|
-
typeof
|
|
129
|
-
?
|
|
135
|
+
typeof errorField === 'string'
|
|
136
|
+
? errorField
|
|
130
137
|
: typeof (current as any).message === 'string'
|
|
131
138
|
? (current as any).message
|
|
132
|
-
:
|
|
139
|
+
: nestedErrorMessage
|
|
133
140
|
if (candidateMessage && !message) message = candidateMessage
|
|
134
141
|
|
|
135
142
|
for (const key of JSON_FIELD_KEYS) {
|
|
@@ -266,12 +273,12 @@ export async function raiseCrudError(res: Response, fallbackMessage?: string): P
|
|
|
266
273
|
|
|
267
274
|
if (parsed && typeof parsed === 'object') {
|
|
268
275
|
const data = parsed as Record<string, unknown>
|
|
276
|
+
const normalized = normalizeCrudServerError(data)
|
|
277
|
+
const normalizedMessage = normalized.message?.trim()
|
|
269
278
|
const rawMessage =
|
|
270
|
-
|
|
271
|
-
?
|
|
272
|
-
:
|
|
273
|
-
? data.message.trim()
|
|
274
|
-
: fallbackMessage ?? `Request failed (${res.status})`
|
|
279
|
+
normalizedMessage && normalizedMessage.length > 0
|
|
280
|
+
? normalizedMessage
|
|
281
|
+
: fallbackMessage ?? `Request failed (${res.status})`
|
|
275
282
|
const message = parseServerMessage(rawMessage)
|
|
276
283
|
throw buildHttpError(message, {
|
|
277
284
|
...data,
|
|
@@ -135,6 +135,86 @@ describe('buildPortalNav', () => {
|
|
|
135
135
|
expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
|
|
136
136
|
})
|
|
137
137
|
|
|
138
|
+
it('dedupes manifest entries that share the same portal pattern', () => {
|
|
139
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
140
|
+
makeRoute({
|
|
141
|
+
moduleId: 'core',
|
|
142
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
143
|
+
nav: { label: 'Dashboard', group: 'main', order: 10 },
|
|
144
|
+
}),
|
|
145
|
+
makeRoute({
|
|
146
|
+
moduleId: 'app',
|
|
147
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
148
|
+
nav: { label: 'Dashboard', group: 'main', order: 10 },
|
|
149
|
+
}),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
153
|
+
const main = groups.find((g) => g.id === 'main')!
|
|
154
|
+
expect(main.items).toHaveLength(1)
|
|
155
|
+
const ids = main.items.map((i) => i.id)
|
|
156
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('prefers the override with non-empty requireCustomerFeatures over a broad fallback', () => {
|
|
160
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
161
|
+
makeRoute({
|
|
162
|
+
moduleId: 'core',
|
|
163
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
164
|
+
nav: { label: 'Dashboard', group: 'main' },
|
|
165
|
+
}),
|
|
166
|
+
makeRoute({
|
|
167
|
+
moduleId: 'app',
|
|
168
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
169
|
+
requireCustomerFeatures: ['portal.dashboard.view'],
|
|
170
|
+
nav: { label: 'Dashboard', group: 'main' },
|
|
171
|
+
}),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
const without = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
175
|
+
expect(without).toEqual([])
|
|
176
|
+
|
|
177
|
+
const withGrant = buildPortalNav({
|
|
178
|
+
routes,
|
|
179
|
+
orgSlug: 'my-org',
|
|
180
|
+
grantedFeatures: ['portal.dashboard.view'],
|
|
181
|
+
})
|
|
182
|
+
expect(withGrant).toEqual([
|
|
183
|
+
{ id: 'main', items: [expect.objectContaining({ label: 'Dashboard' })] },
|
|
184
|
+
])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('prefers the override with the longer requireCustomerFeatures list', () => {
|
|
188
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
189
|
+
makeRoute({
|
|
190
|
+
moduleId: 'core',
|
|
191
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
192
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
193
|
+
nav: { label: 'Orders', group: 'main' },
|
|
194
|
+
}),
|
|
195
|
+
makeRoute({
|
|
196
|
+
moduleId: 'app',
|
|
197
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
198
|
+
requireCustomerFeatures: ['portal.orders.view', 'portal.orders.manage'],
|
|
199
|
+
nav: { label: 'Orders', group: 'main' },
|
|
200
|
+
}),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
const partial = buildPortalNav({
|
|
204
|
+
routes,
|
|
205
|
+
orgSlug: 'my-org',
|
|
206
|
+
grantedFeatures: ['portal.orders.view'],
|
|
207
|
+
})
|
|
208
|
+
expect(partial).toEqual([])
|
|
209
|
+
|
|
210
|
+
const full = buildPortalNav({
|
|
211
|
+
routes,
|
|
212
|
+
orgSlug: 'my-org',
|
|
213
|
+
grantedFeatures: ['portal.orders.view', 'portal.orders.manage'],
|
|
214
|
+
})
|
|
215
|
+
expect(full[0].items[0].label).toBe('Orders')
|
|
216
|
+
})
|
|
217
|
+
|
|
138
218
|
it('sorts items by order then label', () => {
|
|
139
219
|
const routes: FrontendRouteManifestEntry[] = [
|
|
140
220
|
makeRoute({
|
package/src/portal/utils/nav.ts
CHANGED
|
@@ -46,6 +46,20 @@ function pickGroup(group: unknown): PortalNavGroupId {
|
|
|
46
46
|
return 'main'
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function pickPreferredRoute(
|
|
50
|
+
existing: FrontendRouteManifestEntry,
|
|
51
|
+
candidate: FrontendRouteManifestEntry,
|
|
52
|
+
): FrontendRouteManifestEntry {
|
|
53
|
+
const existingFeatures = existing.requireCustomerFeatures ?? []
|
|
54
|
+
const candidateFeatures = candidate.requireCustomerFeatures ?? []
|
|
55
|
+
if (existingFeatures.length === 0 && candidateFeatures.length > 0) return candidate
|
|
56
|
+
if (candidateFeatures.length === 0 && existingFeatures.length > 0) return existing
|
|
57
|
+
if (existingFeatures.length !== candidateFeatures.length) {
|
|
58
|
+
return candidateFeatures.length > existingFeatures.length ? candidate : existing
|
|
59
|
+
}
|
|
60
|
+
return existing
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
/**
|
|
50
64
|
* Build the portal sidebar from the frontend route manifest.
|
|
51
65
|
*
|
|
@@ -66,19 +80,27 @@ export function buildPortalNav({
|
|
|
66
80
|
const mainItems: PortalNavItem[] = []
|
|
67
81
|
const accountItems: PortalNavItem[] = []
|
|
68
82
|
|
|
83
|
+
const dedupedByPattern = new Map<string, FrontendRouteManifestEntry>()
|
|
69
84
|
for (const route of routes) {
|
|
70
85
|
const pattern = route.pattern ?? route.path
|
|
71
86
|
if (!isPortalPattern(pattern)) continue
|
|
72
87
|
if (route.navHidden) continue
|
|
73
88
|
const nav = route.nav
|
|
74
89
|
if (!nav || typeof nav.label !== 'string' || nav.label.length === 0) continue
|
|
90
|
+
const existing = dedupedByPattern.get(pattern)
|
|
91
|
+
dedupedByPattern.set(pattern, existing ? pickPreferredRoute(existing, route) : route)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const route of dedupedByPattern.values()) {
|
|
95
|
+
const pattern = (route.pattern ?? route.path) as string
|
|
96
|
+
const nav = route.nav!
|
|
75
97
|
|
|
76
98
|
const requireFeatures = route.requireCustomerFeatures ?? []
|
|
77
99
|
if (!isPortalAdmin && requireFeatures.length) {
|
|
78
100
|
if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
|
|
79
101
|
}
|
|
80
102
|
|
|
81
|
-
const href = resolveHref(pattern
|
|
103
|
+
const href = resolveHref(pattern, orgSlug)
|
|
82
104
|
if (!hasNoUnresolvedParams(href)) continue
|
|
83
105
|
|
|
84
106
|
const group = pickGroup(nav.group)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
4
|
+
useT: () => (_key: string, fallback: string) => fallback,
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
import * as React from 'react'
|
|
8
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
9
|
+
import { PasswordInput } from '../password-input'
|
|
10
|
+
|
|
11
|
+
describe('PasswordInput primitive', () => {
|
|
12
|
+
it('renders the inner input as type="password" by default', () => {
|
|
13
|
+
const { container } = render(<PasswordInput defaultValue="secret" />)
|
|
14
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
15
|
+
expect(input).not.toBeNull()
|
|
16
|
+
expect(input.type).toBe('password')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('toggles the input type when the reveal button is clicked', () => {
|
|
20
|
+
const { container } = render(<PasswordInput defaultValue="secret" />)
|
|
21
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
22
|
+
const toggle = screen.getByRole('button', { name: 'Show password' })
|
|
23
|
+
expect(input.type).toBe('password')
|
|
24
|
+
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
25
|
+
|
|
26
|
+
fireEvent.click(toggle)
|
|
27
|
+
|
|
28
|
+
expect(input.type).toBe('text')
|
|
29
|
+
expect(screen.getByRole('button', { name: 'Hide password' })).toHaveAttribute('aria-pressed', 'true')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('respects the controlled reveal state via revealed prop', () => {
|
|
33
|
+
const onRevealedChange = jest.fn()
|
|
34
|
+
const { container, rerender } = render(
|
|
35
|
+
<PasswordInput defaultValue="secret" revealed={false} onRevealedChange={onRevealedChange} />,
|
|
36
|
+
)
|
|
37
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
38
|
+
expect(input.type).toBe('password')
|
|
39
|
+
|
|
40
|
+
fireEvent.click(screen.getByRole('button', { name: 'Show password' }))
|
|
41
|
+
expect(onRevealedChange).toHaveBeenCalledWith(true)
|
|
42
|
+
expect(input.type).toBe('password')
|
|
43
|
+
|
|
44
|
+
rerender(<PasswordInput defaultValue="secret" revealed onRevealedChange={onRevealedChange} />)
|
|
45
|
+
expect(input.type).toBe('text')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('hides the reveal toggle when revealable is false', () => {
|
|
49
|
+
render(<PasswordInput defaultValue="secret" revealable={false} />)
|
|
50
|
+
expect(screen.queryByRole('button', { name: /show password|hide password/i })).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('hides the leading lock icon when showLockIcon is false', () => {
|
|
54
|
+
const { container } = render(<PasswordInput showLockIcon={false} defaultValue="secret" />)
|
|
55
|
+
expect(container.querySelector('svg.lucide-lock')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('forwards consumer-supplied autoComplete to the input element', () => {
|
|
59
|
+
const { container } = render(
|
|
60
|
+
<PasswordInput defaultValue="secret" autoComplete="new-password" />,
|
|
61
|
+
)
|
|
62
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
63
|
+
expect(input).toHaveAttribute('autocomplete', 'new-password')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not set autoComplete when the consumer omits it (lets browser heuristics decide)', () => {
|
|
67
|
+
const { container } = render(<PasswordInput defaultValue="secret" />)
|
|
68
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
69
|
+
expect(input.getAttribute('autocomplete')).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('forwards name and id to the input element', () => {
|
|
73
|
+
const { container } = render(<PasswordInput id="signup-password" name="password" />)
|
|
74
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
75
|
+
expect(input.id).toBe('signup-password')
|
|
76
|
+
expect(input.name).toBe('password')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('disables both the input and the reveal toggle when disabled', () => {
|
|
80
|
+
const { container } = render(<PasswordInput defaultValue="secret" disabled />)
|
|
81
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
82
|
+
const toggle = screen.getByRole('button', { name: 'Show password' }) as HTMLButtonElement
|
|
83
|
+
expect(input.disabled).toBe(true)
|
|
84
|
+
expect(toggle.disabled).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('forwards refs to the underlying input element', () => {
|
|
88
|
+
const ref = React.createRef<HTMLInputElement>()
|
|
89
|
+
render(<PasswordInput ref={ref} defaultValue="secret" />)
|
|
90
|
+
expect(ref.current).not.toBeNull()
|
|
91
|
+
expect(ref.current?.tagName).toBe('INPUT')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
4
|
+
useT: () => (_key: string, fallback: string) => fallback,
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
import * as React from 'react'
|
|
8
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
9
|
+
import { SearchInput } from '../search-input'
|
|
10
|
+
|
|
11
|
+
function Harness(initial: string, props: Partial<React.ComponentProps<typeof SearchInput>> = {}) {
|
|
12
|
+
const { onChange: consumerOnChange, ...rest } = props
|
|
13
|
+
function Wrapped() {
|
|
14
|
+
const [value, setValue] = React.useState(initial)
|
|
15
|
+
return (
|
|
16
|
+
<SearchInput
|
|
17
|
+
{...rest}
|
|
18
|
+
value={value}
|
|
19
|
+
onChange={(next) => {
|
|
20
|
+
setValue(next)
|
|
21
|
+
consumerOnChange?.(next)
|
|
22
|
+
}}
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return render(<Wrapped />)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('SearchInput primitive', () => {
|
|
30
|
+
it('renders an input with type="search"', () => {
|
|
31
|
+
const { container } = Harness('')
|
|
32
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
33
|
+
expect(input).not.toBeNull()
|
|
34
|
+
expect(input.type).toBe('search')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('exposes the searchbox ARIA role (matches Playwright getByRole expectations)', () => {
|
|
38
|
+
Harness('')
|
|
39
|
+
expect(screen.getByRole('searchbox')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('uses the translated placeholder fallback', () => {
|
|
43
|
+
Harness('')
|
|
44
|
+
expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('honors a consumer-supplied placeholder over the i18n fallback', () => {
|
|
48
|
+
Harness('', { placeholder: 'Find a customer' })
|
|
49
|
+
expect(screen.getByPlaceholderText('Find a customer')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not render the clear button when value is empty', () => {
|
|
53
|
+
Harness('')
|
|
54
|
+
expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders the clear button when value is non-empty', () => {
|
|
58
|
+
Harness('jan')
|
|
59
|
+
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('clicking the clear button calls onChange("") when no onClear is supplied', () => {
|
|
63
|
+
const onChange = jest.fn()
|
|
64
|
+
Harness('jan', { onChange })
|
|
65
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
|
66
|
+
expect(onChange).toHaveBeenCalledWith('')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('clicking the clear button calls onClear instead of onChange when onClear is supplied', () => {
|
|
70
|
+
const onChange = jest.fn()
|
|
71
|
+
const onClear = jest.fn()
|
|
72
|
+
Harness('jan', { onChange, onClear })
|
|
73
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
|
74
|
+
expect(onClear).toHaveBeenCalledTimes(1)
|
|
75
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('hides the clear button when clearable=false even with non-empty value', () => {
|
|
79
|
+
Harness('jan', { clearable: false })
|
|
80
|
+
expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('hides the clear button when disabled', () => {
|
|
84
|
+
Harness('jan', { disabled: true })
|
|
85
|
+
expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('forwards typing to onChange', () => {
|
|
89
|
+
const onChange = jest.fn()
|
|
90
|
+
Harness('', { onChange })
|
|
91
|
+
const input = screen.getByRole('searchbox') as HTMLInputElement
|
|
92
|
+
fireEvent.change(input, { target: { value: 'kowalski' } })
|
|
93
|
+
expect(onChange).toHaveBeenCalledWith('kowalski')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectItemLeading,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
} from './select'
|
|
15
|
+
|
|
16
|
+
export type AmountCurrency = {
|
|
17
|
+
/** ISO 4217 code (e.g. `'EUR'`, `'USD'`, `'PLN'`). */
|
|
18
|
+
code: string
|
|
19
|
+
/** Symbol shown as the leading prefix inside the amount input (e.g. `'€'`). */
|
|
20
|
+
symbol: string
|
|
21
|
+
/** Human-readable label for the picker dropdown. */
|
|
22
|
+
label: string
|
|
23
|
+
/** Optional emoji flag (or any leading visual) for the picker row. */
|
|
24
|
+
flag?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Static currency list — extend as needed. Order is alphabetical by code so the picker reads
|
|
29
|
+
* predictably; the first entry is the default fallback when `value.currency` is empty or
|
|
30
|
+
* unrecognised.
|
|
31
|
+
*/
|
|
32
|
+
export const AMOUNT_CURRENCIES: AmountCurrency[] = [
|
|
33
|
+
{ code: 'EUR', symbol: '€', label: 'Euro', flag: '🇪🇺' },
|
|
34
|
+
{ code: 'USD', symbol: '$', label: 'US Dollar', flag: '🇺🇸' },
|
|
35
|
+
{ code: 'GBP', symbol: '£', label: 'Pound Sterling', flag: '🇬🇧' },
|
|
36
|
+
{ code: 'PLN', symbol: 'zł', label: 'Polish Złoty', flag: '🇵🇱' },
|
|
37
|
+
{ code: 'CHF', symbol: 'CHF', label: 'Swiss Franc', flag: '🇨🇭' },
|
|
38
|
+
{ code: 'SEK', symbol: 'kr', label: 'Swedish Krona', flag: '🇸🇪' },
|
|
39
|
+
{ code: 'CZK', symbol: 'Kč', label: 'Czech Koruna', flag: '🇨🇿' },
|
|
40
|
+
{ code: 'JPY', symbol: '¥', label: 'Japanese Yen', flag: '🇯🇵' },
|
|
41
|
+
{ code: 'AUD', symbol: 'A$', label: 'Australian Dollar', flag: '🇦🇺' },
|
|
42
|
+
{ code: 'CAD', symbol: 'C$', label: 'Canadian Dollar', flag: '🇨🇦' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const DEFAULT_CURRENCY = AMOUNT_CURRENCIES[0]
|
|
46
|
+
|
|
47
|
+
export type AmountValue = {
|
|
48
|
+
/** Raw numeric amount as a string (preserves leading zeros, partial decimals like `'12.'`). */
|
|
49
|
+
amount: string
|
|
50
|
+
/** ISO 4217 currency code from the picker. */
|
|
51
|
+
currency: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type AmountInputProps = Omit<
|
|
55
|
+
React.ComponentPropsWithoutRef<'input'>,
|
|
56
|
+
'size' | 'type' | 'value' | 'onChange'
|
|
57
|
+
> &
|
|
58
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
59
|
+
value: AmountValue
|
|
60
|
+
onChange: (next: AmountValue) => void
|
|
61
|
+
/** Override the static currency list (e.g. limit to specific markets). */
|
|
62
|
+
currencies?: AmountCurrency[]
|
|
63
|
+
/** Hide the currency picker entirely — single-currency surfaces (e.g. settings page). */
|
|
64
|
+
showCurrency?: boolean
|
|
65
|
+
/** Optional className on the wrapper. */
|
|
66
|
+
className?: string
|
|
67
|
+
/** Optional className on the inner `<input>`. */
|
|
68
|
+
inputClassName?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findCurrency(code: string, list: AmountCurrency[]): AmountCurrency {
|
|
72
|
+
return list.find((c) => c.code === code) ?? list[0] ?? DEFAULT_CURRENCY
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Amount input matching Figma `Text Input [1.1]` (node `266:5251`) **Amount** variant — leading
|
|
77
|
+
* currency symbol inside the input, then a vertical divider, then a `Select`-driven currency
|
|
78
|
+
* picker (flag + code + chevron). Numeric `inputMode="decimal"`.
|
|
79
|
+
*
|
|
80
|
+
* Value shape is `{ amount: string, currency: string }` — the amount is stored as a string to
|
|
81
|
+
* preserve raw user input (leading zeros, in-progress decimals). Parse to `Number` at the
|
|
82
|
+
* API/persistence boundary.
|
|
83
|
+
*/
|
|
84
|
+
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
|
85
|
+
(
|
|
86
|
+
{
|
|
87
|
+
className,
|
|
88
|
+
inputClassName,
|
|
89
|
+
size,
|
|
90
|
+
value,
|
|
91
|
+
onChange,
|
|
92
|
+
currencies: currenciesProp,
|
|
93
|
+
showCurrency = true,
|
|
94
|
+
placeholder,
|
|
95
|
+
disabled,
|
|
96
|
+
...props
|
|
97
|
+
},
|
|
98
|
+
ref,
|
|
99
|
+
) => {
|
|
100
|
+
const t = useT()
|
|
101
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.amountInput.placeholder', '0.00')
|
|
102
|
+
const currencies = currenciesProp ?? AMOUNT_CURRENCIES
|
|
103
|
+
const current = findCurrency(value.currency, currencies)
|
|
104
|
+
|
|
105
|
+
const handleAmountChange = React.useCallback(
|
|
106
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
107
|
+
onChange({ amount: event.target.value, currency: current.code })
|
|
108
|
+
},
|
|
109
|
+
[current.code, onChange],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const handleCurrencyChange = React.useCallback(
|
|
113
|
+
(nextCode: string) => {
|
|
114
|
+
onChange({ amount: value.amount, currency: nextCode })
|
|
115
|
+
},
|
|
116
|
+
[onChange, value.amount],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
className={cn(inputWrapperVariants({ size }), 'px-0 overflow-hidden', className)}
|
|
122
|
+
data-slot="amount-input-wrapper"
|
|
123
|
+
>
|
|
124
|
+
<div className="flex flex-1 min-w-0 items-center gap-2 pl-3">
|
|
125
|
+
<span
|
|
126
|
+
className="text-sm text-muted-foreground select-none shrink-0 tabular-nums"
|
|
127
|
+
aria-hidden="true"
|
|
128
|
+
>
|
|
129
|
+
{current.symbol}
|
|
130
|
+
</span>
|
|
131
|
+
<input
|
|
132
|
+
ref={ref}
|
|
133
|
+
type="text"
|
|
134
|
+
inputMode="decimal"
|
|
135
|
+
value={value.amount}
|
|
136
|
+
onChange={handleAmountChange}
|
|
137
|
+
placeholder={resolvedPlaceholder}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
className={cn(
|
|
140
|
+
inputElementVariants({ size }),
|
|
141
|
+
'pr-3 tabular-nums',
|
|
142
|
+
inputClassName,
|
|
143
|
+
)}
|
|
144
|
+
{...props}
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
{showCurrency ? (
|
|
148
|
+
<>
|
|
149
|
+
<div aria-hidden="true" className="w-px self-stretch bg-input" />
|
|
150
|
+
<Select value={current.code} onValueChange={handleCurrencyChange} disabled={disabled}>
|
|
151
|
+
<SelectTrigger
|
|
152
|
+
aria-label={t('ui.inputs.amountInput.currencyLabel', 'Currency')}
|
|
153
|
+
className={cn(
|
|
154
|
+
'h-auto w-auto shrink-0 gap-1.5 rounded-none border-0 bg-transparent px-2.5 py-2 shadow-none',
|
|
155
|
+
'hover:bg-muted/40 focus:bg-muted/40 focus-visible:shadow-none focus-visible:border-0',
|
|
156
|
+
'disabled:bg-transparent disabled:hover:bg-transparent',
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
{current.flag ? (
|
|
160
|
+
<span className="text-base leading-none" aria-hidden="true">{current.flag}</span>
|
|
161
|
+
) : null}
|
|
162
|
+
<span className="text-sm text-foreground tabular-nums">{current.code}</span>
|
|
163
|
+
</SelectTrigger>
|
|
164
|
+
<SelectContent align="end">
|
|
165
|
+
{currencies.map((c) => (
|
|
166
|
+
<SelectItem key={c.code} value={c.code}>
|
|
167
|
+
{c.flag ? (
|
|
168
|
+
<SelectItemLeading>
|
|
169
|
+
<span className="text-base leading-none">{c.flag}</span>
|
|
170
|
+
</SelectItemLeading>
|
|
171
|
+
) : null}
|
|
172
|
+
<span className="flex-1 truncate">{c.label}</span>
|
|
173
|
+
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{c.code}</span>
|
|
174
|
+
</SelectItem>
|
|
175
|
+
))}
|
|
176
|
+
</SelectContent>
|
|
177
|
+
</Select>
|
|
178
|
+
</>
|
|
179
|
+
) : null}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
AmountInput.displayName = 'AmountInput'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
5
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
7
|
+
|
|
8
|
+
export type ButtonInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> &
|
|
9
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
10
|
+
/** Optional leading icon (decorative `aria-hidden` wrapper). */
|
|
11
|
+
leftIcon?: React.ReactNode
|
|
12
|
+
/**
|
|
13
|
+
* Required interactive trailing element rendered inside the wrapper, after the vertical
|
|
14
|
+
* divider. Typically an `<IconButton>` (copy URL, send, refresh). The slot stays focusable
|
|
15
|
+
* and screen-reader accessible — do NOT pass a decorative icon here.
|
|
16
|
+
*/
|
|
17
|
+
trailingAction: React.ReactNode
|
|
18
|
+
/** Optional className on the wrapper. */
|
|
19
|
+
className?: string
|
|
20
|
+
/** Optional className on the inner `<input>`. */
|
|
21
|
+
inputClassName?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Button input matching Figma `Text Input [1.1]` (node `266:5251`) **Button** variant — a text
|
|
26
|
+
* input with an interactive trailing button slot, separated by a vertical divider. Built on the
|
|
27
|
+
* shared `inputWrapperVariants` / `inputElementVariants` CVA so the visual contract matches the
|
|
28
|
+
* foundation `Input` primitive.
|
|
29
|
+
*
|
|
30
|
+
* Common pairings: share-link + copy button, subscribe-email + send button, API key + regenerate
|
|
31
|
+
* button. The trailing slot is rendered AS-IS (no auto-wrapping), so consumers control button
|
|
32
|
+
* type, size, variant, and `aria-label` directly via the passed element.
|
|
33
|
+
*/
|
|
34
|
+
export const ButtonInput = React.forwardRef<HTMLInputElement, ButtonInputProps>(
|
|
35
|
+
({ className, inputClassName, size, leftIcon, trailingAction, disabled, ...props }, ref) => {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={cn(inputWrapperVariants({ size }), 'px-0 overflow-hidden', className)}
|
|
39
|
+
data-slot="button-input-wrapper"
|
|
40
|
+
>
|
|
41
|
+
<div className="flex flex-1 min-w-0 items-center gap-2 pl-3 pr-2">
|
|
42
|
+
{leftIcon ? (
|
|
43
|
+
<span
|
|
44
|
+
className="flex shrink-0 items-center text-muted-foreground [&_svg]:size-4"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
>
|
|
47
|
+
{leftIcon}
|
|
48
|
+
</span>
|
|
49
|
+
) : null}
|
|
50
|
+
<input
|
|
51
|
+
ref={ref}
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
className={cn(
|
|
54
|
+
inputElementVariants({ size }),
|
|
55
|
+
inputClassName,
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
<div aria-hidden="true" className="w-px self-stretch bg-input" />
|
|
61
|
+
<div className="flex shrink-0 items-stretch">
|
|
62
|
+
{trailingAction}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
ButtonInput.displayName = 'ButtonInput'
|