@db-vincent/components 0.1.0 → 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 (66) hide show
  1. package/package.json +3 -3
  2. package/src/astro/Avatar.astro +88 -0
  3. package/src/astro/AvatarGroup.astro +63 -0
  4. package/src/astro/Chip.astro +22 -3
  5. package/src/astro/Drawer.astro +30 -0
  6. package/src/astro/Grid.astro +7 -1
  7. package/src/astro/GridItem.astro +3 -1
  8. package/src/astro/Menu.astro +151 -0
  9. package/src/astro/MenuItem.astro +57 -0
  10. package/src/astro/Modal.astro +10 -3
  11. package/src/astro/Pagination.astro +82 -32
  12. package/src/astro/Popover.astro +51 -0
  13. package/src/astro/Progress.astro +71 -0
  14. package/src/astro/Select.astro +211 -194
  15. package/src/astro/Skeleton.astro +4 -2
  16. package/src/astro/Spinner.astro +1 -0
  17. package/src/astro/Stepper.astro +82 -0
  18. package/src/astro/Switch.astro +78 -0
  19. package/src/astro/Table.astro +90 -76
  20. package/src/astro/Tabs.astro +83 -0
  21. package/src/astro/TextField.astro +100 -82
  22. package/src/astro/Toast.astro +1 -1
  23. package/src/astro/Tooltip.astro +54 -0
  24. package/src/data/icons.ts +16 -0
  25. package/src/scripts/avatar.ts +33 -0
  26. package/src/scripts/menu.ts +218 -0
  27. package/src/scripts/tabs.ts +127 -0
  28. package/src/scripts/toast.ts +45 -11
  29. package/src/scripts/tooltip.ts +127 -0
  30. package/src/styles/accordion.css +2 -0
  31. package/src/styles/alert.css +2 -0
  32. package/src/styles/avatar.css +131 -0
  33. package/src/styles/badge.css +2 -0
  34. package/src/styles/base.css +2 -0
  35. package/src/styles/breadcrumbs.css +2 -0
  36. package/src/styles/button.css +13 -2
  37. package/src/styles/card.css +8 -4
  38. package/src/styles/chip.css +2 -0
  39. package/src/styles/choice.css +8 -2
  40. package/src/styles/code.css +2 -0
  41. package/src/styles/empty.css +2 -0
  42. package/src/styles/example.css +2 -0
  43. package/src/styles/grid.css +2 -0
  44. package/src/styles/input.css +11 -1
  45. package/src/styles/menu.css +151 -0
  46. package/src/styles/modal.css +169 -27
  47. package/src/styles/motion.css +21 -3
  48. package/src/styles/nav.css +2 -0
  49. package/src/styles/navbar.css +2 -0
  50. package/src/styles/page-header.css +3 -0
  51. package/src/styles/pagination.css +4 -0
  52. package/src/styles/popover.css +134 -0
  53. package/src/styles/progress.css +95 -0
  54. package/src/styles/section.css +2 -0
  55. package/src/styles/select.css +12 -1
  56. package/src/styles/skeleton.css +2 -0
  57. package/src/styles/spinner.css +2 -0
  58. package/src/styles/sr-only.css +14 -0
  59. package/src/styles/stepper.css +169 -0
  60. package/src/styles/switch.css +143 -0
  61. package/src/styles/table.css +2 -0
  62. package/src/styles/tabs.css +120 -0
  63. package/src/styles/theme-toggle.css +2 -0
  64. package/src/styles/toast.css +41 -23
  65. package/src/styles/tooltip.css +63 -0
  66. package/src/styles/topbar.css +2 -0
