@hilum/ui 3.8.6 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +32 -1
  2. package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
  3. package/dist/chunk-R576DV2A.js.map +1 -0
  4. package/dist/create-theme.js +2 -1
  5. package/dist/create-theme.js.map +1 -1
  6. package/dist/index.d.ts +733 -68
  7. package/dist/index.js +3097 -1619
  8. package/dist/index.js.map +1 -1
  9. package/dist/tokens.css +163 -19
  10. package/dist/tokens.d.ts +69 -5
  11. package/dist/tokens.js +1 -1
  12. package/llms.txt +11 -4
  13. package/package.json +11 -10
  14. package/src/components/accordion.tsx +2 -2
  15. package/src/components/account-menu.tsx +1 -1
  16. package/src/components/alert-dialog.tsx +107 -0
  17. package/src/components/ask-user-questions.tsx +24 -18
  18. package/src/components/badge.tsx +36 -3
  19. package/src/components/breadcrumb.tsx +4 -2
  20. package/src/components/button-group.tsx +1 -1
  21. package/src/components/button.tsx +31 -23
  22. package/src/components/calendar.tsx +1 -1
  23. package/src/components/card.tsx +4 -2
  24. package/src/components/checkbox-card.tsx +1 -1
  25. package/src/components/checkbox-group.tsx +2 -1
  26. package/src/components/code-block.tsx +99 -0
  27. package/src/components/color-input.tsx +9 -6
  28. package/src/components/color-picker.tsx +38 -26
  29. package/src/components/combobox.tsx +59 -12
  30. package/src/components/command-palette.tsx +150 -42
  31. package/src/components/command.tsx +5 -3
  32. package/src/components/context-menu.tsx +18 -15
  33. package/src/components/contextual-save-bar.tsx +156 -0
  34. package/src/components/data-table.tsx +34 -5
  35. package/src/components/date-picker.tsx +275 -28
  36. package/src/components/date-text.tsx +90 -0
  37. package/src/components/dropdown-menu.tsx +11 -8
  38. package/src/components/dropdown.tsx +6 -3
  39. package/src/components/empty-state.tsx +85 -20
  40. package/src/components/file-dropzone.tsx +2 -1
  41. package/src/components/file-thumbnail.tsx +84 -20
  42. package/src/components/filter-bar.tsx +91 -0
  43. package/src/components/grid-list.tsx +2 -2
  44. package/src/components/help-tooltip.tsx +1 -1
  45. package/src/components/input-copy.tsx +1 -1
  46. package/src/components/input-group.tsx +1 -1
  47. package/src/components/input-message.tsx +5 -4
  48. package/src/components/input-number.tsx +103 -11
  49. package/src/components/input.tsx +5 -3
  50. package/src/components/label.tsx +1 -0
  51. package/src/components/media-asset-card.tsx +1 -1
  52. package/src/components/menu-item.tsx +37 -3
  53. package/src/components/menubar.tsx +21 -18
  54. package/src/components/native-select.tsx +1 -1
  55. package/src/components/navigation-menu.tsx +2 -2
  56. package/src/components/notification.tsx +1 -1
  57. package/src/components/pagination.tsx +97 -3
  58. package/src/components/popover.tsx +2 -0
  59. package/src/components/property-row.tsx +7 -4
  60. package/src/components/radio-card.tsx +1 -1
  61. package/src/components/radio-group.tsx +2 -1
  62. package/src/components/rating.tsx +136 -0
  63. package/src/components/resizable.tsx +1 -1
  64. package/src/components/resource-item.tsx +133 -0
  65. package/src/components/rich-text-editor.tsx +1 -0
  66. package/src/components/search-input.tsx +98 -0
  67. package/src/components/searchable-table.tsx +10 -11
  68. package/src/components/select.tsx +28 -9
  69. package/src/components/sidebar.tsx +4 -4
  70. package/src/components/slider.tsx +78 -36
  71. package/src/components/sonner.tsx +18 -4
  72. package/src/components/stat-card.tsx +121 -37
  73. package/src/components/status-badge.tsx +142 -47
  74. package/src/components/status-tile.tsx +9 -0
  75. package/src/components/switch.tsx +6 -5
  76. package/src/components/tabs-subtle.tsx +10 -2
  77. package/src/components/tabs.tsx +23 -3
  78. package/src/components/textarea.tsx +2 -3
  79. package/src/components/thinking-steps.tsx +3 -3
  80. package/src/components/toggle.tsx +14 -13
  81. package/src/components/tooltip.tsx +11 -8
  82. package/src/components/url-redirect-prompt.tsx +0 -1
  83. package/src/index.ts +15 -0
  84. package/src/lib/density-context.tsx +65 -0
  85. package/src/lib/format.tsx +301 -0
  86. package/src/lib/interaction.ts +12 -4
  87. package/src/lib/scroll-fade.tsx +39 -0
  88. package/src/tokens/tokens.test.ts +77 -0
  89. package/src/tokens/tokens.ts +114 -5
  90. package/dist/chunk-RI6QJR2L.js.map +0 -1
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import { Menubar } from "radix-ui";
5
5
  import { Check, ChevronRight, Circle } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
