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.
- package/apps/lib/components/Badge.tsx +34 -137
- package/apps/lib/components/BadgeField.tsx +4 -4
- package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +416 -0
- package/apps/lib/components/DataViews/DataViewsHeader.tsx +126 -0
- package/apps/lib/components/DataViews/DataViewsLayout.tsx +300 -0
- package/apps/lib/components/DataViews/FilterPanel.tsx +324 -0
- package/apps/lib/components/DataViews/InboxView.tsx +514 -0
- package/apps/lib/components/DataViews/KanbanView.tsx +242 -0
- package/apps/lib/components/DataViews/PanelControls.tsx +80 -0
- package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
- package/apps/lib/components/DataViews/TableView.tsx +232 -0
- package/apps/lib/components/DataViews/TreeView.tsx +363 -0
- package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
- package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
- package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
- package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
- package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
- package/apps/lib/components/DataViews/index.ts +30 -0
- package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
- package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
- package/apps/lib/components/DataViews/types.ts +177 -0
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +387 -0
- package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
- package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +235 -0
- package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
- package/apps/lib/components/TreeFolder/icons.tsx +63 -0
- package/apps/lib/components/TreeFolder/index.ts +17 -0
- package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
- package/apps/lib/components/TreeFolder/types.ts +68 -0
- package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
- package/apps/lib/hooks/useDataViewsState.ts +169 -0
- package/apps/lib/hooks/useIsMobile.ts +21 -0
- package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
- package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
- package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
- package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
- package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
- package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
- package/dist/bin/index.js +3 -3
- package/dist/bin/index.js.map +1 -1
- package/dist/src/commands/add.d.ts.map +1 -1
- package/dist/src/commands/add.js +29 -6
- package/dist/src/commands/add.js.map +1 -1
- package/dist/src/commands/utils.d.ts.map +1 -1
- package/dist/src/commands/utils.js +22 -2
- package/dist/src/commands/utils.js.map +1 -1
- package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
- package/dist/src/shared/copyComponentsRecursively.js +8 -1
- package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
- package/docs/components/badge-field.md +21 -21
- package/docs/components/badge.md +156 -483
- package/docs/components/form-stepper.md +244 -0
- package/docs/components/stepper.md +215 -0
- package/docs/components/timeline.md +248 -0
- package/docs/reference/components.md +8 -7
- package/docs/reference/types.md +34 -26
- package/docs/tutorials/theming-basics.md +30 -27
- package/package.json +1 -1
- /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
- /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"
|