@db-vincent/components 0.0.0-stage → 0.1.1

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 (86) hide show
  1. package/package.json +28 -4
  2. package/src/astro/Accordion.astro +37 -0
  3. package/src/astro/Alert.astro +73 -0
  4. package/src/astro/Avatar.astro +88 -0
  5. package/src/astro/AvatarGroup.astro +63 -0
  6. package/src/astro/Badge.astro +23 -0
  7. package/src/astro/Breadcrumbs.astro +45 -0
  8. package/src/astro/Button.astro +125 -0
  9. package/src/astro/Card.astro +63 -0
  10. package/src/astro/Checkbox.astro +61 -0
  11. package/src/astro/Chip.astro +135 -0
  12. package/src/astro/CodeBlock.astro +17 -0
  13. package/src/astro/Drawer.astro +30 -0
  14. package/src/astro/EmptyState.astro +45 -0
  15. package/src/astro/Example.astro +37 -0
  16. package/src/astro/Grid.astro +56 -0
  17. package/src/astro/GridItem.astro +25 -0
  18. package/src/astro/Icon.astro +52 -0
  19. package/src/astro/Menu.astro +151 -0
  20. package/src/astro/MenuItem.astro +57 -0
  21. package/src/astro/Modal.astro +124 -0
  22. package/src/astro/Navbar.astro +756 -0
  23. package/src/astro/PageHeader.astro +34 -0
  24. package/src/astro/Pagination.astro +212 -0
  25. package/src/astro/Popover.astro +51 -0
  26. package/src/astro/Progress.astro +71 -0
  27. package/src/astro/Radio.astro +58 -0
  28. package/src/astro/RadioGroup.astro +25 -0
  29. package/src/astro/Section.astro +25 -0
  30. package/src/astro/Select.astro +420 -0
  31. package/src/astro/Sidebar.astro +215 -0
  32. package/src/astro/Skeleton.astro +41 -0
  33. package/src/astro/Spinner.astro +20 -0
  34. package/src/astro/Stepper.astro +82 -0
  35. package/src/astro/Switch.astro +78 -0
  36. package/src/astro/Table.astro +268 -0
  37. package/src/astro/Tabs.astro +83 -0
  38. package/src/astro/TextField.astro +305 -0
  39. package/src/astro/ThemeToggle.astro +66 -0
  40. package/src/astro/Toast.astro +181 -0
  41. package/src/astro/Tooltip.astro +54 -0
  42. package/src/astro/TopBar.astro +29 -0
  43. package/src/data/icons.ts +120 -0
  44. package/src/scripts/avatar.ts +33 -0
  45. package/src/scripts/menu.ts +218 -0
  46. package/src/scripts/tabs.ts +127 -0
  47. package/src/scripts/toast.ts +565 -0
  48. package/src/scripts/tooltip.ts +127 -0
  49. package/src/styles/accordion.css +79 -0
  50. package/src/styles/alert.css +108 -0
  51. package/src/styles/avatar.css +131 -0
  52. package/src/styles/badge.css +48 -0
  53. package/src/styles/base.css +205 -0
  54. package/src/styles/breadcrumbs.css +54 -0
  55. package/src/styles/button.css +174 -0
  56. package/src/styles/card.css +112 -0
  57. package/src/styles/chip.css +106 -0
  58. package/src/styles/choice.css +143 -0
  59. package/src/styles/code.css +44 -0
  60. package/src/styles/empty.css +59 -0
  61. package/src/styles/example.css +57 -0
  62. package/src/styles/grid.css +64 -0
  63. package/src/styles/input.css +208 -0
  64. package/src/styles/menu.css +151 -0
  65. package/src/styles/modal.css +285 -0
  66. package/src/styles/motion.css +114 -0
  67. package/src/styles/nav.css +232 -0
  68. package/src/styles/navbar.css +658 -0
  69. package/src/styles/page-header.css +62 -0
  70. package/src/styles/pagination.css +81 -0
  71. package/src/styles/popover.css +134 -0
  72. package/src/styles/progress.css +95 -0
  73. package/src/styles/section.css +38 -0
  74. package/src/styles/select.css +246 -0
  75. package/src/styles/skeleton.css +66 -0
  76. package/src/styles/spinner.css +44 -0
  77. package/src/styles/sr-only.css +14 -0
  78. package/src/styles/stepper.css +169 -0
  79. package/src/styles/switch.css +143 -0
  80. package/src/styles/table.css +177 -0
  81. package/src/styles/tabs.css +120 -0
  82. package/src/styles/theme-toggle.css +31 -0
  83. package/src/styles/toast.css +225 -0
  84. package/src/styles/tooltip.css +63 -0
  85. package/src/styles/topbar.css +32 -0
  86. package/README.md +0 -3
