torch-glare 2.1.0 → 2.1.2

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 (65) hide show
  1. package/apps/lib/components/Badge.tsx +34 -137
  2. package/apps/lib/components/BadgeField.tsx +4 -4
  3. package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
  4. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +416 -0
  5. package/apps/lib/components/DataViews/DataViewsHeader.tsx +126 -0
  6. package/apps/lib/components/DataViews/DataViewsLayout.tsx +300 -0
  7. package/apps/lib/components/DataViews/FilterPanel.tsx +324 -0
  8. package/apps/lib/components/DataViews/InboxView.tsx +514 -0
  9. package/apps/lib/components/DataViews/KanbanView.tsx +242 -0
  10. package/apps/lib/components/DataViews/PanelControls.tsx +80 -0
  11. package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
  12. package/apps/lib/components/DataViews/TableView.tsx +232 -0
  13. package/apps/lib/components/DataViews/TreeView.tsx +363 -0
  14. package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
  15. package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
  16. package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
  17. package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
  18. package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
  19. package/apps/lib/components/DataViews/index.ts +30 -0
  20. package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
  21. package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
  22. package/apps/lib/components/DataViews/types.ts +177 -0
  23. package/apps/lib/components/TreeFolder/TreeFolder.tsx +387 -0
  24. package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
  25. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +235 -0
  26. package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
  27. package/apps/lib/components/TreeFolder/icons.tsx +63 -0
  28. package/apps/lib/components/TreeFolder/index.ts +17 -0
  29. package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
  30. package/apps/lib/components/TreeFolder/types.ts +68 -0
  31. package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
  32. package/apps/lib/hooks/useDataViewsState.ts +169 -0
  33. package/apps/lib/hooks/useIsMobile.ts +21 -0
  34. package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
  35. package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
  36. package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
  37. package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
  38. package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
  39. package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
  40. package/dist/bin/index.js +3 -3
  41. package/dist/bin/index.js.map +1 -1
  42. package/dist/src/commands/add.d.ts.map +1 -1
  43. package/dist/src/commands/add.js +29 -6
  44. package/dist/src/commands/add.js.map +1 -1
  45. package/dist/src/commands/utils.d.ts.map +1 -1
  46. package/dist/src/commands/utils.js +22 -2
  47. package/dist/src/commands/utils.js.map +1 -1
  48. package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
  49. package/dist/src/shared/copyComponentsRecursively.js +8 -1
  50. package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
  51. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
  52. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
  53. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
  54. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
  55. package/docs/components/badge-field.md +21 -21
  56. package/docs/components/badge.md +156 -483
  57. package/docs/components/form-stepper.md +244 -0
  58. package/docs/components/stepper.md +215 -0
  59. package/docs/components/timeline.md +248 -0
  60. package/docs/reference/components.md +8 -7
  61. package/docs/reference/types.md +34 -26
  62. package/docs/tutorials/theming-basics.md +30 -27
  63. package/package.json +1 -1
  64. /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
  65. /package/docs/components/{tree-dropdown.md → tree-drop-down.md} +0 -0
