@hilum/ui 3.8.5 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
- package/dist/chunk-R576DV2A.js.map +1 -0
- package/dist/create-theme.js +2 -1
- package/dist/create-theme.js.map +1 -1
- package/dist/index.d.ts +733 -68
- package/dist/index.js +3097 -1619
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +163 -19
- package/dist/tokens.d.ts +69 -5
- package/dist/tokens.js +1 -1
- package/llms.txt +11 -4
- package/package.json +11 -10
- package/src/components/accordion.tsx +2 -2
- package/src/components/account-menu.tsx +1 -1
- package/src/components/alert-dialog.tsx +107 -0
- package/src/components/ask-user-questions.tsx +24 -18
- package/src/components/badge.tsx +36 -3
- package/src/components/breadcrumb.tsx +4 -2
- package/src/components/button-group.tsx +1 -1
- package/src/components/button.tsx +31 -23
- package/src/components/calendar.tsx +1 -1
- package/src/components/card.tsx +4 -2
- package/src/components/checkbox-card.tsx +1 -1
- package/src/components/checkbox-group.tsx +2 -1
- package/src/components/code-block.tsx +99 -0
- package/src/components/color-input.tsx +9 -6
- package/src/components/color-picker.tsx +38 -26
- package/src/components/combobox.tsx +59 -12
- package/src/components/command-palette.tsx +150 -42
- package/src/components/command.tsx +5 -3
- package/src/components/context-menu.tsx +18 -15
- package/src/components/contextual-save-bar.tsx +156 -0
- package/src/components/data-table.tsx +34 -5
- package/src/components/date-picker.tsx +275 -28
- package/src/components/date-text.tsx +90 -0
- package/src/components/dropdown-menu.tsx +11 -8
- package/src/components/dropdown.tsx +6 -3
- package/src/components/empty-state.tsx +85 -20
- package/src/components/file-dropzone.tsx +2 -1
- package/src/components/file-thumbnail.tsx +84 -20
- package/src/components/filter-bar.tsx +91 -0
- package/src/components/grid-list.tsx +2 -2
- package/src/components/help-tooltip.tsx +1 -1
- package/src/components/input-copy.tsx +1 -1
- package/src/components/input-group.tsx +1 -1
- package/src/components/input-message.tsx +5 -4
- package/src/components/input-number.tsx +103 -11
- package/src/components/input.tsx +5 -3
- package/src/components/label.tsx +1 -0
- package/src/components/media-asset-card.tsx +1 -1
- package/src/components/menu-item.tsx +37 -3
- package/src/components/menubar.tsx +21 -18
- package/src/components/native-select.tsx +1 -1
- package/src/components/navigation-menu.tsx +2 -2
- package/src/components/notification.tsx +1 -1
- package/src/components/pagination.tsx +97 -3
- package/src/components/popover.tsx +2 -0
- package/src/components/property-row.tsx +7 -4
- package/src/components/radio-card.tsx +1 -1
- package/src/components/radio-group.tsx +2 -1
- package/src/components/rating.tsx +136 -0
- package/src/components/resizable.tsx +1 -1
- package/src/components/resource-item.tsx +133 -0
- package/src/components/rich-text-editor.tsx +1 -0
- package/src/components/search-input.tsx +98 -0
- package/src/components/searchable-table.tsx +10 -11
- package/src/components/select.tsx +28 -9
- package/src/components/sidebar.tsx +4 -4
- package/src/components/slider.tsx +78 -36
- package/src/components/sonner.tsx +18 -4
- package/src/components/stat-card.tsx +121 -37
- package/src/components/status-badge.tsx +142 -47
- package/src/components/status-tile.tsx +9 -0
- package/src/components/switch.tsx +6 -5
- package/src/components/tabs-subtle.tsx +10 -2
- package/src/components/tabs.tsx +23 -3
- package/src/components/textarea.tsx +2 -3
- package/src/components/thinking-steps.tsx +3 -3
- package/src/components/toggle.tsx +14 -13
- package/src/components/tooltip.tsx +11 -8
- package/src/components/url-redirect-prompt.tsx +0 -1
- package/src/index.ts +15 -0
- package/src/lib/density-context.tsx +65 -0
- package/src/lib/format.tsx +301 -0
- package/src/lib/interaction.ts +12 -4
- package/src/lib/scroll-fade.tsx +39 -0
- package/src/tokens/tokens.test.ts +77 -0
- package/src/tokens/tokens.ts +114 -5
- package/dist/chunk-RI6QJR2L.js.map +0 -1
package/src/components/tabs.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import { useShape } from "../lib/shape-context";
|
|
|
24
24
|
import { useSurface } from "../lib/surface-context";
|
|
25
25
|
import { surfaceClasses } from "../lib/surface-classes";
|
|
26
26
|
import { useProximityHover } from "../hooks/use-proximity-hover";
|
|
27
|
+
import { scrollStripItemIntoView, useHorizontalOverflowMask } from "../lib/scroll-fade";
|
|
27
28
|
|
|
28
29
|
/* ─────────────────────── Contexts ─────────────────────── */
|
|
29
30
|
|
|
@@ -128,10 +129,18 @@ Tabs.displayName = "Tabs";
|
|
|
128
129
|
|
|
129
130
|
/* ─────────────────────── TabsList ─────────────────────── */
|
|
130
131
|
|
|
131
|
-
|
|
132
|
+
interface TabsListProps extends ComponentPropsWithoutRef<typeof TabsPrimitive.List> {
|
|
133
|
+
/**
|
|
134
|
+
* Let the strip scroll horizontally instead of clipping when the tabs are
|
|
135
|
+
* wider than their container (settings tabs on mobile, narrow panels).
|
|
136
|
+
* Edges fade while more tabs are hidden and the active tab is scrolled into
|
|
137
|
+
* view. Default: `true`.
|
|
138
|
+
*/
|
|
139
|
+
scrollable?: boolean;
|
|
140
|
+
}
|
|
132
141
|
|
|
133
142
|
const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
|
|
134
|
-
({ children, className, ...props }, ref) => {
|
|
143
|
+
({ children, className, scrollable = true, style, ...props }, ref) => {
|
|
135
144
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
136
145
|
const isMouseInside = useRef(false);
|
|
137
146
|
const shape = useShape();
|
|
@@ -211,6 +220,13 @@ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
|
|
|
211
220
|
setOptimisticIdx(selectedIdx >= 0 ? selectedIdx : null);
|
|
212
221
|
}, [selectedIdx]);
|
|
213
222
|
|
|
223
|
+
const maskStyle = useHorizontalOverflowMask(containerRef, scrollable);
|
|
224
|
+
|
|
225
|
+
// Keep the active tab visible when the strip overflows.
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (scrollable) scrollStripItemIntoView(containerRef.current, selectedIdx);
|
|
228
|
+
}, [scrollable, selectedIdx]);
|
|
229
|
+
|
|
214
230
|
const activeSelectedIdx = optimisticIdx;
|
|
215
231
|
const selectedRect = activeSelectedIdx !== null ? itemRects[activeSelectedIdx] : null;
|
|
216
232
|
const hoverRect = hoveredIndex !== null ? itemRects[hoveredIndex] : null;
|
|
@@ -259,11 +275,15 @@ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
|
|
|
259
275
|
if (isMouseInside.current) return;
|
|
260
276
|
setHoveredIndex(null);
|
|
261
277
|
}}
|
|
278
|
+
data-scrollable={scrollable ? "" : undefined}
|
|
262
279
|
className={cn(
|
|
263
280
|
"relative inline-flex items-center gap-0.5 p-1 select-none bg-muted",
|
|
281
|
+
scrollable &&
|
|
282
|
+
"max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>[role=tab]]:shrink-0",
|
|
264
283
|
shape.container,
|
|
265
284
|
className,
|
|
266
285
|
)}
|
|
286
|
+
style={maskStyle ? { ...maskStyle, ...style } : style}
|
|
267
287
|
{...props}
|
|
268
288
|
>
|
|
269
289
|
{/* Active segment indicator */}
|
|
@@ -336,7 +356,7 @@ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
|
|
|
336
356
|
{focusRect && (
|
|
337
357
|
<motion.div
|
|
338
358
|
className={cn(
|
|
339
|
-
"absolute pointer-events-none z-20 border border-
|
|
359
|
+
"absolute pointer-events-none z-20 border-2 border-ring",
|
|
340
360
|
shape.focusRing,
|
|
341
361
|
)}
|
|
342
362
|
initial={false}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
5
|
-
import { controlSurfaceClasses,
|
|
5
|
+
import { controlSurfaceClasses, inputFocusClasses, motionClasses } from "../lib/interaction";
|
|
6
6
|
import { useShape } from "../lib/shape-context";
|
|
7
7
|
import type { ControlDensity, ControlMobileSurface } from "./input";
|
|
8
8
|
|
|
@@ -49,9 +49,8 @@ function Textarea({
|
|
|
49
49
|
"resize-none",
|
|
50
50
|
controlSurfaceClasses,
|
|
51
51
|
motionClasses,
|
|
52
|
-
|
|
52
|
+
inputFocusClasses,
|
|
53
53
|
textareaMobileSurfaceClasses[mobileSurface],
|
|
54
|
-
"focus-visible:border-brand-primary",
|
|
55
54
|
"disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-muted",
|
|
56
55
|
className,
|
|
57
56
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { forwardRef, type ReactNode, type HTMLAttributes } from "react";
|
|
4
4
|
import { motion } from "framer-motion";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
6
|
import { useIcon } from "../lib/icon-context";
|
|
@@ -152,8 +152,8 @@ function ThinkingStep({
|
|
|
152
152
|
label,
|
|
153
153
|
description,
|
|
154
154
|
status = "complete",
|
|
155
|
-
index
|
|
156
|
-
|
|
155
|
+
// `index` / `delay` are accepted for API compatibility but no longer drive
|
|
156
|
+
// the entrance animation, so they aren't destructured here.
|
|
157
157
|
isLast = false,
|
|
158
158
|
children,
|
|
159
159
|
className,
|
|
@@ -10,7 +10,8 @@ const toggleVariants = cva(
|
|
|
10
10
|
"inline-flex shrink-0 items-center justify-center gap-2",
|
|
11
11
|
"body font-medium whitespace-nowrap rounded-md",
|
|
12
12
|
"transition-[background-color,border-color,box-shadow,color,opacity,scale] duration-150 outline-none active:scale-[0.96]",
|
|
13
|
-
"focus-visible:ring-2 focus-visible:ring-
|
|
13
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
14
|
+
"compact:rounded-[5px] compact:text-[12px] compact:gap-1.5 [&_svg:not([class*='size-'])]:compact:size-3.5",
|
|
14
15
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
15
16
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
16
17
|
],
|
|
@@ -19,27 +20,27 @@ const toggleVariants = cva(
|
|
|
19
20
|
variant: {
|
|
20
21
|
default: [
|
|
21
22
|
"bg-transparent text-muted-foreground",
|
|
22
|
-
"hover:bg-
|
|
23
|
-
"data-[state=on]:bg-
|
|
23
|
+
"hover:bg-hover hover:text-foreground",
|
|
24
|
+
"data-[state=on]:bg-active data-[state=on]:text-foreground",
|
|
24
25
|
],
|
|
25
26
|
outline: [
|
|
26
27
|
"border border-border bg-card text-muted-foreground shadow-natural",
|
|
27
|
-
"hover:bg-
|
|
28
|
-
"data-[state=on]:bg-
|
|
28
|
+
"hover:bg-hover hover:text-foreground hover:border-border-strong",
|
|
29
|
+
"data-[state=on]:bg-active data-[state=on]:text-foreground data-[state=on]:border-border-strong",
|
|
29
30
|
],
|
|
30
31
|
brand: [
|
|
31
32
|
"bg-transparent text-muted-foreground",
|
|
32
|
-
"hover:bg-
|
|
33
|
-
"data-[state=on]:bg-
|
|
33
|
+
"hover:bg-hover hover:text-foreground",
|
|
34
|
+
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
|
|
34
35
|
],
|
|
35
36
|
},
|
|
36
37
|
size: {
|
|
37
|
-
sm: "h-8 gap-1.5 px-3",
|
|
38
|
-
default: "h-10 px-3",
|
|
39
|
-
lg: "h-11 px-4",
|
|
40
|
-
icon: "size-9",
|
|
41
|
-
"icon-sm": "size-8",
|
|
42
|
-
"icon-lg": "size-11",
|
|
38
|
+
sm: "h-8 gap-1.5 px-3 compact:h-6 compact:px-2",
|
|
39
|
+
default: "h-10 px-3 compact:h-6 compact:px-2",
|
|
40
|
+
lg: "h-11 px-4 compact:h-7 compact:px-2.5",
|
|
41
|
+
icon: "size-9 compact:size-6",
|
|
42
|
+
"icon-sm": "size-8 compact:size-6",
|
|
43
|
+
"icon-lg": "size-11 compact:size-7",
|
|
43
44
|
},
|
|
44
45
|
},
|
|
45
46
|
defaultVariants: {
|
|
@@ -80,7 +80,18 @@ function Tooltip({
|
|
|
80
80
|
forceOpen,
|
|
81
81
|
onOpenChange: onOpenChangeProp,
|
|
82
82
|
}: TooltipProps) {
|
|
83
|
+
// All hooks run before the `content === undefined` early return so the hook
|
|
84
|
+
// order is stable if `content` toggles between defined and undefined.
|
|
83
85
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
86
|
+
const open = forceOpen ?? openProp ?? internalOpen;
|
|
87
|
+
const [mounted, setMounted] = useState(false);
|
|
88
|
+
const shape = useShape();
|
|
89
|
+
const portalContainer = useContext(TooltipPortalContainerContext);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (open) setMounted(true);
|
|
93
|
+
}, [open]);
|
|
94
|
+
|
|
84
95
|
if (content === undefined) {
|
|
85
96
|
return (
|
|
86
97
|
<TooltipPrimitive.Root
|
|
@@ -91,14 +102,6 @@ function Tooltip({
|
|
|
91
102
|
</TooltipPrimitive.Root>
|
|
92
103
|
);
|
|
93
104
|
}
|
|
94
|
-
const open = forceOpen ?? openProp ?? internalOpen;
|
|
95
|
-
const [mounted, setMounted] = useState(false);
|
|
96
|
-
const shape = useShape();
|
|
97
|
-
const portalContainer = useContext(TooltipPortalContainerContext);
|
|
98
|
-
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
if (open) setMounted(true);
|
|
101
|
-
}, [open]);
|
|
102
105
|
|
|
103
106
|
const handleExitComplete = () => {
|
|
104
107
|
if (!open) setMounted(false);
|
|
@@ -2,7 +2,6 @@ import * as React from "react";
|
|
|
2
2
|
import { ArrowRight } from "lucide-react";
|
|
3
3
|
import { Callout } from "./callout";
|
|
4
4
|
import { Switch } from "./switch";
|
|
5
|
-
import { cn } from "../lib/utils";
|
|
6
5
|
|
|
7
6
|
function normalizeUrlHandle(value: string): string {
|
|
8
7
|
return String(value || "")
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,14 @@ export { cn } from "./lib/utils";
|
|
|
10
10
|
export * from "./lib/icon-context";
|
|
11
11
|
export * from "./lib/shape-context";
|
|
12
12
|
export * from "./lib/surface-context";
|
|
13
|
+
export * from "./lib/density-context";
|
|
14
|
+
export * from "./lib/format";
|
|
15
|
+
export {
|
|
16
|
+
useScrollEdges,
|
|
17
|
+
horizontalEdgeMask,
|
|
18
|
+
useHorizontalOverflowMask,
|
|
19
|
+
scrollStripItemIntoView,
|
|
20
|
+
} from "./lib/scroll-fade";
|
|
13
21
|
|
|
14
22
|
// Components (alphabetical)
|
|
15
23
|
export * from "./components/account-menu";
|
|
@@ -37,15 +45,18 @@ export * from "./components/checkbox";
|
|
|
37
45
|
export * from "./components/checkbox-card";
|
|
38
46
|
export * from "./components/checkbox-group";
|
|
39
47
|
export * from "./components/collapsible";
|
|
48
|
+
export * from "./components/code-block";
|
|
40
49
|
export * from "./components/color-input";
|
|
41
50
|
export * from "./components/color-picker";
|
|
42
51
|
export * from "./components/combobox";
|
|
43
52
|
export * from "./components/command-palette";
|
|
44
53
|
export * from "./components/command";
|
|
45
54
|
export * from "./components/context-menu";
|
|
55
|
+
export * from "./components/contextual-save-bar";
|
|
46
56
|
export * from "./components/data-table";
|
|
47
57
|
export * from "./components/data-transfer-controls";
|
|
48
58
|
export * from "./components/date-picker";
|
|
59
|
+
export * from "./components/date-text";
|
|
49
60
|
export * from "./components/description-list";
|
|
50
61
|
export * from "./components/dialog";
|
|
51
62
|
export * from "./components/drawer";
|
|
@@ -54,6 +65,7 @@ export * from "./components/dropdown-menu";
|
|
|
54
65
|
export * from "./components/empty-state";
|
|
55
66
|
export * from "./components/field";
|
|
56
67
|
export * from "./components/file-thumbnail";
|
|
68
|
+
export * from "./components/filter-bar";
|
|
57
69
|
export * from "./components/file-dropzone";
|
|
58
70
|
export * from "./components/grid-list";
|
|
59
71
|
export * from "./components/help-tooltip";
|
|
@@ -83,11 +95,14 @@ export * from "./components/popover";
|
|
|
83
95
|
export * from "./components/progress";
|
|
84
96
|
export * from "./components/property-row";
|
|
85
97
|
export * from "./components/radio-card";
|
|
98
|
+
export * from "./components/rating";
|
|
86
99
|
export * from "./components/radio-group";
|
|
87
100
|
export * from "./components/resizable";
|
|
101
|
+
export * from "./components/resource-item";
|
|
88
102
|
export * from "./components/rich-text-editor";
|
|
89
103
|
export * from "./components/scroll-area";
|
|
90
104
|
export * from "./components/section-heading";
|
|
105
|
+
export * from "./components/search-input";
|
|
91
106
|
export * from "./components/searchable-table";
|
|
92
107
|
export * from "./components/select";
|
|
93
108
|
export * from "./components/separator";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Control density.
|
|
7
|
+
*
|
|
8
|
+
* - `default` — the standard Hilum sizing (40px inputs, 32px buttons, 40px
|
|
9
|
+
* menu rows). Used everywhere unless something opts in.
|
|
10
|
+
* - `compact` — editor-chrome sizing (Figma / Framer / Linear inspectors):
|
|
11
|
+
* 24px controls, 28px rows and menu items, 12px text, 11px labels, 8px
|
|
12
|
+
* horizontal padding, 4–6px radii.
|
|
13
|
+
*
|
|
14
|
+
* Density is carried two ways so it works for both CSS and portalled UI:
|
|
15
|
+
* 1. the `data-density="compact"` attribute on an ancestor element drives the
|
|
16
|
+
* `compact:` Tailwind variant and the `--density-*` CSS variables emitted
|
|
17
|
+
* in tokens.css;
|
|
18
|
+
* 2. React context, so portalled content (menus, popovers, select lists)
|
|
19
|
+
* can re-apply the attribute on its own root outside the DOM subtree.
|
|
20
|
+
*/
|
|
21
|
+
type Density = "default" | "compact";
|
|
22
|
+
|
|
23
|
+
const DensityContext = createContext<Density>("default");
|
|
24
|
+
|
|
25
|
+
function useDensity(): Density {
|
|
26
|
+
return useContext(DensityContext);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface DensityProviderProps {
|
|
30
|
+
density: Density;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Render a `display: contents` wrapper carrying `data-density` (default).
|
|
34
|
+
* Pass `false` when you already set `data-density` on your own element and
|
|
35
|
+
* only need the React context (e.g. for portalled menus).
|
|
36
|
+
*/
|
|
37
|
+
wrap?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function DensityProvider({ density, children, wrap = true }: DensityProviderProps) {
|
|
41
|
+
return (
|
|
42
|
+
<DensityContext.Provider value={density}>
|
|
43
|
+
{wrap ? (
|
|
44
|
+
<div data-density={density} style={{ display: "contents" }}>
|
|
45
|
+
{children}
|
|
46
|
+
</div>
|
|
47
|
+
) : (
|
|
48
|
+
children
|
|
49
|
+
)}
|
|
50
|
+
</DensityContext.Provider>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Props to spread on a portalled content root so it inherits the density of
|
|
56
|
+
* the tree that opened it. Returns `{}` in default density so markup is
|
|
57
|
+
* unchanged for non-editor consumers.
|
|
58
|
+
*/
|
|
59
|
+
function useDensityAttributes(): { "data-density"?: Density } {
|
|
60
|
+
const density = useDensity();
|
|
61
|
+
return density === "default" ? {} : { "data-density": density };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { DensityContext, DensityProvider, useDensity, useDensityAttributes };
|
|
65
|
+
export type { Density, DensityProviderProps };
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Locale-aware formatting helpers
|
|
7
|
+
//
|
|
8
|
+
// Product UIs drift quickly when every screen formats dates, money and counts
|
|
9
|
+
// by hand ("Sep 26, 2026" next to "9/26/2026", "1 items", raw ISO strings).
|
|
10
|
+
// These helpers wrap `Intl` so every surface shares one convention:
|
|
11
|
+
//
|
|
12
|
+
// - dates: medium style ("Sep 26, 2026") unless a caller opts out
|
|
13
|
+
// - datetimes: medium date + short time ("Sep 26, 2026, 3:04 PM")
|
|
14
|
+
// - relative: "3 hours ago" / "in 2 days", falling back to a date after 7 days
|
|
15
|
+
// - money: ISO-4217 currency, minor units handled by Intl
|
|
16
|
+
// - counts: Intl.PluralRules-based ("1 item", "2 items")
|
|
17
|
+
//
|
|
18
|
+
// Every helper accepts an explicit `locale`; components read defaults from
|
|
19
|
+
// the nearest <FormatProvider>.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export type DateInput = Date | string | number | null | undefined;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* - `date` / `short` — "Sep 26, 2026" (default; `short` is an alias)
|
|
26
|
+
* - `long` — "September 26, 2026"
|
|
27
|
+
* - `datetime` — "Sep 26, 2026, 3:04 PM"
|
|
28
|
+
* - `time` — "3:04 PM"
|
|
29
|
+
* - `monthDay` — "Sep 26" (dense timelines, same-year lists)
|
|
30
|
+
* - `month` — "September 2026"
|
|
31
|
+
* - `iso` — "2026-09-26" (local calendar date, for APIs)
|
|
32
|
+
*/
|
|
33
|
+
export type DateFormatStyle =
|
|
34
|
+
| "date"
|
|
35
|
+
| "short"
|
|
36
|
+
| "long"
|
|
37
|
+
| "datetime"
|
|
38
|
+
| "time"
|
|
39
|
+
| "monthDay"
|
|
40
|
+
| "month"
|
|
41
|
+
| "iso";
|
|
42
|
+
|
|
43
|
+
export interface FormatOptions {
|
|
44
|
+
/** BCP-47 locale. Defaults to the provider locale, then the runtime default. */
|
|
45
|
+
locale?: string | undefined;
|
|
46
|
+
/** IANA time zone, e.g. "America/Lima". Defaults to the runtime zone. */
|
|
47
|
+
timeZone?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DATE_STYLE_OPTIONS: Record<Exclude<DateFormatStyle, "iso">, Intl.DateTimeFormatOptions> = {
|
|
51
|
+
date: { year: "numeric", month: "short", day: "numeric" },
|
|
52
|
+
datetime: { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" },
|
|
53
|
+
time: { hour: "numeric", minute: "2-digit" },
|
|
54
|
+
short: { year: "numeric", month: "short", day: "numeric" },
|
|
55
|
+
monthDay: { month: "short", day: "numeric" },
|
|
56
|
+
long: { year: "numeric", month: "long", day: "numeric" },
|
|
57
|
+
month: { year: "numeric", month: "long" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse a date-ish value. Date-only ISO strings ("2026-09-26") are read as
|
|
64
|
+
* local calendar dates instead of UTC midnight, so they never shift a day
|
|
65
|
+
* backwards in western time zones. Returns `null` for empty/invalid input.
|
|
66
|
+
*/
|
|
67
|
+
export function toDate(value: DateInput): Date | null {
|
|
68
|
+
if (value === null || value === undefined || value === "") return null;
|
|
69
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
70
|
+
if (typeof value === "string" && DATE_ONLY_PATTERN.test(value)) {
|
|
71
|
+
const [y, m, d] = value.split("-").map(Number) as [number, number, number];
|
|
72
|
+
return new Date(y, m - 1, d);
|
|
73
|
+
}
|
|
74
|
+
const date = new Date(value);
|
|
75
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Format a Date as a local calendar date string ("YYYY-MM-DD"). */
|
|
79
|
+
export function toISODate(value: DateInput): string {
|
|
80
|
+
const date = toDate(value);
|
|
81
|
+
if (!date) return "";
|
|
82
|
+
const y = date.getFullYear();
|
|
83
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
84
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
85
|
+
return `${y}-${m}-${d}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FormatDateOptions extends FormatOptions {
|
|
89
|
+
/** Preset style. Default: `"date"` ("Sep 26, 2026"). */
|
|
90
|
+
style?: DateFormatStyle;
|
|
91
|
+
/** Rendered for empty/invalid input. Default: "—". */
|
|
92
|
+
fallback?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatDate(value: DateInput, options: FormatDateOptions = {}): string {
|
|
96
|
+
const { style = "date", fallback = "—", locale, timeZone } = options;
|
|
97
|
+
const date = toDate(value);
|
|
98
|
+
if (!date) return fallback;
|
|
99
|
+
if (style === "iso") return toISODate(date);
|
|
100
|
+
return new Intl.DateTimeFormat(locale, {
|
|
101
|
+
...DATE_STYLE_OPTIONS[style],
|
|
102
|
+
...(timeZone ? { timeZone } : {}),
|
|
103
|
+
}).format(date);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatDateTime(value: DateInput, options: Omit<FormatDateOptions, "style"> = {}) {
|
|
107
|
+
return formatDate(value, { ...options, style: "datetime" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FormatDateRangeOptions extends FormatOptions {
|
|
111
|
+
style?: Exclude<DateFormatStyle, "iso" | "time">;
|
|
112
|
+
fallback?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** "Sep 1 – 26, 2026" — collapses shared parts via Intl.DateTimeFormat#formatRange. */
|
|
116
|
+
export function formatDateRange(
|
|
117
|
+
start: DateInput,
|
|
118
|
+
end: DateInput,
|
|
119
|
+
options: FormatDateRangeOptions = {},
|
|
120
|
+
): string {
|
|
121
|
+
const { style = "date", fallback = "—", locale, timeZone } = options;
|
|
122
|
+
const from = toDate(start);
|
|
123
|
+
const to = toDate(end);
|
|
124
|
+
if (!from && !to) return fallback;
|
|
125
|
+
if (!from || !to) return formatDate(from ?? to, { style, locale, timeZone, fallback });
|
|
126
|
+
const formatter = new Intl.DateTimeFormat(locale, {
|
|
127
|
+
...DATE_STYLE_OPTIONS[style],
|
|
128
|
+
...(timeZone ? { timeZone } : {}),
|
|
129
|
+
});
|
|
130
|
+
return typeof formatter.formatRange === "function"
|
|
131
|
+
? formatter.formatRange(from, to)
|
|
132
|
+
: `${formatter.format(from)} – ${formatter.format(to)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
|
136
|
+
["year", 365 * 24 * 60 * 60],
|
|
137
|
+
["month", 30 * 24 * 60 * 60],
|
|
138
|
+
["week", 7 * 24 * 60 * 60],
|
|
139
|
+
["day", 24 * 60 * 60],
|
|
140
|
+
["hour", 60 * 60],
|
|
141
|
+
["minute", 60],
|
|
142
|
+
["second", 1],
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
export interface FormatRelativeTimeOptions extends FormatOptions {
|
|
146
|
+
/** Reference time. Default: now. */
|
|
147
|
+
now?: DateInput;
|
|
148
|
+
/**
|
|
149
|
+
* After this many seconds the absolute date is shown instead
|
|
150
|
+
* ("Sep 26, 2026"). Default: 7 days. Pass `Infinity` to always be relative.
|
|
151
|
+
*/
|
|
152
|
+
maxRelativeSeconds?: number;
|
|
153
|
+
fallback?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** "just now", "5 minutes ago", "in 2 days"; absolute date past `maxRelativeSeconds`. */
|
|
157
|
+
export function formatRelativeTime(
|
|
158
|
+
value: DateInput,
|
|
159
|
+
options: FormatRelativeTimeOptions = {},
|
|
160
|
+
): string {
|
|
161
|
+
const {
|
|
162
|
+
locale,
|
|
163
|
+
timeZone,
|
|
164
|
+
now,
|
|
165
|
+
maxRelativeSeconds = 7 * 24 * 60 * 60,
|
|
166
|
+
fallback = "—",
|
|
167
|
+
} = options;
|
|
168
|
+
const date = toDate(value);
|
|
169
|
+
if (!date) return fallback;
|
|
170
|
+
const reference = toDate(now) ?? new Date();
|
|
171
|
+
const diffSeconds = Math.round((date.getTime() - reference.getTime()) / 1000);
|
|
172
|
+
const abs = Math.abs(diffSeconds);
|
|
173
|
+
if (abs > maxRelativeSeconds) return formatDate(date, { locale, timeZone });
|
|
174
|
+
|
|
175
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
176
|
+
if (abs < 45) return rtf.format(0, "second");
|
|
177
|
+
for (const [unit, seconds] of RELATIVE_UNITS) {
|
|
178
|
+
if (abs >= seconds) return rtf.format(Math.round(diffSeconds / seconds), unit);
|
|
179
|
+
}
|
|
180
|
+
return rtf.format(0, "second");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface FormatNumberOptions extends FormatOptions, Intl.NumberFormatOptions {
|
|
184
|
+
fallback?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function formatNumber(
|
|
188
|
+
value: number | string | null | undefined,
|
|
189
|
+
options: FormatNumberOptions = {},
|
|
190
|
+
): string {
|
|
191
|
+
const { locale, timeZone: _timeZone, fallback = "—", ...intl } = options;
|
|
192
|
+
const num = typeof value === "string" ? Number(value) : value;
|
|
193
|
+
if (num === null || num === undefined || Number.isNaN(num)) return fallback;
|
|
194
|
+
return new Intl.NumberFormat(locale, intl).format(num);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface FormatCurrencyOptions extends FormatNumberOptions {
|
|
198
|
+
/** ISO-4217 code. Default: provider currency, then "USD". */
|
|
199
|
+
currency?: string;
|
|
200
|
+
/** When true, `value` is in minor units (cents) and divided by 10^fractionDigits. */
|
|
201
|
+
minorUnits?: boolean;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function formatCurrency(
|
|
205
|
+
value: number | string | null | undefined,
|
|
206
|
+
options: FormatCurrencyOptions = {},
|
|
207
|
+
): string {
|
|
208
|
+
const { currency = "USD", minorUnits = false, ...rest } = options;
|
|
209
|
+
const num = typeof value === "string" ? Number(value) : value;
|
|
210
|
+
if (num === null || num === undefined || Number.isNaN(num)) return rest.fallback ?? "—";
|
|
211
|
+
let amount = num;
|
|
212
|
+
if (minorUnits) {
|
|
213
|
+
const digits =
|
|
214
|
+
new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions()
|
|
215
|
+
.maximumFractionDigits ?? 2;
|
|
216
|
+
amount = num / 10 ** digits;
|
|
217
|
+
}
|
|
218
|
+
return formatNumber(amount, { style: "currency", currency, ...rest });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function formatPercent(
|
|
222
|
+
value: number | null | undefined,
|
|
223
|
+
options: FormatNumberOptions = {},
|
|
224
|
+
): string {
|
|
225
|
+
return formatNumber(value, { style: "percent", maximumFractionDigits: 1, ...options });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface PluralizeOptions extends FormatOptions {
|
|
229
|
+
/** Plural form. Default: `${singular}s`. */
|
|
230
|
+
plural?: string;
|
|
231
|
+
/** Omit the number ("items" instead of "2 items"). */
|
|
232
|
+
hideCount?: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* "1 item", "2 items", "1,204 orders". Uses Intl.PluralRules to pick the form
|
|
237
|
+
* and Intl.NumberFormat for the count.
|
|
238
|
+
*/
|
|
239
|
+
export function pluralize(count: number, singular: string, options: PluralizeOptions = {}) {
|
|
240
|
+
const { plural = `${singular}s`, hideCount = false, locale } = options;
|
|
241
|
+
const rule = new Intl.PluralRules(locale).select(count);
|
|
242
|
+
const word = rule === "one" ? singular : plural;
|
|
243
|
+
return hideCount ? word : `${new Intl.NumberFormat(locale).format(count)} ${word}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// FormatProvider — app-wide locale/currency/timeZone defaults
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
export interface FormatContextValue {
|
|
251
|
+
locale?: string | undefined;
|
|
252
|
+
currency?: string | undefined;
|
|
253
|
+
timeZone?: string | undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const FormatContext = createContext<FormatContextValue>({});
|
|
257
|
+
|
|
258
|
+
export interface FormatProviderProps extends FormatContextValue {
|
|
259
|
+
children: ReactNode;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function FormatProvider({ locale, currency, timeZone, children }: FormatProviderProps) {
|
|
263
|
+
const value = useMemo(() => ({ locale, currency, timeZone }), [locale, currency, timeZone]);
|
|
264
|
+
return <FormatContext.Provider value={value}>{children}</FormatContext.Provider>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Formatters bound to the nearest FormatProvider's locale/currency/timeZone. */
|
|
268
|
+
export function useFormatter() {
|
|
269
|
+
const ctx = useContext(FormatContext);
|
|
270
|
+
return useMemo(() => {
|
|
271
|
+
const base = {
|
|
272
|
+
...(ctx.locale ? { locale: ctx.locale } : {}),
|
|
273
|
+
...(ctx.timeZone ? { timeZone: ctx.timeZone } : {}),
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
locale: ctx.locale,
|
|
277
|
+
currencyCode: ctx.currency,
|
|
278
|
+
timeZone: ctx.timeZone,
|
|
279
|
+
date: (value: DateInput, options: FormatDateOptions = {}) =>
|
|
280
|
+
formatDate(value, { ...base, ...options }),
|
|
281
|
+
dateTime: (value: DateInput, options: Omit<FormatDateOptions, "style"> = {}) =>
|
|
282
|
+
formatDateTime(value, { ...base, ...options }),
|
|
283
|
+
dateRange: (start: DateInput, end: DateInput, options: FormatDateRangeOptions = {}) =>
|
|
284
|
+
formatDateRange(start, end, { ...base, ...options }),
|
|
285
|
+
relative: (value: DateInput, options: FormatRelativeTimeOptions = {}) =>
|
|
286
|
+
formatRelativeTime(value, { ...base, ...options }),
|
|
287
|
+
number: (value: number | string | null | undefined, options: FormatNumberOptions = {}) =>
|
|
288
|
+
formatNumber(value, { ...base, ...options }),
|
|
289
|
+
currency: (value: number | string | null | undefined, options: FormatCurrencyOptions = {}) =>
|
|
290
|
+
formatCurrency(value, {
|
|
291
|
+
...base,
|
|
292
|
+
...(ctx.currency ? { currency: ctx.currency } : {}),
|
|
293
|
+
...options,
|
|
294
|
+
}),
|
|
295
|
+
percent: (value: number | null | undefined, options: FormatNumberOptions = {}) =>
|
|
296
|
+
formatPercent(value, { ...base, ...options }),
|
|
297
|
+
pluralize: (count: number, singular: string, options: PluralizeOptions = {}) =>
|
|
298
|
+
pluralize(count, singular, { ...base, ...options }),
|
|
299
|
+
};
|
|
300
|
+
}, [ctx.locale, ctx.currency, ctx.timeZone]);
|
|
301
|
+
}
|
package/src/lib/interaction.ts
CHANGED
|
@@ -9,12 +9,20 @@ export const springMotionClasses =
|
|
|
9
9
|
export const pressClasses = "active:scale-[0.97] motion-reduce:active:scale-100";
|
|
10
10
|
|
|
11
11
|
export const focusRingClasses =
|
|
12
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-
|
|
12
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background";
|
|
13
|
+
|
|
14
|
+
/** Focus treatment for text-entry fields: ring-token border + a soft 2px halo. */
|
|
15
|
+
export const inputFocusClasses =
|
|
16
|
+
"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35";
|
|
17
|
+
|
|
18
|
+
/** Same as inputFocusClasses for composite fields that wrap a native input. */
|
|
19
|
+
export const inputFocusWithinClasses =
|
|
20
|
+
"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/35";
|
|
13
21
|
|
|
14
22
|
export const iconStrokeClasses =
|
|
15
23
|
"[&_svg]:transition-[stroke-width,transform,color] [&_svg]:duration-150 [&_svg]:ease-out group-hover:[&_svg]:stroke-[2]";
|
|
16
24
|
|
|
17
|
-
export const controlSurfaceClasses = "border border-border bg-background hover:border-
|
|
25
|
+
export const controlSurfaceClasses = "border border-border bg-background hover:border-border-strong";
|
|
18
26
|
|
|
19
27
|
export const surfaceElevationClasses = {
|
|
20
28
|
flat: "border border-border bg-card",
|
|
@@ -29,10 +37,10 @@ export const radiusClasses = {
|
|
|
29
37
|
} as const;
|
|
30
38
|
|
|
31
39
|
export const menuItemClasses =
|
|
32
|
-
"relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 body outline-none transition-[background-color,color,box-shadow] duration-150 ease-out";
|
|
40
|
+
"relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 body outline-none transition-[background-color,color,box-shadow] duration-150 ease-out compact:min-h-7 compact:px-2 compact:py-1 compact:text-[13px] compact:rounded-[4px]";
|
|
33
41
|
|
|
34
42
|
export const menuItemActiveClasses =
|
|
35
|
-
"focus:bg-
|
|
43
|
+
"focus:bg-active focus:text-foreground data-[highlighted]:bg-active";
|
|
36
44
|
|
|
37
45
|
export function useProximityIndex<T extends HTMLElement>(axis: "x" | "y" = "y") {
|
|
38
46
|
const containerRef = React.useRef<T | null>(null);
|