@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.
Files changed (90) hide show
  1. package/README.md +32 -1
  2. package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
  3. package/dist/chunk-R576DV2A.js.map +1 -0
  4. package/dist/create-theme.js +2 -1
  5. package/dist/create-theme.js.map +1 -1
  6. package/dist/index.d.ts +733 -68
  7. package/dist/index.js +3097 -1619
  8. package/dist/index.js.map +1 -1
  9. package/dist/tokens.css +163 -19
  10. package/dist/tokens.d.ts +69 -5
  11. package/dist/tokens.js +1 -1
  12. package/llms.txt +11 -4
  13. package/package.json +11 -10
  14. package/src/components/accordion.tsx +2 -2
  15. package/src/components/account-menu.tsx +1 -1
  16. package/src/components/alert-dialog.tsx +107 -0
  17. package/src/components/ask-user-questions.tsx +24 -18
  18. package/src/components/badge.tsx +36 -3
  19. package/src/components/breadcrumb.tsx +4 -2
  20. package/src/components/button-group.tsx +1 -1
  21. package/src/components/button.tsx +31 -23
  22. package/src/components/calendar.tsx +1 -1
  23. package/src/components/card.tsx +4 -2
  24. package/src/components/checkbox-card.tsx +1 -1
  25. package/src/components/checkbox-group.tsx +2 -1
  26. package/src/components/code-block.tsx +99 -0
  27. package/src/components/color-input.tsx +9 -6
  28. package/src/components/color-picker.tsx +38 -26
  29. package/src/components/combobox.tsx +59 -12
  30. package/src/components/command-palette.tsx +150 -42
  31. package/src/components/command.tsx +5 -3
  32. package/src/components/context-menu.tsx +18 -15
  33. package/src/components/contextual-save-bar.tsx +156 -0
  34. package/src/components/data-table.tsx +34 -5
  35. package/src/components/date-picker.tsx +275 -28
  36. package/src/components/date-text.tsx +90 -0
  37. package/src/components/dropdown-menu.tsx +11 -8
  38. package/src/components/dropdown.tsx +6 -3
  39. package/src/components/empty-state.tsx +85 -20
  40. package/src/components/file-dropzone.tsx +2 -1
  41. package/src/components/file-thumbnail.tsx +84 -20
  42. package/src/components/filter-bar.tsx +91 -0
  43. package/src/components/grid-list.tsx +2 -2
  44. package/src/components/help-tooltip.tsx +1 -1
  45. package/src/components/input-copy.tsx +1 -1
  46. package/src/components/input-group.tsx +1 -1
  47. package/src/components/input-message.tsx +5 -4
  48. package/src/components/input-number.tsx +103 -11
  49. package/src/components/input.tsx +5 -3
  50. package/src/components/label.tsx +1 -0
  51. package/src/components/media-asset-card.tsx +1 -1
  52. package/src/components/menu-item.tsx +37 -3
  53. package/src/components/menubar.tsx +21 -18
  54. package/src/components/native-select.tsx +1 -1
  55. package/src/components/navigation-menu.tsx +2 -2
  56. package/src/components/notification.tsx +1 -1
  57. package/src/components/pagination.tsx +97 -3
  58. package/src/components/popover.tsx +2 -0
  59. package/src/components/property-row.tsx +7 -4
  60. package/src/components/radio-card.tsx +1 -1
  61. package/src/components/radio-group.tsx +2 -1
  62. package/src/components/rating.tsx +136 -0
  63. package/src/components/resizable.tsx +1 -1
  64. package/src/components/resource-item.tsx +133 -0
  65. package/src/components/rich-text-editor.tsx +1 -0
  66. package/src/components/search-input.tsx +98 -0
  67. package/src/components/searchable-table.tsx +10 -11
  68. package/src/components/select.tsx +28 -9
  69. package/src/components/sidebar.tsx +4 -4
  70. package/src/components/slider.tsx +78 -36
  71. package/src/components/sonner.tsx +18 -4
  72. package/src/components/stat-card.tsx +121 -37
  73. package/src/components/status-badge.tsx +142 -47
  74. package/src/components/status-tile.tsx +9 -0
  75. package/src/components/switch.tsx +6 -5
  76. package/src/components/tabs-subtle.tsx +10 -2
  77. package/src/components/tabs.tsx +23 -3
  78. package/src/components/textarea.tsx +2 -3
  79. package/src/components/thinking-steps.tsx +3 -3
  80. package/src/components/toggle.tsx +14 -13
  81. package/src/components/tooltip.tsx +11 -8
  82. package/src/components/url-redirect-prompt.tsx +0 -1
  83. package/src/index.ts +15 -0
  84. package/src/lib/density-context.tsx +65 -0
  85. package/src/lib/format.tsx +301 -0
  86. package/src/lib/interaction.ts +12 -4
  87. package/src/lib/scroll-fade.tsx +39 -0
  88. package/src/tokens/tokens.test.ts +77 -0
  89. package/src/tokens/tokens.ts +114 -5
  90. package/dist/chunk-RI6QJR2L.js.map +0 -1
@@ -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, caches the module, and points the worker
9
- // at the matching CDN build. Consumers don't need bundler-side worker config.
10
- type PdfjsModule = typeof import("pdfjs-dist");
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
- async function loadPdfjs(): Promise<PdfjsModule> {
14
- if (!pdfjsPromise) {
15
- pdfjsPromise = import("pdfjs-dist").then((mod) => {
16
- if (!mod.GlobalWorkerOptions.workerSrc) {
17
- mod.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${mod.version}/build/pdf.worker.min.mjs`;
18
- }
19
- return mod;
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
- async function renderPdfFirstPage(file: File, targetWidth: number): Promise<string> {
26
- const pdfjs = await loadPdfjs();
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({ file, name, type, size = 48, className }: FileThumbnailProps) {
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
- shape.bg,
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/30 rounded-xl"
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/30 rounded-xl"
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-brand-primary/30",
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-1 focus-visible:ring-[#6B97FF]",
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-light/60" : "bg-transparent";
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-1 focus-visible:ring-[#6B97FF]"
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-1 focus-visible:ring-[#6B97FF]",
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-1 focus-visible:ring-[#6B97FF]",
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 1px #6B97FF, ${EDGE_DROP}`
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
- /** Hides the up/down stepper buttons. */
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 = false,
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
- "inline-flex h-8 items-stretch overflow-hidden border border-border bg-background",
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
- "focus-within:border-brand-primary focus-within:ring-2 focus-within:ring-brand-primary/20",
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
- className="w-full caption text-foreground px-2 bg-transparent focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none text-right"
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 select-none"
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 px-1 hover:bg-muted active:bg-muted transition-colors text-muted-foreground"
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 px-1 hover:bg-muted active:bg-muted transition-colors text-muted-foreground"
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>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { cn } from "../lib/utils";
5
- import { controlSurfaceClasses, focusRingClasses, motionClasses } from "../lib/interaction";
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
- focusRingClasses,
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}
@@ -13,6 +13,7 @@ const Label = React.forwardRef<
13
13
  className={cn(
14
14
  "body font-medium leading-none text-muted-foreground",
15
15
  "peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
16
+ "compact:text-[11px]",
16
17
  className,
17
18
  )}
18
19
  {...props}
@@ -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-brand-primary/30",
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
- { icon: Icon, label, index = 0, checked, onSelect, trailing, className, children, ...props },
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="menuitemradio"
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;