@@ -0,0 +1,78 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import "../styles/switch.css";
4
+
5
+ /**
6
+ * On/off setting that applies immediately. Native checkbox with
7
+ * role="switch"; the label names it, hint and error describe it.
8
+ */
9
+ interface Props {
10
+ label: string;
11
+ hint?: string;
12
+ error?: string;
13
+ id?: string;
14
+ name?: string;
15
+ value?: string;
16
+ checked?: boolean;
17
+ disabled?: boolean;
18
+ required?: boolean;
19
+ size?: "sm" | "md";
20
+ class?: string;
21
+ }
22
+
23
+ const {
24
+ label,
25
+ hint,
26
+ error,
27
+ id,
28
+ name,
29
+ value,
30
+ checked = false,
31
+ disabled = false,
32
+ required = false,
33
+ size = "md",
34
+ class: className,
35
+ } = Astro.props;
36
+
37
+ const inputId = id ?? `switch-${Math.random().toString(36).slice(2, 9)}`;
38
+ const labelId = `${inputId}-label`;
39
+ const hintId = hint ? `${inputId}-hint` : undefined;
40
+ const errorId = error ? `${inputId}-error` : undefined;
41
+ const describedby = [hintId, errorId].filter(Boolean).join(" ") || undefined;
42
+ ---
43
+
44
+ <label
45
+ class:list={["ds-switch", size !== "md" && `ds-switch--${size}`, className]}
46
+ >
47
+ <input
48
+ class="ds-switch__input"
49
+ id={inputId}
50
+ type="checkbox"
51
+ role="switch"
52
+ name={name}
53
+ value={value}
54
+ checked={checked}
55
+ disabled={disabled}
56
+ required={required}
57
+ aria-invalid={error ? "true" : undefined}
58
+ aria-labelledby={describedby ? labelId : undefined}
59
+ aria-describedby={describedby}
60
+ />
61
+ <span class="ds-switch__track" aria-hidden="true">
62
+ <span class="ds-switch__thumb"></span>
63
+ </span>
64
+ <span class="ds-switch__label">
65
+ <span id={labelId}>{label}</span>
66
+ {hint && (
67
+ <span class="ds-switch__hint" id={hintId}>
68
+ {hint}
69
+ </span>
70
+ )}
71
+ {error && (
72
+ <span class="ds-switch__error" id={errorId}>
73
+ <Icon name="alert-circle" size={14} />
74
+ {error}
75
+ </span>
76
+ )}
77
+ </span>
78
+ </label>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "./Icon.astro";
3
+ import "../styles/sr-only.css";
3
4
  import "../styles/input.css";
4
5
  import "../styles/table.css";
5
6
 
@@ -133,7 +134,7 @@ const filterId = `table-filter-${Math.random().toString(36).slice(2, 9)}`;
133
134
  {cell.swatch && (
134
135
  <span
135
136
  class="ds-table__swatch"
136
- style={`background:${cell.swatch}`}
137
+ style={`background:${/^(#[0-9a-f]{3,8}|var\(--[a-z0-9-]+\)|[a-z]+)$/i.test(cell.swatch) ? cell.swatch : "transparent"}`}
137
138
  aria-hidden="true"
138
139
  ></span>
139
140
  )}
@@ -166,89 +167,102 @@ const filterId = `table-filter-${Math.random().toString(36).slice(2, 9)}`;
166
167
  </div>
167
168
 
168
169
  <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
