@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.
- 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
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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-
|
|
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:
|
|
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,
|
|
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
|
-
(
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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 =
|
|
64
|
-
const
|
|
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
|
-
//
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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 -
|
|
421
|
+
isRange ? x + THUMB_SIZE / 2 - trackInset : 0,
|
|
388
422
|
);
|
|
389
|
-
const fillWidthSingle = useTransform(motionX0, (x) => x + THUMB_SIZE / 2 -
|
|
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:
|
|
710
|
-
height:
|
|
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 ?? "
|
|
715
|
-
boxShadow: "0 1px
|
|
716
|
-
border:
|
|
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-
|
|
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 +
|
|
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
|
|
842
|
-
style={{
|
|
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
|
|
929
|
+
"absolute overflow-hidden rounded-full bg-foreground/[0.12]",
|
|
887
930
|
trackClassName,
|
|
888
931
|
)}
|
|
889
932
|
initial={false}
|
|
890
933
|
animate={{
|
|
891
|
-
height:
|
|
892
|
-
top:
|
|
934
|
+
height: trackHeight,
|
|
935
|
+
top: trackPad + (THUMB_SIZE - trackHeight) / 2,
|
|
893
936
|
}}
|
|
894
937
|
transition={spring.fast}
|
|
895
938
|
style={
|
|
896
939
|
{
|
|
897
|
-
left:
|
|
898
|
-
right:
|
|
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
|
|
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 -
|
|
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(--
|
|
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:
|
|
946
|
-
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.
|
|
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 ? "
|
|
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: "
|
|
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
|
-
|
|
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=
|
|
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 };
|