@hilum/ui 3.8.6 → 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.
Files changed (90) hide show
  1. package/README.md +32 -1
  2. package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
  3. package/dist/chunk-R576DV2A.js.map +1 -0
  4. package/dist/create-theme.js +2 -1
  5. package/dist/create-theme.js.map +1 -1
  6. package/dist/index.d.ts +733 -68
  7. package/dist/index.js +3097 -1619
  8. package/dist/index.js.map +1 -1
  9. package/dist/tokens.css +163 -19
  10. package/dist/tokens.d.ts +69 -5
  11. package/dist/tokens.js +1 -1
  12. package/llms.txt +11 -4
  13. package/package.json +11 -10
  14. package/src/components/accordion.tsx +2 -2
  15. package/src/components/account-menu.tsx +1 -1
  16. package/src/components/alert-dialog.tsx +107 -0
  17. package/src/components/ask-user-questions.tsx +24 -18
  18. package/src/components/badge.tsx +36 -3
  19. package/src/components/breadcrumb.tsx +4 -2
  20. package/src/components/button-group.tsx +1 -1
  21. package/src/components/button.tsx +31 -23
  22. package/src/components/calendar.tsx +1 -1
  23. package/src/components/card.tsx +4 -2
  24. package/src/components/checkbox-card.tsx +1 -1
  25. package/src/components/checkbox-group.tsx +2 -1
  26. package/src/components/code-block.tsx +99 -0
  27. package/src/components/color-input.tsx +9 -6
  28. package/src/components/color-picker.tsx +38 -26
  29. package/src/components/combobox.tsx +59 -12
  30. package/src/components/command-palette.tsx +150 -42
  31. package/src/components/command.tsx +5 -3
  32. package/src/components/context-menu.tsx +18 -15
  33. package/src/components/contextual-save-bar.tsx +156 -0
  34. package/src/components/data-table.tsx +34 -5
  35. package/src/components/date-picker.tsx +275 -28
  36. package/src/components/date-text.tsx +90 -0
  37. package/src/components/dropdown-menu.tsx +11 -8
  38. package/src/components/dropdown.tsx +6 -3
  39. package/src/components/empty-state.tsx +85 -20
  40. package/src/components/file-dropzone.tsx +2 -1
  41. package/src/components/file-thumbnail.tsx +84 -20
  42. package/src/components/filter-bar.tsx +91 -0
  43. package/src/components/grid-list.tsx +2 -2
  44. package/src/components/help-tooltip.tsx +1 -1
  45. package/src/components/input-copy.tsx +1 -1
  46. package/src/components/input-group.tsx +1 -1
  47. package/src/components/input-message.tsx +5 -4
  48. package/src/components/input-number.tsx +103 -11
  49. package/src/components/input.tsx +5 -3
  50. package/src/components/label.tsx +1 -0
  51. package/src/components/media-asset-card.tsx +1 -1
  52. package/src/components/menu-item.tsx +37 -3
  53. package/src/components/menubar.tsx +21 -18
  54. package/src/components/native-select.tsx +1 -1
  55. package/src/components/navigation-menu.tsx +2 -2
  56. package/src/components/notification.tsx +1 -1
  57. package/src/components/pagination.tsx +97 -3
  58. package/src/components/popover.tsx +2 -0
  59. package/src/components/property-row.tsx +7 -4
  60. package/src/components/radio-card.tsx +1 -1
  61. package/src/components/radio-group.tsx +2 -1
  62. package/src/components/rating.tsx +136 -0
  63. package/src/components/resizable.tsx +1 -1
  64. package/src/components/resource-item.tsx +133 -0
  65. package/src/components/rich-text-editor.tsx +1 -0
  66. package/src/components/search-input.tsx +98 -0
  67. package/src/components/searchable-table.tsx +10 -11
  68. package/src/components/select.tsx +28 -9
  69. package/src/components/sidebar.tsx +4 -4
  70. package/src/components/slider.tsx +78 -36
  71. package/src/components/sonner.tsx +18 -4
  72. package/src/components/stat-card.tsx +121 -37
  73. package/src/components/status-badge.tsx +142 -47
  74. package/src/components/status-tile.tsx +9 -0
  75. package/src/components/switch.tsx +6 -5
  76. package/src/components/tabs-subtle.tsx +10 -2
  77. package/src/components/tabs.tsx +23 -3
  78. package/src/components/textarea.tsx +2 -3
  79. package/src/components/thinking-steps.tsx +3 -3
  80. package/src/components/toggle.tsx +14 -13
  81. package/src/components/tooltip.tsx +11 -8
  82. package/src/components/url-redirect-prompt.tsx +0 -1
  83. package/src/index.ts +15 -0
  84. package/src/lib/density-context.tsx +65 -0
  85. package/src/lib/format.tsx +301 -0
  86. package/src/lib/interaction.ts +12 -4
  87. package/src/lib/scroll-fade.tsx +39 -0
  88. package/src/tokens/tokens.test.ts +77 -0
  89. package/src/tokens/tokens.ts +114 -5
  90. 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
- const formatDate = (date: Date) =>
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 DatePickerProps {
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
- disabled,
30
- className,
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
- <Button
36
- variant="outline"
37
- className={cn(
38
- "w-[240px] justify-start gap-2 text-left font-normal",
39
- !value && "text-muted-foreground",
40
- className,
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
- {...(onChange !== undefined && { onSelect: onChange })}
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
- export { DatePicker, formatDate };
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-muted-foreground focus:bg-muted focus:text-foreground",
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-muted-foreground",
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-muted-foreground",
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-muted", className)}
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-muted-foreground data-[state=open]:bg-muted",
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('[role="menuitemradio"]') ?? [],
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-[#6B97FF]`}
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/60", className)}
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?: string;
9
- action?: {
10
- label: string;
11
- href?: string;
12
- onClick?: () => void;
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 EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
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 className={cn("flex flex-col items-center gap-4 px-6 py-14 text-center", className)}>
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 className="flex size-9 items-center justify-center rounded-md bg-brand-secondary/20 text-muted-foreground">
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-xs text-pretty text-muted-foreground">{description}</p>
95
+ <p className="body max-w-sm text-pretty text-muted-foreground">{description}</p>
29
96
  )}
30
97
  </div>
31
- {action &&
32
- (action.href ? (
33
- <Button size="sm" asChild>
34
- <a href={action.href}>{action.label}</a>
35
- </Button>
36
- ) : (
37
- <Button size="sm" onClick={action.onClick}>
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-brand-primary/30",
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,