@hilum/ui 3.8.5 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
- package/dist/chunk-R576DV2A.js.map +1 -0
- package/dist/create-theme.js +2 -1
- package/dist/create-theme.js.map +1 -1
- package/dist/index.d.ts +733 -68
- package/dist/index.js +3097 -1619
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +163 -19
- package/dist/tokens.d.ts +69 -5
- package/dist/tokens.js +1 -1
- package/llms.txt +11 -4
- package/package.json +11 -10
- package/src/components/accordion.tsx +2 -2
- package/src/components/account-menu.tsx +1 -1
- package/src/components/alert-dialog.tsx +107 -0
- package/src/components/ask-user-questions.tsx +24 -18
- package/src/components/badge.tsx +36 -3
- package/src/components/breadcrumb.tsx +4 -2
- package/src/components/button-group.tsx +1 -1
- package/src/components/button.tsx +31 -23
- package/src/components/calendar.tsx +1 -1
- package/src/components/card.tsx +4 -2
- package/src/components/checkbox-card.tsx +1 -1
- package/src/components/checkbox-group.tsx +2 -1
- package/src/components/code-block.tsx +99 -0
- package/src/components/color-input.tsx +9 -6
- package/src/components/color-picker.tsx +38 -26
- package/src/components/combobox.tsx +59 -12
- package/src/components/command-palette.tsx +150 -42
- package/src/components/command.tsx +5 -3
- package/src/components/context-menu.tsx +18 -15
- package/src/components/contextual-save-bar.tsx +156 -0
- package/src/components/data-table.tsx +34 -5
- package/src/components/date-picker.tsx +275 -28
- package/src/components/date-text.tsx +90 -0
- package/src/components/dropdown-menu.tsx +11 -8
- package/src/components/dropdown.tsx +6 -3
- package/src/components/empty-state.tsx +85 -20
- package/src/components/file-dropzone.tsx +2 -1
- package/src/components/file-thumbnail.tsx +84 -20
- package/src/components/filter-bar.tsx +91 -0
- package/src/components/grid-list.tsx +2 -2
- package/src/components/help-tooltip.tsx +1 -1
- package/src/components/input-copy.tsx +1 -1
- package/src/components/input-group.tsx +1 -1
- package/src/components/input-message.tsx +5 -4
- package/src/components/input-number.tsx +103 -11
- package/src/components/input.tsx +5 -3
- package/src/components/label.tsx +1 -0
- package/src/components/media-asset-card.tsx +1 -1
- package/src/components/menu-item.tsx +37 -3
- package/src/components/menubar.tsx +21 -18
- package/src/components/native-select.tsx +1 -1
- package/src/components/navigation-menu.tsx +2 -2
- package/src/components/notification.tsx +1 -1
- package/src/components/pagination.tsx +97 -3
- package/src/components/popover.tsx +2 -0
- package/src/components/property-row.tsx +7 -4
- package/src/components/radio-card.tsx +1 -1
- package/src/components/radio-group.tsx +2 -1
- package/src/components/rating.tsx +136 -0
- package/src/components/resizable.tsx +1 -1
- package/src/components/resource-item.tsx +133 -0
- package/src/components/rich-text-editor.tsx +1 -0
- package/src/components/search-input.tsx +98 -0
- package/src/components/searchable-table.tsx +10 -11
- package/src/components/select.tsx +28 -9
- package/src/components/sidebar.tsx +4 -4
- package/src/components/slider.tsx +78 -36
- package/src/components/sonner.tsx +18 -4
- package/src/components/stat-card.tsx +121 -37
- package/src/components/status-badge.tsx +142 -47
- package/src/components/status-tile.tsx +9 -0
- package/src/components/switch.tsx +6 -5
- package/src/components/tabs-subtle.tsx +10 -2
- package/src/components/tabs.tsx +23 -3
- package/src/components/textarea.tsx +2 -3
- package/src/components/thinking-steps.tsx +3 -3
- package/src/components/toggle.tsx +14 -13
- package/src/components/tooltip.tsx +11 -8
- package/src/components/url-redirect-prompt.tsx +0 -1
- package/src/index.ts +15 -0
- package/src/lib/density-context.tsx +65 -0
- package/src/lib/format.tsx +301 -0
- package/src/lib/interaction.ts +12 -4
- package/src/lib/scroll-fade.tsx +39 -0
- package/src/tokens/tokens.test.ts +77 -0
- package/src/tokens/tokens.ts +114 -5
- package/dist/chunk-RI6QJR2L.js.map +0 -1
|
@@ -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-
|
|
47
|
-
"focus:bg-
|
|
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-
|
|
99
|
-
"focus:bg-
|
|
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-
|
|
118
|
-
"focus:bg-
|
|
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-
|
|
143
|
-
"focus:bg-
|
|
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-
|
|
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-
|
|
192
|
-
"focus:bg-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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(
|
|
44
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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 };
|