@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.
- package/dist/components/accordion.d.ts +7 -0
- package/dist/components/accordion.d.ts.map +1 -0
- package/dist/components/accordion.jsx +19 -0
- package/dist/components/accordion.jsx.map +1 -0
- package/dist/components/alert-dialog.d.ts +22 -0
- package/dist/components/alert-dialog.d.ts.map +1 -0
- package/dist/components/alert-dialog.jsx +27 -0
- package/dist/components/alert-dialog.jsx.map +1 -0
- package/dist/components/audit-timeline.d.ts +24 -0
- package/dist/components/audit-timeline.d.ts.map +1 -0
- package/dist/components/audit-timeline.jsx +60 -0
- package/dist/components/audit-timeline.jsx.map +1 -0
- package/dist/components/avatar.d.ts +6 -0
- package/dist/components/avatar.d.ts.map +1 -0
- package/dist/components/avatar.jsx +10 -0
- package/dist/components/avatar.jsx.map +1 -0
- package/dist/components/badge.d.ts +10 -0
- package/dist/components/badge.d.ts.map +1 -0
- package/dist/components/badge.jsx +19 -0
- package/dist/components/badge.jsx.map +1 -0
- package/dist/components/breadcrumb.d.ts +17 -0
- package/dist/components/breadcrumb.d.ts.map +1 -0
- package/dist/components/breadcrumb.jsx +27 -0
- package/dist/components/breadcrumb.jsx.map +1 -0
- package/dist/components/button.d.ts +12 -0
- package/dist/components/button.d.ts.map +1 -0
- package/dist/components/button.jsx +37 -0
- package/dist/components/button.jsx.map +1 -0
- package/dist/components/calendar.d.ts +9 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.jsx +102 -0
- package/dist/components/calendar.jsx.map +1 -0
- package/dist/components/card.d.ts +8 -0
- package/dist/components/card.d.ts.map +1 -0
- package/dist/components/card.jsx +18 -0
- package/dist/components/card.jsx.map +1 -0
- package/dist/components/chart.d.ts +97 -0
- package/dist/components/chart.d.ts.map +1 -0
- package/dist/components/chart.jsx +233 -0
- package/dist/components/chart.jsx.map +1 -0
- package/dist/components/checkbox.d.ts +4 -0
- package/dist/components/checkbox.d.ts.map +1 -0
- package/dist/components/checkbox.jsx +11 -0
- package/dist/components/checkbox.jsx.map +1 -0
- package/dist/components/combobox.d.ts +46 -0
- package/dist/components/combobox.d.ts.map +1 -0
- package/dist/components/combobox.jsx +145 -0
- package/dist/components/combobox.jsx.map +1 -0
- package/dist/components/command.d.ts +80 -0
- package/dist/components/command.d.ts.map +1 -0
- package/dist/components/command.jsx +32 -0
- package/dist/components/command.jsx.map +1 -0
- package/dist/components/date-picker.d.ts +24 -0
- package/dist/components/date-picker.d.ts.map +1 -0
- package/dist/components/date-picker.jsx +149 -0
- package/dist/components/date-picker.jsx.map +1 -0
- package/dist/components/date-range-input.d.ts +22 -0
- package/dist/components/date-range-input.d.ts.map +1 -0
- package/dist/components/date-range-input.jsx +202 -0
- package/dist/components/date-range-input.jsx.map +1 -0
- package/dist/components/dialog.d.ts +19 -0
- package/dist/components/dialog.d.ts.map +1 -0
- package/dist/components/dialog.jsx +30 -0
- package/dist/components/dialog.jsx.map +1 -0
- package/dist/components/diff-view.d.ts +24 -0
- package/dist/components/diff-view.d.ts.map +1 -0
- package/dist/components/diff-view.jsx +69 -0
- package/dist/components/diff-view.jsx.map +1 -0
- package/dist/components/dropdown-menu.d.ts +27 -0
- package/dist/components/dropdown-menu.d.ts.map +1 -0
- package/dist/components/dropdown-menu.jsx +48 -0
- package/dist/components/dropdown-menu.jsx.map +1 -0
- package/dist/components/empty.d.ts +15 -0
- package/dist/components/empty.d.ts.map +1 -0
- package/dist/components/empty.jsx +27 -0
- package/dist/components/empty.jsx.map +1 -0
- package/dist/components/field.d.ts +23 -0
- package/dist/components/field.d.ts.map +1 -0
- package/dist/components/field.jsx +60 -0
- package/dist/components/field.jsx.map +1 -0
- package/dist/components/file-input.d.ts +50 -0
- package/dist/components/file-input.d.ts.map +1 -0
- package/dist/components/file-input.jsx +104 -0
- package/dist/components/file-input.jsx.map +1 -0
- package/dist/components/form.d.ts +20 -0
- package/dist/components/form.d.ts.map +1 -0
- package/dist/components/form.jsx +66 -0
- package/dist/components/form.jsx.map +1 -0
- package/dist/components/info-tooltip.d.ts +11 -0
- package/dist/components/info-tooltip.d.ts.map +1 -0
- package/dist/components/info-tooltip.jsx +17 -0
- package/dist/components/info-tooltip.jsx.map +1 -0
- package/dist/components/input.d.ts +13 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.jsx +19 -0
- package/dist/components/input.jsx.map +1 -0
- package/dist/components/json-editor.d.ts +23 -0
- package/dist/components/json-editor.d.ts.map +1 -0
- package/dist/components/json-editor.jsx +143 -0
- package/dist/components/json-editor.jsx.map +1 -0
- package/dist/components/kbd.d.ts +15 -0
- package/dist/components/kbd.d.ts.map +1 -0
- package/dist/components/kbd.jsx +23 -0
- package/dist/components/kbd.jsx.map +1 -0
- package/dist/components/key-value-editor.d.ts +92 -0
- package/dist/components/key-value-editor.d.ts.map +1 -0
- package/dist/components/key-value-editor.jsx +187 -0
- package/dist/components/key-value-editor.jsx.map +1 -0
- package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
- package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
- package/dist/components/keyboard-shortcuts-help.jsx +97 -0
- package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
- package/dist/components/label.d.ts +5 -0
- package/dist/components/label.d.ts.map +1 -0
- package/dist/components/label.jsx +8 -0
- package/dist/components/label.jsx.map +1 -0
- package/dist/components/media-preview.d.ts +30 -0
- package/dist/components/media-preview.d.ts.map +1 -0
- package/dist/components/media-preview.jsx +189 -0
- package/dist/components/media-preview.jsx.map +1 -0
- package/dist/components/multi-file-input.d.ts +76 -0
- package/dist/components/multi-file-input.d.ts.map +1 -0
- package/dist/components/multi-file-input.jsx +131 -0
- package/dist/components/multi-file-input.jsx.map +1 -0
- package/dist/components/password-input.d.ts +10 -0
- package/dist/components/password-input.d.ts.map +1 -0
- package/dist/components/password-input.jsx +18 -0
- package/dist/components/password-input.jsx.map +1 -0
- package/dist/components/popover.d.ts +7 -0
- package/dist/components/popover.d.ts.map +1 -0
- package/dist/components/popover.jsx +11 -0
- package/dist/components/popover.jsx.map +1 -0
- package/dist/components/revision-timeline.d.ts +30 -0
- package/dist/components/revision-timeline.d.ts.map +1 -0
- package/dist/components/revision-timeline.jsx +42 -0
- package/dist/components/revision-timeline.jsx.map +1 -0
- package/dist/components/richtext-editor.d.ts +43 -0
- package/dist/components/richtext-editor.d.ts.map +1 -0
- package/dist/components/richtext-editor.jsx +319 -0
- package/dist/components/richtext-editor.jsx.map +1 -0
- package/dist/components/richtext-mode.d.ts +23 -0
- package/dist/components/richtext-mode.d.ts.map +1 -0
- package/dist/components/richtext-mode.js +36 -0
- package/dist/components/richtext-mode.js.map +1 -0
- package/dist/components/richtext-render.d.ts +8 -0
- package/dist/components/richtext-render.d.ts.map +1 -0
- package/dist/components/richtext-render.jsx +33 -0
- package/dist/components/richtext-render.jsx.map +1 -0
- package/dist/components/richtext-sync.d.ts +37 -0
- package/dist/components/richtext-sync.d.ts.map +1 -0
- package/dist/components/richtext-sync.js +46 -0
- package/dist/components/richtext-sync.js.map +1 -0
- package/dist/components/scroll-area.d.ts +5 -0
- package/dist/components/scroll-area.d.ts.map +1 -0
- package/dist/components/scroll-area.jsx +16 -0
- package/dist/components/scroll-area.jsx.map +1 -0
- package/dist/components/select.d.ts +36 -0
- package/dist/components/select.d.ts.map +1 -0
- package/dist/components/select.jsx +87 -0
- package/dist/components/select.jsx.map +1 -0
- package/dist/components/separator.d.ts +4 -0
- package/dist/components/separator.d.ts.map +1 -0
- package/dist/components/separator.jsx +6 -0
- package/dist/components/separator.jsx.map +1 -0
- package/dist/components/sheet.d.ts +29 -0
- package/dist/components/sheet.d.ts.map +1 -0
- package/dist/components/sheet.jsx +44 -0
- package/dist/components/sheet.jsx.map +1 -0
- package/dist/components/sidebar.d.ts +70 -0
- package/dist/components/sidebar.d.ts.map +1 -0
- package/dist/components/sidebar.jsx +245 -0
- package/dist/components/sidebar.jsx.map +1 -0
- package/dist/components/skeleton.d.ts +3 -0
- package/dist/components/skeleton.d.ts.map +1 -0
- package/dist/components/skeleton.jsx +6 -0
- package/dist/components/skeleton.jsx.map +1 -0
- package/dist/components/sonner.d.ts +6 -0
- package/dist/components/sonner.d.ts.map +1 -0
- package/dist/components/sonner.jsx +29 -0
- package/dist/components/sonner.jsx.map +1 -0
- package/dist/components/switch.d.ts +4 -0
- package/dist/components/switch.d.ts.map +1 -0
- package/dist/components/switch.jsx +8 -0
- package/dist/components/switch.jsx.map +1 -0
- package/dist/components/table.d.ts +10 -0
- package/dist/components/table.d.ts.map +1 -0
- package/dist/components/table.jsx +21 -0
- package/dist/components/table.jsx.map +1 -0
- package/dist/components/tabs.d.ts +7 -0
- package/dist/components/tabs.d.ts.map +1 -0
- package/dist/components/tabs.jsx +14 -0
- package/dist/components/tabs.jsx.map +1 -0
- package/dist/components/textarea.d.ts +4 -0
- package/dist/components/textarea.d.ts.map +1 -0
- package/dist/components/textarea.jsx +5 -0
- package/dist/components/textarea.jsx.map +1 -0
- package/dist/components/tooltip.d.ts +7 -0
- package/dist/components/tooltip.d.ts.map +1 -0
- package/dist/components/tooltip.jsx +11 -0
- package/dist/components/tooltip.jsx.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/theme.d.ts +11 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +44 -0
- package/dist/lib/theme.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/styles.css +242 -0
- package/package.json +85 -0
- package/src/components/accordion.tsx +48 -0
- package/src/components/alert-dialog.tsx +113 -0
- package/src/components/audit-timeline.tsx +102 -0
- package/src/components/avatar.tsx +42 -0
- package/src/components/badge.tsx +34 -0
- package/src/components/breadcrumb.tsx +99 -0
- package/src/components/button.tsx +58 -0
- package/src/components/calendar.tsx +176 -0
- package/src/components/card.tsx +60 -0
- package/src/components/chart.tsx +558 -0
- package/src/components/checkbox.tsx +23 -0
- package/src/components/combobox.tsx +264 -0
- package/src/components/command.tsx +120 -0
- package/src/components/date-picker.tsx +221 -0
- package/src/components/date-range-input.tsx +295 -0
- package/src/components/dialog.tsx +94 -0
- package/src/components/diff-view.tsx +182 -0
- package/src/components/dropdown-menu.tsx +165 -0
- package/src/components/empty.tsx +100 -0
- package/src/components/field.tsx +168 -0
- package/src/components/file-input.tsx +233 -0
- package/src/components/form.tsx +152 -0
- package/src/components/info-tooltip.tsx +40 -0
- package/src/components/input.tsx +55 -0
- package/src/components/json-editor.tsx +210 -0
- package/src/components/kbd.tsx +35 -0
- package/src/components/key-value-editor.tsx +423 -0
- package/src/components/keyboard-shortcuts-help.tsx +136 -0
- package/src/components/label.tsx +16 -0
- package/src/components/media-preview.tsx +278 -0
- package/src/components/multi-file-input.tsx +315 -0
- package/src/components/password-input.tsx +50 -0
- package/src/components/popover.tsx +26 -0
- package/src/components/revision-timeline.tsx +93 -0
- package/src/components/richtext-editor.tsx +624 -0
- package/src/components/richtext-mode.ts +39 -0
- package/src/components/richtext-render.tsx +51 -0
- package/src/components/richtext-sync.ts +57 -0
- package/src/components/scroll-area.tsx +41 -0
- package/src/components/select.tsx +200 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +109 -0
- package/src/components/sidebar.tsx +660 -0
- package/src/components/skeleton.tsx +9 -0
- package/src/components/sonner.tsx +45 -0
- package/src/components/switch.tsx +24 -0
- package/src/components/table.tsx +93 -0
- package/src/components/tabs.tsx +57 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/tooltip.tsx +25 -0
- package/src/index.ts +342 -0
- package/src/lib/theme.ts +45 -0
- package/src/lib/utils.ts +6 -0
- 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
|