@modern-admin/ui 0.1.0

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 (268) hide show
  1. package/dist/components/accordion.d.ts +7 -0
  2. package/dist/components/accordion.d.ts.map +1 -0
  3. package/dist/components/accordion.jsx +19 -0
  4. package/dist/components/accordion.jsx.map +1 -0
  5. package/dist/components/alert-dialog.d.ts +22 -0
  6. package/dist/components/alert-dialog.d.ts.map +1 -0
  7. package/dist/components/alert-dialog.jsx +27 -0
  8. package/dist/components/alert-dialog.jsx.map +1 -0
  9. package/dist/components/audit-timeline.d.ts +24 -0
  10. package/dist/components/audit-timeline.d.ts.map +1 -0
  11. package/dist/components/audit-timeline.jsx +60 -0
  12. package/dist/components/audit-timeline.jsx.map +1 -0
  13. package/dist/components/avatar.d.ts +6 -0
  14. package/dist/components/avatar.d.ts.map +1 -0
  15. package/dist/components/avatar.jsx +10 -0
  16. package/dist/components/avatar.jsx.map +1 -0
  17. package/dist/components/badge.d.ts +10 -0
  18. package/dist/components/badge.d.ts.map +1 -0
  19. package/dist/components/badge.jsx +19 -0
  20. package/dist/components/badge.jsx.map +1 -0
  21. package/dist/components/breadcrumb.d.ts +17 -0
  22. package/dist/components/breadcrumb.d.ts.map +1 -0
  23. package/dist/components/breadcrumb.jsx +27 -0
  24. package/dist/components/breadcrumb.jsx.map +1 -0
  25. package/dist/components/button.d.ts +12 -0
  26. package/dist/components/button.d.ts.map +1 -0
  27. package/dist/components/button.jsx +37 -0
  28. package/dist/components/button.jsx.map +1 -0
  29. package/dist/components/calendar.d.ts +9 -0
  30. package/dist/components/calendar.d.ts.map +1 -0
  31. package/dist/components/calendar.jsx +102 -0
  32. package/dist/components/calendar.jsx.map +1 -0
  33. package/dist/components/card.d.ts +8 -0
  34. package/dist/components/card.d.ts.map +1 -0
  35. package/dist/components/card.jsx +18 -0
  36. package/dist/components/card.jsx.map +1 -0
  37. package/dist/components/chart.d.ts +97 -0
  38. package/dist/components/chart.d.ts.map +1 -0
  39. package/dist/components/chart.jsx +233 -0
  40. package/dist/components/chart.jsx.map +1 -0
  41. package/dist/components/checkbox.d.ts +4 -0
  42. package/dist/components/checkbox.d.ts.map +1 -0
  43. package/dist/components/checkbox.jsx +11 -0
  44. package/dist/components/checkbox.jsx.map +1 -0
  45. package/dist/components/combobox.d.ts +46 -0
  46. package/dist/components/combobox.d.ts.map +1 -0
  47. package/dist/components/combobox.jsx +145 -0
  48. package/dist/components/combobox.jsx.map +1 -0
  49. package/dist/components/command.d.ts +80 -0
  50. package/dist/components/command.d.ts.map +1 -0
  51. package/dist/components/command.jsx +32 -0
  52. package/dist/components/command.jsx.map +1 -0
  53. package/dist/components/date-picker.d.ts +24 -0
  54. package/dist/components/date-picker.d.ts.map +1 -0
  55. package/dist/components/date-picker.jsx +149 -0
  56. package/dist/components/date-picker.jsx.map +1 -0
  57. package/dist/components/date-range-input.d.ts +22 -0
  58. package/dist/components/date-range-input.d.ts.map +1 -0
  59. package/dist/components/date-range-input.jsx +202 -0
  60. package/dist/components/date-range-input.jsx.map +1 -0
  61. package/dist/components/dialog.d.ts +19 -0
  62. package/dist/components/dialog.d.ts.map +1 -0
  63. package/dist/components/dialog.jsx +30 -0
  64. package/dist/components/dialog.jsx.map +1 -0
  65. package/dist/components/diff-view.d.ts +24 -0
  66. package/dist/components/diff-view.d.ts.map +1 -0
  67. package/dist/components/diff-view.jsx +69 -0
  68. package/dist/components/diff-view.jsx.map +1 -0
  69. package/dist/components/dropdown-menu.d.ts +27 -0
  70. package/dist/components/dropdown-menu.d.ts.map +1 -0
  71. package/dist/components/dropdown-menu.jsx +48 -0
  72. package/dist/components/dropdown-menu.jsx.map +1 -0
  73. package/dist/components/empty.d.ts +15 -0
  74. package/dist/components/empty.d.ts.map +1 -0
  75. package/dist/components/empty.jsx +27 -0
  76. package/dist/components/empty.jsx.map +1 -0
  77. package/dist/components/field.d.ts +23 -0
  78. package/dist/components/field.d.ts.map +1 -0
  79. package/dist/components/field.jsx +60 -0
  80. package/dist/components/field.jsx.map +1 -0
  81. package/dist/components/file-input.d.ts +50 -0
  82. package/dist/components/file-input.d.ts.map +1 -0
  83. package/dist/components/file-input.jsx +104 -0
  84. package/dist/components/file-input.jsx.map +1 -0
  85. package/dist/components/form.d.ts +20 -0
  86. package/dist/components/form.d.ts.map +1 -0
  87. package/dist/components/form.jsx +66 -0
  88. package/dist/components/form.jsx.map +1 -0
  89. package/dist/components/info-tooltip.d.ts +11 -0
  90. package/dist/components/info-tooltip.d.ts.map +1 -0
  91. package/dist/components/info-tooltip.jsx +17 -0
  92. package/dist/components/info-tooltip.jsx.map +1 -0
  93. package/dist/components/input.d.ts +13 -0
  94. package/dist/components/input.d.ts.map +1 -0
  95. package/dist/components/input.jsx +19 -0
  96. package/dist/components/input.jsx.map +1 -0
  97. package/dist/components/json-editor.d.ts +23 -0
  98. package/dist/components/json-editor.d.ts.map +1 -0
  99. package/dist/components/json-editor.jsx +143 -0
  100. package/dist/components/json-editor.jsx.map +1 -0
  101. package/dist/components/kbd.d.ts +15 -0
  102. package/dist/components/kbd.d.ts.map +1 -0
  103. package/dist/components/kbd.jsx +23 -0
  104. package/dist/components/kbd.jsx.map +1 -0
  105. package/dist/components/key-value-editor.d.ts +92 -0
  106. package/dist/components/key-value-editor.d.ts.map +1 -0
  107. package/dist/components/key-value-editor.jsx +187 -0
  108. package/dist/components/key-value-editor.jsx.map +1 -0
  109. package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
  110. package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
  111. package/dist/components/keyboard-shortcuts-help.jsx +97 -0
  112. package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
  113. package/dist/components/label.d.ts +5 -0
  114. package/dist/components/label.d.ts.map +1 -0
  115. package/dist/components/label.jsx +8 -0
  116. package/dist/components/label.jsx.map +1 -0
  117. package/dist/components/media-preview.d.ts +30 -0
  118. package/dist/components/media-preview.d.ts.map +1 -0
  119. package/dist/components/media-preview.jsx +189 -0
  120. package/dist/components/media-preview.jsx.map +1 -0
  121. package/dist/components/multi-file-input.d.ts +76 -0
  122. package/dist/components/multi-file-input.d.ts.map +1 -0
  123. package/dist/components/multi-file-input.jsx +131 -0
  124. package/dist/components/multi-file-input.jsx.map +1 -0
  125. package/dist/components/password-input.d.ts +10 -0
  126. package/dist/components/password-input.d.ts.map +1 -0
  127. package/dist/components/password-input.jsx +18 -0
  128. package/dist/components/password-input.jsx.map +1 -0
  129. package/dist/components/popover.d.ts +7 -0
  130. package/dist/components/popover.d.ts.map +1 -0
  131. package/dist/components/popover.jsx +11 -0
  132. package/dist/components/popover.jsx.map +1 -0
  133. package/dist/components/revision-timeline.d.ts +30 -0
  134. package/dist/components/revision-timeline.d.ts.map +1 -0
  135. package/dist/components/revision-timeline.jsx +42 -0
  136. package/dist/components/revision-timeline.jsx.map +1 -0
  137. package/dist/components/richtext-editor.d.ts +43 -0
  138. package/dist/components/richtext-editor.d.ts.map +1 -0
  139. package/dist/components/richtext-editor.jsx +319 -0
  140. package/dist/components/richtext-editor.jsx.map +1 -0
  141. package/dist/components/richtext-mode.d.ts +23 -0
  142. package/dist/components/richtext-mode.d.ts.map +1 -0
  143. package/dist/components/richtext-mode.js +36 -0
  144. package/dist/components/richtext-mode.js.map +1 -0
  145. package/dist/components/richtext-render.d.ts +8 -0
  146. package/dist/components/richtext-render.d.ts.map +1 -0
  147. package/dist/components/richtext-render.jsx +33 -0
  148. package/dist/components/richtext-render.jsx.map +1 -0
  149. package/dist/components/richtext-sync.d.ts +37 -0
  150. package/dist/components/richtext-sync.d.ts.map +1 -0
  151. package/dist/components/richtext-sync.js +46 -0
  152. package/dist/components/richtext-sync.js.map +1 -0
  153. package/dist/components/scroll-area.d.ts +5 -0
  154. package/dist/components/scroll-area.d.ts.map +1 -0
  155. package/dist/components/scroll-area.jsx +16 -0
  156. package/dist/components/scroll-area.jsx.map +1 -0
  157. package/dist/components/select.d.ts +36 -0
  158. package/dist/components/select.d.ts.map +1 -0
  159. package/dist/components/select.jsx +87 -0
  160. package/dist/components/select.jsx.map +1 -0
  161. package/dist/components/separator.d.ts +4 -0
  162. package/dist/components/separator.d.ts.map +1 -0
  163. package/dist/components/separator.jsx +6 -0
  164. package/dist/components/separator.jsx.map +1 -0
  165. package/dist/components/sheet.d.ts +29 -0
  166. package/dist/components/sheet.d.ts.map +1 -0
  167. package/dist/components/sheet.jsx +44 -0
  168. package/dist/components/sheet.jsx.map +1 -0
  169. package/dist/components/sidebar.d.ts +70 -0
  170. package/dist/components/sidebar.d.ts.map +1 -0
  171. package/dist/components/sidebar.jsx +245 -0
  172. package/dist/components/sidebar.jsx.map +1 -0
  173. package/dist/components/skeleton.d.ts +3 -0
  174. package/dist/components/skeleton.d.ts.map +1 -0
  175. package/dist/components/skeleton.jsx +6 -0
  176. package/dist/components/skeleton.jsx.map +1 -0
  177. package/dist/components/sonner.d.ts +6 -0
  178. package/dist/components/sonner.d.ts.map +1 -0
  179. package/dist/components/sonner.jsx +29 -0
  180. package/dist/components/sonner.jsx.map +1 -0
  181. package/dist/components/switch.d.ts +4 -0
  182. package/dist/components/switch.d.ts.map +1 -0
  183. package/dist/components/switch.jsx +8 -0
  184. package/dist/components/switch.jsx.map +1 -0
  185. package/dist/components/table.d.ts +10 -0
  186. package/dist/components/table.d.ts.map +1 -0
  187. package/dist/components/table.jsx +21 -0
  188. package/dist/components/table.jsx.map +1 -0
  189. package/dist/components/tabs.d.ts +7 -0
  190. package/dist/components/tabs.d.ts.map +1 -0
  191. package/dist/components/tabs.jsx +14 -0
  192. package/dist/components/tabs.jsx.map +1 -0
  193. package/dist/components/textarea.d.ts +4 -0
  194. package/dist/components/textarea.d.ts.map +1 -0
  195. package/dist/components/textarea.jsx +5 -0
  196. package/dist/components/textarea.jsx.map +1 -0
  197. package/dist/components/tooltip.d.ts +7 -0
  198. package/dist/components/tooltip.d.ts.map +1 -0
  199. package/dist/components/tooltip.jsx +11 -0
  200. package/dist/components/tooltip.jsx.map +1 -0
  201. package/dist/index.d.ts +52 -0
  202. package/dist/index.d.ts.map +1 -0
  203. package/dist/index.js +72 -0
  204. package/dist/index.js.map +1 -0
  205. package/dist/lib/theme.d.ts +11 -0
  206. package/dist/lib/theme.d.ts.map +1 -0
  207. package/dist/lib/theme.js +44 -0
  208. package/dist/lib/theme.js.map +1 -0
  209. package/dist/lib/utils.d.ts +3 -0
  210. package/dist/lib/utils.d.ts.map +1 -0
  211. package/dist/lib/utils.js +6 -0
  212. package/dist/lib/utils.js.map +1 -0
  213. package/dist/styles.css +242 -0
  214. package/package.json +85 -0
  215. package/src/components/accordion.tsx +48 -0
  216. package/src/components/alert-dialog.tsx +113 -0
  217. package/src/components/audit-timeline.tsx +102 -0
  218. package/src/components/avatar.tsx +42 -0
  219. package/src/components/badge.tsx +34 -0
  220. package/src/components/breadcrumb.tsx +99 -0
  221. package/src/components/button.tsx +58 -0
  222. package/src/components/calendar.tsx +176 -0
  223. package/src/components/card.tsx +60 -0
  224. package/src/components/chart.tsx +558 -0
  225. package/src/components/checkbox.tsx +23 -0
  226. package/src/components/combobox.tsx +264 -0
  227. package/src/components/command.tsx +120 -0
  228. package/src/components/date-picker.tsx +221 -0
  229. package/src/components/date-range-input.tsx +295 -0
  230. package/src/components/dialog.tsx +94 -0
  231. package/src/components/diff-view.tsx +182 -0
  232. package/src/components/dropdown-menu.tsx +165 -0
  233. package/src/components/empty.tsx +100 -0
  234. package/src/components/field.tsx +168 -0
  235. package/src/components/file-input.tsx +233 -0
  236. package/src/components/form.tsx +152 -0
  237. package/src/components/info-tooltip.tsx +40 -0
  238. package/src/components/input.tsx +55 -0
  239. package/src/components/json-editor.tsx +210 -0
  240. package/src/components/kbd.tsx +35 -0
  241. package/src/components/key-value-editor.tsx +423 -0
  242. package/src/components/keyboard-shortcuts-help.tsx +136 -0
  243. package/src/components/label.tsx +16 -0
  244. package/src/components/media-preview.tsx +278 -0
  245. package/src/components/multi-file-input.tsx +315 -0
  246. package/src/components/password-input.tsx +50 -0
  247. package/src/components/popover.tsx +26 -0
  248. package/src/components/revision-timeline.tsx +93 -0
  249. package/src/components/richtext-editor.tsx +624 -0
  250. package/src/components/richtext-mode.ts +39 -0
  251. package/src/components/richtext-render.tsx +51 -0
  252. package/src/components/richtext-sync.ts +57 -0
  253. package/src/components/scroll-area.tsx +41 -0
  254. package/src/components/select.tsx +200 -0
  255. package/src/components/separator.tsx +21 -0
  256. package/src/components/sheet.tsx +109 -0
  257. package/src/components/sidebar.tsx +660 -0
  258. package/src/components/skeleton.tsx +9 -0
  259. package/src/components/sonner.tsx +45 -0
  260. package/src/components/switch.tsx +24 -0
  261. package/src/components/table.tsx +93 -0
  262. package/src/components/tabs.tsx +57 -0
  263. package/src/components/textarea.tsx +18 -0
  264. package/src/components/tooltip.tsx +25 -0
  265. package/src/index.ts +342 -0
  266. package/src/lib/theme.ts +45 -0
  267. package/src/lib/utils.ts +6 -0
  268. package/src/styles.css +242 -0
