@hilum/ui 3.8.5 → 3.10.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/README.md +32 -1
- package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
- package/dist/chunk-R576DV2A.js.map +1 -0
- package/dist/create-theme.js +2 -1
- package/dist/create-theme.js.map +1 -1
- package/dist/index.d.ts +733 -68
- package/dist/index.js +3097 -1619
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +163 -19
- package/dist/tokens.d.ts +69 -5
- package/dist/tokens.js +1 -1
- package/llms.txt +11 -4
- package/package.json +11 -10
- package/src/components/accordion.tsx +2 -2
- package/src/components/account-menu.tsx +1 -1
- package/src/components/alert-dialog.tsx +107 -0
- package/src/components/ask-user-questions.tsx +24 -18
- package/src/components/badge.tsx +36 -3
- package/src/components/breadcrumb.tsx +4 -2
- package/src/components/button-group.tsx +1 -1
- package/src/components/button.tsx +31 -23
- package/src/components/calendar.tsx +1 -1
- package/src/components/card.tsx +4 -2
- package/src/components/checkbox-card.tsx +1 -1
- package/src/components/checkbox-group.tsx +2 -1
- package/src/components/code-block.tsx +99 -0
- package/src/components/color-input.tsx +9 -6
- package/src/components/color-picker.tsx +38 -26
- package/src/components/combobox.tsx +59 -12
- package/src/components/command-palette.tsx +150 -42
- package/src/components/command.tsx +5 -3
- package/src/components/context-menu.tsx +18 -15
- package/src/components/contextual-save-bar.tsx +156 -0
- package/src/components/data-table.tsx +34 -5
- package/src/components/date-picker.tsx +275 -28
- package/src/components/date-text.tsx +90 -0
- package/src/components/dropdown-menu.tsx +11 -8
- package/src/components/dropdown.tsx +6 -3
- package/src/components/empty-state.tsx +85 -20
- package/src/components/file-dropzone.tsx +2 -1
- package/src/components/file-thumbnail.tsx +84 -20
- package/src/components/filter-bar.tsx +91 -0
- package/src/components/grid-list.tsx +2 -2
- package/src/components/help-tooltip.tsx +1 -1
- package/src/components/input-copy.tsx +1 -1
- package/src/components/input-group.tsx +1 -1
- package/src/components/input-message.tsx +5 -4
- package/src/components/input-number.tsx +103 -11
- package/src/components/input.tsx +5 -3
- package/src/components/label.tsx +1 -0
- package/src/components/media-asset-card.tsx +1 -1
- package/src/components/menu-item.tsx +37 -3
- package/src/components/menubar.tsx +21 -18
- package/src/components/native-select.tsx +1 -1
- package/src/components/navigation-menu.tsx +2 -2
- package/src/components/notification.tsx +1 -1
- package/src/components/pagination.tsx +97 -3
- package/src/components/popover.tsx +2 -0
- package/src/components/property-row.tsx +7 -4
- package/src/components/radio-card.tsx +1 -1
- package/src/components/radio-group.tsx +2 -1
- package/src/components/rating.tsx +136 -0
- package/src/components/resizable.tsx +1 -1
- package/src/components/resource-item.tsx +133 -0
- package/src/components/rich-text-editor.tsx +1 -0
- package/src/components/search-input.tsx +98 -0
- package/src/components/searchable-table.tsx +10 -11
- package/src/components/select.tsx +28 -9
- package/src/components/sidebar.tsx +4 -4
- package/src/components/slider.tsx +78 -36
- package/src/components/sonner.tsx +18 -4
- package/src/components/stat-card.tsx +121 -37
- package/src/components/status-badge.tsx +142 -47
- package/src/components/status-tile.tsx +9 -0
- package/src/components/switch.tsx +6 -5
- package/src/components/tabs-subtle.tsx +10 -2
- package/src/components/tabs.tsx +23 -3
- package/src/components/textarea.tsx +2 -3
- package/src/components/thinking-steps.tsx +3 -3
- package/src/components/toggle.tsx +14 -13
- package/src/components/tooltip.tsx +11 -8
- package/src/components/url-redirect-prompt.tsx +0 -1
- package/src/index.ts +15 -0
- package/src/lib/density-context.tsx +65 -0
- package/src/lib/format.tsx +301 -0
- package/src/lib/interaction.ts +12 -4
- package/src/lib/scroll-fade.tsx +39 -0
- package/src/tokens/tokens.test.ts +77 -0
- package/src/tokens/tokens.ts +114 -5
- package/dist/chunk-RI6QJR2L.js.map +0 -1
|
@@ -1,55 +1,164 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { CalendarIcon } from "lucide-react";
|
|
4
|
+
import { CalendarIcon, X } from "lucide-react";
|
|
5
|
+
import type { DateRange as DayPickerDateRange, Matcher } from "react-day-picker";
|
|
5
6
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
6
7
|
import { Calendar } from "./calendar";
|
|
7
8
|
import { Button } from "./button";
|
|
8
9
|
import { cn } from "../lib/utils";
|
|
10
|
+
import { formatDate, formatDateRange, useFormatter } from "../lib/format";
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
date.toLocaleDateString("en-US", {
|
|
12
|
-
month: "long",
|
|
13
|
-
day: "numeric",
|
|
14
|
-
year: "numeric",
|
|
15
|
-
});
|
|
12
|
+
type DateRange = { from: Date | undefined; to?: Date | undefined };
|
|
16
13
|
|
|
17
|
-
interface
|
|
18
|
-
value?: Date | undefined;
|
|
19
|
-
onChange?: (date: Date | undefined) => void;
|
|
14
|
+
interface DatePickerBaseProps {
|
|
20
15
|
placeholder?: string;
|
|
21
16
|
disabled?: boolean;
|
|
17
|
+
/** Earliest selectable day. */
|
|
18
|
+
minDate?: Date;
|
|
19
|
+
/** Latest selectable day. */
|
|
20
|
+
maxDate?: Date;
|
|
21
|
+
/** Show a clear (×) button when a value is set. */
|
|
22
|
+
clearable?: boolean;
|
|
23
|
+
/** BCP-47 locale for the trigger label. Defaults to FormatProvider. */
|
|
24
|
+
locale?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Stretch the trigger to its container (default, like Input). Pass `false`
|
|
27
|
+
* for an inline 15rem trigger, or size it with `containerClassName`.
|
|
28
|
+
*/
|
|
29
|
+
fullWidth?: boolean;
|
|
30
|
+
/** Classes for the trigger wrapper (width, margins). `className` targets the button. */
|
|
31
|
+
containerClassName?: string;
|
|
32
|
+
id?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
"aria-label"?: string;
|
|
35
|
+
"aria-invalid"?: boolean;
|
|
22
36
|
className?: string;
|
|
23
37
|
}
|
|
24
38
|
|
|
39
|
+
interface DatePickerProps extends DatePickerBaseProps {
|
|
40
|
+
value?: Date | undefined;
|
|
41
|
+
onChange?: (date: Date | undefined) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function disabledMatchers(minDate?: Date, maxDate?: Date): Matcher[] | undefined {
|
|
45
|
+
const matchers: Matcher[] = [];
|
|
46
|
+
if (minDate) matchers.push({ before: minDate });
|
|
47
|
+
if (maxDate) matchers.push({ after: maxDate });
|
|
48
|
+
return matchers.length ? matchers : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TriggerProps extends DatePickerBaseProps {
|
|
52
|
+
label: React.ReactNode;
|
|
53
|
+
empty: boolean;
|
|
54
|
+
onClear: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DatePickerTrigger = React.forwardRef<HTMLButtonElement, TriggerProps>(
|
|
58
|
+
(
|
|
59
|
+
{
|
|
60
|
+
label,
|
|
61
|
+
empty,
|
|
62
|
+
onClear,
|
|
63
|
+
clearable,
|
|
64
|
+
disabled,
|
|
65
|
+
fullWidth = true,
|
|
66
|
+
containerClassName,
|
|
67
|
+
className,
|
|
68
|
+
id,
|
|
69
|
+
"aria-label": ariaLabel,
|
|
70
|
+
"aria-invalid": ariaInvalid,
|
|
71
|
+
...rest
|
|
72
|
+
},
|
|
73
|
+
ref,
|
|
74
|
+
) => (
|
|
75
|
+
<div
|
|
76
|
+
className={cn(
|
|
77
|
+
"relative inline-flex",
|
|
78
|
+
fullWidth ? "w-full" : "w-60 max-w-full",
|
|
79
|
+
containerClassName,
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<Button
|
|
83
|
+
ref={ref}
|
|
84
|
+
type="button"
|
|
85
|
+
variant="outline"
|
|
86
|
+
id={id}
|
|
87
|
+
aria-label={ariaLabel}
|
|
88
|
+
aria-invalid={ariaInvalid}
|
|
89
|
+
className={cn(
|
|
90
|
+
"w-full justify-start gap-2 text-left font-normal",
|
|
91
|
+
empty && "text-muted-foreground",
|
|
92
|
+
clearable && !empty && "pr-8",
|
|
93
|
+
ariaInvalid && "border-destructive",
|
|
94
|
+
className,
|
|
95
|
+
)}
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
{...rest}
|
|
98
|
+
>
|
|
99
|
+
<CalendarIcon size={14} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
100
|
+
<span className="min-w-0 truncate">{label}</span>
|
|
101
|
+
</Button>
|
|
102
|
+
{clearable && !empty && !disabled && (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
aria-label="Clear date"
|
|
106
|
+
onClick={(event) => {
|
|
107
|
+
event.stopPropagation();
|
|
108
|
+
onClear();
|
|
109
|
+
}}
|
|
110
|
+
className="absolute top-1/2 right-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
111
|
+
>
|
|
112
|
+
<X size={12} aria-hidden="true" />
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
DatePickerTrigger.displayName = "DatePickerTrigger";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Single-date picker — a button trigger + calendar popover. Replaces native
|
|
122
|
+
* `<input type="date">` so dates look and localise the same everywhere.
|
|
123
|
+
* Pair with `toISODate()` / `toDate()` when your API speaks "YYYY-MM-DD".
|
|
124
|
+
*/
|
|
25
125
|
function DatePicker({
|
|
26
126
|
value,
|
|
27
127
|
onChange,
|
|
28
128
|
placeholder = "Pick a date",
|
|
29
|
-
|
|
30
|
-
|
|
129
|
+
minDate,
|
|
130
|
+
maxDate,
|
|
131
|
+
locale,
|
|
132
|
+
...rest
|
|
31
133
|
}: DatePickerProps) {
|
|
134
|
+
const fmt = useFormatter();
|
|
135
|
+
const [open, setOpen] = React.useState(false);
|
|
136
|
+
const resolvedLocale = locale ?? fmt.locale;
|
|
137
|
+
const label = value
|
|
138
|
+
? formatDate(value, { style: "long", ...(resolvedLocale ? { locale: resolvedLocale } : {}) })
|
|
139
|
+
: placeholder;
|
|
140
|
+
const disabledDays = disabledMatchers(minDate, maxDate);
|
|
141
|
+
|
|
32
142
|
return (
|
|
33
|
-
<Popover>
|
|
143
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
34
144
|
<PopoverTrigger asChild>
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
)}
|
|
42
|
-
disabled={disabled}
|
|
43
|
-
>
|
|
44
|
-
<CalendarIcon size={14} className="text-muted-foreground shrink-0" />
|
|
45
|
-
{value ? formatDate(value) : <span>{placeholder}</span>}
|
|
46
|
-
</Button>
|
|
145
|
+
<DatePickerTrigger
|
|
146
|
+
{...rest}
|
|
147
|
+
label={label}
|
|
148
|
+
empty={!value}
|
|
149
|
+
onClear={() => onChange?.(undefined)}
|
|
150
|
+
/>
|
|
47
151
|
</PopoverTrigger>
|
|
48
152
|
<PopoverContent className="w-auto p-0 shadow-elevated" align="start">
|
|
49
153
|
<Calendar
|
|
50
154
|
mode="single"
|
|
51
|
-
{...(value !== undefined && { selected: value })}
|
|
52
|
-
{...(
|
|
155
|
+
{...(value !== undefined && { selected: value, defaultMonth: value })}
|
|
156
|
+
{...(disabledDays && { disabled: disabledDays })}
|
|
157
|
+
onSelect={(date: Date | undefined) => {
|
|
158
|
+
onChange?.(date);
|
|
159
|
+
if (date) setOpen(false);
|
|
160
|
+
}}
|
|
161
|
+
// eslint-disable-next-line jsx-a11y/no-autofocus -- moves focus into the calendar when the popover opens (dialog focus management)
|
|
53
162
|
autoFocus
|
|
54
163
|
/>
|
|
55
164
|
</PopoverContent>
|
|
@@ -58,4 +167,142 @@ function DatePicker({
|
|
|
58
167
|
}
|
|
59
168
|
DatePicker.displayName = "DatePicker";
|
|
60
169
|
|
|
61
|
-
|
|
170
|
+
interface DateRangePreset {
|
|
171
|
+
label: string;
|
|
172
|
+
range: () => DateRange;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function daysAgo(days: number) {
|
|
176
|
+
const d = new Date();
|
|
177
|
+
d.setHours(0, 0, 0, 0);
|
|
178
|
+
d.setDate(d.getDate() - days);
|
|
179
|
+
return d;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function today() {
|
|
183
|
+
const d = new Date();
|
|
184
|
+
d.setHours(0, 0, 0, 0);
|
|
185
|
+
return d;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Common analytics / reporting presets. */
|
|
189
|
+
const DEFAULT_DATE_RANGE_PRESETS: DateRangePreset[] = [
|
|
190
|
+
{ label: "Today", range: () => ({ from: today(), to: today() }) },
|
|
191
|
+
{ label: "Last 7 days", range: () => ({ from: daysAgo(6), to: today() }) },
|
|
192
|
+
{ label: "Last 30 days", range: () => ({ from: daysAgo(29), to: today() }) },
|
|
193
|
+
{ label: "Last 90 days", range: () => ({ from: daysAgo(89), to: today() }) },
|
|
194
|
+
{
|
|
195
|
+
label: "Month to date",
|
|
196
|
+
range: () => {
|
|
197
|
+
const t = today();
|
|
198
|
+
return { from: new Date(t.getFullYear(), t.getMonth(), 1), to: t };
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
label: "Year to date",
|
|
203
|
+
range: () => {
|
|
204
|
+
const t = today();
|
|
205
|
+
return { from: new Date(t.getFullYear(), 0, 1), to: t };
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
interface DateRangePickerProps extends DatePickerBaseProps {
|
|
211
|
+
value?: DateRange | undefined;
|
|
212
|
+
/** Shorthand for `value={{ from, to }}`. Ignored when `value` is set. */
|
|
213
|
+
from?: Date | undefined;
|
|
214
|
+
/** Shorthand for `value={{ from, to }}`. Ignored when `value` is set. */
|
|
215
|
+
to?: Date | undefined;
|
|
216
|
+
onChange?: (range: DateRange | undefined) => void;
|
|
217
|
+
/** Quick ranges shown beside the calendar. Pass `[]` to hide. */
|
|
218
|
+
presets?: DateRangePreset[];
|
|
219
|
+
/** Months shown side-by-side from `sm` up. Default: 2. */
|
|
220
|
+
numberOfMonths?: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Date-range picker with optional presets ("Last 30 days") — for analytics
|
|
225
|
+
* filters, reports and exports. Replaces pairs of native date inputs.
|
|
226
|
+
*/
|
|
227
|
+
function DateRangePicker({
|
|
228
|
+
value: valueProp,
|
|
229
|
+
from,
|
|
230
|
+
to,
|
|
231
|
+
onChange,
|
|
232
|
+
placeholder = "Pick a date range",
|
|
233
|
+
minDate,
|
|
234
|
+
maxDate,
|
|
235
|
+
locale,
|
|
236
|
+
presets = DEFAULT_DATE_RANGE_PRESETS,
|
|
237
|
+
numberOfMonths = 2,
|
|
238
|
+
...rest
|
|
239
|
+
}: DateRangePickerProps) {
|
|
240
|
+
const fmt = useFormatter();
|
|
241
|
+
const [open, setOpen] = React.useState(false);
|
|
242
|
+
const value = valueProp ?? (from || to ? { from, to } : undefined);
|
|
243
|
+
const resolvedLocale = locale ?? fmt.locale;
|
|
244
|
+
const label = value?.from
|
|
245
|
+
? formatDateRange(value.from, value.to ?? value.from, {
|
|
246
|
+
...(resolvedLocale ? { locale: resolvedLocale } : {}),
|
|
247
|
+
})
|
|
248
|
+
: placeholder;
|
|
249
|
+
const disabledDays = disabledMatchers(minDate, maxDate);
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
253
|
+
<PopoverTrigger asChild>
|
|
254
|
+
<DatePickerTrigger
|
|
255
|
+
{...rest}
|
|
256
|
+
label={label}
|
|
257
|
+
empty={!value?.from}
|
|
258
|
+
onClear={() => onChange?.(undefined)}
|
|
259
|
+
/>
|
|
260
|
+
</PopoverTrigger>
|
|
261
|
+
<PopoverContent className="w-auto max-w-[calc(100vw-2rem)] p-0 shadow-elevated" align="start">
|
|
262
|
+
<div className="flex flex-col sm:flex-row">
|
|
263
|
+
{presets.length > 0 && (
|
|
264
|
+
<div
|
|
265
|
+
role="group"
|
|
266
|
+
aria-label="Preset ranges"
|
|
267
|
+
className="flex gap-1 overflow-x-auto border-b border-border p-2 [scrollbar-width:none] sm:w-40 sm:flex-col sm:overflow-visible sm:border-r sm:border-b-0"
|
|
268
|
+
>
|
|
269
|
+
{presets.map((preset) => (
|
|
270
|
+
<Button
|
|
271
|
+
key={preset.label}
|
|
272
|
+
type="button"
|
|
273
|
+
size="sm"
|
|
274
|
+
variant="ghost"
|
|
275
|
+
className="shrink-0 justify-start"
|
|
276
|
+
onClick={() => {
|
|
277
|
+
onChange?.(preset.range());
|
|
278
|
+
setOpen(false);
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
{preset.label}
|
|
282
|
+
</Button>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
<Calendar
|
|
287
|
+
mode="range"
|
|
288
|
+
numberOfMonths={numberOfMonths}
|
|
289
|
+
{...(value?.from && {
|
|
290
|
+
selected: value as DayPickerDateRange,
|
|
291
|
+
defaultMonth: value.from,
|
|
292
|
+
})}
|
|
293
|
+
{...(disabledDays && { disabled: disabledDays })}
|
|
294
|
+
onSelect={(range: DayPickerDateRange | undefined) =>
|
|
295
|
+
onChange?.(range ? { from: range.from, to: range.to } : undefined)
|
|
296
|
+
}
|
|
297
|
+
// eslint-disable-next-line jsx-a11y/no-autofocus -- moves focus into the calendar when the popover opens (dialog focus management)
|
|
298
|
+
autoFocus
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
</PopoverContent>
|
|
302
|
+
</Popover>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
DateRangePicker.displayName = "DateRangePicker";
|
|
306
|
+
|
|
307
|
+
export { DatePicker, DateRangePicker, DEFAULT_DATE_RANGE_PRESETS };
|
|
308
|
+
export type { DatePickerProps, DateRangePickerProps, DateRange, DateRangePreset };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import {
|
|
6
|
+
formatDate,
|
|
7
|
+
formatRelativeTime,
|
|
8
|
+
toDate,
|
|
9
|
+
useFormatter,
|
|
10
|
+
type DateFormatStyle,
|
|
11
|
+
type DateInput,
|
|
12
|
+
} from "../lib/format";
|
|
13
|
+
|
|
14
|
+
interface DateTextProps extends Omit<React.HTMLAttributes<HTMLTimeElement>, "children"> {
|
|
15
|
+
/** Date, ISO string, or epoch ms. Empty/invalid renders `fallback`. */
|
|
16
|
+
value: DateInput;
|
|
17
|
+
/**
|
|
18
|
+
* Preset. `date`/`short` → "Sep 26, 2026" (default), `datetime` → "Sep 26,
|
|
19
|
+
* 2026, 3:04 PM", `monthDay` → "Sep 26", `long`, `month`, `time`, `iso`, or
|
|
20
|
+
* `relative` → "3 hours ago".
|
|
21
|
+
*/
|
|
22
|
+
format?: DateFormatStyle | "relative";
|
|
23
|
+
locale?: string;
|
|
24
|
+
timeZone?: string;
|
|
25
|
+
/** Shown when `value` is empty or invalid. Default: "—". */
|
|
26
|
+
fallback?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders a semantic `<time>` with a consistent, locale-aware format and the
|
|
31
|
+
* full date-time as a hover title. Use it for every date in tables, detail
|
|
32
|
+
* sidebars and timelines instead of `toLocaleDateString()` or raw ISO strings.
|
|
33
|
+
*/
|
|
34
|
+
function DateText({
|
|
35
|
+
value,
|
|
36
|
+
format = "date",
|
|
37
|
+
locale,
|
|
38
|
+
timeZone,
|
|
39
|
+
fallback = "—",
|
|
40
|
+
className,
|
|
41
|
+
title,
|
|
42
|
+
...props
|
|
43
|
+
}: DateTextProps) {
|
|
44
|
+
const fmt = useFormatter();
|
|
45
|
+
const date = toDate(value);
|
|
46
|
+
const opts = {
|
|
47
|
+
...(locale ?? fmt.locale ? { locale: locale ?? fmt.locale } : {}),
|
|
48
|
+
...(timeZone ?? fmt.timeZone ? { timeZone: timeZone ?? fmt.timeZone } : {}),
|
|
49
|
+
};
|
|
50
|
+
if (!date) {
|
|
51
|
+
return <span className={cn("text-muted-foreground", className)}>{fallback}</span>;
|
|
52
|
+
}
|
|
53
|
+
const text =
|
|
54
|
+
format === "relative"
|
|
55
|
+
? formatRelativeTime(date, opts)
|
|
56
|
+
: formatDate(date, { ...opts, style: format });
|
|
57
|
+
return (
|
|
58
|
+
<time
|
|
59
|
+
dateTime={date.toISOString()}
|
|
60
|
+
title={title ?? formatDate(date, { ...opts, style: "datetime" })}
|
|
61
|
+
className={cn("tabular-nums", className)}
|
|
62
|
+
{...props}
|
|
63
|
+
>
|
|
64
|
+
{text}
|
|
65
|
+
</time>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
DateText.displayName = "DateText";
|
|
70
|
+
|
|
71
|
+
interface RelativeTimeProps extends Omit<DateTextProps, "format"> {
|
|
72
|
+
/** Re-render on an interval so "just now" ages. Default: 60s. `0` disables. */
|
|
73
|
+
updateInterval?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** "5 minutes ago" that keeps itself fresh; falls back to a date after 7 days. */
|
|
77
|
+
function RelativeTime({ updateInterval = 60_000, ...props }: RelativeTimeProps) {
|
|
78
|
+
const [, force] = React.useReducer((n: number) => n + 1, 0);
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
if (!updateInterval) return;
|
|
81
|
+
const id = window.setInterval(force, updateInterval);
|
|
82
|
+
return () => window.clearInterval(id);
|
|
83
|
+
}, [updateInterval]);
|
|
84
|
+
return <DateText {...props} format="relative" />;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
RelativeTime.displayName = "RelativeTime";
|
|
88
|
+
|
|
89
|
+
export { DateText, RelativeTime };
|
|
90
|
+
export type { DateTextProps, RelativeTimeProps };
|
|
@@ -4,6 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import { DropdownMenu } from "radix-ui";
|
|
5
5
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
|
+
import { useDensityAttributes } from "../lib/density-context";
|
|
7
8
|
import {
|
|
8
9
|
menuItemActiveClasses,
|
|
9
10
|
menuItemClasses,
|
|
@@ -32,6 +33,7 @@ const DropdownMenuContent = React.forwardRef<
|
|
|
32
33
|
<style>{mobilePopperSheetStyle}</style>
|
|
33
34
|
<DropdownMenu.Portal>
|
|
34
35
|
<DropdownMenu.Content
|
|
36
|
+
{...useDensityAttributes()}
|
|
35
37
|
ref={ref}
|
|
36
38
|
data-hilum-mobile-sheet="true"
|
|
37
39
|
sideOffset={sideOffset}
|
|
@@ -70,10 +72,10 @@ const DropdownMenuItem = React.forwardRef<
|
|
|
70
72
|
pressClasses,
|
|
71
73
|
destructive
|
|
72
74
|
? "text-destructive focus:bg-destructive/10 focus:text-destructive"
|
|
73
|
-
: "text-
|
|
75
|
+
: "text-foreground focus:bg-active",
|
|
74
76
|
!destructive && menuItemActiveClasses,
|
|
75
77
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
76
|
-
inset && "pl-8",
|
|
78
|
+
inset && "pl-8 compact:pl-7",
|
|
77
79
|
className,
|
|
78
80
|
)}
|
|
79
81
|
{...props}
|
|
@@ -90,7 +92,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|
|
90
92
|
className={cn(
|
|
91
93
|
menuItemClasses,
|
|
92
94
|
menuItemActiveClasses,
|
|
93
|
-
"py-2 pl-8 pr-2.5 text-
|
|
95
|
+
"py-2 pl-8 pr-2.5 text-foreground compact:pl-7 compact:pr-2",
|
|
94
96
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
95
97
|
className,
|
|
96
98
|
)}
|
|
@@ -115,7 +117,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
|
115
117
|
className={cn(
|
|
116
118
|
menuItemClasses,
|
|
117
119
|
menuItemActiveClasses,
|
|
118
|
-
"py-2 pl-8 pr-2.5 text-
|
|
120
|
+
"py-2 pl-8 pr-2.5 text-foreground compact:pl-7 compact:pr-2",
|
|
119
121
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
120
122
|
className,
|
|
121
123
|
)}
|
|
@@ -139,7 +141,7 @@ const DropdownMenuLabel = React.forwardRef<
|
|
|
139
141
|
>(({ className, inset, ...props }, ref) => (
|
|
140
142
|
<DropdownMenu.Label
|
|
141
143
|
ref={ref}
|
|
142
|
-
className={cn("px-2.5 py-1 label text-muted-foreground", inset && "pl-8", className)}
|
|
144
|
+
className={cn("px-2.5 py-1 label text-muted-foreground compact:px-2 compact:text-[11px]", inset && "pl-8 compact:pl-7", className)}
|
|
143
145
|
{...props}
|
|
144
146
|
/>
|
|
145
147
|
));
|
|
@@ -151,7 +153,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
|
|
151
153
|
>(({ className, ...props }, ref) => (
|
|
152
154
|
<DropdownMenu.Separator
|
|
153
155
|
ref={ref}
|
|
154
|
-
className={cn("mx-1 my-1 h-px bg-
|
|
156
|
+
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
155
157
|
{...props}
|
|
156
158
|
/>
|
|
157
159
|
));
|
|
@@ -168,8 +170,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|
|
168
170
|
className={cn(
|
|
169
171
|
menuItemClasses,
|
|
170
172
|
menuItemActiveClasses,
|
|
171
|
-
"text-
|
|
172
|
-
inset && "pl-8",
|
|
173
|
+
"text-foreground data-[state=open]:bg-active",
|
|
174
|
+
inset && "pl-8 compact:pl-7",
|
|
173
175
|
className,
|
|
174
176
|
)}
|
|
175
177
|
{...props}
|
|
@@ -187,6 +189,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|
|
187
189
|
<>
|
|
188
190
|
<style>{mobilePopperSheetStyle}</style>
|
|
189
191
|
<DropdownMenu.SubContent
|
|
192
|
+
{...useDensityAttributes()}
|
|
190
193
|
ref={ref}
|
|
191
194
|
data-hilum-mobile-sheet="true"
|
|
192
195
|
className={cn(
|
|
@@ -29,6 +29,9 @@ interface DropdownContextValue {
|
|
|
29
29
|
checkedIndex?: number;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Every menu-item flavour participates in arrow-key navigation.
|
|
33
|
+
const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]';
|
|
34
|
+
|
|
32
35
|
const DropdownContext = createContext<DropdownContextValue | null>(null);
|
|
33
36
|
|
|
34
37
|
export function useDropdown() {
|
|
@@ -101,7 +104,7 @@ const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
|
|
|
101
104
|
}}
|
|
102
105
|
onKeyDown={(e) => {
|
|
103
106
|
const items = Array.from(
|
|
104
|
-
containerRef.current?.querySelectorAll(
|
|
107
|
+
containerRef.current?.querySelectorAll(MENU_ITEM_SELECTOR) ?? [],
|
|
105
108
|
) as HTMLElement[];
|
|
106
109
|
const currentIdx = items.indexOf(e.target as HTMLElement);
|
|
107
110
|
if (currentIdx === -1) return;
|
|
@@ -182,7 +185,7 @@ const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
|
|
|
182
185
|
<AnimatePresence>
|
|
183
186
|
{focusRect && (
|
|
184
187
|
<motion.div
|
|
185
|
-
className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-
|
|
188
|
+
className={`absolute ${shape.focusRing} pointer-events-none z-20 border-2 border-ring`}
|
|
186
189
|
initial={false}
|
|
187
190
|
animate={{
|
|
188
191
|
left: focusRect.left - 2,
|
|
@@ -233,7 +236,7 @@ const DropdownSeparator = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
|
|
|
233
236
|
<div
|
|
234
237
|
ref={ref}
|
|
235
238
|
role="separator"
|
|
236
|
-
className={cn("my-1 -mx-1 h-px bg-border
|
|
239
|
+
className={cn("my-1 -mx-1 h-px bg-border", className)}
|
|
237
240
|
{...props}
|
|
238
241
|
/>
|
|
239
242
|
),
|
|
@@ -2,42 +2,106 @@ import * as React from "react";
|
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
3
|
import { Button } from "./button";
|
|
4
4
|
|
|
5
|
+
interface EmptyStateAction {
|
|
6
|
+
label: string;
|
|
7
|
+
href?: string;
|
|
8
|
+
onClick?: () => void;
|
|
9
|
+
/** Optional leading icon. */
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
interface EmptyStateProps {
|
|
6
14
|
icon?: React.ReactNode;
|
|
7
15
|
title: string;
|
|
8
|
-
description?:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
description?: React.ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* Primary call to action — every first-run empty state should have one
|
|
19
|
+
* ("Add product", "Create discount"). Pass an element for router links.
|
|
20
|
+
*/
|
|
21
|
+
action?: EmptyStateAction | React.ReactElement;
|
|
22
|
+
/** Secondary, lower-emphasis action (e.g. "Import CSV", "Learn more"). */
|
|
23
|
+
secondaryAction?: EmptyStateAction | React.ReactElement;
|
|
24
|
+
/**
|
|
25
|
+
* `plain` (default) — sits inside an existing card/table.
|
|
26
|
+
* `card` — standalone bordered surface for a whole page section.
|
|
27
|
+
*/
|
|
28
|
+
variant?: "plain" | "card";
|
|
29
|
+
/** `sm` for inline table/list bodies, `md` (default) for page sections. */
|
|
30
|
+
size?: "sm" | "md";
|
|
31
|
+
/** Extra content below the actions (e.g. a help link or illustration). */
|
|
32
|
+
children?: React.ReactNode;
|
|
14
33
|
className?: string;
|
|
15
34
|
}
|
|
16
35
|
|
|
17
|
-
function
|
|
36
|
+
function renderAction(
|
|
37
|
+
action: EmptyStateAction | React.ReactElement,
|
|
38
|
+
variant: "primary" | "outline",
|
|
39
|
+
) {
|
|
40
|
+
if (React.isValidElement(action)) return action;
|
|
41
|
+
const { label, href, onClick, icon } = action as EmptyStateAction;
|
|
42
|
+
const content = (
|
|
43
|
+
<>
|
|
44
|
+
{icon && (
|
|
45
|
+
<span className="inline-flex shrink-0 [&_svg]:size-3.5" aria-hidden="true">
|
|
46
|
+
{icon}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
{label}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
return href ? (
|
|
53
|
+
<Button size="sm" variant={variant} asChild>
|
|
54
|
+
<a href={href}>{content}</a>
|
|
55
|
+
</Button>
|
|
56
|
+
) : (
|
|
57
|
+
<Button size="sm" variant={variant} onClick={onClick}>
|
|
58
|
+
{content}
|
|
59
|
+
</Button>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function EmptyState({
|
|
64
|
+
icon,
|
|
65
|
+
title,
|
|
66
|
+
description,
|
|
67
|
+
action,
|
|
68
|
+
secondaryAction,
|
|
69
|
+
variant = "plain",
|
|
70
|
+
size = "md",
|
|
71
|
+
children,
|
|
72
|
+
className,
|
|
73
|
+
}: EmptyStateProps) {
|
|
18
74
|
return (
|
|
19
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
data-slot="empty-state"
|
|
77
|
+
className={cn(
|
|
78
|
+
"flex flex-col items-center gap-4 text-center",
|
|
79
|
+
size === "sm" ? "px-4 py-8" : "px-6 py-14",
|
|
80
|
+
variant === "card" && "rounded-xl border border-dashed border-border bg-card",
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
20
84
|
{icon && (
|
|
21
|
-
<div
|
|
85
|
+
<div
|
|
86
|
+
className="flex size-9 items-center justify-center rounded-md bg-brand-secondary/20 text-muted-foreground"
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
>
|
|
22
89
|
{icon}
|
|
23
90
|
</div>
|
|
24
91
|
)}
|
|
25
92
|
<div className="flex flex-col gap-1.5">
|
|
26
93
|
<p className="body text-balance font-semibold text-foreground">{title}</p>
|
|
27
94
|
{description && (
|
|
28
|
-
<p className="body max-w-
|
|
95
|
+
<p className="body max-w-sm text-pretty text-muted-foreground">{description}</p>
|
|
29
96
|
)}
|
|
30
97
|
</div>
|
|
31
|
-
{action &&
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{action.label}
|
|
39
|
-
</Button>
|
|
40
|
-
))}
|
|
98
|
+
{(action || secondaryAction) && (
|
|
99
|
+
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
100
|
+
{action && renderAction(action, "primary")}
|
|
101
|
+
{secondaryAction && renderAction(secondaryAction, "outline")}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
{children}
|
|
41
105
|
</div>
|
|
42
106
|
);
|
|
43
107
|
}
|
|
@@ -45,3 +109,4 @@ function EmptyState({ icon, title, description, action, className }: EmptyStateP
|
|
|
45
109
|
EmptyState.displayName = "EmptyState";
|
|
46
110
|
|
|
47
111
|
export { EmptyState };
|
|
112
|
+
export type { EmptyStateProps, EmptyStateAction };
|
|
@@ -83,6 +83,7 @@ function FileDropzone({
|
|
|
83
83
|
return (
|
|
84
84
|
<label
|
|
85
85
|
htmlFor={resolvedId}
|
|
86
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- label for the hidden file input, made a focusable button with Enter/Space handling (onKeyDown) so it also works as a drop target
|
|
86
87
|
role="button"
|
|
87
88
|
tabIndex={isUnavailable ? -1 : (tabIndex ?? 0)}
|
|
88
89
|
aria-disabled={isUnavailable || undefined}
|
|
@@ -91,7 +92,7 @@ function FileDropzone({
|
|
|
91
92
|
"group flex min-h-36 cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card p-6 text-center shadow-natural",
|
|
92
93
|
"transition-[background-color,border-color,box-shadow,scale] duration-150",
|
|
93
94
|
"hover:border-brand-primary/50 hover:bg-muted/40 active:scale-[0.96]",
|
|
94
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-
|
|
95
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
95
96
|
"data-[dragging=true]:border-brand-primary data-[dragging=true]:bg-brand-secondary/25",
|
|
96
97
|
isUnavailable && "pointer-events-none cursor-not-allowed opacity-60 active:scale-100",
|
|
97
98
|
className,
|