@gentleduck/registry-ui 0.2.1

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 (175) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/index.css +3 -0
  3. package/package.json +59 -0
  4. package/src/_old/_table/index.ts +5 -0
  5. package/src/_old/_table/table-advanced.constants.tsx +24 -0
  6. package/src/_old/_table/table-advanced.tsx +311 -0
  7. package/src/_old/_table/table-advanced.types.ts +272 -0
  8. package/src/_old/_table/table.constants.ts +2 -0
  9. package/src/_old/_table/table.hook.tsx +115 -0
  10. package/src/_old/_table/table.lib.ts +85 -0
  11. package/src/_old/_table/table.tsx +916 -0
  12. package/src/_old/_table/table.types.ts +118 -0
  13. package/src/_old/_table/todo.md +11 -0
  14. package/src/_old/_upload/index.ts +9 -0
  15. package/src/_old/_upload/todo.md +38 -0
  16. package/src/_old/_upload/upload-advanced-chunks.tsx +1624 -0
  17. package/src/_old/_upload/upload-advanced.tsx +507 -0
  18. package/src/_old/_upload/upload-sonner.tsx +58 -0
  19. package/src/_old/_upload/upload.assets.tsx +239 -0
  20. package/src/_old/_upload/upload.constants.tsx +75 -0
  21. package/src/_old/_upload/upload.dto.ts +19 -0
  22. package/src/_old/_upload/upload.lib.tsx +630 -0
  23. package/src/_old/_upload/upload.tsx +491 -0
  24. package/src/_old/_upload/upload.types.ts +436 -0
  25. package/src/accordion/accordion.tsx +247 -0
  26. package/src/accordion/index.ts +1 -0
  27. package/src/alert/alert.constants.ts +17 -0
  28. package/src/alert/alert.tsx +52 -0
  29. package/src/alert/index.ts +2 -0
  30. package/src/alert-dialog/alert-dialog.tsx +107 -0
  31. package/src/alert-dialog/index.ts +1 -0
  32. package/src/aspect-ratio/aspect-ratio.tsx +33 -0
  33. package/src/aspect-ratio/index.ts +1 -0
  34. package/src/audio/audio-record.tsx +776 -0
  35. package/src/audio/audio-visualizer.tsx +377 -0
  36. package/src/audio/audio.libs.ts +5 -0
  37. package/src/audio/audio.types.ts +50 -0
  38. package/src/audio/index.ts +2 -0
  39. package/src/avatar/avatar.tsx +78 -0
  40. package/src/avatar/index.ts +1 -0
  41. package/src/badge/badge.constants.ts +38 -0
  42. package/src/badge/badge.tsx +19 -0
  43. package/src/badge/index.ts +2 -0
  44. package/src/breadcrumb/breadcrumb.tsx +119 -0
  45. package/src/breadcrumb/index.ts +1 -0
  46. package/src/button/button.constants.ts +44 -0
  47. package/src/button/button.tsx +79 -0
  48. package/src/button/button.types.ts +38 -0
  49. package/src/button/index.ts +3 -0
  50. package/src/button-group/button-group.constants.ts +26 -0
  51. package/src/button-group/button-group.tsx +65 -0
  52. package/src/button-group/index.ts +2 -0
  53. package/src/calendar/calendar.tsx +191 -0
  54. package/src/calendar/index.ts +1 -0
  55. package/src/card/card.tsx +81 -0
  56. package/src/card/index.ts +1 -0
  57. package/src/carousel/carousel.tsx +211 -0
  58. package/src/carousel/carousel.types.ts +23 -0
  59. package/src/carousel/index.ts +2 -0
  60. package/src/chart/chart.libs.ts +27 -0
  61. package/src/chart/chart.tsx +260 -0
  62. package/src/chart/chart.types.ts +38 -0
  63. package/src/chart/index.ts +3 -0
  64. package/src/checkbox/checkbox.tsx +144 -0
  65. package/src/checkbox/checkbox.types.ts +24 -0
  66. package/src/checkbox/index.ts +2 -0
  67. package/src/collapsible/collapsible.tsx +151 -0
  68. package/src/collapsible/index.ts +1 -0
  69. package/src/combobox/combobox.tsx +132 -0
  70. package/src/combobox/index.ts +1 -0
  71. package/src/command/command.tsx +192 -0
  72. package/src/command/command.types.ts +11 -0
  73. package/src/command/index.ts +2 -0
  74. package/src/context-menu/context-menu.tsx +178 -0
  75. package/src/context-menu/index.ts +1 -0
  76. package/src/dialog/dialog-responsive.tsx +137 -0
  77. package/src/dialog/dialog.tsx +97 -0
  78. package/src/dialog/index.ts +2 -0
  79. package/src/direction/direction.tsx +13 -0
  80. package/src/direction/index.ts +1 -0
  81. package/src/drawer/drawer.tsx +185 -0
  82. package/src/drawer/index.ts +1 -0
  83. package/src/dropdown-menu/dropdown-menu.tsx +181 -0
  84. package/src/dropdown-menu/index.ts +1 -0
  85. package/src/empty/empty.constants.ts +15 -0
  86. package/src/empty/empty.tsx +73 -0
  87. package/src/empty/index.ts +2 -0
  88. package/src/field/field.constants.ts +22 -0
  89. package/src/field/field.tsx +203 -0
  90. package/src/field/index.ts +2 -0
  91. package/src/hover-card/hover-card.tsx +79 -0
  92. package/src/hover-card/index.ts +1 -0
  93. package/src/input/index.ts +1 -0
  94. package/src/input/input.tsx +45 -0
  95. package/src/input-group/index.ts +1 -0
  96. package/src/input-group/input-group.tsx +170 -0
  97. package/src/input-otp/index.ts +1 -0
  98. package/src/input-otp/input-otp.tsx +66 -0
  99. package/src/item/index.ts +2 -0
  100. package/src/item/item.constants.ts +22 -0
  101. package/src/item/item.tsx +185 -0
  102. package/src/json-editor/index.ts +4 -0
  103. package/src/json-editor/json-editor.hooks.ts +21 -0
  104. package/src/json-editor/json-editor.libs.ts +34 -0
  105. package/src/json-editor/json-editor.tsx +425 -0
  106. package/src/json-editor/json-editor.types.ts +80 -0
  107. package/src/json-editor/json-editor.view.tsx +110 -0
  108. package/src/json-editor/json-text-area.tsx +7 -0
  109. package/src/kbd/index.ts +1 -0
  110. package/src/kbd/kbd.tsx +39 -0
  111. package/src/label/index.ts +1 -0
  112. package/src/label/label.tsx +28 -0
  113. package/src/menubar/index.ts +1 -0
  114. package/src/menubar/menubar.tsx +213 -0
  115. package/src/navigation-menu/index.ts +1 -0
  116. package/src/navigation-menu/navigation-menu.tsx +152 -0
  117. package/src/pagination/index.ts +2 -0
  118. package/src/pagination/pagination.tsx +191 -0
  119. package/src/pagination/pagination.types.ts +17 -0
  120. package/src/popover/index.ts +1 -0
  121. package/src/popover/popover.tsx +35 -0
  122. package/src/preview-panel/index.ts +3 -0
  123. package/src/preview-panel/preview-panel-dialog.tsx +99 -0
  124. package/src/preview-panel/preview-panel.tsx +389 -0
  125. package/src/preview-panel/preview-panel.types.ts +49 -0
  126. package/src/progress/index.ts +1 -0
  127. package/src/progress/progress.tsx +32 -0
  128. package/src/radio-group/index.ts +1 -0
  129. package/src/radio-group/radio-group.tsx +92 -0
  130. package/src/resizable/index.ts +1 -0
  131. package/src/resizable/resizable.tsx +52 -0
  132. package/src/scroll-area/index.ts +1 -0
  133. package/src/scroll-area/scroll-area.tsx +30 -0
  134. package/src/select/index.ts +1 -0
  135. package/src/select/select.tsx +138 -0
  136. package/src/separator/index.ts +1 -0
  137. package/src/separator/separator.tsx +28 -0
  138. package/src/sheet/index.ts +2 -0
  139. package/src/sheet/sheet.constants.tsx +20 -0
  140. package/src/sheet/sheet.tsx +92 -0
  141. package/src/sidebar/index.ts +4 -0
  142. package/src/sidebar/sidebar.constants.ts +30 -0
  143. package/src/sidebar/sidebar.hooks.ts +13 -0
  144. package/src/sidebar/sidebar.tsx +676 -0
  145. package/src/sidebar/sidebar.types.ts +28 -0
  146. package/src/skeleton/index.ts +1 -0
  147. package/src/skeleton/skeleton.tsx +22 -0
  148. package/src/slider/index.ts +1 -0
  149. package/src/slider/slider.tsx +57 -0
  150. package/src/sonner/index.ts +4 -0
  151. package/src/sonner/sonner.chunks.tsx +80 -0
  152. package/src/sonner/sonner.libs.ts +13 -0
  153. package/src/sonner/sonner.tsx +31 -0
  154. package/src/sonner/sonner.types.ts +9 -0
  155. package/src/switch/index.ts +1 -0
  156. package/src/switch/switch.tsx +63 -0
  157. package/src/table/index.ts +1 -0
  158. package/src/table/table.tsx +95 -0
  159. package/src/tabs/index.ts +1 -0
  160. package/src/tabs/tabs.tsx +151 -0
  161. package/src/textarea/index.ts +1 -0
  162. package/src/textarea/textarea.tsx +24 -0
  163. package/src/toggle/index.ts +2 -0
  164. package/src/toggle/toggle.constants.ts +22 -0
  165. package/src/toggle/toggle.tsx +24 -0
  166. package/src/toggle-group/index.ts +1 -0
  167. package/src/toggle-group/toggle-group.tsx +69 -0
  168. package/src/tooltip/index.ts +1 -0
  169. package/src/tooltip/tooltip.tsx +32 -0
  170. package/src/upload/index.ts +1 -0
  171. package/src/upload/upload.constants.tsx +19 -0
  172. package/src/upload/upload.libs.ts +97 -0
  173. package/src/upload/upload.tsx +340 -0
  174. package/src/upload/upload.types.ts +44 -0
  175. package/tsconfig.json +25 -0