@@ -0,0 +1,423 @@
1
+ // KeyValueEditor + KeyValueView — a friendly alternative to JsonEditor for
2
+ // JSON columns with a *fixed* set of keys.
3
+ //
4
+ // Instead of showing the raw JSON (`{ "locale": "en", "featured": true }`)
5
+ // the editor renders one row per declared key, each with a normal form
6
+ // input typed appropriately (string, number, boolean, textarea, select).
7
+ // No braces, no quotes, no parse errors — the user just edits the values.
8
+ //
9
+ // The component is i18n-unaware: it accepts an optional `labels` prop with
10
+ // English fallbacks so it works standalone in tests/Storybook. The
11
+ // `packages/react` layer translates and feeds them in.
12
+ //
13
+ // Mobile-first: each row stacks label-above-input on narrow screens and
14
+ // switches to a two-column label/input layout from `sm:` upwards.
15
+
16
+ import * as React from 'react'
17
+ import { cn } from '../lib/utils.js'
18
+ import { Input } from './input.js'
19
+ import { Textarea } from './textarea.js'
20
+ import { Switch } from './switch.js'
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from './select.js'
28
+ import { Combobox, type ComboboxLabels, type ComboboxSuggestion } from './combobox.js'
29
+ import { InfoTooltip } from './info-tooltip.js'
30
+
31
+ /** Built-in editor types. Resource code may pass a string; unknown values
32
+ * fall back to a plain string input. */
33
+ export type KeyValueFieldType =
34
+ | 'string'
35
+ | 'number'
36
+ | 'boolean'
37
+ | 'textarea'
38
+ | 'select'
39
+ | 'autocomplete'
40
+
41
+ /** One declared key inside the JSON object. */
42
+ export interface KeyValueFieldSpec {
43
+ /** JSON key on the underlying object. */
44
+ key: string
45
+ /** Visible label. Defaults to the key. */
46
+ label?: string
47
+ /** Editor kind. Default: `'string'`. */
48
+ type?: KeyValueFieldType
49
+ /** Helper text shown under the input. */
50
+ description?: string
51
+ /** Placeholder for text/number inputs. */
52
+ placeholder?: string
53
+ /** Visual `*` marker; required-ness is enforced by the form layer. */
54
+ isRequired?: boolean
55
+ /**
56
+ * Enum source for `type: 'select'` and static suggestions for
57
+ * `type: 'autocomplete'`. Either a list of strings (used both as value
58
+ * and label) or `{ value, label }` objects.
59
+ */
60
+ availableValues?: ReadonlyArray<string | { value: string; label: string }>
61
+ /**
62
+ * For `type: 'autocomplete'`: pull dynamic suggestions from the named
63
+ * field of records of another resource (e.g. `users.email`). Resolved
64
+ * by the `packages/react` layer before render — KeyValueEditor itself
65
+ * never fetches, the loaded values arrive via `suggestionsByKey`.
66
+ */
67
+ suggestionsResource?: string
68
+ /** Path of the field on `suggestionsResource` to project. */
69
+ suggestionsField?: string
70
+ }
71
+
72
+ /** English-default labels surfaced through `labels` for i18n. */
73
+ export interface KeyValueEditorLabels {
74
+ /** Placeholder shown in the empty `select` slot. Default: '—'. */
75
+ emptyOption?: string
76
+ /** Visually-hidden / fallback label suffix when a row has no `label`. */
77
+ fieldLabelFallback?: (key: string) => string
78
+ /** Forwarded to the inner `Combobox` for autocomplete fields. */
79
+ combobox?: ComboboxLabels
80
+ }
81
+
82
+ export interface KeyValueEditorProps {
83
+ /** Declared key set. Order is preserved on screen. */
84
+ fields: ReadonlyArray<KeyValueFieldSpec>
85
+ /** Current value. Anything that is not a plain object is treated as `{}`. */
86
+ value: unknown
87
+ /** Emits a fresh JSON object on every change. */
88
+ onChange(next: Record<string, unknown>): void
89
+ onBlur?(): void
90
+ disabled?: boolean
91
+ className?: string
92
+ labels?: KeyValueEditorLabels
93
+ /**
94
+ * Pre-loaded suggestions per autocomplete field, keyed by `field.key`.
95
+ * The editor stays i18n-/network-unaware: callers (e.g. the React
96
+ * property renderer) load values from the database and feed them in.
97
+ */
98
+ suggestionsByKey?: Readonly<Record<string, ReadonlyArray<ComboboxSuggestion>>>
99
+ /** Per-key loading flags to render a spinner while suggestions stream in. */
100
+ suggestionsLoadingByKey?: Readonly<Record<string, boolean>>
101
+ }
102
+
103
+ const defaultLabels: Required<Omit<KeyValueEditorLabels, 'combobox'>> &
104
+ Pick<KeyValueEditorLabels, 'combobox'> = {
105
+ emptyOption: '—',
106
+ fieldLabelFallback: (key) => key,
107
+ }
108
+
109
+ const toRecord = (value: unknown): Record<string, unknown> => {
110
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
111
+ return value as Record<string, unknown>
112
+ }
113
+ return {}
114
+ }
115
+
116
+ const normaliseAvailableValues = (
117
+ raw: KeyValueFieldSpec['availableValues'],
118
+ ): Array<{ value: string; label: string }> => {
119
+ if (!raw) return []
120
+ return raw.map((v) => (typeof v === 'string' ? { value: v, label: v } : v))
121
+ }
122
+
123
+ const SENTINEL_EMPTY = '__kv_empty__'
124
+
125
+ export function KeyValueEditor({
126
+ fields,
127
+ value,
128
+ onChange,
129
+ onBlur,
130
+ disabled,
131
+ className,
132
+ labels,
133
+ suggestionsByKey,
134
+ suggestionsLoadingByKey,
135
+ }: KeyValueEditorProps): React.ReactElement {
136
+ const l = { ...defaultLabels, ...labels }
137
+ const obj = toRecord(value)
138
+
139
+ // Replace `key` with `next` and emit a brand-new object so callers using
140
+ // referential equality (e.g. RHF) detect the change.
141
+ const set = (key: string, next: unknown): void => {
142
+ const out: Record<string, unknown> = { ...obj }
143
+ if (next === null || next === undefined) {
144
+ delete out[key]
145
+ } else {
146
+ out[key] = next
147
+ }
148
+ onChange(out)
149
+ }
150
+
151
+ return (
152
+ <div
153
+ className={cn(
154
+ 'divide-y divide-border rounded-md border border-border bg-card',
155
+ className,
156
+ )}
157
+ >
158
+ {fields.map((f) => {
159
+ const fieldType: KeyValueFieldType = f.type ?? 'string'
160
+ const label = f.label ?? l.fieldLabelFallback(f.key)
161
+ const inputId = `kv-${f.key}`
162
+ const raw = obj[f.key]
163
+
164
+ let control: React.ReactElement
165
+ if (fieldType === 'boolean') {
166
+ control = (
167
+ <Switch
168
+ id={inputId}
169
+ checked={Boolean(raw)}
170
+ onCheckedChange={(v) => set(f.key, Boolean(v))}
171
+ disabled={disabled}
172
+ aria-label={label}
173
+ />
174
+ )
175
+ } else if (fieldType === 'number') {
176
+ control = (
177
+ <Input
178
+ id={inputId}
179
+ type="number"
180
+ inputMode="decimal"
181
+ value={raw == null ? '' : String(raw)}
182
+ placeholder={f.placeholder}
183
+ disabled={disabled}
184
+ onBlur={onBlur}
185
+ onChange={(e) => {
186
+ const v = e.target.value
187
+ if (v === '') return set(f.key, null)
188
+ const n = Number(v)
189
+ set(f.key, Number.isFinite(n) ? n : v)
190
+ }}
191
+ aria-label={label}
192
+ />
193
+ )
194
+ } else if (fieldType === 'textarea') {
195
+ control = (
196
+ <Textarea
197
+ id={inputId}
198
+ value={raw == null ? '' : String(raw)}
199
+ placeholder={f.placeholder}
200
+ disabled={disabled}
201
+ onBlur={onBlur}
202
+ onChange={(e) =>
203
+ set(f.key, e.target.value === '' ? null : e.target.value)
204
+ }
205
+ rows={3}
206
+ aria-label={label}
207
+ />
208
+ )
209
+ } else if (fieldType === 'select') {
210
+ const opts = normaliseAvailableValues(f.availableValues)
211
+ const current = raw == null || raw === '' ? SENTINEL_EMPTY : String(raw)
212
+ control = (
213
+ <Select
214
+ value={current}
215
+ onValueChange={(v) => set(f.key, v === SENTINEL_EMPTY ? null : v)}
216
+ disabled={disabled}
217
+ >
218
+ <SelectTrigger id={inputId} aria-label={label}>
219
+ <SelectValue placeholder={l.emptyOption} />
220
+ </SelectTrigger>
221
+ <SelectContent>
222
+ <SelectItem value={SENTINEL_EMPTY}>{l.emptyOption}</SelectItem>
223
+ {opts.map((opt) => (
224
+ <SelectItem key={opt.value} value={opt.value}>
225
+ {opt.label}
226
+ </SelectItem>
227
+ ))}
228
+ </SelectContent>
229
+ </Select>
230
+ )
231
+ } else if (fieldType === 'autocomplete') {
232
+ // Combine static `availableValues` declared on the field with
233
+ // any dynamic values pre-loaded by the parent (e.g. distinct
234
+ // values from a DB column). Dedupe by `value` so a static hint
235
+ // and a DB row that share a value collapse to a single item.
236
+ const fromStatic = normaliseAvailableValues(f.availableValues)
237
+ const fromDynamic = suggestionsByKey?.[f.key] ?? []
238
+ const seen = new Set<string>()
239
+ const merged: ComboboxSuggestion[] = []
240
+ for (const s of [...fromStatic, ...fromDynamic]) {
241
+ const v = typeof s === 'string' ? s : s.value
242
+ if (seen.has(v)) continue
243
+ seen.add(v)
244
+ merged.push(s)
245
+ }
246
+ control = (
247
+ <Combobox
248
+ id={inputId}
249
+ value={raw == null ? '' : String(raw)}
250
+ onChange={(v) => set(f.key, v === '' ? null : v)}
251
+ onBlur={onBlur}
252
+ suggestions={merged}
253
+ loading={suggestionsLoadingByKey?.[f.key]}
254
+ disabled={disabled}
255
+ placeholder={f.placeholder}
256
+ aria-label={label}
257
+ labels={labels?.combobox}
258
+ />
259
+ )
260
+ } else {
261
+ // string (default)
262
+ control = (
263
+ <Input
264
+ id={inputId}
265
+ type="text"
266
+ value={raw == null ? '' : String(raw)}
267
+ placeholder={f.placeholder}
268
+ disabled={disabled}
269
+ onBlur={onBlur}
270
+ onChange={(e) =>
271
+ set(f.key, e.target.value === '' ? null : e.target.value)
272
+ }
273
+ aria-label={label}
274
+ />
275
+ )
276
+ }
277
+
278
+ return (
279
+ <div
280
+ key={f.key}
281
+ className="flex flex-col gap-1.5 p-3 sm:flex-row sm:items-start sm:gap-4"
282
+ >
283
+ <label
284
+ htmlFor={inputId}
285
+ className="flex shrink-0 items-center gap-1 text-sm font-medium text-foreground sm:w-44 sm:pt-2"
286
+ >
287
+ <span className="truncate">{label}</span>
288
+ {f.description ? (
289
+ <InfoTooltip content={f.description} ariaLabel={f.description} />
290
+ ) : null}
291
+ {f.isRequired ? (
292
+ <span aria-hidden="true" className="text-destructive">
293
+ *
294
+ </span>
295
+ ) : null}
296
+ </label>
297
+ <div className="min-w-0 flex-1">
298
+ {control}
299
+ </div>
300
+ </div>
301
+ )
302
+ })}
303
+ </div>
304
+ )
305
+ }
306
+
307
+ // ─── Read-only view ──────────────────────────────────────────────────────────
308
+
309
+ export interface KeyValueViewLabels {
310
+ /** Placeholder for missing values. Default: '—'. */
311
+ emptyValue?: string
312
+ /** Field label fallback when a spec entry has no `label`. */
313
+ fieldLabelFallback?: (key: string) => string
314
+ /** Boolean true label. Default: 'Yes'. */
315
+ trueLabel?: string
316
+ /** Boolean false label. Default: 'No'. */
317
+ falseLabel?: string
318
+ }
319
+
320
+ export interface KeyValueViewProps {
321
+ fields: ReadonlyArray<KeyValueFieldSpec>
322
+ value: unknown
323
+ className?: string
324
+ /**
325
+ * `'inline'` collapses all fields into a single comma-separated row used
326
+ * by the list view. `'block'` (default) renders a vertical key/value
327
+ * table for the show view.
328
+ */
329
+ variant?: 'inline' | 'block'
330
+ labels?: KeyValueViewLabels
331
+ }
332
+
333
+ const defaultViewLabels: Required<KeyValueViewLabels> = {
334
+ emptyValue: '—',
335
+ fieldLabelFallback: (key) => key,
336
+ trueLabel: 'Yes',
337
+ falseLabel: 'No',
338
+ }
339
+
340
+ const stringifyDisplay = (
341
+ raw: unknown,
342
+ field: KeyValueFieldSpec,
343
+ l: Required<KeyValueViewLabels>,
344
+ ): string => {
345
+ if (raw == null || raw === '') return l.emptyValue
346
+ if (field.type === 'boolean') return raw ? l.trueLabel : l.falseLabel
347
+ if (
348
+ (field.type === 'select' || field.type === 'autocomplete') &&
349
+ field.availableValues
350
+ ) {
351
+ const opts = normaliseAvailableValues(field.availableValues)
352
+ const match = opts.find((o) => o.value === String(raw))
353
+ return match?.label ?? String(raw)
354
+ }
355
+ if (typeof raw === 'object') {
356
+ try {
357
+ return JSON.stringify(raw)
358
+ } catch {
359
+ return String(raw)
360
+ }
361
+ }
362
+ return String(raw)
363
+ }
364
+
365
+ export function KeyValueView({
366
+ fields,
367
+ value,
368
+ className,
369
+ variant = 'block',
370
+ labels,
371
+ }: KeyValueViewProps): React.ReactElement {
372
+ const l = { ...defaultViewLabels, ...labels }
373
+ const obj = toRecord(value)
374
+
375
+ if (variant === 'inline') {
376
+ const parts = fields
377
+ .map((f) => {
378
+ const raw = obj[f.key]
379
+ if (raw == null || raw === '') return null
380
+ const label = f.label ?? l.fieldLabelFallback(f.key)
381
+ return `${label}: ${stringifyDisplay(raw, f, l)}`
382
+ })
383
+ .filter((s): s is string => s !== null)
384
+ return (
385
+ <span
386
+ className={cn(
387
+ 'line-clamp-1 max-w-[24rem] truncate text-xs text-muted-foreground',
388
+ className,
389
+ )}
390
+ title={parts.join(', ') || undefined}
391
+ >
392
+ {parts.length > 0 ? parts.join(', ') : l.emptyValue}
393
+ </span>
394
+ )
395
+ }
396
+
397
+ return (
398
+ <dl
399
+ className={cn(
400
+ 'divide-y divide-border rounded-md border border-border bg-muted/30 text-sm',
401
+ className,
402
+ )}
403
+ >
404
+ {fields.map((f) => {
405
+ const label = f.label ?? l.fieldLabelFallback(f.key)
406
+ const text = stringifyDisplay(obj[f.key], f, l)
407
+ return (
408
+ <div
409
+ key={f.key}
410
+ className="flex flex-col gap-0.5 p-3 sm:flex-row sm:items-baseline sm:gap-4"
411
+ >
412
+ <dt className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground sm:w-44">
413
+ {label}
414
+ </dt>
415
+ <dd className="min-w-0 flex-1 break-words text-foreground">
416
+ {text}
417
+ </dd>
418
+ </div>
419
+ )
420
+ })}
421
+ </dl>
422
+ )
423
+ }
@@ -0,0 +1,136 @@
1
+ // Presentational dialog that lists keyboard shortcuts. Pass `items`;
2
+ // each entry's `keys` is a `+`-separated combo string (e.g. `mod+s`)
3
+ // and gets rendered as <Kbd> caps. `mod` resolves to ⌘ on macOS / Ctrl
4
+ // elsewhere via `getModKeyLabel()`. Items can optionally be grouped via
5
+ // `group` and the dialog sorts groups in insertion order.
6
+
7
+ import * as React from 'react'
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from './dialog.js'
15
+ import { Kbd, getModKeyLabel } from './kbd.js'
16
+
17
+ export interface KeyboardShortcutItem {
18
+ /** Combo in `useHotkey` syntax, e.g. `mod+s`, `shift+/`, `esc`. */
19
+ keys: string
20
+ description: string
21
+ group?: string
22
+ }
23
+
24
+ export interface KeyboardShortcutsHelpProps {
25
+ open: boolean
26
+ onOpenChange: (open: boolean) => void
27
+ items: KeyboardShortcutItem[]
28
+ title?: React.ReactNode
29
+ description?: React.ReactNode
30
+ emptyMessage?: React.ReactNode
31
+ }
32
+
33
+ function chordParts(combo: string, modLabel: string): string[] {
34
+ // Pick the first alternative (`a|b`) — the help dialog shows one
35
+ // canonical chord per entry.
36
+ const first = combo.split('|')[0] ?? ''
37
+ return first
38
+ .split('+')
39
+ .map((s) => s.trim().toLowerCase())
40
+ .filter(Boolean)
41
+ .map((p) => {
42
+ if (p === 'mod' || p === 'ctrl' || p === 'meta' || p === 'cmd') return modLabel
43
+ if (p === 'shift') return 'Shift'
44
+ if (p === 'alt' || p === 'option') return 'Alt'
45
+ if (p === 'esc' || p === 'escape') return 'Esc'
46
+ if (p === 'space' || p === ' ' || p === 'spacebar') return 'Space'
47
+ if (p === 'enter' || p === 'return') return 'Enter'
48
+ if (p === 'tab') return 'Tab'
49
+ if (p === 'backspace') return '⌫'
50
+ if (p === 'delete' || p === 'del') return 'Del'
51
+ if (p === 'arrowup') return '↑'
52
+ if (p === 'arrowdown') return '↓'
53
+ if (p === 'arrowleft') return '←'
54
+ if (p === 'arrowright') return '→'
55
+ if (p.length === 1) return p.toUpperCase()
56
+ return p.charAt(0).toUpperCase() + p.slice(1)
57
+ })
58
+ }
59
+
60
+ export function KeyboardShortcutsHelp({
61
+ open,
62
+ onOpenChange,
63
+ items,
64
+ title,
65
+ description,
66
+ emptyMessage,
67
+ }: KeyboardShortcutsHelpProps): React.ReactElement {
68
+ const modLabel = getModKeyLabel()
69
+
70
+ // Group preserving insertion order.
71
+ const groups = React.useMemo(() => {
72
+ const m = new Map<string, KeyboardShortcutItem[]>()
73
+ for (const it of items) {
74
+ const key = it.group ?? ''
75
+ const list = m.get(key)
76
+ if (list) list.push(it)
77
+ else m.set(key, [it])
78
+ }
79
+ return Array.from(m.entries())
80
+ }, [items])
81
+
82
+ return (
83
+ <Dialog open={open} onOpenChange={onOpenChange}>
84
+ {/* aria-describedby={undefined} explicitly suppresses Radix's missing-
85
+ description warning for dialogs where no <DialogDescription> is
86
+ rendered. When `description` is provided the prop is omitted so
87
+ Radix can wire its context-based aria-describedby automatically. */}
88
+ <DialogContent
89
+ className="max-w-md"
90
+ {...(!description ? { 'aria-describedby': undefined } : {})}
91
+ >
92
+ <DialogHeader>
93
+ <DialogTitle>{title ?? 'Keyboard shortcuts'}</DialogTitle>
94
+ {description && <DialogDescription>{description}</DialogDescription>}
95
+ </DialogHeader>
96
+ {items.length === 0 ? (
97
+ <p className="text-sm text-muted-foreground">
98
+ {emptyMessage ?? 'No shortcuts available on this screen.'}
99
+ </p>
100
+ ) : (
101
+ <div className="flex flex-col gap-4">
102
+ {groups.map(([groupName, list]) => (
103
+ <div key={groupName || '__default__'} className="flex flex-col gap-2">
104
+ {groupName && (
105
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
106
+ {groupName}
107
+ </h3>
108
+ )}
109
+ <ul className="flex flex-col divide-y divide-border rounded-md border border-border">
110
+ {list.map((it, idx) => (
111
+ <li
112
+ key={`${it.keys}:${idx}`}
113
+ className="flex items-center justify-between gap-3 px-3 py-2"
114
+ >
115
+ <span className="text-sm">{it.description}</span>
116
+ <span className="inline-flex shrink-0 items-center gap-1">
117
+ {chordParts(it.keys, modLabel).map((part, i, arr) => (
118
+ <React.Fragment key={i}>
119
+ <Kbd>{part}</Kbd>
120
+ {i < arr.length - 1 && (
121
+ <span className="text-muted-foreground">+</span>
122
+ )}
123
+ </React.Fragment>
124
+ ))}
125
+ </span>
126
+ </li>
127
+ ))}
128
+ </ul>
129
+ </div>
130
+ ))}
131
+ </div>
132
+ )}
133
+ </DialogContent>
134
+ </Dialog>
135
+ )
136
+ }
@@ -0,0 +1,16 @@
1
+ import * as React from 'react'
2
+ import * as LabelPrimitive from '@radix-ui/react-label'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '../lib/utils.js'
5
+
6
+ const labelVariants = cva(
7
+ 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
8
+ )
9
+
10
+ export const Label = React.forwardRef<
11
+ React.ElementRef<typeof LabelPrimitive.Root>,
12
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
13
+ >(({ className, ...props }, ref) => (
14
+ <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
15
+ ))
16
+ Label.displayName = LabelPrimitive.Root.displayName