@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.
Files changed (109) hide show
  1. package/README.md +9 -10
  2. package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
  3. package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
  4. package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
  5. package/dist/chunk-2EO5VCXP.mjs +3912 -0
  6. package/dist/chunk-APKRZQRO.mjs +251 -0
  7. package/dist/chunk-BS7BZI5W.mjs +19 -0
  8. package/dist/chunk-IULORI3A.mjs +8 -0
  9. package/dist/index.d.mts +3 -3
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +997 -576
  12. package/dist/index.mjs +49 -45
  13. package/dist/suite.d.mts +157 -55
  14. package/dist/suite.d.ts +157 -55
  15. package/dist/suite.js +4249 -801
  16. package/dist/suite.mjs +2578 -594
  17. package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
  18. package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
  19. package/dist/theme-boot.d.mts +2 -0
  20. package/dist/theme-boot.d.ts +2 -0
  21. package/dist/theme-boot.js +44 -0
  22. package/dist/theme-boot.mjs +8 -0
  23. package/dist/ui.d.mts +5 -29
  24. package/dist/ui.d.ts +5 -29
  25. package/dist/ui.js +647 -352
  26. package/dist/ui.mjs +10 -12
  27. package/docs/README.md +34 -0
  28. package/docs/ai-quick-reference.md +56 -0
  29. package/docs/component-library-improvement-plan.md +751 -0
  30. package/docs/component-reference.md +81 -0
  31. package/docs/components.md +48 -0
  32. package/docs/demos.md +19 -0
  33. package/docs/installation.md +117 -0
  34. package/docs/suite-audit.md +34 -0
  35. package/docs/theme-token-reference.md +67 -0
  36. package/docs/themes.md +164 -0
  37. package/package.json +22 -21
  38. package/public/fonts/OpenRunde-Bold.woff2 +0 -0
  39. package/public/fonts/OpenRunde-Medium.woff2 +0 -0
  40. package/public/fonts/OpenRunde-Regular.woff2 +0 -0
  41. package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
  42. package/scripts/package-smoke.mjs +43 -0
  43. package/src/app/globals.css +2 -1
  44. package/src/components/ui/Accordion.tsx +20 -6
  45. package/src/components/ui/Avatar.tsx +5 -3
  46. package/src/components/ui/Calendar.tsx +187 -63
  47. package/src/components/ui/Card.test.tsx +38 -0
  48. package/src/components/ui/Card.tsx +77 -14
  49. package/src/components/ui/CollapsibleSection.tsx +12 -4
  50. package/src/components/ui/CommandPalette.test.tsx +91 -4
  51. package/src/components/ui/CommandPalette.tsx +138 -128
  52. package/src/components/ui/EmptyState.tsx +2 -2
  53. package/src/components/ui/FormField.tsx +1 -1
  54. package/src/components/ui/Input.tsx +42 -18
  55. package/src/components/ui/Kbd.tsx +18 -3
  56. package/src/components/ui/Modal.tsx +3 -1
  57. package/src/components/ui/Progress.tsx +14 -5
  58. package/src/components/ui/SegmentedControl.tsx +2 -2
  59. package/src/components/ui/SettingsLayout.tsx +1 -1
  60. package/src/components/ui/Stat.tsx +3 -2
  61. package/src/components/ui/Table.tsx +8 -3
  62. package/src/components/ui/Tooltip.test.tsx +70 -1
  63. package/src/components/ui/Tooltip.tsx +53 -29
  64. package/src/components/ui/Typography.tsx +103 -49
  65. package/src/components/ui/avatar-stat.test.tsx +23 -0
  66. package/src/components/ui/calendar.test.tsx +88 -0
  67. package/src/components/ui/disclosure.test.tsx +39 -0
  68. package/src/components/ui/index.ts +44 -6
  69. package/src/components/ui/progress.test.tsx +15 -0
  70. package/src/components/ui/typography.test.tsx +26 -1
  71. package/src/styles/suite-skin.css +8 -2
  72. package/src/styles/themes.css +184 -68
  73. package/src/suite/components/ai-message-blocknote.tsx +13 -2
  74. package/src/suite/components/ai-panel.tsx +41 -10
  75. package/src/suite/components/app-switcher.test.tsx +21 -6
  76. package/src/suite/components/app-switcher.tsx +7 -11
  77. package/src/suite/components/suite-app-layout.tsx +12 -3
  78. package/src/suite/components/suite-bottom-nav.tsx +0 -1
  79. package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
  80. package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
  81. package/src/suite/components/suite-integration-picker.test.tsx +224 -0
  82. package/src/suite/components/suite-integration-picker.tsx +939 -0
  83. package/src/suite/components/suite-integration-rules.test.tsx +157 -0
  84. package/src/suite/components/suite-integration-rules.tsx +471 -0
  85. package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
  86. package/src/suite/components/suite-mobile-drawer.tsx +65 -98
  87. package/src/suite/components/suite-notification-bell.tsx +29 -4
  88. package/src/suite/components/suite-sidebar.tsx +25 -25
  89. package/src/suite/components/suite-skeleton.tsx +2 -2
  90. package/src/suite/components/suite-user-menu.tsx +9 -8
  91. package/src/suite/components/theme-provider.test.tsx +87 -3
  92. package/src/suite/components/theme-provider.tsx +144 -23
  93. package/src/suite/components/today-ui.tsx +79 -68
  94. package/src/suite/components/workspace-switcher.test.tsx +56 -0
  95. package/src/suite/components/workspace-switcher.tsx +3 -3
  96. package/src/suite/icons/app-accents.ts +12 -12
  97. package/src/suite/icons/glyphs.ts +6 -12
  98. package/src/suite/index.ts +27 -0
  99. package/src/suite/lib/apps.test.ts +9 -1
  100. package/src/suite/lib/apps.ts +14 -8
  101. package/src/suite/lib/cookies.ts +53 -0
  102. package/src/suite/lib/theme-boot.ts +32 -0
  103. package/src/suite/lib/theme-cookie.ts +7 -0
  104. package/src/suite/lib/use-sidebar-width.ts +5 -51
  105. package/dist/chunk-KDR6HI6F.mjs +0 -1075
  106. package/dist/chunk-NOI42JNZ.mjs +0 -151
  107. package/dist/chunk-VXISSXTB.mjs +0 -2564
  108. package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
  109. 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
