@stasho/ds 0.1.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/package.json +88 -0
- package/src/components/alert/alert.tsx +204 -0
- package/src/components/badge/badge.tsx +139 -0
- package/src/components/breadcrumb/breadcrumb.tsx +130 -0
- package/src/components/button/button.tsx +153 -0
- package/src/components/card/card.tsx +48 -0
- package/src/components/checkbox/checkbox.tsx +82 -0
- package/src/components/combobox/combobox.tsx +166 -0
- package/src/components/copyable-text/copyable-text.tsx +194 -0
- package/src/components/dialog/dialog.tsx +145 -0
- package/src/components/form-field/form-field.tsx +71 -0
- package/src/components/input/input.tsx +52 -0
- package/src/components/logo/logo.tsx +68 -0
- package/src/components/multi-select/multi-select.tsx +312 -0
- package/src/components/pagination/pagination.tsx +218 -0
- package/src/components/progress-bar/progress-bar.tsx +134 -0
- package/src/components/radio-group/radio-group.tsx +87 -0
- package/src/components/select/select.tsx +124 -0
- package/src/components/slider/slider.tsx +127 -0
- package/src/components/status-dot/status-dot.tsx +53 -0
- package/src/components/stepper/stepper.tsx +229 -0
- package/src/components/switch/switch.tsx +74 -0
- package/src/components/table/table.tsx +260 -0
- package/src/components/tabs/tabs.tsx +493 -0
- package/src/components/textarea/textarea.tsx +56 -0
- package/src/components/tooltip/tooltip.tsx +35 -0
- package/src/components/ui/skeleton.tsx +21 -0
- package/src/components/ui/spinner.tsx +27 -0
- package/src/lib/cn.ts +6 -0
- package/src/styles/tokens.css +459 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "@ac/lib/cn";
|
|
4
|
+
|
|
5
|
+
const statusDotVariants = cva("inline-block rounded-full shrink-0", {
|
|
6
|
+
variants: {
|
|
7
|
+
status: {
|
|
8
|
+
healthy: "bg-success-500 animate-pulse motion-reduce:animate-none",
|
|
9
|
+
degraded: "bg-warning-500",
|
|
10
|
+
error: "bg-error-500",
|
|
11
|
+
offline: "bg-neutral-400",
|
|
12
|
+
unknown: "bg-neutral-300",
|
|
13
|
+
},
|
|
14
|
+
size: {
|
|
15
|
+
sm: "size-2",
|
|
16
|
+
md: "size-3",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
defaultVariants: {
|
|
20
|
+
status: "unknown",
|
|
21
|
+
size: "md",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
type StatusDotProps = HTMLAttributes<HTMLSpanElement> &
|
|
26
|
+
VariantProps<typeof statusDotVariants>;
|
|
27
|
+
|
|
28
|
+
const statusLabels: Record<NonNullable<StatusDotProps["status"]>, string> = {
|
|
29
|
+
healthy: "Healthy",
|
|
30
|
+
degraded: "Degraded",
|
|
31
|
+
error: "Error",
|
|
32
|
+
offline: "Offline",
|
|
33
|
+
unknown: "Unknown",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const StatusDot = forwardRef<HTMLSpanElement, StatusDotProps>(
|
|
37
|
+
({ status, size, className, ...rest }, ref) => {
|
|
38
|
+
const resolvedStatus = status ?? "unknown";
|
|
39
|
+
return (
|
|
40
|
+
<span
|
|
41
|
+
ref={ref}
|
|
42
|
+
role="status"
|
|
43
|
+
aria-label={statusLabels[resolvedStatus]}
|
|
44
|
+
className={cn(statusDotVariants({ status, size }), className)}
|
|
45
|
+
{...rest}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
StatusDot.displayName = "StatusDot";
|
|
52
|
+
|
|
53
|
+
export { StatusDot, statusDotVariants, type StatusDotProps };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useContext,
|
|
7
|
+
type HTMLAttributes,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { cn } from "@ac/lib/cn";
|
|
10
|
+
|
|
11
|
+
/* ── Stepper context (orientation) ─────────────── */
|
|
12
|
+
|
|
13
|
+
type StepperContextValue = { orientation: "horizontal" | "vertical" };
|
|
14
|
+
|
|
15
|
+
const StepperContext = createContext<StepperContextValue>({
|
|
16
|
+
orientation: "horizontal",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function useStepperContext(): StepperContextValue {
|
|
20
|
+
return useContext(StepperContext);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ── StepperItem context (state propagation) ───── */
|
|
24
|
+
|
|
25
|
+
type StepperItemState = "completed" | "active" | "inactive";
|
|
26
|
+
|
|
27
|
+
type StepperItemContextValue = { state: StepperItemState };
|
|
28
|
+
|
|
29
|
+
const StepperItemContext = createContext<StepperItemContextValue>({
|
|
30
|
+
state: "inactive",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function useStepperItemContext(): StepperItemContextValue {
|
|
34
|
+
return useContext(StepperItemContext);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ── Stepper (nav root) ────────────────────────── */
|
|
38
|
+
|
|
39
|
+
type StepperProps = HTMLAttributes<HTMLElement> & {
|
|
40
|
+
/** Layout direction. Default "horizontal". */
|
|
41
|
+
orientation?: "horizontal" | "vertical";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const Stepper = forwardRef<HTMLElement, StepperProps>(
|
|
45
|
+
({ orientation = "horizontal", className, ...rest }, ref) => (
|
|
46
|
+
<StepperContext.Provider value={{ orientation }}>
|
|
47
|
+
<nav
|
|
48
|
+
ref={ref}
|
|
49
|
+
data-orientation={orientation}
|
|
50
|
+
className={cn(className)}
|
|
51
|
+
{...rest}
|
|
52
|
+
/>
|
|
53
|
+
</StepperContext.Provider>
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
Stepper.displayName = "Stepper";
|
|
58
|
+
|
|
59
|
+
/* ── StepperList (ol container) ────────────────── */
|
|
60
|
+
|
|
61
|
+
type StepperListProps = HTMLAttributes<HTMLOListElement>;
|
|
62
|
+
|
|
63
|
+
const StepperList = forwardRef<HTMLOListElement, StepperListProps>(
|
|
64
|
+
({ className, ...rest }, ref) => {
|
|
65
|
+
const { orientation } = useStepperContext();
|
|
66
|
+
return (
|
|
67
|
+
<ol
|
|
68
|
+
ref={ref}
|
|
69
|
+
className={cn(
|
|
70
|
+
"flex gap-2",
|
|
71
|
+
orientation === "horizontal" ? "items-center" : "flex-col",
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
{...rest}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
StepperList.displayName = "StepperList";
|
|
81
|
+
|
|
82
|
+
/* ── StepperItem (li, carries state) ───────────── */
|
|
83
|
+
|
|
84
|
+
type StepperItemProps = HTMLAttributes<HTMLLIElement> & {
|
|
85
|
+
/** Step state. Propagated as data-state to children. */
|
|
86
|
+
state?: StepperItemState;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const StepperItem = forwardRef<HTMLLIElement, StepperItemProps>(
|
|
90
|
+
({ state = "inactive", className, ...rest }, ref) => (
|
|
91
|
+
<StepperItemContext.Provider value={{ state }}>
|
|
92
|
+
<li
|
|
93
|
+
ref={ref}
|
|
94
|
+
data-state={state}
|
|
95
|
+
aria-current={state === "active" ? "step" : undefined}
|
|
96
|
+
className={cn("flex items-center gap-2", className)}
|
|
97
|
+
{...rest}
|
|
98
|
+
/>
|
|
99
|
+
</StepperItemContext.Provider>
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
StepperItem.displayName = "StepperItem";
|
|
104
|
+
|
|
105
|
+
/* ── StepperIndicator (div slot) ───────────────── */
|
|
106
|
+
|
|
107
|
+
type StepperIndicatorProps = HTMLAttributes<HTMLDivElement>;
|
|
108
|
+
|
|
109
|
+
const StepperIndicator = forwardRef<HTMLDivElement, StepperIndicatorProps>(
|
|
110
|
+
({ className, children, ...rest }, ref) => {
|
|
111
|
+
const { state } = useStepperItemContext();
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
ref={ref}
|
|
115
|
+
data-state={state}
|
|
116
|
+
className={cn(
|
|
117
|
+
"relative flex size-8 items-center justify-center rounded-full",
|
|
118
|
+
"font-heading text-sm font-bold",
|
|
119
|
+
"border-2 border-edge text-muted-foreground",
|
|
120
|
+
"data-[state=active]:border-primary-500 data-[state=active]:bg-primary-500 data-[state=active]:text-white",
|
|
121
|
+
"data-[state=completed]:border-primary-500 data-[state=completed]:bg-primary-500 data-[state=completed]:text-white",
|
|
122
|
+
"transition-all duration-300 motion-reduce:transition-colors",
|
|
123
|
+
className,
|
|
124
|
+
)}
|
|
125
|
+
{...rest}
|
|
126
|
+
>
|
|
127
|
+
{state === "active" && (
|
|
128
|
+
<>
|
|
129
|
+
<span className="absolute -inset-1 rounded-full border-2 border-primary-400/35 animate-[ring-wave_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />
|
|
130
|
+
<span className="absolute -inset-1.5 rounded-full border border-primary-300/25 animate-[ring-wave_2.4s_ease-in-out_-1.2s_infinite] motion-reduce:animate-none" />
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
{children}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
StepperIndicator.displayName = "StepperIndicator";
|
|
140
|
+
|
|
141
|
+
/* ── StepperLabel (span) ───────────────────────── */
|
|
142
|
+
|
|
143
|
+
type StepperLabelProps = HTMLAttributes<HTMLSpanElement>;
|
|
144
|
+
|
|
145
|
+
const StepperLabel = forwardRef<HTMLSpanElement, StepperLabelProps>(
|
|
146
|
+
({ className, ...rest }, ref) => {
|
|
147
|
+
const { state } = useStepperItemContext();
|
|
148
|
+
return (
|
|
149
|
+
<span
|
|
150
|
+
ref={ref}
|
|
151
|
+
data-state={state}
|
|
152
|
+
className={cn(
|
|
153
|
+
"block text-sm text-muted-foreground transition-colors",
|
|
154
|
+
"data-[state=active]:text-foreground data-[state=active]:font-medium",
|
|
155
|
+
"data-[state=completed]:text-foreground",
|
|
156
|
+
className,
|
|
157
|
+
)}
|
|
158
|
+
{...rest}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
StepperLabel.displayName = "StepperLabel";
|
|
165
|
+
|
|
166
|
+
/* ── StepperDescription (span) ─────────────────── */
|
|
167
|
+
|
|
168
|
+
type StepperDescriptionProps = HTMLAttributes<HTMLSpanElement>;
|
|
169
|
+
|
|
170
|
+
const StepperDescription = forwardRef<
|
|
171
|
+
HTMLSpanElement,
|
|
172
|
+
StepperDescriptionProps
|
|
173
|
+
>(({ className, ...rest }, ref) => {
|
|
174
|
+
const { state } = useStepperItemContext();
|
|
175
|
+
return (
|
|
176
|
+
<span
|
|
177
|
+
ref={ref}
|
|
178
|
+
data-state={state}
|
|
179
|
+
className={cn("block text-xs text-muted-foreground mt-0.5", className)}
|
|
180
|
+
{...rest}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
StepperDescription.displayName = "StepperDescription";
|
|
186
|
+
|
|
187
|
+
/* ── StepperConnector (li, visual line) ────────── */
|
|
188
|
+
|
|
189
|
+
type StepperConnectorProps = HTMLAttributes<HTMLLIElement>;
|
|
190
|
+
|
|
191
|
+
const StepperConnector = forwardRef<HTMLLIElement, StepperConnectorProps>(
|
|
192
|
+
({ className, ...rest }, ref) => {
|
|
193
|
+
const { orientation } = useStepperContext();
|
|
194
|
+
return (
|
|
195
|
+
<li
|
|
196
|
+
ref={ref}
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
data-orientation={orientation}
|
|
199
|
+
className={cn(
|
|
200
|
+
"relative overflow-hidden rounded-full bg-edge/50 flex-1",
|
|
201
|
+
orientation === "horizontal" ? "h-1" : "w-1",
|
|
202
|
+
className,
|
|
203
|
+
)}
|
|
204
|
+
{...rest}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
StepperConnector.displayName = "StepperConnector";
|
|
211
|
+
|
|
212
|
+
/* ── Exports ───────────────────────────────────── */
|
|
213
|
+
|
|
214
|
+
export {
|
|
215
|
+
Stepper,
|
|
216
|
+
StepperConnector,
|
|
217
|
+
StepperDescription,
|
|
218
|
+
StepperIndicator,
|
|
219
|
+
StepperItem,
|
|
220
|
+
StepperLabel,
|
|
221
|
+
StepperList,
|
|
222
|
+
type StepperConnectorProps,
|
|
223
|
+
type StepperDescriptionProps,
|
|
224
|
+
type StepperIndicatorProps,
|
|
225
|
+
type StepperItemProps,
|
|
226
|
+
type StepperLabelProps,
|
|
227
|
+
type StepperListProps,
|
|
228
|
+
type StepperProps,
|
|
229
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { forwardRef, type ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import { Switch as SwitchPrimitive } from "radix-ui";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "@ac/lib/cn";
|
|
5
|
+
|
|
6
|
+
const switchVariants = cva(
|
|
7
|
+
[
|
|
8
|
+
"peer inline-flex shrink-0 cursor-pointer",
|
|
9
|
+
"items-center rounded-full",
|
|
10
|
+
"border-3 border-edge bg-muted",
|
|
11
|
+
"hover:border-edge-hover",
|
|
12
|
+
"focus-visible:outline-none focus-visible:ring-3",
|
|
13
|
+
"focus-visible:ring-primary-500",
|
|
14
|
+
"disabled:opacity-50 disabled:pointer-events-none",
|
|
15
|
+
"data-[state=checked]:bg-primary data-[state=checked]:border-primary",
|
|
16
|
+
"transition-colors",
|
|
17
|
+
].join(" "),
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
size: {
|
|
21
|
+
xs: "h-5 w-9",
|
|
22
|
+
sm: "h-[26px] w-12",
|
|
23
|
+
md: "h-8 w-[60px]",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
size: "md",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const thumbVariants = cva(
|
|
33
|
+
[
|
|
34
|
+
"pointer-events-none block rounded-full bg-white",
|
|
35
|
+
"shadow-sm transition-transform motion-reduce:transition-none",
|
|
36
|
+
"data-[state=unchecked]:translate-x-0.5",
|
|
37
|
+
].join(" "),
|
|
38
|
+
{
|
|
39
|
+
variants: {
|
|
40
|
+
size: {
|
|
41
|
+
xs: "size-3 data-[state=checked]:translate-x-[18px]",
|
|
42
|
+
sm: "size-[18px] data-[state=checked]:translate-x-[24px]",
|
|
43
|
+
md: "size-6 data-[state=checked]:translate-x-[30px]",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
defaultVariants: {
|
|
47
|
+
size: "md",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
type SwitchProps = Omit<
|
|
53
|
+
ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>,
|
|
54
|
+
"size"
|
|
55
|
+
> &
|
|
56
|
+
VariantProps<typeof switchVariants>;
|
|
57
|
+
|
|
58
|
+
const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
|
|
59
|
+
({ size, className, ...rest }, ref) => {
|
|
60
|
+
return (
|
|
61
|
+
<SwitchPrimitive.Root
|
|
62
|
+
ref={ref}
|
|
63
|
+
className={cn(switchVariants({ size }), className)}
|
|
64
|
+
{...rest}
|
|
65
|
+
>
|
|
66
|
+
<SwitchPrimitive.Thumb className={thumbVariants({ size })} />
|
|
67
|
+
</SwitchPrimitive.Root>
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
Switch.displayName = "Switch";
|
|
73
|
+
|
|
74
|
+
export { Switch, switchVariants, type SwitchProps };
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type KeyboardEvent, type ReactNode } from "react";
|
|
4
|
+
import { CaretUp } from "@phosphor-icons/react";
|
|
5
|
+
import { cn } from "@ac/lib/cn";
|
|
6
|
+
|
|
7
|
+
type SortDirection = "asc" | "desc";
|
|
8
|
+
|
|
9
|
+
export type Column<T> = {
|
|
10
|
+
header: string;
|
|
11
|
+
accessor: (row: T) => ReactNode;
|
|
12
|
+
sortable?: boolean;
|
|
13
|
+
sortValue?: (row: T) => string | number;
|
|
14
|
+
width?: string;
|
|
15
|
+
align?: "left" | "center" | "right";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type TableProps<T> = {
|
|
19
|
+
columns: Column<T>[];
|
|
20
|
+
data: T[];
|
|
21
|
+
keyExtractor: (row: T) => string;
|
|
22
|
+
onRowClick?: (row: T) => void;
|
|
23
|
+
activeKey?: string | undefined;
|
|
24
|
+
emptyState?: ReactNode;
|
|
25
|
+
className?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Header of the column to indicate as sorted.
|
|
28
|
+
* When provided alongside `onSortChange`, the table operates in
|
|
29
|
+
* controlled mode: it does not sort `data` internally and assumes
|
|
30
|
+
* the parent passes pre-sorted rows. Use this when sorting must
|
|
31
|
+
* apply to a larger dataset than the rows currently rendered
|
|
32
|
+
* (e.g. when paginating outside the table).
|
|
33
|
+
*/
|
|
34
|
+
sortColumn?: string;
|
|
35
|
+
sortDirection?: SortDirection;
|
|
36
|
+
onSortChange?: (column: string, direction: SortDirection) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function SortIcon({
|
|
40
|
+
direction,
|
|
41
|
+
}: {
|
|
42
|
+
direction: SortDirection | null;
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<CaretUp
|
|
46
|
+
weight="bold"
|
|
47
|
+
className={cn(
|
|
48
|
+
"inline size-3 transition-transform motion-reduce:transition-none",
|
|
49
|
+
direction === "desc" && "rotate-180",
|
|
50
|
+
direction === null && "opacity-0",
|
|
51
|
+
)}
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ariaSortValue(
|
|
58
|
+
colIndex: number,
|
|
59
|
+
sortCol: number | null,
|
|
60
|
+
sortDir: SortDirection,
|
|
61
|
+
): "ascending" | "descending" | "none" {
|
|
62
|
+
if (colIndex !== sortCol) return "none";
|
|
63
|
+
return sortDir === "asc" ? "ascending" : "descending";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function Table<T>({
|
|
67
|
+
columns,
|
|
68
|
+
data,
|
|
69
|
+
keyExtractor,
|
|
70
|
+
onRowClick,
|
|
71
|
+
activeKey,
|
|
72
|
+
emptyState,
|
|
73
|
+
className,
|
|
74
|
+
sortColumn,
|
|
75
|
+
sortDirection,
|
|
76
|
+
onSortChange,
|
|
77
|
+
}: TableProps<T>) {
|
|
78
|
+
const isControlled = onSortChange != null;
|
|
79
|
+
|
|
80
|
+
const [internalSortCol, setInternalSortCol] = useState<number | null>(
|
|
81
|
+
null,
|
|
82
|
+
);
|
|
83
|
+
const [internalSortDir, setInternalSortDir] =
|
|
84
|
+
useState<SortDirection>("asc");
|
|
85
|
+
|
|
86
|
+
const controlledSortCol =
|
|
87
|
+
sortColumn != null
|
|
88
|
+
? columns.findIndex((c) => c.header === sortColumn)
|
|
89
|
+
: -1;
|
|
90
|
+
const activeSortCol = isControlled
|
|
91
|
+
? controlledSortCol >= 0
|
|
92
|
+
? controlledSortCol
|
|
93
|
+
: null
|
|
94
|
+
: internalSortCol;
|
|
95
|
+
const activeSortDir = isControlled
|
|
96
|
+
? (sortDirection ?? "asc")
|
|
97
|
+
: internalSortDir;
|
|
98
|
+
|
|
99
|
+
function handleSort(colIndex: number) {
|
|
100
|
+
const col = columns[colIndex];
|
|
101
|
+
if (!col?.sortable) return;
|
|
102
|
+
if (isControlled) {
|
|
103
|
+
const isCurrent = activeSortCol === colIndex;
|
|
104
|
+
const nextDir: SortDirection = isCurrent
|
|
105
|
+
? activeSortDir === "asc"
|
|
106
|
+
? "desc"
|
|
107
|
+
: "asc"
|
|
108
|
+
: "asc";
|
|
109
|
+
onSortChange(col.header, nextDir);
|
|
110
|
+
} else if (internalSortCol === colIndex) {
|
|
111
|
+
setInternalSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
|
112
|
+
} else {
|
|
113
|
+
setInternalSortCol(colIndex);
|
|
114
|
+
setInternalSortDir("asc");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function handleHeaderKeyDown(
|
|
119
|
+
e: KeyboardEvent,
|
|
120
|
+
colIndex: number,
|
|
121
|
+
) {
|
|
122
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
handleSort(colIndex);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleRowKeyDown(e: KeyboardEvent, row: T) {
|
|
129
|
+
if (e.key === "Enter") {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
onRowClick?.(row);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let sortedData = data;
|
|
136
|
+
if (!isControlled) {
|
|
137
|
+
const activeCol =
|
|
138
|
+
activeSortCol !== null ? columns[activeSortCol] : undefined;
|
|
139
|
+
if (activeCol?.sortValue) {
|
|
140
|
+
const getValue = activeCol.sortValue;
|
|
141
|
+
const dir = activeSortDir === "asc" ? 1 : -1;
|
|
142
|
+
sortedData = [...data].sort((a, b) => {
|
|
143
|
+
const aVal = getValue(a);
|
|
144
|
+
const bVal = getValue(b);
|
|
145
|
+
if (typeof aVal === "number" && typeof bVal === "number") {
|
|
146
|
+
return (aVal - bVal) * dir;
|
|
147
|
+
}
|
|
148
|
+
return String(aVal).localeCompare(String(bVal)) * dir;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const alignClass = {
|
|
154
|
+
left: "text-left",
|
|
155
|
+
center: "text-center",
|
|
156
|
+
right: "text-right",
|
|
157
|
+
} as const;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div className={cn("w-full overflow-x-auto", className)}>
|
|
161
|
+
<table className="w-full border-collapse">
|
|
162
|
+
<thead>
|
|
163
|
+
<tr className="bg-muted/50">
|
|
164
|
+
{columns.map((col, i) => (
|
|
165
|
+
<th
|
|
166
|
+
key={col.header}
|
|
167
|
+
className={cn(
|
|
168
|
+
"px-4 py-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground",
|
|
169
|
+
alignClass[col.align ?? "left"],
|
|
170
|
+
col.sortable && "cursor-pointer select-none",
|
|
171
|
+
)}
|
|
172
|
+
style={col.width ? { width: col.width } : undefined}
|
|
173
|
+
tabIndex={col.sortable ? 0 : undefined}
|
|
174
|
+
aria-sort={
|
|
175
|
+
col.sortable
|
|
176
|
+
? ariaSortValue(i, activeSortCol, activeSortDir)
|
|
177
|
+
: undefined
|
|
178
|
+
}
|
|
179
|
+
onClick={col.sortable ? () => handleSort(i) : undefined}
|
|
180
|
+
onKeyDown={
|
|
181
|
+
col.sortable
|
|
182
|
+
? (e) => handleHeaderKeyDown(e, i)
|
|
183
|
+
: undefined
|
|
184
|
+
}
|
|
185
|
+
>
|
|
186
|
+
{col.sortable ? (
|
|
187
|
+
<span
|
|
188
|
+
className={cn(
|
|
189
|
+
"inline-flex items-center gap-1",
|
|
190
|
+
col.align === "right" && "flex-row-reverse",
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{col.header}
|
|
194
|
+
<SortIcon
|
|
195
|
+
direction={
|
|
196
|
+
activeSortCol === i ? activeSortDir : null
|
|
197
|
+
}
|
|
198
|
+
/>
|
|
199
|
+
</span>
|
|
200
|
+
) : (
|
|
201
|
+
col.header
|
|
202
|
+
)}
|
|
203
|
+
</th>
|
|
204
|
+
))}
|
|
205
|
+
</tr>
|
|
206
|
+
</thead>
|
|
207
|
+
<tbody>
|
|
208
|
+
{sortedData.length === 0 && emptyState ? (
|
|
209
|
+
<tr>
|
|
210
|
+
<td
|
|
211
|
+
colSpan={columns.length}
|
|
212
|
+
className="px-4 py-8 text-center text-sm text-muted-foreground"
|
|
213
|
+
>
|
|
214
|
+
{emptyState}
|
|
215
|
+
</td>
|
|
216
|
+
</tr>
|
|
217
|
+
) : (
|
|
218
|
+
sortedData.map((row) => (
|
|
219
|
+
<tr
|
|
220
|
+
key={keyExtractor(row)}
|
|
221
|
+
className={cn(
|
|
222
|
+
"border-b border-edge transition-all",
|
|
223
|
+
activeKey === keyExtractor(row)
|
|
224
|
+
? "bg-primary-600/10 shadow-[inset_3px_0_0_var(--color-primary-500)]"
|
|
225
|
+
: "even:bg-muted/30",
|
|
226
|
+
"hover:bg-muted/50",
|
|
227
|
+
onRowClick &&
|
|
228
|
+
"cursor-pointer hover:shadow-[inset_3px_0_0_var(--color-primary-500)]",
|
|
229
|
+
)}
|
|
230
|
+
aria-current={
|
|
231
|
+
activeKey === keyExtractor(row) ? "true" : undefined
|
|
232
|
+
}
|
|
233
|
+
style={{ transitionDuration: "var(--duration-fast)" }}
|
|
234
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
235
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
236
|
+
onKeyDown={
|
|
237
|
+
onRowClick
|
|
238
|
+
? (e) => handleRowKeyDown(e, row)
|
|
239
|
+
: undefined
|
|
240
|
+
}
|
|
241
|
+
>
|
|
242
|
+
{columns.map((col) => (
|
|
243
|
+
<td
|
|
244
|
+
key={col.header}
|
|
245
|
+
className={cn(
|
|
246
|
+
"px-4 py-3 text-sm",
|
|
247
|
+
alignClass[col.align ?? "left"],
|
|
248
|
+
)}
|
|
249
|
+
>
|
|
250
|
+
{col.accessor(row)}
|
|
251
|
+
</td>
|
|
252
|
+
))}
|
|
253
|
+
</tr>
|
|
254
|
+
))
|
|
255
|
+
)}
|
|
256
|
+
</tbody>
|
|
257
|
+
</table>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|