@open-mercato/ui 0.4.2-canary-c02407ff85

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 (319) hide show
  1. package/build.mjs +62 -0
  2. package/dist/backend/AppShell.js +902 -0
  3. package/dist/backend/AppShell.js.map +7 -0
  4. package/dist/backend/ConfirmDialog.js +17 -0
  5. package/dist/backend/ConfirmDialog.js.map +7 -0
  6. package/dist/backend/ContextHelp.js +31 -0
  7. package/dist/backend/ContextHelp.js.map +7 -0
  8. package/dist/backend/CrudForm.js +2028 -0
  9. package/dist/backend/CrudForm.js.map +7 -0
  10. package/dist/backend/DataTable.js +1363 -0
  11. package/dist/backend/DataTable.js.map +7 -0
  12. package/dist/backend/EmptyState.js +52 -0
  13. package/dist/backend/EmptyState.js.map +7 -0
  14. package/dist/backend/FilterBar.js +140 -0
  15. package/dist/backend/FilterBar.js.map +7 -0
  16. package/dist/backend/FilterOverlay.js +279 -0
  17. package/dist/backend/FilterOverlay.js.map +7 -0
  18. package/dist/backend/FlashMessages.js +66 -0
  19. package/dist/backend/FlashMessages.js.map +7 -0
  20. package/dist/backend/JsonBuilder.js +322 -0
  21. package/dist/backend/JsonBuilder.js.map +7 -0
  22. package/dist/backend/JsonDisplay.js +203 -0
  23. package/dist/backend/JsonDisplay.js.map +7 -0
  24. package/dist/backend/Page.js +27 -0
  25. package/dist/backend/Page.js.map +7 -0
  26. package/dist/backend/PerspectiveSidebar.js +282 -0
  27. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  28. package/dist/backend/RowActions.js +148 -0
  29. package/dist/backend/RowActions.js.map +7 -0
  30. package/dist/backend/TruncatedCell.js +92 -0
  31. package/dist/backend/TruncatedCell.js.map +7 -0
  32. package/dist/backend/UserMenu.js +107 -0
  33. package/dist/backend/UserMenu.js.map +7 -0
  34. package/dist/backend/ValueIcons.js +34 -0
  35. package/dist/backend/ValueIcons.js.map +7 -0
  36. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
  37. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  38. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  39. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  40. package/dist/backend/dashboard/DashboardScreen.js +578 -0
  41. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  42. package/dist/backend/dashboard/index.js +5 -0
  43. package/dist/backend/dashboard/index.js.map +7 -0
  44. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  45. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  46. package/dist/backend/detail/ActivitiesSection.js +962 -0
  47. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  48. package/dist/backend/detail/AddressEditor.js +413 -0
  49. package/dist/backend/detail/AddressEditor.js.map +7 -0
  50. package/dist/backend/detail/AddressTiles.js +437 -0
  51. package/dist/backend/detail/AddressTiles.js.map +7 -0
  52. package/dist/backend/detail/AddressesSection.js +264 -0
  53. package/dist/backend/detail/AddressesSection.js.map +7 -0
  54. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  55. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  56. package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
  57. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  58. package/dist/backend/detail/AttachmentsSection.js +367 -0
  59. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  60. package/dist/backend/detail/CustomDataSection.js +433 -0
  61. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  62. package/dist/backend/detail/DetailFieldsSection.js +75 -0
  63. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  64. package/dist/backend/detail/ErrorMessage.js +28 -0
  65. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  66. package/dist/backend/detail/InlineEditors.js +681 -0
  67. package/dist/backend/detail/InlineEditors.js.map +7 -0
  68. package/dist/backend/detail/LoadingMessage.js +14 -0
  69. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  70. package/dist/backend/detail/NotesSection.js +1032 -0
  71. package/dist/backend/detail/NotesSection.js.map +7 -0
  72. package/dist/backend/detail/TabEmptyState.js +25 -0
  73. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  74. package/dist/backend/detail/TagsSection.js +254 -0
  75. package/dist/backend/detail/TagsSection.js.map +7 -0
  76. package/dist/backend/detail/addressFormat.js +77 -0
  77. package/dist/backend/detail/addressFormat.js.map +7 -0
  78. package/dist/backend/detail/index.js +34 -0
  79. package/dist/backend/detail/index.js.map +7 -0
  80. package/dist/backend/fields/registry.generated.js +8 -0
  81. package/dist/backend/fields/registry.generated.js.map +7 -0
  82. package/dist/backend/fields/registry.js +29 -0
  83. package/dist/backend/fields/registry.js.map +7 -0
  84. package/dist/backend/indexes/PartialIndexBanner.js +58 -0
  85. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  86. package/dist/backend/indexes/store.js +62 -0
  87. package/dist/backend/indexes/store.js.map +7 -0
  88. package/dist/backend/injection/InjectionSpot.js +179 -0
  89. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  90. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  91. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  92. package/dist/backend/injection/helpers.js +26 -0
  93. package/dist/backend/injection/helpers.js.map +7 -0
  94. package/dist/backend/injection/widgetRegistry.js +55 -0
  95. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  96. package/dist/backend/inputs/ComboboxInput.js +225 -0
  97. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  98. package/dist/backend/inputs/LookupSelect.js +191 -0
  99. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  100. package/dist/backend/inputs/PhoneNumberField.js +100 -0
  101. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  102. package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
  103. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  104. package/dist/backend/inputs/TagsInput.js +222 -0
  105. package/dist/backend/inputs/TagsInput.js.map +7 -0
  106. package/dist/backend/inputs/index.js +6 -0
  107. package/dist/backend/inputs/index.js.map +7 -0
  108. package/dist/backend/operations/LastOperationBanner.js +80 -0
  109. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  110. package/dist/backend/operations/store.js +183 -0
  111. package/dist/backend/operations/store.js.map +7 -0
  112. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  113. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  114. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  115. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  116. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  117. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  118. package/dist/backend/schedule/ScheduleView.js +165 -0
  119. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  120. package/dist/backend/schedule/index.js +6 -0
  121. package/dist/backend/schedule/index.js.map +7 -0
  122. package/dist/backend/schedule/recurrence.js +83 -0
  123. package/dist/backend/schedule/recurrence.js.map +7 -0
  124. package/dist/backend/schedule/types.js +1 -0
  125. package/dist/backend/schedule/types.js.map +7 -0
  126. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  127. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  128. package/dist/backend/utils/api.js +127 -0
  129. package/dist/backend/utils/api.js.map +7 -0
  130. package/dist/backend/utils/apiCall.js +48 -0
  131. package/dist/backend/utils/apiCall.js.map +7 -0
  132. package/dist/backend/utils/crud.js +126 -0
  133. package/dist/backend/utils/crud.js.map +7 -0
  134. package/dist/backend/utils/customFieldColumns.js +56 -0
  135. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  136. package/dist/backend/utils/customFieldDefs.js +143 -0
  137. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  138. package/dist/backend/utils/customFieldFilters.js +126 -0
  139. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  140. package/dist/backend/utils/customFieldForms.js +162 -0
  141. package/dist/backend/utils/customFieldForms.js.map +7 -0
  142. package/dist/backend/utils/customFieldValues.js +26 -0
  143. package/dist/backend/utils/customFieldValues.js.map +7 -0
  144. package/dist/backend/utils/flash.js +16 -0
  145. package/dist/backend/utils/flash.js.map +7 -0
  146. package/dist/backend/utils/nav.js +185 -0
  147. package/dist/backend/utils/nav.js.map +7 -0
  148. package/dist/backend/utils/serverErrors.js +230 -0
  149. package/dist/backend/utils/serverErrors.js.map +7 -0
  150. package/dist/frontend/AuthFooter.js +23 -0
  151. package/dist/frontend/AuthFooter.js.map +7 -0
  152. package/dist/frontend/LanguageSwitcher.js +57 -0
  153. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  154. package/dist/frontend/Layout.js +14 -0
  155. package/dist/frontend/Layout.js.map +7 -0
  156. package/dist/index.js +32 -0
  157. package/dist/index.js.map +7 -0
  158. package/dist/primitives/DataLoader.js +67 -0
  159. package/dist/primitives/DataLoader.js.map +7 -0
  160. package/dist/primitives/ErrorNotice.js +20 -0
  161. package/dist/primitives/ErrorNotice.js.map +7 -0
  162. package/dist/primitives/alert.js +38 -0
  163. package/dist/primitives/alert.js.map +7 -0
  164. package/dist/primitives/badge.js +28 -0
  165. package/dist/primitives/badge.js.map +7 -0
  166. package/dist/primitives/button.js +44 -0
  167. package/dist/primitives/button.js.map +7 -0
  168. package/dist/primitives/card.js +91 -0
  169. package/dist/primitives/card.js.map +7 -0
  170. package/dist/primitives/checkbox.js +28 -0
  171. package/dist/primitives/checkbox.js.map +7 -0
  172. package/dist/primitives/dialog.js +90 -0
  173. package/dist/primitives/dialog.js.map +7 -0
  174. package/dist/primitives/input.js +22 -0
  175. package/dist/primitives/input.js.map +7 -0
  176. package/dist/primitives/label.js +21 -0
  177. package/dist/primitives/label.js.map +7 -0
  178. package/dist/primitives/separator.js +9 -0
  179. package/dist/primitives/separator.js.map +7 -0
  180. package/dist/primitives/spinner.js +24 -0
  181. package/dist/primitives/spinner.js.map +7 -0
  182. package/dist/primitives/switch.js +80 -0
  183. package/dist/primitives/switch.js.map +7 -0
  184. package/dist/primitives/table.js +29 -0
  185. package/dist/primitives/table.js.map +7 -0
  186. package/dist/primitives/tabs.js +87 -0
  187. package/dist/primitives/tabs.js.map +7 -0
  188. package/dist/primitives/textarea.js +21 -0
  189. package/dist/primitives/textarea.js.map +7 -0
  190. package/dist/primitives/tooltip.js +60 -0
  191. package/dist/primitives/tooltip.js.map +7 -0
  192. package/dist/theme/QueryProvider.js +44 -0
  193. package/dist/theme/QueryProvider.js.map +7 -0
  194. package/dist/theme/ThemeProvider.js +95 -0
  195. package/dist/theme/ThemeProvider.js.map +7 -0
  196. package/dist/theme/ThemeToggle.js +88 -0
  197. package/dist/theme/ThemeToggle.js.map +7 -0
  198. package/dist/theme/index.js +10 -0
  199. package/dist/theme/index.js.map +7 -0
  200. package/dist/types/react-big-calendar.d.js +1 -0
  201. package/dist/types/react-big-calendar.d.js.map +7 -0
  202. package/jest.config.cjs +23 -0
  203. package/jest.setup.ts +55 -0
  204. package/package.json +105 -0
  205. package/src/backend/AppShell.tsx +1096 -0
  206. package/src/backend/ConfirmDialog.tsx +19 -0
  207. package/src/backend/ContextHelp.tsx +38 -0
  208. package/src/backend/CrudForm.tsx +2503 -0
  209. package/src/backend/DataTable.tsx +1730 -0
  210. package/src/backend/EmptyState.tsx +65 -0
  211. package/src/backend/FilterBar.tsx +161 -0
  212. package/src/backend/FilterOverlay.tsx +328 -0
  213. package/src/backend/FlashMessages.tsx +82 -0
  214. package/src/backend/JsonBuilder.tsx +362 -0
  215. package/src/backend/JsonDisplay.tsx +254 -0
  216. package/src/backend/Page.tsx +30 -0
  217. package/src/backend/PerspectiveSidebar.tsx +337 -0
  218. package/src/backend/RowActions.tsx +151 -0
  219. package/src/backend/TruncatedCell.tsx +133 -0
  220. package/src/backend/UserMenu.tsx +118 -0
  221. package/src/backend/ValueIcons.tsx +48 -0
  222. package/src/backend/__tests__/AppShell.test.tsx +115 -0
  223. package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
  224. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  225. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  226. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  227. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  228. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
  229. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  230. package/src/backend/dashboard/DashboardScreen.tsx +684 -0
  231. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
  232. package/src/backend/dashboard/index.ts +1 -0
  233. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  234. package/src/backend/detail/ActivitiesSection.tsx +1284 -0
  235. package/src/backend/detail/AddressEditor.tsx +472 -0
  236. package/src/backend/detail/AddressTiles.tsx +587 -0
  237. package/src/backend/detail/AddressesSection.tsx +346 -0
  238. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  239. package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
  240. package/src/backend/detail/AttachmentsSection.tsx +414 -0
  241. package/src/backend/detail/CustomDataSection.tsx +530 -0
  242. package/src/backend/detail/DetailFieldsSection.tsx +147 -0
  243. package/src/backend/detail/ErrorMessage.tsx +32 -0
  244. package/src/backend/detail/InlineEditors.tsx +877 -0
  245. package/src/backend/detail/LoadingMessage.tsx +14 -0
  246. package/src/backend/detail/NotesSection.tsx +1275 -0
  247. package/src/backend/detail/TabEmptyState.tsx +48 -0
  248. package/src/backend/detail/TagsSection.tsx +314 -0
  249. package/src/backend/detail/addressFormat.tsx +121 -0
  250. package/src/backend/detail/index.ts +44 -0
  251. package/src/backend/fields/registry.generated.ts +8 -0
  252. package/src/backend/fields/registry.ts +38 -0
  253. package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
  254. package/src/backend/indexes/store.ts +88 -0
  255. package/src/backend/injection/InjectionSpot.tsx +236 -0
  256. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  257. package/src/backend/injection/helpers.ts +35 -0
  258. package/src/backend/injection/widgetRegistry.ts +68 -0
  259. package/src/backend/inputs/ComboboxInput.tsx +269 -0
  260. package/src/backend/inputs/LookupSelect.tsx +247 -0
  261. package/src/backend/inputs/PhoneNumberField.tsx +129 -0
  262. package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
  263. package/src/backend/inputs/TagsInput.tsx +259 -0
  264. package/src/backend/inputs/index.ts +5 -0
  265. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  266. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
  267. package/src/backend/operations/store.ts +230 -0
  268. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  269. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  270. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  271. package/src/backend/schedule/ScheduleView.tsx +198 -0
  272. package/src/backend/schedule/index.ts +5 -0
  273. package/src/backend/schedule/recurrence.ts +99 -0
  274. package/src/backend/schedule/types.ts +26 -0
  275. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  276. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  277. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  278. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  279. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  280. package/src/backend/utils/api.ts +149 -0
  281. package/src/backend/utils/apiCall.ts +96 -0
  282. package/src/backend/utils/crud.ts +174 -0
  283. package/src/backend/utils/customFieldColumns.ts +71 -0
  284. package/src/backend/utils/customFieldDefs.ts +245 -0
  285. package/src/backend/utils/customFieldFilters.ts +145 -0
  286. package/src/backend/utils/customFieldForms.ts +196 -0
  287. package/src/backend/utils/customFieldValues.ts +41 -0
  288. package/src/backend/utils/flash.ts +17 -0
  289. package/src/backend/utils/nav.ts +238 -0
  290. package/src/backend/utils/serverErrors.ts +302 -0
  291. package/src/frontend/AuthFooter.tsx +29 -0
  292. package/src/frontend/LanguageSwitcher.tsx +66 -0
  293. package/src/frontend/Layout.tsx +13 -0
  294. package/src/index.ts +32 -0
  295. package/src/primitives/DataLoader.tsx +92 -0
  296. package/src/primitives/ErrorNotice.tsx +26 -0
  297. package/src/primitives/alert.tsx +52 -0
  298. package/src/primitives/badge.tsx +31 -0
  299. package/src/primitives/button.tsx +47 -0
  300. package/src/primitives/card.tsx +92 -0
  301. package/src/primitives/checkbox.tsx +28 -0
  302. package/src/primitives/dialog.tsx +110 -0
  303. package/src/primitives/input.tsx +20 -0
  304. package/src/primitives/label.tsx +18 -0
  305. package/src/primitives/separator.tsx +7 -0
  306. package/src/primitives/spinner.tsx +27 -0
  307. package/src/primitives/switch.tsx +86 -0
  308. package/src/primitives/table.tsx +27 -0
  309. package/src/primitives/tabs.tsx +128 -0
  310. package/src/primitives/textarea.tsx +20 -0
  311. package/src/primitives/tooltip.tsx +85 -0
  312. package/src/theme/QueryProvider.tsx +46 -0
  313. package/src/theme/ThemeProvider.tsx +120 -0
  314. package/src/theme/ThemeToggle.tsx +88 -0
  315. package/src/theme/index.ts +3 -0
  316. package/src/types/react-big-calendar.d.ts +16 -0
  317. package/tsconfig.build.json +11 -0
  318. package/tsconfig.json +9 -0
  319. package/watch.mjs +6 -0