+ import { useDensityAttributes } from "../lib/density-context";
7
8
  import {
8
9
  mobilePopperSheetMotionClassName,
9
10
  mobilePopperSheetPositionClassName,
@@ -43,8 +44,8 @@ const MenubarTrigger = React.forwardRef<
43
44
  className={cn(
44
45
  "flex min-h-8 cursor-default items-center rounded-md px-3 py-1 body font-medium text-muted-foreground select-none outline-none",
45
46
  "hover:bg-muted",
46
- "data-[state=open]:bg-muted data-[state=open]:text-foreground",
47
- "focus:bg-muted",
47
+ "data-[state=open]:bg-active data-[state=open]:text-foreground",
48
+ "focus:bg-active",
48
49
  className,
49
50
  )}
50
51
  {...props}
@@ -60,6 +61,7 @@ const MenubarContent = React.forwardRef<
60
61
  <style>{mobilePopperSheetStyle}</style>
61
62
  <Menubar.Portal>
62
63
  <Menubar.Content
64
+ {...useDensityAttributes()}
63
65
  ref={ref}
64
66
  data-hilum-mobile-sheet="true"
65
67
  align={align}
@@ -94,11 +96,11 @@ const MenubarItem = React.forwardRef<
94
96
  <Menubar.Item
95
97
  ref={ref}
96
98
  className={cn(
97
- "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2",
98
- "body text-muted-foreground outline-none transition-colors",
99
- "focus:bg-muted focus:text-foreground",
99
+ "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 compact:min-h-7 compact:px-2 compact:py-1 compact:text-[13px] compact:rounded-[4px]",
100
+ "body text-foreground outline-none transition-colors",
101
+ "focus:bg-active focus:text-foreground",
100
102
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
101
- inset && "pl-8",
103
+ inset && "pl-8 compact:pl-7",
102
104
  className,
103
105
  )}
104
106
  {...props}
@@ -113,9 +115,9 @@ const MenubarCheckboxItem = React.forwardRef<
113
115
  <Menubar.CheckboxItem
114
116
  ref={ref}
115
117
  className={cn(
116
- "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md py-2 pl-8 pr-2.5",
117
- "body text-muted-foreground outline-none transition-colors",
118
- "focus:bg-muted focus:text-foreground",
118
+ "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md py-2 pl-8 pr-2.5 compact:min-h-7 compact:py-1 compact:pl-7 compact:pr-2 compact:text-[13px] compact:rounded-[4px]",
119
+ "body text-foreground outline-none transition-colors",
120
+ "focus:bg-active focus:text-foreground",
119
121
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
120
122
  className,
121
123
  )}
@@ -138,9 +140,9 @@ const MenubarRadioItem = React.forwardRef<
138
140
  <Menubar.RadioItem
139
141
  ref={ref}
140
142
  className={cn(
141
- "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md py-2 pl-8 pr-2.5",
142
- "body text-muted-foreground outline-none transition-colors",
143
- "focus:bg-muted focus:text-foreground",
143
+ "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md py-2 pl-8 pr-2.5 compact:min-h-7 compact:py-1 compact:pl-7 compact:pr-2 compact:text-[13px] compact:rounded-[4px]",
144
+ "body text-foreground outline-none transition-colors",
145
+ "focus:bg-active focus:text-foreground",
144
146
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
145
147
  className,
146
148
  )}
@@ -164,7 +166,7 @@ const MenubarLabel = React.forwardRef<
164
166
  >(({ className, inset, ...props }, ref) => (
165
167
  <Menubar.Label
166
168
  ref={ref}
167
- className={cn("px-2.5 py-1 label text-muted-foreground", inset && "pl-8", className)}
169
+ className={cn("px-2.5 py-1 label text-muted-foreground compact:px-2 compact:text-[11px]", inset && "pl-8 compact:pl-7", className)}
168
170
  {...props}
169
171
  />
170
172
  ));
@@ -174,7 +176,7 @@ const MenubarSeparator = React.forwardRef<
174
176
  React.ComponentRef<typeof Menubar.Separator>,
175
177
  React.ComponentPropsWithoutRef<typeof Menubar.Separator>
176
178
  >(({ className, ...props }, ref) => (
177
- <Menubar.Separator ref={ref} className={cn("mx-1 my-1 h-px bg-muted", className)} {...props} />
179
+ <Menubar.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
178
180
  ));
179
181
  MenubarSeparator.displayName = "MenubarSeparator";
180
182
 
@@ -187,10 +189,10 @@ const MenubarSubTrigger = React.forwardRef<
187
189
  <Menubar.SubTrigger
188
190
  ref={ref}
189
191
  className={cn(
190
- "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2",
191
- "body text-muted-foreground outline-none transition-colors",
192
- "focus:bg-muted focus:text-foreground data-[state=open]:bg-muted",
193
- inset && "pl-8",
192
+ "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 compact:min-h-7 compact:px-2 compact:py-1 compact:text-[13px] compact:rounded-[4px]",
193
+ "body text-foreground outline-none transition-colors",
194
+ "focus:bg-active focus:text-foreground data-[state=open]:bg-active",
195
+ inset && "pl-8 compact:pl-7",
194
196
  className,
195
197
  )}
196
198
  {...props}
@@ -208,6 +210,7 @@ const MenubarSubContent = React.forwardRef<
208
210
  <>
209
211
  <style>{mobilePopperSheetStyle}</style>
210
212
  <Menubar.SubContent
213
+ {...useDensityAttributes()}
211
214
  ref={ref}
212
215
  data-hilum-mobile-sheet="true"
213
216
  className={cn(
@@ -19,7 +19,7 @@ const NativeSelect = React.forwardRef<
19
19
  "peer h-10 w-full appearance-none border border-border bg-background pl-3 pr-8",
20
20
  shape.input,
21
21
  "body text-foreground",
22
- "focus:border-border focus:outline-none focus:ring-2 focus:ring-ring/30",
22
+ "focus:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
23
23
  "disabled:cursor-not-allowed disabled:opacity-50",
24
24
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus:ring-destructive/20",
25
25
  className,
@@ -43,7 +43,7 @@ const NavigationMenuTrigger = React.forwardRef<
43
43
  "group inline-flex h-10 w-max items-center justify-center gap-1 rounded-md px-3 py-2",
44
44
  "body font-medium text-muted-foreground",
45
45
  "hover:bg-muted hover:text-foreground",
46
- "data-[active]:bg-muted data-[state=open]:bg-muted",
46
+ "data-[active]:bg-active data-[state=open]:bg-active",
47
47
  "outline-none transition-colors",
48
48
  className,
49
49
  )}
@@ -140,7 +140,7 @@ function navigationMenuTriggerStyle() {
140
140
  "group inline-flex h-10 w-max items-center justify-center gap-1 rounded-md px-3 py-2",
141
141
  "body font-medium text-muted-foreground",
142
142
  "hover:bg-muted hover:text-foreground",
143
- "data-[active]:bg-muted data-[state=open]:bg-muted",
143
+ "data-[active]:bg-active data-[state=open]:bg-active",
144
144
  "outline-none transition-colors",
145
145
  );
146
146
  }
