@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56

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 (131) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +23 -14
  3. package/dist/backend/AppShell.js +8 -32
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +18 -0
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +149 -53
  8. package/dist/backend/DataTable.js.map +2 -2
  9. package/dist/backend/FilterBar.js +11 -14
  10. package/dist/backend/FilterBar.js.map +2 -2
  11. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  12. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  13. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  14. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  15. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  16. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  17. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  18. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  19. package/dist/backend/filters/FilterEmptyState.js +41 -0
  20. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  21. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  22. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  23. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  24. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  25. package/dist/backend/filters/QuickFilters.js +41 -0
  26. package/dist/backend/filters/QuickFilters.js.map +7 -0
  27. package/dist/backend/filters/filterValidation.js +47 -0
  28. package/dist/backend/filters/filterValidation.js.map +7 -0
  29. package/dist/backend/filters/treeReducer.js +159 -0
  30. package/dist/backend/filters/treeReducer.js.map +7 -0
  31. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  32. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +11 -7
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/EventPatternInput.js +5 -2
  36. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  37. package/dist/backend/inputs/EventSelect.js +10 -4
  38. package/dist/backend/inputs/EventSelect.js.map +2 -2
  39. package/dist/backend/inputs/LookupSelect.js +51 -47
  40. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  41. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  42. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  43. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  44. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  45. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  46. package/dist/backend/utils/api.js +18 -1
  47. package/dist/backend/utils/api.js.map +2 -2
  48. package/dist/backend/utils/serverErrors.js +6 -2
  49. package/dist/backend/utils/serverErrors.js.map +2 -2
  50. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  51. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  52. package/dist/backend/utils/useCurrentUserId.js +31 -0
  53. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  54. package/dist/portal/utils/nav.js +17 -0
  55. package/dist/portal/utils/nav.js.map +2 -2
  56. package/dist/primitives/amount-input.js +128 -0
  57. package/dist/primitives/amount-input.js.map +7 -0
  58. package/dist/primitives/button-input.js +47 -0
  59. package/dist/primitives/button-input.js.map +7 -0
  60. package/dist/primitives/card-input.js +133 -0
  61. package/dist/primitives/card-input.js.map +7 -0
  62. package/dist/primitives/dialog.js +7 -5
  63. package/dist/primitives/dialog.js.map +2 -2
  64. package/dist/primitives/email-input.js +29 -0
  65. package/dist/primitives/email-input.js.map +7 -0
  66. package/dist/primitives/password-input.js +64 -0
  67. package/dist/primitives/password-input.js.map +7 -0
  68. package/dist/primitives/search-input.js +57 -0
  69. package/dist/primitives/search-input.js.map +7 -0
  70. package/dist/primitives/select.js +43 -5
  71. package/dist/primitives/select.js.map +2 -2
  72. package/dist/primitives/tag.js +4 -2
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/website-input.js +56 -0
  75. package/dist/primitives/website-input.js.map +7 -0
  76. package/package.json +3 -3
  77. package/src/backend/AppShell.tsx +7 -30
  78. package/src/backend/CrudForm.tsx +17 -0
  79. package/src/backend/DataTable.tsx +307 -63
  80. package/src/backend/FilterBar.tsx +5 -6
  81. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  82. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  83. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  84. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  85. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  86. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  87. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  88. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  89. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  90. package/src/backend/filters/QuickFilters.tsx +62 -0
  91. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  92. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  93. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  94. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  95. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  96. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  97. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  98. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  99. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  100. package/src/backend/filters/filterValidation.ts +61 -0
  101. package/src/backend/filters/treeReducer.ts +201 -0
  102. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  103. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  104. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  105. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  106. package/src/backend/inputs/EventSelect.tsx +13 -4
  107. package/src/backend/inputs/LookupSelect.tsx +75 -53
  108. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  109. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  110. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  111. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  112. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  113. package/src/backend/utils/api.ts +27 -2
  114. package/src/backend/utils/serverErrors.ts +15 -8
  115. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  116. package/src/backend/utils/useCurrentUserId.ts +40 -0
  117. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  118. package/src/portal/utils/nav.ts +23 -1
  119. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  120. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  121. package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
  122. package/src/primitives/amount-input.tsx +184 -0
  123. package/src/primitives/button-input.tsx +68 -0
  124. package/src/primitives/card-input.tsx +193 -0
  125. package/src/primitives/dialog.tsx +18 -7
  126. package/src/primitives/email-input.tsx +36 -0
  127. package/src/primitives/password-input.tsx +100 -0
  128. package/src/primitives/search-input.tsx +87 -0
  129. package/src/primitives/select.tsx +98 -11
  130. package/src/primitives/tag.tsx +3 -1
  131. 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
- <Input
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')}
@@ -23,6 +23,23 @@ function readRedirectOverride(headers: Headers, headerName: string): boolean {
23
23
  return headers.get(headerName) === '0'
24
24
  }
25
25
 
26
+ function isSameOriginRequest(input: RequestInfo | URL): boolean {
27
+ if (typeof window === 'undefined') return false
28
+ const host = window.location?.host
29
+ if (!host) return false
30
+ let urlString: string
31
+ if (typeof input === 'string') urlString = input
32
+ else if (input instanceof URL) urlString = input.toString()
33
+ else if (typeof Request !== 'undefined' && input instanceof Request) urlString = input.url
34
+ else return false
35
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(urlString)) return true
36
+ try {
37
+ return new URL(urlString).host === host
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
26
43
  export async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {
27
44
  return scopedHeaders.withScopedHeaders(headers, run)
28
45
  }
@@ -109,9 +126,17 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
109
126
  )
110
127
  }
