@xenide-io/the-old-ui-theme 0.5.2 → 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/README.md +47 -34
- 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-RZXHZWUB.mjs → chunk-SBBKXV35.mjs} +174 -222
- package/dist/index.d.mts +4 -80
- package/dist/index.d.ts +4 -80
- package/dist/index.js +562 -3746
- package/dist/index.mjs +3 -765
- package/dist/suite.d.mts +56 -25
- package/dist/suite.d.ts +56 -25
- package/dist/suite.js +248 -107
- package/dist/suite.mjs +217 -105
- package/dist/ui.d.mts +608 -2
- package/dist/ui.d.ts +608 -2
- package/dist/ui.js +232 -230
- 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/app/globals.css +1 -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/ThemeSwitcher.tsx +12 -3
- package/src/components/ui/interaction-primitives.test.tsx +52 -0
- package/src/styles/suite-skin.css +53 -1
- package/src/styles/themes.css +445 -1656
- 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 +31 -10
- 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 +54 -43
- package/src/suite/components/suite-user-menu.tsx +54 -26
- package/src/suite/components/today-page-frame.tsx +1 -1
- package/src/suite/icons/app-accents.ts +38 -43
- package/src/suite/icons/glyph-parts.tsx +16 -9
- package/src/suite/icons/glyphs.ts +130 -99
- package/src/suite/lib/apps.ts +1 -1
- package/src/suite/lib/injected.ts +12 -11
- package/src/suite/lib/use-sidebar-width.ts +23 -8
- 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
package/dist/ui.mjs
CHANGED
|
@@ -108,7 +108,7 @@ import {
|
|
|
108
108
|
ToggleButton,
|
|
109
109
|
ToggleGroup,
|
|
110
110
|
Widget
|
|
111
|
-
} from "./chunk-
|
|
111
|
+
} from "./chunk-SBBKXV35.mjs";
|
|
112
112
|
import {
|
|
113
113
|
ButtonChrome,
|
|
114
114
|
DropdownButton,
|
|
@@ -120,7 +120,7 @@ import {
|
|
|
120
120
|
IconByName,
|
|
121
121
|
Tooltip,
|
|
122
122
|
TooltipProvider
|
|
123
|
-
} from "./chunk-
|
|
123
|
+
} from "./chunk-E5ZBQYEX.mjs";
|
|
124
124
|
import "./chunk-FWCSY2DS.mjs";
|
|
125
125
|
export {
|
|
126
126
|
Accordion,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xenide-io/the-old-ui-theme",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Props-driven React components and theme tokens inspired by classic interface design \u2014 optimized for Next.js apps and internal tools.",
|
|
5
5
|
"author": "Xenide",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
},
|
|
58
58
|
"files": [
|
|
59
59
|
"dist",
|
|
60
|
+
"scripts",
|
|
60
61
|
"src/app/globals.css",
|
|
61
62
|
"src/components/ui",
|
|
62
|
-
"src/components/charts",
|
|
63
63
|
"src/components/icons",
|
|
64
64
|
"src/suite",
|
|
65
65
|
"src/styles/themes.css",
|
|
@@ -87,14 +87,10 @@
|
|
|
87
87
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
88
88
|
"next": ">=16.2.7",
|
|
89
89
|
"tailwindcss": "^3.4.0",
|
|
90
|
-
"d3-scale": "^4.0.2",
|
|
91
90
|
"iconoir-react": ">=7.11.1",
|
|
92
91
|
"motion": ">=12"
|
|
93
92
|
},
|
|
94
93
|
"peerDependenciesMeta": {
|
|
95
|
-
"d3-scale": {
|
|
96
|
-
"optional": true
|
|
97
|
-
},
|
|
98
94
|
"iconoir-react": {
|
|
99
95
|
"optional": true
|
|
100
96
|
},
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Dev-only: copy this package tree into an app's node_modules so Next.js
|
|
3
|
+
# Turbopack resolves CSS/exports from real files (not a broken file: link).
|
|
4
|
+
#
|
|
5
|
+
# Host must have built dist first: `bun run build:lib` in this repo.
|
|
6
|
+
# Safe to skip when THEME_DIR is missing (production images).
|
|
7
|
+
#
|
|
8
|
+
# Usage (from an app directory, Docker or local):
|
|
9
|
+
# THEME_DIR=/the-old-ui TARGET=/app/node_modules/@xenide-io/the-old-ui-theme \
|
|
10
|
+
# sh /the-old-ui/scripts/install-theme-dev.sh
|
|
11
|
+
# Or via each app's thin wrapper under scripts/install-theme-dev.sh.
|
|
12
|
+
|
|
13
|
+
set -e
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
|
16
|
+
THEME_DIR=${THEME_DIR:-$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)}
|
|
17
|
+
|
|
18
|
+
if [ -z "${TARGET:-}" ]; then
|
|
19
|
+
if [ -d /app/node_modules ]; then
|
|
20
|
+
TARGET=/app/node_modules/@xenide-io/the-old-ui-theme
|
|
21
|
+
elif [ -d ./node_modules ]; then
|
|
22
|
+
TARGET=$(pwd)/node_modules/@xenide-io/the-old-ui-theme
|
|
23
|
+
else
|
|
24
|
+
echo "install-theme-dev: set TARGET=.../node_modules/@xenide-io/the-old-ui-theme" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
if [ ! -d "$THEME_DIR" ]; then
|
|
30
|
+
echo "install-theme-dev: $THEME_DIR not found, skipping"
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if [ ! -d "$THEME_DIR/dist" ]; then
|
|
35
|
+
echo "install-theme-dev: $THEME_DIR/dist is missing."
|
|
36
|
+
echo "Run: cd \"$THEME_DIR\" && bun run build:lib"
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
echo "install-theme-dev: copying $THEME_DIR -> $TARGET"
|
|
41
|
+
|
|
42
|
+
rm -rf "$TARGET"
|
|
43
|
+
mkdir -p "$TARGET"
|
|
44
|
+
|
|
45
|
+
# tar preserves structure and is faster than cp for many small files.
|
|
46
|
+
tar -C "$THEME_DIR" -cf - \
|
|
47
|
+
--exclude='.git' \
|
|
48
|
+
--exclude='.next' \
|
|
49
|
+
--exclude='node_modules' \
|
|
50
|
+
--exclude='test-results' \
|
|
51
|
+
--exclude='playwright-report' \
|
|
52
|
+
--exclude='coverage' \
|
|
53
|
+
--exclude='out' \
|
|
54
|
+
--exclude='*.log' \
|
|
55
|
+
--exclude='.DS_Store' \
|
|
56
|
+
--exclude='.turbo' \
|
|
57
|
+
--exclude='*.tsbuildinfo' \
|
|
58
|
+
. | tar -C "$TARGET" -xf -
|
|
59
|
+
|
|
60
|
+
echo "install-theme-dev: done"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Compare local Lemon glyphs with PostHog open-source icons.tsx.
|
|
4
|
+
* Usage: node scripts/sync-posthog-icons.mjs
|
|
5
|
+
*
|
|
6
|
+
* Downloads: frontend/src/lib/lemon-ui/icons/icons.tsx
|
|
7
|
+
* Reports path mismatches — Material-only icons (bell, volume, …) are expected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
const POSTHOG_URL =
|
|
14
|
+
"https://raw.githubusercontent.com/PostHog/posthog/master/frontend/src/lib/lemon-ui/icons/icons.tsx";
|
|
15
|
+
const LOCAL = resolve("src/components/icons/icons.tsx");
|
|
16
|
+
const CACHE = resolve("scripts/.posthog-icons.tsx");
|
|
17
|
+
|
|
18
|
+
function extractPhBlock(source, name) {
|
|
19
|
+
const start = source.indexOf(`export function ${name}(`);
|
|
20
|
+
if (start < 0) return null;
|
|
21
|
+
const next = source.indexOf("\nexport function Icon", start + 10);
|
|
22
|
+
return source.slice(start, next > 0 ? next : start + 4000);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractOurBlock(source, name) {
|
|
26
|
+
const start = source.indexOf(`export const ${name} =`);
|
|
27
|
+
if (start < 0) return null;
|
|
28
|
+
const next = source.indexOf("\nexport const Icon", start + 10);
|
|
29
|
+
const nextFn = source.indexOf("\nexport function Icon", start + 10);
|
|
30
|
+
const end = [next, nextFn].filter((x) => x > 0).sort((a, b) => a - b)[0] || start + 4000;
|
|
31
|
+
return source.slice(start, end);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normPaths(block) {
|
|
35
|
+
if (!block) return "";
|
|
36
|
+
const fills = [...block.matchAll(/fill\(\s*\n?\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
37
|
+
const ds = [...block.matchAll(/<path d="([^"]+)"/g)].map((m) => m[1]);
|
|
38
|
+
const attrs = [...block.matchAll(/\sd="([^"]+)"/g)].map((m) => m[1]);
|
|
39
|
+
return [...fills, ...ds, ...attrs].join("|").replace(/\s/g, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const response = await fetch(POSTHOG_URL);
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`Failed to fetch PostHog icons: ${response.status}`);
|
|
46
|
+
}
|
|
47
|
+
const ph = await response.text();
|
|
48
|
+
writeFileSync(CACHE, ph);
|
|
49
|
+
|
|
50
|
+
const local = readFileSync(LOCAL, "utf8");
|
|
51
|
+
const phNames = new Set([...ph.matchAll(/export function (Icon\w+)/g)].map((m) => m[1]));
|
|
52
|
+
const ourNames = [...local.matchAll(/export const (Icon\w+)/g)]
|
|
53
|
+
.map((m) => m[1])
|
|
54
|
+
.filter((n) => n !== "IconSpinner");
|
|
55
|
+
|
|
56
|
+
const shared = ourNames.filter((n) => phNames.has(n));
|
|
57
|
+
const materialOnly = ourNames.filter((n) => !phNames.has(n));
|
|
58
|
+
const mismatches = shared.filter(
|
|
59
|
+
(n) => normPaths(extractPhBlock(ph, n)) !== normPaths(extractOurBlock(local, n)),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
console.log(`Compared ${shared.length} shared icons with PostHog master.`);
|
|
63
|
+
if (mismatches.length) {
|
|
64
|
+
console.log("\nPath mismatches (update src/components/icons/icons.tsx):");
|
|
65
|
+
mismatches.forEach((n) => console.log(` - ${n}`));
|
|
66
|
+
} else {
|
|
67
|
+
console.log("All shared PostHog icons match.");
|
|
68
|
+
}
|
|
69
|
+
console.log(`\nMaterial-only icons (${materialOnly.length}): ${materialOnly.join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
main().catch((error) => {
|
|
73
|
+
console.error(error.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
package/src/app/globals.css
CHANGED
|
@@ -14,31 +14,49 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip({
|
|
|
14
14
|
className,
|
|
15
15
|
...props
|
|
16
16
|
}, ref) {
|
|
17
|
+
const chipClassName = cn(
|
|
18
|
+
"ph-chip",
|
|
19
|
+
selected && "ph-chip--selected",
|
|
20
|
+
className
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (onRemove) {
|
|
24
|
+
const removeLabel =
|
|
25
|
+
typeof children === "string" ? `Remove ${children}` : "Remove";
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<span className={chipClassName}>
|
|
29
|
+
<button
|
|
30
|
+
ref={ref}
|
|
31
|
+
type="button"
|
|
32
|
+
className="min-w-0 bg-transparent p-0 text-inherit"
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
<span className="ph-chip__text">{children}</span>
|
|
36
|
+
</button>
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={onRemove}
|
|
40
|
+
disabled={props.disabled}
|
|
41
|
+
className="ph-chip__remove"
|
|
42
|
+
aria-label={removeLabel}
|
|
43
|
+
>
|
|
44
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
|
45
|
+
<path d="M2 2l6 6M8 2l-6 6" />
|
|
46
|
+
</svg>
|
|
47
|
+
</button>
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
17
52
|
return (
|
|
18
53
|
<button
|
|
19
54
|
ref={ref}
|
|
20
55
|
type="button"
|
|
21
|
-
className={
|
|
22
|
-
"ph-chip",
|
|
23
|
-
selected && "ph-chip--selected",
|
|
24
|
-
className
|
|
25
|
-
)}
|
|
56
|
+
className={chipClassName}
|
|
26
57
|
{...props}
|
|
27
58
|
>
|
|
28
59
|
<span className="ph-chip__text">{children}</span>
|
|
29
|
-
{onRemove && (
|
|
30
|
-
<span
|
|
31
|
-
role="button"
|
|
32
|
-
tabIndex={-1}
|
|
33
|
-
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
|
34
|
-
className="ph-chip__remove"
|
|
35
|
-
aria-label="Remove"
|
|
36
|
-
>
|
|
37
|
-
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
38
|
-
<path d="M2 2l6 6M8 2l-6 6" />
|
|
39
|
-
</svg>
|
|
40
|
-
</span>
|
|
41
|
-
)}
|
|
42
60
|
</button>
|
|
43
61
|
);
|
|
44
62
|
});
|
|
@@ -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">
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_THEME_ID,
|
|
6
|
+
persistTheme,
|
|
7
|
+
readStoredTheme,
|
|
8
|
+
THEME_GROUPS,
|
|
9
|
+
THEMES,
|
|
10
|
+
type ThemeId,
|
|
11
|
+
} from "@/themes";
|
|
5
12
|
import { cn } from "@/lib/cn";
|
|
6
13
|
|
|
7
14
|
export interface ThemeSwitcherProps {
|
|
@@ -10,7 +17,7 @@ export interface ThemeSwitcherProps {
|
|
|
10
17
|
|
|
11
18
|
/** Colour-only theme picker — persists to localStorage and sets html[data-theme]. */
|
|
12
19
|
export function ThemeSwitcher({ className }: ThemeSwitcherProps) {
|
|
13
|
-
const [themeId, setThemeId] = useState<ThemeId>(
|
|
20
|
+
const [themeId, setThemeId] = useState<ThemeId>(DEFAULT_THEME_ID);
|
|
14
21
|
|
|
15
22
|
useEffect(() => {
|
|
16
23
|
const id = readStoredTheme() as ThemeId;
|
|
@@ -20,7 +27,9 @@ export function ThemeSwitcher({ className }: ThemeSwitcherProps) {
|
|
|
20
27
|
|
|
21
28
|
return (
|
|
22
29
|
<label className={cn("flex min-w-0 items-center gap-2", className)}>
|
|
23
|
-
<span className="shrink-0 text-xs font-medium text-ph-mutedtext sm:text-sm">
|
|
30
|
+
<span className="shrink-0 text-xs font-medium text-ph-mutedtext sm:text-sm">
|
|
31
|
+
Theme
|
|
32
|
+
</span>
|
|
24
33
|
<select
|
|
25
34
|
className="ph-select max-w-[11rem] !min-h-[2rem] py-1 pl-2.5 pr-8 text-xs sm:max-w-[14rem] sm:text-sm"
|
|
26
35
|
value={themeId}
|