+ }
@@ -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-nav__group h3 {
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 className={cn("divide-y divide-ph-border rounded-xl border border-ph-border bg-ph-surface", className)}>
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-emerald-500",
31
+ online: "bg-ph-success",
32
32
  offline: "bg-ph-mutedtext",
33
- away: "bg-amber-400",
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-white",
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
- export function Calendar({ value, onChange, className }: CalendarProps) {
14
- const [currentMonth, setCurrentMonth] = useState(value || new Date());
15
- const today = new Date();
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
- const year = currentMonth.getFullYear();
18
- const month = currentMonth.getMonth();
22
+ function startOfDay(date: Date) {
23
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
24
+ }
19
25
 
20
- const firstDay = new Date(year, month, 1).getDay();
21
- const daysInMonth = new Date(year, month + 1, 0).getDate();
26
+ function startOfMonth(date: Date) {
27
+ return new Date(date.getFullYear(), date.getMonth(), 1);
28
+ }
22
29
 
23
- const prevMonth = () => setCurrentMonth(new Date(year, month - 1, 1));
24
- const nextMonth = () => setCurrentMonth(new Date(year, month + 1, 1));
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
- const monthNames = [
27
- "January", "February", "March", "April", "May", "June",
28
- "July", "August", "September", "October", "November", "December",
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
- const weekDays = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
58
+ function dateLabel(date: Date) {
59
+ return `${fullWeekDays[date.getDay()]}, ${date.getDate()} ${monthNames[date.getMonth()]} ${date.getFullYear()}`;
60
+ }
32
61
 
33
- const isSelected = (day: number) => {
34
- if (!value) return false;
35
- return (
36
- value.getDate() === day &&
37
- value.getMonth() === month &&
38
- value.getFullYear() === year
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 isToday = (day: number) => {
43
- return (
44
- today.getDate() === day &&
45
- today.getMonth() === month &&
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 = (day: number) => {
51
- onChange?.(new Date(year, month, day));
100
+ const handleSelect = (date: Date) => {
101
+ setFocusedDate(date);
102
+ onChange?.(startOfDay(date));
52
103
  };
53
104
 
54
- const days = [];
55
- for (let i = 0; i < firstDay; i++) {
56
- days.push(null);
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
- for (let i = 1; i <= daysInMonth; i++) {
59
- days.push(i);
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={prevMonth}
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={nextMonth}
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 className="grid grid-cols-7 gap-1">
87
- {weekDays.map((day) => (
88
- <div key={day} className="py-1 text-center text-xs font-medium text-ph-mutedtext">
89
- {day}
90
- </div>
91
- ))}
92
- {days.map((day, index) => (
93
- <div key={index} className="aspect-square">
94
- {day && (
95
- <button
96
- type="button"
97
- onClick={() => handleSelect(day)}
98
- className={cn(
99
- "flex h-full w-full items-center justify-center rounded-lg text-sm transition-colors",
100
- isSelected(day)
101
- ? "bg-ph-brand font-semibold text-[var(--ph-on-accent)] shadow-ph"
102
- : isToday(day)
103
- ? "bg-ph-muted font-semibold text-ph-brand ring-1 ring-ph-brand/30"
104
- : "text-ph-ink hover:bg-ph-muted"
105
- )}
106
- >
107
- {day}
108
- </button>
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 { forwardRef, type HTMLAttributes, type ReactNode } from "react";
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<HTMLDivElement, CardProps>(function Card({
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
- return (
42
- <div
43
- ref={ref}
44
- className={cn(
45
- "ph-card",
46
- variantMap[variant],
47
- interactive && "ph-card-interactive",
48
- className
49
- )}
50
- {...props}
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({ title, children, defaultOpen = true, id }: CollapsibleSectionProps) {
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}