@@ -0,0 +1,334 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { Badge } from "../Badge"
5
+ import { Avatar, AvatarFallback, AvatarImage } from "../Avatar"
6
+ import { cn } from "../../utils/cn"
7
+ import type {
8
+ CurrencyOptions,
9
+ DynamicRecord,
10
+ FieldConfig,
11
+ FieldType,
12
+ } from "./types"
13
+ import { getByPath } from "../../utils/dataViews/pathUtils"
14
+ import { resolveBadgeVariant } from "./badgeAdapter"
15
+
16
+ type RenderArgs = {
17
+ value: any
18
+ field: FieldConfig
19
+ row: DynamicRecord
20
+ }
21
+
22
+ const NULL_PLACEHOLDER = (
23
+ <span className="text-content-presentation-global-tertiary">-</span>
24
+ )
25
+
26
+ export function renderField(
27
+ value: any,
28
+ field: FieldConfig,
29
+ row: DynamicRecord,
30
+ ): ReactNode {
31
+ if (field.render) return field.render(value, row)
32
+
33
+ const type = field.type ?? "text"
34
+ if (type === "hidden") return null
35
+
36
+ if (value == null && type !== "boolean" && type !== "progress-bar") {
37
+ return NULL_PLACEHOLDER
38
+ }
39
+
40
+ const renderer = RENDERERS[type] ?? renderText
41
+ return renderer({ value, field, row })
42
+ }
43
+
44
+ function renderText({ value }: RenderArgs): ReactNode {
45
+ return (
46
+ <span className="text-content-presentation-global-primary">{String(value)}</span>
47
+ )
48
+ }
49
+
50
+ function renderNumber({ value }: RenderArgs): ReactNode {
51
+ return (
52
+ <span className="font-mono text-content-presentation-global-primary">
53
+ {typeof value === "number" ? value.toLocaleString() : String(value)}
54
+ </span>
55
+ )
56
+ }
57
+
58
+ function renderDate({ value }: RenderArgs): ReactNode {
59
+ return <span className="text-content-presentation-global-primary">{String(value)}</span>
60
+ }
61
+
62
+ function renderDateFormat({ value, field }: RenderArgs): ReactNode {
63
+ const opts = field.dateFormat
64
+ let formatted = String(value)
65
+ try {
66
+ const d = value instanceof Date ? value : new Date(value)
67
+ if (!isNaN(d.getTime())) {
68
+ if (typeof opts === "object" && opts) {
69
+ formatted = new Intl.DateTimeFormat(undefined, opts).format(d)
70
+ } else if (typeof opts === "string") {
71
+ formatted = formatWithToken(d, opts)
72
+ } else {
73
+ formatted = new Intl.DateTimeFormat(undefined, {
74
+ year: "numeric",
75
+ month: "short",
76
+ day: "numeric",
77
+ }).format(d)
78
+ }
79
+ }
80
+ } catch {
81
+ /* fall through to raw string */
82
+ }
83
+ return <span className="text-content-presentation-global-primary">{formatted}</span>
84
+ }
85
+
86
+ function formatWithToken(d: Date, token: string): string {
87
+ const pad = (n: number) => String(n).padStart(2, "0")
88
+ return token
89
+ .replace(/YYYY/g, String(d.getFullYear()))
90
+ .replace(/MM/g, pad(d.getMonth() + 1))
91
+ .replace(/DD/g, pad(d.getDate()))
92
+ .replace(/HH/g, pad(d.getHours()))
93
+ .replace(/mm/g, pad(d.getMinutes()))
94
+ .replace(/ss/g, pad(d.getSeconds()))
95
+ }
96
+
97
+ function renderBoolean({ value, field }: RenderArgs): ReactNode {
98
+ const isTrue = !!value
99
+ const variant = isTrue ? field.trueVariant ?? "green" : field.falseVariant ?? "gray"
100
+ const badgeProps = resolveBadgeVariant(variant)
101
+ return (
102
+ <Badge
103
+ {...badgeProps}
104
+ label={isTrue ? field.trueLabel ?? "Yes" : field.falseLabel ?? "No"}
105
+ size="S"
106
+
107
+ />
108
+ )
109
+ }
110
+
111
+ function renderEnumBadge({ value, field }: RenderArgs): ReactNode {
112
+ const key = String(value)
113
+ const variant = field.variants?.[key] ?? field.defaultVariant ?? "gray"
114
+ const badgeProps = resolveBadgeVariant(variant)
115
+ // Use medium size so the glare badge dot indicator is clearly visible
116
+ return <Badge {...badgeProps} label={key} size="S" />
117
+ }
118
+
119
+ function renderBadgeArray({ value, field }: RenderArgs): ReactNode {
120
+ const badgeProps = resolveBadgeVariant(field.variant ?? "blue")
121
+ if (!Array.isArray(value)) {
122
+ return <Badge {...badgeProps} label={String(value)} size="XS" />
123
+ }
124
+ const limit = field.limit ?? value.length
125
+ const head = value.slice(0, limit)
126
+ const overflow = value.length - head.length
127
+ const overflowProps = resolveBadgeVariant("gray")
128
+ return (
129
+ <div className="flex flex-wrap gap-1">
130
+ {head.map((v, i) => (
131
+ <Badge
132
+ key={i}
133
+ {...badgeProps}
134
+ label={String(v)}
135
+ size="XS"
136
+
137
+ />
138
+ ))}
139
+ {overflow > 0 && (
140
+ <Badge {...overflowProps} label={`+${overflow}`} size="XS" />
141
+ )}
142
+ </div>
143
+ )
144
+ }
145
+
146
+ function renderCurrency({ value, field }: RenderArgs): ReactNode {
147
+ const num = Number(value)
148
+ if (Number.isNaN(num)) return renderText({ value, field, row: {} as any })
149
+
150
+ const opts: CurrencyOptions =
151
+ typeof field.currency === "string"
152
+ ? { code: field.currency }
153
+ : field.currency ?? {}
154
+
155
+ let formatted: string
156
+ if (opts.code) {
157
+ try {
158
+ formatted = new Intl.NumberFormat(opts.locale, {
159
+ style: "currency",
160
+ currency: opts.code,
161
+ minimumFractionDigits: opts.decimals,
162
+ maximumFractionDigits: opts.decimals,
163
+ }).format(num)
164
+ } catch {
165
+ formatted = `${opts.symbol ?? "$"}${num.toLocaleString(opts.locale, {
166
+ minimumFractionDigits: opts.decimals,
167
+ maximumFractionDigits: opts.decimals,
168
+ })}`
169
+ }
170
+ } else {
171
+ formatted = `${opts.symbol ?? "$"}${num.toLocaleString(opts.locale, {
172
+ minimumFractionDigits: opts.decimals,
173
+ maximumFractionDigits: opts.decimals,
174
+ })}`
175
+ }
176
+
177
+ return <span className="font-semibold text-green-600">{formatted}</span>
178
+ }
179
+
180
+ function renderNumberFormat({ value, field }: RenderArgs): ReactNode {
181
+ const num = Number(value)
182
+ if (Number.isNaN(num)) return renderText({ value, field, row: {} as any })
183
+ const formatted = new Intl.NumberFormat(undefined, field.format).format(num)
184
+ return <span className="font-mono text-content-presentation-global-primary">{formatted}</span>
185
+ }
186
+
187
+ function renderProgressBar({ value, field }: RenderArgs): ReactNode {
188
+ const raw = Number(value)
189
+ const num = Number.isFinite(raw) ? raw : 0
190
+ const pct = Math.max(0, Math.min(100, num))
191
+ const [warn, ok] = field.thresholds ?? [40, 70]
192
+ const color =
193
+ num >= ok ? "bg-green-500"
194
+ : num >= warn ? "bg-yellow-500"
195
+ : "bg-red-500"
196
+
197
+ return (
198
+ <div className="flex items-center gap-2 min-w-[120px]">
199
+ <div className="flex-1 h-2 rounded-full bg-background-presentation-form-field-primary overflow-hidden">
200
+ <div className={cn("h-full transition-all", color)} style={{ width: `${pct}%` }} />
201
+ </div>
202
+ <span className="text-xs font-medium tabular-nums text-content-presentation-global-primary w-10 text-right">
203
+ {Math.round(num)}%
204
+ </span>
205
+ </div>
206
+ )
207
+ }
208
+
209
+ function renderStarRating({ value, field }: RenderArgs): ReactNode {
210
+ const num = Number(value)
211
+ const max = field.max ?? 5
212
+ const filled = Math.max(0, Math.min(max, Math.floor(num)))
213
+
214
+ return (
215
+ <div className="flex items-center gap-2">
216
+ <div className="flex">
217
+ {Array.from({ length: max }).map((_, i) => (
218
+ <span
219
+ key={i}
220
+ className={i < filled ? "text-yellow-500" : "text-gray-300"}
221
+ aria-hidden
222
+ >
223
+
224
+ </span>
225
+ ))}
226
+ </div>
227
+ <span className="text-sm font-semibold tabular-nums">{Number.isFinite(num) ? num : "-"}</span>
228
+ </div>
229
+ )
230
+ }
231
+
232
+ function renderIconText({ value, field }: RenderArgs): ReactNode {
233
+ const icon = field.icon ?? ""
234
+ const after = field.iconPosition === "after"
235
+ return (
236
+ <span className="inline-flex items-center gap-1.5 text-content-presentation-global-primary">
237
+ {!after && icon && <IconNode icon={icon} />}
238
+ <span>{String(value)}</span>
239
+ {after && icon && <IconNode icon={icon} />}
240
+ </span>
241
+ )
242
+ }
243
+
244
+ function IconNode({ icon }: { icon: string }) {
245
+ if (/^ri-/.test(icon)) return <i className={icon} />
246
+ return <span aria-hidden>{icon}</span>
247
+ }
248
+
249
+ function renderTwoLine({ value, field, row }: RenderArgs): ReactNode {
250
+ const secondary =
251
+ field.secondaryPath != null ? getByPath(row, field.secondaryPath) : null
252
+ return (
253
+ <div className="leading-tight">
254
+ <div className="font-semibold text-content-presentation-global-primary">
255
+ {String(value)}
256
+ </div>
257
+ {secondary != null && (
258
+ <div className="text-xs text-content-presentation-global-secondary">
259
+ {String(secondary)}
260
+ </div>
261
+ )}
262
+ </div>
263
+ )
264
+ }
265
+
266
+ function renderAvatar({ value, field, row }: RenderArgs): ReactNode {
267
+ const src = typeof value === "string" ? value : null
268
+ const fallbackSource =
269
+ field.fallbackPath != null ? getByPath(row, field.fallbackPath) : null
270
+ const initials = toInitials(fallbackSource ?? src ?? "?")
271
+
272
+ return (
273
+ <Avatar>
274
+ {src && <AvatarImage src={src} alt={String(initials)} />}
275
+ <AvatarFallback>{initials}</AvatarFallback>
276
+ </Avatar>
277
+ )
278
+ }
279
+
280
+ function toInitials(s: string): string {
281
+ if (!s) return "?"
282
+ const parts = String(s).trim().split(/\s+/).slice(0, 2)
283
+ return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "?"
284
+ }
285
+
286
+ function renderLink({ value, field }: RenderArgs): ReactNode {
287
+ const v = String(value)
288
+ let href = v
289
+ if (field.linkType === "mailto" && !v.startsWith("mailto:")) href = `mailto:${v}`
290
+ else if (field.linkType === "tel" && !v.startsWith("tel:")) href = `tel:${v}`
291
+
292
+ return (
293
+ <a
294
+ href={href}
295
+ target={field.linkType === "url" ? "_blank" : undefined}
296
+ rel={field.linkType === "url" ? "noopener noreferrer" : undefined}
297
+ className="text-blue-600 hover:underline"
298
+ onClick={(e) => e.stopPropagation()}
299
+ >
300
+ {v}
301
+ </a>
302
+ )
303
+ }
304
+
305
+ function renderImage({ value }: RenderArgs): ReactNode {
306
+ if (typeof value !== "string" || !value) return NULL_PLACEHOLDER
307
+ return (
308
+ <img
309
+ src={value}
310
+ alt=""
311
+ className="h-10 w-10 rounded object-cover border border-border-presentation-global-primary"
312
+ />
313
+ )
314
+ }
315
+
316
+ const RENDERERS: Record<FieldType, (a: RenderArgs) => ReactNode> = {
317
+ "text": renderText,
318
+ "number": renderNumber,
319
+ "date": renderDate,
320
+ "boolean": renderBoolean,
321
+ "hidden": () => null,
322
+ "enum-badge": renderEnumBadge,
323
+ "badge-array": renderBadgeArray,
324
+ "currency": renderCurrency,
325
+ "number-format": renderNumberFormat,
326
+ "progress-bar": renderProgressBar,
327
+ "star-rating": renderStarRating,
328
+ "icon-text": renderIconText,
329
+ "two-line": renderTwoLine,
330
+ "avatar": renderAvatar,
331
+ "link": renderLink,
332
+ "image": renderImage,
333
+ "date-format": renderDateFormat,
334
+ }
@@ -0,0 +1,113 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { DayPicker, type DateRange } from "react-day-picker"
5
+ import "react-day-picker/style.css"
6
+ import * as PopoverPrimitive from "@radix-ui/react-popover"
7
+ import { Calendar, X } from "lucide-react"
8
+ import { cn } from "../../../utils/cn"
9
+ import type { DateRangeFilter, FieldConfig } from "../types"
10
+ import { toIsoDate } from "../../../utils/dataViews/rangeUtils"
11
+ import { PresetChips } from "./PresetChips"
12
+
13
+ type Props = {
14
+ value: DateRangeFilter | undefined
15
+ onChange: (next: DateRangeFilter) => void
16
+ presets: FieldConfig["presets"]
17
+ }
18
+
19
+ export function DateRangePopover({ value, onChange, presets }: Props) {
20
+ const [open, setOpen] = useState(false)
21
+
22
+ const range: DateRange | undefined =
23
+ value && (value.from || value.to)
24
+ ? {
25
+ from: value.from ? new Date(value.from + "T00:00:00") : undefined,
26
+ to: value.to ? new Date(value.to + "T00:00:00") : undefined,
27
+ }
28
+ : undefined
29
+
30
+ const label =
31
+ value?.from && value?.to ? `${value.from} → ${value.to}`
32
+ : value?.from ? `from ${value.from}`
33
+ : value?.to ? `until ${value.to}`
34
+ : "Any date"
35
+
36
+ const handleSelect = (next: DateRange | undefined) => {
37
+ onChange({
38
+ kind: "date",
39
+ from: next?.from ? toIsoDate(next.from) : undefined,
40
+ to: next?.to ? toIsoDate(next.to) : undefined,
41
+ })
42
+ }
43
+
44
+ const isActive = !!(value?.from || value?.to)
45
+
46
+ return (
47
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
48
+ <PopoverPrimitive.Trigger asChild>
49
+ <button
50
+ type="button"
51
+ className={cn(
52
+ "flex items-center gap-2 w-full text-xs px-2.5 py-2 rounded-md border bg-background-presentation-body-primary text-left",
53
+ isActive
54
+ ? "border-content-presentation-action-primary text-content-presentation-global-primary"
55
+ : "border-border-presentation-global-primary text-content-presentation-global-secondary hover:text-content-presentation-global-primary",
56
+ )}
57
+ >
58
+ <Calendar className="w-3.5 h-3.5 shrink-0" />
59
+ <span className="flex-1 truncate">{label}</span>
60
+ {isActive && (
61
+ <span
62
+ role="button"
63
+ tabIndex={0}
64
+ aria-label="Clear date filter"
65
+ onClick={(e) => {
66
+ e.stopPropagation()
67
+ onChange({ kind: "date" })
68
+ }}
69
+ onKeyDown={(e) => {
70
+ if (e.key === "Enter" || e.key === " ") {
71
+ e.stopPropagation()
72
+ e.preventDefault()
73
+ onChange({ kind: "date" })
74
+ }
75
+ }}
76
+ className="text-content-presentation-global-tertiary hover:text-content-presentation-global-primary cursor-pointer"
77
+ >
78
+ <X className="w-3 h-3" />
79
+ </span>
80
+ )}
81
+ </button>
82
+ </PopoverPrimitive.Trigger>
83
+ <PopoverPrimitive.Portal>
84
+ <PopoverPrimitive.Content
85
+ align="start"
86
+ sideOffset={6}
87
+ className="z-50 bg-background-presentation-body-primary border border-border-presentation-global-primary rounded-lg shadow-lg p-3 space-y-3"
88
+ >
89
+ {presets && presets.length > 0 && (
90
+ <PresetChips presets={presets} current={value} onSelect={onChange as any} />
91
+ )}
92
+ <DayPicker
93
+ mode="range"
94
+ selected={range}
95
+ onSelect={handleSelect}
96
+ numberOfMonths={1}
97
+ showOutsideDays
98
+ className="rdp-glare"
99
+ />
100
+ <div className="flex justify-end pt-1 border-t border-border-presentation-global-primary">
101
+ <button
102
+ type="button"
103
+ onClick={() => setOpen(false)}
104
+ className="text-xs px-3 py-1 rounded-md bg-content-presentation-action-primary text-white hover:opacity-90"
105
+ >
106
+ Done
107
+ </button>
108
+ </div>
109
+ </PopoverPrimitive.Content>
110
+ </PopoverPrimitive.Portal>
111
+ </PopoverPrimitive.Root>
112
+ )
113
+ }
@@ -0,0 +1,45 @@
1
+ "use client"
2
+
3
+ import { cn } from "../../../utils/cn"
4
+ import type { FieldPreset, FilterValue } from "../types"
5
+ import { isPresetActive, presetToFilterValue } from "../../../utils/dataViews/rangeUtils"
6
+
7
+ type PresetChipsProps = {
8
+ presets: FieldPreset[]
9
+ current: FilterValue | undefined
10
+ onSelect: (value: FilterValue) => void
11
+ }
12
+
13
+ export function PresetChips({ presets, current, onSelect }: PresetChipsProps) {
14
+ if (!presets || presets.length === 0) return null
15
+
16
+ return (
17
+ <div className="flex flex-wrap gap-1.5">
18
+ {presets.map((p) => {
19
+ const active = isPresetActive(p, current)
20
+ return (
21
+ <button
22
+ key={p.label}
23
+ type="button"
24
+ onClick={() => {
25
+ if (active) {
26
+ const v = presetToFilterValue(p)
27
+ onSelect(v.kind === "number" ? { kind: "number" } : { kind: "date" })
28
+ } else {
29
+ onSelect(presetToFilterValue(p))
30
+ }
31
+ }}
32
+ className={cn(
33
+ "text-xs px-2 py-1 rounded-md border transition-colors whitespace-nowrap",
34
+ active
35
+ ? "bg-content-presentation-action-primary text-white border-content-presentation-action-primary"
36
+ : "bg-background-presentation-form-field-primary border-border-presentation-global-primary text-content-presentation-global-secondary hover:text-content-presentation-global-primary",
37
+ )}
38
+ >
39
+ {p.label}
40
+ </button>
41
+ )
42
+ })}
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,154 @@
1
+ "use client"
2
+
3
+ import { useEffect, useMemo, useState } from "react"
4
+ import * as Slider from "@radix-ui/react-slider"
5
+ import { cn } from "../../../utils/cn"
6
+ import type { FieldConfig, NumericRangeFilter } from "../types"
7
+ import type { NumericExtremes } from "../../../utils/dataViews/rangeUtils"
8
+
9
+ type Props = {
10
+ field: FieldConfig
11
+ extremes: NumericExtremes
12
+ step: number
13
+ value: NumericRangeFilter | undefined
14
+ onChange: (next: NumericRangeFilter) => void
15
+ }
16
+
17
+ export function RangeSliderWithInputs({ field, extremes, step, value, onChange }: Props) {
18
+ const min = field.rangeMin ?? extremes.min
19
+ const max = field.rangeMax ?? extremes.max
20
+
21
+ const lo = value?.min ?? min
22
+ const hi = value?.max ?? max
23
+
24
+ const [loInput, setLoInput] = useState(formatForInput(lo, field))
25
+ const [hiInput, setHiInput] = useState(formatForInput(hi, field))
26
+
27
+ useEffect(() => { setLoInput(formatForInput(lo, field)) }, [lo, field])
28
+ useEffect(() => { setHiInput(formatForInput(hi, field)) }, [hi, field])
29
+
30
+ const commit = (rawLo: number, rawHi: number) => {
31
+ let nlo = clamp(rawLo, min, max)
32
+ let nhi = clamp(rawHi, min, max)
33
+ if (nlo > nhi) [nlo, nhi] = [nhi, nlo]
34
+ const atFloor = nlo === min
35
+ const atCeil = nhi === max
36
+ onChange({
37
+ kind: "number",
38
+ min: atFloor ? undefined : nlo,
39
+ max: atCeil ? undefined : nhi,
40
+ })
41
+ }
42
+
43
+ const handleSliderChange = (vals: number[]) => {
44
+ if (vals.length !== 2) return
45
+ commit(vals[0], vals[1])
46
+ }
47
+
48
+ const commitFromInputs = () => {
49
+ const nlo = parseFloat(loInput)
50
+ const nhi = parseFloat(hiInput)
51
+ commit(Number.isFinite(nlo) ? nlo : lo, Number.isFinite(nhi) ? nhi : hi)
52
+ }
53
+
54
+ const prefix = useMemo(() => prefixFor(field), [field])
55
+ const suffix = useMemo(() => suffixFor(field), [field])
56
+
57
+ return (
58
+ <div className="space-y-3">
59
+ <Slider.Root
60
+ className="relative flex items-center select-none touch-none w-full h-5"
61
+ min={min}
62
+ max={max}
63
+ step={step}
64
+ value={[lo, hi]}
65
+ onValueChange={handleSliderChange}
66
+ minStepsBetweenThumbs={0}
67
+ >
68
+ <Slider.Track className="bg-background-presentation-form-field-primary relative grow rounded-full h-1">
69
+ <Slider.Range className="absolute bg-content-presentation-action-primary rounded-full h-full" />
70
+ </Slider.Track>
71
+ <Slider.Thumb
72
+ aria-label="Minimum"
73
+ className="block w-4 h-4 bg-white border-2 border-content-presentation-action-primary rounded-full shadow hover:bg-background-presentation-body-overlay-primary focus:outline-none focus:ring-2 focus:ring-content-presentation-action-primary"
74
+ />
75
+ <Slider.Thumb
76
+ aria-label="Maximum"
77
+ className="block w-4 h-4 bg-white border-2 border-content-presentation-action-primary rounded-full shadow hover:bg-background-presentation-body-overlay-primary focus:outline-none focus:ring-2 focus:ring-content-presentation-action-primary"
78
+ />
79
+ </Slider.Root>
80
+
81
+ <div className="flex items-center gap-2">
82
+ <NumberCell
83
+ label="Min"
84
+ value={loInput}
85
+ onChange={setLoInput}
86
+ onCommit={commitFromInputs}
87
+ prefix={prefix}
88
+ suffix={suffix}
89
+ />
90
+ <span className="text-xs text-content-presentation-global-tertiary">–</span>
91
+ <NumberCell
92
+ label="Max"
93
+ value={hiInput}
94
+ onChange={setHiInput}
95
+ onCommit={commitFromInputs}
96
+ prefix={prefix}
97
+ suffix={suffix}
98
+ />
99
+ </div>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ function NumberCell({
105
+ label, value, onChange, onCommit, prefix, suffix,
106
+ }: {
107
+ label: string
108
+ value: string
109
+ onChange: (v: string) => void
110
+ onCommit: () => void
111
+ prefix?: string
112
+ suffix?: string
113
+ }) {
114
+ return (
115
+ <label className="flex-1 flex items-center gap-1 text-xs bg-background-presentation-body-primary border border-border-presentation-global-primary rounded-md px-2 py-1.5 focus-within:border-content-presentation-action-primary">
116
+ <span className="sr-only">{label}</span>
117
+ {prefix && <span className="text-content-presentation-global-tertiary">{prefix}</span>}
118
+ <input
119
+ inputMode="decimal"
120
+ value={value}
121
+ onChange={(e) => onChange(e.target.value)}
122
+ onBlur={onCommit}
123
+ onKeyDown={(e) => { if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur() }}
124
+ className={cn(
125
+ "w-full bg-transparent outline-none tabular-nums text-content-presentation-global-primary",
126
+ )}
127
+ />
128
+ {suffix && <span className="text-content-presentation-global-tertiary">{suffix}</span>}
129
+ </label>
130
+ )
131
+ }
132
+
133
+ function clamp(n: number, min: number, max: number): number {
134
+ return Math.min(max, Math.max(min, n))
135
+ }
136
+
137
+ function formatForInput(n: number, field: FieldConfig): string {
138
+ if (!Number.isFinite(n)) return ""
139
+ if (field.type === "star-rating") return n.toFixed(1)
140
+ if (Number.isInteger(n)) return String(n)
141
+ return String(n)
142
+ }
143
+
144
+ function prefixFor(field: FieldConfig): string | undefined {
145
+ if (field.type !== "currency") return undefined
146
+ if (typeof field.currency === "string") return "$"
147
+ return field.currency?.symbol ?? "$"
148
+ }
149
+
150
+ function suffixFor(field: FieldConfig): string | undefined {
151
+ if (field.type === "progress-bar") return "%"
152
+ if (field.type === "star-rating") return "★"
153
+ return undefined
154
+ }
@@ -0,0 +1,30 @@
1
+ export { DataViewsLayout } from "./DataViewsLayout"
2
+ export type { DataViewsLayoutProps } from "./DataViewsLayout"
3
+
4
+ export { TableView } from "./TableView"
5
+ export type { TableViewProps } from "./TableView"
6
+
7
+ export { KanbanView } from "./KanbanView"
8
+ export type { KanbanViewProps } from "./KanbanView"
9
+
10
+ export { InboxView } from "./InboxView"
11
+ export type { InboxViewProps } from "./InboxView"
12
+
13
+ export { TreeView } from "./TreeView"
14
+ export type { TreeViewProps } from "./TreeView"
15
+
16
+ export { FilterPanel } from "./FilterPanel"
17
+ export { SettingsPanel } from "./SettingsPanel"
18
+ export { DataViewsHeader } from "./DataViewsHeader"
19
+ export type { DataViewsHeaderView } from "./DataViewsHeader"
20
+ export { DataViewsConfigPanel } from "./DataViewsConfigPanel"
21
+ export type { DataViewsConfigPanelProps } from "./DataViewsConfigPanel"
22
+
23
+ export { renderField } from "./fieldRenderers"
24
+ export { resolveBadgeVariant } from "./badgeAdapter"
25
+ export type { ResolvedBadgeProps } from "./badgeAdapter"
26
+
27
+ export * from "./types"
28
+
29
+ export { useDataViewsState } from "../../hooks/useDataViewsState"
30
+ export type { UseDataViewsStateOptions } from "../../hooks/useDataViewsState"