@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
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Search, X } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Input, type InputProps } from "./input";
7
+ import { Spinner } from "./spinner";
8
+
9
+ interface SearchInputProps extends Omit<InputProps, "type" | "onChange" | "value"> {
10
+ value: string;
11
+ /** Called with the new string (not the event) on every keystroke and on clear. */
12
+ onValueChange: (value: string) => void;
13
+ /** Show a spinner in place of the clear button (e.g. while results load). */
14
+ loading?: boolean;
15
+ /** Accessible name when there is no visible label. Default: placeholder or "Search". */
16
+ "aria-label"?: string;
17
+ /** Classes for the wrapper (width, margins). `className` targets the input. */
18
+ containerClassName?: string;
19
+ }
20
+
21
+ /**
22
+ * Search field with a leading magnifier, clear button and Escape-to-clear.
23
+ * Replaces the hand-built "Input + absolutely positioned Search icon" pattern.
24
+ */
25
+ const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
26
+ (
27
+ {
28
+ value,
29
+ onValueChange,
30
+ loading = false,
31
+ placeholder = "Search",
32
+ className,
33
+ containerClassName,
34
+ onKeyDown,
35
+ "aria-label": ariaLabel,
36
+ ...props
37
+ },
38
+ ref,
39
+ ) => {
40
+ const innerRef = React.useRef<HTMLInputElement>(null);
41
+ React.useImperativeHandle(ref, () => innerRef.current as HTMLInputElement);
42
+
43
+ return (
44
+ <div
45
+ className={cn("relative w-full min-w-0", containerClassName)}
46
+ data-slot="search-input"
47
+ >
48
+ <Search
49
+ className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground compact:left-2 compact:size-3.5"
50
+ aria-hidden="true"
51
+ />
52
+ <Input
53
+ ref={innerRef}
54
+ type="search"
55
+ role="searchbox"
56
+ value={value}
57
+ placeholder={placeholder}
58
+ aria-label={ariaLabel ?? placeholder}
59
+ onChange={(event) => onValueChange(event.target.value)}
60
+ onKeyDown={(event) => {
61
+ if (event.key === "Escape" && value) {
62
+ event.preventDefault();
63
+ onValueChange("");
64
+ }
65
+ onKeyDown?.(event);
66
+ }}
67
+ className={cn(
68
+ "pl-9 pr-9 compact:pl-7 compact:pr-7 [&::-webkit-search-cancel-button]:appearance-none",
69
+ className,
70
+ )}
71
+ {...props}
72
+ />
73
+ <div className="absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center">
74
+ {loading ? (
75
+ <Spinner size="sm" aria-label="Searching" />
76
+ ) : value ? (
77
+ <button
78
+ type="button"
79
+ aria-label="Clear search"
80
+ onClick={() => {
81
+ onValueChange("");
82
+ innerRef.current?.focus();
83
+ }}
84
+ className="flex size-6 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 compact:size-5"
85
+ >
86
+ <X className="size-3.5" aria-hidden="true" />
87
+ </button>
88
+ ) : null}
89
+ </div>
90
+ </div>
91
+ );
92
+ },
93
+ );
94
+
95
+ SearchInput.displayName = "SearchInput";
96
+
97
+ export { SearchInput };
98
+ export type { SearchInputProps };
@@ -3,7 +3,7 @@
3
3
  import * as React from "react";
4
4
  import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, Search } from "lucide-react";
5
5
  import { Button } from "./button";
6
- import { EmptyState } from "./empty-state";
6
+ import { EmptyState, type EmptyStateProps } from "./empty-state";
7
7
  import { InputGroup } from "./input-group";
