@open-mercato/ui 0.6.7-develop.6785.1.1dd7cfac55 → 0.6.7-develop.6795.1.8a3f27921c

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 (28) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/build.mjs +2 -54
  3. package/dist/backend/NextStepCallout.js +4 -4
  4. package/dist/backend/NextStepCallout.js.map +1 -1
  5. package/dist/backend/confirm-dialog/ConfirmDialog.js +12 -0
  6. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  7. package/dist/backend/detail/AddressTiles.js +4 -5
  8. package/dist/backend/detail/AddressTiles.js.map +2 -2
  9. package/dist/backend/icons/lucideRegistry.generated.js +1 -25
  10. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  11. package/dist/backend/icons/lucideRegistry.js +3 -3
  12. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  13. package/dist/backend/icons/lucideRegistryRuntime.js +28 -0
  14. package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
  15. package/dist/backend/schedule/ScheduleToolbar.js +87 -70
  16. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  17. package/jest.config.cjs +2 -1
  18. package/package.json +17 -16
  19. package/scripts/lucideRegistrySource.cjs +133 -0
  20. package/src/backend/NextStepCallout.tsx +4 -4
  21. package/src/backend/confirm-dialog/ConfirmDialog.tsx +15 -0
  22. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +33 -1
  23. package/src/backend/detail/AddressTiles.tsx +3 -6
  24. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
  25. package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
  26. package/src/backend/icons/lucideRegistry.ts +2 -2
  27. package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
  28. package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