@@ -0,0 +1,269 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+
5
+ export type ComboboxOption = {
6
+ value: string
7
+ label: string
8
+ description?: string | null
9
+ }
10
+
11
+ export type ComboboxInputProps = {
12
+ value: string
13
+ onChange: (next: string) => void
14
+ placeholder?: string
15
+ suggestions?: Array<string | ComboboxOption>
16
+ loadSuggestions?: (query?: string) => Promise<Array<string | ComboboxOption>>
17
+ resolveLabel?: (value: string) => string
18
+ resolveDescription?: (value: string) => string | null | undefined
19
+ autoFocus?: boolean
20
+ disabled?: boolean
21
+ allowCustomValues?: boolean
22
+ }
23
+
24
+ function normalizeOptions(input?: Array<string | ComboboxOption>): ComboboxOption[] {
25
+ if (!Array.isArray(input)) return []
26
+ return input
27
+ .map((option) => {
28
+ if (typeof option === 'string') {
29
+ const trimmed = option.trim()
30
+ if (!trimmed) return null
31
+ return { value: trimmed, label: trimmed }
32
+ }
33
+ const value = typeof option.value === 'string' ? option.value.trim() : ''
34
+ if (!value) return null
35
+ return {
36
+ value,
37
+ label: option.label?.trim() || value,
38
+ description: option.description ?? null,
39
+ }
40
+ })
41
+ .filter((option): option is ComboboxOption => !!option)
42
+ }
43
+
44
+ export function ComboboxInput({
45
+ value,
46
+ onChange,
47
+ placeholder,
48
+ suggestions,
49
+ loadSuggestions,
50
+ resolveLabel,
51
+ resolveDescription,
52
+ autoFocus,
53
+ disabled = false,
54
+ allowCustomValues = true,
55
+ }: ComboboxInputProps) {
56
+ const [input, setInput] = React.useState('')
57
+ const [asyncOptions, setAsyncOptions] = React.useState<ComboboxOption[]>([])
58
+ const [loading, setLoading] = React.useState(false)
59
+ const [touched, setTouched] = React.useState(false)
60
+ const [showSuggestions, setShowSuggestions] = React.useState(false)
61
+ const [selectedIndex, setSelectedIndex] = React.useState(-1)
62
+ const inputRef = React.useRef<HTMLInputElement>(null)
63
+
64
+ const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])
65
+
66
+ const optionMap = React.useMemo(() => {
67
+ const map = new Map<string, ComboboxOption>()
68
+ const register = (option: ComboboxOption) => {
69
+ if (!map.has(option.value)) {
70
+ map.set(option.value, option)
71
+ }
72
+ }
73
+ staticOptions.forEach(register)
74
+ asyncOptions.forEach(register)
75
+ if (value) {
76
+ const existing = map.get(value)
77
+ if (!existing) {
78
+ map.set(value, {
79
+ value,
80
+ label: resolveLabel?.(value) ?? value,
81
+ description: resolveDescription?.(value) ?? null,
82
+ })
83
+ }
84
+ }
85
+ return map
86
+ }, [asyncOptions, resolveDescription, resolveLabel, staticOptions, value])
87
+
88
+ const availableOptions = React.useMemo(() => {
89
+ return Array.from(optionMap.values())
90
+ }, [optionMap])
91
+
92
+ const filteredSuggestions = React.useMemo(() => {
93
+ const query = input.toLowerCase().trim()
94
+ if (!query) return availableOptions.slice(0, 8)
95
+ return availableOptions.filter((option) => {
96
+ const labelMatch = option.label.toLowerCase().includes(query)
97
+ const descMatch = option.description?.toLowerCase().includes(query)
98
+ return labelMatch || Boolean(descMatch)
99
+ }).slice(0, 8)
100
+ }, [availableOptions, input])
101
+
102
+ React.useEffect(() => {
103
+ if (!loadSuggestions || !touched || disabled) return
104
+ const query = input.trim()
105
+ let cancelled = false
106
+ const handle = window.setTimeout(async () => {
107
+ setLoading(true)
108
+ try {
109
+ const items = await loadSuggestions(query)
110
+ if (!cancelled) {
111
+ setAsyncOptions(normalizeOptions(items))
112
+ }
113
+ } finally {
114
+ if (!cancelled) setLoading(false)
115
+ }
116
+ }, 200)
117
+ return () => {
118
+ cancelled = true
119
+ window.clearTimeout(handle)
120
+ }
121
+ }, [disabled, input, loadSuggestions, touched])
122
+
123
+ // Sync input with value when value changes externally and input is not focused
124
+ React.useEffect(() => {
125
+ if (document.activeElement !== inputRef.current) {
126
+ const option = optionMap.get(value)
127
+ setInput(option?.label ?? value ?? '')
128
+ }
129
+ }, [value, optionMap])
130
+
131
+ const selectValue = React.useCallback(
132
+ (nextValue: string) => {
133
+ if (disabled) return
134
+ const trimmed = nextValue.trim()
135
+ onChange(trimmed)
136
+ const option = optionMap.get(trimmed)
137
+ setInput(option?.label ?? trimmed)
138
+ setShowSuggestions(false)
139
+ setSelectedIndex(-1)
140
+ },
141
+ [disabled, onChange, optionMap]
142
+ )
143
+
144
+ const findOptionForInput = React.useCallback(
145
+ (raw: string): ComboboxOption | null => {
146
+ const query = raw.trim().toLowerCase()
147
+ if (!query) return null
148
+ for (const option of optionMap.values()) {
149
+ if (option.value === raw.trim()) return option
150
+ if (option.label.toLowerCase() === query) return option
151
+ }
152
+ return null
153
+ },
154
+ [optionMap]
155
+ )
156
+
157
+ const confirmSelection = React.useCallback(
158
+ (raw: string) => {
159
+ if (disabled) return
160
+ const option = findOptionForInput(raw)
161
+ if (option) {
162
+ selectValue(option.value)
163
+ return
164
+ }
165
+ if (!allowCustomValues) {
166
+ // Revert to current value if custom values not allowed
167
+ const currentOption = optionMap.get(value)
168
+ setInput(currentOption?.label ?? value ?? '')
169
+ setShowSuggestions(false)
170
+ return
171
+ }
172
+ selectValue(raw)
173
+ },
174
+ [allowCustomValues, disabled, findOptionForInput, optionMap, selectValue, value]
175
+ )
176
+
177
+ const handleKeyDown = React.useCallback(
178
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
179
+ if (disabled) return
180
+
181
+ if (event.key === 'ArrowDown') {
182
+ event.preventDefault()
183
+ if (!showSuggestions) {
184
+ setShowSuggestions(true)
185
+ setSelectedIndex(0)
186
+ } else {
187
+ setSelectedIndex((prev) => Math.min(prev + 1, filteredSuggestions.length - 1))
188
+ }
189
+ } else if (event.key === 'ArrowUp') {
190
+ event.preventDefault()
191
+ setSelectedIndex((prev) => Math.max(prev - 1, -1))
192
+ } else if (event.key === 'Enter') {
193
+ event.preventDefault()
194
+ if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
195
+ selectValue(filteredSuggestions[selectedIndex].value)
196
+ } else {
197
+ confirmSelection(input)
198
+ }
199
+ } else if (event.key === 'Escape') {
200
+ event.preventDefault()
201
+ setShowSuggestions(false)
202
+ setSelectedIndex(-1)
203
+ }
204
+ },
205
+ [confirmSelection, disabled, filteredSuggestions, input, selectValue, selectedIndex, showSuggestions]
206
+ )
207
+
208
+ return (
209
+ <div className="relative w-full">
210
+ <input
211
+ ref={inputRef}
212
+ type="text"
213
+ className="w-full h-9 rounded border px-2 text-sm disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
214
+ value={input}
215
+ placeholder={placeholder || 'Type to search...'}
216
+ autoFocus={autoFocus}
217
+ data-crud-focus-target=""
218
+ disabled={disabled}
219
+ onFocus={() => {
220
+ setTouched(true)
221
+ setShowSuggestions(true)
222
+ }}
223
+ onChange={(event) => {
224
+ setTouched(true)
225
+ setInput(event.target.value)
226
+ setShowSuggestions(true)
227
+ setSelectedIndex(-1)
228
+ }}
229
+ onKeyDown={handleKeyDown}
230
+ onBlur={() => {
231
+ // Delay to allow click on suggestions
232
+ setTimeout(() => {
233
+ if (disabled) return
234
+ confirmSelection(input)
235
+ }, 200)
236
+ }}
237
+ />
238
+
239
+ {showSuggestions && !disabled && (loading || filteredSuggestions.length > 0) && (
240
+ <div className="absolute z-50 w-full mt-1 rounded border bg-popover shadow-lg max-h-60 overflow-auto">
241
+ {loading && touched ? (
242
+ <div className="px-3 py-2 text-xs text-muted-foreground">Loading suggestions…</div>
243
+ ) : (
244
+ filteredSuggestions.map((option, index) => (
245
+ <button
246
+ key={option.value}
247
+ type="button"
248
+ className={[
249
+ 'w-full flex flex-col items-start px-3 py-2 text-sm transition text-left',
250
+ index === selectedIndex ? 'bg-accent' : 'hover:bg-muted',
251
+ ]
252
+ .filter(Boolean)
253
+ .join(' ')}
254
+ onMouseDown={(event) => event.preventDefault()}
255
+ onClick={() => selectValue(option.value)}
256
+ onMouseEnter={() => setSelectedIndex(index)}
257
+ >
258
+ <span className="font-medium">{option.label}</span>
259
+ {option.description ? (
260
+ <span className="text-xs text-muted-foreground">{option.description}</span>
261
+ ) : null}
262
+ </button>
263
+ ))
264
+ )}
265
+ </div>
266
+ )}
267
+ </div>
268
+ )
269
+ }
@@ -0,0 +1,247 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Loader2, Search, X } from 'lucide-react'
5
+ import { Button } from '../../primitives/button'
6
+ import { cn } from '@open-mercato/shared/lib/utils'
7
+
8
+ export type LookupSelectItem = {
9
+ id: string
10
+ title: string
11
+ subtitle?: string | null
12
+ badge?: string | null
13
+ icon?: React.ReactNode
14
+ disabled?: boolean
15
+ rightLabel?: string | null
16
+ description?: string | null
17
+ }
18
+
19
+ type LookupSelectProps = {
20
+ value: string | null
21
+ onChange: (next: string | null) => void
22
+ fetchItems?: (query: string) => Promise<LookupSelectItem[]>
23
+ fetchOptions?: (query?: string) => Promise<LookupSelectItem[]>
24
+ options?: LookupSelectItem[]
25
+ minQuery?: number
26
+ actionSlot?: React.ReactNode
27
+ onReady?: (controls: { setQuery: (value: string) => void }) => void
28
+ searchPlaceholder?: string
29
+ placeholder?: string
30
+ clearLabel?: string
31
+ emptyLabel?: string
32
+ loadingLabel?: string
33
+ selectLabel?: string
34
+ selectedLabel?: string
35
+ minQueryHintLabel?: string
36
+ startTypingLabel?: string
37
+ selectedHintLabel?: (id: string) => string
38
+ disabled?: boolean
39
+ loading?: boolean
40
+ defaultOpen?: boolean
41
+ }
42
+
43
+ export function LookupSelect({
44
+ value,
45
+ onChange,
46
+ fetchItems,
47
+ fetchOptions,
48
+ options,
49
+ minQuery = 2,
50
+ actionSlot,
51
+ onReady,
52
+ placeholder,
53
+ searchPlaceholder = placeholder ?? 'Search…',
54
+ clearLabel = 'Clear selection',
55
+ emptyLabel = 'No results',
56
+ loadingLabel = 'Searching…',
57
+ selectLabel = 'Select',
58
+ selectedLabel = 'Selected',
59
+ minQueryHintLabel,
60
+ startTypingLabel = 'Start typing to search.',
61
+ selectedHintLabel,
62
+ disabled = false,
63
+ loading: loadingProp = false,
64
+ defaultOpen = false,
65
+ }: LookupSelectProps) {
66
+ const [query, setQuery] = React.useState('')
67
+ const [items, setItems] = React.useState<LookupSelectItem[]>(options ?? [])
68
+ const [loading, setLoading] = React.useState(false)
69
+ const [hasTyped, setHasTyped] = React.useState(defaultOpen)
70
+ const [error, setError] = React.useState<string | null>(null)
71
+ const fetchItemsRef = React.useRef(fetchItems ?? fetchOptions)
72
+ const setQueryRef = React.useRef(setQuery)
73
+
74
+ React.useEffect(() => {
75
+ fetchItemsRef.current = fetchItems ?? fetchOptions
76
+ }, [fetchItems, fetchOptions])
77
+
78
+ React.useEffect(() => {
79
+ if (Array.isArray(options)) {
80
+ setItems(options)
81
+ }
82
+ }, [options])
83
+
84
+ React.useEffect(() => {
85
+ setQueryRef.current = setQuery
86
+ if (onReady) onReady({ setQuery })
87
+ }, [onReady, setQuery])
88
+
89
+ const shouldSearch =
90
+ defaultOpen || query.trim().length >= minQuery || Boolean(value && (options?.length ?? 0) > 0)
91
+ React.useEffect(() => {
92
+ if (disabled) {
93
+ setItems(options ?? [])
94
+ setLoading(false)
95
+ return
96
+ }
97
+ let cancelled = false
98
+ let timer: ReturnType<typeof setTimeout> | null = null
99
+ if (!shouldSearch) {
100
+ setItems(options ?? [])
101
+ setLoading(false)
102
+ setError(null)
103
+ return () => { cancelled = true }
104
+ }
105
+ setLoading(true)
106
+ setError(null)
107
+ timer = setTimeout(() => {
108
+ const requestId = Date.now()
109
+ const fetcher = fetchItemsRef.current
110
+ const loader = fetcher ?? (() => Promise.resolve(options ?? []))
111
+ loader(query.trim())
112
+ .then((result) => {
113
+ if (cancelled) return
114
+ setItems(result)
115
+ })
116
+ .catch((err) => {
117
+ if (cancelled) return
118
+ console.error('LookupSelect.fetchItems', err)
119
+ setError('error')
120
+ })
121
+ .finally(() => {
122
+ if (!cancelled) setLoading(false)
123
+ })
124
+ return requestId
125
+ }, 220)
126
+ return () => {
127
+ cancelled = true
128
+ if (timer) clearTimeout(timer)
129
+ }
130
+ }, [query, shouldSearch])
131
+
132
+ return (
133
+ <div className="space-y-3">
134
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
135
+ <div className="relative flex-1">
136
+ <Search className="pointer-events-none absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
137
+ <input
138
+ className="w-full rounded border pl-8 pr-2 py-2 text-sm"
139
+ value={query}
140
+ onChange={(event) => {
141
+ setQuery(event.target.value)
142
+ setHasTyped(true)
143
+ }}
144
+ placeholder={searchPlaceholder}
145
+ disabled={disabled}
146
+ />
147
+ </div>
148
+ {actionSlot ? <div className="sm:self-start">{actionSlot}</div> : null}
149
+ </div>
150
+ {shouldSearch ? (
151
+ <div className="space-y-2">
152
+ {loading || loadingProp ? (
153
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
154
+ <Loader2 className="h-4 w-4 animate-spin" />
155
+ {loadingLabel}
156
+ </div>
157
+ ) : null}
158
+ {!loading && !loadingProp && !items.length ? (
159
+ <p className="text-xs text-muted-foreground">{emptyLabel}</p>
160
+ ) : null}
161
+ <div className="space-y-2 max-h-80 overflow-y-auto">
162
+ {items.map((item) => {
163
+ const isSelected = value === item.id
164
+ const handleSelect = () => {
165
+ if (item.disabled && !isSelected) return
166
+ onChange(item.id)
167
+ }
168
+ return (
169
+ <div
170
+ key={item.id}
171
+ className={cn(
172
+ 'flex gap-3 rounded border bg-card p-3 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
173
+ isSelected ? 'border-primary/70 bg-primary/5' : 'hover:border-primary/50'
174
+ )}
175
+ role="button"
176
+ tabIndex={item.disabled ? -1 : 0}
177
+ onClick={handleSelect}
178
+ onKeyDown={(event) => {
179
+ if (event.key === 'Enter' || event.key === ' ') {
180
+ event.preventDefault()
181
+ handleSelect()
182
+ }
183
+ }}
184
+ aria-pressed={isSelected}
185
+ >
186
+ <div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded border bg-muted">
187
+ {item.icon ?? <span className="text-muted-foreground">•</span>}
188
+ </div>
189
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
190
+ <div className="flex items-start justify-between gap-2">
191
+ <div className="min-w-0">
192
+ <div className="truncate text-sm font-medium">{item.title}</div>
193
+ {item.subtitle ? (
194
+ <div className="text-xs text-muted-foreground truncate">{item.subtitle}</div>
195
+ ) : null}
196
+ {item.description ? (
197
+ <div className="text-xs text-muted-foreground truncate">{item.description}</div>
198
+ ) : null}
199
+ </div>
200
+ {item.rightLabel ? (
201
+ <div className="shrink-0 text-xs font-medium text-muted-foreground">{item.rightLabel}</div>
202
+ ) : null}
203
+ </div>
204
+ <div className="flex justify-end">
205
+ <Button
206
+ type="button"
207
+ variant={isSelected ? 'secondary' : 'outline'}
208
+ size="sm"
209
+ className="shrink-0"
210
+ onClick={(event) => {
211
+ event.stopPropagation()
212
+ handleSelect()
213
+ }}
214
+ disabled={item.disabled && !isSelected}
215
+ >
216
+ {isSelected ? selectedLabel : selectLabel}
217
+ </Button>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ )
222
+ })}
223
+ </div>
224
+ {value ? (
225
+ <Button
226
+ type="button"
227
+ variant="ghost"
228
+ size="sm"
229
+ className="w-fit gap-1 text-sm font-normal"
230
+ onClick={() => onChange(null)}
231
+ >
232
+ <X className="h-4 w-4" />
233
+ {clearLabel}
234
+ </Button>
235
+ ) : null}
236
+ </div>
237
+ ) : hasTyped ? (
238
+ <p className="text-xs text-muted-foreground">
239
+ {minQueryHintLabel ?? `Type at least ${minQuery} characters or paste an id to search.`}
240
+ </p>
241
+ ) : (
242
+ <p className="text-xs text-muted-foreground">{startTypingLabel}</p>
243
+ )}
244
+ {error ? <p className="text-xs text-destructive">{emptyLabel}</p> : null}
245
+ </div>
246
+ )
247
+ }
@@ -0,0 +1,129 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+
5
+ export type PhoneDuplicateMatch = {
6
+ id: string
7
+ label: string
8
+ href: string
9
+ }
10
+
11
+ export type PhoneNumberFieldProps = {
12
+ value?: string | null
13
+ onValueChange: (next: string | undefined) => void
14
+ onDigitsChange?: (digits: string | null) => void
15
+ disabled?: boolean
16
+ autoFocus?: boolean
17
+ placeholder?: string
18
+ minDigits?: number
19
+ checkingLabel?: string
20
+ duplicateLabel?: (match: PhoneDuplicateMatch) => string
21
+ duplicateLinkLabel?: string
22
+ onDuplicateLookup?: (normalizedValue: string) => Promise<PhoneDuplicateMatch | null>
23
+ }
24
+
25
+ const DEFAULT_MIN_DIGITS = 6
26
+ const DIGIT_PATTERN = /\d+/g
27
+
28
+ const digitsOnly = (value: string): string => {
29
+ const matches = value.match(DIGIT_PATTERN)
30
+ return matches ? matches.join('') : ''
31
+ }
32
+
33
+ export function PhoneNumberField({
34
+ value,
35
+ onValueChange,
36
+ onDigitsChange,
37
+ disabled = false,
38
+ autoFocus,
39
+ placeholder,
40
+ minDigits = DEFAULT_MIN_DIGITS,
41
+ checkingLabel,
42
+ duplicateLabel,
43
+ duplicateLinkLabel,
44
+ onDuplicateLookup,
45
+ }: PhoneNumberFieldProps) {
46
+ const [local, setLocal] = React.useState<string>(() => (value == null ? '' : String(value)))
47
+ const [duplicate, setDuplicate] = React.useState<PhoneDuplicateMatch | null>(null)
48
+ const [checking, setChecking] = React.useState(false)
49
+
50
+ React.useEffect(() => {
51
+ if (value == null || value === '') {
52
+ setLocal('')
53
+ onDigitsChange?.(null)
54
+ return
55
+ }
56
+ const nextValue = String(value)
57
+ setLocal(nextValue)
58
+ onDigitsChange?.(digitsOnly(nextValue) || null)
59
+ }, [value, onDigitsChange])
60
+
61
+ React.useEffect(() => {
62
+ if (!onDuplicateLookup || disabled) {
63
+ setDuplicate(null)
64
+ setChecking(false)
65
+ return
66
+ }
67
+ const digits = digitsOnly(local)
68
+ if (!digits || digits.length < minDigits) {
69
+ setDuplicate(null)
70
+ setChecking(false)
71
+ return
72
+ }
73
+
74
+ let cancelled = false
75
+ setChecking(true)
76
+ const handle = window.setTimeout(async () => {
77
+ try {
78
+ const match = await onDuplicateLookup(digits)
79
+ if (!cancelled) setDuplicate(match)
80
+ } catch {
81
+ if (!cancelled) setDuplicate(null)
82
+ } finally {
83
+ if (!cancelled) setChecking(false)
84
+ }
85
+ }, 350)
86
+
87
+ return () => {
88
+ cancelled = true
89
+ window.clearTimeout(handle)
90
+ }
91
+ }, [local, disabled, minDigits, onDuplicateLookup])
92
+
93
+ const handleChange = React.useCallback(
94
+ (event: React.ChangeEvent<HTMLInputElement>) => {
95
+ const next = event.target.value
96
+ const cleanDigits = digitsOnly(next)
97
+ setLocal(next)
98
+ onValueChange(next.length ? next : undefined)
99
+ onDigitsChange?.(cleanDigits.length ? cleanDigits : null)
100
+ },
101
+ [onValueChange, onDigitsChange]
102
+ )
103
+
104
+ return (
105
+ <div className="space-y-2">
106
+ <input
107
+ type="tel"
108
+ className="w-full h-9 rounded border px-2 text-sm"
109
+ value={local}
110
+ onChange={handleChange}
111
+ placeholder={placeholder}
112
+ autoFocus={autoFocus}
113
+ disabled={disabled}
114
+ data-crud-focus-target=""
115
+ />
116
+ {!disabled && duplicate && duplicateLabel && duplicateLinkLabel ? (
117
+ <p className="text-xs text-amber-600">
118
+ {duplicateLabel(duplicate)}{' '}
119
+ <a className="font-medium text-primary underline underline-offset-2" href={duplicate.href}>
120
+ {duplicateLinkLabel}
121
+ </a>
122
+ </p>
123
+ ) : null}
124
+ {!disabled && !duplicate && checking && checkingLabel ? (
125
+ <p className="text-xs text-muted-foreground">{checkingLabel}</p>
126
+ ) : null}
127
+ </div>
128
+ )
129
+ }