@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.
Files changed (30) hide show
  1. package/package.json +88 -0
  2. package/src/components/alert/alert.tsx +204 -0
  3. package/src/components/badge/badge.tsx +139 -0
  4. package/src/components/breadcrumb/breadcrumb.tsx +130 -0
  5. package/src/components/button/button.tsx +153 -0
  6. package/src/components/card/card.tsx +48 -0
  7. package/src/components/checkbox/checkbox.tsx +82 -0
  8. package/src/components/combobox/combobox.tsx +166 -0
  9. package/src/components/copyable-text/copyable-text.tsx +194 -0
  10. package/src/components/dialog/dialog.tsx +145 -0
  11. package/src/components/form-field/form-field.tsx +71 -0
  12. package/src/components/input/input.tsx +52 -0
  13. package/src/components/logo/logo.tsx +68 -0
  14. package/src/components/multi-select/multi-select.tsx +312 -0
  15. package/src/components/pagination/pagination.tsx +218 -0
  16. package/src/components/progress-bar/progress-bar.tsx +134 -0
  17. package/src/components/radio-group/radio-group.tsx +87 -0
  18. package/src/components/select/select.tsx +124 -0
  19. package/src/components/slider/slider.tsx +127 -0
  20. package/src/components/status-dot/status-dot.tsx +53 -0
  21. package/src/components/stepper/stepper.tsx +229 -0
  22. package/src/components/switch/switch.tsx +74 -0
  23. package/src/components/table/table.tsx +260 -0
  24. package/src/components/tabs/tabs.tsx +493 -0
  25. package/src/components/textarea/textarea.tsx +56 -0
  26. package/src/components/tooltip/tooltip.tsx +35 -0
  27. package/src/components/ui/skeleton.tsx +21 -0
  28. package/src/components/ui/spinner.tsx +27 -0
  29. package/src/lib/cn.ts +6 -0
  30. 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
+ }