@@ -0,0 +1,177 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { Project, ScriptKind, SyntaxKind, type ObjectLiteralExpression, type SourceFile } from 'ts-morph'
5
+ import { buildLucideRegistrySource } from '../../../../scripts/lucideRegistrySource.cjs'
6
+
7
+ const packageDir = join(__dirname, '..', '..', '..', '..')
8
+ const iconsDir = join(packageDir, 'src', 'backend', 'icons')
9
+
10
+ type ResolvedIcon = { kebab: string; exportName: string }
11
+
12
+ const populatedFixture: ResolvedIcon[] = [
13
+ { kebab: 'bell', exportName: 'Bell' },
14
+ { kebab: 'alert-circle', exportName: 'AlertCircle' },
15
+ { kebab: 'bar-chart-2', exportName: 'BarChart2' },
16
+ ]
17
+
18
+ const duplicateExportFixture: ResolvedIcon[] = [
19
+ { kebab: 'trash', exportName: 'Trash' },
20
+ { kebab: 'trash-can', exportName: 'Trash' },
21
+ ]
22
+
23
+ function parse(source: string): SourceFile {
24
+ const project = new Project({ useInMemoryFileSystem: true })
25
+ return project.createSourceFile('lucideRegistry.generated.tsx', source, {
26
+ overwrite: true,
27
+ scriptKind: ScriptKind.TSX,
28
+ })
29
+ }
30
+
31
+ function syntacticDiagnostics(source: string): string[] {
32
+ const sourceFile = parse(source)
33
+ return sourceFile
34
+ .getProject()
35
+ .getProgram()
36
+ .getSyntacticDiagnostics(sourceFile)
37
+ .map((diagnostic) => `${diagnostic.getLineNumber() ?? 0}: ${diagnostic.getMessageText()}`)
38
+ }
39
+
40
+ function registryObject(source: string): ObjectLiteralExpression {
41
+ const declaration = parse(source).getVariableDeclarationOrThrow('LUCIDE_ICON_REGISTRY')
42
+ return declaration.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression)
43
+ }
44
+
45
+ function registryEntries(source: string): Record<string, string> {
46
+ const entries: Record<string, string> = {}
47
+ for (const property of registryObject(source).getProperties()) {
48
+ const assignment = property.asKindOrThrow(SyntaxKind.PropertyAssignment)
49
+ entries[assignment.getSymbolOrThrow().getName()] = assignment.getInitializerOrThrow().getText()
50
+ }
51
+ return entries
52
+ }
53
+
54
+ function valueImportNames(source: string, moduleSpecifier: string): string[] {
55
+ return parse(source)
56
+ .getImportDeclarations()
57
+ .filter((declaration) => declaration.getModuleSpecifierValue() === moduleSpecifier)
58
+ .filter((declaration) => !declaration.isTypeOnly())
59
+ .flatMap((declaration) => declaration.getNamedImports().map((named) => named.getName()))
60
+ }
61
+
62
+ describe('buildLucideRegistrySource', () => {
63
+ it('maps every discovered kebab name to its lucide export', () => {
64
+ expect(registryEntries(buildLucideRegistrySource(populatedFixture))).toEqual({
65
+ 'alert-circle': 'AlertCircle',
66
+ 'bar-chart-2': 'BarChart2',
67
+ bell: 'Bell',
68
+ })
69
+ })
70
+
71
+ it('emits the registry keys sorted by kebab name', () => {
72
+ const keys = registryObject(buildLucideRegistrySource(populatedFixture))
73
+ .getProperties()
74
+ .map((property) => property.asKindOrThrow(SyntaxKind.PropertyAssignment).getSymbolOrThrow().getName())
75
+ expect(keys).toEqual(['alert-circle', 'bar-chart-2', 'bell'])
76
+ })
77
+
78
+ it('imports each lucide export exactly once', () => {
79
+ expect(valueImportNames(buildLucideRegistrySource(populatedFixture), 'lucide-react')).toEqual([
80
+ 'AlertCircle',
81
+ 'BarChart2',
82
+ 'Bell',
83
+ ])
84
+ })
85
+
86
+ it('quotes keys that are not valid identifiers', () => {
87
+ const property = registryObject(buildLucideRegistrySource(populatedFixture))
88
+ .getPropertyOrThrow("'bar-chart-2'")
89
+ .asKindOrThrow(SyntaxKind.PropertyAssignment)
90
+ expect(property.getNameNode().getKind()).toBe(SyntaxKind.StringLiteral)
91
+ })
92
+
93
+ it('de-duplicates the value import when two names share one export', () => {
94
+ const source = buildLucideRegistrySource(duplicateExportFixture)
95
+ expect(valueImportNames(source, 'lucide-react')).toEqual(['Trash'])
96
+ expect(registryEntries(source)).toEqual({ trash: 'Trash', 'trash-can': 'Trash' })
97
+ })
98
+
99
+ it('emits a valid module with an empty registry when no icons are discovered', () => {
100
+ const source = buildLucideRegistrySource([])
101
+ expect(registryObject(source).getProperties()).toHaveLength(0)
102
+ expect(valueImportNames(source, 'lucide-react')).toEqual([])
103
+ expect(source).not.toContain("import {} from 'lucide-react'")
104
+ })
105
+
106
+ it('keeps the registry a plain mutable object literal', () => {
107
+ const source = buildLucideRegistrySource(populatedFixture)
108
+ const declaration = parse(source).getVariableDeclarationOrThrow('LUCIDE_ICON_REGISTRY')
109
+ expect(declaration.getTypeNodeOrThrow().getText()).toBe('Record<string, LucideIcon>')
110
+ expect(declaration.getVariableStatementOrThrow().isExported()).toBe(true)
111
+ expect(source).not.toContain('as const')
112
+ expect(source).not.toContain('Object.freeze')
113
+ })
114
+
115
+ it('keeps the LucideIcon import type-only', () => {
116
+ const typeOnly = parse(buildLucideRegistrySource(populatedFixture))
117
+ .getImportDeclarations()
118
+ .filter((declaration) => declaration.isTypeOnly())
119
+ .flatMap((declaration) => declaration.getNamedImports().map((named) => named.getName()))
120
+ expect(typeOnly).toEqual(['LucideIcon'])
121
+ })
122
+
123
+ it.each([
124
+ ['populated', populatedFixture],
125
+ ['empty', [] as ResolvedIcon[]],
126
+ ['duplicate exports', duplicateExportFixture],
127
+ ])('emits %s output with zero syntactic diagnostics', (_label, fixture) => {
128
+ expect(syntacticDiagnostics(buildLucideRegistrySource(fixture))).toEqual([])
129
+ })
130
+
131
+ it('fails loudly when an export name would produce an unparseable module', () => {
132
+ expect(() => buildLucideRegistrySource([{ kebab: 'broken', exportName: 'not a valid export' }])).toThrow(
133
+ /not syntactically valid/
134
+ )
135
+ })
136
+ })
137
+
138
+ describe('lucideRegistry barrel', () => {
139
+ it('keeps its public export list unchanged', () => {
140
+ const project = new Project({ useInMemoryFileSystem: true })
141
+ const barrel = project.createSourceFile(
142
+ 'lucideRegistry.ts',
143
+ readFileSync(join(iconsDir, 'lucideRegistry.ts'), 'utf-8'),
144
+ { overwrite: true }
145
+ )
146
+ expect([...barrel.getExportedDeclarations().keys()].sort()).toEqual([
147
+ 'LUCIDE_ICON_REGISTRY',
148
+ 'registerAdditionalIcons',
149
+ 'resolveRegisteredLucideIcon',
150
+ 'resolveRegisteredLucideIconNode',
151
+ ])
152
+ })
153
+ })
154
+
155
+ describe('lucideRegistry.generated importers', () => {
156
+ it('is imported only from within src/backend/icons', () => {
157
+ const repoRoot = join(packageDir, '..', '..')
158
+ let matches: string[] = []
159
+ try {
160
+ matches = execFileSync(
161
+ 'git',
162
+ ['grep', '-l', 'lucideRegistry.generated', '--', '*.ts', '*.tsx'],
163
+ { cwd: repoRoot, encoding: 'utf-8' }
164
+ )
165
+ .split('\n')
166
+ .filter(Boolean)
167
+ } catch (error) {
168
+ const status = (error as { status?: number }).status
169
+ if (status !== 1) throw error
170
+ }
171
+
172
+ const outsideIconsFolder = matches.filter(
173
+ (path) => !path.startsWith('packages/ui/src/backend/icons/')
174
+ )
175
+ expect(outsideIconsFolder).toEqual([])
176
+ })
177
+ })
@@ -2,8 +2,7 @@
2
2
  // AUTO-GENERATED by @open-mercato/ui build. Do not edit by hand.