- };
170
+ const bind = () => {
171
+ document
172
+ .querySelectorAll<HTMLElement>("[data-ds-table]")
173
+ .forEach((root) => {
174
+ if (root.dataset.dsBound) return;
175
+ root.dataset.dsBound = "true";
176
+ const tbody = root.querySelector<HTMLElement>("tbody");
177
+ const filter = root.querySelector<HTMLInputElement>(
178
+ "[data-table-filter]",
179
+ );
180
+ const count = root.querySelector<HTMLElement>("[data-table-count]");
181
+ const empty = root.querySelector<HTMLElement>("[data-table-empty]");
182
+ const sortButtons = Array.from(
183
+ root.querySelectorAll<HTMLButtonElement>("[data-sort-index]"),
184
+ );
185
+ if (!tbody) return;
193
186
 
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
- };
187
+ const rows = Array.from(
188
+ tbody.querySelectorAll<HTMLTableRowElement>("tr[data-row]"),
189
+ );
190
+ let sortIndex = -1;
191
+ let direction = 1;
200
192
 
201
- const apply = () => {
202
- const query = filter?.value.trim().toLowerCase() ?? "";
203
- let visible = 0;
193
+ const textFor = (row: HTMLTableRowElement, index: number) =>
194
+ (row.cells[index]?.textContent || "").trim();
204
195
 
205
- rows.forEach((row) => {
206
- const match = !query || row.textContent.toLowerCase().includes(query);
207
- row.hidden = !match;
208
- if (match) visible++;
209
- });
196
+ const numberFor = (value: string) => {
197
+ if (!/[0-9]/.test(value)) return null;
198
+ const parsed = Number(value.replace(/[^0-9.-]/g, ""));
199
+ return Number.isFinite(parsed) ? parsed : null;
200
+ };
210
201
 
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
- }
202
+ const compare = (a: string, b: string) => {
203
+ const first = numberFor(a);
204
+ const second = numberFor(b);
205
+ if (first !== null && second !== null) return first - second;
206
+ return a.localeCompare(b);
207
+ };
218
208
 
219
- if (empty) empty.hidden = visible !== 0;
220
- if (count) {
221
- count.textContent = `${visible} ${visible === 1 ? "row" : "rows"}`;
222
- }
223
- };
209
+ const apply = () => {
210
+ const query = filter?.value.trim().toLowerCase() ?? "";
211
+ let visible = 0;
212
+
213
+ rows.forEach((row) => {
214
+ const match =
215
+ !query || row.textContent.toLowerCase().includes(query);
216
+ row.hidden = !match;
217
+ if (match) visible++;
218
+ });
219
+
220
+ if (sortIndex >= 0) {
221
+ rows.sort(
222
+ (a, b) =>
223
+ direction *
224
+ compare(textFor(a, sortIndex), textFor(b, sortIndex)),
225
+ );
226
+ rows.forEach((row) => tbody.append(row));
227
+ }
224
228
 
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
- );
229
+ if (empty) empty.hidden = visible !== 0;
230
+ if (count) {
231
+ count.textContent = `${visible} ${visible === 1 ? "row" : "rows"}`;
232
+ }
233
+ };
234
+
235
+ sortButtons.forEach((button) => {
236
+ button.addEventListener("click", () => {
237
+ const header = button.closest("th");
238
+ const index = Number(button.dataset.sortIndex);
239
+ direction =
240
+ sortIndex === index &&
241
+ header?.getAttribute("aria-sort") === "ascending"
242
+ ? -1
243
+ : 1;
244
+ sortIndex = index;
245
+ sortButtons.forEach((other) => {
246
+ const otherHeader = other.closest("th");
247
+ if (!otherHeader) return;
248
+ otherHeader.setAttribute(
249
+ "aria-sort",
250
+ other === button
251
+ ? direction === 1
252
+ ? "ascending"
253
+ : "descending"
254
+ : "none",
255
+ );
256
+ });
257
+ apply();
258
+ });
246
259
  });
260
+
261
+ filter?.addEventListener("input", apply);
247
262
  apply();
248
263
  });
249
- });
264
+ };
250
265
 
251
- filter?.addEventListener("input", apply);
252
- apply();
253
- });
266
+ bind();
267
+ document.addEventListener("astro:page-load", bind);
254
268
  </script>
@@ -0,0 +1,83 @@
1
+ ---
2
+ import "../styles/tabs.css";
3
+
4
+ /**
5
+ * Tabs with automatic activation. Each item names a panel slot:
6
+ *
7
+ * <Tabs label="Service" items={[{ id: "logs", label: "Logs" }]}>
8
+ * <p slot="logs">…</p>
9
+ * </Tabs>
10
+ *
11
+ * Arrow keys, Home and End move between tabs; only the selected tab is in the
12
+ * tab order. The indicator slides with a transform. Fires `ds-tabs-change`.
13
+ */
14
+ interface TabItem {
15
+ id: string;
16
+ label: string;
17
+ disabled?: boolean;
18
+ }
19
+
20
+ interface Props {
21
+ items: TabItem[];
22
+ /** Accessible name for the tab list. */
23
+ label: string;
24
+ /** Id of the initially selected tab. Defaults to the first enabled tab. */
25
+ selected?: string;
26
+ id?: string;
27
+ class?: string;
28
+ }
29
+
30
+ const { items, label, selected, id, class: className } = Astro.props;
31
+
32
+ const rootId = id ?? `tabs-${Math.random().toString(36).slice(2, 9)}`;
33
+ const current =
34
+ items.find((item) => item.id === selected && !item.disabled) ??
35
+ items.find((item) => !item.disabled);
36
+ ---
37
+
38
+ <div class:list={["ds-tabs", className]} id={rootId} data-ds-tabs>
39
+ <div class="ds-tabs__list" role="tablist" aria-label={label}>
40
+ {items.map((item) => (
41
+ <button
42
+ class="ds-tabs__tab"
43
+ type="button"
44
+ role="tab"
45
+ id={`${rootId}-tab-${item.id}`}
46
+ aria-selected={item === current ? "true" : "false"}
47
+ aria-controls={`${rootId}-panel-${item.id}`}
48
+ tabindex={item === current ? 0 : -1}
49
+ disabled={item.disabled}
50
+ >
51
+ {item.label}
52
+ </button>
53
+ ))}
54
+ <span class="ds-tabs__indicator" aria-hidden="true"></span>
55
+ </div>
56
+ {items.map((item) => (
57
+ <div
58
+ class="ds-tabs__panel"
59
+ role="tabpanel"
60
+ id={`${rootId}-panel-${item.id}`}
61
+ aria-labelledby={`${rootId}-tab-${item.id}`}
62
+ tabindex="0"
63
+ hidden={item !== current}
64
+ >
65
+ <slot name={item.id} />
66
+ </div>
67
+ ))}
68
+ </div>
69
+
70
+ <script>
71
+ import { mountTabs } from "../scripts/tabs";
72
+
73
+ const bind = () => {
74
+ document.querySelectorAll<HTMLElement>("[data-ds-tabs]").forEach((root) => {
75
+ if (root.dataset.dsBound) return;
76
+ root.dataset.dsBound = "true";
77
+ mountTabs(root);
78
+ });
79
+ };
80
+
81
+ bind();
82
+ document.addEventListener("astro:page-load", bind);
83
+ </script>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "./Icon.astro";
3
+ import "../styles/sr-only.css";
3
4
  import "../styles/input.css";
