@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
|
@@ -3,27 +3,54 @@
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
5
5
|
import { useShape } from "../lib/shape-context";
|
|
6
|
+
import type * as Pdfjs from "pdfjs-dist";
|
|
6
7
|
|
|
7
8
|
// ─── Lazy pdfjs loader ────────────────────────────────────────────────────
|
|
8
|
-
// Imports pdfjs-dist on first PDF
|
|
9
|
-
//
|
|
10
|
-
|
|
9
|
+
// Imports pdfjs-dist on first PDF and caches the module. pdf.js needs a worker
|
|
10
|
+
// script; where it comes from, in order of precedence:
|
|
11
|
+
// 1. `pdfWorkerSrc` prop on <FileThumbnail> (or the arg to loadPdfjs)
|
|
12
|
+
// 2. setPdfWorkerSrc(url) — app-wide, call once at startup
|
|
13
|
+
// 3. GlobalWorkerOptions.workerSrc already set by the app on pdfjs-dist
|
|
14
|
+
// 4. Fallback: the matching build on cdn.jsdelivr.net. Convenient, but it's a
|
|
15
|
+
// third-party request at runtime — apps with a strict CSP or offline
|
|
16
|
+
// requirements should self-host the worker via 1 or 2, e.g. with Vite:
|
|
17
|
+
// import workerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
|
18
|
+
// setPdfWorkerSrc(workerSrc);
|
|
19
|
+
// Type-only import: erased at build time, so pdfjs stays out of the main bundle.
|
|
20
|
+
type PdfjsModule = typeof Pdfjs;
|
|
11
21
|
let pdfjsPromise: Promise<PdfjsModule> | null = null;
|
|
22
|
+
let configuredWorkerSrc: string | undefined;
|
|
12
23
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
const cdnWorkerSrc = (version: string) =>
|
|
25
|
+
`https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.min.mjs`;
|
|
26
|
+
|
|
27
|
+
function applyWorkerSrc(mod: PdfjsModule, workerSrc?: string) {
|
|
28
|
+
const explicit = workerSrc ?? configuredWorkerSrc;
|
|
29
|
+
if (explicit) mod.GlobalWorkerOptions.workerSrc = explicit;
|
|
30
|
+
else if (!mod.GlobalWorkerOptions.workerSrc) {
|
|
31
|
+
mod.GlobalWorkerOptions.workerSrc = cdnWorkerSrc(mod.version);
|
|
21
32
|
}
|
|
22
|
-
return pdfjsPromise;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
/** Set the pdf.js worker URL used by FileThumbnail PDF previews (app-wide). */
|
|
36
|
+
function setPdfWorkerSrc(workerSrc: string | undefined) {
|
|
37
|
+
configuredWorkerSrc = workerSrc;
|
|
38
|
+
void pdfjsPromise?.then((mod) => applyWorkerSrc(mod));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function loadPdfjs(workerSrc?: string): Promise<PdfjsModule> {
|
|
42
|
+
if (!pdfjsPromise) pdfjsPromise = import("pdfjs-dist");
|
|
43
|
+
const mod = await pdfjsPromise;
|
|
44
|
+
applyWorkerSrc(mod, workerSrc);
|
|
45
|
+
return mod;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function renderPdfFirstPage(
|
|
49
|
+
file: File,
|
|
50
|
+
targetWidth: number,
|
|
51
|
+
workerSrc?: string,
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
const pdfjs = await loadPdfjs(workerSrc);
|
|
27
54
|
const buffer = await file.arrayBuffer();
|
|
28
55
|
const pdf = await pdfjs.getDocument({ data: buffer }).promise;
|
|
29
56
|
const page = await pdf.getPage(1);
|
|
@@ -50,9 +77,21 @@ interface FileThumbnailProps {
|
|
|
50
77
|
/** Side length of the square thumbnail in pixels. */
|
|
51
78
|
size?: number | string;
|
|
52
79
|
className?: string;
|
|
80
|
+
/**
|
|
81
|
+
* URL of the pdf.js worker used for PDF previews. Overrides
|
|
82
|
+
* setPdfWorkerSrc(); when neither is set, the worker loads from jsDelivr.
|
|
83
|
+
*/
|
|
84
|
+
pdfWorkerSrc?: string;
|
|
53
85
|
}
|
|
54
86
|
|
|
55
|
-
function FileThumbnail({
|
|
87
|
+
function FileThumbnail({
|
|
88
|
+
file,
|
|
89
|
+
name,
|
|
90
|
+
type,
|
|
91
|
+
size = 48,
|
|
92
|
+
className,
|
|
93
|
+
pdfWorkerSrc,
|
|
94
|
+
}: FileThumbnailProps) {
|
|
56
95
|
const shape = useShape();
|
|
57
96
|
const numericSize = typeof size === "number" ? size : 48;
|
|
58
97
|
|
|
@@ -78,6 +117,32 @@ function FileThumbnail({ file, name, type, size = 48, className }: FileThumbnail
|
|
|
78
117
|
);
|
|
79
118
|
}
|
|
80
119
|
|
|
120
|
+
// Previews own their hooks in a child component so the no-file branch above
|
|
121
|
+
// never changes this component's hook order.
|
|
122
|
+
return (
|
|
123
|
+
<FilePreview
|
|
124
|
+
file={file}
|
|
125
|
+
size={numericSize}
|
|
126
|
+
className={className}
|
|
127
|
+
pdfWorkerSrc={pdfWorkerSrc}
|
|
128
|
+
shapeClassName={shape.bg}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function FilePreview({
|
|
134
|
+
file,
|
|
135
|
+
size: numericSize,
|
|
136
|
+
className,
|
|
137
|
+
pdfWorkerSrc,
|
|
138
|
+
shapeClassName,
|
|
139
|
+
}: {
|
|
140
|
+
file: File;
|
|
141
|
+
size: number;
|
|
142
|
+
className?: string | undefined;
|
|
143
|
+
pdfWorkerSrc?: string | undefined;
|
|
144
|
+
shapeClassName: string;
|
|
145
|
+
}) {
|
|
81
146
|
const isImage = file.type.startsWith("image/");
|
|
82
147
|
const isPdf = file.type === "application/pdf";
|
|
83
148
|
|
|
@@ -104,7 +169,7 @@ function FileThumbnail({ file, name, type, size = 48, className }: FileThumbnail
|
|
|
104
169
|
useEffect(() => {
|
|
105
170
|
if (!isPdf) return;
|
|
106
171
|
let cancelled = false;
|
|
107
|
-
renderPdfFirstPage(file, numericSize)
|
|
172
|
+
renderPdfFirstPage(file, numericSize, pdfWorkerSrc)
|
|
108
173
|
.then((url) => {
|
|
109
174
|
if (!cancelled) setPdfUrl(url);
|
|
110
175
|
})
|
|
@@ -114,7 +179,7 @@ function FileThumbnail({ file, name, type, size = 48, className }: FileThumbnail
|
|
|
114
179
|
return () => {
|
|
115
180
|
cancelled = true;
|
|
116
181
|
};
|
|
117
|
-
}, [file, isPdf, numericSize]);
|
|
182
|
+
}, [file, isPdf, numericSize, pdfWorkerSrc]);
|
|
118
183
|
|
|
119
184
|
const previewUrl = imageUrl ?? pdfUrl;
|
|
120
185
|
|
|
@@ -122,13 +187,12 @@ function FileThumbnail({ file, name, type, size = 48, className }: FileThumbnail
|
|
|
122
187
|
<div
|
|
123
188
|
className={cn(
|
|
124
189
|
"relative shrink-0 overflow-hidden bg-accent border border-border",
|
|
125
|
-
|
|
190
|
+
shapeClassName,
|
|
126
191
|
className,
|
|
127
192
|
)}
|
|
128
193
|
style={{ width: numericSize, height: numericSize }}
|
|
129
194
|
>
|
|
130
195
|
{previewUrl ? (
|
|
131
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
132
196
|
<img
|
|
133
197
|
src={previewUrl}
|
|
134
198
|
alt={file.name}
|
|
@@ -152,5 +216,5 @@ function FileThumbnail({ file, name, type, size = 48, className }: FileThumbnail
|
|
|
152
216
|
);
|
|
153
217
|
}
|
|
154
218
|
|
|
155
|
-
export { FileThumbnail, loadPdfjs, renderPdfFirstPage };
|
|
219
|
+
export { FileThumbnail, loadPdfjs, renderPdfFirstPage, setPdfWorkerSrc };
|
|
156
220
|
export type { FileThumbnailProps };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { SearchInput } from "./search-input";
|
|
8
|
+
|
|
9
|
+
interface FilterBarSearch {
|
|
10
|
+
value: string;
|
|
11
|
+
onValueChange: (value: string) => void;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
loading?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FilterBarProps {
|
|
17
|
+
/** Built-in SearchInput. Omit for filter-only bars. */
|
|
18
|
+
search?: FilterBarSearch;
|
|
19
|
+
/** Filter controls — Select, DateRangePicker, toggles. Scroll horizontally on mobile. */
|
|
20
|
+
children?: React.ReactNode;
|
|
21
|
+
/** Whether any filter/search is applied; shows the clear action. */
|
|
22
|
+
active?: boolean;
|
|
23
|
+
/** Reset search and filters. Shown only while `active`. */
|
|
24
|
+
onClear?: () => void;
|
|
25
|
+
clearLabel?: string;
|
|
26
|
+
/** Trailing actions (sort, export, view toggle) aligned right from `sm`. */
|
|
27
|
+
actions?: React.ReactNode;
|
|
28
|
+
/** Result summary under the bar ("24 orders"). */
|
|
29
|
+
summary?: React.ReactNode;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* List toolbar: search + filters + clear + actions. Stacks on mobile with the
|
|
35
|
+
* filter row scrolling horizontally; one row from `md`. Replaces hand-built
|
|
36
|
+
* "Input + Select + date inputs + reset" rows above tables.
|
|
37
|
+
*/
|
|
38
|
+
function FilterBar({
|
|
39
|
+
search,
|
|
40
|
+
children,
|
|
41
|
+
active = false,
|
|
42
|
+
onClear,
|
|
43
|
+
clearLabel = "Clear filters",
|
|
44
|
+
actions,
|
|
45
|
+
summary,
|
|
46
|
+
className,
|
|
47
|
+
}: FilterBarProps) {
|
|
48
|
+
return (
|
|
49
|
+
<div data-slot="filter-bar" className={cn("flex min-w-0 flex-col gap-2", className)}>
|
|
50
|
+
<div
|
|
51
|
+
role="toolbar"
|
|
52
|
+
aria-label="Filters"
|
|
53
|
+
className="flex min-w-0 flex-col gap-2 md:flex-row md:items-center"
|
|
54
|
+
>
|
|
55
|
+
{search && (
|
|
56
|
+
<SearchInput
|
|
57
|
+
value={search.value}
|
|
58
|
+
onValueChange={search.onValueChange}
|
|
59
|
+
placeholder={search.placeholder ?? "Search"}
|
|
60
|
+
{...(search.loading ? { loading: true } : {})}
|
|
61
|
+
containerClassName="md:max-w-xs md:flex-1"
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
{(children || (active && onClear)) && (
|
|
65
|
+
<div className="-mx-4 flex min-w-0 items-center gap-2 overflow-x-auto px-4 [scrollbar-width:none] md:mx-0 md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden [&>*]:shrink-0">
|
|
66
|
+
{children}
|
|
67
|
+
{active && onClear && (
|
|
68
|
+
<Button type="button" variant="ghost" size="sm" onClick={onClear}>
|
|
69
|
+
<X size={14} aria-hidden="true" />
|
|
70
|
+
{clearLabel}
|
|
71
|
+
</Button>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
{actions && (
|
|
76
|
+
<div className="flex shrink-0 items-center gap-2 md:ml-auto">{actions}</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
{summary && (
|
|
80
|
+
<p className="caption text-muted-foreground" aria-live="polite">
|
|
81
|
+
{summary}
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
FilterBar.displayName = "FilterBar";
|
|
89
|
+
|
|
90
|
+
export { FilterBar };
|
|
91
|
+
export type { FilterBarProps, FilterBarSearch };
|
|
@@ -80,7 +80,7 @@ function GridListCard({
|
|
|
80
80
|
{href ? (
|
|
81
81
|
<a
|
|
82
82
|
href={href}
|
|
83
|
-
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
|
|
83
|
+
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
|
|
84
84
|
>
|
|
85
85
|
{inner}
|
|
86
86
|
</a>
|
|
@@ -145,7 +145,7 @@ function GridListAccentCard({
|
|
|
145
145
|
{href ? (
|
|
146
146
|
<a
|
|
147
147
|
href={href}
|
|
148
|
-
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
|
|
148
|
+
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
|
|
149
149
|
>
|
|
150
150
|
{inner}
|
|
151
151
|
</a>
|
|
@@ -50,7 +50,7 @@ function HelpTooltip({
|
|
|
50
50
|
className={cn(
|
|
51
51
|
"relative ml-1 inline-flex size-5 items-center justify-center rounded-full text-muted-foreground",
|
|
52
52
|
"transition-[color,scale] duration-150 hover:text-foreground active:scale-[0.96]",
|
|
53
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-
|
|
53
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
54
54
|
"before:absolute before:-inset-2.5 before:content-['']",
|
|
55
55
|
)}
|
|
56
56
|
aria-label="Help"
|
|
@@ -252,7 +252,7 @@ const InputCopy = forwardRef<HTMLDivElement, InputCopyProps>(
|
|
|
252
252
|
aria-label={copied ? "Copied" : "Copy to clipboard"}
|
|
253
253
|
className={cn(
|
|
254
254
|
"group flex items-center w-full cursor-pointer outline-none transition-all duration-80",
|
|
255
|
-
"focus-visible:ring-
|
|
255
|
+
"focus-visible:ring-2 focus-visible:ring-ring",
|
|
256
256
|
shape.input,
|
|
257
257
|
)}
|
|
258
258
|
>
|
|
@@ -245,7 +245,7 @@ const InputField = forwardRef<HTMLLabelElement, InputFieldProps>(
|
|
|
245
245
|
bgClass = "bg-transparent";
|
|
246
246
|
ringClass = "ring-border";
|
|
247
247
|
} else if (error) {
|
|
248
|
-
bgClass = isFocused ? "bg-card" : isActive ? "bg-destructive
|
|
248
|
+
bgClass = isFocused ? "bg-card" : isActive ? "bg-destructive/10" : "bg-transparent";
|
|
249
249
|
ringClass = isFocused || isActive ? "ring-destructive/50" : "ring-transparent";
|
|
250
250
|
} else if (isFocused) {
|
|
251
251
|
bgClass = "bg-card";
|
|
@@ -175,7 +175,7 @@ function FilePreviewTile({ file, onRemove, size }: FilePreviewTileProps) {
|
|
|
175
175
|
// of theme — the close badge needs to read as a "delete affordance"
|
|
176
176
|
// over arbitrary image/PDF content, so it sits at a fixed contrast
|
|
177
177
|
// instead of flipping with the surrounding surface.
|
|
178
|
-
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-neutral-900 text-white opacity-0 group-hover/tile:opacity-100 transition-opacity duration-80 flex items-center justify-center cursor-pointer outline-none focus-visible:opacity-100 focus-visible:ring-
|
|
178
|
+
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-neutral-900 text-white opacity-0 group-hover/tile:opacity-100 transition-opacity duration-80 flex items-center justify-center cursor-pointer outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring"
|
|
179
179
|
>
|
|
180
180
|
<XIcon size={12} strokeWidth={2.5} />
|
|
181
181
|
</button>
|
|
@@ -246,7 +246,7 @@ function QueuedRow({
|
|
|
246
246
|
"group/qrow flex items-center gap-2 rounded-lg bg-muted px-2.5 py-1.5",
|
|
247
247
|
"text-[13px] text-foreground/85 select-none outline-none",
|
|
248
248
|
"cursor-grab active:cursor-grabbing",
|
|
249
|
-
"focus-visible:ring-
|
|
249
|
+
"focus-visible:ring-2 focus-visible:ring-ring",
|
|
250
250
|
)}
|
|
251
251
|
style={{ fontVariationSettings: fontWeights.normal }}
|
|
252
252
|
>
|
|
@@ -277,7 +277,7 @@ function QueuedRow({
|
|
|
277
277
|
? "opacity-100"
|
|
278
278
|
: "opacity-0 group-hover/qrow:opacity-100 focus-visible:opacity-100",
|
|
279
279
|
"transition-opacity duration-80 cursor-pointer outline-none",
|
|
280
|
-
"focus-visible:ring-
|
|
280
|
+
"focus-visible:ring-2 focus-visible:ring-ring",
|
|
281
281
|
)}
|
|
282
282
|
>
|
|
283
283
|
<XIcon size={13} strokeWidth={2.5} />
|
|
@@ -379,7 +379,7 @@ const InputMessage = forwardRef<HTMLDivElement, InputMessageProps>(
|
|
|
379
379
|
// `shadow-surface-2` supplies the resting edge.
|
|
380
380
|
const EDGE_DROP = "0 1px 1px -0.5px var(--shadow-color)";
|
|
381
381
|
const edgeShadow = dragOver
|
|
382
|
-
? `0 0 0
|
|
382
|
+
? `0 0 0 2px var(--ring), ${EDGE_DROP}`
|
|
383
383
|
: focusVisible
|
|
384
384
|
? `0 0 0 1px color-mix(in oklab, var(--foreground) 20%, transparent), ${EDGE_DROP}`
|
|
385
385
|
: hovered && clickToFocus && !disabled
|
|
@@ -688,6 +688,7 @@ const InputMessage = forwardRef<HTMLDivElement, InputMessageProps>(
|
|
|
688
688
|
);
|
|
689
689
|
|
|
690
690
|
return (
|
|
691
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- click-to-focus-the-textarea and drag/drop target; the textarea and buttons inside carry the keyboard interaction
|
|
691
692
|
<div
|
|
692
693
|
ref={ref}
|
|
693
694
|
onMouseDown={handleContainerMouseDown}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
5
5
|
import { useShape } from "../lib/shape-context";
|
|
6
|
+
import { useDensity } from "../lib/density-context";
|
|
7
|
+
import { inputFocusWithinClasses } from "../lib/interaction";
|
|
6
8
|
|
|
7
9
|
interface InputNumberProps extends Omit<
|
|
8
10
|
React.ComponentProps<"input">,
|
|
@@ -21,10 +23,31 @@ interface InputNumberProps extends Omit<
|
|
|
21
23
|
mixedLabel?: string | undefined;
|
|
22
24
|
/** Calls onChange while typing instead of waiting for blur/Enter. */
|
|
23
25
|
commitOnChange?: boolean | undefined;
|
|
24
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Hides the up/down stepper buttons. Defaults to `true` under
|
|
28
|
+
* data-density="compact" (editor chrome relies on arrow keys + label
|
|
29
|
+
* scrubbing there) and `false` otherwise.
|
|
30
|
+
*/
|
|
25
31
|
hideSteppers?: boolean | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Short prefix label rendered inside the field (e.g. "X", "W", an icon).
|
|
34
|
+
* It doubles as a scrub handle: drag horizontally to change the value
|
|
35
|
+
* (Shift = 10×), like Figma's inspector fields.
|
|
36
|
+
*/
|
|
37
|
+
label?: React.ReactNode;
|
|
38
|
+
/** Accessible name for the scrub handle when `label` is not plain text. */
|
|
39
|
+
scrubLabel?: string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Horizontal alignment of the number. Defaults to `"left"` when a `label`
|
|
42
|
+
* is given or under compact density (value sits next to its label), and
|
|
43
|
+
* `"right"` otherwise.
|
|
44
|
+
*/
|
|
45
|
+
align?: "left" | "right" | undefined;
|
|
26
46
|
}
|
|
27
47
|
|
|
48
|
+
/** Pixels of pointer travel per step while scrubbing a label. */
|
|
49
|
+
const SCRUB_PX_PER_STEP = 2;
|
|
50
|
+
|
|
28
51
|
/**
|
|
29
52
|
* Numeric input with up/down steppers, unit suffix, and arrow-key step (Shift = 10×).
|
|
30
53
|
* Designed for designer property panels — see PHASE_0_AUDIT.md §P0.2.
|
|
@@ -41,7 +64,10 @@ const InputNumber = React.forwardRef<HTMLInputElement, InputNumberProps>(
|
|
|
41
64
|
precision = 0,
|
|
42
65
|
mixedLabel = "Mixed",
|
|
43
66
|
commitOnChange = false,
|
|
44
|
-
hideSteppers
|
|
67
|
+
hideSteppers: hideSteppersProp,
|
|
68
|
+
label,
|
|
69
|
+
scrubLabel,
|
|
70
|
+
align: alignProp,
|
|
45
71
|
className,
|
|
46
72
|
disabled,
|
|
47
73
|
onBlur,
|
|
@@ -52,6 +78,10 @@ const InputNumber = React.forwardRef<HTMLInputElement, InputNumberProps>(
|
|
|
52
78
|
ref,
|
|
53
79
|
) => {
|
|
54
80
|
const shape = useShape();
|
|
81
|
+
const density = useDensity();
|
|
82
|
+
const compact = density === "compact";
|
|
83
|
+
const hideSteppers = hideSteppersProp ?? compact;
|
|
84
|
+
const align = alignProp ?? (label !== undefined || compact ? "left" : "right");
|
|
55
85
|
|
|
56
86
|
const formatValue = React.useCallback(
|
|
57
87
|
(next: number | null) => (next === null ? mixedLabel : next.toFixed(precision)),
|
|
@@ -93,6 +123,32 @@ const InputNumber = React.forwardRef<HTMLInputElement, InputNumberProps>(
|
|
|
93
123
|
setText(next.toFixed(precision));
|
|
94
124
|
};
|
|
95
125
|
|
|
126
|
+
// Label scrubbing — drag the prefix label to nudge the value.
|
|
127
|
+
const scrubRef = React.useRef<{ x: number; start: number; last: number } | null>(null);
|
|
128
|
+
const handleScrubDown = (e: React.PointerEvent<HTMLSpanElement>) => {
|
|
129
|
+
if (disabled || rest.readOnly || e.button !== 0) return;
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
132
|
+
const start = value ?? 0;
|
|
133
|
+
scrubRef.current = { x: e.clientX, start, last: start };
|
|
134
|
+
};
|
|
135
|
+
const handleScrubMove = (e: React.PointerEvent<HTMLSpanElement>) => {
|
|
136
|
+
const scrub = scrubRef.current;
|
|
137
|
+
if (!scrub) return;
|
|
138
|
+
const steps = Math.round((e.clientX - scrub.x) / SCRUB_PX_PER_STEP);
|
|
139
|
+
const next = clamp(scrub.start + steps * step * (e.shiftKey ? 10 : 1));
|
|
140
|
+
const rounded = Number(next.toFixed(precision));
|
|
141
|
+
if (rounded === scrub.last) return;
|
|
142
|
+
scrub.last = rounded;
|
|
143
|
+
onChange(rounded);
|
|
144
|
+
setText(rounded.toFixed(precision));
|
|
145
|
+
};
|
|
146
|
+
const handleScrubEnd = (e: React.PointerEvent<HTMLSpanElement>) => {
|
|
147
|
+
if (!scrubRef.current) return;
|
|
148
|
+
scrubRef.current = null;
|
|
149
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
150
|
+
};
|
|
151
|
+
|
|
96
152
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
97
153
|
onKeyDown?.(e);
|
|
98
154
|
if (e.defaultPrevented) return;
|
|
@@ -110,19 +166,45 @@ const InputNumber = React.forwardRef<HTMLInputElement, InputNumberProps>(
|
|
|
110
166
|
|
|
111
167
|
return (
|
|
112
168
|
<div
|
|
169
|
+
data-slot="input-number"
|
|
113
170
|
className={cn(
|
|
114
|
-
|
|
171
|
+
// w-48 ≈ the old intrinsic width of the native input (size=20), kept as an
|
|
172
|
+
// overridable default; min-w-fit stops narrow widths clipping digits.
|
|
173
|
+
"inline-flex h-8 w-48 min-w-fit items-stretch overflow-hidden border border-border bg-background",
|
|
174
|
+
"transition-[border-color,box-shadow] duration-150 hover:border-border-strong",
|
|
115
175
|
shape.input,
|
|
116
|
-
"
|
|
176
|
+
"compact:h-6 compact:rounded-[5px]",
|
|
177
|
+
inputFocusWithinClasses,
|
|
117
178
|
disabled && "opacity-50 pointer-events-none",
|
|
118
179
|
className,
|
|
119
180
|
)}
|
|
120
181
|
>
|
|
182
|
+
{label !== undefined && (
|
|
183
|
+
<span
|
|
184
|
+
aria-hidden
|
|
185
|
+
title={scrubLabel}
|
|
186
|
+
onPointerDown={handleScrubDown}
|
|
187
|
+
onPointerMove={handleScrubMove}
|
|
188
|
+
onPointerUp={handleScrubEnd}
|
|
189
|
+
onPointerCancel={handleScrubEnd}
|
|
190
|
+
className={cn(
|
|
191
|
+
// Fixed-width prefix column so values in stacked fields line up.
|
|
192
|
+
"flex w-6 shrink-0 cursor-ew-resize select-none items-center justify-center",
|
|
193
|
+
"caption-xs font-medium leading-none text-muted-foreground transition-colors hover:text-foreground",
|
|
194
|
+
"compact:w-5 compact:text-[11px] [&_svg]:size-3",
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
{label}
|
|
198
|
+
</span>
|
|
199
|
+
)}
|
|
121
200
|
<input
|
|
122
201
|
ref={ref}
|
|
123
202
|
type="text"
|
|
124
203
|
inputMode="decimal"
|
|
125
204
|
spellCheck={false}
|
|
205
|
+
// Neutralise the native 20-char intrinsic width so the container's
|
|
206
|
+
// min-w-fit is driven by the explicit min-width below, not `size`.
|
|
207
|
+
size={1}
|
|
126
208
|
value={text}
|
|
127
209
|
onChange={(e) => {
|
|
128
210
|
setText(e.target.value);
|
|
@@ -145,27 +227,37 @@ const InputNumber = React.forwardRef<HTMLInputElement, InputNumberProps>(
|
|
|
145
227
|
}}
|
|
146
228
|
onKeyDown={handleKeyDown}
|
|
147
229
|
disabled={disabled}
|
|
148
|
-
|
|
230
|
+
aria-label={
|
|
231
|
+
typeof label === "string" && !rest["aria-labelledby"] ? label : undefined
|
|
232
|
+
}
|
|
233
|
+
className={cn(
|
|
234
|
+
// min-width fits three tabular digits + padding so "100" never clips.
|
|
235
|
+
"w-full min-w-[calc(3ch+1rem)] caption tabular-nums text-foreground px-2 bg-transparent focus:outline-none",
|
|
236
|
+
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
|
|
237
|
+
"compact:min-w-[calc(3ch+0.75rem)] compact:px-1.5 compact:text-[12px]",
|
|
238
|
+
label !== undefined && "pl-0.5 compact:pl-0.5",
|
|
239
|
+
align === "right" ? "text-right" : "text-left",
|
|
240
|
+
)}
|
|
149
241
|
{...rest}
|
|
150
242
|
/>
|
|
151
243
|
{unit && (
|
|
152
244
|
<span
|
|
153
245
|
aria-hidden
|
|
154
|
-
className="caption-xs text-muted-foreground self-center pr-1.5
|
|
246
|
+
className="caption-xs text-muted-foreground self-center pr-2 select-none compact:pr-1.5 compact:text-[11px]"
|
|
155
247
|
>
|
|
156
248
|
{unit}
|
|
157
249
|
</span>
|
|
158
250
|
)}
|
|
159
251
|
{!hideSteppers && (
|
|
160
|
-
<div className="flex flex-col border-l border-border">
|
|
252
|
+
<div className="flex w-5 shrink-0 flex-col border-l border-border">
|
|
161
253
|
<button
|
|
162
254
|
type="button"
|
|
163
255
|
tabIndex={-1}
|
|
164
256
|
onClick={() => bump(step)}
|
|
165
257
|
aria-label="Increment"
|
|
166
|
-
className="flex-1
|
|
258
|
+
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors hover:bg-hover hover:text-foreground active:bg-active"
|
|
167
259
|
>
|
|
168
|
-
<svg viewBox="0 0 8 5" width="8" height="5" fill="currentColor">
|
|
260
|
+
<svg viewBox="0 0 8 5" width="8" height="5" fill="currentColor" aria-hidden>
|
|
169
261
|
<path d="M0 5 L4 0 L8 5 Z" />
|
|
170
262
|
</svg>
|
|
171
263
|
</button>
|
|
@@ -175,9 +267,9 @@ const InputNumber = React.forwardRef<HTMLInputElement, InputNumberProps>(
|
|
|
175
267
|
tabIndex={-1}
|
|
176
268
|
onClick={() => bump(-step)}
|
|
177
269
|
aria-label="Decrement"
|
|
178
|
-
className="flex-1
|
|
270
|
+
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors hover:bg-hover hover:text-foreground active:bg-active"
|
|
179
271
|
>
|
|
180
|
-
<svg viewBox="0 0 8 5" width="8" height="5" fill="currentColor">
|
|
272
|
+
<svg viewBox="0 0 8 5" width="8" height="5" fill="currentColor" aria-hidden>
|
|
181
273
|
<path d="M0 0 L4 5 L8 0 Z" />
|
|
182
274
|
</svg>
|
|
183
275
|
</button>
|
package/src/components/input.tsx
CHANGED
|
@@ -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
|
|
|
8
8
|
type ControlDensity = "default" | "compact";
|
|
@@ -52,11 +52,13 @@ function Input({
|
|
|
52
52
|
"body text-foreground placeholder:text-muted-foreground",
|
|
53
53
|
controlSurfaceClasses,
|
|
54
54
|
motionClasses,
|
|
55
|
-
|
|
55
|
+
inputFocusClasses,
|
|
56
56
|
controlMobileSurfaceClasses[mobileSurface],
|
|
57
|
-
"focus-visible:border-brand-primary",
|
|
58
57
|
"disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-muted",
|
|
59
58
|
"file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
|
|
59
|
+
// Editor-chrome density (data-density="compact" ancestor).
|
|
60
|
+
"compact:h-6 compact:px-2 compact:py-0 compact:text-[12px] compact:rounded-[5px]",
|
|
61
|
+
type === "number" && "tabular-nums",
|
|
60
62
|
className,
|
|
61
63
|
)}
|
|
62
64
|
{...props}
|
package/src/components/label.tsx
CHANGED
|
@@ -66,7 +66,7 @@ function MediaAssetCard({
|
|
|
66
66
|
"relative flex shrink-0 items-center justify-center overflow-hidden bg-muted text-left",
|
|
67
67
|
"transition-[background-color,box-shadow,scale] duration-150",
|
|
68
68
|
isInteractive &&
|
|
69
|
-
"cursor-pointer active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-
|
|
69
|
+
"cursor-pointer active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
70
70
|
isList && "size-16 rounded-lg",
|
|
71
71
|
isGrid && "aspect-square w-full",
|
|
72
72
|
orientation === "responsive" &&
|
|
@@ -19,6 +19,15 @@ import { shapeMap } from "../lib/shape-context";
|
|
|
19
19
|
// shape — see dropdown.tsx for the rationale.
|
|
20
20
|
const shape = shapeMap.rounded;
|
|
21
21
|
|
|
22
|
+
/** ARIA flavour of a menu row. */
|
|
23
|
+
type MenuItemType = "item" | "radio" | "checkbox";
|
|
24
|
+
|
|
25
|
+
const MENU_ITEM_ROLES: Record<MenuItemType, "menuitem" | "menuitemradio" | "menuitemcheckbox"> = {
|
|
26
|
+
item: "menuitem",
|
|
27
|
+
radio: "menuitemradio",
|
|
28
|
+
checkbox: "menuitemcheckbox",
|
|
29
|
+
};
|
|
30
|
+
|
|
22
31
|
interface MenuItemProps extends HTMLAttributes<HTMLDivElement> {
|
|
23
32
|
/** Optional leading icon. When omitted, the row renders text-only with no
|
|
24
33
|
* reserved icon column. */
|
|
@@ -26,13 +35,34 @@ interface MenuItemProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
26
35
|
label?: string;
|
|
27
36
|
index?: number;
|
|
28
37
|
checked?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* ARIA semantics of the row:
|
|
40
|
+
* - `"item"` → `role="menuitem"` (a plain action, no `aria-checked`)
|
|
41
|
+
* - `"radio"` → `role="menuitemradio"` (one-of-many selection)
|
|
42
|
+
* - `"checkbox"` → `role="menuitemcheckbox"` (independent toggle)
|
|
43
|
+
*
|
|
44
|
+
* Defaults to `"radio"` when the row carries selection state — `checked` is
|
|
45
|
+
* passed or the parent `<Dropdown>` has a `checkedIndex` — otherwise `"item"`.
|
|
46
|
+
*/
|
|
47
|
+
type?: MenuItemType;
|
|
29
48
|
onSelect?: () => void;
|
|
30
49
|
trailing?: ReactNode;
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
34
53
|
(
|
|
35
|
-
{
|
|
54
|
+
{
|
|
55
|
+
icon: Icon,
|
|
56
|
+
label,
|
|
57
|
+
index = 0,
|
|
58
|
+
checked,
|
|
59
|
+
type,
|
|
60
|
+
onSelect,
|
|
61
|
+
trailing,
|
|
62
|
+
className,
|
|
63
|
+
children,
|
|
64
|
+
...props
|
|
65
|
+
},
|
|
36
66
|
ref,
|
|
37
67
|
) => {
|
|
38
68
|
const internalRef = useRef<HTMLDivElement>(null);
|
|
@@ -48,6 +78,9 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
|
48
78
|
hasMounted.current = true;
|
|
49
79
|
}, []);
|
|
50
80
|
|
|
81
|
+
const resolvedType: MenuItemType =
|
|
82
|
+
type ?? (checked !== undefined || checkedIndex !== undefined ? "radio" : "item");
|
|
83
|
+
const role = MENU_ITEM_ROLES[resolvedType];
|
|
51
84
|
const isActive = activeIndex === index;
|
|
52
85
|
const skipAnimation = !hasMounted.current;
|
|
53
86
|
|
|
@@ -60,8 +93,8 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
|
60
93
|
}}
|
|
61
94
|
data-proximity-index={index}
|
|
62
95
|
tabIndex={index === (checkedIndex ?? 0) ? 0 : -1}
|
|
63
|
-
role=
|
|
64
|
-
aria-checked={!!checked}
|
|
96
|
+
role={role}
|
|
97
|
+
aria-checked={resolvedType === "item" ? undefined : !!checked}
|
|
65
98
|
aria-label={label ?? (typeof children === "string" ? children : undefined)}
|
|
66
99
|
onClick={onSelect}
|
|
67
100
|
onKeyDown={(e) => {
|
|
@@ -153,4 +186,5 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
|
153
186
|
MenuItem.displayName = "MenuItem";
|
|
154
187
|
|
|
155
188
|
export { MenuItem };
|
|
189
|
+
export type { MenuItemProps, MenuItemType };
|
|
156
190
|
export default MenuItem;
|