3
3
  // Generated from discovered icon string usages across the repo.
4
4
 
5
- import type * as React from 'react'
6
- import type { LucideIcon } from 'lucide-react'
5
+ import type { LucideIcon } from 'lucide-react';
7
6
  import {
8
7
  Activity,
9
8
  AlertCircle,
@@ -150,7 +149,7 @@ import {
150
149
  Wrench,
151
150
  X,
152
151
  XCircle,
153
- } from 'lucide-react'
152
+ } from 'lucide-react';
154
153
 
155
154
  export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
156
155
  'activity': Activity,
@@ -298,37 +297,4 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
298
297
  'wrench': Wrench,
299
298
  'x': X,
300
299
  'x-circle': XCircle,
301
- }
302
-
303
- function normalizeKebabIconName(input: string): string {
304
- const trimmed = input.trim()
305
- if (!trimmed) return ''
306
- const withoutPrefix = trimmed.startsWith('lucide:') ? trimmed.slice('lucide:'.length) : trimmed
307
- if (!withoutPrefix) return ''
308
- if (!withoutPrefix.includes('-') && !withoutPrefix.includes('_') && !withoutPrefix.includes(' ') && /[A-Z]/.test(withoutPrefix)) {
309
- return withoutPrefix
310
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
311
- .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
312
- .toLowerCase()
313
- }
314
- return withoutPrefix
315
- .replace(/[_\s]+/g, '-')
316
- .replace(/-+/g, '-')
317
- .toLowerCase()
318
- }
319
-
320
- export function resolveRegisteredLucideIcon(name: string | undefined): LucideIcon | null {
321
- if (!name) return null
322
- const normalized = normalizeKebabIconName(name)
323
- if (!normalized) return null
324
- return (LUCIDE_ICON_REGISTRY as Record<string, LucideIcon | undefined>)[normalized] ?? null
325
- }
326
-
327
- export function resolveRegisteredLucideIconNode(
328
- name: string | undefined,
329
- className: string
330
- ): React.ReactNode | null {
331
- const Icon = resolveRegisteredLucideIcon(name)
332
- if (!Icon) return null
333
- return <Icon className={className} />
334
- }
300
+ };
@@ -1,8 +1,8 @@
1
1
  export {
2
2
  resolveRegisteredLucideIcon,
3
3
  resolveRegisteredLucideIconNode,
4
- LUCIDE_ICON_REGISTRY,
5
- } from './lucideRegistry.generated'
4
+ } from './lucideRegistryRuntime'
5
+ export { LUCIDE_ICON_REGISTRY } from './lucideRegistry.generated'
6
6
 
7
7
  import type { LucideIcon } from 'lucide-react'
8
8
  import { LUCIDE_ICON_REGISTRY } from './lucideRegistry.generated'