4
5
 
5
6
  /**
@@ -25,6 +26,8 @@ interface Props {
25
26
  optional?: boolean;
26
27
  multiline?: boolean;
27
28
  rows?: number;
29
+ /** Multiline only. Which way the user can drag the handle. */
30
+ resize?: "none" | "vertical" | "both";
28
31
  maxlength?: number;
29
32
  minlength?: number;
30
33
  pattern?: string;
@@ -52,6 +55,7 @@ const {
52
55
  optional = false,
53
56
  multiline = false,
54
57
  rows = 3,
58
+ resize = "vertical",
55
59
  maxlength,
56
60
  minlength,
57
61
  pattern,
@@ -74,6 +78,7 @@ const isPassword = !multiline && type === "password";
74
78
  const inputClass = [
75
79
  "ds-input",
76
80
  size !== "md" && `ds-input--${size}`,
81
+ multiline && resize !== "vertical" && `ds-input--resize-${resize}`,
77
82
  error && "is-invalid",
78
83
  valid && !error && "is-valid",
79
84
  valid && !error && "ds-input--with-state",
@@ -191,97 +196,110 @@ const inputClass = [
191
196
  </div>
192
197
 
193
198
  <script>
194
- const fields = document.querySelectorAll<HTMLElement>("[data-ds-field]");
199
+ const bind = () => {
200
+ const fields = document.querySelectorAll<HTMLElement>("[data-ds-field]");
195
201
 
196
- fields.forEach((field) => {
197
- const input = field.querySelector<HTMLInputElement | HTMLTextAreaElement>(
198
- ".ds-input",
199
- );
200
- const count = field.querySelector<HTMLElement>("[data-ds-count]");
201
- const countLive = field.querySelector<HTMLElement>("[data-ds-count-live]");
202
- const error = field.querySelector<HTMLElement>("[data-ds-error]");
203
- const errorText = field.querySelector<HTMLElement>("[data-ds-error-text]");
204
- const hint = field.querySelector<HTMLElement>(".ds-field__hint");
205
- if (!input) return;
202
+ fields.forEach((field) => {
203
+ if (field.dataset.dsBound) return;
204
+ field.dataset.dsBound = "true";
205
+ const input = field.querySelector<HTMLInputElement | HTMLTextAreaElement>(
206
+ ".ds-input",
207
+ );
208
+ const count = field.querySelector<HTMLElement>("[data-ds-count]");
209
+ const countLive = field.querySelector<HTMLElement>(
210
+ "[data-ds-count-live]",
211
+ );
212
+ const error = field.querySelector<HTMLElement>("[data-ds-error]");
213
+ const errorText = field.querySelector<HTMLElement>(
214
+ "[data-ds-error-text]",
215
+ );
216
+ const hint = field.querySelector<HTMLElement>(".ds-field__hint");
217
+ if (!input) return;
206
218
 
207
- if (count && input.maxLength > 0) {
208
- // The visible counter updates silently; screen readers only hear it
209
- // near the limit, not on every keystroke.
210
- const updateCount = () => {
211
- count.textContent = `${input.value.length} / ${input.maxLength}`;
212
- const left = input.maxLength - input.value.length;
213
- if (countLive) {
214
- countLive.textContent =
215
- left <= 5
216
- ? `${left} ${left === 1 ? "character" : "characters"} left`
217
- : "";
218
- }
219
- };
220
- input.addEventListener("input", updateCount);
221
- updateCount();
222
- }
219
+ if (count && input.maxLength > 0) {
220
+ // The visible counter updates silently; screen readers only hear it
221
+ // near the limit, not on every keystroke.
222
+ const updateCount = () => {
223
+ count.textContent = `${input.value.length} / ${input.maxLength}`;
224
+ const left = input.maxLength - input.value.length;
225
+ if (countLive) {
226
+ countLive.textContent =
227
+ left <= 5
228
+ ? `${left} ${left === 1 ? "character" : "characters"} left`
229
+ : "";
230
+ }
231
+ };
232
+ input.addEventListener("input", updateCount);
233
+ updateCount();
234
+ }
223
235
 
224
- if (!error || !errorText) return;
236
+ if (!error || !errorText) return;
225
237
 
226
- const message = () => {
227
- if (input.validity.valueMissing) {
228
- return input.dataset.requiredMessage || "This field is required.";
229
- }
230
- if (input.validity.tooShort) {
231
- return `Use at least ${input.minLength} characters.`;
232
- }
233
- if (input.validity.patternMismatch) {
234
- return field.dataset.message || "Use the requested format.";
235
- }
236
- if (input.validity.typeMismatch) {
237
- return "Enter a valid value.";
238
- }
239
- if (input.validity.tooLong) {
240
- return `Use at most ${input.maxLength} characters.`;
241
- }
242
- return "";
243
- };
238
+ const message = () => {
239
+ if (input.validity.valueMissing) {
240
+ return input.dataset.requiredMessage || "This field is required.";
241
+ }
242
+ if (input.validity.tooShort) {
243
+ return `Use at least ${input.minLength} characters.`;
244
+ }
245
+ if (input.validity.patternMismatch) {
246
+ return field.dataset.message || "Use the requested format.";
247
+ }
248
+ if (input.validity.typeMismatch) {
249
+ return "Enter a valid value.";
250
+ }
251
+ if (input.validity.tooLong) {
252
+ return `Use at most ${input.maxLength} characters.`;
253
+ }
254
+ return "";
255
+ };
244
256
 
245
- const apply = (invalid: boolean, text: string) => {
246
- input.classList.toggle("is-invalid", invalid);
247
- if (invalid) {
248
- input.setAttribute("aria-invalid", "true");
249
- } else {
250
- input.removeAttribute("aria-invalid");
251
- }
252
- error.hidden = !invalid;
253
- errorText.textContent = text;
254
- if (hint) hint.hidden = invalid;
255
- };
257
+ const apply = (invalid: boolean, text: string) => {
258
+ input.classList.toggle("is-invalid", invalid);
259
+ if (invalid) {
260
+ input.setAttribute("aria-invalid", "true");
261
+ } else {
262
+ input.removeAttribute("aria-invalid");
263
+ }
264
+ error.hidden = !invalid;
265
+ errorText.textContent = text;
266
+ if (hint) hint.hidden = invalid;
267
+ };
256
268
 
257
- const validate = () => {
258
- const text = message();
259
- apply(!!text, text);
260
- };
269
+ const validate = () => {
270
+ const text = message();
271
+ apply(!!text, text);
272
+ };
261
273
 
262
- input.addEventListener("blur", validate);
263
- input.addEventListener("input", () => {
264
- if (input.getAttribute("aria-invalid") === "true") validate();
274
+ input.addEventListener("blur", validate);
275
+ input.addEventListener("input", () => {
276
+ if (input.getAttribute("aria-invalid") === "true") validate();
277
+ });
265
278
  });
266
- });
267
279
 
268
- document
269
- .querySelectorAll<HTMLElement>("[data-ds-reveal]")
270
- .forEach((button) => {
271
- const field = button.closest("[data-ds-field]");
272
- const input = field?.querySelector<HTMLInputElement>(".ds-input");
273
- if (!input) return;
274
- const show = button.querySelector<HTMLElement>("[data-ds-reveal-show]");
275
- const hide = button.querySelector<HTMLElement>("[data-ds-reveal-hide]");
280
+ document
281
+ .querySelectorAll<HTMLElement>("[data-ds-reveal]")
282
+ .forEach((button) => {
283
+ if (button.dataset.dsBound) return;
284
+ button.dataset.dsBound = "true";
285
+ const field = button.closest("[data-ds-field]");
286
+ const input = field?.querySelector<HTMLInputElement>(".ds-input");
287
+ if (!input) return;
288
+ const show = button.querySelector<HTMLElement>("[data-ds-reveal-show]");
289
+ const hide = button.querySelector<HTMLElement>("[data-ds-reveal-hide]");
276
290
 
277
- button.addEventListener("click", () => {
278
- const revealed = input.type === "text";
279
- input.type = revealed ? "password" : "text";
280
- // Stable name, state in aria-pressed: "Show password, pressed".
281
- button.setAttribute("aria-pressed", revealed ? "false" : "true");
282
- if (show) show.hidden = !revealed;
283
- if (hide) hide.hidden = revealed;
284
- input.focus({ preventScroll: true });
291
+ button.addEventListener("click", () => {
292
+ const revealed = input.type === "text";
293
+ input.type = revealed ? "password" : "text";
294
+ // Stable name, state in aria-pressed: "Show password, pressed".
295
+ button.setAttribute("aria-pressed", revealed ? "false" : "true");
296
+ if (show) show.hidden = !revealed;
297
+ if (hide) hide.hidden = revealed;
298
+ input.focus({ preventScroll: true });
299
+ });
285
300
  });
286
- });
301
+ };
302
+
303
+ bind();
304
+ document.addEventListener("astro:page-load", bind);
287
305
  </script>
@@ -10,7 +10,7 @@ import "../styles/toast.css";
10
10
  *
11
11
  * With `triggerLabel` the component renders a real button and a template; the
12
12
  * button mounts the toast into one shared bottom-end region. For imperative
13
- * use, import `toast` from `@ds/components/scripts/toast` and call
13
+ * use, import `toast` from `@db-vincent/components/scripts/toast` and call
14
14
  * `toast.add`, `toast.close` or `toast.promise`.
15
15
  */
16
16
  interface Props {
@@ -0,0 +1,54 @@
1
+ ---
2
+ import "../styles/popover.css";
3
+ import "../styles/tooltip.css";
4
+
5
+ /**
6
+ * Tooltip. A short, non-essential label for a control. Wrap one focusable
7
+ * trigger; the script adds aria-describedby to it. Never put information a
8
+ * user needs, or anything interactive, in a tooltip: touch and many assistive
9
+ * technologies cannot reach it. Use Popover for that.
10
+ *
11
+ * <Tooltip text="Copy address"><Button iconOnly icon="copy" ariaLabel="Copy" /></Tooltip>
12
+ */
13
+ interface Props {
14
+ text: string;
15
+ placement?: "top" | "bottom" | "start" | "end";
16
+ /** Hover delay in milliseconds. Keyboard focus opens at once. */
17
+ delay?: number;
18
+ id?: string;
19
+ class?: string;
20
+ }
21
+
22
+ const { text, placement = "top", delay, id, class: className } = Astro.props;
23
+
24
+ const tipId = id ?? `tooltip-${Math.random().toString(36).slice(2, 9)}`;
25
+ ---
26
+
27
+ <span class="ds-anchor" data-ds-tooltip data-delay={delay}>
28
+ <slot />
29
+ <span
30
+ class:list={["ds-tooltip", `ds-tooltip--${placement}`, className]}
31
+ id={tipId}
32
+ role="tooltip"
33
+ popover="manual"
34
+ >
35
+ {text}
36
+ </span>
37
+ </span>
38
+
39
+ <script>
40
+ import { mountTooltip } from "../scripts/tooltip";
41
+
42
+ const bind = () => {
43
+ document
44
+ .querySelectorAll<HTMLElement>("[data-ds-tooltip]")
45
+ .forEach((root) => {
46
+ if (root.dataset.dsBound) return;
47
+ root.dataset.dsBound = "true";
48
+ mountTooltip(root);
49
+ });
50
+ };
51
+
52
+ bind();
53
+ document.addEventListener("astro:page-load", bind);
54
+ </script>