@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.
Files changed (107) 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-YB4TBWHV.mjs → chunk-2EO5VCXP.mjs} +688 -388
  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 +7 -1
  13. package/dist/suite.d.mts +136 -57
  14. package/dist/suite.d.ts +136 -57
  15. package/dist/suite.js +3115 -882
  16. package/dist/suite.mjs +2566 -661
  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 +1 -1
  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 +889 -18
  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 +22 -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-NOI42JNZ.mjs +0 -151
  106. package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
  107. 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
- expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument();
35
- expect(screen.getByRole("textbox", { name: "Search commands" })).toHaveFocus();
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 { useState, useEffect, useRef } from "react";
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 panelRef = useRef<HTMLDivElement>(null);
42
- const previousFocusRef = useRef<HTMLElement | null>(null);
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
- const handleKeyDown = (e: KeyboardEvent) => {
63
- if (!isOpen) return;
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
- window.addEventListener("keydown", handleKeyDown);
104
- return () => window.removeEventListener("keydown", handleKeyDown);
105
- }, [isOpen, filtered, selectedIndex, onClose]);
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
- if (!isOpen) return null;
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
- <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();
99
+ <Dialog.Root
100
+ open={isOpen}
101
+ onOpenChange={(nextOpen) => {
102
+ if (!nextOpen) onClose();
116
103
  }}
117
104
  >
118
- <div
119
- ref={panelRef}
120
- role="dialog"
121
- aria-modal="true"
122
- aria-label="Command palette"
123
- className={cn(
124
- "w-full max-w-xl overflow-hidden rounded-xl border border-ph-border bg-ph-surface shadow-ph-md",
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
- </div>
183
- </div>
184
- </div>
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
- <h3 className="text-base font-semibold text-ph-ink">{title}</h3>
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
- <input
62
- {...fieldProps}
63
- ref={ref}
64
- disabled={disabled}
65
- required={required}
66
- aria-describedby={joinIds(ariaDescribedBy, fieldProps["aria-describedby"])}
67
- className={cn(
68
- "ph-input w-full",
69
- sizeMap[size],
70
- variant === "ghost" && "ph-input-ghost",
71
- hasError && "ph-input-error",
72
- disabled && "cursor-not-allowed opacity-60",
73
- className
74
- )}
75
- {...props}
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 className={cn("ph-code-token ph-kbd", className)} aria-label={ariaLabel} {...props}>
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 className={cn("ph-keycap", className)} aria-label={accessibleLabel} {...props}>
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 className={cn("ph-shortcut-group ph-keycap", className)} aria-label={accessibleLabel} {...props}>
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 { ComponentPropsWithoutRef } from "react";
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 percentage = Math.min(100, Math.max(0, (value / max) * 100));
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={value}
59
+ aria-valuenow={safeValue}
53
60
  aria-valuemin={0}
54
- aria-valuemax={max}
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;