@@ -0,0 +1,36 @@
1
+ import type * as React from 'react'
2
+ import type { LucideIcon } from 'lucide-react'
3
+ import { LUCIDE_ICON_REGISTRY } from './lucideRegistry.generated'
4
+
5
+ function normalizeKebabIconName(input: string): string {
6
+ const trimmed = input.trim()
7
+ if (!trimmed) return ''
8
+ const withoutPrefix = trimmed.startsWith('lucide:') ? trimmed.slice('lucide:'.length) : trimmed
9
+ if (!withoutPrefix) return ''
10
+ if (!withoutPrefix.includes('-') && !withoutPrefix.includes('_') && !withoutPrefix.includes(' ') && /[A-Z]/.test(withoutPrefix)) {
11
+ return withoutPrefix
12
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
13
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
14
+ .toLowerCase()
15
+ }
16
+ return withoutPrefix
17
+ .replace(/[_\s]+/g, '-')
18
+ .replace(/-+/g, '-')
19
+ .toLowerCase()
20
+ }
21
+
22
+ export function resolveRegisteredLucideIcon(name: string | undefined): LucideIcon | null {
23
+ if (!name) return null
24
+ const normalized = normalizeKebabIconName(name)
25
+ if (!normalized) return null
26
+ return (LUCIDE_ICON_REGISTRY as Record<string, LucideIcon | undefined>)[normalized] ?? null
27
+ }
28
+
29
+ export function resolveRegisteredLucideIconNode(
30
+ name: string | undefined,
31
+ className: string
32
+ ): React.ReactNode | null {
33
+ const Icon = resolveRegisteredLucideIcon(name)
34
+ if (!Icon) return null
35
+ return <Icon className={className} />
36
+ }
@@ -1,8 +1,19 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { Button } from '../../primitives/button'
5
- import { Input } from '../../primitives/input'
4
+ import { DateRangePicker } from '../../primitives/date-range-picker'
5
+ import { IconButton } from '../../primitives/icon-button'
6
+ import {
7
+ SegmentedControl,
8
+ SegmentedControlItem,
9
+ } from '../../primitives/segmented-control'
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '../../primitives/select'
6
17
  import type { ScheduleRange, ScheduleViewMode } from './types'
7
18
  import { cn } from '@open-mercato/shared/lib/utils'
8
19
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -27,17 +38,19 @@ const VIEW_OPTIONS: Array<{ id: ScheduleViewMode; labelKey: string; fallback: st
27
38
  { id: 'agenda', labelKey: 'schedule.view.agenda', fallback: 'Agenda' },
28
39
  ]
29
40
 