@@ -0,0 +1,120 @@
1
+ export const ICONS: Record<string, string> = {
2
+ "app-window":
3
+ '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 9h20"/><path d="M6 6.5h.01M9 6.5h.01"/>',
4
+ card: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 15h18"/>',
5
+ "check-square":
6
+ '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="m8 12 3 3 5-6"/>',
7
+ "chevrons-up-down": '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
8
+ "circle-dot":
9
+ '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/>',
10
+ loader:
11
+ '<path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/>',
12
+ "message-square":
13
+ '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/>',
14
+ "mouse-pointer": '<path d="m4 4 7 17 2.5-7.5L21 11 4 4Z"/>',
15
+ "panel-top":
16
+ '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/>',
17
+ "rectangle-horizontal": '<rect x="3" y="8" width="18" height="8" rx="2"/>',
18
+ rows: '<rect x="3" y="3" width="18" height="7" rx="1.5"/><rect x="3" y="14" width="18" height="7" rx="1.5"/>',
19
+ "square-dashed":
20
+ '<path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/>',
21
+ table:
22
+ '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 9v12"/>',
23
+ tag: '<path d="M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4Z"/><circle cx="7.5" cy="7.5" r="1"/>',
24
+ "text-cursor":
25
+ '<path d="M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1"/><path d="M7 22h1a4 4 0 0 0 4-4v-1"/><path d="M7 2h1a4 4 0 0 1 4 4v1"/>',
26
+ "alert-circle":
27
+ '<circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/>',
28
+ "alert-triangle":
29
+ '<path d="M10.3 3.6 2.4 17a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.6a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
30
+ "arrow-left": '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
31
+ "arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
32
+ "arrow-up-right": '<path d="M7 17 17 7"/><path d="M7 7h10v10"/>',
33
+ activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
34
+ bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>',
35
+ book: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/>',
36
+ box: '<path d="m21 8-9 5-9-5"/><path d="M3 8v8l9 5 9-5V8"/><path d="m12 3 9 5-9 5-9-5 9-5Z"/>',
37
+ check: '<path d="M20 6 9 17l-5-5"/>',
38
+ "check-circle":
39
+ '<circle cx="12" cy="12" r="9"/><path d="m8.5 12 2.5 2.5 4.5-5"/>',
40
+ "chevron-down": '<path d="m6 9 6 6 6-6"/>',
41
+ "chevron-left": '<path d="m15 18-6-6 6-6"/>',
42
+ "chevron-right": '<path d="m9 18 6-6-6-6"/>',
43
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
44
+ command:
45
+ '<path d="M9 6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6Z"/>',
46
+ copy: '<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2"/>',
47
+ cpu: '<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
48
+ database:
49
+ '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
50
+ download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
51
+ external:
52
+ '<path d="M15 3h6v6"/><path d="m10 14 11-11"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
53
+ eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
54
+ "eye-off":
55
+ '<path d="m3 3 18 18"/><path d="M10.6 10.6a3 3 0 0 0 4.2 4.2"/><path d="M9.9 5.2A10.5 10.5 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-2.2 3.2"/><path d="M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.1-.8"/>',
56
+ "file-text":
57
+ '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h6"/>',
58
+ filter: '<path d="M3 5h18l-7 8v6l-4 2v-8Z"/>',
59
+ "git-branch":
60
+ '<path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
61
+ grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
62
+ "hard-drive":
63
+ '<path d="M22 12H2"/><path d="M5.5 5h13L22 12v5a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5Z"/><path d="M6 16h.01M10 16h.01"/>',
64
+ home: '<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1Z"/>',
65
+ inbox:
66
+ '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5h13L22 12v5a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5Z"/>',
67
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/>',
68
+ layers:
69
+ '<path d="m12 2 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5"/><path d="m3 17 9 5 9-5"/>',
70
+ login:
71
+ '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/>',
72
+ "log-out":
73
+ '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
74
+ menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
75
+ minus: '<path d="M5 12h14"/>',
76
+ "more-horizontal":
77
+ '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
78
+ moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/>',
79
+ network:
80
+ '<rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-4h14v4"/>',
81
+ "panel-left":
82
+ '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/>',
83
+ pause: '<path d="M9 5v14M15 5v14"/>',
84
+ play: '<path d="m7 4 13 8-13 8Z"/>',
85
+ plus: '<path d="M12 5v14M5 12h14"/>',
86
+ refresh:
87
+ '<path d="M20 11a8 8 0 0 0-14.9-4L3 10"/><path d="M3 4v6h6"/><path d="M4 13a8 8 0 0 0 14.9 4L21 14"/><path d="M21 20v-6h-6"/>',
88
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/>',
89
+ server:
90
+ '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01"/>',
91
+ settings:
92
+ '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
93
+ shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/>',
94
+ sliders:
95
+ '<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/>',
96
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
97
+ terminal: '<path d="m4 17 6-6-6-6"/><path d="M12 19h8"/>',
98
+ trash:
99
+ '<path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6M14 11v6"/>',
100
+ upload: '<path d="M12 21V9"/><path d="m7 14 5-5 5 5"/><path d="M5 3h14"/>',
101
+ user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
102
+ users:
103
+ '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9"/><path d="M16 3.1a4 4 0 0 1 0 7.8"/>',
104
+ x: '<path d="M18 6 6 18M6 6l12 12"/>',
105
+ zap: '<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z"/>',
106
+ "toggle-right":
107
+ '<rect x="2" y="7" width="20" height="10" rx="5"/><circle cx="16" cy="12" r="2.5"/>',
108
+ "panel-right":
109
+ '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/>',
110
+ "message-square-text":
111
+ '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/><path d="M8 9h8M8 13h5"/>',
112
+ "folder-tabs":
113
+ '<path d="M3 21V5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3h9a1 1 0 0 1 1 1v12Z"/>',
114
+ tooltip:
115
+ '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="m9 16 3 4 3-4"/>',
116
+ "list-ordered":
117
+ '<path d="M10 6h11M10 12h11M10 18h11"/><path d="M4 6h1v4M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3a1 1 0 0 0-2 0"/>',
118
+ "bar-progress":
119
+ '<rect x="3" y="9" width="18" height="6" rx="3"/><path d="M6 12h6"/>',
120
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Avatar runtime. Dependency-free; shared by the Astro component and the
3
+ * React port.
4
+ *
5
+ * import { getInitials, mountAvatar } from "@db-vincent/components/scripts/avatar";
6
+ *
7
+ * An image that fails to load marks the avatar `data-failed`, which hides the
8
+ * image and leaves the initials (or icon) underneath showing.
9
+ */
10
+
11
+ /** First letter of the first and last word, upper-cased: "ada king lovelace" -> "AL". */
12
+ export function getInitials(name: string): string {
13
+ const words = name.trim().split(/\s+/).filter(Boolean);
14
+ if (words.length === 0) return "";
15
+ const first = words[0];
16
+ const last = words.length > 1 ? words[words.length - 1] : "";
17
+ return `${[...first][0] ?? ""}${[...last][0] ?? ""}`.toUpperCase();
18
+ }
19
+
20
+ /** Wire up one `[data-ds-avatar]` root. Returns a cleanup function. */
21
+ export function mountAvatar(root: HTMLElement): () => void {
22
+ const image = root.querySelector<HTMLImageElement>(".ds-avatar__img");
23
+ if (!image) return () => {};
24
+
25
+ const fail = () => {
26
+ root.dataset.failed = "";
27
+ };
28
+ image.addEventListener("error", fail);
29
+ // The error may have fired before this script ran.
30
+ if (image.complete && image.naturalWidth === 0) fail();
31
+
32
+ return () => image.removeEventListener("error", fail);
33
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Menu runtime. Dependency-free; the Astro component calls `mountMenu`, the
3
+ * React port uses the pure helpers. The surface is a native `popover="auto"`,
4
+ * so light dismiss, Escape and focus return come from the platform. This adds
5
+ * the WAI-ARIA menu behaviour: roving focus, arrow keys, Home/End, typeahead,
6
+ * Tab to close, and checkbox/radio state.
7
+ *
8
+ * import { mountMenu } from "@db-vincent/components/scripts/menu";
9
+ * const destroy = mountMenu(document.querySelector("[data-ds-menu]"));
10
+ */
11
+
12
+ const ITEM = '[role^="menuitem"]';
13
+ const TYPEAHEAD_RESET_MS = 500;
14
+
15
+ /**
16
+ * Index a navigation key should focus, wrapping at both ends, or `null` when
17
+ * the key is not a menu navigation key. `current` is -1 when no item has focus.
18
+ */
19
+ export function nextMenuIndex(
20
+ key: string,
21
+ current: number,
22
+ count: number,
23
+ ): number | null {
24
+ if (count === 0) return null;
25
+ if (key === "Home") return 0;
26
+ if (key === "End") return count - 1;
27
+ if (key === "ArrowDown") return current < 0 ? 0 : (current + 1) % count;
28
+ if (key === "ArrowUp")
29
+ return current < 0 ? count - 1 : (current - 1 + count) % count;
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Index of the next label starting with `query`, case-insensitive, or `null`.
35
+ * A single character cycles through matches after `current`; a longer query
36
+ * keeps the current item when it still matches.
37
+ */
38
+ export function typeaheadIndex(
39
+ labels: readonly string[],
40
+ current: number,
41
+ query: string,
42
+ ): number | null {
43
+ const needle = query.trim().toLowerCase();
44
+ if (!needle || labels.length === 0) return null;
45
+ const start = needle.length === 1 ? current + 1 : Math.max(current, 0);
46
+ for (let offset = 0; offset < labels.length; offset += 1) {
47
+ const index = (start + offset) % labels.length;
48
+ if (labels[index].trim().toLowerCase().startsWith(needle)) return index;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** Wire up one `[data-ds-menu]` root. Returns a cleanup function. */
54
+ export function mountMenu(root: HTMLElement): () => void {
55
+ const trigger = root.querySelector<HTMLElement>('[aria-haspopup="menu"]');
56
+ const surface = root.querySelector<HTMLElement>('[role="menu"]');
57
+ if (!trigger || !surface) return () => {};
58
+
59
+ const items = () => [...surface.querySelectorAll<HTMLElement>(ITEM)];
60
+ const labelOf = (item: HTMLElement) => item.textContent ?? "";
61
+
62
+ let viaKeyboard = false;
63
+ let landOn: "first" | "last" = "first";
64
+ let query = "";
65
+ let queryTimer = 0;
66
+
67
+ const focusItem = (index: number | null) => {
68
+ if (index !== null) items()[index]?.focus({ preventScroll: false });
69
+ };
70
+
71
+ const onTriggerKeydown = (event: KeyboardEvent) => {
72
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
73
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
74
+ event.preventDefault();
75
+ viaKeyboard = true;
76
+ landOn = event.key === "ArrowUp" ? "last" : "first";
77
+ if (!surface.matches(":popover-open")) surface.showPopover();
78
+ } else if (event.key === "Enter" || event.key === " ") {
79
+ viaKeyboard = true;
80
+ landOn = "first";
81
+ }
82
+ };
83
+
84
+ const onTriggerPointerdown = () => {
85
+ viaKeyboard = false;
86
+ };
87
+
88
+ const onBeforeToggle = (event: Event) => {
89
+ if ((event as ToggleEvent).newState === "open") {
90
+ // A keyboard open appears at once; only pointer opens animate.
91
+ surface.toggleAttribute("data-instant", viaKeyboard);
92
+ }
93
+ };
94
+
95
+ const onToggle = (event: Event) => {
96
+ const open = (event as ToggleEvent).newState === "open";
97
+ trigger.setAttribute("aria-expanded", String(open));
98
+ if (open) {
99
+ if (viaKeyboard) {
100
+ const all = items();
101
+ focusItem(landOn === "last" ? all.length - 1 : 0);
102
+ } else {
103
+ surface.focus({ preventScroll: true });
104
+ }
105
+ return;
106
+ }
107
+ query = "";
108
+ window.clearTimeout(queryTimer);
109
+ const active = document.activeElement;
110
+ if (!active || active === document.body || surface.contains(active)) {
111
+ trigger.focus({ preventScroll: true });
112
+ }
113
+ };
114
+
115
+ const activate = (item: HTMLElement) => {
116
+ if (item.getAttribute("aria-disabled") === "true") return;
117
+ const role = item.getAttribute("role");
118
+ if (role === "menuitemcheckbox") {
119
+ item.setAttribute(
120
+ "aria-checked",
121
+ String(item.getAttribute("aria-checked") !== "true"),
122
+ );
123
+ } else if (role === "menuitemradio") {
124
+ const group = item.closest('[role="group"]') ?? surface;
125
+ for (const radio of group.querySelectorAll<HTMLElement>(
126
+ '[role="menuitemradio"]',
127
+ )) {
128
+ radio.setAttribute("aria-checked", String(radio === item));
129
+ }
130
+ }
131
+ root.dispatchEvent(
132
+ new CustomEvent("ds-menu-select", {
133
+ detail: {
134
+ value: item.dataset.value ?? null,
135
+ checked:
136
+ role === "menuitem"
137
+ ? null
138
+ : item.getAttribute("aria-checked") === "true",
139
+ },
140
+ }),
141
+ );
142
+ surface.hidePopover();
143
+ };
144
+
145
+ const onClick = (event: MouseEvent) => {
146
+ const item = (event.target as Element).closest<HTMLElement>(ITEM);
147
+ if (!item) return;
148
+ if (item.getAttribute("aria-disabled") === "true") {
149
+ event.preventDefault();
150
+ return;
151
+ }
152
+ activate(item);
153
+ };
154
+
155
+ const onKeydown = (event: KeyboardEvent) => {
156
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
157
+ const all = items();
158
+ const current = all.indexOf(document.activeElement as HTMLElement);
159
+
160
+ if (event.key === "Tab") {
161
+ surface.hidePopover();
162
+ return;
163
+ }
164
+
165
+ const next = nextMenuIndex(event.key, current, all.length);
166
+ if (next !== null) {
167
+ event.preventDefault();
168
+ focusItem(next);
169
+ return;
170
+ }
171
+
172
+ // Space activates links like it does buttons.
173
+ if (
174
+ event.key === " " &&
175
+ !query &&
176
+ current >= 0 &&
177
+ all[current].tagName === "A"
178
+ ) {
179
+ event.preventDefault();
180
+ all[current].click();
181
+ return;
182
+ }
183
+
184
+ if (event.key.length === 1 && (event.key !== " " || query)) {
185
+ event.preventDefault();
186
+ query += event.key;
187
+ window.clearTimeout(queryTimer);
188
+ queryTimer = window.setTimeout(() => (query = ""), TYPEAHEAD_RESET_MS);
189
+ focusItem(typeaheadIndex(all.map(labelOf), current, query));
190
+ }
191
+ };
192
+
193
+ // Hover keeps a single highlight: the pointer moves focus with it.
194
+ const onPointerMove = (event: PointerEvent) => {
195
+ const item = (event.target as Element).closest<HTMLElement>(ITEM);
196
+ if (item && item !== document.activeElement)
197
+ item.focus({ preventScroll: true });
198
+ };
199
+
200
+ trigger.addEventListener("keydown", onTriggerKeydown);
201
+ trigger.addEventListener("pointerdown", onTriggerPointerdown);
202
+ surface.addEventListener("beforetoggle", onBeforeToggle);
203
+ surface.addEventListener("toggle", onToggle);
204
+ surface.addEventListener("click", onClick);
205
+ surface.addEventListener("keydown", onKeydown);
206
+ surface.addEventListener("pointermove", onPointerMove);
207
+
208
+ return () => {
209
+ window.clearTimeout(queryTimer);
210
+ trigger.removeEventListener("keydown", onTriggerKeydown);
211
+ trigger.removeEventListener("pointerdown", onTriggerPointerdown);
212
+ surface.removeEventListener("beforetoggle", onBeforeToggle);
213
+ surface.removeEventListener("toggle", onToggle);
214
+ surface.removeEventListener("click", onClick);
215
+ surface.removeEventListener("keydown", onKeydown);
216
+ surface.removeEventListener("pointermove", onPointerMove);
217
+ };
218
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Tabs runtime. Dependency-free; shared by the Astro component and the React
3
+ * port. Follows the WAI-ARIA tabs pattern with automatic activation: arrow
4
+ * keys, Home and End move focus and select in one step.
5
+ *
6
+ * import { mountTabs } from "@db-vincent/components/scripts/tabs";
7
+ * const destroy = mountTabs(document.querySelector("[data-ds-tabs]"));
8
+ */
9
+
10
+ const TAB = '[role="tab"]';
11
+
12
+ /**
13
+ * Index of the tab a key should move to, skipping disabled tabs, or `null`
14
+ * when the key is not a tabs key. `current` is the focused tab's index.
15
+ */
16
+ export function nextTabIndex(
17
+ key: string,
18
+ current: number,
19
+ disabled: readonly boolean[],
20
+ orientation: "horizontal" | "vertical" = "horizontal",
21
+ ): number | null {
22
+ const count = disabled.length;
23
+ const step =
24
+ key === (orientation === "vertical" ? "ArrowDown" : "ArrowRight")
25
+ ? 1
26
+ : key === (orientation === "vertical" ? "ArrowUp" : "ArrowLeft")
27
+ ? -1
28
+ : 0;
29
+
30
+ if (key === "Home" || key === "End") {
31
+ const order =
32
+ key === "Home" ? [...disabled.keys()] : [...disabled.keys()].reverse();
33
+ return order.find((index) => !disabled[index]) ?? null;
34
+ }
35
+ if (!step) return null;
36
+
37
+ for (let offset = 1; offset <= count; offset += 1) {
38
+ const index = (((current + step * offset) % count) + count) % count;
39
+ if (!disabled[index]) return index;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Position the indicator under `tab`. Geometry is exposed as custom
46
+ * properties so the slide stays a transform (see tabs.css).
47
+ */
48
+ export function placeIndicator(
49
+ list: HTMLElement,
50
+ tab: HTMLElement | null,
51
+ ): void {
52
+ if (!tab) return;
53
+ const track = list.scrollWidth;
54
+ list.style.setProperty("--ds-tabs-track", `${track}px`);
55
+ list.style.setProperty("--ds-tabs-x", `${tab.offsetLeft}px`);
56
+ list.style.setProperty("--ds-tabs-scale", String(tab.offsetWidth / track));
57
+ // First placement must not animate from the empty position.
58
+ if (!list.hasAttribute("data-indicator")) {
59
+ list.setAttribute("data-indicator", "");
60
+ requestAnimationFrame(() => list.setAttribute("data-indicator", "ready"));
61
+ }
62
+ }
63
+
64
+ /** Wire up one `[data-ds-tabs]` root. Returns a cleanup function. */
65
+ export function mountTabs(root: HTMLElement): () => void {
66
+ const list = root.querySelector<HTMLElement>('[role="tablist"]');
67
+ if (!list) return () => {};
68
+
69
+ const tabs = () => [...list.querySelectorAll<HTMLButtonElement>(TAB)];
70
+ const panelFor = (tab: HTMLElement) =>
71
+ root.querySelector<HTMLElement>(
72
+ `#${CSS.escape(tab.getAttribute("aria-controls") ?? "")}`,
73
+ );
74
+
75
+ const select = (tab: HTMLButtonElement, focus: boolean) => {
76
+ for (const other of tabs()) {
77
+ const selected = other === tab;
78
+ other.setAttribute("aria-selected", String(selected));
79
+ other.tabIndex = selected ? 0 : -1;
80
+ const panel = panelFor(other);
81
+ if (panel) panel.hidden = !selected;
82
+ }
83
+ placeIndicator(list, tab);
84
+ if (focus) tab.focus();
85
+ root.dispatchEvent(
86
+ new CustomEvent("ds-tabs-change", { detail: { id: tab.id } }),
87
+ );
88
+ };
89
+
90
+ const selectedTab = () =>
91
+ tabs().find((tab) => tab.getAttribute("aria-selected") === "true") ?? null;
92
+
93
+ const onClick = (event: MouseEvent) => {
94
+ const tab = (event.target as Element).closest<HTMLButtonElement>(TAB);
95
+ if (tab && !tab.disabled) select(tab, false);
96
+ };
97
+
98
+ const onKeydown = (event: KeyboardEvent) => {
99
+ const all = tabs();
100
+ const current = all.indexOf(document.activeElement as HTMLButtonElement);
101
+ if (current < 0 || event.altKey || event.ctrlKey || event.metaKey) return;
102
+ const vertical = list.getAttribute("aria-orientation") === "vertical";
103
+ const next = nextTabIndex(
104
+ event.key,
105
+ current,
106
+ all.map((tab) => tab.disabled),
107
+ vertical ? "vertical" : "horizontal",
108
+ );
109
+ if (next === null) return;
110
+ event.preventDefault();
111
+ select(all[next], true);
112
+ };
113
+
114
+ list.addEventListener("click", onClick);
115
+ list.addEventListener("keydown", onKeydown);
116
+
117
+ const resize = new ResizeObserver(() => placeIndicator(list, selectedTab()));
118
+ resize.observe(list);
119
+ for (const tab of tabs()) resize.observe(tab);
120
+ placeIndicator(list, selectedTab());
121
+
122
+ return () => {
123
+ list.removeEventListener("click", onClick);
124
+ list.removeEventListener("keydown", onKeydown);
125
+ resize.disconnect();
126
+ };
127
+ }