@hilum/ui 3.8.6 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
- package/dist/chunk-R576DV2A.js.map +1 -0
- package/dist/create-theme.js +2 -1
- package/dist/create-theme.js.map +1 -1
- package/dist/index.d.ts +733 -68
- package/dist/index.js +3097 -1619
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +163 -19
- package/dist/tokens.d.ts +69 -5
- package/dist/tokens.js +1 -1
- package/llms.txt +11 -4
- package/package.json +11 -10
- package/src/components/accordion.tsx +2 -2
- package/src/components/account-menu.tsx +1 -1
- package/src/components/alert-dialog.tsx +107 -0
- package/src/components/ask-user-questions.tsx +24 -18
- package/src/components/badge.tsx +36 -3
- package/src/components/breadcrumb.tsx +4 -2
- package/src/components/button-group.tsx +1 -1
- package/src/components/button.tsx +31 -23
- package/src/components/calendar.tsx +1 -1
- package/src/components/card.tsx +4 -2
- package/src/components/checkbox-card.tsx +1 -1
- package/src/components/checkbox-group.tsx +2 -1
- package/src/components/code-block.tsx +99 -0
- package/src/components/color-input.tsx +9 -6
- package/src/components/color-picker.tsx +38 -26
- package/src/components/combobox.tsx +59 -12
- package/src/components/command-palette.tsx +150 -42
- package/src/components/command.tsx +5 -3
- package/src/components/context-menu.tsx +18 -15
- package/src/components/contextual-save-bar.tsx +156 -0
- package/src/components/data-table.tsx +34 -5
- package/src/components/date-picker.tsx +275 -28
- package/src/components/date-text.tsx +90 -0
- package/src/components/dropdown-menu.tsx +11 -8
- package/src/components/dropdown.tsx +6 -3
- package/src/components/empty-state.tsx +85 -20
- package/src/components/file-dropzone.tsx +2 -1
- package/src/components/file-thumbnail.tsx +84 -20
- package/src/components/filter-bar.tsx +91 -0
- package/src/components/grid-list.tsx +2 -2
- package/src/components/help-tooltip.tsx +1 -1
- package/src/components/input-copy.tsx +1 -1
- package/src/components/input-group.tsx +1 -1
- package/src/components/input-message.tsx +5 -4
- package/src/components/input-number.tsx +103 -11
- package/src/components/input.tsx +5 -3
- package/src/components/label.tsx +1 -0
- package/src/components/media-asset-card.tsx +1 -1
- package/src/components/menu-item.tsx +37 -3
- package/src/components/menubar.tsx +21 -18
- package/src/components/native-select.tsx +1 -1
- package/src/components/navigation-menu.tsx +2 -2
- package/src/components/notification.tsx +1 -1
- package/src/components/pagination.tsx +97 -3
- package/src/components/popover.tsx +2 -0
- package/src/components/property-row.tsx +7 -4
- package/src/components/radio-card.tsx +1 -1
- package/src/components/radio-group.tsx +2 -1
- package/src/components/rating.tsx +136 -0
- package/src/components/resizable.tsx +1 -1
- package/src/components/resource-item.tsx +133 -0
- package/src/components/rich-text-editor.tsx +1 -0
- package/src/components/search-input.tsx +98 -0
- package/src/components/searchable-table.tsx +10 -11
- package/src/components/select.tsx +28 -9
- package/src/components/sidebar.tsx +4 -4
- package/src/components/slider.tsx +78 -36
- package/src/components/sonner.tsx +18 -4
- package/src/components/stat-card.tsx +121 -37
- package/src/components/status-badge.tsx +142 -47
- package/src/components/status-tile.tsx +9 -0
- package/src/components/switch.tsx +6 -5
- package/src/components/tabs-subtle.tsx +10 -2
- package/src/components/tabs.tsx +23 -3
- package/src/components/textarea.tsx +2 -3
- package/src/components/thinking-steps.tsx +3 -3
- package/src/components/toggle.tsx +14 -13
- package/src/components/tooltip.tsx +11 -8
- package/src/components/url-redirect-prompt.tsx +0 -1
- package/src/index.ts +15 -0
- package/src/lib/density-context.tsx +65 -0
- package/src/lib/format.tsx +301 -0
- package/src/lib/interaction.ts +12 -4
- package/src/lib/scroll-fade.tsx +39 -0
- package/src/tokens/tokens.test.ts +77 -0
- package/src/tokens/tokens.ts +114 -5
- package/dist/chunk-RI6QJR2L.js.map +0 -1
|
@@ -1,16 +1,39 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { TrendingUp, TrendingDown, Minus, Plus } from "lucide-react";
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
|
+
import { Skeleton } from "./skeleton";
|
|
5
|
+
|
|
6
|
+
type StatTrendTone = "positive" | "negative" | "neutral";
|
|
7
|
+
|
|
8
|
+
interface StatCardTrend {
|
|
9
|
+
value: string;
|
|
10
|
+
direction: "up" | "down" | "neutral";
|
|
11
|
+
/**
|
|
12
|
+
* Whether the change is good or bad. Defaults to up → positive,
|
|
13
|
+
* down → negative. Set explicitly for metrics where "up" is bad
|
|
14
|
+
* (refunds, churn, response time).
|
|
15
|
+
*/
|
|
16
|
+
tone?: StatTrendTone;
|
|
17
|
+
/** Screen-reader / caption context, e.g. "vs last 30 days". */
|
|
18
|
+
label?: string;
|
|
19
|
+
}
|
|
4
20
|
|
|
5
21
|
interface StatCardProps {
|
|
6
22
|
label?: string;
|
|
7
23
|
title?: string;
|
|
8
|
-
value?:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
value?: React.ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* `default` — bordered card. `responsive` — flat on mobile, card from `sm`.
|
|
27
|
+
* `plain` — no surface; use inside an existing Card or a divided StatGrid.
|
|
28
|
+
*/
|
|
29
|
+
variant?: "default" | "responsive" | "plain";
|
|
30
|
+
trend?: StatCardTrend;
|
|
31
|
+
/** Secondary line under the value (e.g. "32 orders", "Last 30 days"). */
|
|
32
|
+
description?: React.ReactNode;
|
|
33
|
+
/** Render skeletons for the value and trend. */
|
|
34
|
+
loading?: boolean;
|
|
35
|
+
/** Make the whole card a link (e.g. drill into the orders list). */
|
|
36
|
+
href?: string;
|
|
14
37
|
icon?: React.ReactNode;
|
|
15
38
|
children?: React.ReactNode;
|
|
16
39
|
actionButtons?: React.ReactNode;
|
|
@@ -22,6 +45,11 @@ interface StatCardProps {
|
|
|
22
45
|
|
|
23
46
|
interface StatCardGridProps {
|
|
24
47
|
children: React.ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* Column count at the widest breakpoint. Always 2 columns on mobile (1 for
|
|
50
|
+
* `columns={1}`), stepping up through `sm`/`lg`. Default: 6-slot dashboard grid.
|
|
51
|
+
*/
|
|
52
|
+
columns?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
25
53
|
maxSlots?: number;
|
|
26
54
|
onAddCard?: () => void;
|
|
27
55
|
className?: string;
|
|
@@ -40,6 +68,9 @@ function StatCard({
|
|
|
40
68
|
value,
|
|
41
69
|
variant = "default",
|
|
42
70
|
trend,
|
|
71
|
+
description,
|
|
72
|
+
loading = false,
|
|
73
|
+
href,
|
|
43
74
|
icon,
|
|
44
75
|
children,
|
|
45
76
|
actionButtons,
|
|
@@ -49,11 +80,10 @@ function StatCard({
|
|
|
49
80
|
valueClassName,
|
|
50
81
|
}: StatCardProps) {
|
|
51
82
|
const displayLabel = title ?? label;
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
};
|
|
83
|
+
const trendTone: StatTrendTone | undefined = trend
|
|
84
|
+
? (trend.tone ??
|
|
85
|
+
(trend.direction === "up" ? "positive" : trend.direction === "down" ? "negative" : "neutral"))
|
|
86
|
+
: undefined;
|
|
57
87
|
|
|
58
88
|
const TrendIcon = {
|
|
59
89
|
up: TrendingUp,
|
|
@@ -61,19 +91,14 @@ function StatCard({
|
|
|
61
91
|
neutral: Minus,
|
|
62
92
|
};
|
|
63
93
|
const surfaceClass =
|
|
64
|
-
variant === "
|
|
65
|
-
? "min-w-0
|
|
66
|
-
:
|
|
94
|
+
variant === "plain"
|
|
95
|
+
? "min-w-0 p-4 sm:p-5"
|
|
96
|
+
: variant === "responsive"
|
|
97
|
+
? "min-w-0 border-border bg-transparent p-4 sm:rounded-xl sm:border sm:bg-card sm:p-5 sm:shadow-natural"
|
|
98
|
+
: "min-w-0 rounded-xl border border-border bg-card p-4 shadow-natural sm:p-5";
|
|
67
99
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
className={cn(
|
|
71
|
-
surfaceClass,
|
|
72
|
-
containerClassName,
|
|
73
|
-
className,
|
|
74
|
-
)}
|
|
75
|
-
data-slot="stat-card"
|
|
76
|
-
>
|
|
100
|
+
const body = (
|
|
101
|
+
<>
|
|
77
102
|
<div className="flex items-start justify-between gap-3">
|
|
78
103
|
{displayLabel && (
|
|
79
104
|
<p className={cn("label min-w-0 text-muted-foreground", titleClassName)}>
|
|
@@ -87,23 +112,65 @@ function StatCard({
|
|
|
87
112
|
)}
|
|
88
113
|
{actionButtons && <div className="flex shrink-0 items-center gap-2">{actionButtons}</div>}
|
|
89
114
|
</div>
|
|
90
|
-
{
|
|
91
|
-
<
|
|
115
|
+
{loading ? (
|
|
116
|
+
<Skeleton className="mt-3 h-7 w-24" />
|
|
117
|
+
) : value !== undefined ? (
|
|
118
|
+
<p className={cn("heading-xl mt-2 truncate tabular-nums text-foreground", valueClassName)}>
|
|
92
119
|
{value}
|
|
93
120
|
</p>
|
|
94
121
|
) : children ? (
|
|
95
122
|
<div className="mt-2">{children}</div>
|
|
96
123
|
) : null}
|
|
97
|
-
{trend &&
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
{(description || (trend && !loading)) && (
|
|
125
|
+
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
126
|
+
{trend &&
|
|
127
|
+
!loading &&
|
|
128
|
+
(() => {
|
|
129
|
+
const Icon = TrendIcon[trend.direction];
|
|
130
|
+
return (
|
|
131
|
+
<span
|
|
132
|
+
data-tone={trendTone}
|
|
133
|
+
className={cn(
|
|
134
|
+
"inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5",
|
|
135
|
+
trendTone === "positive" && "bg-success/40 text-foreground",
|
|
136
|
+
trendTone === "negative" && "bg-destructive/10 text-destructive",
|
|
137
|
+
trendTone === "neutral" && "bg-muted text-muted-foreground",
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
<Icon size={12} aria-hidden="true" />
|
|
141
|
+
<span className="caption tabular-nums font-medium">{trend.value}</span>
|
|
142
|
+
{trend.label && <span className="sr-only">{trend.label}</span>}
|
|
143
|
+
</span>
|
|
144
|
+
);
|
|
145
|
+
})()}
|
|
146
|
+
{description && (
|
|
147
|
+
<span className="caption min-w-0 truncate text-muted-foreground">{description}</span>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
</>
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const classes = cn(surfaceClass, containerClassName, className);
|
|
155
|
+
|
|
156
|
+
if (href) {
|
|
157
|
+
return (
|
|
158
|
+
<a
|
|
159
|
+
href={href}
|
|
160
|
+
className={cn(
|
|
161
|
+
classes,
|
|
162
|
+
"block transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
163
|
+
)}
|
|
164
|
+
data-slot="stat-card"
|
|
165
|
+
>
|
|
166
|
+
{body}
|
|
167
|
+
</a>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className={classes} data-slot="stat-card" aria-busy={loading || undefined}>
|
|
173
|
+
{body}
|
|
107
174
|
</div>
|
|
108
175
|
);
|
|
109
176
|
}
|
|
@@ -117,7 +184,7 @@ function StatCardSlot({ onAddCard, className, label = "Add card" }: StatCardSlot
|
|
|
117
184
|
className={cn(
|
|
118
185
|
"flex min-h-28 w-full min-w-0 items-center justify-center rounded-xl border border-dashed border-border bg-card p-4 text-muted-foreground shadow-natural",
|
|
119
186
|
"transition-[background-color,border-color,color,scale] duration-150 hover:border-ground-300 hover:bg-muted hover:text-foreground active:scale-[0.96]",
|
|
120
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-
|
|
187
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
121
188
|
className,
|
|
122
189
|
)}
|
|
123
190
|
onClick={onAddCard}
|
|
@@ -132,8 +199,18 @@ function StatCardSlot({ onAddCard, className, label = "Add card" }: StatCardSlot
|
|
|
132
199
|
|
|
133
200
|
StatCardSlot.displayName = "StatCardSlot";
|
|
134
201
|
|
|
202
|
+
const GRID_COLUMNS: Record<NonNullable<StatCardGridProps["columns"]>, string> = {
|
|
203
|
+
1: "grid-cols-1",
|
|
204
|
+
2: "grid-cols-2",
|
|
205
|
+
3: "grid-cols-2 lg:grid-cols-3",
|
|
206
|
+
4: "grid-cols-2 lg:grid-cols-4",
|
|
207
|
+
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5",
|
|
208
|
+
6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6",
|
|
209
|
+
};
|
|
210
|
+
|
|
135
211
|
function StatCardGrid({
|
|
136
212
|
children,
|
|
213
|
+
columns = 6,
|
|
137
214
|
maxSlots = 6,
|
|
138
215
|
onAddCard,
|
|
139
216
|
className,
|
|
@@ -145,7 +222,8 @@ function StatCardGrid({
|
|
|
145
222
|
return (
|
|
146
223
|
<div
|
|
147
224
|
className={cn(
|
|
148
|
-
"grid min-w-0
|
|
225
|
+
"grid min-w-0 items-stretch gap-2 sm:gap-3 [&>*>[data-slot=stat-card]]:h-full",
|
|
226
|
+
GRID_COLUMNS[columns],
|
|
149
227
|
className,
|
|
150
228
|
)}
|
|
151
229
|
data-slot="stat-card-grid"
|
|
@@ -166,11 +244,17 @@ function StatCardGrid({
|
|
|
166
244
|
|
|
167
245
|
StatCardGrid.displayName = "StatCardGrid";
|
|
168
246
|
|
|
247
|
+
/** Alias — `StatGrid` reads better next to `StatCard` in new code. */
|
|
248
|
+
const StatGrid = StatCardGrid;
|
|
249
|
+
|
|
169
250
|
export {
|
|
170
251
|
StatCard,
|
|
171
252
|
StatCardGrid,
|
|
172
253
|
StatCardSlot,
|
|
254
|
+
StatGrid,
|
|
173
255
|
type StatCardProps,
|
|
256
|
+
type StatCardTrend,
|
|
257
|
+
type StatTrendTone,
|
|
174
258
|
type StatCardGridProps,
|
|
175
259
|
type StatCardSlotProps,
|
|
176
260
|
};
|
|
@@ -1,75 +1,131 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { Badge } from "./badge";
|
|
4
|
+
import { Badge, STATUS_TONE_BADGE, type StatusTone } from "./badge";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
6
|
|
|
7
7
|
type StatusBadgeVariant = NonNullable<React.ComponentProps<typeof Badge>["variant"]>;
|
|
8
8
|
type StatusBadgeIcon = React.ComponentType<{ className?: string }>;
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"children" | "variant"
|
|
13
|
-
> {
|
|
14
|
-
status?: string | null;
|
|
15
|
-
label?: React.ReactNode;
|
|
16
|
-
variant?: StatusBadgeVariant;
|
|
17
|
-
variantMap?: Record<string, StatusBadgeVariant>;
|
|
18
|
-
labelMap?: Record<string, React.ReactNode>;
|
|
19
|
-
icon?: StatusBadgeIcon;
|
|
20
|
-
iconMap?: Record<string, StatusBadgeIcon>;
|
|
21
|
-
iconClassName?: string;
|
|
22
|
-
showDot?: boolean;
|
|
23
|
-
dotClassName?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const DEFAULT_STATUS_VARIANT: Record<string, StatusBadgeVariant> = {
|
|
10
|
+
const DEFAULT_STATUS_TONE: Record<string, StatusTone> = {
|
|
11
|
+
// success
|
|
27
12
|
active: "success",
|
|
28
13
|
approved: "success",
|
|
14
|
+
authorized: "success",
|
|
15
|
+
captured: "success",
|
|
29
16
|
complete: "success",
|
|
30
17
|
completed: "success",
|
|
31
18
|
confirmed: "success",
|
|
19
|
+
connected: "success",
|
|
32
20
|
delivered: "success",
|
|
33
21
|
enabled: "success",
|
|
22
|
+
fulfilled: "success",
|
|
34
23
|
healthy: "success",
|
|
24
|
+
live: "success",
|
|
35
25
|
optimized: "success",
|
|
36
26
|
paid: "success",
|
|
37
27
|
published: "success",
|
|
38
28
|
resolved: "success",
|
|
29
|
+
succeeded: "success",
|
|
30
|
+
success: "success",
|
|
39
31
|
translated: "success",
|
|
40
32
|
verified: "success",
|
|
41
|
-
|
|
33
|
+
// info
|
|
34
|
+
in_progress: "info",
|
|
35
|
+
in_transit: "info",
|
|
36
|
+
info: "info",
|
|
37
|
+
invited: "info",
|
|
38
|
+
new: "info",
|
|
39
|
+
open: "info",
|
|
40
|
+
out_for_delivery: "info",
|
|
41
|
+
processing: "info",
|
|
42
|
+
scheduled: "info",
|
|
43
|
+
shipped: "info",
|
|
44
|
+
trial: "info",
|
|
45
|
+
trialing: "info",
|
|
46
|
+
// attention
|
|
47
|
+
action_required: "attention",
|
|
48
|
+
attention: "attention",
|
|
49
|
+
flagged: "attention",
|
|
50
|
+
held_for_review: "attention",
|
|
51
|
+
on_hold: "attention",
|
|
52
|
+
partially_fulfilled: "attention",
|
|
53
|
+
partially_paid: "attention",
|
|
54
|
+
requires_action: "attention",
|
|
55
|
+
return_requested: "attention",
|
|
56
|
+
unfulfilled: "attention",
|
|
57
|
+
// warning
|
|
42
58
|
degraded: "warning",
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
expiring: "warning",
|
|
60
|
+
overdue: "warning",
|
|
61
|
+
past_due: "warning",
|
|
45
62
|
pending: "warning",
|
|
46
|
-
processing: "warning",
|
|
47
63
|
retrying: "warning",
|
|
48
|
-
scheduled: "warning",
|
|
49
|
-
trial: "warning",
|
|
50
64
|
warning: "warning",
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
// critical
|
|
66
|
+
blocked: "critical",
|
|
67
|
+
canceled: "critical",
|
|
68
|
+
cancelled: "critical",
|
|
69
|
+
chargeback: "critical",
|
|
70
|
+
critical: "critical",
|
|
71
|
+
dead_letter: "critical",
|
|
72
|
+
declined: "critical",
|
|
73
|
+
deleted: "critical",
|
|
74
|
+
disputed: "critical",
|
|
75
|
+
error: "critical",
|
|
76
|
+
expired: "critical",
|
|
77
|
+
failed: "critical",
|
|
78
|
+
outdated: "critical",
|
|
79
|
+
rejected: "critical",
|
|
80
|
+
suspended: "critical",
|
|
81
|
+
unpaid: "critical",
|
|
82
|
+
voided: "critical",
|
|
83
|
+
// neutral
|
|
84
|
+
archived: "neutral",
|
|
85
|
+
closed: "neutral",
|
|
86
|
+
disabled: "neutral",
|
|
87
|
+
draft: "neutral",
|
|
88
|
+
inactive: "neutral",
|
|
89
|
+
missing: "neutral",
|
|
90
|
+
neutral: "neutral",
|
|
91
|
+
partially_refunded: "neutral",
|
|
92
|
+
refunded: "neutral",
|
|
93
|
+
restocked: "neutral",
|
|
94
|
+
returned: "neutral",
|
|
95
|
+
skipped: "neutral",
|
|
96
|
+
unknown: "neutral",
|
|
71
97
|
};
|
|
72
98
|
|
|
99
|
+
interface StatusBadgeProps extends Omit<
|
|
100
|
+
React.ComponentProps<typeof Badge>,
|
|
101
|
+
"children" | "variant"
|
|
102
|
+
> {
|
|
103
|
+
status?: string | null;
|
|
104
|
+
label?: React.ReactNode;
|
|
105
|
+
/**
|
|
106
|
+
* Semantic tone. Wins over `variant` and the built-in status map. Prefer
|
|
107
|
+
* `tone` / `toneMap` over raw `variant` so colours stay consistent.
|
|
108
|
+
*/
|
|
109
|
+
tone?: StatusTone;
|
|
110
|
+
/** Per-app status → tone overrides, merged over the built-in convention. */
|
|
111
|
+
toneMap?: Record<string, StatusTone>;
|
|
112
|
+
variant?: StatusBadgeVariant;
|
|
113
|
+
variantMap?: Record<string, StatusBadgeVariant>;
|
|
114
|
+
labelMap?: Record<string, React.ReactNode>;
|
|
115
|
+
icon?: StatusBadgeIcon;
|
|
116
|
+
iconMap?: Record<string, StatusBadgeIcon>;
|
|
117
|
+
iconClassName?: string;
|
|
118
|
+
showDot?: boolean;
|
|
119
|
+
dotClassName?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const DEFAULT_STATUS_VARIANT: Record<string, StatusBadgeVariant> = Object.fromEntries(
|
|
123
|
+
Object.entries(DEFAULT_STATUS_TONE).map(([status, tone]) => [
|
|
124
|
+
status,
|
|
125
|
+
STATUS_TONE_BADGE[tone].variant,
|
|
126
|
+
]),
|
|
127
|
+
);
|
|
128
|
+
|
|
73
129
|
const DEFAULT_DOT_CLASS: Record<StatusBadgeVariant, string> = {
|
|
74
130
|
default: "bg-background",
|
|
75
131
|
solid: "bg-background",
|
|
@@ -82,6 +138,15 @@ const DEFAULT_DOT_CLASS: Record<StatusBadgeVariant, string> = {
|
|
|
82
138
|
destructive: "bg-destructive",
|
|
83
139
|
};
|
|
84
140
|
|
|
141
|
+
const TONE_DOT_CLASS: Record<StatusTone, string> = {
|
|
142
|
+
success: "bg-emerald-500",
|
|
143
|
+
info: "bg-blue-500",
|
|
144
|
+
attention: "bg-orange-500",
|
|
145
|
+
warning: "bg-amber-500",
|
|
146
|
+
critical: "bg-destructive",
|
|
147
|
+
neutral: "bg-muted-foreground",
|
|
148
|
+
};
|
|
149
|
+
|
|
85
150
|
function normalizeStatus(status?: string | null) {
|
|
86
151
|
return String(status ?? "unknown")
|
|
87
152
|
.trim()
|
|
@@ -100,6 +165,16 @@ function statusLabel(status?: string | null) {
|
|
|
100
165
|
.join(" ");
|
|
101
166
|
}
|
|
102
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a status string to its semantic tone. Unknown statuses resolve to
|
|
170
|
+
* `neutral`. Status strings are normalised ("Partially Fulfilled",
|
|
171
|
+
* "partially-fulfilled" and "PARTIALLY_FULFILLED" are equivalent).
|
|
172
|
+
*/
|
|
173
|
+
function statusToneFor(status?: string | null, toneMap?: Record<string, StatusTone>): StatusTone {
|
|
174
|
+
const normalized = normalizeStatus(status);
|
|
175
|
+
return toneMap?.[normalized] ?? DEFAULT_STATUS_TONE[normalized] ?? "neutral";
|
|
176
|
+
}
|
|
177
|
+
|
|
103
178
|
function statusBadgeVariantFor(
|
|
104
179
|
status?: string | null,
|
|
105
180
|
variantMap?: Record<string, StatusBadgeVariant>,
|
|
@@ -111,6 +186,8 @@ function statusBadgeVariantFor(
|
|
|
111
186
|
function StatusBadge({
|
|
112
187
|
status,
|
|
113
188
|
label,
|
|
189
|
+
tone,
|
|
190
|
+
toneMap,
|
|
114
191
|
variant,
|
|
115
192
|
variantMap,
|
|
116
193
|
labelMap,
|
|
@@ -123,13 +200,25 @@ function StatusBadge({
|
|
|
123
200
|
...props
|
|
124
201
|
}: StatusBadgeProps) {
|
|
125
202
|
const normalized = normalizeStatus(status);
|
|
126
|
-
|
|
203
|
+
// Precedence: tone > toneMap > variant > variantMap > built-in tone map.
|
|
204
|
+
const resolvedTone: StatusTone | undefined =
|
|
205
|
+
tone ??
|
|
206
|
+
toneMap?.[normalized] ??
|
|
207
|
+
(variant === undefined && variantMap?.[normalized] === undefined
|
|
208
|
+
? DEFAULT_STATUS_TONE[normalized]
|
|
209
|
+
: undefined);
|
|
210
|
+
const toneBadge = resolvedTone ? STATUS_TONE_BADGE[resolvedTone] : undefined;
|
|
211
|
+
const resolvedVariant =
|
|
212
|
+
toneBadge?.variant ?? variant ?? statusBadgeVariantFor(normalized, variantMap);
|
|
213
|
+
const resolvedColor = toneBadge?.color ?? props.color;
|
|
127
214
|
const resolvedLabel = label ?? labelMap?.[normalized] ?? statusLabel(normalized);
|
|
128
215
|
const Icon = icon ?? iconMap?.[normalized];
|
|
129
216
|
|
|
130
217
|
return (
|
|
131
218
|
<Badge
|
|
132
219
|
variant={resolvedVariant}
|
|
220
|
+
{...(resolvedColor ? { color: resolvedColor } : {})}
|
|
221
|
+
data-tone={resolvedTone}
|
|
133
222
|
className={cn("max-w-full whitespace-nowrap", (showDot || Icon) && "pl-2", className)}
|
|
134
223
|
{...props}
|
|
135
224
|
>
|
|
@@ -137,7 +226,7 @@ function StatusBadge({
|
|
|
137
226
|
<span
|
|
138
227
|
className={cn(
|
|
139
228
|
"size-1.5 shrink-0 rounded-full",
|
|
140
|
-
DEFAULT_DOT_CLASS[resolvedVariant],
|
|
229
|
+
resolvedTone ? TONE_DOT_CLASS[resolvedTone] : DEFAULT_DOT_CLASS[resolvedVariant],
|
|
141
230
|
dotClassName,
|
|
142
231
|
)}
|
|
143
232
|
aria-hidden="true"
|
|
@@ -151,5 +240,11 @@ function StatusBadge({
|
|
|
151
240
|
|
|
152
241
|
StatusBadge.displayName = "StatusBadge";
|
|
153
242
|
|
|
154
|
-
export {
|
|
243
|
+
export {
|
|
244
|
+
StatusBadge,
|
|
245
|
+
statusBadgeVariantFor,
|
|
246
|
+
statusLabel,
|
|
247
|
+
statusToneFor,
|
|
248
|
+
DEFAULT_STATUS_TONE,
|
|
249
|
+
};
|
|
155
250
|
export type { StatusBadgeProps, StatusBadgeVariant };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
3
|
import { StatusBadge, type StatusBadgeVariant } from "./status-badge";
|
|
4
|
+
import type { StatusTone } from "./badge";
|
|
4
5
|
|
|
5
6
|
interface StatusTileGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
7
|
columns?: 2 | 3 | 4;
|
|
@@ -13,6 +14,10 @@ interface StatusTileProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "ti
|
|
|
13
14
|
meta?: React.ReactNode;
|
|
14
15
|
icon?: React.ComponentType<{ className?: string }>;
|
|
15
16
|
labelMap?: Record<string, React.ReactNode>;
|
|
17
|
+
/** Semantic tone override for the badge. */
|
|
18
|
+
tone?: StatusTone;
|
|
19
|
+
/** Per-app status → tone overrides. */
|
|
20
|
+
toneMap?: Record<string, StatusTone>;
|
|
16
21
|
variantMap?: Record<string, StatusBadgeVariant>;
|
|
17
22
|
iconMap?: Record<string, React.ComponentType<{ className?: string }>>;
|
|
18
23
|
}
|
|
@@ -44,6 +49,8 @@ function StatusTile({
|
|
|
44
49
|
meta,
|
|
45
50
|
icon: Icon,
|
|
46
51
|
labelMap,
|
|
52
|
+
tone,
|
|
53
|
+
toneMap,
|
|
47
54
|
variantMap,
|
|
48
55
|
iconMap,
|
|
49
56
|
className,
|
|
@@ -66,6 +73,8 @@ function StatusTile({
|
|
|
66
73
|
<StatusBadge
|
|
67
74
|
status={status}
|
|
68
75
|
{...(labelMap ? { labelMap } : {})}
|
|
76
|
+
{...(tone ? { tone } : {})}
|
|
77
|
+
{...(toneMap ? { toneMap } : {})}
|
|
69
78
|
{...(variantMap ? { variantMap } : {})}
|
|
70
79
|
{...(iconMap ? { iconMap } : {})}
|
|
71
80
|
className="shrink-0"
|
|
@@ -163,6 +163,7 @@ const Switch = forwardRef<HTMLDivElement, SwitchProps>(
|
|
|
163
163
|
}, [checked, setChecked, motionX, thumbTransition]);
|
|
164
164
|
|
|
165
165
|
return (
|
|
166
|
+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- pointer/drag convenience on the row; the switch button inside is the keyboard-accessible control
|
|
166
167
|
<div
|
|
167
168
|
ref={ref}
|
|
168
169
|
className={cn(
|
|
@@ -196,18 +197,18 @@ const Switch = forwardRef<HTMLDivElement, SwitchProps>(
|
|
|
196
197
|
className={cn(
|
|
197
198
|
"relative shrink-0 rounded-full outline-none cursor-pointer",
|
|
198
199
|
"transition-colors duration-80",
|
|
199
|
-
"focus-visible:ring-
|
|
200
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
200
201
|
)}
|
|
201
202
|
style={{
|
|
202
203
|
width: TRACK_WIDTH,
|
|
203
204
|
height: TRACK_HEIGHT,
|
|
204
205
|
backgroundColor: checked
|
|
205
206
|
? hovered
|
|
206
|
-
? "
|
|
207
|
-
: "
|
|
207
|
+
? "color-mix(in oklab, var(--primary), var(--foreground) 12%)"
|
|
208
|
+
: "var(--primary)"
|
|
208
209
|
: hovered
|
|
209
|
-
? "color-mix(in oklab, var(--
|
|
210
|
-
: "var(--
|
|
210
|
+
? "color-mix(in oklab, var(--foreground) 22%, transparent)"
|
|
211
|
+
: "color-mix(in oklab, var(--foreground) 15%, transparent)",
|
|
211
212
|
}}
|
|
212
213
|
onClick={(e) => e.stopPropagation()}
|
|
213
214
|
>
|
|
@@ -19,6 +19,7 @@ import { spring } from "../lib/springs";
|
|
|
19
19
|
import { fontWeights } from "../lib/font-weight";
|
|
20
20
|
import { useShape } from "../lib/shape-context";
|
|
21
21
|
import { useProximityHover } from "../hooks/use-proximity-hover";
|
|
22
|
+
import { scrollStripItemIntoView, useHorizontalOverflowMask } from "../lib/scroll-fade";
|
|
22
23
|
|
|
23
24
|
interface TabsSubtleContextValue {
|
|
24
25
|
registerTab: (index: number, element: HTMLElement | null) => void;
|
|
@@ -55,11 +56,16 @@ const TabsSubtle = forwardRef<HTMLDivElement, TabsSubtleProps>(
|
|
|
55
56
|
idPrefix: idPrefixProp,
|
|
56
57
|
activeLabel = false,
|
|
57
58
|
className,
|
|
59
|
+
style,
|
|
58
60
|
...props
|
|
59
61
|
},
|
|
60
62
|
ref,
|
|
61
63
|
) => {
|
|
62
64
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const maskStyle = useHorizontalOverflowMask(containerRef);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
scrollStripItemIntoView(containerRef.current, selectedIndex);
|
|
68
|
+
}, [selectedIndex]);
|
|
63
69
|
const isMouseInside = useRef(false);
|
|
64
70
|
const generatedId = useId();
|
|
65
71
|
const shape = useShape();
|
|
@@ -127,6 +133,7 @@ const TabsSubtle = forwardRef<HTMLDivElement, TabsSubtleProps>(
|
|
|
127
133
|
<TabsSubtleContext.Provider
|
|
128
134
|
value={{ registerTab, hoveredIndex, selectedIndex, onSelect, idPrefix, activeLabel }}
|
|
129
135
|
>
|
|
136
|
+
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus -- roving tabindex per WAI-ARIA APG: the tabs are focusable, the tablist container is not */}
|
|
130
137
|
<div
|
|
131
138
|
ref={(node) => {
|
|
132
139
|
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
@@ -173,9 +180,10 @@ const TabsSubtle = forwardRef<HTMLDivElement, TabsSubtleProps>(
|
|
|
173
180
|
}
|
|
174
181
|
}}
|
|
175
182
|
className={cn(
|
|
176
|
-
"relative flex items-center gap-0.5 select-none overflow-x-auto max-w-full scrollbar-
|
|
183
|
+
"relative flex items-center gap-0.5 select-none overflow-x-auto max-w-full overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden -my-1 py-1 [&>[role=tab]]:shrink-0",
|
|
177
184
|
className,
|
|
178
185
|
)}
|
|
186
|
+
style={maskStyle ? { ...maskStyle, ...style } : style}
|
|
179
187
|
role="tablist"
|
|
180
188
|
{...props}
|
|
181
189
|
>
|
|
@@ -242,7 +250,7 @@ const TabsSubtle = forwardRef<HTMLDivElement, TabsSubtleProps>(
|
|
|
242
250
|
{focusRect && (
|
|
243
251
|
<motion.div
|
|
244
252
|
className={cn(
|
|
245
|
-
"absolute pointer-events-none z-20 border border-
|
|
253
|
+
"absolute pointer-events-none z-20 border-2 border-ring",
|
|
246
254
|
shape.focusRing,
|
|
247
255
|
)}
|
|
248
256
|
initial={false}
|