@@ -95,7 +95,7 @@ function Notification({
95
95
  <button
96
96
  type="button"
97
97
  onClick={onClose}
98
- className="flex size-9 items-center justify-center rounded-md text-muted-foreground transition-[color,scale] hover:text-muted-foreground active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
98
+ className="flex size-9 items-center justify-center rounded-md text-muted-foreground transition-[color,scale] hover:text-muted-foreground active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
99
99
  >
100
100
  <span className="sr-only">Close</span>
101
101
  <X size={16} />
@@ -1,7 +1,8 @@
1
1
  import * as React from "react";
2
2
  import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
3
3
  import { cn } from "../lib/utils";
4
- import { buttonVariants } from "./button";
4
+ import { Button, buttonVariants } from "./button";
5
+ import { formatNumber, pluralize, useFormatter } from "../lib/format";
5
6
 
6
7
  const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
7
8
  <nav
@@ -27,7 +28,7 @@ PaginationItem.displayName = "PaginationItem";
27
28
 
28
29
  type PaginationLinkProps = React.ComponentProps<"a"> & { isActive?: boolean };
29
30
 
30
- const PaginationLink = ({ className, isActive, ...props }: PaginationLinkProps) => (
31
+ const PaginationLink = ({ className, isActive, children, ...props }: PaginationLinkProps) => (
31
32
  <a
32
33
  aria-current={isActive ? "page" : undefined}
33
34
  className={cn(
@@ -36,7 +37,9 @@ const PaginationLink = ({ className, isActive, ...props }: PaginationLinkProps)
36
37
  className,
37
38
  )}
38
39
  {...props}
39
- />
40
+ >
41
+ {children}
42
+ </a>
40
43
  );
41
44
  PaginationLink.displayName = "PaginationLink";
42
45
 
@@ -78,7 +81,97 @@ const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span"
78
81
  );
79
82
  PaginationEllipsis.displayName = "PaginationEllipsis";
80
83
 
84
+ /* ─────────────────────── PaginationBar ─────────────────────── */
85
+
86
+ interface PaginationBarProps {
87
+ /** 1-based current page. */
88
+ page: number;
89
+ pageSize: number;
90
+ /** Total item count. Omit for cursor pagination (then pass `hasNextPage`). */
91
+ total?: number;
92
+ /** Cursor pagination: whether a next page exists when `total` is unknown. */
93
+ hasNextPage?: boolean;
94
+ onPageChange: (page: number) => void;
95
+ /** Noun for the summary — "Showing 1–20 of 124 orders". Default: "result". */
96
+ itemLabel?: string;
97
+ /** Plural noun when it isn't `${itemLabel}s`. */
98
+ itemLabelPlural?: string;
99
+ previousLabel?: string;
100
+ nextLabel?: string;
101
+ className?: string;
102
+ }
103
+
104
+ /**
105
+ * Compact list/table footer: "Showing 21–40 of 124 orders" plus Previous /
106
+ * Next. Replaces hand-built Prev/Next rows; use Pagination* for numbered pages.
107
+ */
108
+ function PaginationBar({
109
+ page,
110
+ pageSize,
111
+ total,
112
+ hasNextPage,
113
+ onPageChange,
114
+ itemLabel = "result",
115
+ itemLabelPlural,
116
+ previousLabel = "Previous",
117
+ nextLabel = "Next",
118
+ className,
119
+ }: PaginationBarProps) {
120
+ const fmt = useFormatter();
121
+ const locale = fmt.locale ? { locale: fmt.locale } : {};
122
+ const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
123
+ const end = total !== undefined ? Math.min(page * pageSize, total) : page * pageSize;
124
+ const canNext = total !== undefined ? page * pageSize < total : Boolean(hasNextPage);
125
+ const noun = pluralize(total ?? end, itemLabel, {
126
+ ...locale,
127
+ hideCount: true,
128
+ ...(itemLabelPlural ? { plural: itemLabelPlural } : {}),
129
+ });
130
+ const summary =
131
+ total !== undefined
132
+ ? `Showing ${formatNumber(start, locale)}–${formatNumber(end, locale)} of ${formatNumber(total, locale)} ${noun}`
133
+ : `Showing ${formatNumber(start, locale)}–${formatNumber(end, locale)} ${noun}`;
134
+
135
+ return (
136
+ <nav
137
+ aria-label="Pagination"
138
+ data-slot="pagination-bar"
139
+ className={cn("flex min-w-0 items-center justify-between gap-3", className)}
140
+ >
141
+ <p className="caption min-w-0 truncate tabular-nums text-muted-foreground" aria-live="polite">
142
+ {summary}
143
+ </p>
144
+ <div className="flex shrink-0 items-center gap-2">
145
+ <Button
146
+ type="button"
147
+ variant="outline"
148
+ size="sm"
149
+ onClick={() => onPageChange(page - 1)}
150
+ disabled={page <= 1}
151
+ aria-label="Go to previous page"
152
+ >
153
+ <ChevronLeft size={14} aria-hidden="true" />
154
+ <span className="max-sm:sr-only">{previousLabel}</span>
155
+ </Button>
156
+ <Button
157
+ type="button"
158
+ variant="outline"
159
+ size="sm"
160
+ onClick={() => onPageChange(page + 1)}
161
+ disabled={!canNext}
162
+ aria-label="Go to next page"
163
+ >
164
+ <span className="max-sm:sr-only">{nextLabel}</span>
165
+ <ChevronRight size={14} aria-hidden="true" />
166
+ </Button>
167
+ </div>
168
+ </nav>
169
+ );
170
+ }
171
+ PaginationBar.displayName = "PaginationBar";
172
+
81
173
  export {
174
+ PaginationBar,
82
175
  Pagination,
83
176
  PaginationContent,
84
177
  PaginationItem,
@@ -87,3 +180,4 @@ export {
87
180
  PaginationNext,
88
181
  PaginationEllipsis,
89
182
  };
183
+ export type { PaginationBarProps };
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
  import { Popover } from "radix-ui";
5
5
  import { cn } from "../lib/utils";
6
+ import { useDensityAttributes } from "../lib/density-context";
6
7
  import { motionClasses } from "../lib/interaction";
7
8
  import {
8
9
  mobilePopperSheetMotionClassName,
@@ -24,6 +25,7 @@ const PopoverContent = React.forwardRef<
24
25
  <Popover.Portal>
25
26
  <Popover.Content
26
27
  ref={ref}
28
+ {...useDensityAttributes()}
27
29
  data-hilum-mobile-sheet="true"
28
30
  align={align}
29
31
  sideOffset={sideOffset}
@@ -38,10 +38,13 @@ function PropertyRow({
38
38
  return (
39
39
  <div
40
40
  className={cn(
41
- "flex w-full min-w-0 py-1",
41
+ "flex w-full min-w-0 py-1 compact:py-0.5",
42
42
  inline
43
- ? cn("gap-3", labelAlign === "center" ? "items-center" : "items-start")
44
- : "flex-col items-stretch gap-1.5",
43
+ ? cn(
44
+ "gap-3 compact:min-h-7 compact:gap-2",
45
+ labelAlign === "center" ? "items-center" : "items-start",
46
+ )
47
+ : "flex-col items-stretch gap-1.5 compact:gap-1",
45
48
  className,
46
49
  )}
47
50
  {...rest}
@@ -49,7 +52,7 @@ function PropertyRow({
49
52
  {label !== undefined && (
50
53
  <div
51
54
  className={cn(
52
- "caption select-none text-muted-foreground",
55
+ "caption select-none text-muted-foreground compact:text-[11px] compact:leading-4",
53
56
  inline ? "shrink-0" : "w-full",
54
57
  )}
55
58
  style={
@@ -44,7 +44,7 @@ function RadioCards({ options, value, onValueChange, columns = 3, className }: R
44
44
  className={cn(
45
45
  "relative flex cursor-pointer flex-col gap-1 rounded-xl border p-4 text-left transition-[background-color,border-color,box-shadow,opacity,scale]",
46
46
  "active:scale-[0.96]",
47
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30",
47
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
48
48
  "disabled:cursor-not-allowed disabled:opacity-50",
49
49
  isSelected
50
50
  ? "border-brand-primary bg-card shadow-natural"
@@ -90,6 +90,7 @@ const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
90
90
  };
91
91
 
92
92
  const content = (
93
+ // eslint-disable-next-line jsx-a11y/interactive-supports-focus -- roving tabindex per WAI-ARIA APG: the radios are focusable, the radiogroup container is not
93
94
  <div
94
95
  ref={(node) => {
95
96
  (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
@@ -194,7 +195,7 @@ const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
194
195
  <AnimatePresence>
195
196
  {focusRect && (
196
197
  <motion.div
197
- className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[#6B97FF]`}
198
+ className={`absolute ${shape.focusRing} pointer-events-none z-20 border-2 border-ring`}
198
199
  initial={false}
199
200
  animate={{
200
201
  left: focusRect.left - 2,
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Star } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ interface RatingProps {
8
+ /** Current rating; fractional values render partial stars (read-only). */
9
+ value: number;
10
+ /** Number of stars. Default: 5. */
11
+ max?: number;
12
+ /** When set, renders an interactive radio group (whole stars). */
13
+ onValueChange?: (value: number) => void;
14
+ size?: "sm" | "md" | "lg";
15
+ /** Show "4.5" after the stars. */
16
+ showValue?: boolean;
17
+ /** Optional count after the value, e.g. "(128)". */
18
+ count?: number;
19
+ /** Accessible name for the interactive variant. Default: "Rating". */
20
+ label?: string;
21
+ disabled?: boolean;
22
+ className?: string;
23
+ }
24
+
25
+ const SIZE: Record<NonNullable<RatingProps["size"]>, number> = { sm: 12, md: 16, lg: 20 };
26
+
27
+ /**
28
+ * Star rating. Read-only by default (reviews, product cards); pass
29
+ * `onValueChange` for an accessible radio-group input. Colours are tokens
30
+ * (`foreground` fill on a `border` track) so it works in every theme.
31
+ */
32
+ function Rating({
33
+ value,
34
+ max = 5,
35
+ onValueChange,
36
+ size = "md",
37
+ showValue = false,
38
+ count,
39
+ label = "Rating",
40
+ disabled = false,
41
+ className,
42
+ }: RatingProps) {
43
+ const px = SIZE[size];
44
+ const clamped = Math.max(0, Math.min(max, value));
45
+ const [hover, setHover] = React.useState<number | null>(null);
46
+ const name = React.useId();
47
+
48
+ const summary = (showValue || count !== undefined) && (
49
+ <span className="caption tabular-nums text-muted-foreground">
50
+ {showValue && clamped.toFixed(1)}
51
+ {count !== undefined && ` (${new Intl.NumberFormat().format(count)})`}
52
+ </span>
53
+ );
54
+
55
+ if (!onValueChange) {
56
+ return (
57
+ <span
58
+ role="img"
59
+ aria-label={`${clamped.toFixed(1)} out of ${max} stars`}
60
+ className={cn("inline-flex items-center gap-1.5", className)}
61
+ data-slot="rating"
62
+ >
63
+ <span className="inline-flex items-center gap-0.5" aria-hidden="true">
64
+ {Array.from({ length: max }, (_, i) => {
65
+ const fill = Math.max(0, Math.min(1, clamped - i));
66
+ return (
67
+ <span key={i} className="relative inline-flex" style={{ width: px, height: px }}>
68
+ <Star size={px} className="fill-current text-border" strokeWidth={0} />
69
+ {fill > 0 && (
70
+ <span
71
+ className="absolute inset-y-0 left-0 overflow-hidden"
72
+ style={{ width: `${fill * 100}%` }}
73
+ >
74
+ <Star size={px} className="fill-current text-foreground" strokeWidth={0} />
75
+ </span>
76
+ )}
77
+ </span>
78
+ );
79
+ })}
80
+ </span>
81
+ {summary}
82
+ </span>
83
+ );
84
+ }
85
+
86
+ const shown = hover ?? Math.round(clamped);
87
+ return (
88
+ <span className={cn("inline-flex items-center gap-1.5", className)} data-slot="rating">
89
+ {/* Hover preview only; keyboard users move through the native radios. */}
90
+ <span onMouseLeave={() => setHover(null)} className="inline-flex">
91
+ <span role="radiogroup" aria-label={label} className="inline-flex items-center gap-0.5">
92
+ {Array.from({ length: max }, (_, i) => {
93
+ const starValue = i + 1;
94
+ const checked = Math.round(clamped) === starValue;
95
+ return (
96
+ <label
97
+ key={starValue}
98
+ className={cn(
99
+ "inline-flex cursor-pointer rounded-sm has-focus-visible:ring-2 has-focus-visible:ring-ring",
100
+ disabled && "cursor-not-allowed opacity-50",
101
+ )}
102
+ onMouseEnter={() => !disabled && setHover(starValue)}
103
+ >
104
+ <input
105
+ type="radio"
106
+ className="sr-only"
107
+ name={name}
108
+ value={starValue}
109
+ checked={checked}
110
+ disabled={disabled}
111
+ aria-label={`${starValue} star${starValue === 1 ? "" : "s"}`}
112
+ onChange={() => onValueChange(starValue)}
113
+ />
114
+ <Star
115
+ size={px}
116
+ strokeWidth={0}
117
+ aria-hidden="true"
118
+ className={cn(
119
+ "fill-current transition-colors",
120
+ starValue <= shown ? "text-foreground" : "text-border",
121
+ )}
122
+ />
123
+ </label>
124
+ );
125
+ })}
126
+ </span>
127
+ </span>
128
+ {summary}
129
+ </span>
130
+ );
131
+ }
132
+
133
+ Rating.displayName = "Rating";
134
+
135
+ export { Rating };
136
+ export type { RatingProps };
@@ -47,7 +47,7 @@ function ResizableHandle({ className, withHandle = true, ...props }: ResizableHa
47
47
  <Separator
48
48
  className={cn(
49
49
  "relative flex shrink-0 items-center justify-center bg-muted",
50
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand-primary/50",
50
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
51
51
  // Horizontal (default): vertical bar
52
52
  "[&:not([aria-orientation=vertical])]:h-full [&:not([aria-orientation=vertical])]:w-px",
53
53
  // Vertical: horizontal bar
@@ -0,0 +1,133 @@
1
+ import * as React from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /* ─────────────────────── ResourceCell ─────────────────────── */
5
+
6
+ interface ResourceCellProps {
7
+ /** Primary line — the resource name / order number. Rendered strongest. */
8
+ title: React.ReactNode;
9
+ /** Secondary line — email, SKU, "3 items", date. Rendered muted and smaller. */
10
+ subtitle?: React.ReactNode;
11
+ /** Leading thumbnail / avatar (32px works best). */
12
+ media?: React.ReactNode;
13
+ /** Inline trailing content on the title line (e.g. a StatusBadge). */
14
+ badge?: React.ReactNode;
15
+ /** Link the title (e.g. to the detail page). Use a router link via `title` for SPA navigation. */
16
+ href?: string;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * Two-line table cell with the canonical hierarchy: a strong primary line and
22
+ * a muted secondary line — never the other way round. Use it for the first
23
+ * column of resource tables (products, orders, customers, merchants).
24
+ */
25
+ function ResourceCell({ title, subtitle, media, badge, href, className }: ResourceCellProps) {
26
+ const titleNode = href ? (
27
+ <a
28
+ href={href}
29
+ className="rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
30
+ >
31
+ {title}
32
+ </a>
33
+ ) : (
34
+ title
35
+ );
36
+ return (
37
+ <div className={cn("flex min-w-0 items-center gap-3", className)} data-slot="resource-cell">
38
+ {media && <div className="shrink-0">{media}</div>}
39
+ <div className="min-w-0">
40
+ <div className="flex min-w-0 items-center gap-2">
41
+ <span className="body min-w-0 truncate font-medium text-foreground">{titleNode}</span>
42
+ {badge && <span className="shrink-0">{badge}</span>}
43
+ </div>
44
+ {subtitle && <div className="caption truncate text-muted-foreground">{subtitle}</div>}
45
+ </div>
46
+ </div>
47
+ );
48
+ }
49
+ ResourceCell.displayName = "ResourceCell";
50
+
51
+ /* ─────────────────────── ResourceItem ─────────────────────── */
52
+
53
+ interface ResourceItemProps extends Omit<ResourceCellProps, "href" | "className"> {
54
+ /** Row-level metadata under the subtitle (e.g. "Sep 26, 2026 · 3 items"). */
55
+ meta?: React.ReactNode;
56
+ /** Right-aligned primary value (amount, count). */
57
+ trailing?: React.ReactNode;
58
+ /** Right-aligned secondary value under `trailing` (date, status). */
59
+ trailingSecondary?: React.ReactNode;
60
+ /** Make the whole row a link. */
61
+ href?: string;
62
+ /** Make the whole row a button. Ignored when `href` is set. */
63
+ onClick?: () => void;
64
+ /** Accessible name when the row is interactive and `title` isn't plain text. */
65
+ "aria-label"?: string;
66
+ className?: string;
67
+ }
68
+
69
+ /**
70
+ * List row for `<StackedList>` — the mobile counterpart of a resource table
71
+ * row. Same primary/secondary hierarchy as ResourceCell, with a trailing
72
+ * value column. The entire row is one link/button target.
73
+ */
74
+ function ResourceItem({
75
+ title,
76
+ subtitle,
77
+ media,
78
+ badge,
79
+ meta,
80
+ trailing,
81
+ trailingSecondary,
82
+ href,
83
+ onClick,
84
+ "aria-label": ariaLabel,
85
+ className,
86
+ }: ResourceItemProps) {
87
+ const inner = (
88
+ <span className={cn("flex min-w-0 items-center gap-3 px-4 py-3.5", className)}>
89
+ {media && <span className="block shrink-0">{media}</span>}
90
+ <span className="block min-w-0 flex-1">
91
+ <span className="flex min-w-0 items-center gap-2">
92
+ <span className="body min-w-0 truncate font-medium text-foreground">{title}</span>
93
+ {badge && <span className="shrink-0">{badge}</span>}
94
+ </span>
95
+ {subtitle && <span className="caption block truncate text-muted-foreground">{subtitle}</span>}
96
+ {meta && <span className="caption mt-0.5 block truncate text-muted-foreground">{meta}</span>}
97
+ </span>
98
+ {(trailing || trailingSecondary) && (
99
+ <span className="flex shrink-0 flex-col items-end gap-0.5 text-right">
100
+ {trailing && (
101
+ <span className="body font-medium tabular-nums text-foreground">{trailing}</span>
102
+ )}
103
+ {trailingSecondary && (
104
+ <span className="caption text-muted-foreground">{trailingSecondary}</span>
105
+ )}
106
+ </span>
107
+ )}
108
+ </span>
109
+ );
110
+
111
+ const interactive =
112
+ "block w-full text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset";
113
+
114
+ return (
115
+ <li data-slot="resource-item">
116
+ {href ? (
117
+ <a href={href} className={interactive} aria-label={ariaLabel}>
118
+ {inner}
119
+ </a>
120
+ ) : onClick ? (
121
+ <button type="button" onClick={onClick} className={interactive} aria-label={ariaLabel}>
122
+ {inner}
123
+ </button>
124
+ ) : (
125
+ inner
126
+ )}
127
+ </li>
128
+ );
129
+ }
130
+ ResourceItem.displayName = "ResourceItem";
131
+
132
+ export { ResourceCell, ResourceItem };
133
+ export type { ResourceCellProps, ResourceItemProps };
@@ -385,6 +385,7 @@ function RichTextEditor({
385
385
  insertLink();
386
386
  }
387
387
  }}
388
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- focuses the URL field when the link popover opens (dialog focus management)
388
389
  autoFocus
389
390
  aria-label="Link URL"
390
391
  />