@danielthurau/atlas-labs-codex 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/README.md +138 -0
- package/components/react/Badge/Badge.module.css +38 -0
- package/components/react/Badge/Badge.stories.tsx +79 -0
- package/components/react/Badge/Badge.tsx +42 -0
- package/components/react/Badge/index.ts +2 -0
- package/components/react/Button/Button.module.css +118 -0
- package/components/react/Button/Button.stories.tsx +108 -0
- package/components/react/Button/Button.tsx +73 -0
- package/components/react/Button/index.ts +2 -0
- package/components/react/Card/Card.module.css +59 -0
- package/components/react/Card/Card.stories.tsx +114 -0
- package/components/react/Card/Card.tsx +79 -0
- package/components/react/Card/index.ts +11 -0
- package/components/react/Input/Input.module.css +63 -0
- package/components/react/Input/Input.stories.tsx +88 -0
- package/components/react/Input/Input.tsx +50 -0
- package/components/react/Input/index.ts +2 -0
- package/components/react/Modal/Modal.module.css +103 -0
- package/components/react/Modal/Modal.stories.tsx +119 -0
- package/components/react/Modal/Modal.tsx +75 -0
- package/components/react/Modal/index.ts +2 -0
- package/components/react/RefreshButton/RefreshButton.module.css +202 -0
- package/components/react/RefreshButton/RefreshButton.stories.tsx +43 -0
- package/components/react/RefreshButton/RefreshButton.tsx +222 -0
- package/components/react/RefreshButton/index.ts +2 -0
- package/components/react/Tabs/Tabs.module.css +58 -0
- package/components/react/Tabs/Tabs.stories.tsx +101 -0
- package/components/react/Tabs/Tabs.tsx +62 -0
- package/components/react/Tabs/index.ts +2 -0
- package/components/react/Toast/Toast.module.css +145 -0
- package/components/react/Toast/Toast.stories.tsx +143 -0
- package/components/react/Toast/Toast.tsx +123 -0
- package/components/react/Toast/index.ts +2 -0
- package/components/react/index.ts +31 -0
- package/lib/index.ts +7 -0
- package/lib/utils.ts +32 -0
- package/package.json +95 -0
- package/public/fonts/MartianMono-OFL.txt +93 -0
- package/public/fonts/MartianMono-VariableFont_wdth,wght.ttf +0 -0
- package/themes/css/base.css +142 -0
- package/themes/css/theme-dark.css +48 -0
- package/themes/css/theme-light.css +49 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.buttonGroup {
|
|
8
|
+
display: flex;
|
|
9
|
+
position: relative;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.buttons {
|
|
14
|
+
display: flex;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Main refresh button */
|
|
18
|
+
.refreshButton {
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: var(--space-2);
|
|
22
|
+
padding: var(--space-2) var(--space-3);
|
|
23
|
+
background: var(--color-bg-subtle);
|
|
24
|
+
border: 1px solid var(--color-border-default);
|
|
25
|
+
border-right: none;
|
|
26
|
+
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
|
27
|
+
font-family: inherit;
|
|
28
|
+
font-size: var(--text-sm);
|
|
29
|
+
color: var(--color-text-primary);
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
transition: background 150ms, color 150ms;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* When progress bar is shown, remove bottom radius */
|
|
35
|
+
.buttonGroup[data-has-progress="true"] .refreshButton {
|
|
36
|
+
border-radius: var(--radius-md) 0 0 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.refreshButton:hover:not(:disabled) {
|
|
40
|
+
background: var(--color-bg-muted);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.refreshButton:disabled {
|
|
44
|
+
opacity: 0.6;
|
|
45
|
+
cursor: not-allowed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.icon {
|
|
49
|
+
display: inline-block;
|
|
50
|
+
font-size: var(--text-base);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.spinning {
|
|
54
|
+
animation: spin 1s linear infinite;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@keyframes spin {
|
|
58
|
+
from { transform: rotate(0deg); }
|
|
59
|
+
to { transform: rotate(360deg); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Dropdown trigger */
|
|
63
|
+
.dropdownTrigger {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: var(--space-1);
|
|
67
|
+
padding: var(--space-2) var(--space-2);
|
|
68
|
+
background: var(--color-bg-subtle);
|
|
69
|
+
border: 1px solid var(--color-border-default);
|
|
70
|
+
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
|
71
|
+
font-family: inherit;
|
|
72
|
+
font-size: var(--text-xs);
|
|
73
|
+
color: var(--color-text-secondary);
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
transition: background 150ms;
|
|
76
|
+
min-width: 60px;
|
|
77
|
+
justify-content: center;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* When progress bar is shown, remove bottom radius */
|
|
81
|
+
.buttonGroup[data-has-progress="true"] .dropdownTrigger {
|
|
82
|
+
border-radius: 0 var(--radius-md) 0 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.dropdownTrigger:hover:not(:disabled) {
|
|
86
|
+
background: var(--color-bg-muted);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.dropdownTrigger:disabled {
|
|
90
|
+
opacity: 0.6;
|
|
91
|
+
cursor: not-allowed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.intervalLabel {
|
|
95
|
+
font-weight: var(--font-medium);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.chevron {
|
|
99
|
+
font-size: 8px;
|
|
100
|
+
color: var(--color-text-muted);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Progress bar */
|
|
104
|
+
.progressBar {
|
|
105
|
+
width: 100%;
|
|
106
|
+
height: 6px;
|
|
107
|
+
background: var(--color-bg-muted);
|
|
108
|
+
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
border: 1px solid var(--color-border-default);
|
|
111
|
+
border-top: none;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.progressFill {
|
|
115
|
+
height: 100%;
|
|
116
|
+
background: linear-gradient(90deg, var(--color-intent-primary), var(--color-indigo-400));
|
|
117
|
+
transition: width 100ms linear;
|
|
118
|
+
min-width: 2px; /* Ensure always visible */
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Dropdown menu */
|
|
122
|
+
.dropdown {
|
|
123
|
+
position: absolute;
|
|
124
|
+
top: calc(100% + var(--space-1));
|
|
125
|
+
right: 0;
|
|
126
|
+
min-width: 120px;
|
|
127
|
+
background: var(--color-bg-elevated);
|
|
128
|
+
border: 1px solid var(--color-border-default);
|
|
129
|
+
border-radius: var(--radius-md);
|
|
130
|
+
box-shadow: var(--shadow-lg);
|
|
131
|
+
z-index: 100;
|
|
132
|
+
overflow: hidden;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.dropdownItem {
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: space-between;
|
|
139
|
+
width: 100%;
|
|
140
|
+
padding: var(--space-2) var(--space-3);
|
|
141
|
+
background: transparent;
|
|
142
|
+
border: none;
|
|
143
|
+
font-family: inherit;
|
|
144
|
+
font-size: var(--text-sm);
|
|
145
|
+
color: var(--color-text-primary);
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
transition: background 150ms;
|
|
148
|
+
text-align: left;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.dropdownItem:hover {
|
|
152
|
+
background: var(--color-bg-subtle);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.dropdownItem.selected {
|
|
156
|
+
background: var(--color-intent-primary-bg);
|
|
157
|
+
color: var(--color-intent-primary);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.checkmark {
|
|
161
|
+
font-size: var(--text-xs);
|
|
162
|
+
color: var(--color-intent-primary);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* Dropdown divider */
|
|
166
|
+
.dropdownDivider {
|
|
167
|
+
height: 1px;
|
|
168
|
+
background: var(--color-border-default);
|
|
169
|
+
margin: var(--space-1) 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Check Now button in dropdown */
|
|
173
|
+
.checkNow {
|
|
174
|
+
color: var(--color-text-secondary);
|
|
175
|
+
font-weight: var(--font-medium);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.checkNow:hover {
|
|
179
|
+
background: var(--color-bg-muted);
|
|
180
|
+
color: var(--color-text-primary);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.checkNow:disabled {
|
|
184
|
+
opacity: 0.6;
|
|
185
|
+
cursor: not-allowed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* Responsive */
|
|
189
|
+
@media (max-width: 640px) {
|
|
190
|
+
.refreshButton span:not(.icon) {
|
|
191
|
+
display: none;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.refreshButton {
|
|
195
|
+
padding: var(--space-2);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.dropdownTrigger {
|
|
199
|
+
min-width: 50px;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { RefreshButton } from "./RefreshButton";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof RefreshButton> = {
|
|
5
|
+
title: "Components/RefreshButton",
|
|
6
|
+
component: RefreshButton,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: "centered",
|
|
9
|
+
},
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof RefreshButton>;
|
|
15
|
+
|
|
16
|
+
const mockRefresh = async () => {
|
|
17
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mockForceCheck = async () => {
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
onRefresh: mockRefresh,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const WithForceCheck: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
onRefresh: mockRefresh,
|
|
33
|
+
onForceCheck: mockForceCheck,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Disabled: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
onRefresh: mockRefresh,
|
|
40
|
+
disabled: true,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import styles from "./RefreshButton.module.css";
|
|
5
|
+
|
|
6
|
+
interface RefreshButtonProps {
|
|
7
|
+
onRefresh: () => Promise<void>;
|
|
8
|
+
onForceCheck?: () => Promise<void>;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type IntervalOption = {
|
|
13
|
+
label: string;
|
|
14
|
+
value: number | null; // null means manual only
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const INTERVAL_OPTIONS: IntervalOption[] = [
|
|
18
|
+
{ label: "Manual", value: null },
|
|
19
|
+
{ label: "10s", value: 10000 },
|
|
20
|
+
{ label: "30s", value: 30000 },
|
|
21
|
+
{ label: "1m", value: 60000 },
|
|
22
|
+
{ label: "5m", value: 300000 },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function RefreshButton({ onRefresh, onForceCheck, disabled }: RefreshButtonProps) {
|
|
26
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
27
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
28
|
+
const [isChecking, setIsChecking] = useState(false);
|
|
29
|
+
const [selectedInterval, setSelectedInterval] = useState<IntervalOption>(INTERVAL_OPTIONS[0]);
|
|
30
|
+
const [progress, setProgress] = useState(0);
|
|
31
|
+
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
|
32
|
+
|
|
33
|
+
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
34
|
+
const progressRef = useRef<NodeJS.Timeout | null>(null);
|
|
35
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const onRefreshRef = useRef(onRefresh);
|
|
37
|
+
const onForceCheckRef = useRef(onForceCheck);
|
|
38
|
+
|
|
39
|
+
// Keep refs updated
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
onRefreshRef.current = onRefresh;
|
|
42
|
+
onForceCheckRef.current = onForceCheck;
|
|
43
|
+
}, [onRefresh, onForceCheck]);
|
|
44
|
+
|
|
45
|
+
// Handle manual refresh
|
|
46
|
+
const handleRefresh = useCallback(async () => {
|
|
47
|
+
if (isRefreshing || disabled) return;
|
|
48
|
+
|
|
49
|
+
setIsRefreshing(true);
|
|
50
|
+
setProgress(0);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await onRefreshRef.current();
|
|
54
|
+
} finally {
|
|
55
|
+
setIsRefreshing(false);
|
|
56
|
+
// Reset progress timer if auto-refresh is active
|
|
57
|
+
if (selectedInterval.value) {
|
|
58
|
+
setProgress(0);
|
|
59
|
+
setTimeLeft(selectedInterval.value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}, [isRefreshing, disabled, selectedInterval.value]);
|
|
63
|
+
|
|
64
|
+
// Handle interval selection
|
|
65
|
+
const handleSelectInterval = (option: IntervalOption) => {
|
|
66
|
+
setSelectedInterval(option);
|
|
67
|
+
setIsOpen(false);
|
|
68
|
+
setProgress(0);
|
|
69
|
+
setTimeLeft(option.value);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Handle force check (runs actual health checks)
|
|
73
|
+
const handleForceCheck = useCallback(async () => {
|
|
74
|
+
if (isChecking || disabled || !onForceCheckRef.current) return;
|
|
75
|
+
|
|
76
|
+
setIsChecking(true);
|
|
77
|
+
setIsOpen(false);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await onForceCheckRef.current();
|
|
81
|
+
} finally {
|
|
82
|
+
setIsChecking(false);
|
|
83
|
+
}
|
|
84
|
+
}, [isChecking, disabled]);
|
|
85
|
+
|
|
86
|
+
// Auto-refresh logic
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
// Clear existing intervals
|
|
89
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
90
|
+
if (progressRef.current) clearInterval(progressRef.current);
|
|
91
|
+
|
|
92
|
+
if (!selectedInterval.value) {
|
|
93
|
+
setProgress(0);
|
|
94
|
+
setTimeLeft(null);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const intervalMs = selectedInterval.value;
|
|
99
|
+
setProgress(0);
|
|
100
|
+
setTimeLeft(intervalMs);
|
|
101
|
+
|
|
102
|
+
// Progress update every 100ms
|
|
103
|
+
const progressStep = 100 / (intervalMs / 100);
|
|
104
|
+
progressRef.current = setInterval(() => {
|
|
105
|
+
setProgress((prev) => {
|
|
106
|
+
const next = prev + progressStep;
|
|
107
|
+
return next >= 100 ? 0 : next; // Reset to 0 when reaching 100
|
|
108
|
+
});
|
|
109
|
+
setTimeLeft((prev) => {
|
|
110
|
+
if (prev === null) return intervalMs;
|
|
111
|
+
const next = prev - 100;
|
|
112
|
+
return next <= 0 ? intervalMs : next; // Reset to full when reaching 0
|
|
113
|
+
});
|
|
114
|
+
}, 100);
|
|
115
|
+
|
|
116
|
+
// Trigger refresh at interval
|
|
117
|
+
intervalRef.current = setInterval(async () => {
|
|
118
|
+
setIsRefreshing(true);
|
|
119
|
+
try {
|
|
120
|
+
await onRefreshRef.current();
|
|
121
|
+
} finally {
|
|
122
|
+
setIsRefreshing(false);
|
|
123
|
+
}
|
|
124
|
+
}, intervalMs);
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
128
|
+
if (progressRef.current) clearInterval(progressRef.current);
|
|
129
|
+
};
|
|
130
|
+
}, [selectedInterval.value]);
|
|
131
|
+
|
|
132
|
+
// Close dropdown on outside click
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
135
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
136
|
+
setIsOpen(false);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
141
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
const isAutoRefresh = selectedInterval.value !== null;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className={styles.container} ref={dropdownRef}>
|
|
148
|
+
<div className={styles.buttonGroup} data-has-progress={isAutoRefresh}>
|
|
149
|
+
<div className={styles.buttons}>
|
|
150
|
+
{/* Main refresh button */}
|
|
151
|
+
<button
|
|
152
|
+
className={styles.refreshButton}
|
|
153
|
+
onClick={handleRefresh}
|
|
154
|
+
disabled={isRefreshing || disabled}
|
|
155
|
+
title="Refresh now"
|
|
156
|
+
>
|
|
157
|
+
<span className={`${styles.icon} ${isRefreshing ? styles.spinning : ""}`}>
|
|
158
|
+
↻
|
|
159
|
+
</span>
|
|
160
|
+
{isRefreshing ? "Refreshing..." : "Refresh"}
|
|
161
|
+
</button>
|
|
162
|
+
|
|
163
|
+
{/* Dropdown trigger */}
|
|
164
|
+
<button
|
|
165
|
+
className={styles.dropdownTrigger}
|
|
166
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
title="Set auto-refresh interval"
|
|
169
|
+
>
|
|
170
|
+
<span className={styles.intervalLabel}>
|
|
171
|
+
{selectedInterval.label}
|
|
172
|
+
</span>
|
|
173
|
+
<span className={styles.chevron}>{isOpen ? "▲" : "▼"}</span>
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Progress bar - only shows when auto-refresh is active */}
|
|
178
|
+
{isAutoRefresh && (
|
|
179
|
+
<div className={styles.progressBar}>
|
|
180
|
+
<div
|
|
181
|
+
className={styles.progressFill}
|
|
182
|
+
style={{ width: `${progress}%` }}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Dropdown menu */}
|
|
189
|
+
{isOpen && (
|
|
190
|
+
<div className={styles.dropdown}>
|
|
191
|
+
{INTERVAL_OPTIONS.map((option) => (
|
|
192
|
+
<button
|
|
193
|
+
key={option.label}
|
|
194
|
+
className={`${styles.dropdownItem} ${
|
|
195
|
+
selectedInterval.label === option.label ? styles.selected : ""
|
|
196
|
+
}`}
|
|
197
|
+
onClick={() => handleSelectInterval(option)}
|
|
198
|
+
>
|
|
199
|
+
{option.label}
|
|
200
|
+
{selectedInterval.label === option.label && (
|
|
201
|
+
<span className={styles.checkmark}>✓</span>
|
|
202
|
+
)}
|
|
203
|
+
</button>
|
|
204
|
+
))}
|
|
205
|
+
{onForceCheck && (
|
|
206
|
+
<>
|
|
207
|
+
<div className={styles.dropdownDivider} />
|
|
208
|
+
<button
|
|
209
|
+
className={`${styles.dropdownItem} ${styles.checkNow}`}
|
|
210
|
+
onClick={handleForceCheck}
|
|
211
|
+
disabled={isChecking}
|
|
212
|
+
>
|
|
213
|
+
{isChecking ? "Checking..." : "Check Now"}
|
|
214
|
+
</button>
|
|
215
|
+
</>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.list {
|
|
7
|
+
display: flex;
|
|
8
|
+
gap: var(--space-1);
|
|
9
|
+
border-bottom: 1px solid var(--color-border-default);
|
|
10
|
+
margin-bottom: var(--space-4);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.trigger {
|
|
14
|
+
padding: var(--space-2) var(--space-4);
|
|
15
|
+
font-size: var(--text-sm);
|
|
16
|
+
font-weight: var(--font-medium);
|
|
17
|
+
color: var(--color-text-secondary);
|
|
18
|
+
background: transparent;
|
|
19
|
+
border: none;
|
|
20
|
+
border-bottom: 2px solid transparent;
|
|
21
|
+
margin-bottom: -1px;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
transition: color 150ms ease, border-color 150ms ease;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.trigger:hover:not(:disabled) {
|
|
27
|
+
color: var(--color-text-primary);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.trigger:focus-visible {
|
|
31
|
+
outline: none;
|
|
32
|
+
box-shadow: var(--focus-ring);
|
|
33
|
+
border-radius: var(--radius-sm);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.trigger[data-state="active"] {
|
|
37
|
+
color: var(--color-intent-primary);
|
|
38
|
+
border-bottom-color: var(--color-intent-primary);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.trigger:disabled {
|
|
42
|
+
color: var(--color-text-muted);
|
|
43
|
+
cursor: not-allowed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.content {
|
|
47
|
+
outline: none;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.content:focus-visible {
|
|
51
|
+
box-shadow: var(--focus-ring);
|
|
52
|
+
border-radius: var(--radius-sm);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.content[data-state="inactive"] {
|
|
56
|
+
display: none;
|
|
57
|
+
}
|
|
58
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Tabs } from "./Tabs";
|
|
3
|
+
import { Card } from "../Card";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Tabs> = {
|
|
6
|
+
title: "Components/Tabs",
|
|
7
|
+
component: Tabs,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "centered",
|
|
10
|
+
},
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
decorators: [
|
|
13
|
+
(Story) => (
|
|
14
|
+
<div style={{ width: "500px" }}>
|
|
15
|
+
<Story />
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
tabs: [
|
|
27
|
+
{
|
|
28
|
+
id: "account",
|
|
29
|
+
label: "Account",
|
|
30
|
+
content: (
|
|
31
|
+
<Card>
|
|
32
|
+
<h3>Account Settings</h3>
|
|
33
|
+
<p style={{ marginTop: "8px" }}>
|
|
34
|
+
Manage your account settings and preferences.
|
|
35
|
+
</p>
|
|
36
|
+
</Card>
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "password",
|
|
41
|
+
label: "Password",
|
|
42
|
+
content: (
|
|
43
|
+
<Card>
|
|
44
|
+
<h3>Password Settings</h3>
|
|
45
|
+
<p style={{ marginTop: "8px" }}>
|
|
46
|
+
Update your password and security options.
|
|
47
|
+
</p>
|
|
48
|
+
</Card>
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "notifications",
|
|
53
|
+
label: "Notifications",
|
|
54
|
+
content: (
|
|
55
|
+
<Card>
|
|
56
|
+
<h3>Notification Preferences</h3>
|
|
57
|
+
<p style={{ marginTop: "8px" }}>
|
|
58
|
+
Configure how you receive notifications.
|
|
59
|
+
</p>
|
|
60
|
+
</Card>
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const WithDisabledTab: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
tabs: [
|
|
70
|
+
{
|
|
71
|
+
id: "active",
|
|
72
|
+
label: "Active",
|
|
73
|
+
content: <Card>This tab is active and clickable.</Card>,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "disabled",
|
|
77
|
+
label: "Disabled",
|
|
78
|
+
content: <Card>You shouldn't see this.</Card>,
|
|
79
|
+
disabled: true,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "another",
|
|
83
|
+
label: "Another",
|
|
84
|
+
content: <Card>This tab is also active.</Card>,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const ManyTabs: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
tabs: [
|
|
93
|
+
{ id: "1", label: "Overview", content: <Card>Overview content</Card> },
|
|
94
|
+
{ id: "2", label: "Analytics", content: <Card>Analytics content</Card> },
|
|
95
|
+
{ id: "3", label: "Reports", content: <Card>Reports content</Card> },
|
|
96
|
+
{ id: "4", label: "Settings", content: <Card>Settings content</Card> },
|
|
97
|
+
{ id: "5", label: "Users", content: <Card>Users content</Card> },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type ReactNode } from "react";
|
|
4
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
5
|
+
import { clsx } from "clsx";
|
|
6
|
+
import styles from "./Tabs.module.css";
|
|
7
|
+
|
|
8
|
+
export interface TabItem {
|
|
9
|
+
id: string;
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
content: ReactNode;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TabsProps {
|
|
16
|
+
tabs: TabItem[];
|
|
17
|
+
defaultValue?: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
onValueChange?: (value: string) => void;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
|
24
|
+
({ tabs, defaultValue, value, onValueChange, className }, ref) => {
|
|
25
|
+
const defaultTab = defaultValue || tabs[0]?.id;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<TabsPrimitive.Root
|
|
29
|
+
ref={ref}
|
|
30
|
+
defaultValue={defaultTab}
|
|
31
|
+
value={value}
|
|
32
|
+
onValueChange={onValueChange}
|
|
33
|
+
className={clsx(styles.root, className)}
|
|
34
|
+
>
|
|
35
|
+
<TabsPrimitive.List className={styles.list}>
|
|
36
|
+
{tabs.map((tab) => (
|
|
37
|
+
<TabsPrimitive.Trigger
|
|
38
|
+
key={tab.id}
|
|
39
|
+
value={tab.id}
|
|
40
|
+
disabled={tab.disabled}
|
|
41
|
+
className={styles.trigger}
|
|
42
|
+
>
|
|
43
|
+
{tab.label}
|
|
44
|
+
</TabsPrimitive.Trigger>
|
|
45
|
+
))}
|
|
46
|
+
</TabsPrimitive.List>
|
|
47
|
+
{tabs.map((tab) => (
|
|
48
|
+
<TabsPrimitive.Content
|
|
49
|
+
key={tab.id}
|
|
50
|
+
value={tab.id}
|
|
51
|
+
className={styles.content}
|
|
52
|
+
>
|
|
53
|
+
{tab.content}
|
|
54
|
+
</TabsPrimitive.Content>
|
|
55
|
+
))}
|
|
56
|
+
</TabsPrimitive.Root>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
Tabs.displayName = "Tabs";
|
|
62
|
+
|