@stasho/ds 0.14.0 → 0.15.1

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.14.0",
3
+ "version": "0.15.1",
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
+ };
@@ -9,7 +9,11 @@ import {
9
9
  type MouseEvent,
10
10
  type ReactNode,
11
11
  } from "react";
12
- import { CaretDoubleLeft, CaretDoubleRight } from "@phosphor-icons/react";
12
+ import {
13
+ CaretDoubleLeft,
14
+ CaretDoubleRight,
15
+ CaretRight,
16
+ } from "@phosphor-icons/react";
13
17
  import { LogoLetter, LogoWordmark } from "@ac/components/logo/logo";
14
18
  import {
15
19
  Tooltip,
@@ -167,12 +171,16 @@ export function SidebarNav({
167
171
 
168
172
  export interface SidebarSectionProps {
169
173
  title?: string;
174
+ titleHref?: string;
175
+ onTitleClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
170
176
  children: ReactNode;
171
177
  className?: string;
172
178
  }
173
179
 
174
180
  export function SidebarSection({
175
181
  title,
182
+ titleHref,
183
+ onTitleClick,
176
184
  children,
177
185
  className,
178
186
  }: SidebarSectionProps) {
@@ -183,7 +191,24 @@ export function SidebarSection({
183
191
  aria-label={title ?? "Section"}
184
192
  className={cn("flex flex-col gap-1", className)}
185
193
  >
186
- {title && !collapsed && (
194
+ {title && !collapsed && titleHref && (
195
+ <a
196
+ href={titleHref}
197
+ onClick={onTitleClick}
198
+ className={cn(
199
+ "flex items-center gap-0.5 rounded-sm px-2 pb-1 pt-2",
200
+ "text-[10px] font-semibold uppercase tracking-wider text-foreground/50",
201
+ "transition-colors duration-150 hover:text-foreground",
202
+ "focus-visible:outline-none focus-visible:ring-2",
203
+ "focus-visible:ring-accent focus-visible:ring-offset-2",
204
+ "motion-reduce:transition-none",
205
+ )}
206
+ >
207
+ {title}
208
+ <CaretRight size={10} aria-hidden="true" />
209
+ </a>
210
+ )}
211
+ {title && !collapsed && !titleHref && (
187
212
  <div className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-foreground/50">
188
213
  {title}
189
214
  </div>