111
128
  const scoped = scopedHeaders.resolveScopedHeaders()
112
- const mergedInit = Object.keys(scoped).length
129
+ const baseInit: RequestInit = Object.keys(scoped).length
113
130
  ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }
114
- : init
131
+ : init ?? {}
132
+ // Only auto-inject credentials: 'include' for same-origin requests so cookies
133
+ // round-trip across Next.js proxy.ts rewrites (custom-domain portal flows)
134
+ // without leaking session cookies to third-party hosts.
135
+ const mergedInit: RequestInit = baseInit.credentials
136
+ ? baseInit
137
+ : isSameOriginRequest(input)
138
+ ? { ...baseInit, credentials: 'include' }
139
+ : baseInit
115
140
  const requestHeaders = new Headers(mergedInit?.headers)
116
141
  const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')
117
142
  const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')
@@ -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 (current as any).error === 'string'
129
- ? (current as any).error
135
+ typeof errorField === 'string'
136
+ ? errorField
130
137
  : typeof (current as any).message === 'string'
131
138
  ? (current as any).message
132
- : undefined
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
- typeof data.error === 'string' && data.error.trim()
271
- ? data.error.trim()
272
- : typeof data.message === 'string' && data.message.trim()
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,
@@ -17,6 +17,7 @@ type ColumnMeta = {
17
17
  filterLoadOptions?: (query?: string) => Promise<FilterOption[]>
18
18
  filterable?: boolean
19
19
  filterGroup?: string
20
+ filterIconName?: string
20
21
  columnChooserGroup?: string
21
22
  alwaysVisible?: boolean
22
23
  hidden?: boolean
@@ -85,6 +86,7 @@ export function useAutoDiscoveredFields({
85
86
  type,
86
87
  group: meta?.filterGroup ?? meta?.columnChooserGroup,
87
88
  }
89
+ if (meta?.filterIconName) field.iconName = meta.filterIconName
88
90
  if (meta?.filterOptions) field.options = meta.filterOptions
89
91
  if (meta?.filterLoadOptions) field.loadOptions = meta.filterLoadOptions
90
92
  filterFields.push(field)
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { apiCall } from './apiCall'
4
+
5
+ type FeatureCheckResponse = {
6
+ ok: boolean
7
+ granted: string[]
8
+ userId: string
9
+ }
10
+
11
+ /**
12
+ * Read the current user's id on the client. Returns `''` until the request resolves.
13
+ *
14
+ * Implementation note: there is no first-class `/api/auth/me` endpoint yet, so this
15
+ * piggy-backs on `/api/auth/feature-check` with an empty `features` array — the response
16
+ * always carries the JWT `userId`. Replace with a dedicated `/api/auth/me` or an
17
+ * SSR-bootstrapped user context once available, and remove this hop.
18
+ */
19
+ export function useCurrentUserId(): string {
20
+ const [userId, setUserId] = React.useState<string>('')
21
+ React.useEffect(() => {
22
+ let cancelled = false
23
+ void (async () => {
24
+ try {
25
+ const res = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json' },
28
+ body: JSON.stringify({ features: [] }),
29
+ })
30
+ if (!cancelled && res.ok && typeof res.result?.userId === 'string') {
31
+ setUserId(res.result.userId)
32
+ }
33
+ } catch {
34
+ if (!cancelled) setUserId('')
35
+ }
36
+ })()
37
+ return () => { cancelled = true }
38
+ }, [])
39
+ return userId
40
+ }
@@ -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({
@@ -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 as string, orgSlug)
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
+ })
@@ -1,3 +1,7 @@
1
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
2
+ useT: () => (_key: string, fallback: string) => fallback,
3
+ }))
4
+
1
5
  import * as React from 'react'
2
6
  import * as fs from 'fs'
3
7
  import * as path from 'path'
@@ -14,6 +18,7 @@ const Z_INDEX_BY_TOKEN: Record<string, number> = {
14
18
  'z-modal': 40,
15
19
  'z-popover': 45,
16
20
  'z-toast': 50,
21
+ 'z-modal-elevated': 55,
17
22
  'z-tooltip': 60,
18
23
  'z-banner': 70,
19
24
  'z-top': 100,
@@ -129,4 +134,25 @@ describe('Issue #1836: portaled overlay primitives sit above modals (z-popover >
129
134
  expect(css).toContain('z-index: var(--z-index-popover);')
130
135
  }
131
136
  })
137
+
138
+ it('globals.css defines z-modal-elevated (55) above z-popover so dialogs opened from inside popovers are visible', () => {
139
+ const repoRoot = path.resolve(__dirname, '../../../../..')
140
+ const cssPaths = [
141
+ path.join(repoRoot, 'apps/mercato/src/app/globals.css'),
142
+ path.join(repoRoot, 'packages/create-app/template/src/app/globals.css'),
143
+ ]
144
+
145
+ for (const cssPath of cssPaths) {
146
+ const css = fs.readFileSync(cssPath, 'utf8')
147
+
148
+ expect(css).toContain('@utility z-modal-elevated')
149
+ expect(css).toMatch(/z-index:\s*var\(--z-index-modal-elevated(?:,\s*\d+)?\);/)
150
+
151
+ const elevatedMatch = css.match(/--z-index-modal-elevated:\s*(\d+);/)
152
+ const popoverMatch = css.match(/--z-index-popover:\s*(\d+);/)
153
+ expect(elevatedMatch).not.toBeNull()
154
+ expect(popoverMatch).not.toBeNull()
155
+ expect(Number(elevatedMatch![1])).toBeGreaterThan(Number(popoverMatch![1]))
156
+ }
157
+ })
132
158
  })