@xenide-io/the-old-ui-theme 0.5.3 → 0.5.4
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/dist/{index-Od4pIP4F.d.mts → Chip-C5hRFAhe.d.mts} +10 -606
- package/dist/{index-Od4pIP4F.d.ts → Chip-C5hRFAhe.d.ts} +10 -606
- package/dist/{chunk-G53YLHVE.mjs → chunk-E5ZBQYEX.mjs} +59 -9
- package/dist/{chunk-VFTZXPXM.mjs → chunk-SBBKXV35.mjs} +144 -79
- package/dist/index.d.mts +3 -79
- package/dist/index.d.ts +3 -79
- package/dist/index.js +524 -3595
- package/dist/index.mjs +3 -765
- package/dist/suite.d.mts +38 -12
- package/dist/suite.d.ts +38 -12
- package/dist/suite.js +84 -15
- package/dist/suite.mjs +53 -13
- package/dist/ui.d.mts +608 -2
- package/dist/ui.d.ts +608 -2
- package/dist/ui.js +202 -87
- package/dist/ui.mjs +2 -2
- package/package.json +2 -6
- package/scripts/install-theme-dev.sh +60 -0
- package/scripts/sync-posthog-icons.mjs +75 -0
- package/src/components/ui/Chip.tsx +36 -18
- package/src/components/ui/CommandPalette.test.tsx +41 -0
- package/src/components/ui/CommandPalette.tsx +58 -9
- package/src/components/ui/DropdownMenu.tsx +50 -11
- package/src/components/ui/SegmentedControl.tsx +2 -1
- package/src/components/ui/SettingsLayout.tsx +19 -5
- package/src/components/ui/interaction-primitives.test.tsx +52 -0
- package/src/styles/suite-skin.css +17 -35
- package/src/suite/components/app-switcher.tsx +32 -25
- package/src/suite/components/command-palette-host.tsx +20 -8
- package/src/suite/components/suite-app-layout.tsx +1 -1
- package/src/suite/components/suite-bottom-nav.tsx +23 -18
- package/src/suite/components/suite-layout.test.tsx +44 -0
- package/src/suite/components/suite-layout.tsx +3 -3
- package/src/suite/components/suite-mobile-drawer.tsx +12 -0
- package/src/suite/components/suite-mobile-header.tsx +15 -9
- package/src/suite/components/suite-notification-bell.tsx +23 -11
- package/src/suite/components/suite-settings-mobile-nav.tsx +2 -0
- package/src/suite/components/suite-sidebar.tsx +6 -3
- package/src/suite/components/suite-user-menu.tsx +6 -0
- package/src/suite/lib/injected.ts +12 -11
- package/src/components/charts/index.ts +0 -9
- package/src/components/charts/quill/BarChart.tsx +0 -85
- package/src/components/charts/quill/FunnelChart.tsx +0 -49
- package/src/components/charts/quill/InsightShell.tsx +0 -27
- package/src/components/charts/quill/MetricCard.tsx +0 -44
- package/src/components/charts/quill/PieChart.tsx +0 -81
- package/src/components/charts/quill/Sparkline.tsx +0 -57
- package/src/components/charts/quill/TimeSeriesLineChart.tsx +0 -62
- package/src/components/charts/quill/index.ts +0 -9
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { CommandPalette } from "@/components/ui/CommandPalette";
|
|
6
|
+
|
|
7
|
+
describe("command palette", () => {
|
|
8
|
+
it("behaves as a modal dialog and restores focus when dismissed", async () => {
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
const onSelect = vi.fn();
|
|
11
|
+
|
|
12
|
+
function Fixture() {
|
|
13
|
+
const [open, setOpen] = useState(false);
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<button type="button" onClick={() => setOpen(true)}>
|
|
17
|
+
Open commands
|
|
18
|
+
</button>
|
|
19
|
+
<CommandPalette
|
|
20
|
+
isOpen={open}
|
|
21
|
+
onClose={() => setOpen(false)}
|
|
22
|
+
items={[
|
|
23
|
+
{ id: "reports", label: "Open reports", onSelect },
|
|
24
|
+
]}
|
|
25
|
+
/>
|
|
26
|
+
</>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
render(<Fixture />);
|
|
31
|
+
const trigger = screen.getByRole("button", { name: "Open commands" });
|
|
32
|
+
await user.click(trigger);
|
|
33
|
+
|
|
34
|
+
expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByRole("textbox", { name: "Search commands" })).toHaveFocus();
|
|
36
|
+
|
|
37
|
+
await user.keyboard("{Escape}");
|
|
38
|
+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
39
|
+
expect(trigger).toHaveFocus();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -18,6 +18,10 @@ interface CommandPaletteProps {
|
|
|
18
18
|
onClose: () => void;
|
|
19
19
|
placeholder?: string;
|
|
20
20
|
className?: string;
|
|
21
|
+
id?: string;
|
|
22
|
+
dataTest?: string;
|
|
23
|
+
inputId?: string;
|
|
24
|
+
inputDataTest?: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function CommandPalette({
|
|
@@ -26,21 +30,32 @@ export function CommandPalette({
|
|
|
26
30
|
onClose,
|
|
27
31
|
placeholder = "Type a command or search...",
|
|
28
32
|
className,
|
|
33
|
+
id,
|
|
34
|
+
dataTest,
|
|
35
|
+
inputId,
|
|
36
|
+
inputDataTest,
|
|
29
37
|
}: CommandPaletteProps) {
|
|
30
38
|
const [query, setQuery] = useState("");
|
|
31
39
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
32
40
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
41
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
33
43
|
|
|
34
44
|
const filtered = items.filter((item) =>
|
|
35
|
-
item.label.toLowerCase().includes(query.toLowerCase())
|
|
45
|
+
item.label.toLowerCase().includes(query.toLowerCase()),
|
|
36
46
|
);
|
|
37
47
|
|
|
38
48
|
useEffect(() => {
|
|
39
|
-
if (isOpen)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
if (!isOpen) return;
|
|
50
|
+
previousFocusRef.current =
|
|
51
|
+
document.activeElement instanceof HTMLElement
|
|
52
|
+
? document.activeElement
|
|
53
|
+
: null;
|
|
54
|
+
setQuery("");
|
|
55
|
+
setSelectedIndex(0);
|
|
56
|
+
inputRef.current?.focus();
|
|
57
|
+
|
|
58
|
+
return () => previousFocusRef.current?.focus();
|
|
44
59
|
}, [isOpen]);
|
|
45
60
|
|
|
46
61
|
useEffect(() => {
|
|
@@ -49,10 +64,12 @@ export function CommandPalette({
|
|
|
49
64
|
|
|
50
65
|
switch (e.key) {
|
|
51
66
|
case "ArrowDown":
|
|
67
|
+
if (!filtered.length) break;
|
|
52
68
|
e.preventDefault();
|
|
53
69
|
setSelectedIndex((i) => (i + 1) % filtered.length);
|
|
54
70
|
break;
|
|
55
71
|
case "ArrowUp":
|
|
72
|
+
if (!filtered.length) break;
|
|
56
73
|
e.preventDefault();
|
|
57
74
|
setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
|
58
75
|
break;
|
|
@@ -64,6 +81,22 @@ export function CommandPalette({
|
|
|
64
81
|
case "Escape":
|
|
65
82
|
onClose();
|
|
66
83
|
break;
|
|
84
|
+
case "Tab": {
|
|
85
|
+
const focusable = panelRef.current?.querySelectorAll<HTMLElement>(
|
|
86
|
+
'input, button, [href], [tabindex]:not([tabindex="-1"])',
|
|
87
|
+
);
|
|
88
|
+
if (!focusable?.length) break;
|
|
89
|
+
const first = focusable[0];
|
|
90
|
+
const last = focusable[focusable.length - 1];
|
|
91
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
last.focus();
|
|
94
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
first.focus();
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
67
100
|
}
|
|
68
101
|
};
|
|
69
102
|
|
|
@@ -74,18 +107,32 @@ export function CommandPalette({
|
|
|
74
107
|
if (!isOpen) return null;
|
|
75
108
|
|
|
76
109
|
return (
|
|
77
|
-
<div
|
|
110
|
+
<div
|
|
111
|
+
id={id}
|
|
112
|
+
data-test={dataTest}
|
|
113
|
+
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[20vh] backdrop-blur-sm"
|
|
114
|
+
onMouseDown={(event) => {
|
|
115
|
+
if (event.target === event.currentTarget) onClose();
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
78
118
|
<div
|
|
119
|
+
ref={panelRef}
|
|
120
|
+
role="dialog"
|
|
121
|
+
aria-modal="true"
|
|
122
|
+
aria-label="Command palette"
|
|
79
123
|
className={cn(
|
|
80
124
|
"w-full max-w-xl overflow-hidden rounded-xl border border-ph-border bg-ph-surface shadow-ph-md",
|
|
81
|
-
className
|
|
125
|
+
className,
|
|
82
126
|
)}
|
|
83
127
|
>
|
|
84
128
|
<div className="flex items-center gap-3 border-b border-ph-border px-4 py-3">
|
|
85
129
|
<IconSearch className="h-5 w-5 text-ph-mutedtext" />
|
|
86
130
|
<input
|
|
87
131
|
ref={inputRef}
|
|
132
|
+
id={inputId}
|
|
133
|
+
data-test={inputDataTest}
|
|
88
134
|
type="text"
|
|
135
|
+
aria-label="Search commands"
|
|
89
136
|
value={query}
|
|
90
137
|
onChange={(e) => {
|
|
91
138
|
setQuery(e.target.value);
|
|
@@ -108,6 +155,8 @@ export function CommandPalette({
|
|
|
108
155
|
<button
|
|
109
156
|
key={item.id}
|
|
110
157
|
type="button"
|
|
158
|
+
id={`command-${item.id}`}
|
|
159
|
+
data-test={`command-${item.id}`}
|
|
111
160
|
onClick={() => {
|
|
112
161
|
item.onSelect();
|
|
113
162
|
onClose();
|
|
@@ -117,7 +166,7 @@ export function CommandPalette({
|
|
|
117
166
|
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
|
|
118
167
|
index === selectedIndex
|
|
119
168
|
? "bg-ph-muted text-ph-ink"
|
|
120
|
-
: "text-ph-subtle"
|
|
169
|
+
: "text-ph-subtle",
|
|
121
170
|
)}
|
|
122
171
|
>
|
|
123
172
|
{item.icon && <span className="h-5 w-5">{item.icon}</span>}
|
|
@@ -35,11 +35,14 @@ interface DropdownOpenProps {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface DropdownMenuProps
|
|
38
|
-
extends
|
|
38
|
+
extends
|
|
39
|
+
Omit<HTMLAttributes<HTMLDivElement>, "children">,
|
|
39
40
|
DropdownPlacementProps,
|
|
40
41
|
DropdownOpenProps {
|
|
41
42
|
/** Custom non-interactive trigger node, such as an avatar or icon chip. */
|
|
42
43
|
trigger: ReactNode;
|
|
44
|
+
triggerId?: string;
|
|
45
|
+
triggerDataTest?: string;
|
|
43
46
|
/** Accessible name when the trigger has no visible text. */
|
|
44
47
|
"aria-label": string;
|
|
45
48
|
children: ReactNode;
|
|
@@ -47,7 +50,8 @@ export interface DropdownMenuProps
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
export interface DropdownButtonProps
|
|
50
|
-
extends
|
|
53
|
+
extends
|
|
54
|
+
Omit<HTMLAttributes<HTMLDivElement>, "children">,
|
|
51
55
|
DropdownPlacementProps,
|
|
52
56
|
DropdownOpenProps {
|
|
53
57
|
/** Visible button label. */
|
|
@@ -67,8 +71,12 @@ export interface DropdownButtonProps
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
export type DropdownItemProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
|
70
|
-
export type DropdownRadioGroupProps = ComponentPropsWithoutRef<
|
|
71
|
-
|
|
74
|
+
export type DropdownRadioGroupProps = ComponentPropsWithoutRef<
|
|
75
|
+
typeof DropdownPrimitive.RadioGroup
|
|
76
|
+
>;
|
|
77
|
+
export type DropdownRadioItemProps = ComponentPropsWithoutRef<
|
|
78
|
+
typeof DropdownPrimitive.RadioItem
|
|
79
|
+
>;
|
|
72
80
|
|
|
73
81
|
const sizeClass: Record<ButtonSize, string> = {
|
|
74
82
|
xs: "ph-btn-xs",
|
|
@@ -106,7 +114,11 @@ function DropdownContent({
|
|
|
106
114
|
sticky="partial"
|
|
107
115
|
>
|
|
108
116
|
{children}
|
|
109
|
-
<DropdownPrimitive.Arrow
|
|
117
|
+
<DropdownPrimitive.Arrow
|
|
118
|
+
className="ph-dropdown-arrow"
|
|
119
|
+
width={12}
|
|
120
|
+
height={6}
|
|
121
|
+
/>
|
|
110
122
|
</DropdownPrimitive.Content>
|
|
111
123
|
</DropdownPrimitive.Portal>
|
|
112
124
|
);
|
|
@@ -115,6 +127,8 @@ function DropdownContent({
|
|
|
115
127
|
/** Collision-aware menu that flips sides and shifts its alignment to remain visible. */
|
|
116
128
|
export function DropdownMenu({
|
|
117
129
|
trigger,
|
|
130
|
+
triggerId,
|
|
131
|
+
triggerDataTest,
|
|
118
132
|
children,
|
|
119
133
|
align,
|
|
120
134
|
side,
|
|
@@ -141,7 +155,13 @@ export function DropdownMenu({
|
|
|
141
155
|
modal={modal}
|
|
142
156
|
>
|
|
143
157
|
<DropdownPrimitive.Trigger asChild disabled={disabled}>
|
|
144
|
-
<button
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
id={triggerId}
|
|
161
|
+
data-test={triggerDataTest}
|
|
162
|
+
className="ph-dropdown-trigger"
|
|
163
|
+
aria-label={ariaLabel}
|
|
164
|
+
>
|
|
145
165
|
{trigger}
|
|
146
166
|
</button>
|
|
147
167
|
</DropdownPrimitive.Trigger>
|
|
@@ -187,7 +207,9 @@ export function DropdownButton({
|
|
|
187
207
|
disabled,
|
|
188
208
|
...props
|
|
189
209
|
}: DropdownButtonProps) {
|
|
190
|
-
const trailing = sideIcon ??
|
|
210
|
+
const trailing = sideIcon ?? (
|
|
211
|
+
<IconChevronDown className="h-4 w-4" aria-hidden />
|
|
212
|
+
);
|
|
191
213
|
|
|
192
214
|
return (
|
|
193
215
|
<div className={cn("ph-dropdown", className)} {...props}>
|
|
@@ -210,7 +232,12 @@ export function DropdownButton({
|
|
|
210
232
|
)}
|
|
211
233
|
aria-label={ariaLabel}
|
|
212
234
|
>
|
|
213
|
-
<ButtonChrome
|
|
235
|
+
<ButtonChrome
|
|
236
|
+
label={label}
|
|
237
|
+
icon={icon}
|
|
238
|
+
sideIcon={trailing}
|
|
239
|
+
split={split}
|
|
240
|
+
/>
|
|
214
241
|
</button>
|
|
215
242
|
</DropdownPrimitive.Trigger>
|
|
216
243
|
<DropdownContent
|
|
@@ -229,7 +256,12 @@ export function DropdownButton({
|
|
|
229
256
|
);
|
|
230
257
|
}
|
|
231
258
|
|
|
232
|
-
export function DropdownItem({
|
|
259
|
+
export function DropdownItem({
|
|
260
|
+
className,
|
|
261
|
+
children,
|
|
262
|
+
disabled,
|
|
263
|
+
...props
|
|
264
|
+
}: DropdownItemProps) {
|
|
233
265
|
return (
|
|
234
266
|
<DropdownPrimitive.Item asChild disabled={disabled}>
|
|
235
267
|
<button
|
|
@@ -248,9 +280,16 @@ export function DropdownRadioGroup(props: DropdownRadioGroupProps) {
|
|
|
248
280
|
return <DropdownPrimitive.RadioGroup {...props} />;
|
|
249
281
|
}
|
|
250
282
|
|
|
251
|
-
export function DropdownRadioItem({
|
|
283
|
+
export function DropdownRadioItem({
|
|
284
|
+
className,
|
|
285
|
+
children,
|
|
286
|
+
...props
|
|
287
|
+
}: DropdownRadioItemProps) {
|
|
252
288
|
return (
|
|
253
|
-
<DropdownPrimitive.RadioItem
|
|
289
|
+
<DropdownPrimitive.RadioItem
|
|
290
|
+
className={cn("ph-dropdown-item ph-dropdown-radio-item", className)}
|
|
291
|
+
{...props}
|
|
292
|
+
>
|
|
254
293
|
<span className="ph-dropdown-item-indicator" aria-hidden>
|
|
255
294
|
<DropdownPrimitive.ItemIndicator>
|
|
256
295
|
<IconCheck className="h-3.5 w-3.5" />
|
|
@@ -27,9 +27,10 @@ export function SegmentedControl({ options, value, onChange, className }: Segmen
|
|
|
27
27
|
<button
|
|
28
28
|
key={option.value}
|
|
29
29
|
type="button"
|
|
30
|
+
aria-pressed={value === option.value}
|
|
30
31
|
onClick={() => onChange(option.value)}
|
|
31
32
|
className={cn(
|
|
32
|
-
"relative flex items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium transition-all",
|
|
33
|
+
"relative flex min-h-11 items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium transition-all sm:min-h-9",
|
|
33
34
|
value === option.value
|
|
34
35
|
? "bg-ph-surface text-ph-ink shadow-ph"
|
|
35
36
|
: "text-ph-subtle hover:text-ph-ink"
|
|
@@ -20,7 +20,10 @@ export interface SettingsNavProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
20
20
|
groups: readonly SettingsNavGroup[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export interface SettingsLayoutProps extends Omit<
|
|
23
|
+
export interface SettingsLayoutProps extends Omit<
|
|
24
|
+
ComponentPropsWithoutRef<"section">,
|
|
25
|
+
"title"
|
|
26
|
+
> {
|
|
24
27
|
title: ReactNode;
|
|
25
28
|
description?: ReactNode;
|
|
26
29
|
actions?: ReactNode;
|
|
@@ -40,12 +43,21 @@ export function SettingsNav({ groups, className, ...props }: SettingsNavProps) {
|
|
|
40
43
|
<a
|
|
41
44
|
key={item.id}
|
|
42
45
|
href={item.href}
|
|
43
|
-
|
|
46
|
+
id={`settings-nav-${item.id}`}
|
|
47
|
+
data-test={`settings-nav-${item.id}`}
|
|
48
|
+
className={cn(
|
|
49
|
+
"ph-settings-nav__item",
|
|
50
|
+
item.active && "ph-settings-nav__item--active",
|
|
51
|
+
)}
|
|
44
52
|
aria-current={item.active ? "page" : undefined}
|
|
45
53
|
>
|
|
46
|
-
{item.icon ?
|
|
54
|
+
{item.icon ? (
|
|
55
|
+
<span className="ph-settings-nav__icon">{item.icon}</span>
|
|
56
|
+
) : null}
|
|
47
57
|
<span>{item.label}</span>
|
|
48
|
-
{item.badge ?
|
|
58
|
+
{item.badge ? (
|
|
59
|
+
<span className="ph-settings-nav__badge">{item.badge}</span>
|
|
60
|
+
) : null}
|
|
49
61
|
</a>
|
|
50
62
|
))}
|
|
51
63
|
</div>
|
|
@@ -72,7 +84,9 @@ export function SettingsLayout({
|
|
|
72
84
|
<h2>{title}</h2>
|
|
73
85
|
{description ? <p>{description}</p> : null}
|
|
74
86
|
</div>
|
|
75
|
-
{actions ?
|
|
87
|
+
{actions ? (
|
|
88
|
+
<div className="ph-settings-layout__actions">{actions}</div>
|
|
89
|
+
) : null}
|
|
76
90
|
</header>
|
|
77
91
|
|
|
78
92
|
<details className="ph-settings-layout__mobile-nav">
|
|
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { DropdownButton, DropdownItem } from "@/components/ui/DropdownMenu";
|
|
6
6
|
import { FilterMenu } from "@/components/ui/FilterBar";
|
|
7
7
|
import { FilterChips } from "@/components/ui/FilterChips";
|
|
8
|
+
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
|
9
|
+
import { Chip } from "@/components/ui/Chip";
|
|
8
10
|
|
|
9
11
|
describe("adaptive dropdown", () => {
|
|
10
12
|
it("supports keyboard navigation, selection, closing, and focus restoration", async () => {
|
|
@@ -75,3 +77,53 @@ describe("filter chips", () => {
|
|
|
75
77
|
expect(onToggle).not.toHaveBeenCalled();
|
|
76
78
|
});
|
|
77
79
|
});
|
|
80
|
+
|
|
81
|
+
describe("chip", () => {
|
|
82
|
+
it("keeps selection and removal as separate keyboard controls", async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
const onClick = vi.fn();
|
|
85
|
+
const onRemove = vi.fn();
|
|
86
|
+
|
|
87
|
+
render(
|
|
88
|
+
<Chip onClick={onClick} onRemove={onRemove}>
|
|
89
|
+
React
|
|
90
|
+
</Chip>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
await user.click(screen.getByRole("button", { name: "Remove React" }));
|
|
94
|
+
expect(onRemove).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("segmented control", () => {
|
|
100
|
+
it("exposes and updates the selected option as a pressed button", async () => {
|
|
101
|
+
const user = userEvent.setup();
|
|
102
|
+
|
|
103
|
+
function Fixture() {
|
|
104
|
+
const [value, setValue] = useState("board");
|
|
105
|
+
return (
|
|
106
|
+
<SegmentedControl
|
|
107
|
+
value={value}
|
|
108
|
+
onChange={setValue}
|
|
109
|
+
options={[
|
|
110
|
+
{ value: "board", label: "Board" },
|
|
111
|
+
{ value: "list", label: "List" },
|
|
112
|
+
]}
|
|
113
|
+
/>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
render(<Fixture />);
|
|
118
|
+
expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute(
|
|
119
|
+
"aria-pressed",
|
|
120
|
+
"true",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
await user.click(screen.getByRole("button", { name: "List" }));
|
|
124
|
+
expect(screen.getByRole("button", { name: "List" })).toHaveAttribute(
|
|
125
|
+
"aria-pressed",
|
|
126
|
+
"true",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -78,41 +78,7 @@ body {
|
|
|
78
78
|
|
|
79
79
|
.suite-app-layout__main {
|
|
80
80
|
background: color-mix(in oklab, var(--ph-canvas) 94%, transparent);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
/* The demo intentionally renders a large component catalogue. Let the
|
|
84
|
-
browser skip layout and paint for sections outside the viewport while
|
|
85
|
-
preserving their anchors for sidebar navigation. */
|
|
86
|
-
.suite-app-layout__main > div > div > * {
|
|
87
|
-
content-visibility: auto;
|
|
88
|
-
contain-intrinsic-size: 0 560px;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
@media print {
|
|
92
|
-
.suite-app-layout__main > div > div > * {
|
|
93
|
-
content-visibility: visible;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
.suite-app-layout aside [data-test="nav-link"] {
|
|
98
|
-
position: relative;
|
|
99
|
-
transition:
|
|
100
|
-
color 160ms ease,
|
|
101
|
-
background-color 160ms ease,
|
|
102
|
-
box-shadow 160ms ease;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
.suite-app-layout aside [data-test="nav-link"][aria-current="page"] {
|
|
106
|
-
background: linear-gradient(
|
|
107
|
-
90deg,
|
|
108
|
-
color-mix(in oklab, var(--ph-accent) 14%, var(--ph-muted)),
|
|
109
|
-
color-mix(in oklab, var(--ph-muted) 60%, transparent)
|
|
110
|
-
);
|
|
111
|
-
box-shadow: inset 2px 0 0 var(--ph-accent);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
.suite-app-layout aside [data-test="nav-link"][aria-current="page"] svg {
|
|
115
|
-
color: var(--ph-accent);
|
|
81
|
+
scrollbar-gutter: stable;
|
|
116
82
|
}
|
|
117
83
|
|
|
118
84
|
@supports not (background: color-mix(in oklab, white, transparent)) {
|
|
@@ -531,6 +497,7 @@ button:hover > .ph-card {
|
|
|
531
497
|
background: color-mix(in oklab, var(--ph-canvas) 86%, transparent);
|
|
532
498
|
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
|
533
499
|
backdrop-filter: blur(12px) saturate(1.4);
|
|
500
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
534
501
|
box-shadow: 0 -1px 0 var(--ph-border-subtle);
|
|
535
502
|
}
|
|
536
503
|
@supports not (backdrop-filter: blur(1px)) {
|
|
@@ -545,6 +512,21 @@ button:hover > .ph-card {
|
|
|
545
512
|
-webkit-overflow-scrolling: touch;
|
|
546
513
|
}
|
|
547
514
|
|
|
515
|
+
/* Compact desktop controls keep their density; coarse pointers get a reliable
|
|
516
|
+
44px hit area without app-specific overrides. */
|
|
517
|
+
@media (pointer: coarse) {
|
|
518
|
+
.ph-btn.ph-btn-raised:not(.ph-btn-lg) {
|
|
519
|
+
--ph-btn-control-size: 2.75rem;
|
|
520
|
+
}
|
|
521
|
+
.ph-btn:not(.ph-btn-raised) {
|
|
522
|
+
min-height: 2.75rem;
|
|
523
|
+
}
|
|
524
|
+
.ph-btn-circle,
|
|
525
|
+
.ph-btn-square {
|
|
526
|
+
min-width: 2.75rem;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
548
530
|
/* ── Ask AI answer content ───────────────────────────────────────────── */
|
|
549
531
|
.suite-ai-blocknote .bn-editor {
|
|
550
532
|
min-height: 0;
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import Image from
|
|
4
|
-
import { useState, type ReactNode } from
|
|
5
|
-
import { Check, NavArrowDown as ChevronDown } from
|
|
3
|
+
import Image from "next/image";
|
|
4
|
+
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { Check, NavArrowDown as ChevronDown } from "iconoir-react";
|
|
6
6
|
|
|
7
|
-
import { cn } from
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
8
|
import type {
|
|
9
9
|
SuiteDropdownItemComponent,
|
|
10
10
|
SuiteDropdownMenuComponent,
|
|
11
|
-
} from
|
|
11
|
+
} from "../lib/injected";
|
|
12
12
|
|
|
13
13
|
export function appSwitcherTriggerClass(open: boolean, collapsed = false) {
|
|
14
14
|
return cn(
|
|
15
|
-
|
|
16
|
-
collapsed ?
|
|
15
|
+
"group flex min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 text-left transition-all duration-200",
|
|
16
|
+
collapsed ? "h-11 w-11 justify-center px-0 py-0" : "w-full",
|
|
17
17
|
open
|
|
18
|
-
?
|
|
19
|
-
:
|
|
18
|
+
? "border-ph-brand/30 bg-ph-muted shadow-[0_1px_2px_rgba(0,0,0,0.06)] ring-1 ring-ph-brand/15"
|
|
19
|
+
: "border-transparent hover:border-ph-border hover:bg-ph-muted/80 hover:shadow-sm",
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function appSwitcherMarkClass() {
|
|
24
|
-
return
|
|
24
|
+
return "h-8 w-8 shrink-0 rounded-lg shadow-[0_1px_2px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.08)] ring-1 ring-black/[0.06]";
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** No sticky Radix highlight while waiting for cross-app redirect. */
|
|
28
28
|
export function appSwitcherMenuItemClass() {
|
|
29
|
-
return
|
|
29
|
+
return "rounded-lg px-2 py-2 data-[highlighted]:bg-transparent hover:bg-ph-muted/80";
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function AppSwitcherMark({
|
|
@@ -39,8 +39,8 @@ export function AppSwitcherMark({
|
|
|
39
39
|
return (
|
|
40
40
|
<span
|
|
41
41
|
className={cn(
|
|
42
|
-
|
|
43
|
-
collapsed &&
|
|
42
|
+
"relative shrink-0",
|
|
43
|
+
collapsed && "group-hover:scale-[1.02]",
|
|
44
44
|
)}
|
|
45
45
|
>
|
|
46
46
|
{children}
|
|
@@ -59,15 +59,15 @@ export function AppSwitcherChevron({
|
|
|
59
59
|
return (
|
|
60
60
|
<span
|
|
61
61
|
className={cn(
|
|
62
|
-
|
|
63
|
-
open &&
|
|
62
|
+
"absolute bottom-0 right-0 flex h-3 w-3 items-center justify-center rounded-full border border-ph-border bg-ph-surface shadow-sm transition-colors",
|
|
63
|
+
open && "border-ph-brand/40 bg-ph-brand/10",
|
|
64
64
|
)}
|
|
65
65
|
aria-hidden
|
|
66
66
|
>
|
|
67
67
|
<ChevronDown
|
|
68
68
|
className={cn(
|
|
69
|
-
|
|
70
|
-
open &&
|
|
69
|
+
"h-2 w-2 text-ph-mutedtext transition-transform duration-200",
|
|
70
|
+
open && "rotate-180 text-ph-brand",
|
|
71
71
|
)}
|
|
72
72
|
/>
|
|
73
73
|
</span>
|
|
@@ -77,17 +77,17 @@ export function AppSwitcherChevron({
|
|
|
77
77
|
return (
|
|
78
78
|
<span
|
|
79
79
|
className={cn(
|
|
80
|
-
|
|
80
|
+
"border-ph-border/80 flex h-5 w-5 shrink-0 items-center justify-center rounded-lg border bg-ph-canvas shadow-sm transition-all duration-200",
|
|
81
81
|
open
|
|
82
|
-
?
|
|
83
|
-
:
|
|
82
|
+
? "border-ph-brand/35 bg-ph-brand/10 text-ph-brand"
|
|
83
|
+
: "text-ph-mutedtext group-hover:border-ph-border group-hover:bg-ph-surface",
|
|
84
84
|
)}
|
|
85
85
|
aria-hidden
|
|
86
86
|
>
|
|
87
87
|
<ChevronDown
|
|
88
88
|
className={cn(
|
|
89
|
-
|
|
90
|
-
open &&
|
|
89
|
+
"h-3 w-3 transition-transform duration-200",
|
|
90
|
+
open && "rotate-180",
|
|
91
91
|
)}
|
|
92
92
|
/>
|
|
93
93
|
</span>
|
|
@@ -115,6 +115,8 @@ export function AppSwitcher({
|
|
|
115
115
|
collapsed = false,
|
|
116
116
|
dropdownMenu: DropdownMenu,
|
|
117
117
|
dropdownItem: DropdownItem,
|
|
118
|
+
triggerId,
|
|
119
|
+
triggerDataTest,
|
|
118
120
|
}: {
|
|
119
121
|
apps: SuiteAppEntry[];
|
|
120
122
|
currentApp: string;
|
|
@@ -124,13 +126,17 @@ export function AppSwitcher({
|
|
|
124
126
|
collapsed?: boolean;
|
|
125
127
|
dropdownMenu: SuiteDropdownMenuComponent;
|
|
126
128
|
dropdownItem: SuiteDropdownItemComponent;
|
|
129
|
+
triggerId?: string;
|
|
130
|
+
triggerDataTest?: string;
|
|
127
131
|
}) {
|
|
128
132
|
const [open, setOpen] = useState(false);
|
|
129
133
|
|
|
130
134
|
return (
|
|
131
135
|
<DropdownMenu
|
|
132
136
|
aria-label="Switch application"
|
|
133
|
-
|
|
137
|
+
triggerId={triggerId ?? "app-switcher-trigger"}
|
|
138
|
+
triggerDataTest={triggerDataTest ?? "app-switcher-trigger"}
|
|
139
|
+
className={collapsed ? "w-11 shrink-0" : "min-w-0 flex-1"}
|
|
134
140
|
panelClassName="w-64"
|
|
135
141
|
align="start"
|
|
136
142
|
open={open}
|
|
@@ -150,13 +156,14 @@ export function AppSwitcher({
|
|
|
150
156
|
</span>
|
|
151
157
|
}
|
|
152
158
|
>
|
|
153
|
-
<div className="p-1" data-test="app-switcher-menu">
|
|
159
|
+
<div id="app-switcher-menu" className="p-1" data-test="app-switcher-menu">
|
|
154
160
|
<p className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-ph-mutedtext">
|
|
155
161
|
Switch application
|
|
156
162
|
</p>
|
|
157
163
|
{apps.map((app) => (
|
|
158
164
|
<DropdownItem
|
|
159
165
|
key={app.slug}
|
|
166
|
+
id={`switch-app-${app.slug}`}
|
|
160
167
|
data-test={`switch-app-${app.slug}`}
|
|
161
168
|
onMouseDown={(e) => e.preventDefault()}
|
|
162
169
|
onClick={() => {
|