@db-vincent/components 0.0.0-stage → 0.1.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 (62) 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/Badge.astro +23 -0
  5. package/src/astro/Breadcrumbs.astro +45 -0
  6. package/src/astro/Button.astro +125 -0
  7. package/src/astro/Card.astro +63 -0
  8. package/src/astro/Checkbox.astro +61 -0
  9. package/src/astro/Chip.astro +116 -0
  10. package/src/astro/CodeBlock.astro +17 -0
  11. package/src/astro/EmptyState.astro +45 -0
  12. package/src/astro/Example.astro +37 -0
  13. package/src/astro/Grid.astro +50 -0
  14. package/src/astro/GridItem.astro +23 -0
  15. package/src/astro/Icon.astro +52 -0
  16. package/src/astro/Modal.astro +117 -0
  17. package/src/astro/Navbar.astro +756 -0
  18. package/src/astro/PageHeader.astro +34 -0
  19. package/src/astro/Pagination.astro +162 -0
  20. package/src/astro/Radio.astro +58 -0
  21. package/src/astro/RadioGroup.astro +25 -0
  22. package/src/astro/Section.astro +25 -0
  23. package/src/astro/Select.astro +403 -0
  24. package/src/astro/Sidebar.astro +215 -0
  25. package/src/astro/Skeleton.astro +39 -0
  26. package/src/astro/Spinner.astro +19 -0
  27. package/src/astro/Table.astro +254 -0
  28. package/src/astro/TextField.astro +287 -0
  29. package/src/astro/ThemeToggle.astro +66 -0
  30. package/src/astro/Toast.astro +181 -0
  31. package/src/astro/TopBar.astro +29 -0
  32. package/src/data/icons.ts +104 -0
  33. package/src/scripts/toast.ts +531 -0
  34. package/src/styles/accordion.css +77 -0
  35. package/src/styles/alert.css +106 -0
  36. package/src/styles/badge.css +46 -0
  37. package/src/styles/base.css +203 -0
  38. package/src/styles/breadcrumbs.css +52 -0
  39. package/src/styles/button.css +163 -0
  40. package/src/styles/card.css +108 -0
  41. package/src/styles/chip.css +104 -0
  42. package/src/styles/choice.css +137 -0
  43. package/src/styles/code.css +42 -0
  44. package/src/styles/empty.css +57 -0
  45. package/src/styles/example.css +55 -0
  46. package/src/styles/grid.css +62 -0
  47. package/src/styles/input.css +198 -0
  48. package/src/styles/modal.css +143 -0
  49. package/src/styles/motion.css +96 -0
  50. package/src/styles/nav.css +230 -0
  51. package/src/styles/navbar.css +656 -0
  52. package/src/styles/page-header.css +59 -0
  53. package/src/styles/pagination.css +77 -0
  54. package/src/styles/section.css +36 -0
  55. package/src/styles/select.css +235 -0
  56. package/src/styles/skeleton.css +64 -0
  57. package/src/styles/spinner.css +42 -0
  58. package/src/styles/table.css +175 -0
  59. package/src/styles/theme-toggle.css +29 -0
  60. package/src/styles/toast.css +207 -0
  61. package/src/styles/topbar.css +30 -0
  62. package/README.md +0 -3