@@ -0,0 +1,425 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@gentleduck/libs/cn'
4
+ import { Portal } from '@gentleduck/primitives/portal'
5
+ import { Maximize } from 'lucide-react'
6
+ import * as React from 'react'
7
+ import type { FieldValues } from 'react-hook-form'
8
+ import { useController } from 'react-hook-form'
9
+ import { toast } from 'sonner'
10
+ import {
11
+ AlertDialog,
12
+ AlertDialogAction,
13
+ AlertDialogCancel,
14
+ AlertDialogContent,
15
+ AlertDialogDescription,
16
+ AlertDialogFooter,
17
+ AlertDialogHeader,
18
+ AlertDialogTitle,
19
+ } from '../alert-dialog'
20
+ import { Button } from '../button'
21
+ import { Field, FieldDescription, FieldError, FieldLabel } from '../field'
22
+ import { Popover, PopoverContent, PopoverTrigger } from '../popover'
23
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../sheet'
24
+ import { useJsonEditorHotkeys } from './json-editor.hooks'
25
+ import { formatJson, isObjectLike, safeStringify, tryParseJson } from './json-editor.libs'
26
+ import type { JsonEditorText, JsonTextareaFieldProps } from './json-editor.types'
27
+ import { JsonEditorView } from './json-editor.view'
28
+
29
+ export function JsonTextareaField<TFieldValues extends FieldValues>(
30
+ props: JsonTextareaFieldProps<TFieldValues>,
31
+ ): React.JSX.Element {
32
+ const {
33
+ control,
34
+ name,
35
+ label,
36
+ description,
37
+ className,
38
+ actionsClassName,
39
+ isEditable = true,
40
+ allowArray = true,
41
+ mode = 'inline',
42
+ rows = 12,
43
+ placeholder = '{\n "theme": "dark"\n}',
44
+ lineNumbers = true,
45
+ lineHeightPx = 20,
46
+ dir,
47
+ lang,
48
+ expandMode = 'sheet',
49
+ sheetSide = 'right',
50
+ sheetTitle = 'Edit JSON',
51
+ text: textProp,
52
+ onExpandEditor,
53
+ } = props
54
+
55
+ const t: Required<JsonEditorText> = {
56
+ format: textProp?.format ?? 'Format',
57
+ cancel: textProp?.cancel ?? 'Cancel',
58
+ save: textProp?.save ?? 'Save',
59
+ full: textProp?.full ?? 'Full',
60
+ close: textProp?.close ?? 'Close',
61
+ keepEditing: textProp?.keepEditing ?? 'Keep editing',
62
+ discard: textProp?.discard ?? 'Discard',
63
+ discardTitle: textProp?.discardTitle ?? 'Discard changes?',
64
+ discardDescription:
65
+ textProp?.discardDescription ?? 'You have unsaved changes in the editor. If you close now, they will be lost.',
66
+ statusHint: textProp?.statusHint ?? 'Ctrl/Cmd + Enter: Save, Esc: Cancel',
67
+ sheetStatusHint: textProp?.sheetStatusHint ?? 'Ctrl/Cmd + Enter: Save, Esc: Close',
68
+ unsavedChanges: textProp?.unsavedChanges ?? 'Unsaved changes',
69
+ saved: textProp?.saved ?? 'Saved',
70
+ nullPreview: textProp?.nullPreview ?? 'NULL',
71
+ }
72
+
73
+ const { field, fieldState } = useController({ control, name })
74
+ const committedText = React.useMemo(() => safeStringify(field.value), [field.value])
75
+
76
+ const [draft, setDraft] = React.useState(committedText)
77
+ const [dirty, setDirty] = React.useState(false)
78
+ const [scrollTop, setScrollTop] = React.useState(0)
79
+
80
+ React.useEffect(() => {
81
+ if (dirty) return
82
+ setDraft(committedText)
83
+ }, [committedText, dirty])
84
+
85
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
86
+
87
+ React.useEffect(() => {
88
+ if (mode !== 'popover') {
89
+ setPopoverOpen(false)
90
+ }
91
+ }, [mode])
92
+
93
+ const [sheetOpen, setSheetOpen] = React.useState(false)
94
+ const [sheetDraft, setSheetDraft] = React.useState('')
95
+ const [sheetDirty, setSheetDirty] = React.useState(false)
96
+ const [sheetScrollTop, setSheetScrollTop] = React.useState(0)
97
+ const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false)
98
+
99
+ const validateAndCommitValue = React.useCallback(
100
+ (text: string): boolean => {
101
+ if (!isEditable) return false
102
+
103
+ const parsed = tryParseJson(text)
104
+ if (!parsed.ok) {
105
+ toast.error(parsed.message || 'Please enter valid JSON')
106
+ return false
107
+ }
108
+
109
+ if (parsed.value === null) {
110
+ field.onChange(null)
111
+ return true
112
+ }
113
+
114
+ if (!allowArray && Array.isArray(parsed.value)) {
115
+ toast.error('Value must be a JSON object (arrays are not allowed).')
116
+ return false
117
+ }
118
+
119
+ if (!allowArray && !isObjectLike(parsed.value)) {
120
+ toast.error('Value must be a JSON object.')
121
+ return false
122
+ }
123
+
124
+ field.onChange(parsed.value)
125
+ return true
126
+ },
127
+ [allowArray, field, isEditable],
128
+ )
129
+
130
+ const cancelInline = React.useCallback(() => {
131
+ setDraft(committedText)
132
+ setDirty(false)
133
+ if (mode === 'popover') {
134
+ setPopoverOpen(false)
135
+ }
136
+ }, [committedText, mode])
137
+
138
+ const saveInline = React.useCallback(() => {
139
+ if (!validateAndCommitValue(draft)) return
140
+ setDirty(false)
141
+ if (mode === 'popover') {
142
+ setPopoverOpen(false)
143
+ }
144
+ }, [draft, mode, validateAndCommitValue])
145
+
146
+ const formatInline = React.useCallback(() => {
147
+ if (!isEditable) return
148
+
149
+ const result = formatJson(draft)
150
+ if (!result.ok) {
151
+ toast.error(result.message || 'Please enter valid JSON to format')
152
+ return
153
+ }
154
+
155
+ setDraft(result.formatted)
156
+ setDirty(true)
157
+ }, [draft, isEditable])
158
+
159
+ const inlineHotkeys = useJsonEditorHotkeys({
160
+ enabled: mode === 'inline' || popoverOpen,
161
+ onEscape: cancelInline,
162
+ onSave: saveInline,
163
+ })
164
+
165
+ const openSheet = React.useCallback(() => {
166
+ setSheetDraft(draft)
167
+ setSheetDirty(false)
168
+ setSheetScrollTop(0)
169
+ setSheetOpen(true)
170
+ }, [draft])
171
+
172
+ const requestCloseSheet = React.useCallback(() => {
173
+ if (!sheetDirty) {
174
+ setSheetOpen(false)
175
+ return
176
+ }
177
+
178
+ setConfirmDiscardOpen(true)
179
+ }, [sheetDirty])
180
+
181
+ const discardSheetChanges = React.useCallback(() => {
182
+ setConfirmDiscardOpen(false)
183
+ setSheetDirty(false)
184
+ setSheetOpen(false)
185
+ }, [])
186
+
187
+ const saveSheet = React.useCallback(() => {
188
+ if (!validateAndCommitValue(sheetDraft)) return
189
+
190
+ setDraft(sheetDraft)
191
+ setDirty(false)
192
+ setSheetDirty(false)
193
+ setSheetOpen(false)
194
+ }, [sheetDraft, validateAndCommitValue])
195
+
196
+ const formatSheet = React.useCallback(() => {
197
+ if (!isEditable) return
198
+
199
+ const result = formatJson(sheetDraft)
200
+ if (!result.ok) {
201
+ toast.error(result.message || 'Please enter valid JSON to format')
202
+ return
203
+ }
204
+
205
+ setSheetDraft(result.formatted)
206
+ setSheetDirty(true)
207
+ }, [isEditable, sheetDraft])
208
+
209
+ const sheetHotkeys = useJsonEditorHotkeys({
210
+ enabled: sheetOpen,
211
+ onEscape: requestCloseSheet,
212
+ onSave: saveSheet,
213
+ })
214
+
215
+ const handleExpand = React.useCallback(() => {
216
+ if (expandMode === 'none') return
217
+
218
+ if (expandMode === 'callback') {
219
+ const parsed = tryParseJson(draft)
220
+ onExpandEditor?.({
221
+ name,
222
+ rawText: draft,
223
+ value: parsed.ok ? parsed.value : field.value,
224
+ })
225
+ return
226
+ }
227
+
228
+ openSheet()
229
+ }, [draft, expandMode, field.value, name, onExpandEditor, openSheet])
230
+
231
+ const canFormatDraft = React.useMemo(() => {
232
+ const parsed = tryParseJson(draft)
233
+ return parsed.ok && parsed.value !== null
234
+ }, [draft])
235
+
236
+ const canFormatSheet = React.useMemo(() => {
237
+ const parsed = tryParseJson(sheetDraft)
238
+ return parsed.ok && parsed.value !== null
239
+ }, [sheetDraft])
240
+
241
+ const preview = React.useMemo(() => {
242
+ if (!committedText.trim()) return t.nullPreview
243
+
244
+ const oneLine = committedText.replace(/\s+/g, ' ').trim()
245
+ return oneLine.length > 120 ? `${oneLine.slice(0, 117)}...` : oneLine
246
+ }, [committedText])
247
+
248
+ const inlineEditor = (
249
+ <div className="space-y-2" data-slot="json-editor-inline">
250
+ <JsonEditorView
251
+ dir={dir}
252
+ lang={lang}
253
+ lineHeightPx={lineHeightPx}
254
+ lineNumbers={lineNumbers}
255
+ onChange={(value) => {
256
+ setDraft(value)
257
+ setDirty(true)
258
+ }}
259
+ onKeyDown={inlineHotkeys}
260
+ onScroll={setScrollTop}
261
+ placeholder={placeholder}
262
+ readOnly={!isEditable}
263
+ rows={rows}
264
+ scrollTop={scrollTop}
265
+ value={draft}
266
+ />
267
+
268
+ <div
269
+ className="flex items-center justify-between gap-2 text-muted-foreground text-xs"
270
+ data-slot="json-editor-status">
271
+ <span>{t.statusHint}</span>
272
+ {dirty ? <span className="text-foreground">{t.unsavedChanges}</span> : <span>{t.saved}</span>}
273
+ </div>
274
+ </div>
275
+ )
276
+
277
+ return (
278
+ <Field className={cn('space-y-3', className)} data-slot="json-editor-field" dir={dir}>
279
+ <div className="mb-1 flex items-start justify-between gap-4" data-slot="json-editor-header">
280
+ <div className="space-y-1">
281
+ <FieldLabel className="font-semibold text-base">{label}</FieldLabel>
282
+ {description ? <FieldDescription>{description}</FieldDescription> : null}
283
+ </div>
284
+
285
+ <div className={cn('flex items-center gap-2', actionsClassName)} data-slot="json-editor-actions">
286
+ <Button
287
+ disabled={!isEditable || !canFormatDraft}
288
+ onClick={formatInline}
289
+ size="sm"
290
+ type="button"
291
+ variant="outline">
292
+ {t.format}
293
+ </Button>
294
+
295
+ {dirty ? (
296
+ <>
297
+ <Button onClick={cancelInline} size="sm" type="button" variant="outline">
298
+ {t.cancel}
299
+ </Button>
300
+ <Button disabled={!isEditable} onClick={saveInline} size="sm" type="button">
301
+ {t.save}
302
+ </Button>
303
+ </>
304
+ ) : null}
305
+
306
+ {expandMode !== 'none' ? (
307
+ <Button onClick={handleExpand} size="sm" type="button" variant="outline">
308
+ <Maximize aria-hidden="true" size={14} />
309
+ <span className="ms-2">{t.full}</span>
310
+ </Button>
311
+ ) : null}
312
+ </div>
313
+ </div>
314
+
315
+ <div>
316
+ {mode === 'inline' ? (
317
+ inlineEditor
318
+ ) : (
319
+ <div data-slot="json-editor-popover">
320
+ <Popover onOpenChange={setPopoverOpen} open={popoverOpen}>
321
+ <PopoverTrigger asChild>
322
+ <Button
323
+ className="w-full justify-start overflow-hidden text-start font-mono text-xs"
324
+ type="button"
325
+ variant="outline">
326
+ <span className="truncate">{preview}</span>
327
+ </Button>
328
+ </PopoverTrigger>
329
+ <PopoverContent className="w-[min(96vw,720px)] p-2">{inlineEditor}</PopoverContent>
330
+ </Popover>
331
+ </div>
332
+ )}
333
+ </div>
334
+
335
+ {fieldState.error ? <FieldError errors={[fieldState.error]} /> : null}
336
+
337
+ {expandMode === 'sheet' ? (
338
+ <>
339
+ <Sheet
340
+ onOpenChange={(nextOpen) => {
341
+ if (nextOpen) {
342
+ openSheet()
343
+ return
344
+ }
345
+
346
+ requestCloseSheet()
347
+ }}
348
+ open={sheetOpen}>
349
+ <SheetContent className="w-full sm:max-w-3xl" dir={dir} side={sheetSide}>
350
+ <SheetHeader>
351
+ <SheetTitle>{sheetTitle}</SheetTitle>
352
+ </SheetHeader>
353
+
354
+ <div className="mt-4 space-y-3" data-slot="json-editor-sheet-content">
355
+ <JsonEditorView
356
+ dir={dir}
357
+ lang={lang}
358
+ lineHeightPx={lineHeightPx}
359
+ lineNumbers={lineNumbers}
360
+ onChange={(value) => {
361
+ setSheetDraft(value)
362
+ setSheetDirty(true)
363
+ }}
364
+ onKeyDown={sheetHotkeys}
365
+ onScroll={setSheetScrollTop}
366
+ placeholder={placeholder}
367
+ readOnly={!isEditable}
368
+ rows={24}
369
+ scrollTop={sheetScrollTop}
370
+ value={sheetDraft}
371
+ />
372
+
373
+ <div className="flex items-center justify-between gap-2" data-slot="json-editor-sheet-actions">
374
+ <div className="text-muted-foreground text-xs">{t.sheetStatusHint}</div>
375
+
376
+ <div className="flex items-center gap-2">
377
+ <Button
378
+ disabled={!isEditable || !canFormatSheet}
379
+ onClick={formatSheet}
380
+ size="sm"
381
+ type="button"
382
+ variant="outline">
383
+ {t.format}
384
+ </Button>
385
+
386
+ <Button onClick={requestCloseSheet} size="sm" type="button" variant="outline">
387
+ {t.close}
388
+ </Button>
389
+
390
+ <Button disabled={!isEditable} onClick={saveSheet} size="sm" type="button">
391
+ {t.save}
392
+ </Button>
393
+ </div>
394
+ </div>
395
+ </div>
396
+ </SheetContent>
397
+ </Sheet>
398
+ </>
399
+ ) : null}
400
+
401
+ <Portal>
402
+ <AlertDialog onOpenChange={setConfirmDiscardOpen} open={confirmDiscardOpen}>
403
+ <AlertDialogContent>
404
+ <AlertDialogHeader>
405
+ <AlertDialogTitle>{t.discardTitle}</AlertDialogTitle>
406
+ <AlertDialogDescription>{t.discardDescription}</AlertDialogDescription>
407
+ </AlertDialogHeader>
408
+ <AlertDialogFooter>
409
+ <AlertDialogCancel asChild onClick={() => setConfirmDiscardOpen(false)}>
410
+ <Button variant="outline" size="sm">
411
+ {t.keepEditing}
412
+ </Button>
413
+ </AlertDialogCancel>
414
+ <AlertDialogAction asChild onClick={discardSheetChanges}>
415
+ <Button variant="default" size="sm">
416
+ {t.discard}
417
+ </Button>
418
+ </AlertDialogAction>
419
+ </AlertDialogFooter>
420
+ </AlertDialogContent>
421
+ </AlertDialog>
422
+ </Portal>
423
+ </Field>
424
+ )
425
+ }
@@ -0,0 +1,80 @@
1
+ import type { Control, FieldPath, FieldValues } from 'react-hook-form'
2
+
3
+ export type JsonEditorMode = 'inline' | 'popover'
4
+ export type JsonEditorExpandMode = 'none' | 'callback' | 'sheet'
5
+
6
+ export type JsonEditorExpandPayload<TFieldValues extends FieldValues> = {
7
+ name: FieldPath<TFieldValues>
8
+ rawText: string
9
+ value: unknown
10
+ }
11
+
12
+ export type JsonTextareaFieldProps<TFieldValues extends FieldValues> = {
13
+ control: Control<TFieldValues>
14
+ name: FieldPath<TFieldValues>
15
+ label: string
16
+ description?: string
17
+ className?: string
18
+ actionsClassName?: string
19
+
20
+ isEditable?: boolean
21
+ allowArray?: boolean
22
+
23
+ mode?: JsonEditorMode
24
+ rows?: number
25
+ placeholder?: string
26
+
27
+ lineNumbers?: boolean
28
+ lineHeightPx?: number
29
+
30
+ dir?: 'ltr' | 'rtl'
31
+ lang?: string
32
+
33
+ expandMode?: JsonEditorExpandMode
34
+ sheetSide?: 'left' | 'right'
35
+ sheetTitle?: string
36
+
37
+ text?: JsonEditorText
38
+
39
+ onExpandEditor?: (payload: JsonEditorExpandPayload<TFieldValues>) => void
40
+ }
41
+
42
+ export type JsonEditorText = {
43
+ format?: string
44
+ cancel?: string
45
+ save?: string
46
+ full?: string
47
+ close?: string
48
+ keepEditing?: string
49
+ discard?: string
50
+ discardTitle?: string
51
+ discardDescription?: string
52
+ statusHint?: string
53
+ sheetStatusHint?: string
54
+ unsavedChanges?: string
55
+ saved?: string
56
+ nullPreview?: string
57
+ }
58
+
59
+ export type JsonParseResult = { ok: true; value: unknown } | { ok: false; message: string }
60
+
61
+ export type JsonEditorViewProps = {
62
+ value: string
63
+ onChange: (value: string) => void
64
+ onScroll?: (scrollTop: number) => void
65
+ scrollTop: number
66
+ rows: number
67
+ placeholder: string
68
+ readOnly: boolean
69
+ lineNumbers: boolean
70
+ lineHeightPx: number
71
+ dir?: 'ltr' | 'rtl'
72
+ lang?: string
73
+ onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
74
+ }
75
+
76
+ export type UseJsonEditorHotkeysOptions = {
77
+ enabled: boolean
78
+ onEscape: () => void
79
+ onSave: () => void
80
+ }
@@ -0,0 +1,110 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@gentleduck/libs/cn'
4
+ import { type Direction, useDirection } from '@gentleduck/primitives/direction'
5
+ import * as React from 'react'
6
+ import { Textarea } from '../textarea'
7
+ import type { JsonEditorViewProps } from './json-editor.types'
8
+
9
+ const LOCALE_NUMBERING_SYSTEMS: Record<string, string> = {
10
+ ar: 'arab',
11
+ bn: 'beng',
12
+ fa: 'arabext',
13
+ gu: 'gujr',
14
+ hi: 'deva',
15
+ km: 'khmr',
16
+ kn: 'knda',
17
+ lo: 'laoo',
18
+ ml: 'mlym',
19
+ mr: 'deva',
20
+ my: 'mymr',
21
+ ne: 'deva',
22
+ or: 'orya',
23
+ pa: 'guru',
24
+ ps: 'arabext',
25
+ ta: 'tamldec',
26
+ te: 'telu',
27
+ th: 'thai',
28
+ ur: 'arabext',
29
+ }
30
+
31
+ export function JsonEditorView({
32
+ value,
33
+ onChange,
34
+ onScroll,
35
+ scrollTop,
36
+ rows,
37
+ placeholder,
38
+ readOnly,
39
+ lineNumbers,
40
+ lineHeightPx,
41
+ dir,
42
+ lang,
43
+ onKeyDown,
44
+ }: JsonEditorViewProps) {
45
+ const direction = useDirection(dir as Direction)
46
+ const lineCount = React.useMemo(() => {
47
+ const count = value ? value.split(/\r\n|\r|\n/).length : 1
48
+ return Math.max(1, count)
49
+ }, [value])
50
+
51
+ const numbers = React.useMemo(() => {
52
+ if (!lang) {
53
+ return Array.from({ length: lineCount }, (_, i) => String(i + 1)).join('\n')
54
+ }
55
+
56
+ // Build formatter; if the locale already includes a -u-nu- extension, use as-is.
57
+ // Otherwise append the native numbering system for known locales so that
58
+ // environments defaulting to latn still produce locale-appropriate digits.
59
+ let localeTag = lang
60
+ if (!lang.includes('-u-') || !lang.includes('-nu-')) {
61
+ const [base = ''] = lang.toLowerCase().split('-')
62
+ const numberingSystem = LOCALE_NUMBERING_SYSTEMS[base]
63
+ if (numberingSystem) {
64
+ localeTag = `${lang}-u-nu-${numberingSystem}`
65
+ }
66
+ }
67
+
68
+ const fmt = new Intl.NumberFormat(localeTag, { useGrouping: false })
69
+ return Array.from({ length: lineCount }, (_, i) => fmt.format(i + 1)).join('\n')
70
+ }, [lineCount, lang])
71
+
72
+ return (
73
+ <div className="overflow-hidden rounded-md border bg-background" data-slot="json-editor-shell" dir={direction}>
74
+ <div className="relative" data-slot="json-editor-container">
75
+ {lineNumbers ? (
76
+ <div className="absolute inset-y-0 start-0 w-12 border-e bg-muted/30" data-slot="json-editor-gutter">
77
+ <pre
78
+ aria-hidden
79
+ className="select-none px-2 py-2 text-end font-mono text-muted-foreground text-xs"
80
+ data-slot="json-editor-line-numbers"
81
+ style={{
82
+ lineHeight: `${lineHeightPx}px`,
83
+ transform: `translateY(-${scrollTop}px)`,
84
+ }}>
85
+ {numbers}
86
+ </pre>
87
+ </div>
88
+ ) : null}
89
+
90
+ <Textarea
91
+ className={cn(
92
+ 'w-full resize-y bg-transparent px-3 py-2 font-mono text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring',
93
+ lineNumbers ? 'ps-14' : '',
94
+ )}
95
+ data-slot="json-editor-textarea"
96
+ dir="ltr"
97
+ onChange={(event) => onChange(event.currentTarget.value)}
98
+ onKeyDown={onKeyDown}
99
+ onScroll={(event) => onScroll?.(event.currentTarget.scrollTop)}
100
+ placeholder={placeholder}
101
+ readOnly={readOnly}
102
+ rows={rows}
103
+ spellCheck={false}
104
+ style={{ lineHeight: `${lineHeightPx}px` }}
105
+ value={value}
106
+ />
107
+ </div>
108
+ </div>
109
+ )
110
+ }
@@ -0,0 +1,7 @@
1
+ export { JsonTextareaField } from './json-editor'
2
+ export type {
3
+ JsonEditorExpandMode,
4
+ JsonEditorExpandPayload,
5
+ JsonEditorMode,
6
+ JsonTextareaFieldProps,
7
+ } from './json-editor.types'
@@ -0,0 +1 @@
1
+ export * from './kbd'
@@ -0,0 +1,39 @@
1
+ import { cn } from '@gentleduck/libs/cn'
2
+ import { type Direction, useDirection } from '@gentleduck/primitives/direction'
3
+ import * as React from 'react'
4
+
5
+ const Kbd = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<'kbd'>>(
6
+ ({ className, dir, ...props }, ref) => {
7
+ const direction = useDirection(dir as Direction)
8
+ return (
9
+ <kbd
10
+ className={cn(
11
+ 'pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm bg-muted px-1 font-medium font-sans text-muted-foreground text-xs',
12
+ "[&_svg:not([class*='size-'])]:size-3",
13
+ className,
14
+ )}
15
+ data-slot="kbd"
16
+ dir={direction}
17
+ ref={ref}
18
+ {...props}
19
+ />
20
+ )
21
+ },
22
+ )
23
+ Kbd.displayName = 'Kbd'
24
+
25
+ const KbdGroup = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
26
+ ({ className, ...props }, ref) => {
27
+ return (
28
+ <kbd
29
+ className={cn('inline-flex items-center gap-1', className)}
30
+ data-slot="kbd-group"
31
+ ref={ref as React.Ref<HTMLElement>}
32
+ {...props}
33
+ />
34
+ )
35
+ },
36
+ )
37
+ KbdGroup.displayName = 'KbdGroup'
38
+
39
+ export { Kbd, KbdGroup }
@@ -0,0 +1 @@
1
+ export * from './label'
@@ -0,0 +1,28 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@gentleduck/libs/cn'
4
+ import { type Direction, useDirection } from '@gentleduck/primitives/direction'
5
+ import * as React from 'react'
6
+
7
+ export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
8
+
9
+ const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, htmlFor, dir, ...props }, ref) => {
10
+ const direction = useDirection(dir as Direction)
11
+
12
+ return (
13
+ <label
14
+ className={cn(
15
+ 'text-balance font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
16
+ className,
17
+ )}
18
+ data-slot="label"
19
+ dir={direction}
20
+ htmlFor={htmlFor}
21
+ ref={ref}
22
+ {...props}
23
+ />
24
+ )
25
+ })
26
+ Label.displayName = 'Label'
27
+
28
+ export { Label }
@@ -0,0 +1 @@
1
+ export * from './menubar'