@xenide-io/the-old-ui-theme 0.7.0 → 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-2EO5VCXP.mjs +3912 -0
- 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 +49 -45
- package/dist/suite.d.mts +157 -55
- package/dist/suite.d.ts +157 -55
- package/dist/suite.js +4249 -801
- package/dist/suite.mjs +2578 -594
- 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 +10 -12
- 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 +939 -0
- 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 +27 -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-KDR6HI6F.mjs +0 -1075
- package/dist/chunk-NOI42JNZ.mjs +0 -151
- package/dist/chunk-VXISSXTB.mjs +0 -2564
- 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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const packageJson = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
9
|
+
const packed = JSON.parse(
|
|
10
|
+
execFileSync("npm", ["pack", "--dry-run", "--json"], {
|
|
11
|
+
cwd: packageRoot,
|
|
12
|
+
encoding: "utf8",
|
|
13
|
+
}),
|
|
14
|
+
)[0];
|
|
15
|
+
const packedPaths = new Set(packed.files.map(({ path: filePath }) => filePath));
|
|
16
|
+
|
|
17
|
+
const expectedFiles = [
|
|
18
|
+
"README.md",
|
|
19
|
+
"docs/README.md",
|
|
20
|
+
"public/fonts/OpenRunde-Regular.woff2",
|
|
21
|
+
"public/fonts/OpenRunde-Medium.woff2",
|
|
22
|
+
"public/fonts/OpenRunde-Semibold.woff2",
|
|
23
|
+
"public/fonts/OpenRunde-Bold.woff2",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
for (const [subpath, target] of Object.entries(packageJson.exports)) {
|
|
27
|
+
const targets = typeof target === "string" ? [target] : Object.values(target);
|
|
28
|
+
|
|
29
|
+
for (const exportTarget of targets) {
|
|
30
|
+
expectedFiles.push(exportTarget.replace(/^\.\//, ""));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof target !== "string" && target.require) {
|
|
34
|
+
const requirePackage = createRequire(import.meta.url);
|
|
35
|
+
requirePackage(`${packageJson.name}${subpath === "." ? "" : subpath.slice(1)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const missingFiles = expectedFiles.filter((filePath) => !packedPaths.has(filePath));
|
|
40
|
+
|
|
41
|
+
if (missingFiles.length > 0) {
|
|
42
|
+
throw new Error(`npm package is missing required files:\n${missingFiles.join("\n")}`);
|
|
43
|
+
}
|
package/src/app/globals.css
CHANGED
|
@@ -847,6 +847,7 @@ body {
|
|
|
847
847
|
.ph-checkbox-icon {
|
|
848
848
|
@apply pointer-events-none absolute inset-0 h-4 w-4 scale-75 opacity-0;
|
|
849
849
|
color: var(--ph-on-accent);
|
|
850
|
+
stroke-width: 3;
|
|
850
851
|
transition: opacity var(--ph-motion-fast) ease;
|
|
851
852
|
}
|
|
852
853
|
|
|
@@ -1788,7 +1789,7 @@ body {
|
|
|
1788
1789
|
@apply space-y-5;
|
|
1789
1790
|
}
|
|
1790
1791
|
|
|
1791
|
-
.ph-settings-
|
|
1792
|
+
.ph-settings-nav__heading {
|
|
1792
1793
|
@apply mb-1 px-2 text-[10px] font-semibold uppercase tracking-wider text-ph-mutedtext;
|
|
1793
1794
|
}
|
|
1794
1795
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useId, useState } from "react";
|
|
4
4
|
import { cn } from "@/lib/cn";
|
|
5
5
|
import { IconChevronDown } from "@/components/icons";
|
|
6
6
|
|
|
7
|
-
interface AccordionItem {
|
|
7
|
+
export interface AccordionItem {
|
|
8
8
|
id: string;
|
|
9
9
|
title: string;
|
|
10
10
|
content: React.ReactNode;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
interface AccordionProps {
|
|
13
|
+
export interface AccordionProps {
|
|
14
14
|
items: AccordionItem[];
|
|
15
15
|
allowMultiple?: boolean;
|
|
16
16
|
defaultOpen?: string[];
|
|
@@ -24,6 +24,7 @@ export function Accordion({
|
|
|
24
24
|
className,
|
|
25
25
|
}: AccordionProps) {
|
|
26
26
|
const [openItems, setOpenItems] = useState<Set<string>>(new Set(defaultOpen));
|
|
27
|
+
const idPrefix = useId();
|
|
27
28
|
|
|
28
29
|
const toggleItem = (id: string) => {
|
|
29
30
|
setOpenItems((prev) => {
|
|
@@ -41,28 +42,41 @@ export function Accordion({
|
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
return (
|
|
44
|
-
<div
|
|
45
|
+
<div
|
|
46
|
+
className={cn(
|
|
47
|
+
"divide-y divide-ph-border rounded-xl border border-ph-border bg-ph-surface",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
45
51
|
{items.map((item) => {
|
|
46
52
|
const isOpen = openItems.has(item.id);
|
|
53
|
+
const contentId = `accordion-${idPrefix}-${item.id}-content`;
|
|
47
54
|
return (
|
|
48
55
|
<div key={item.id} className="first:rounded-t-xl last:rounded-b-xl">
|
|
49
56
|
<button
|
|
50
57
|
type="button"
|
|
51
58
|
onClick={() => toggleItem(item.id)}
|
|
59
|
+
aria-expanded={isOpen}
|
|
60
|
+
aria-controls={contentId}
|
|
52
61
|
className="flex w-full items-center justify-between px-5 py-4 text-left transition hover:bg-ph-muted"
|
|
53
62
|
>
|
|
54
63
|
<span className="font-semibold text-ph-ink">{item.title}</span>
|
|
55
64
|
<IconChevronDown
|
|
56
65
|
className={cn(
|
|
57
66
|
"h-5 w-5 text-ph-mutedtext transition-transform duration-200",
|
|
58
|
-
isOpen && "rotate-180"
|
|
67
|
+
isOpen && "rotate-180",
|
|
59
68
|
)}
|
|
60
69
|
/>
|
|
61
70
|
</button>
|
|
62
71
|
<div
|
|
72
|
+
id={contentId}
|
|
73
|
+
aria-hidden={!isOpen}
|
|
74
|
+
ref={(node) => {
|
|
75
|
+
if (node) node.toggleAttribute("inert", !isOpen);
|
|
76
|
+
}}
|
|
63
77
|
className={cn(
|
|
64
78
|
"grid transition-all duration-200",
|
|
65
|
-
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
|
79
|
+
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
|
66
80
|
)}
|
|
67
81
|
>
|
|
68
82
|
<div className="overflow-hidden">
|
|
@@ -28,9 +28,9 @@ const sizeMap: Record<AvatarSize, string> = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const statusMap: Record<AvatarStatus, string> = {
|
|
31
|
-
online: "bg-
|
|
31
|
+
online: "bg-ph-success",
|
|
32
32
|
offline: "bg-ph-mutedtext",
|
|
33
|
-
away: "bg-
|
|
33
|
+
away: "bg-ph-warning",
|
|
34
34
|
busy: "bg-ph-danger",
|
|
35
35
|
};
|
|
36
36
|
|
|
@@ -57,8 +57,10 @@ export function Avatar({
|
|
|
57
57
|
</div>
|
|
58
58
|
{status && (
|
|
59
59
|
<span
|
|
60
|
+
role="img"
|
|
61
|
+
aria-label={`${status} status`}
|
|
60
62
|
className={cn(
|
|
61
|
-
"absolute bottom-0 right-1 h-2.5 w-2.5 rounded-full border-2 border-
|
|
63
|
+
"absolute bottom-0 right-1 h-2.5 w-2.5 rounded-full border-2 border-ph-surface",
|
|
62
64
|
statusMap[status],
|
|
63
65
|
)}
|
|
64
66
|
/>
|
|
@@ -1,62 +1,159 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
4
|
import { IconChevronLeft, IconChevronRight } from "@/components/icons";
|
|
5
5
|
import { cn } from "@/lib/cn";
|
|
6
6
|
|
|
7
|
-
interface CalendarProps {
|
|
7
|
+
export interface CalendarProps {
|
|
8
8
|
value?: Date;
|
|
9
9
|
onChange?: (date: Date) => void;
|
|
10
10
|
className?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const monthNames = [
|
|
14
|
+
"January", "February", "March", "April", "May", "June",
|
|
15
|
+
"July", "August", "September", "October", "November", "December",
|
|
16
|
+
];
|
|
17
|
+
const weekDays = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
|
18
|
+
const fullWeekDays = [
|
|
19
|
+
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
|
|
20
|
+
];
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
function startOfDay(date: Date) {
|
|
23
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
24
|
+
}
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
function startOfMonth(date: Date) {
|
|
27
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
28
|
+
}
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
function isSameDay(first: Date, second: Date) {
|
|
31
|
+
return (
|
|
32
|
+
first.getFullYear() === second.getFullYear() &&
|
|
33
|
+
first.getMonth() === second.getMonth() &&
|
|
34
|
+
first.getDate() === second.getDate()
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addDays(date: Date, amount: number) {
|
|
39
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function addMonths(date: Date, amount: number) {
|
|
43
|
+
const targetMonth = new Date(date.getFullYear(), date.getMonth() + amount, 1);
|
|
44
|
+
const lastDay = new Date(
|
|
45
|
+
targetMonth.getFullYear(),
|
|
46
|
+
targetMonth.getMonth() + 1,
|
|
47
|
+
0,
|
|
48
|
+
).getDate();
|
|
25
49
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
50
|
+
return new Date(
|
|
51
|
+
targetMonth.getFullYear(), targetMonth.getMonth(), Math.min(date.getDate(), lastDay));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dateKey(date: Date) {
|
|
55
|
+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
56
|
+
}
|
|
30
57
|
|
|
31
|
-
|
|
58
|
+
function dateLabel(date: Date) {
|
|
59
|
+
return `${fullWeekDays[date.getDay()]}, ${date.getDate()} ${monthNames[date.getMonth()]} ${date.getFullYear()}`;
|
|
60
|
+
}
|
|
32
61
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
62
|
+
export function Calendar({ value, onChange, className }: CalendarProps) {
|
|
63
|
+
const [currentMonth, setCurrentMonth] = useState(() => startOfMonth(value ?? new Date()));
|
|
64
|
+
const [focusedDate, setFocusedDate] = useState(() => startOfDay(value ?? new Date()));
|
|
65
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
66
|
+
const shouldFocusDateRef = useRef(false);
|
|
67
|
+
const monthLabelId = useId();
|
|
68
|
+
const today = startOfDay(new Date());
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!value) return;
|
|
72
|
+
const nextDate = startOfDay(value);
|
|
73
|
+
setCurrentMonth(startOfMonth(nextDate));
|
|
74
|
+
setFocusedDate(nextDate);
|
|
75
|
+
}, [value]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!shouldFocusDateRef.current) return;
|
|
79
|
+
gridRef.current
|
|
80
|
+
?.querySelector<HTMLButtonElement>(`[data-date="${dateKey(focusedDate)}"]`)
|
|
81
|
+
?.focus();
|
|
82
|
+
shouldFocusDateRef.current = false;
|
|
83
|
+
}, [currentMonth, focusedDate]);
|
|
84
|
+
|
|
85
|
+
const year = currentMonth.getFullYear();
|
|
86
|
+
const month = currentMonth.getMonth();
|
|
87
|
+
|
|
88
|
+
const changeMonth = (amount: number) => {
|
|
89
|
+
const nextDate = addMonths(focusedDate, amount);
|
|
90
|
+
setFocusedDate(nextDate);
|
|
91
|
+
setCurrentMonth(startOfMonth(nextDate));
|
|
40
92
|
};
|
|
41
93
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
today.getFullYear() === year
|
|
47
|
-
);
|
|
94
|
+
const moveFocus = (nextDate: Date) => {
|
|
95
|
+
shouldFocusDateRef.current = true;
|
|
96
|
+
setFocusedDate(nextDate);
|
|
97
|
+
setCurrentMonth(startOfMonth(nextDate));
|
|
48
98
|
};
|
|
49
99
|
|
|
50
|
-
const handleSelect = (
|
|
51
|
-
|
|
100
|
+
const handleSelect = (date: Date) => {
|
|
101
|
+
setFocusedDate(date);
|
|
102
|
+
onChange?.(startOfDay(date));
|
|
52
103
|
};
|
|
53
104
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
105
|
+
const handleDayKeyDown = (
|
|
106
|
+
event: React.KeyboardEvent<HTMLButtonElement>,
|
|
107
|
+
date: Date,
|
|
108
|
+
) => {
|
|
109
|
+
let nextDate: Date | undefined;
|
|
110
|
+
|
|
111
|
+
switch (event.key) {
|
|
112
|
+
case "ArrowLeft":
|
|
113
|
+
nextDate = addDays(date, -1);
|
|
114
|
+
break;
|
|
115
|
+
case "ArrowRight":
|
|
116
|
+
nextDate = addDays(date, 1);
|
|
117
|
+
break;
|
|
118
|
+
case "ArrowUp":
|
|
119
|
+
nextDate = addDays(date, -7);
|
|
120
|
+
break;
|
|
121
|
+
case "ArrowDown":
|
|
122
|
+
nextDate = addDays(date, 7);
|
|
123
|
+
break;
|
|
124
|
+
case "Home":
|
|
125
|
+
nextDate = addDays(date, -date.getDay());
|
|
126
|
+
break;
|
|
127
|
+
case "End":
|
|
128
|
+
nextDate = addDays(date, 6 - date.getDay());
|
|
129
|
+
break;
|
|
130
|
+
case "PageUp":
|
|
131
|
+
nextDate = addMonths(date, event.shiftKey ? -12 : -1);
|
|
132
|
+
break;
|
|
133
|
+
case "PageDown":
|
|
134
|
+
nextDate = addMonths(date, event.shiftKey ? 12 : 1);
|
|
135
|
+
break;
|
|
136
|
+
case "Enter":
|
|
137
|
+
case " ":
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
handleSelect(date);
|
|
140
|
+
return;
|
|
141
|
+
default:
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
moveFocus(nextDate);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const firstDay = new Date(year, month, 1).getDay();
|
|
150
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
151
|
+
const days: Array<Date | null> = Array.from({ length: firstDay }, () => null);
|
|
152
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
153
|
+
days.push(new Date(year, month, day));
|
|
57
154
|
}
|
|
58
|
-
|
|
59
|
-
days.push(
|
|
155
|
+
while (days.length < 42) {
|
|
156
|
+
days.push(null);
|
|
60
157
|
}
|
|
61
158
|
|
|
62
159
|
return (
|
|
@@ -64,18 +161,18 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
|
|
|
64
161
|
<div className="mb-4 flex items-center justify-between">
|
|
65
162
|
<button
|
|
66
163
|
type="button"
|
|
67
|
-
onClick={
|
|
164
|
+
onClick={() => changeMonth(-1)}
|
|
68
165
|
aria-label="Previous month"
|
|
69
166
|
className="rounded-lg p-1 text-ph-subtle transition hover:bg-ph-muted hover:text-ph-ink"
|
|
70
167
|
>
|
|
71
168
|
<IconChevronLeft className="h-5 w-5" aria-hidden />
|
|
72
169
|
</button>
|
|
73
|
-
<span className="text-sm font-semibold text-ph-ink">
|
|
170
|
+
<span id={monthLabelId} aria-live="polite" className="text-sm font-semibold text-ph-ink">
|
|
74
171
|
{monthNames[month]} {year}
|
|
75
172
|
</span>
|
|
76
173
|
<button
|
|
77
174
|
type="button"
|
|
78
|
-
onClick={
|
|
175
|
+
onClick={() => changeMonth(1)}
|
|
79
176
|
aria-label="Next month"
|
|
80
177
|
className="rounded-lg p-1 text-ph-subtle transition hover:bg-ph-muted hover:text-ph-ink"
|
|
81
178
|
>
|
|
@@ -83,32 +180,59 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
|
|
|
83
180
|
</button>
|
|
84
181
|
</div>
|
|
85
182
|
|
|
86
|
-
<div
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
183
|
+
<div ref={gridRef} role="grid" aria-labelledby={monthLabelId}>
|
|
184
|
+
<div role="row" className="grid grid-cols-7 gap-1">
|
|
185
|
+
{weekDays.map((day, index) => (
|
|
186
|
+
<div
|
|
187
|
+
key={day}
|
|
188
|
+
role="columnheader"
|
|
189
|
+
aria-label={fullWeekDays[index]}
|
|
190
|
+
className="py-1 text-center text-xs font-medium text-ph-mutedtext"
|
|
191
|
+
>
|
|
192
|
+
{day}
|
|
193
|
+
</div>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
{Array.from({ length: 6 }, (_, week) => {
|
|
197
|
+
const weekDates = days.slice(week * 7, week * 7 + 7);
|
|
198
|
+
if (weekDates.length === 0 || weekDates.every((date) => !date)) return null;
|
|
199
|
+
return (
|
|
200
|
+
<div key={week} role="row" className="grid grid-cols-7 gap-1">
|
|
201
|
+
{weekDates.map((date, day) =>
|
|
202
|
+
date ? (
|
|
203
|
+
<div
|
|
204
|
+
key={dateKey(date)}
|
|
205
|
+
role="gridcell"
|
|
206
|
+
aria-selected={value ? isSameDay(value, date) : false}
|
|
207
|
+
className="aspect-square"
|
|
208
|
+
>
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
data-date={dateKey(date)}
|
|
212
|
+
aria-label={dateLabel(date)}
|
|
213
|
+
aria-current={isSameDay(today, date) ? "date" : undefined}
|
|
214
|
+
tabIndex={isSameDay(focusedDate, date) ? 0 : -1}
|
|
215
|
+
onClick={() => handleSelect(date)}
|
|
216
|
+
onKeyDown={(event) => handleDayKeyDown(event, date)}
|
|
217
|
+
className={cn(
|
|
218
|
+
"flex h-full w-full items-center justify-center rounded-lg text-sm transition-colors",
|
|
219
|
+
value && isSameDay(value, date)
|
|
220
|
+
? "bg-ph-brand font-semibold text-[var(--ph-on-accent)] shadow-ph"
|
|
221
|
+
: isSameDay(today, date)
|
|
222
|
+
? "bg-ph-muted font-semibold text-ph-brand ring-1 ring-ph-brand/30"
|
|
223
|
+
: "text-ph-ink hover:bg-ph-muted",
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
226
|
+
{date.getDate()}
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
) : (
|
|
230
|
+
<div key={`empty-${week}-${day}`} role="gridcell" aria-hidden="true" className="aspect-square" />
|
|
231
|
+
),
|
|
109
232
|
)}
|
|
110
233
|
</div>
|
|
111
|
-
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
112
236
|
</div>
|
|
113
237
|
</div>
|
|
114
238
|
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { Card } from "@/components/ui/Card";
|
|
5
|
+
|
|
6
|
+
describe("Card", () => {
|
|
7
|
+
it("renders an onClick card as a keyboard-operable button", async () => {
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
const onClick = vi.fn();
|
|
10
|
+
|
|
11
|
+
render(<Card title="Open report" onClick={onClick} />);
|
|
12
|
+
|
|
13
|
+
const card = screen.getByRole("button", { name: "Open report" });
|
|
14
|
+
expect(card).toHaveAttribute("type", "button");
|
|
15
|
+
expect(card).toHaveClass("ph-card-interactive");
|
|
16
|
+
|
|
17
|
+
await user.tab();
|
|
18
|
+
await user.keyboard("{Enter}");
|
|
19
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders an href card as a link", () => {
|
|
23
|
+
render(<Card title="Project settings" href="/settings" target="_blank" />);
|
|
24
|
+
|
|
25
|
+
const card = screen.getByRole("link", { name: "Project settings" });
|
|
26
|
+
expect(card).toHaveAttribute("href", "/settings");
|
|
27
|
+
expect(card).toHaveAttribute("target", "_blank");
|
|
28
|
+
expect(card).toHaveClass("ph-card-interactive");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps visual-only interactive cards as non-interactive containers", () => {
|
|
32
|
+
render(<Card title="Card with actions" interactive />);
|
|
33
|
+
|
|
34
|
+
expect(screen.getByText("Card with actions").closest(".ph-card")).toBeInstanceOf(
|
|
35
|
+
HTMLDivElement,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
type AnchorHTMLAttributes,
|
|
4
|
+
type ButtonHTMLAttributes,
|
|
5
|
+
type HTMLAttributes,
|
|
6
|
+
type MouseEventHandler,
|
|
7
|
+
type Ref,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
2
10
|
import { cn } from "@/lib/cn";
|
|
3
11
|
|
|
4
12
|
export type CardVariant = "default" | "outlined" | "elevated" | "highlighted";
|
|
5
13
|
|
|
6
|
-
export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
14
|
+
export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title" | "onClick"> {
|
|
7
15
|
children?: ReactNode;
|
|
8
16
|
title?: ReactNode;
|
|
9
17
|
description?: ReactNode;
|
|
@@ -13,7 +21,22 @@ export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title">
|
|
|
13
21
|
variant?: CardVariant;
|
|
14
22
|
className?: string;
|
|
15
23
|
bodyClassName?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Applies interactive chrome to a card containing its own semantic control.
|
|
26
|
+
* This alone does not make the card clickable; use `href` or `onClick` for a
|
|
27
|
+
* full-card action.
|
|
28
|
+
*/
|
|
16
29
|
interactive?: boolean;
|
|
30
|
+
/** Renders the full card as a semantic link. Do not combine with nested controls. */
|
|
31
|
+
href?: string;
|
|
32
|
+
/** Renders the full card as a semantic button. Do not combine with nested controls. */
|
|
33
|
+
onClick?: MouseEventHandler<HTMLElement>;
|
|
34
|
+
/** Link target, used when `href` is provided. */
|
|
35
|
+
target?: string;
|
|
36
|
+
/** Link relationship, used when `href` is provided. */
|
|
37
|
+
rel?: string;
|
|
38
|
+
/** Button behaviour, used when `onClick` is provided without `href`. */
|
|
39
|
+
type?: "button" | "submit" | "reset";
|
|
17
40
|
titleAs?: "h2" | "h3" | "h4";
|
|
18
41
|
}
|
|
19
42
|
|
|
@@ -24,7 +47,7 @@ const variantMap: Record<CardVariant, string> = {
|
|
|
24
47
|
highlighted: "ph-card-highlighted",
|
|
25
48
|
};
|
|
26
49
|
|
|
27
|
-
export const Card = forwardRef<
|
|
50
|
+
export const Card = forwardRef<HTMLElement, CardProps>(function Card({
|
|
28
51
|
children,
|
|
29
52
|
title,
|
|
30
53
|
description,
|
|
@@ -35,20 +58,24 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card({
|
|
|
35
58
|
className,
|
|
36
59
|
bodyClassName,
|
|
37
60
|
interactive = false,
|
|
61
|
+
href,
|
|
62
|
+
onClick,
|
|
63
|
+
target,
|
|
64
|
+
rel,
|
|
65
|
+
type,
|
|
38
66
|
titleAs: Title = "h3",
|
|
39
67
|
...props
|
|
40
68
|
}, ref) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
>
|
|
69
|
+
const isLink = href !== undefined;
|
|
70
|
+
const isButton = onClick !== undefined && !isLink;
|
|
71
|
+
const cardClassName = cn(
|
|
72
|
+
"ph-card",
|
|
73
|
+
variantMap[variant],
|
|
74
|
+
(interactive || isLink || isButton) && "ph-card-interactive",
|
|
75
|
+
className,
|
|
76
|
+
);
|
|
77
|
+
const cardContent = (
|
|
78
|
+
<>
|
|
52
79
|
{media && <div className="ph-card-media">{media}</div>}
|
|
53
80
|
<div className={cn("ph-card-body", bodyClassName)}>
|
|
54
81
|
{title && (
|
|
@@ -63,6 +90,42 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card({
|
|
|
63
90
|
{footer && (
|
|
64
91
|
<div className="border-t border-ph-border px-6 py-4 text-xs text-ph-subtle">{footer}</div>
|
|
65
92
|
)}
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (isLink) {
|
|
97
|
+
return (
|
|
98
|
+
<a
|
|
99
|
+
ref={ref as Ref<HTMLAnchorElement>}
|
|
100
|
+
className={cardClassName}
|
|
101
|
+
href={href}
|
|
102
|
+
target={target}
|
|
103
|
+
rel={rel}
|
|
104
|
+
onClick={onClick}
|
|
105
|
+
{...(props as AnchorHTMLAttributes<HTMLAnchorElement>)}
|
|
106
|
+
>
|
|
107
|
+
{cardContent}
|
|
108
|
+
</a>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (isButton) {
|
|
113
|
+
return (
|
|
114
|
+
<button
|
|
115
|
+
ref={ref as Ref<HTMLButtonElement>}
|
|
116
|
+
type={type ?? "button"}
|
|
117
|
+
className={cardClassName}
|
|
118
|
+
onClick={onClick}
|
|
119
|
+
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
|
|
120
|
+
>
|
|
121
|
+
{cardContent}
|
|
122
|
+
</button>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div ref={ref as Ref<HTMLDivElement>} className={cardClassName} {...props}>
|
|
128
|
+
{cardContent}
|
|
66
129
|
</div>
|
|
67
130
|
);
|
|
68
131
|
});
|
|
@@ -4,14 +4,19 @@ import { useId, useState } from "react";
|
|
|
4
4
|
import { IconChevronDown } from "@/components/icons";
|
|
5
5
|
import { cn } from "@/lib/cn";
|
|
6
6
|
|
|
7
|
-
interface CollapsibleSectionProps {
|
|
7
|
+
export interface CollapsibleSectionProps {
|
|
8
8
|
title: string;
|
|
9
9
|
children: React.ReactNode;
|
|
10
10
|
defaultOpen?: boolean;
|
|
11
11
|
id?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export function CollapsibleSection({
|
|
14
|
+
export function CollapsibleSection({
|
|
15
|
+
title,
|
|
16
|
+
children,
|
|
17
|
+
defaultOpen = true,
|
|
18
|
+
id,
|
|
19
|
+
}: CollapsibleSectionProps) {
|
|
15
20
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
16
21
|
const generatedId = useId();
|
|
17
22
|
const contentId = `${id ?? generatedId}-content`;
|
|
@@ -29,7 +34,7 @@ export function CollapsibleSection({ title, children, defaultOpen = true, id }:
|
|
|
29
34
|
<IconChevronDown
|
|
30
35
|
className={cn(
|
|
31
36
|
"h-5 w-5 text-ph-subtle transition-transform duration-200",
|
|
32
|
-
isOpen && "rotate-180"
|
|
37
|
+
isOpen && "rotate-180",
|
|
33
38
|
)}
|
|
34
39
|
aria-hidden
|
|
35
40
|
/>
|
|
@@ -37,9 +42,12 @@ export function CollapsibleSection({ title, children, defaultOpen = true, id }:
|
|
|
37
42
|
<div
|
|
38
43
|
id={contentId}
|
|
39
44
|
aria-hidden={!isOpen}
|
|
45
|
+
ref={(node) => {
|
|
46
|
+
if (node) node.toggleAttribute("inert", !isOpen);
|
|
47
|
+
}}
|
|
40
48
|
className={cn(
|
|
41
49
|
"overflow-hidden transition-all duration-300",
|
|
42
|
-
isOpen ? "max-h-[50000px] opacity-100" : "max-h-0 opacity-0"
|
|
50
|
+
isOpen ? "max-h-[50000px] opacity-100" : "max-h-0 opacity-0",
|
|
43
51
|
)}
|
|
44
52
|
>
|
|
45
53
|
{children}
|