@@ -0,0 +1,215 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import Badge from "./Badge.astro";
4
+ import "../styles/nav.css";
5
+
6
+ interface NavItem {
7
+ label: string;
8
+ href: string;
9
+ icon?: string;
10
+ active?: boolean;
11
+ badge?: string;
12
+ open?: boolean;
13
+ children?: { label: string; href: string; active?: boolean }[];
14
+ }
15
+
16
+ interface NavGroup {
17
+ label?: string;
18
+ items: NavItem[];
19
+ }
20
+
21
+ interface Props {
22
+ brand: string;
23
+ brandHref?: string;
24
+ groups: NavGroup[];
25
+ label?: string;
26
+ id?: string;
27
+ class?: string;
28
+ }
29
+
30
+ const {
31
+ brand,
32
+ brandHref = "/",
33
+ groups,
34
+ label = "Primary",
35
+ id = "ds-sidebar",
36
+ class: className,
37
+ } = Astro.props;
38
+ ---
39
+
40
+ <header class="ds-nav__mobilebar">
41
+ <a class="ds-nav__brand" href={brandHref}>
42
+ <span class="ds-nav__brand-mark" aria-hidden="true">
43
+ <Icon name="terminal" size={16} />
44
+ </span>
45
+ {brand}
46
+ </a>
47
+ <button
48
+ class="ds-nav__toggle"
49
+ type="button"
50
+ aria-controls={id}
51
+ aria-expanded="false"
52
+ data-nav-toggle
53
+ >
54
+ <Icon name="menu" size={20} label="Open navigation" />
55
+ </button>
56
+ </header>
57
+
58
+ <aside class:list={["ds-nav", className]} id={id} data-nav data-open="false">
59
+ <a class="ds-nav__brand" href={brandHref}>
60
+ <span class="ds-nav__brand-mark" aria-hidden="true">
61
+ <Icon name="terminal" size={16} />
62
+ </span>
63
+ {brand}
64
+ </a>
65
+
66
+ <div class="ds-nav__scroll">
67
+ <nav aria-label={label}>
68
+ {groups.map((group) => (
69
+ <div class="ds-nav__group">
70
+ {group.label && <div class="ds-nav__group-label">{group.label}</div>}
71
+ <ul class="ds-nav__list">
72
+ {group.items.map((item) =>
73
+ item.children?.length ? (
74
+ <li>
75
+ <details
76
+ class="ds-nav__submenu"
77
+ open={
78
+ item.open || item.children.some((child) => child.active)
79
+ }
80
+ >
81
+ <summary
82
+ class="ds-nav__summary"
83
+ data-active={
84
+ item.children.some((child) => child.active)
85
+ ? "true"
86
+ : undefined
87
+ }
88
+ >
89
+ {item.icon && (
90
+ <Icon name={item.icon} size={16} class="ds-nav__icon" />
91
+ )}
92
+ <span>{item.label}</span>
93
+ <Icon
94
+ name="chevron-right"
95
+ size={14}
96
+ class="ds-nav__chevron"
97
+ />
98
+ </summary>
99
+ <ul class="ds-nav__list">
100
+ {item.children.map((child) => (
101
+ <li>
102
+ <a
103
+ class="ds-nav__link"
104
+ href={child.href}
105
+ aria-current={child.active ? "page" : undefined}
106
+ >
107
+ <span>{child.label}</span>
108
+ </a>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ </details>
113
+ </li>
114
+ ) : (
115
+ <li>
116
+ <a
117
+ class="ds-nav__link"
118
+ href={item.href}
119
+ aria-current={item.active ? "page" : undefined}
120
+ >
121
+ {item.icon && (
122
+ <Icon name={item.icon} size={16} class="ds-nav__icon" />
123
+ )}
124
+ <span>{item.label}</span>
125
+ {item.badge && (
126
+ <span class="ds-nav__badge">
127
+ <Badge>{item.badge}</Badge>
128
+ </span>
129
+ )}
130
+ </a>
131
+ </li>
132
+ ),
133
+ )}
134
+ </ul>
135
+ </div>
136
+ ))}
137
+ </nav>
138
+ </div>
139
+
140
+ <div class="ds-nav__footer">
141
+ <slot />
142
+ </div>
143
+ </aside>
144
+ <div class="ds-nav__backdrop" data-nav-close hidden></div>
145
+
146
+ <script>
147
+ const setup = () => {
148
+ document
149
+ .querySelectorAll<HTMLElement>("[data-nav-toggle]")
150
+ .forEach((toggle) => {
151
+ if (toggle.dataset.bound) return;
152
+ toggle.dataset.bound = "true";
153
+ const id = toggle.getAttribute("aria-controls");
154
+ const nav = id ? document.getElementById(id) : null;
155
+ if (!nav) return;
156
+ const backdrop = nav.nextElementSibling as HTMLElement | null;
157
+
158
+ const setOpen = (open: boolean) => {
159
+ nav.dataset.open = String(open);
160
+ toggle.setAttribute("aria-expanded", String(open));
161
+ toggle.setAttribute(
162
+ "aria-label",
163
+ open ? "Close navigation" : "Open navigation",
164
+ );
165
+ if (backdrop) backdrop.hidden = !open;
166
+ document.body.style.overflow = open ? "hidden" : "";
167
+ if (open) {
168
+ const first = nav.querySelector<HTMLElement>("a, button, summary");
169
+ first?.focus();
170
+ } else {
171
+ toggle.focus();
172
+ }
173
+ };
174
+
175
+ toggle.addEventListener("click", () =>
176
+ setOpen(nav.dataset.open !== "true"),
177
+ );
178
+ backdrop?.addEventListener("click", () => setOpen(false));
179
+ nav.addEventListener("keydown", (event) => {
180
+ if (event.key === "Escape" && nav.dataset.open === "true") {
181
+ setOpen(false);
182
+ return;
183
+ }
184
+ if (event.key !== "Tab" || nav.dataset.open !== "true") return;
185
+ const focusable = Array.from(
186
+ nav.querySelectorAll<HTMLElement>(
187
+ 'a[href], button:not([disabled]), summary, [tabindex]:not([tabindex="-1"])',
188
+ ),
189
+ ).filter((element) =>
190
+ // Links inside a closed <details> are not reachable; skip them.
191
+ typeof element.checkVisibility === "function"
192
+ ? element.checkVisibility()
193
+ : element.offsetParent !== null,
194
+ );
195
+ if (!focusable.length) return;
196
+ const first = focusable[0];
197
+ const last = focusable[focusable.length - 1];
198
+ if (event.shiftKey && document.activeElement === first) {
199
+ event.preventDefault();
200
+ last.focus();
201
+ } else if (!event.shiftKey && document.activeElement === last) {
202
+ event.preventDefault();
203
+ first.focus();
204
+ }
205
+ });
206
+ window.addEventListener("resize", () => {
207
+ if (window.innerWidth > 900 && nav.dataset.open === "true")
208
+ setOpen(false);
209
+ });
210
+ });
211
+ };
212
+
213
+ setup();
214
+ document.addEventListener("astro:page-load", setup);
215
+ </script>
@@ -0,0 +1,39 @@
1
+ ---
2
+ import "../styles/skeleton.css";
3
+
4
+ /**
5
+ * Skeleton. Decorative by definition: aria-hidden, so a loading region must
6
+ * expose its own status ("Loading invoices…") and set aria-busy on the region.
7
+ */
8
+ interface Props {
9
+ variant?: "image" | "heading" | "paragraph";
10
+ lines?: number;
11
+ width?: string;
12
+ class?: string;
13
+ }
14
+
15
+ const {
16
+ variant = "paragraph",
17
+ lines = 3,
18
+ width,
19
+ class: className,
20
+ } = Astro.props;
21
+ ---
22
+
23
+ {variant === "paragraph" ? (
24
+ <div
25
+ class:list={["ds-skeleton-stack", className]}
26
+ aria-hidden="true"
27
+ style={width ? `width:${width}` : undefined}
28
+ >
29
+ {Array.from({ length: lines }).map(() => (
30
+ <span class="ds-skeleton ds-skeleton--text"></span>
31
+ ))}
32
+ </div>
33
+ ) : (
34
+ <span
35
+ class:list={["ds-skeleton", `ds-skeleton--${variant}`, className]}
36
+ aria-hidden="true"
37
+ style={width ? `width:${width}` : undefined}
38
+ ></span>
39
+ )}
@@ -0,0 +1,19 @@
1
+ ---
2
+ import "../styles/spinner.css";
3
+
4
+ /** Loading spinner. Announces through a visually hidden status, not motion. */
5
+ interface Props {
6
+ size?: "sm" | "md" | "lg";
7
+ label?: string;
8
+ class?: string;
9
+ }
10
+
11
+ const { size = "md", label = "Loading", class: className } = Astro.props;
12
+ ---
13
+
14
+ <span
15
+ class:list={["ds-spinner", size !== "md" && `ds-spinner--${size}`, className]}
16
+ role="status"
17
+ >
18
+ <span class="ds-sr-only">{label}…</span>
19
+ </span>
@@ -0,0 +1,254 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import "../styles/input.css";
4
+ import "../styles/table.css";
5
+
6
+ type Cell =
7
+ | string
8
+ | number
9
+ | {
10
+ text: string | number;
11
+ mono?: boolean;
12
+ kbd?: boolean;
13
+ swatch?: string;
14
+ };
15
+
16
+ interface Column {
17
+ key: string;
18
+ label: string;
19
+ align?: "start" | "end";
20
+ numeric?: boolean;
21
+ }
22
+
23
+ interface Props {
24
+ columns: Column[];
25
+ rows: Record<string, Cell>[];
26
+ caption: string;
27
+ hideCaption?: boolean;
28
+ empty?: string;
29
+ sortable?: boolean;
30
+ filterable?: boolean;
31
+ filterLabel?: string;
32
+ filterPlaceholder?: string;
33
+ class?: string;
34
+ }
35
+
36
+ const {
37
+ columns,
38
+ rows,
39
+ caption,
40
+ hideCaption = false,
41
+ empty = "No records yet.",
42
+ sortable = false,
43
+ filterable = false,
44
+ filterLabel = "Filter rows",
45
+ filterPlaceholder = "Filter…",
46
+ class: className,
47
+ } = Astro.props;
48
+
49
+ const filterId = `table-filter-${Math.random().toString(36).slice(2, 9)}`;
50
+ ---
51
+
52
+ <div class:list={["ds-table-wrap", className]} data-ds-table>
53
+ {filterable && (
54
+ <div class="ds-table__toolbar">
55
+ <label class="ds-sr-only" for={filterId}>
56
+ {filterLabel}
57
+ </label>
58
+ <input
59
+ class="ds-input ds-input--sm ds-table__filter"
60
+ id={filterId}
61
+ type="search"
62
+ placeholder={filterPlaceholder}
63
+ data-table-filter
64
+ />
65
+ <span class="ds-table__count" aria-live="polite" data-table-count>
66
+ {rows.length} {rows.length === 1 ? "row" : "rows"}
67
+ </span>
68
+ </div>
69
+ )}
70
+ <table class:list={["ds-table", columns.length >= 4 && "ds-table--wide"]}>
71
+ <caption class:list={["ds-table__caption", hideCaption && "ds-sr-only"]}>
72
+ {caption}
73
+ </caption>
74
+ <thead>
75
+ <tr>
76
+ {columns.map((column, index) => (
77
+ <th
78
+ scope="col"
79
+ class:list={[
80
+ column.numeric && "is-numeric",
81
+ column.align === "end" && "is-end",
82
+ ]}
83
+ aria-sort={sortable ? "none" : undefined}
84
+ >
85
+ {sortable ? (
86
+ <button
87
+ class="ds-table__sort-button"
88
+ type="button"
89
+ data-sort-index={index}
90
+ >
91
+ {column.label}
92
+ <span class="ds-table__sort" aria-hidden="true">
93
+ <Icon
94
+ name="chevrons-up-down"
95
+ size={14}
96
+ class="ds-table__sort-idle"
97
+ />
98
+ <Icon
99
+ name="chevron-down"
100
+ size={14}
101
+ class="ds-table__sort-dir"
102
+ />
103
+ </span>
104
+ </button>
105
+ ) : (
106
+ column.label
107
+ )}
108
+ </th>
109
+ ))}
110
+ </tr>
111
+ </thead>
112
+ <tbody>
113
+ {rows.length === 0 ? (
114
+ <tr>
115
+ <td class="ds-table__empty" colspan={columns.length}>
116
+ {empty}
117
+ </td>
118
+ </tr>
119
+ ) : (
120
+ rows.map((row) => (
121
+ <tr data-row>
122
+ {columns.map((column) => {
123
+ const cell = row[column.key];
124
+ return (
125
+ <td
126
+ class:list={[
127
+ column.numeric && "is-numeric",
128
+ column.align === "end" && "is-end",
129
+ ]}
130
+ >
131
+ {typeof cell === "object" ? (
132
+ <>
133
+ {cell.swatch && (
134
+ <span
135
+ class="ds-table__swatch"
136
+ style={`background:${cell.swatch}`}
137
+ aria-hidden="true"
138
+ ></span>
139
+ )}
140
+ {cell.kbd ? (
141
+ <kbd>{cell.text}</kbd>
142
+ ) : (
143
+ <span class:list={[cell.mono && "ds-table__mono"]}>
144
+ {cell.text}
145
+ </span>
146
+ )}
147
+ </>
148
+ ) : (
149
+ cell
150
+ )}
151
+ </td>
152
+ );
153
+ })}
154
+ </tr>
155
+ ))
156
+ )}
157
+ {filterable && rows.length > 0 && (
158
+ <tr data-table-empty hidden>
159
+ <td class="ds-table__empty" colspan={columns.length}>
160
+ No matching rows.
161
+ </td>
162
+ </tr>
163
+ )}
164
+ </tbody>
165
+ </table>
166
+ </div>
167
+
168
+ <script>
169
+ document.querySelectorAll<HTMLElement>("[data-ds-table]").forEach((root) => {
170
+ const tbody = root.querySelector<HTMLElement>("tbody");
171
+ const filter = root.querySelector<HTMLInputElement>("[data-table-filter]");
172
+ const count = root.querySelector<HTMLElement>("[data-table-count]");
173
+ const empty = root.querySelector<HTMLElement>("[data-table-empty]");
174
+ const sortButtons = Array.from(
175
+ root.querySelectorAll<HTMLButtonElement>("[data-sort-index]"),
176
+ );
177
+ if (!tbody) return;
178
+
179
+ const rows = Array.from(
180
+ tbody.querySelectorAll<HTMLTableRowElement>("tr[data-row]"),
181
+ );
182
+ let sortIndex = -1;
183
+ let direction = 1;
184
+
185
+ const textFor = (row: HTMLTableRowElement, index: number) =>
186
+ (row.cells[index]?.textContent || "").trim();
187
+
188
+ const numberFor = (value: string) => {
189
+ if (!/[0-9]/.test(value)) return null;
190
+ const parsed = Number(value.replace(/[^0-9.-]/g, ""));
191
+ return Number.isFinite(parsed) ? parsed : null;
192
+ };
193
+
194
+ const compare = (a: string, b: string) => {
195
+ const first = numberFor(a);
196
+ const second = numberFor(b);
197
+ if (first !== null && second !== null) return first - second;
198
+ return a.localeCompare(b);
199
+ };
200
+
201
+ const apply = () => {
202
+ const query = filter?.value.trim().toLowerCase() ?? "";
203
+ let visible = 0;
204
+
205
+ rows.forEach((row) => {
206
+ const match = !query || row.textContent.toLowerCase().includes(query);
207
+ row.hidden = !match;
208
+ if (match) visible++;
209
+ });
210
+
211
+ if (sortIndex >= 0) {
212
+ rows.sort(
213
+ (a, b) =>
214
+ direction * compare(textFor(a, sortIndex), textFor(b, sortIndex)),
215
+ );
216
+ rows.forEach((row) => tbody.append(row));
217
+ }
218
+
219
+ if (empty) empty.hidden = visible !== 0;
220
+ if (count) {
221
+ count.textContent = `${visible} ${visible === 1 ? "row" : "rows"}`;
222
+ }
223
+ };
224
+
225
+ sortButtons.forEach((button) => {
226
+ button.addEventListener("click", () => {
227
+ const header = button.closest("th");
228
+ const index = Number(button.dataset.sortIndex);
229
+ direction =
230
+ sortIndex === index &&
231
+ header?.getAttribute("aria-sort") === "ascending"
232
+ ? -1
233
+ : 1;
234
+ sortIndex = index;
235
+ sortButtons.forEach((other) => {
236
+ const otherHeader = other.closest("th");
237
+ if (!otherHeader) return;
238
+ otherHeader.setAttribute(
239
+ "aria-sort",
240
+ other === button
241
+ ? direction === 1
242
+ ? "ascending"
243
+ : "descending"
244
+ : "none",
245
+ );
246
+ });
247
+ apply();
248
+ });
249
+ });
250
+
251
+ filter?.addEventListener("input", apply);
252
+ apply();
253
+ });
254
+ </script>