8
8
  import {
9
9
  Pagination,
@@ -48,16 +48,14 @@ interface SearchableTableProps<T extends { id: string | number }> {
48
48
  placeholder: string;
49
49
  }
50
50
  >;
51
- emptyState?: {
52
- icon?: React.ReactNode;
53
- title: string;
54
- description: string;
55
- action?: {
56
- label: string;
57
- href?: string;
58
- onClick?: () => void;
59
- };
60
- };
51
+ /**
52
+ * Shown when there are no rows. Give first-run states a primary `action`
53
+ * ("Add product"); give filtered-empty states a "Clear filters" action.
54
+ */
55
+ emptyState?: Pick<
56
+ EmptyStateProps,
57
+ "icon" | "title" | "description" | "action" | "secondaryAction"
58
+ >;
61
59
  actions?: (item: T) => React.ReactNode;
62
60
  mobileCard?: (item: T) => React.ReactNode;
63
61
  tableClassName?: string;
@@ -182,6 +180,7 @@ function SearchableTable<T extends { id: string | number }>({
182
180
  {...(className ? { className } : {})}
183
181
  {...(emptyState?.icon ? { icon: normalizeEmptyStateIcon(emptyState.icon) } : {})}
184
182
  {...(emptyState?.action ? { action: emptyState.action } : {})}
183
+ {...(emptyState?.secondaryAction ? { secondaryAction: emptyState.secondaryAction } : {})}
185
184
  />
186
185
  );
187
186
 
@@ -6,6 +6,7 @@ import {
6
6
  useEffect,
7
7
  useState,
8
8
  useCallback,
9
+ useId,
9
10
  createContext,
10
11
  useContext,
11
12
  Children,
@@ -19,6 +20,7 @@ import { motion, AnimatePresence } from "framer-motion";
19
20
  import { cva, type VariantProps } from "class-variance-authority";
20
21
  import type { IconComponent } from "../lib/icon-context";
21
22
  import { cn } from "../lib/utils";
23
+ import { useDensityAttributes } from "../lib/density-context";
22
24
  import { spring } from "../lib/springs";
23
25
  import { useProximityHover } from "../hooks/use-proximity-hover";
24
26
  import { useShape } from "../lib/shape-context";
@@ -37,6 +39,10 @@ interface SelectContextValue {
37
39
  setOpen: (open: boolean) => void;
38
40
  disabled: boolean;
39
41
  triggerRef: React.RefObject<HTMLButtonElement | null>;
42
+ /** Ref callback that attaches the trigger node to `triggerRef`. */
43
+ registerTrigger: (node: HTMLButtonElement | null) => void;
44
+ /** id of the listbox, referenced by the trigger's aria-controls. */
45
+ listboxId: string;
40
46
  labelMap: React.MutableRefObject<Map<string, string>>;
41
47
  }
42
48
 
@@ -85,6 +91,10 @@ function Select({
85
91
  const currentValue = value !== undefined ? value : internalValue;
86
92
  const triggerRef = useRef<HTMLButtonElement>(null);
87
93
  const labelMap = useRef(new Map<string, string>());
94
+ const listboxId = useId();
95
+ const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
96
+ triggerRef.current = node;
97
+ }, []);
88
98
 
89
99
  const onChange = useCallback(
90
100
  (v: string) => {
@@ -105,6 +115,8 @@ function Select({
105
115
  setOpen,
106
116
  disabled,
107
117
  triggerRef,
118
+ registerTrigger,
119
+ listboxId,
108
120
  labelMap,
109
121
  }}
110
122
  >
@@ -126,12 +138,14 @@ const triggerVariants = cva(
126
138
  "text-[13px] h-9 px-3 min-w-[160px]",
127
139
  "transition-all duration-80",
128
140
  "disabled:opacity-50 disabled:pointer-events-none",
129
- "focus-visible:ring-1 focus-visible:ring-[#6B97FF]",
141
+ "focus-visible:ring-2 focus-visible:ring-ring",
142
+ "compact:h-6 compact:min-w-0 compact:gap-1 compact:px-2 compact:text-[12px] compact:rounded-[5px]",
130
143
  ],
131
144
  {
132
145
  variants: {
133
146
  variant: {
134
- bordered: "border border-border bg-transparent text-foreground hover:bg-hover",
147
+ bordered:
148
+ "border border-border bg-transparent text-foreground hover:bg-hover hover:border-border-strong",
135
149
  borderless: "border border-transparent bg-transparent text-foreground hover:bg-hover",
136
150
  },
137
151
  },
@@ -183,7 +197,8 @@ const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
183
197
  },
184
198
  ref,
185
199
  ) => {
186
- const { value, open, setOpen, disabled, triggerRef, labelMap } = useSelectContext();
200
+ const { value, open, setOpen, disabled, registerTrigger, listboxId, labelMap } =
201
+ useSelectContext();
187
202
  const shape = useShape();
188
203
  const label = value ? (labelMap.current.get(value) ?? value) : undefined;
189
204
 
@@ -191,13 +206,14 @@ const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
191
206
  <div className="flex flex-col gap-1">
192
207
  <button
193
208
  ref={(node) => {
194
- (triggerRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
209
+ registerTrigger(node);
195
210
  if (typeof ref === "function") ref(node);
196
211
  else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
197
212
  }}
198
213
  type="button"
199
214
  role="combobox"
200
215
  aria-expanded={open}
216
+ aria-controls={open ? listboxId : undefined}
201
217
  aria-haspopup="listbox"
202
218
  disabled={disabled}
203
219
  onClick={() => setOpen(!open)}
@@ -274,8 +290,9 @@ interface SelectContentProps {
274
290
 
275
291
  const SelectContent = forwardRef<HTMLDivElement, SelectContentProps>(
276
292
  ({ className, children, scrollFade = true }, ref) => {
277
- const { open, setOpen, value, triggerRef } = useSelectContext();
293
+ const { open, setOpen, value, triggerRef, listboxId } = useSelectContext();
278
294
  const shape = useShape();
295
+ const densityAttributes = useDensityAttributes();
279
296
  const containerRef = useRef<HTMLDivElement>(null);
280
297
  const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null);
281
298
 
@@ -311,9 +328,8 @@ const SelectContent = forwardRef<HTMLDivElement, SelectContentProps>(
311
328
  useEffect(() => {
312
329
  if (!open || !triggerRect) return;
313
330
  // Double rAF: first waits for React commit, second for layout
314
- let outer: number;
315
331
  let inner: number;
316
- outer = requestAnimationFrame(() => {
332
+ const outer = requestAnimationFrame(() => {
317
333
  inner = requestAnimationFrame(() => {
318
334
  measureItems();
319
335
  const container = containerRef.current;
@@ -437,6 +453,7 @@ const SelectContent = forwardRef<HTMLDivElement, SelectContentProps>(
437
453
  }
438
454
  >
439
455
  <div
456
+ {...densityAttributes}
440
457
  style={{
441
458
  position: "fixed",
442
459
  top: triggerRect.bottom + 6,
@@ -459,6 +476,7 @@ const SelectContent = forwardRef<HTMLDivElement, SelectContentProps>(
459
476
  if (typeof ref === "function") ref(node);
460
477
  else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
461
478
  }}
479
+ id={listboxId}
462
480
  role="listbox"
463
481
  data-hilum-mobile-sheet="true"
464
482
  tabIndex={-1}
@@ -544,7 +562,7 @@ const SelectContent = forwardRef<HTMLDivElement, SelectContentProps>(
544
562
  <AnimatePresence>
545
563
  {focusRect && (
546
564
  <motion.div
547
- className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[#6B97FF]`}
565
+ className={`absolute ${shape.focusRing} pointer-events-none z-20 border-2 border-ring`}
548
566
  initial={false}
549
567
  animate={{
550
568
  left: focusRect.left - 2,
@@ -644,6 +662,7 @@ const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
644
662
  }}
645
663
  className={cn(
646
664
  `relative z-10 flex items-center gap-2 ${shape.item} px-2 py-2 text-[13px] cursor-pointer outline-none select-none`,
665
+ "compact:min-h-7 compact:py-1 compact:rounded-[4px]",
647
666
  "transition-[color] duration-80",
648
667
  isActive || isChecked ? "text-foreground" : "text-muted-foreground",
649
668
  disabled && "opacity-50 pointer-events-none",
@@ -731,7 +750,7 @@ const SelectSeparator = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement
731
750
  <div
732
751
  ref={ref}
733
752
  role="separator"
734
- className={cn("my-1 -mx-1 h-px bg-border/60", className)}
753
+ className={cn("my-1 -mx-1 h-px bg-border", className)}
735
754
  {...props}
736
755
  />
737
756
  ),
@@ -446,7 +446,7 @@ function SidebarGroupAction({
446
446
  className={cn(
447
447
  "absolute right-2 top-2 flex size-8 items-center justify-center",
448
448
  "rounded-md text-muted-foreground hover:bg-muted hover:text-foreground",
449
- "outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30",
449
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring",
450
450
  className,
451
451
  )}
452
452
  {...props}
@@ -509,7 +509,7 @@ function SidebarMenuButton({
509
509
  "outline-none ring-ring",
510
510
  "transition-[width,height,padding] duration-150",
511
511
  "hover:bg-muted hover:text-foreground",
512
- "focus-visible:ring-2 focus-visible:ring-brand-primary/30",
512
+ "focus-visible:ring-2 focus-visible:ring-ring",
513
513
  "active:bg-muted",
514
514
  "disabled:pointer-events-none disabled:opacity-50",
515
515
  "[&>svg]:size-4 [&>svg]:shrink-0",
@@ -580,7 +580,7 @@ function SidebarMenuAction({
580
580
  className={cn(
581
581
  "absolute right-1 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center",
582
582
  "rounded-md text-muted-foreground hover:bg-muted hover:text-foreground",
583
- "outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30",
583
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring",
584
584
  showOnHover && [
585
585
  "opacity-0 transition-opacity",
586
586
  "group-hover/menu-item:opacity-100",
@@ -699,7 +699,7 @@ function SidebarMenuSubButton({
699
699
  "body text-muted-foreground outline-none",
700
700
  "transition-colors duration-150",
701
701
  "hover:text-foreground hover:bg-muted",
702
- "focus-visible:ring-2 focus-visible:ring-brand-primary/30",
702
+ "focus-visible:ring-2 focus-visible:ring-ring",
703
703
  "disabled:pointer-events-none disabled:opacity-50",
704
704
  "[&>svg]:size-3.5 [&>svg]:shrink-0",
705
705
  isActive && "text-brand-primary hover:text-brand-primary",
@@ -24,6 +24,7 @@ import { cn } from "../lib/utils";
24
24
  import { spring } from "../lib/springs";
25
25
  import { fontWeights } from "../lib/font-weight";
26
26
  import { useShape } from "../lib/shape-context";
27
+ import { useDensity } from "../lib/density-context";
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // Types
@@ -53,19 +54,35 @@ interface SliderProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" |
53
54
  hideFill?: boolean;
54
55
  thumbColor?: string;
55
56
  thumbBorderColor?: string;
57
+ /**
58
+ * Track thickness in px. Default 4 (thin track + filled range). Thick tracks
59
+ * (≥ 8px, e.g. colour-picker hue / alpha strips) keep the thumb inside the
60
+ * rounded ends instead of overhanging them.
61
+ */
62
+ trackSize?: number;
56
63
  }
57
64
 
58
65
  // ---------------------------------------------------------------------------
59
66
  // Constants
60
67
  // ---------------------------------------------------------------------------
61
68
 
69
+ // THUMB_SIZE is the thumb's layout / hit box; the visible knob is smaller.
62
70
  const THUMB_SIZE = 20;
63
- const THUMB_SIZE_REST = 16;
64
- const TRACK_BG_HEIGHT = 18;
71
+ const THUMB_SIZE_REST = 14;
72
+ const THUMB_SIZE_REST_COMPACT = 12;
73
+ const THUMB_SIZE_ACTIVE = 16;
74
+ // Thin 4px track (Figma / Linear style) with a filled range.
75
+ const TRACK_BG_HEIGHT = 4;
65
76
  const DOT_SIZE = 4;
66
77
  const PIP_SIZE = 5;
67
- // Inset track BG so its rounded-end centers align with thumb centers at min/max
68
- const TRACK_INSET = (THUMB_SIZE - TRACK_BG_HEIGHT) / 2;
78
+ // Step pips are only drawn when they stay legible; beyond this the track
79
+ // turns into a dotted texture (e.g. 0–100 step 2 = 50 pips).
80
+ const MAX_STEP_DOTS = 10;
81
+ // Inset the track so its ends line up with the thumb centres at min / max.
82
+ const TRACK_INSET = THUMB_SIZE / 2;
83
+ // Vertical padding around the thumb box (hit-area slop) per density.
84
+ const TRACK_PAD = 8;
85
+ const TRACK_PAD_COMPACT = 4;
69
86
 
70
87
  // ---------------------------------------------------------------------------
71
88
  // Helpers
@@ -112,6 +129,7 @@ interface ValueDisplayProps {
112
129
  label?: string;
113
130
  isRange: boolean;
114
131
  isInteracting: boolean;
132
+ disabled?: boolean;
115
133
  }
116
134
 
117
135
  function ValueDisplay({
@@ -127,6 +145,7 @@ function ValueDisplay({
127
145
  label,
128
146
  isRange,
129
147
  isInteracting,
148
+ disabled,
130
149
  }: ValueDisplayProps) {
131
150
  const shape = useShape();
132
151
  const [inputValue, setInputValue] = useState("");
@@ -194,9 +213,17 @@ function ValueDisplay({
194
213
  }
195
214
 
196
215
  return (
197
- <span className="cursor-text select-none" onClick={() => onStartEdit(index)}>
216
+ // A real button so the "click to type a value" affordance is also
217
+ // reachable by keyboard (Tab + Enter/Space), not just by pointer.
218
+ <button
219
+ type="button"
220
+ className="cursor-text select-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
221
+ aria-label={`Edit slider value${isRange ? (index === 0 ? " (start)" : " (end)") : ""}: ${formatValue(values[index])}`}
222
+ disabled={disabled}
223
+ onClick={() => onStartEdit(index)}
224
+ >
198
225
  {formatValue(values[index])}
199
- </span>
226
+ </button>
200
227
  );
201
228
  };
202
229
 
@@ -308,11 +335,16 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
308
335
  hideFill = false,
309
336
  thumbColor,
310
337
  thumbBorderColor,
338
+ trackSize = TRACK_BG_HEIGHT,
311
339
  className,
312
340
  ...props
313
341
  },
314
342
  ref,
315
343
  ) => {
344
+ const trackHeight = Math.max(2, Math.min(THUMB_SIZE, trackSize));
345
+ // Thin tracks run centre-to-centre between the extreme thumb positions;
346
+ // thick tracks wrap the thumb so its centre sits in the rounded end cap.
347
+ const trackInset = trackHeight >= 8 ? (THUMB_SIZE - trackHeight) / 2 : TRACK_INSET;
316
348
  const [uncontrolledValue, setUncontrolledValue] = useState<SliderValue>(() => {
317
349
  const initial = defaultValue ?? [min];
318
350
  return initial.length > 1
@@ -338,6 +370,8 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
338
370
  const isRange = Array.isArray(resolvedValue);
339
371
  const values = toRadixValue(resolvedValue);
340
372
  const shape = useShape();
373
+ const compact = useDensity() === "compact";
374
+ const trackPad = compact ? TRACK_PAD_COMPACT : TRACK_PAD;
341
375
 
342
376
  // --- Refs ---
343
377
  const trackRef = useRef<HTMLDivElement>(null);
@@ -384,9 +418,9 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
384
418
 
385
419
  // --- Derived motion values for fill ---
386
420
  const fillLeft = useTransform(motionX0, (x) =>
387
- isRange ? x + THUMB_SIZE / 2 - TRACK_INSET : 0,
421
+ isRange ? x + THUMB_SIZE / 2 - trackInset : 0,
388
422
  );
389
- const fillWidthSingle = useTransform(motionX0, (x) => x + THUMB_SIZE / 2 - TRACK_INSET);
423
+ const fillWidthSingle = useTransform(motionX0, (x) => x + THUMB_SIZE / 2 - trackInset);
390
424
  const fillWidthRange = useTransform(
391
425
  [motionX0, motionX1] as MotionValue<number>[],
392
426
  ([x0, x1]) => (x1 as number) - (x0 as number),
@@ -650,16 +684,17 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
650
684
  }, []);
651
685
 
652
686
  // --- Step dots ---
687
+ const stepCount = step > 0 ? Math.round((max - min) / step) : 0;
653
688
  const stepDots = useMemo(
654
689
  () =>
655
- showSteps
690
+ showSteps && stepCount > 0 && stepCount <= MAX_STEP_DOTS
656
691
  ? Array.from({ length: Math.round((max - min) / step) + 1 }, (_, i) => {
657
692
  const v = min + i * step;
658
693
  const percent = (v - min) / (max - min);
659
694
  return { value: v, percent };
660
695
  })
661
696
  : [],
662
- [showSteps, min, max, step],
697
+ [showSteps, stepCount, min, max, step],
663
698
  );
664
699
 
665
700
  // --- Interaction state for tooltip ---
@@ -680,9 +715,12 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
680
715
  {...(label !== undefined ? { label } : {})}
681
716
  isRange={isRange}
682
717
  isInteracting={isInteracting}
718
+ disabled={disabled}
683
719
  />
684
720
  );
685
721
 
722
+ const thumbRest = compact ? THUMB_SIZE_REST_COMPACT : THUMB_SIZE_REST;
723
+
686
724
  // --- Render visual thumb (not Radix — purely visual) ---
687
725
  const renderVisualThumb = (index: number) => {
688
726
  const motionX = index === 0 ? motionX0 : motionX1;
@@ -706,19 +744,19 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
706
744
  className="block rounded-full"
707
745
  initial={false}
708
746
  animate={{
709
- width: THUMB_SIZE_REST,
710
- height: THUMB_SIZE_REST,
747
+ width: isPressed ? THUMB_SIZE_ACTIVE : thumbRest,
748
+ height: isPressed ? THUMB_SIZE_ACTIVE : thumbRest,
711
749
  }}
712
750
  transition={spring.fast}
713
751
  style={{
714
- backgroundColor: thumbColor ?? "white",
715
- boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
716
- border: thumbBorderColor ? `1px solid ${thumbBorderColor}` : undefined,
752
+ backgroundColor: thumbColor ?? "#ffffff",
753
+ boxShadow: "0 1px 3px rgba(0,0,0,0.22), 0 0 0 0.5px rgba(0,0,0,0.06)",
754
+ border: `1px solid ${thumbBorderColor ?? "var(--border-strong)"}`,
717
755
  }}
718
756
  />
719
757
  {/* Focus ring */}
720
758
  <motion.span
721
- className="absolute rounded-full border border-[#6B97FF] pointer-events-none"
759
+ className="absolute rounded-full border-2 border-ring pointer-events-none"
722
760
  initial={false}
723
761
  animate={{
724
762
  opacity: focusedThumb === index ? 1 : 0,
@@ -737,7 +775,7 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
737
775
  className={cn(
738
776
  "flex flex-col gap-0 w-full select-none touch-none overflow-visible",
739
777
  valuePosition === "left" || valuePosition === "right"
740
- ? "flex-row items-center gap-2 mb-2"
778
+ ? "flex-row items-center gap-2 mb-2 compact:mb-0"
741
779
  : "flex-col",
742
780
  disabled && "opacity-50 pointer-events-none",
743
781
  className,
@@ -753,7 +791,7 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
753
791
  style={{
754
792
  height:
755
793
  valuePosition === "left" || valuePosition === "right"
756
- ? THUMB_SIZE + 16
794
+ ? THUMB_SIZE + trackPad * 2
757
795
  : THUMB_SIZE + (valuePosition === "tooltip" ? 16 : 0),
758
796
  paddingTop: valuePosition === "tooltip" ? 16 : 0,
759
797
  }}
@@ -838,8 +876,13 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
838
876
  {/* Visual track with pointer handlers */}
839
877
  <div
840
878
  ref={trackRef}
841
- className="relative w-full cursor-ew-resize py-2"
842
- style={{ height: THUMB_SIZE + 16, opacity: ready ? 1 : 0 }}
879
+ className="relative w-full cursor-ew-resize"
880
+ style={{
881
+ height: THUMB_SIZE + trackPad * 2,
882
+ paddingTop: trackPad,
883
+ paddingBottom: trackPad,
884
+ opacity: ready ? 1 : 0,
885
+ }}
843
886
  onPointerDown={handlePointerDown}
844
887
  onPointerMove={handlePointerMove}
845
888
  onPointerUp={handlePointerUp}
@@ -883,20 +926,19 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
883
926
  {/* Track background */}
884
927
  <motion.div
885
928
  className={cn(
886
- "absolute border border-border overflow-hidden rounded-full",
929
+ "absolute overflow-hidden rounded-full bg-foreground/[0.12]",
887
930
  trackClassName,
888
931
  )}
889
932
  initial={false}
890
933
  animate={{
891
- height: TRACK_BG_HEIGHT,
892
- top: 8 + (THUMB_SIZE - TRACK_BG_HEIGHT) / 2,
934
+ height: trackHeight,
935
+ top: trackPad + (THUMB_SIZE - trackHeight) / 2,
893
936
  }}
894
937
  transition={spring.fast}
895
938
  style={
896
939
  {
897
- left: TRACK_INSET,
898
- right: TRACK_INSET,
899
- backgroundColor: "transparent",
940
+ left: trackInset,
941
+ right: trackInset,
900
942
  ...trackStyle,
901
943
  } as any
902
944
  }
@@ -904,7 +946,7 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
904
946
  {/* Filled range */}
905
947
  {!hideFill && (
906
948
  <motion.div
907
- className={cn("absolute h-full bg-selected/50 dark:bg-accent/40", fillClassName)}
949
+ className={cn("absolute h-full rounded-full bg-foreground", fillClassName)}
908
950
  style={
909
951
  {
910
952
  left: fillLeft,
@@ -926,13 +968,13 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
926
968
  opacity: { duration: 0.15 },
927
969
  }}
928
970
  style={{
929
- left: hoverPreview ? hoverPreview.left - TRACK_INSET : 0,
971
+ left: hoverPreview ? hoverPreview.left - trackInset : 0,
930
972
  width: hoverPreview ? hoverPreview.width : 0,
931
973
  borderRadius:
932
974
  hoverPreview && hoverPreview.cursorX > hoverPreview.left
933
975
  ? "0 9999px 9999px 0"
934
976
  : "9999px 0 0 9999px",
935
- backgroundColor: "color-mix(in srgb, var(--color-accent) 40%, transparent)",
977
+ backgroundColor: "color-mix(in srgb, var(--foreground) 22%, transparent)",
936
978
  }}
937
979
  />
938
980
  </motion.div>
@@ -942,8 +984,8 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
942
984
  <motion.div
943
985
  className="absolute left-0 right-0 pointer-events-none"
944
986
  style={{
945
- top: 8 + (THUMB_SIZE - TRACK_BG_HEIGHT) / 2,
946
- height: TRACK_BG_HEIGHT,
987
+ top: trackPad + (THUMB_SIZE - trackHeight) / 2,
988
+ height: trackHeight,
947
989
  WebkitMaskImage: stepDotsMask,
948
990
  maskImage: stepDotsMask,
949
991
  }}
@@ -969,7 +1011,7 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
969
1011
  transition={spring.moderate}
970
1012
  style={{
971
1013
  backgroundColor: "var(--muted-foreground)",
972
- opacity: 0.3,
1014
+ opacity: 0.55,
973
1015
  }}
974
1016
  />
975
1017
  </div>
@@ -1346,7 +1388,7 @@ const SliderComfortable = forwardRef<HTMLDivElement, SliderComfortableProps>(
1346
1388
  )}
1347
1389
  initial={false}
1348
1390
  animate={{
1349
- outline: isFocused ? "1px solid #6B97FF" : "1px solid transparent",
1391
+ outline: isFocused ? "2px solid var(--ring)" : "1px solid transparent",
1350
1392
  }}
1351
1393
  transition={spring.fast}
1352
1394
  style={style as any}
@@ -1388,12 +1430,12 @@ const SliderComfortable = forwardRef<HTMLDivElement, SliderComfortableProps>(
1388
1430
  style={{
1389
1431
  left: hoverPreview ? hoverPreview.left : 0,
1390
1432
  width: hoverPreview ? hoverPreview.width : 0,
1391
- backgroundColor: "color-mix(in srgb, var(--color-accent) 40%, transparent)",
1433
+ backgroundColor: "var(--hover)",
1392
1434
  }}
1393
1435
  />
1394
1436
 
1395
- {/* Pips: dots layer — z-[1] */}
1396
- {variant === "pips" && (
1437
+ {/* Pips: dots layer — z-[1]. Only drawn while pips stay legible. */}
1438
+ {variant === "pips" && pipCount <= MAX_STEP_DOTS + 1 && (
1397
1439
  <motion.div
1398
1440
  className="absolute inset-0 flex justify-between items-center px-3 pointer-events-none z-[1]"
1399
1441
  style={{ WebkitMaskImage: pipsMaskStyle, maskImage: pipsMaskStyle }}
@@ -1,14 +1,27 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { Toaster as SonnerToaster } from "sonner";
4
+ import { Toaster as SonnerToaster, toast, useSonner } from "sonner";
5
5
 
6
6
  type ToasterProps = React.ComponentProps<typeof SonnerToaster>;
7
+ type ToastOptions = NonNullable<Parameters<typeof toast>[1]>;
7
8
 
8
- function Toaster({ ...props }: ToasterProps) {
9
+ /**
10
+ * Toast host. Mount once near the app root; fire toasts with the re-exported
11
+ * `toast()` API so apps never import `sonner` directly:
12
+ *
13
+ * import { Toaster, toast } from "@hilum/ui";
14
+ * toast.success("Product saved");
15
+ * toast.promise(save(), { loading: "Saving…", success: "Saved", error: "Could not save" });
16
+ *
17
+ * Colours come from Hilum tokens, so the default `theme="system"` only
18
+ * affects Sonner's built-in icons and close button.
19
+ */
20
+ function Toaster({ theme = "system", position = "bottom-right", ...props }: ToasterProps) {
9
21
  return (
10
22
  <SonnerToaster
11
- theme="light"
23
+ theme={theme}
24
+ position={position}
12
25
  className="toaster group"
13
26
  toastOptions={{
14
27
  classNames: {
@@ -32,4 +45,5 @@ function Toaster({ ...props }: ToasterProps) {
32
45
  );
33
46
  }
34
47
 
35
- export { Toaster };
48
+ export { Toaster, toast, useSonner };
49
+ export type { ToasterProps, ToastOptions };