30
- function formatDateInputValue(value: Date): string {
31
- const year = value.getFullYear()
32
- const month = String(value.getMonth() + 1).padStart(2, '0')
33
- const day = String(value.getDate()).padStart(2, '0')
34
- return `${year}-${month}-${day}`
35
- }
36
-
37
- function parseDateInputValue(value: string, fallback: Date): Date {
38
- if (!value) return fallback
39
- const next = new Date(`${value}T00:00:00`)
40
- return Number.isNaN(next.getTime()) ? fallback : next
41
+ function getTimezoneOptions(current?: string): string[] {
42
+ const options = new Set<string>()
43
+ if (current) options.add(current)
44
+ options.add('UTC')
45
+ const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone
46
+ if (resolved) options.add(resolved)
47
+ const intlWithSupportedValues = Intl as typeof Intl & { supportedValuesOf?: (input: 'timeZone') => string[] }
48
+ if (typeof intlWithSupportedValues.supportedValuesOf === 'function') {
49
+ intlWithSupportedValues.supportedValuesOf('timeZone').forEach((timezone) => {
50
+ if (timezone) options.add(timezone)
51
+ })
52
+ }
53
+ return Array.from(options).sort((a, b) => a.localeCompare(b))
41
54
  }
42
55
 
43
56
  export type ScheduleToolbarProps = {
@@ -120,68 +133,76 @@ export function ScheduleToolbar({
120
133
  onRangeChange({ start: nextStart, end: endOfDay(addDays(nextStart, rangeLength - 1)) })
121
134
  }, [onRangeChange, range.start, rangeLength, view])
122
135
 
136
+ const timezoneOptions = React.useMemo(() => getTimezoneOptions(timezone), [timezone])
137
+
123
138
  return (
124
- <div className={cn('flex flex-col gap-3 rounded-xl border bg-card p-4 md:flex-row md:items-center md:justify-between', className)}>
125
- <div className="flex flex-wrap items-center gap-2">
139
+ <div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border bg-card p-3', className)}>
140
+ <SegmentedControl
141
+ value={view}
142
+ onValueChange={(value) => {
143
+ const nextView = value as ScheduleViewMode
144
+ if (nextView === view) return
145
+ onViewChange(nextView)
146
+ onRangeChange(deriveRangeForView(new Date(), nextView))
147
+ }}
148
+ aria-label={t('schedule.view.label', 'Schedule view')}
149
+ className="shrink-0"
150
+ >
126
151
  {VIEW_OPTIONS.map((option) => (
127
- <Button
128
- key={option.id}
129
- variant={view === option.id ? 'default' : 'outline'}
130
- size="sm"
131
- onClick={() => {
132
- if (option.id === view) return
133
- onViewChange(option.id)
134
- onRangeChange(deriveRangeForView(new Date(), option.id))
135
- }}
136
- >
152
+ <SegmentedControlItem key={option.id} value={option.id}>
137
153
  {t(option.labelKey, option.fallback)}
138
- </Button>
154
+ </SegmentedControlItem>
139
155
  ))}
140
- </div>
141
- <div className="flex flex-wrap items-center gap-2">
142
- <Button type="button" variant="outline" size="sm" onClick={() => shiftRange('prev')} aria-label={t('schedule.range.prev', 'Previous')}>
156
+ </SegmentedControl>
157
+ <div className="flex shrink-0 items-center gap-1">
158
+ <IconButton
159
+ type="button"
160
+ variant="outline"
161
+ onClick={() => shiftRange('prev')}
162
+ aria-label={t('schedule.range.prev', 'Previous')}
163
+ >
143
164
  <ChevronLeft className="size-4" aria-hidden />
144
- </Button>
145
- <div className="text-sm font-medium text-foreground">{rangeLabel}</div>
146
- <Button type="button" variant="outline" size="sm" onClick={() => shiftRange('next')} aria-label={t('schedule.range.next', 'Next')}>
165
+ </IconButton>
166
+ <div className="min-w-0 whitespace-nowrap px-1 text-sm font-medium text-foreground">{rangeLabel}</div>
167
+ <IconButton
168
+ type="button"
169
+ variant="outline"
170
+ onClick={() => shiftRange('next')}
171
+ aria-label={t('schedule.range.next', 'Next')}
172
+ >
147
173
  <ChevronRight className="size-4" aria-hidden />
148
- </Button>
174
+ </IconButton>
149
175
  </div>
150
- <div className="flex flex-wrap items-center gap-3">
151
- <label className="flex items-center gap-2 text-xs text-muted-foreground">
152
- <span>{t('schedule.range.start', 'Start')}</span>
153
- <Input
154
- type="date"
155
- value={formatDateInputValue(range.start)}
156
- onChange={(event) => {
157
- const nextStart = parseDateInputValue(event.target.value, range.start)
158
- onRangeChange({ start: nextStart, end: range.end })
159
- }}
160
- className="h-8 w-full sm:w-[140px]"
161
- />
162
- </label>
163
- <label className="flex items-center gap-2 text-xs text-muted-foreground">
164
- <span>{t('schedule.range.end', 'End')}</span>
165
- <Input
166
- type="date"
167
- value={formatDateInputValue(range.end)}
168
- onChange={(event) => {
169
- const nextEnd = parseDateInputValue(event.target.value, range.end)
170
- onRangeChange({ start: range.start, end: nextEnd })
171
- }}
172
- className="h-8 w-full sm:w-[140px]"
173
- />
174
- </label>
175
- <label className="flex items-center gap-2 text-xs text-muted-foreground">
176
- <span>{t('schedule.range.timezone', 'Timezone')}</span>
177
- <Input
178
- type="text"
179
- value={timezone ?? ''}
180
- onChange={(event) => onTimezoneChange?.(event.target.value)}
181
- className="h-8 w-full sm:w-[180px]"
182
- placeholder={t('schedule.range.timezone.placeholder', 'UTC')}
183
- />
184
- </label>
176
+ <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 md:ml-auto">
177
+ <DateRangePicker
178
+ value={range}
179
+ onChange={(next) => {
180
+ if (!next) return
181
+ onRangeChange({ start: startOfDay(next.start), end: endOfDay(next.end) })
182
+ }}
183
+ size="sm"
184
+ showPresets={false}
185
+ numberOfMonths={2}
186
+ aria-label={t('schedule.range.label', 'Date range')}
187
+ />
188
+ {onTimezoneChange ? (
189
+ <Select value={timezone ?? undefined} onValueChange={onTimezoneChange}>
190
+ <SelectTrigger
191
+ size="sm"
192
+ className="w-auto min-w-[10rem]"
193
+ aria-label={t('schedule.range.timezone', 'Timezone')}
194
+ >
195
+ <SelectValue placeholder={t('schedule.range.timezone.placeholder', 'UTC')} />
196
+ </SelectTrigger>
197
+ <SelectContent>
198
+ {timezoneOptions.map((option) => (
199
+ <SelectItem key={option} value={option}>
200
+ {option}
201
+ </SelectItem>
202
+ ))}
203
+ </SelectContent>
204
+ </Select>
205
+ ) : null}
185
206
  </div>
186
207
  </div>
187
208
  )