@xenide-io/the-old-ui-theme 0.7.1 → 0.9.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 +9 -10
- package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
- package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
- package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
- package/dist/{chunk-YB4TBWHV.mjs → chunk-2EO5VCXP.mjs} +688 -388
- package/dist/chunk-APKRZQRO.mjs +251 -0
- package/dist/chunk-BS7BZI5W.mjs +19 -0
- package/dist/chunk-IULORI3A.mjs +8 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +997 -576
- package/dist/index.mjs +7 -1
- package/dist/suite.d.mts +136 -57
- package/dist/suite.d.ts +136 -57
- package/dist/suite.js +3115 -882
- package/dist/suite.mjs +2566 -661
- package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
- package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
- package/dist/theme-boot.d.mts +2 -0
- package/dist/theme-boot.d.ts +2 -0
- package/dist/theme-boot.js +44 -0
- package/dist/theme-boot.mjs +8 -0
- package/dist/ui.d.mts +5 -29
- package/dist/ui.d.ts +5 -29
- package/dist/ui.js +647 -352
- package/dist/ui.mjs +1 -1
- package/docs/README.md +34 -0
- package/docs/ai-quick-reference.md +56 -0
- package/docs/component-library-improvement-plan.md +751 -0
- package/docs/component-reference.md +81 -0
- package/docs/components.md +48 -0
- package/docs/demos.md +19 -0
- package/docs/installation.md +117 -0
- package/docs/suite-audit.md +34 -0
- package/docs/theme-token-reference.md +67 -0
- package/docs/themes.md +164 -0
- package/package.json +22 -21
- package/public/fonts/OpenRunde-Bold.woff2 +0 -0
- package/public/fonts/OpenRunde-Medium.woff2 +0 -0
- package/public/fonts/OpenRunde-Regular.woff2 +0 -0
- package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
- package/scripts/package-smoke.mjs +43 -0
- package/src/app/globals.css +2 -1
- package/src/components/ui/Accordion.tsx +20 -6
- package/src/components/ui/Avatar.tsx +5 -3
- package/src/components/ui/Calendar.tsx +187 -63
- package/src/components/ui/Card.test.tsx +38 -0
- package/src/components/ui/Card.tsx +77 -14
- package/src/components/ui/CollapsibleSection.tsx +12 -4
- package/src/components/ui/CommandPalette.test.tsx +91 -4
- package/src/components/ui/CommandPalette.tsx +138 -128
- package/src/components/ui/EmptyState.tsx +2 -2
- package/src/components/ui/FormField.tsx +1 -1
- package/src/components/ui/Input.tsx +42 -18
- package/src/components/ui/Kbd.tsx +18 -3
- package/src/components/ui/Modal.tsx +3 -1
- package/src/components/ui/Progress.tsx +14 -5
- package/src/components/ui/SegmentedControl.tsx +2 -2
- package/src/components/ui/SettingsLayout.tsx +1 -1
- package/src/components/ui/Stat.tsx +3 -2
- package/src/components/ui/Table.tsx +8 -3
- package/src/components/ui/Tooltip.test.tsx +70 -1
- package/src/components/ui/Tooltip.tsx +53 -29
- package/src/components/ui/Typography.tsx +103 -49
- package/src/components/ui/avatar-stat.test.tsx +23 -0
- package/src/components/ui/calendar.test.tsx +88 -0
- package/src/components/ui/disclosure.test.tsx +39 -0
- package/src/components/ui/index.ts +44 -6
- package/src/components/ui/progress.test.tsx +15 -0
- package/src/components/ui/typography.test.tsx +26 -1
- package/src/styles/suite-skin.css +8 -2
- package/src/styles/themes.css +184 -68
- package/src/suite/components/ai-message-blocknote.tsx +13 -2
- package/src/suite/components/ai-panel.tsx +41 -10
- package/src/suite/components/app-switcher.test.tsx +21 -6
- package/src/suite/components/app-switcher.tsx +7 -11
- package/src/suite/components/suite-app-layout.tsx +12 -3
- package/src/suite/components/suite-bottom-nav.tsx +0 -1
- package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
- package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
- package/src/suite/components/suite-integration-picker.test.tsx +224 -0
- package/src/suite/components/suite-integration-picker.tsx +889 -18
- package/src/suite/components/suite-integration-rules.test.tsx +157 -0
- package/src/suite/components/suite-integration-rules.tsx +471 -0
- package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
- package/src/suite/components/suite-mobile-drawer.tsx +65 -98
- package/src/suite/components/suite-notification-bell.tsx +29 -4
- package/src/suite/components/suite-sidebar.tsx +25 -25
- package/src/suite/components/suite-skeleton.tsx +2 -2
- package/src/suite/components/suite-user-menu.tsx +9 -8
- package/src/suite/components/theme-provider.test.tsx +87 -3
- package/src/suite/components/theme-provider.tsx +144 -23
- package/src/suite/components/today-ui.tsx +79 -68
- package/src/suite/components/workspace-switcher.test.tsx +56 -0
- package/src/suite/components/workspace-switcher.tsx +3 -3
- package/src/suite/icons/app-accents.ts +12 -12
- package/src/suite/icons/glyphs.ts +6 -12
- package/src/suite/index.ts +22 -0
- package/src/suite/lib/apps.test.ts +9 -1
- package/src/suite/lib/apps.ts +14 -8
- package/src/suite/lib/cookies.ts +53 -0
- package/src/suite/lib/theme-boot.ts +32 -0
- package/src/suite/lib/theme-cookie.ts +7 -0
- package/src/suite/lib/use-sidebar-width.ts +5 -51
- package/dist/chunk-NOI42JNZ.mjs +0 -151
- package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
- package/src/suite/lib/use-lock-mobile-page-zoom.ts +0 -51
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
3
|
import userEvent from "@testing-library/user-event";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { CommandPalette } from "@/components/ui/CommandPalette";
|
|
6
6
|
|
|
7
7
|
describe("command palette", () => {
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
8
10
|
it("behaves as a modal dialog and restores focus when dismissed", async () => {
|
|
9
11
|
const user = userEvent.setup();
|
|
10
12
|
const onSelect = vi.fn();
|
|
@@ -19,6 +21,7 @@ describe("command palette", () => {
|
|
|
19
21
|
<CommandPalette
|
|
20
22
|
isOpen={open}
|
|
21
23
|
onClose={() => setOpen(false)}
|
|
24
|
+
dataTest="command-palette-overlay"
|
|
22
25
|
items={[
|
|
23
26
|
{ id: "reports", label: "Open reports", onSelect },
|
|
24
27
|
]}
|
|
@@ -31,11 +34,95 @@ describe("command palette", () => {
|
|
|
31
34
|
const trigger = screen.getByRole("button", { name: "Open commands" });
|
|
32
35
|
await user.click(trigger);
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
const dialog = screen.getByRole("dialog", { name: "Command palette" });
|
|
38
|
+
const input = screen.getByRole("combobox", { name: "Search commands" });
|
|
39
|
+
expect(dialog.parentElement).toBe(document.body);
|
|
40
|
+
expect(trigger.parentElement).toHaveAttribute("aria-hidden", "true");
|
|
41
|
+
expect(input).toHaveFocus();
|
|
42
|
+
|
|
43
|
+
await user.tab();
|
|
44
|
+
expect(input).toHaveFocus();
|
|
36
45
|
|
|
37
46
|
await user.keyboard("{Escape}");
|
|
38
47
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
39
48
|
expect(trigger).toHaveFocus();
|
|
40
49
|
});
|
|
50
|
+
|
|
51
|
+
it("dismisses through the Radix overlay and restores focus", async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
|
|
54
|
+
function Fixture() {
|
|
55
|
+
const [open, setOpen] = useState(false);
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
<button type="button" onClick={() => setOpen(true)}>
|
|
59
|
+
Open commands
|
|
60
|
+
</button>
|
|
61
|
+
<CommandPalette
|
|
62
|
+
isOpen={open}
|
|
63
|
+
onClose={() => setOpen(false)}
|
|
64
|
+
dataTest="command-palette-overlay"
|
|
65
|
+
items={[{ id: "reports", label: "Open reports", onSelect: vi.fn() }]}
|
|
66
|
+
/>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
render(<Fixture />);
|
|
72
|
+
const trigger = screen.getByRole("button", { name: "Open commands" });
|
|
73
|
+
await user.click(trigger);
|
|
74
|
+
|
|
75
|
+
await user.click(document.querySelector('[data-test="command-palette-overlay"]')!);
|
|
76
|
+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
77
|
+
expect(trigger).toHaveFocus();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("keeps focus on the search input while navigating and selecting listbox options", async () => {
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
const onReports = vi.fn();
|
|
83
|
+
const onSettings = vi.fn();
|
|
84
|
+
|
|
85
|
+
function Fixture() {
|
|
86
|
+
const [open, setOpen] = useState(false);
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
<button type="button" onClick={() => setOpen(true)}>
|
|
90
|
+
Open commands
|
|
91
|
+
</button>
|
|
92
|
+
<CommandPalette
|
|
93
|
+
isOpen={open}
|
|
94
|
+
onClose={() => setOpen(false)}
|
|
95
|
+
items={[
|
|
96
|
+
{ id: "reports", label: "Open reports", onSelect: onReports },
|
|
97
|
+
{ id: "settings", label: "Open settings", onSelect: onSettings },
|
|
98
|
+
]}
|
|
99
|
+
/>
|
|
100
|
+
</>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
render(<Fixture />);
|
|
105
|
+
const trigger = screen.getByRole("button", { name: "Open commands" });
|
|
106
|
+
await user.click(trigger);
|
|
107
|
+
|
|
108
|
+
const input = screen.getByRole("combobox", { name: "Search commands" });
|
|
109
|
+
const listbox = screen.getByRole("listbox", { name: "Commands" });
|
|
110
|
+
const reports = screen.getByRole("option", { name: "Open reports" });
|
|
111
|
+
const settings = screen.getByRole("option", { name: "Open settings" });
|
|
112
|
+
|
|
113
|
+
expect(input).toHaveAttribute("aria-controls", listbox.id);
|
|
114
|
+
expect(input).toHaveAttribute("aria-activedescendant", reports.id);
|
|
115
|
+
expect(reports).toHaveAttribute("aria-selected", "true");
|
|
116
|
+
|
|
117
|
+
await user.keyboard("{ArrowDown}");
|
|
118
|
+
expect(input).toHaveFocus();
|
|
119
|
+
expect(input).toHaveAttribute("aria-activedescendant", settings.id);
|
|
120
|
+
expect(settings).toHaveAttribute("aria-selected", "true");
|
|
121
|
+
|
|
122
|
+
await user.keyboard("{Enter}");
|
|
123
|
+
expect(onSettings).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(onReports).not.toHaveBeenCalled();
|
|
125
|
+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
126
|
+
expect(trigger).toHaveFocus();
|
|
127
|
+
});
|
|
41
128
|
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
4
5
|
import { cn } from "@/lib/cn";
|
|
5
6
|
import { IconSearch } from "@/components/icons";
|
|
6
7
|
|
|
7
|
-
interface CommandItem {
|
|
8
|
+
export interface CommandItem {
|
|
8
9
|
id: string;
|
|
9
10
|
label: string;
|
|
10
11
|
shortcut?: string;
|
|
@@ -12,7 +13,7 @@ interface CommandItem {
|
|
|
12
13
|
onSelect: () => void;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
interface CommandPaletteProps {
|
|
16
|
+
export interface CommandPaletteProps {
|
|
16
17
|
items: CommandItem[];
|
|
17
18
|
isOpen: boolean;
|
|
18
19
|
onClose: () => void;
|
|
@@ -38,149 +39,158 @@ export function CommandPalette({
|
|
|
38
39
|
const [query, setQuery] = useState("");
|
|
39
40
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
40
41
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
41
|
-
const
|
|
42
|
-
const
|
|
42
|
+
const listboxRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
|
44
|
+
const generatedId = useId();
|
|
45
|
+
const listboxId = `${id ?? `command-palette-${generatedId}`}-listbox`;
|
|
43
46
|
|
|
44
47
|
const filtered = items.filter((item) =>
|
|
45
48
|
item.label.toLowerCase().includes(query.toLowerCase()),
|
|
46
49
|
);
|
|
50
|
+
const activeIndex = filtered.length
|
|
51
|
+
? Math.min(selectedIndex, filtered.length - 1)
|
|
52
|
+
: 0;
|
|
53
|
+
const activeItem = filtered[activeIndex];
|
|
47
54
|
|
|
48
55
|
useEffect(() => {
|
|
49
56
|
if (!isOpen) return;
|
|
50
|
-
previousFocusRef.current =
|
|
51
|
-
document.activeElement instanceof HTMLElement
|
|
52
|
-
? document.activeElement
|
|
53
|
-
: null;
|
|
54
57
|
setQuery("");
|
|
55
58
|
setSelectedIndex(0);
|
|
56
|
-
inputRef.current?.focus();
|
|
57
|
-
|
|
58
|
-
return () => previousFocusRef.current?.focus();
|
|
59
59
|
}, [isOpen]);
|
|
60
60
|
|
|
61
61
|
useEffect(() => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
switch (e.key) {
|
|
66
|
-
case "ArrowDown":
|
|
67
|
-
if (!filtered.length) break;
|
|
68
|
-
e.preventDefault();
|
|
69
|
-
setSelectedIndex((i) => (i + 1) % filtered.length);
|
|
70
|
-
break;
|
|
71
|
-
case "ArrowUp":
|
|
72
|
-
if (!filtered.length) break;
|
|
73
|
-
e.preventDefault();
|
|
74
|
-
setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length);
|
|
75
|
-
break;
|
|
76
|
-
case "Enter":
|
|
77
|
-
e.preventDefault();
|
|
78
|
-
filtered[selectedIndex]?.onSelect();
|
|
79
|
-
onClose();
|
|
80
|
-
break;
|
|
81
|
-
case "Escape":
|
|
82
|
-
onClose();
|
|
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
|
-
}
|
|
100
|
-
}
|
|
101
|
-
};
|
|
62
|
+
setSelectedIndex((current) => Math.min(current, Math.max(filtered.length - 1, 0)));
|
|
63
|
+
}, [filtered.length]);
|
|
102
64
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!isOpen || !activeItem) return;
|
|
67
|
+
listboxRef.current
|
|
68
|
+
?.querySelectorAll<HTMLElement>('[role="option"]')
|
|
69
|
+
[activeIndex]
|
|
70
|
+
?.scrollIntoView({ block: "nearest" });
|
|
71
|
+
}, [activeIndex, activeItem, isOpen]);
|
|
106
72
|
|
|
107
|
-
|
|
73
|
+
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
74
|
+
switch (event.key) {
|
|
75
|
+
case "ArrowDown":
|
|
76
|
+
if (!filtered.length) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
setSelectedIndex((current) => (current + 1) % filtered.length);
|
|
79
|
+
return;
|
|
80
|
+
case "ArrowUp":
|
|
81
|
+
if (!filtered.length) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
setSelectedIndex((current) =>
|
|
84
|
+
(current - 1 + filtered.length) % filtered.length,
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
case "Enter":
|
|
88
|
+
if (!activeItem) return;
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
activeItem.onSelect();
|
|
91
|
+
onClose();
|
|
92
|
+
return;
|
|
93
|
+
default:
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
108
97
|
|
|
109
98
|
return (
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
onMouseDown={(event) => {
|
|
115
|
-
if (event.target === event.currentTarget) onClose();
|
|
99
|
+
<Dialog.Root
|
|
100
|
+
open={isOpen}
|
|
101
|
+
onOpenChange={(nextOpen) => {
|
|
102
|
+
if (!nextOpen) onClose();
|
|
116
103
|
}}
|
|
117
104
|
>
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
className
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
<div className="flex items-center gap-3 border-b border-ph-border px-4 py-3">
|
|
129
|
-
<IconSearch className="h-5 w-5 text-ph-mutedtext" />
|
|
130
|
-
<input
|
|
131
|
-
ref={inputRef}
|
|
132
|
-
id={inputId}
|
|
133
|
-
data-test={inputDataTest}
|
|
134
|
-
type="text"
|
|
135
|
-
aria-label="Search commands"
|
|
136
|
-
value={query}
|
|
137
|
-
onChange={(e) => {
|
|
138
|
-
setQuery(e.target.value);
|
|
139
|
-
setSelectedIndex(0);
|
|
140
|
-
}}
|
|
141
|
-
placeholder={placeholder}
|
|
142
|
-
className="flex-1 bg-transparent text-ph-ink outline-none placeholder:text-ph-mutedtext"
|
|
143
|
-
/>
|
|
144
|
-
<kbd className="rounded border border-ph-border bg-ph-muted px-1.5 py-0.5 text-xs text-ph-mutedtext">
|
|
145
|
-
ESC
|
|
146
|
-
</kbd>
|
|
147
|
-
</div>
|
|
148
|
-
<div className="max-h-[50vh] overflow-y-auto py-2">
|
|
149
|
-
{filtered.length === 0 ? (
|
|
150
|
-
<div className="px-4 py-8 text-center text-sm text-ph-mutedtext">
|
|
151
|
-
No results found.
|
|
152
|
-
</div>
|
|
153
|
-
) : (
|
|
154
|
-
filtered.map((item, index) => (
|
|
155
|
-
<button
|
|
156
|
-
key={item.id}
|
|
157
|
-
type="button"
|
|
158
|
-
id={`command-${item.id}`}
|
|
159
|
-
data-test={`command-${item.id}`}
|
|
160
|
-
onClick={() => {
|
|
161
|
-
item.onSelect();
|
|
162
|
-
onClose();
|
|
163
|
-
}}
|
|
164
|
-
onMouseEnter={() => setSelectedIndex(index)}
|
|
165
|
-
className={cn(
|
|
166
|
-
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
|
|
167
|
-
index === selectedIndex
|
|
168
|
-
? "bg-ph-muted text-ph-ink"
|
|
169
|
-
: "text-ph-subtle",
|
|
170
|
-
)}
|
|
171
|
-
>
|
|
172
|
-
{item.icon && <span className="h-5 w-5">{item.icon}</span>}
|
|
173
|
-
<span className="flex-1">{item.label}</span>
|
|
174
|
-
{item.shortcut && (
|
|
175
|
-
<kbd className="rounded border border-ph-border bg-ph-surface px-1.5 py-0.5 text-xs text-ph-mutedtext">
|
|
176
|
-
{item.shortcut}
|
|
177
|
-
</kbd>
|
|
178
|
-
)}
|
|
179
|
-
</button>
|
|
180
|
-
))
|
|
105
|
+
<Dialog.Portal>
|
|
106
|
+
<Dialog.Overlay
|
|
107
|
+
id={id}
|
|
108
|
+
data-test={dataTest}
|
|
109
|
+
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
|
110
|
+
/>
|
|
111
|
+
<Dialog.Content
|
|
112
|
+
className={cn(
|
|
113
|
+
"fixed left-1/2 top-[20vh] z-50 w-full max-w-xl -translate-x-1/2 overflow-hidden rounded-xl border border-ph-border bg-ph-surface shadow-ph-md",
|
|
114
|
+
className,
|
|
181
115
|
)}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
116
|
+
onOpenAutoFocus={(event) => {
|
|
117
|
+
restoreFocusRef.current =
|
|
118
|
+
document.activeElement instanceof HTMLElement
|
|
119
|
+
? document.activeElement
|
|
120
|
+
: null;
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
inputRef.current?.focus();
|
|
123
|
+
}}
|
|
124
|
+
onCloseAutoFocus={(event) => {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
restoreFocusRef.current?.focus();
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<Dialog.Title className="sr-only">Command palette</Dialog.Title>
|
|
130
|
+
<div className="flex items-center gap-3 border-b border-ph-border px-4 py-3">
|
|
131
|
+
<IconSearch className="h-5 w-5 text-ph-mutedtext" />
|
|
132
|
+
<input
|
|
133
|
+
ref={inputRef}
|
|
134
|
+
id={inputId}
|
|
135
|
+
data-test={inputDataTest}
|
|
136
|
+
type="text"
|
|
137
|
+
role="combobox"
|
|
138
|
+
aria-label="Search commands"
|
|
139
|
+
aria-autocomplete="list"
|
|
140
|
+
aria-controls={listboxId}
|
|
141
|
+
aria-expanded="true"
|
|
142
|
+
aria-activedescendant={activeItem ? `command-${activeItem.id}` : undefined}
|
|
143
|
+
value={query}
|
|
144
|
+
onChange={(event) => {
|
|
145
|
+
setQuery(event.target.value);
|
|
146
|
+
setSelectedIndex(0);
|
|
147
|
+
}}
|
|
148
|
+
onKeyDown={handleInputKeyDown}
|
|
149
|
+
placeholder={placeholder}
|
|
150
|
+
className="flex-1 bg-transparent text-ph-ink outline-none placeholder:text-ph-mutedtext"
|
|
151
|
+
/>
|
|
152
|
+
<kbd aria-hidden="true" className="rounded border border-ph-border bg-ph-muted px-1.5 py-0.5 text-xs text-ph-mutedtext">
|
|
153
|
+
ESC
|
|
154
|
+
</kbd>
|
|
155
|
+
</div>
|
|
156
|
+
<div ref={listboxRef} id={listboxId} role="listbox" aria-label="Commands" className="max-h-[50vh] overflow-y-auto py-2">
|
|
157
|
+
{filtered.length === 0 ? (
|
|
158
|
+
<div role="status" className="px-4 py-8 text-center text-sm text-ph-mutedtext">
|
|
159
|
+
No results found.
|
|
160
|
+
</div>
|
|
161
|
+
) : (
|
|
162
|
+
filtered.map((item, index) => (
|
|
163
|
+
<div
|
|
164
|
+
key={item.id}
|
|
165
|
+
id={`command-${item.id}`}
|
|
166
|
+
data-test={`command-${item.id}`}
|
|
167
|
+
role="option"
|
|
168
|
+
aria-selected={index === activeIndex}
|
|
169
|
+
onClick={() => {
|
|
170
|
+
item.onSelect();
|
|
171
|
+
onClose();
|
|
172
|
+
}}
|
|
173
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
174
|
+
className={cn(
|
|
175
|
+
"flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
|
|
176
|
+
index === activeIndex
|
|
177
|
+
? "bg-ph-muted text-ph-ink"
|
|
178
|
+
: "text-ph-subtle",
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
{item.icon && <span aria-hidden="true" className="h-5 w-5">{item.icon}</span>}
|
|
182
|
+
<span className="flex-1">{item.label}</span>
|
|
183
|
+
{item.shortcut && (
|
|
184
|
+
<kbd aria-hidden="true" className="rounded border border-ph-border bg-ph-surface px-1.5 py-0.5 text-xs text-ph-mutedtext">
|
|
185
|
+
{item.shortcut}
|
|
186
|
+
</kbd>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
))
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</Dialog.Content>
|
|
193
|
+
</Dialog.Portal>
|
|
194
|
+
</Dialog.Root>
|
|
185
195
|
);
|
|
186
196
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { cn } from "@/lib/cn";
|
|
4
4
|
|
|
5
|
-
interface EmptyStateProps {
|
|
5
|
+
export interface EmptyStateProps {
|
|
6
6
|
icon?: React.ReactNode;
|
|
7
7
|
title: string;
|
|
8
8
|
description?: string;
|
|
@@ -23,7 +23,7 @@ export function EmptyState({ icon, title, description, action, className }: Empt
|
|
|
23
23
|
{icon}
|
|
24
24
|
</div>
|
|
25
25
|
)}
|
|
26
|
-
<
|
|
26
|
+
<h2 className="text-base font-semibold text-ph-ink">{title}</h2>
|
|
27
27
|
{description && (
|
|
28
28
|
<p className="mt-1 max-w-sm text-sm text-ph-subtle">{description}</p>
|
|
29
29
|
)}
|
|
@@ -37,7 +37,7 @@ export function FormField({
|
|
|
37
37
|
const errorId = error ? `${id}-error` : undefined;
|
|
38
38
|
const controlProps: FormFieldControlProps = {
|
|
39
39
|
id,
|
|
40
|
-
"aria-describedby": descriptionId,
|
|
40
|
+
"aria-describedby": errorId ?? descriptionId,
|
|
41
41
|
"aria-errormessage": errorId,
|
|
42
42
|
"aria-invalid": error ? true : undefined,
|
|
43
43
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { forwardRef, useId, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
|
1
|
+
import { forwardRef, useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
|
2
|
+
import { Eye, EyeClosed } from "iconoir-react";
|
|
2
3
|
import { FormField } from "@/components/ui/FormField";
|
|
3
4
|
import { IconCheck } from "@/components/icons";
|
|
4
5
|
import { cn } from "@/lib/cn";
|
|
@@ -35,6 +36,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
|
35
36
|
helperText,
|
|
36
37
|
size = "md",
|
|
37
38
|
variant = "default",
|
|
39
|
+
type = "text",
|
|
38
40
|
className,
|
|
39
41
|
wrapperClassName,
|
|
40
42
|
disabled,
|
|
@@ -45,6 +47,8 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
|
45
47
|
const autoId = useId();
|
|
46
48
|
const id = idProp ?? autoId;
|
|
47
49
|
const hasError = Boolean(error);
|
|
50
|
+
const isPassword = type === "password";
|
|
51
|
+
const [revealed, setRevealed] = useState(false);
|
|
48
52
|
|
|
49
53
|
return (
|
|
50
54
|
<FormField
|
|
@@ -58,22 +62,42 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
|
58
62
|
className={wrapperClassName}
|
|
59
63
|
>
|
|
60
64
|
{(fieldProps) => (
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
<div className="relative">
|
|
66
|
+
<input
|
|
67
|
+
{...fieldProps}
|
|
68
|
+
ref={ref}
|
|
69
|
+
type={isPassword && revealed ? "text" : type}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
required={required}
|
|
72
|
+
aria-describedby={joinIds(ariaDescribedBy, fieldProps["aria-describedby"])}
|
|
73
|
+
className={cn(
|
|
74
|
+
"ph-input w-full",
|
|
75
|
+
isPassword && "pr-10",
|
|
76
|
+
sizeMap[size],
|
|
77
|
+
variant === "ghost" && "ph-input-ghost",
|
|
78
|
+
hasError && "ph-input-error",
|
|
79
|
+
disabled && "cursor-not-allowed opacity-60",
|
|
80
|
+
className
|
|
81
|
+
)}
|
|
82
|
+
{...props}
|
|
83
|
+
/>
|
|
84
|
+
{isPassword ? (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={() => setRevealed((current) => !current)}
|
|
88
|
+
aria-label={revealed ? "Hide password" : "Show password"}
|
|
89
|
+
aria-pressed={revealed}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
className="absolute inset-y-0 right-0 flex w-10 items-center justify-center rounded-r-[var(--ph-radius-control)] text-ph-mutedtext transition-colors hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
|
|
92
|
+
>
|
|
93
|
+
{revealed ? (
|
|
94
|
+
<EyeClosed className="h-4 w-4" aria-hidden />
|
|
95
|
+
) : (
|
|
96
|
+
<Eye className="h-4 w-4" aria-hidden />
|
|
97
|
+
)}
|
|
98
|
+
</button>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
77
101
|
)}
|
|
78
102
|
</FormField>
|
|
79
103
|
);
|
|
@@ -222,7 +246,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Che
|
|
|
222
246
|
type="checkbox"
|
|
223
247
|
disabled={disabled}
|
|
224
248
|
aria-invalid={hasError || undefined}
|
|
225
|
-
aria-describedby={joinIds(ariaDescribedBy, descriptionId)}
|
|
249
|
+
aria-describedby={joinIds(ariaDescribedBy, descriptionId, errorId)}
|
|
226
250
|
aria-errormessage={errorId}
|
|
227
251
|
className={cn(
|
|
228
252
|
"ph-checkbox",
|
|
@@ -65,7 +65,12 @@ export function Kbd({
|
|
|
65
65
|
}: KbdProps) {
|
|
66
66
|
if (variant === "token") {
|
|
67
67
|
return (
|
|
68
|
-
<code
|
|
68
|
+
<code
|
|
69
|
+
className={cn("ph-code-token ph-kbd", className)}
|
|
70
|
+
role={ariaLabel ? "img" : undefined}
|
|
71
|
+
aria-label={ariaLabel}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
69
74
|
{children}
|
|
70
75
|
</code>
|
|
71
76
|
);
|
|
@@ -77,14 +82,24 @@ export function Kbd({
|
|
|
77
82
|
|
|
78
83
|
if (variant === "key") {
|
|
79
84
|
return (
|
|
80
|
-
<kbd
|
|
85
|
+
<kbd
|
|
86
|
+
className={cn("ph-keycap", className)}
|
|
87
|
+
role={accessibleLabel ? "img" : undefined}
|
|
88
|
+
aria-label={accessibleLabel}
|
|
89
|
+
{...props}
|
|
90
|
+
>
|
|
81
91
|
{displayKeys[0]}
|
|
82
92
|
</kbd>
|
|
83
93
|
);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
return (
|
|
87
|
-
<kbd
|
|
97
|
+
<kbd
|
|
98
|
+
className={cn("ph-shortcut-group ph-keycap", className)}
|
|
99
|
+
role={accessibleLabel ? "img" : undefined}
|
|
100
|
+
aria-label={accessibleLabel}
|
|
101
|
+
{...props}
|
|
102
|
+
>
|
|
88
103
|
{displayKeys.map((key, index) => (
|
|
89
104
|
<span key={index} className="contents">
|
|
90
105
|
{index > 0 && separator != null ? <span className="ph-shortcut-separator" aria-hidden="true">{separator}</span> : null}
|
|
@@ -16,6 +16,7 @@ export interface ModalProps {
|
|
|
16
16
|
footer?: ReactNode;
|
|
17
17
|
size?: ModalSize;
|
|
18
18
|
className?: string;
|
|
19
|
+
bodyClassName?: string;
|
|
19
20
|
customLayout?: boolean;
|
|
20
21
|
showCloseButton?: boolean;
|
|
21
22
|
closeOnOutsideClick?: boolean;
|
|
@@ -45,6 +46,7 @@ export function Modal({
|
|
|
45
46
|
footer,
|
|
46
47
|
size = "md",
|
|
47
48
|
className,
|
|
49
|
+
bodyClassName,
|
|
48
50
|
customLayout = false,
|
|
49
51
|
showCloseButton = true,
|
|
50
52
|
closeOnOutsideClick = true,
|
|
@@ -138,7 +140,7 @@ export function Modal({
|
|
|
138
140
|
) : null}
|
|
139
141
|
</header>
|
|
140
142
|
) : null}
|
|
141
|
-
<div className="ph-modal-body">{children}</div>
|
|
143
|
+
<div className={cn("ph-modal-body", bodyClassName)}>{children}</div>
|
|
142
144
|
{footer ? <div className="ph-modal-footer">{footer}</div> : null}
|
|
143
145
|
</>
|
|
144
146
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useId, type ComponentPropsWithoutRef } from "react";
|
|
2
2
|
import { cn } from "@/lib/cn";
|
|
3
3
|
|
|
4
4
|
export interface ProgressProps extends ComponentPropsWithoutRef<"div"> {
|
|
@@ -30,15 +30,22 @@ export function Progress({
|
|
|
30
30
|
showPercentage = false,
|
|
31
31
|
size = "md",
|
|
32
32
|
className,
|
|
33
|
+
"aria-label": ariaLabel,
|
|
34
|
+
"aria-labelledby": ariaLabelledBy,
|
|
33
35
|
...props
|
|
34
36
|
}: ProgressProps) {
|
|
35
|
-
const
|
|
37
|
+
const labelId = useId();
|
|
38
|
+
const safeMax = Number.isFinite(max) && max > 0 ? max : 100;
|
|
39
|
+
const safeValue = Number.isFinite(value)
|
|
40
|
+
? Math.min(safeMax, Math.max(0, value))
|
|
41
|
+
: 0;
|
|
42
|
+
const percentage = (safeValue / safeMax) * 100;
|
|
36
43
|
|
|
37
44
|
return (
|
|
38
45
|
<div className={cn("w-full", className)} {...props}>
|
|
39
46
|
{(label || showPercentage) && (
|
|
40
47
|
<div className="mb-1.5 flex justify-between text-sm text-ph-subtle">
|
|
41
|
-
{label && <span>{label}</span>}
|
|
48
|
+
{label && <span id={labelId}>{label}</span>}
|
|
42
49
|
{showPercentage && (
|
|
43
50
|
<span className="tabular-nums">{Math.round(percentage)}%</span>
|
|
44
51
|
)}
|
|
@@ -49,9 +56,11 @@ export function Progress({
|
|
|
49
56
|
className={cn("ph-progress-bar", color)}
|
|
50
57
|
style={{ width: `${percentage}%` }}
|
|
51
58
|
role="progressbar"
|
|
52
|
-
aria-valuenow={
|
|
59
|
+
aria-valuenow={safeValue}
|
|
53
60
|
aria-valuemin={0}
|
|
54
|
-
aria-valuemax={
|
|
61
|
+
aria-valuemax={safeMax}
|
|
62
|
+
aria-label={label ? undefined : (ariaLabel ?? "Progress")}
|
|
63
|
+
aria-labelledby={label ? (ariaLabelledBy ?? labelId) : ariaLabelledBy}
|
|
55
64
|
/>
|
|
56
65
|
</div>
|
|
57
66
|
</div>
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { cn } from "@/lib/cn";
|
|
4
4
|
|
|
5
|
-
interface SegmentedControlOption {
|
|
5
|
+
export interface SegmentedControlOption {
|
|
6
6
|
value: string;
|
|
7
7
|
label: string;
|
|
8
8
|
icon?: React.ReactNode;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
interface SegmentedControlProps {
|
|
11
|
+
export interface SegmentedControlProps {
|
|
12
12
|
options: SegmentedControlOption[];
|
|
13
13
|
value: string;
|
|
14
14
|
onChange: (value: string) => void;
|