@stasho/ds 0.14.0 → 0.15.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stasho/ds",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "stasho design system",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"./loader": "./src/components/loader/loader.tsx",
|
|
57
57
|
"./popover": "./src/components/popover/popover.tsx",
|
|
58
58
|
"./dropdown-menu": "./src/components/dropdown-menu/dropdown-menu.tsx",
|
|
59
|
+
"./project-switcher": "./src/components/project-switcher/project-switcher.tsx",
|
|
59
60
|
"./sidebar": "./src/components/sidebar/sidebar.tsx",
|
|
60
61
|
"./header": "./src/components/header/header.tsx",
|
|
61
62
|
"./lib/cn": "./src/lib/cn.ts",
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { forwardRef, useState } from "react";
|
|
2
|
+
import { Popover } from "radix-ui";
|
|
3
|
+
import { Command } from "cmdk";
|
|
4
|
+
import {
|
|
5
|
+
CaretDown,
|
|
6
|
+
Check,
|
|
7
|
+
List,
|
|
8
|
+
Plus,
|
|
9
|
+
SquaresFour,
|
|
10
|
+
} from "@phosphor-icons/react";
|
|
11
|
+
import { cn } from "@ac/lib/cn";
|
|
12
|
+
|
|
13
|
+
type ProjectSwitcherItem = {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** Extra search terms (e.g. full repo name, stored project name). */
|
|
17
|
+
keywords?: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ProjectSwitcherGroup = {
|
|
21
|
+
/** Stable key — labels are not unique (encrypted placeholders collide). */
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
items: ProjectSwitcherItem[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ProjectSwitcherProps = {
|
|
28
|
+
groups: ProjectSwitcherGroup[];
|
|
29
|
+
solos: ProjectSwitcherItem[];
|
|
30
|
+
currentId: string;
|
|
31
|
+
triggerLabel: string;
|
|
32
|
+
collapsed?: boolean;
|
|
33
|
+
onSelect: (id: string) => void;
|
|
34
|
+
onViewAll: () => void;
|
|
35
|
+
onNewProject: () => void;
|
|
36
|
+
searchPlaceholder?: string;
|
|
37
|
+
emptyMessage?: string;
|
|
38
|
+
viewAllLabel?: string;
|
|
39
|
+
newProjectLabel?: string;
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function itemMatches(item: ProjectSwitcherItem, needle: string): boolean {
|
|
44
|
+
if (item.label.toLowerCase().includes(needle)) return true;
|
|
45
|
+
return (item.keywords ?? []).some((k) => k.toLowerCase().includes(needle));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const itemClasses = (isCurrent: boolean, indent: boolean) =>
|
|
49
|
+
cn(
|
|
50
|
+
"flex cursor-pointer select-none items-center gap-2 rounded-sm",
|
|
51
|
+
"px-3 py-2 text-sm text-foreground outline-none",
|
|
52
|
+
"data-[selected=true]:bg-muted",
|
|
53
|
+
indent && "pl-6",
|
|
54
|
+
isCurrent && "font-semibold",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const actionClasses = cn(
|
|
58
|
+
"flex cursor-pointer select-none items-center gap-2 rounded-sm",
|
|
59
|
+
"px-3 py-2 text-sm text-foreground outline-none",
|
|
60
|
+
"data-[selected=true]:bg-muted",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const ProjectSwitcher = forwardRef<HTMLButtonElement, ProjectSwitcherProps>(
|
|
64
|
+
(
|
|
65
|
+
{
|
|
66
|
+
groups,
|
|
67
|
+
solos,
|
|
68
|
+
currentId,
|
|
69
|
+
triggerLabel,
|
|
70
|
+
collapsed = false,
|
|
71
|
+
onSelect,
|
|
72
|
+
onViewAll,
|
|
73
|
+
onNewProject,
|
|
74
|
+
searchPlaceholder = "Search projects…",
|
|
75
|
+
emptyMessage = "No matches",
|
|
76
|
+
viewAllLabel = "View all projects",
|
|
77
|
+
newProjectLabel = "New project",
|
|
78
|
+
className,
|
|
79
|
+
},
|
|
80
|
+
ref,
|
|
81
|
+
) => {
|
|
82
|
+
const [open, setOpen] = useState(false);
|
|
83
|
+
const [query, setQuery] = useState("");
|
|
84
|
+
const needle = query.trim().toLowerCase();
|
|
85
|
+
|
|
86
|
+
// Group-first filtering: a group stays WHOLE when its own label or any
|
|
87
|
+
// child matches; solos filter individually. Manual (shouldFilter=false)
|
|
88
|
+
// because cmdk's scorer would re-rank rows and break the caller's
|
|
89
|
+
// deterministic ordering.
|
|
90
|
+
const visibleGroups = needle
|
|
91
|
+
? groups.filter(
|
|
92
|
+
(g) =>
|
|
93
|
+
g.label.toLowerCase().includes(needle) ||
|
|
94
|
+
g.items.some((i) => itemMatches(i, needle)),
|
|
95
|
+
)
|
|
96
|
+
: groups;
|
|
97
|
+
const visibleSolos = needle
|
|
98
|
+
? solos.filter((i) => itemMatches(i, needle))
|
|
99
|
+
: solos;
|
|
100
|
+
const nothingMatches =
|
|
101
|
+
visibleGroups.length === 0 && visibleSolos.length === 0;
|
|
102
|
+
|
|
103
|
+
const close = () => {
|
|
104
|
+
setOpen(false);
|
|
105
|
+
setQuery("");
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const renderItem = (item: ProjectSwitcherItem, indent: boolean) => (
|
|
109
|
+
<Command.Item
|
|
110
|
+
key={item.id}
|
|
111
|
+
value={item.id}
|
|
112
|
+
onSelect={() => {
|
|
113
|
+
close();
|
|
114
|
+
onSelect(item.id);
|
|
115
|
+
}}
|
|
116
|
+
aria-current={item.id === currentId || undefined}
|
|
117
|
+
className={itemClasses(item.id === currentId, indent)}
|
|
118
|
+
>
|
|
119
|
+
<span className="truncate">{item.label}</span>
|
|
120
|
+
{item.id === currentId && (
|
|
121
|
+
<Check
|
|
122
|
+
weight="bold"
|
|
123
|
+
className="ml-auto size-3.5 shrink-0 text-accent"
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
</Command.Item>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Popover.Root
|
|
132
|
+
open={open}
|
|
133
|
+
onOpenChange={(next) => {
|
|
134
|
+
setOpen(next);
|
|
135
|
+
if (!next) setQuery("");
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<Popover.Trigger
|
|
139
|
+
ref={ref}
|
|
140
|
+
aria-label={triggerLabel}
|
|
141
|
+
title={collapsed ? triggerLabel : undefined}
|
|
142
|
+
className={cn(
|
|
143
|
+
"flex w-full items-center rounded-md",
|
|
144
|
+
"text-sm font-semibold text-foreground",
|
|
145
|
+
"hover:bg-muted",
|
|
146
|
+
"focus-visible:outline-none focus-visible:ring-2",
|
|
147
|
+
"focus-visible:ring-accent",
|
|
148
|
+
collapsed
|
|
149
|
+
? "h-9 justify-center px-0"
|
|
150
|
+
: "gap-2 border border-edge bg-surface px-3 py-2",
|
|
151
|
+
className,
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
{collapsed ? (
|
|
155
|
+
<SquaresFour size={20} aria-hidden="true" />
|
|
156
|
+
) : (
|
|
157
|
+
<>
|
|
158
|
+
<span className="flex-1 truncate text-left">{triggerLabel}</span>
|
|
159
|
+
<CaretDown
|
|
160
|
+
size={12}
|
|
161
|
+
aria-hidden="true"
|
|
162
|
+
className="shrink-0 text-foreground/60"
|
|
163
|
+
/>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
</Popover.Trigger>
|
|
167
|
+
<Popover.Portal>
|
|
168
|
+
<Popover.Content
|
|
169
|
+
align="start"
|
|
170
|
+
sideOffset={4}
|
|
171
|
+
className={cn(
|
|
172
|
+
"z-50 w-72 overflow-hidden rounded-sm",
|
|
173
|
+
"bg-popover-bg border border-popover-border shadow",
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
<Command shouldFilter={false}>
|
|
177
|
+
<Command.Input
|
|
178
|
+
value={query}
|
|
179
|
+
onValueChange={setQuery}
|
|
180
|
+
placeholder={searchPlaceholder}
|
|
181
|
+
className={cn(
|
|
182
|
+
"w-full border-b border-edge bg-transparent px-4 py-2.5",
|
|
183
|
+
"text-sm text-foreground placeholder:text-muted-foreground",
|
|
184
|
+
"outline-none",
|
|
185
|
+
)}
|
|
186
|
+
/>
|
|
187
|
+
<Command.List className="max-h-64 overflow-y-auto p-1">
|
|
188
|
+
{nothingMatches && (
|
|
189
|
+
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
190
|
+
{emptyMessage}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
{visibleGroups.map((g) => (
|
|
194
|
+
<Command.Group
|
|
195
|
+
key={g.id}
|
|
196
|
+
value={g.id}
|
|
197
|
+
heading={g.label}
|
|
198
|
+
className={cn(
|
|
199
|
+
"[&_[cmdk-group-heading]]:px-3",
|
|
200
|
+
"[&_[cmdk-group-heading]]:pb-1",
|
|
201
|
+
"[&_[cmdk-group-heading]]:pt-2",
|
|
202
|
+
"[&_[cmdk-group-heading]]:text-[11px]",
|
|
203
|
+
"[&_[cmdk-group-heading]]:uppercase",
|
|
204
|
+
"[&_[cmdk-group-heading]]:tracking-wider",
|
|
205
|
+
"[&_[cmdk-group-heading]]:text-muted-foreground",
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
{g.items.map((i) => renderItem(i, true))}
|
|
209
|
+
</Command.Group>
|
|
210
|
+
))}
|
|
211
|
+
{visibleSolos.map((i) => renderItem(i, false))}
|
|
212
|
+
<Command.Separator
|
|
213
|
+
alwaysRender
|
|
214
|
+
className="my-1 h-px bg-edge"
|
|
215
|
+
/>
|
|
216
|
+
<Command.Item
|
|
217
|
+
value="__view-all"
|
|
218
|
+
onSelect={() => {
|
|
219
|
+
close();
|
|
220
|
+
onViewAll();
|
|
221
|
+
}}
|
|
222
|
+
className={actionClasses}
|
|
223
|
+
>
|
|
224
|
+
<List size={14} aria-hidden="true" />
|
|
225
|
+
{viewAllLabel}
|
|
226
|
+
</Command.Item>
|
|
227
|
+
<Command.Item
|
|
228
|
+
value="__new-project"
|
|
229
|
+
onSelect={() => {
|
|
230
|
+
close();
|
|
231
|
+
onNewProject();
|
|
232
|
+
}}
|
|
233
|
+
className={actionClasses}
|
|
234
|
+
>
|
|
235
|
+
<Plus size={14} aria-hidden="true" />
|
|
236
|
+
{newProjectLabel}
|
|
237
|
+
</Command.Item>
|
|
238
|
+
</Command.List>
|
|
239
|
+
</Command>
|
|
240
|
+
</Popover.Content>
|
|
241
|
+
</Popover.Portal>
|
|
242
|
+
</Popover.Root>
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
ProjectSwitcher.displayName = "ProjectSwitcher";
|
|
248
|
+
|
|
249
|
+
export {
|
|
250
|
+
ProjectSwitcher,
|
|
251
|
+
type ProjectSwitcherProps,
|
|
252
|
+
type ProjectSwitcherGroup,
|
|
253
|
+
type ProjectSwitcherItem,
|
|
254
|
+
};
|