@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,305 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import "../styles/sr-only.css";
4
+ import "../styles/input.css";
5
+
6
+ /**
7
+ * Text field. Label above, hint and count below, error replaces the hint.
8
+ * Hint and error are wired through aria-describedby; invalid sets
9
+ * aria-invalid. `validate` adds native constraint validation on blur, with
10
+ * live recovery while typing. Password fields get a reveal toggle, and
11
+ * counters update as the value changes.
12
+ */
13
+ interface Props {
14
+ label?: string;
15
+ id?: string;
16
+ name?: string;
17
+ type?: string;
18
+ value?: string;
19
+ placeholder?: string;
20
+ hint?: string;
21
+ error?: string;
22
+ valid?: boolean;
23
+ validate?: boolean;
24
+ disabled?: boolean;
25
+ required?: boolean;
26
+ optional?: boolean;
27
+ multiline?: boolean;
28
+ rows?: number;
29
+ /** Multiline only. Which way the user can drag the handle. */
30
+ resize?: "none" | "vertical" | "both";
31
+ maxlength?: number;
32
+ minlength?: number;
33
+ pattern?: string;
34
+ count?: boolean;
35
+ size?: "sm" | "md" | "lg";
36
+ autocomplete?: string;
37
+ inputmode?:
38
+ "text" | "numeric" | "decimal" | "email" | "tel" | "search" | "url";
39
+ class?: string;
40
+ }
41
+
42
+ const {
43
+ label,
44
+ id,
45
+ name,
46
+ type = "text",
47
+ value,
48
+ placeholder,
49
+ hint,
50
+ error,
51
+ valid = false,
52
+ validate = false,
53
+ disabled = false,
54
+ required = false,
55
+ optional = false,
56
+ multiline = false,
57
+ rows = 3,
58
+ resize = "vertical",
59
+ maxlength,
60
+ minlength,
61
+ pattern,
62
+ count = false,
63
+ size = "md",
64
+ autocomplete,
65
+ inputmode,
66
+ class: className,
67
+ } = Astro.props;
68
+
69
+ const fieldId = id ?? `field-${Math.random().toString(36).slice(2, 9)}`;
70
+ const hintId = `${fieldId}-hint`;
71
+ const errorId = `${fieldId}-error`;
72
+ const describedby =
73
+ [hint && (!error || validate) && hintId, (error || validate) && errorId]
74
+ .filter(Boolean)
75
+ .join(" ") || undefined;
76
+ const current = value ?? "";
77
+ const isPassword = !multiline && type === "password";
78
+ const inputClass = [
79
+ "ds-input",
80
+ size !== "md" && `ds-input--${size}`,
81
+ multiline && resize !== "vertical" && `ds-input--resize-${resize}`,
82
+ error && "is-invalid",
83
+ valid && !error && "is-valid",
84
+ valid && !error && "ds-input--with-state",
85
+ ]
86
+ .filter(Boolean)
87
+ .join(" ");
88
+ ---
89
+
90
+ <div
91
+ class:list={["ds-field", isPassword && "ds-field--reveal", className]}
92
+ data-ds-field
93
+ data-message={error}
94
+ >
95
+ {label && (
96
+ <label class="ds-field__label" for={fieldId}>
97
+ {label}
98
+ {optional && <span class="ds-field__optional"> (optional)</span>}
99
+ {required && (
100
+ <span class="ds-field__required" aria-hidden="true">
101
+ {" "}
102
+ *
103
+ </span>
104
+ )}
105
+ </label>
106
+ )}
107
+
108
+ <div class="ds-field__control">
109
+ {multiline ? (
110
+ <textarea
111
+ class={inputClass}
112
+ id={fieldId}
113
+ name={name}
114
+ rows={rows}
115
+ placeholder={placeholder}
116
+ disabled={disabled}
117
+ required={required}
118
+ maxlength={maxlength}
119
+ minlength={minlength}
120
+ aria-invalid={error ? "true" : undefined}
121
+ aria-describedby={describedby}
122
+ autocomplete={autocomplete}
123
+ >{current}</textarea>
124
+ ) : (
125
+ <input
126
+ class={inputClass}
127
+ id={fieldId}
128
+ name={name}
129
+ type={type}
130
+ value={value}
131
+ placeholder={placeholder}
132
+ disabled={disabled}
133
+ required={required}
134
+ maxlength={maxlength}
135
+ minlength={minlength}
136
+ pattern={pattern}
137
+ inputmode={inputmode}
138
+ autocomplete={autocomplete}
139
+ aria-invalid={error ? "true" : undefined}
140
+ aria-describedby={describedby}
141
+ />
142
+ )}
143
+ {valid && !error && (
144
+ <>
145
+ <span class="ds-field__state" aria-hidden="true">
146
+ <Icon name="check" size={14} />
147
+ </span>
148
+ <span class="ds-sr-only">Valid.</span>
149
+ </>
150
+ )}
151
+ {isPassword && (
152
+ <button
153
+ class="ds-field__reveal"
154
+ type="button"
155
+ data-ds-reveal
156
+ aria-label="Show password"
157
+ aria-pressed="false"
158
+ aria-controls={fieldId}
159
+ disabled={disabled}
160
+ >
161
+ <span data-ds-reveal-show aria-hidden="true">
162
+ <Icon name="eye" size={16} />
163
+ </span>
164
+ <span data-ds-reveal-hide aria-hidden="true" hidden>
165
+ <Icon name="eye-off" size={16} />
166
+ </span>
167
+ </button>
168
+ )}
169
+ </div>
170
+
171
+ {((error && !validate) || (hint && (!error || validate))) && (
172
+ <div
173
+ class:list={[error && !validate ? "ds-field__error" : "ds-field__hint"]}
174
+ id={error && !validate ? errorId : hintId}
175
+ >
176
+ {error && !validate && <Icon name="alert-circle" size={14} />}
177
+ {error && !validate ? error : hint}
178
+ </div>
179
+ )}
180
+
181
+ {count && maxlength && (
182
+ <>
183
+ <span class="ds-field__count" data-ds-count>
184
+ {current.length} / {maxlength}
185
+ </span>
186
+ <span class="ds-sr-only" aria-live="polite" data-ds-count-live></span>
187
+ </>
188
+ )}
189
+
190
+ {validate && (
191
+ <div class="ds-field__error" id={errorId} data-ds-error hidden={!error}>
192
+ <Icon name="alert-circle" size={14} />
193
+ <span data-ds-error-text>{error || "Check this field."}</span>
194
+ </div>
195
+ )}
196
+ </div>
197
+
198
+ <script>
199
+ const bind = () => {
200
+ const fields = document.querySelectorAll<HTMLElement>("[data-ds-field]");
201
+
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;
218
+
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
+ }
235
+
236
+ if (!error || !errorText) return;
237
+
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
+ };
256
+
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
+ };
268
+
269
+ const validate = () => {
270
+ const text = message();
271
+ apply(!!text, text);
272
+ };
273
+
274
+ input.addEventListener("blur", validate);
275
+ input.addEventListener("input", () => {
276
+ if (input.getAttribute("aria-invalid") === "true") validate();
277
+ });
278
+ });
279
+
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]");
290
+
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
+ });
300
+ });
301
+ };
302
+
303
+ bind();
304
+ document.addEventListener("astro:page-load", bind);
305
+ </script>
@@ -0,0 +1,66 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import Button from "./Button.astro";
4
+ import "../styles/theme-toggle.css";
5
+
6
+ /** Light/dark switch. Persists the choice and follows the OS until then. */
7
+ interface Props {
8
+ class?: string;
9
+ }
10
+
11
+ const { class: className } = Astro.props;
12
+ ---
13
+
14
+ <Button
15
+ variant="ghost"
16
+ iconOnly
17
+ ariaLabel="Switch color theme"
18
+ class:list={["ds-theme-toggle", className]}
19
+ data-theme-toggle
20
+ >
21
+ <span class="ds-theme-toggle__sun" aria-hidden="true">
22
+ <Icon name="sun" size={16} />
23
+ </span>
24
+ <span class="ds-theme-toggle__moon" aria-hidden="true">
25
+ <Icon name="moon" size={16} />
26
+ </span>
27
+ </Button>
28
+
29
+ <script>
30
+ const root = document.documentElement;
31
+ const query = window.matchMedia("(prefers-color-scheme: dark)");
32
+ const current = () =>
33
+ root.dataset.theme ?? (query.matches ? "dark" : "light");
34
+
35
+ const sync = () => {
36
+ const theme = current();
37
+ document
38
+ .querySelectorAll<HTMLElement>("[data-theme-toggle]")
39
+ .forEach((button) => {
40
+ button.setAttribute(
41
+ "aria-label",
42
+ `Switch to ${theme === "dark" ? "light" : "dark"} theme`,
43
+ );
44
+ });
45
+ };
46
+
47
+ const apply = (theme: "light" | "dark") => {
48
+ root.dataset.theme = theme;
49
+ try {
50
+ localStorage.setItem("minerva-theme", theme);
51
+ } catch {}
52
+ sync();
53
+ };
54
+
55
+ document
56
+ .querySelectorAll<HTMLElement>("[data-theme-toggle]")
57
+ .forEach((button) => {
58
+ if (button.dataset.bound) return;
59
+ button.dataset.bound = "true";
60
+ button.addEventListener("click", () =>
61
+ apply(current() === "dark" ? "light" : "dark"),
62
+ );
63
+ });
64
+ query.addEventListener("change", sync);
65
+ sync();
66
+ </script>
@@ -0,0 +1,181 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import Button from "./Button.astro";
4
+ import "../styles/toast.css";
5
+
6
+ /**
7
+ * Toast. A transient message on a raised neutral popover surface, shaped after
8
+ * shadcn/ui: neutral card, type icon, inline action and close. `danger` (alias
9
+ * `error`) and `warning` are assertive, the rest are polite.
10
+ *
11
+ * With `triggerLabel` the component renders a real button and a template; the
12
+ * button mounts the toast into one shared bottom-end region. For imperative
13
+ * use, import `toast` from `@db-vincent/components/scripts/toast` and call
14
+ * `toast.add`, `toast.close` or `toast.promise`.
15
+ */
16
+ interface Props {
17
+ tone?: "info" | "success" | "warning" | "danger" | "error" | "loading";
18
+ type?: "info" | "success" | "warning" | "danger" | "error" | "loading";
19
+ title: string;
20
+ message?: string;
21
+ description?: string;
22
+ dismissible?: boolean;
23
+ icon?: boolean;
24
+ triggerLabel?: string;
25
+ triggerVariant?: "primary" | "secondary" | "ghost" | "danger";
26
+ /** Auto-dismiss delay for the mounted toast, in milliseconds. 0 keeps it. */
27
+ duration?: number;
28
+ id?: string;
29
+ class?: string;
30
+ }
31
+
32
+ const {
33
+ tone: toneProp,
34
+ type: typeProp,
35
+ title,
36
+ message,
37
+ description,
38
+ dismissible = true,
39
+ icon = true,
40
+ triggerLabel,
41
+ triggerVariant = "secondary",
42
+ duration = 5000,
43
+ id,
44
+ class: className,
45
+ } = Astro.props;
46
+
47
+ const requested = typeProp ?? toneProp ?? "info";
48
+ const tone = requested === "error" ? "danger" : requested;
49
+ const role = tone === "danger" || tone === "warning" ? "alert" : "status";
50
+ const live = role === "alert" ? "assertive" : "polite";
51
+ const iconName = {
52
+ info: "info",
53
+ success: "check-circle",
54
+ warning: "alert-triangle",
55
+ danger: "alert-circle",
56
+ loading: "loader",
57
+ }[tone];
58
+ const body = description ?? message;
59
+ const toastId = id ?? `toast-${Math.random().toString(36).slice(2, 9)}`;
60
+ const hasActions = Astro.slots.has("actions");
61
+ const classes = ["ds-toast", `ds-toast--${tone}`, className]
62
+ .filter(Boolean)
63
+ .join(" ");
64
+ ---
65
+
66
+ {triggerLabel ? (
67
+ <>
68
+ <Button variant={triggerVariant} type="button" data-toast-trigger={toastId}>
69
+ {triggerLabel}
70
+ </Button>
71
+ <template id={`${toastId}-template`}>
72
+ <div
73
+ class={classes}
74
+ id={toastId}
75
+ role={role}
76
+ aria-live={live}
77
+ aria-atomic="true"
78
+ data-toast
79
+ data-toast-type={tone}
80
+ data-toast-duration={duration}
81
+ >
82
+ {icon && (
83
+ <span class="ds-toast__icon" aria-hidden="true">
84
+ <Icon name={iconName} size={16} />
85
+ </span>
86
+ )}
87
+ <div class="ds-toast__content">
88
+ <p class="ds-toast__title">{title}</p>
89
+ {body && <p class="ds-toast__body">{body}</p>}
90
+ <slot />
91
+ </div>
92
+ {hasActions && (
93
+ <div class="ds-toast__actions">
94
+ <slot name="actions" />
95
+ </div>
96
+ )}
97
+ {dismissible && (
98
+ <button
99
+ class="ds-toast__close"
100
+ type="button"
101
+ aria-label="Close toast"
102
+ data-toast-close
103
+ >
104
+ <Icon name="x" size={16} />
105
+ </button>
106
+ )}
107
+ </div>
108
+ </template>
109
+ </>
110
+ ) : (
111
+ <div
112
+ class={classes}
113
+ id={toastId}
114
+ role={role}
115
+ aria-live={live}
116
+ aria-atomic="true"
117
+ data-toast
118
+ data-toast-type={tone}
119
+ >
120
+ {icon && (
121
+ <span class="ds-toast__icon" aria-hidden="true">
122
+ <Icon name={iconName} size={16} />
123
+ </span>
124
+ )}
125
+ <div class="ds-toast__content">
126
+ <p class="ds-toast__title">{title}</p>
127
+ {body && <p class="ds-toast__body">{body}</p>}
128
+ <slot />
129
+ </div>
130
+ {hasActions && (
131
+ <div class="ds-toast__actions">
132
+ <slot name="actions" />
133
+ </div>
134
+ )}
135
+ {dismissible && (
136
+ <button
137
+ class="ds-toast__close"
138
+ type="button"
139
+ aria-label="Close toast"
140
+ data-toast-close
141
+ >
142
+ <Icon name="x" size={16} />
143
+ </button>
144
+ )}
145
+ </div>
146
+ )}
147
+
148
+ <script>
149
+ import { dismiss, mount } from "../scripts/toast";
150
+
151
+ document.addEventListener("click", (event) => {
152
+ const target = event.target;
153
+ if (!(target instanceof Element)) return;
154
+
155
+ const trigger = target.closest<HTMLElement>("[data-toast-trigger]");
156
+ if (trigger) {
157
+ const id = trigger.dataset.toastTrigger;
158
+ const template = id
159
+ ? (document.getElementById(
160
+ `${id}-template`,
161
+ ) as HTMLTemplateElement | null)
162
+ : null;
163
+ const toast = template?.content.firstElementChild?.cloneNode(true);
164
+ if (toast instanceof HTMLElement) {
165
+ // Inside the shared region the region itself is the live region.
166
+ if (toast.getAttribute("role") === "status") {
167
+ toast.removeAttribute("role");
168
+ toast.removeAttribute("aria-live");
169
+ }
170
+ mount(toast, trigger);
171
+ }
172
+ return;
173
+ }
174
+
175
+ // Inline previews rendered directly on a page are not mounted in the
176
+ // shared region, so they keep a local close handler.
177
+ const close = target.closest("[data-toast-close]");
178
+ const toast = close?.closest<HTMLElement>("[data-toast]");
179
+ if (toast && !toast.closest("[data-toast-region]")) dismiss(toast);
180
+ });
181
+ </script>
@@ -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>
@@ -0,0 +1,29 @@
1
+ ---
2
+ import "../styles/topbar.css";
3
+
4
+ /**
5
+ * Slim, non-sticky utility strip for the top of a page.
6
+ *
7
+ * The start slot holds the page context (usually `<Breadcrumbs>`), the
8
+ * actions slot holds small utilities (theme toggle, command shortcut).
9
+ * It scrolls away with the page; persistent navigation belongs to
10
+ * `<Navbar>` or the sidebar.
11
+ */
12
+
13
+ interface Props {
14
+ class?: string;
15
+ }
16
+
17
+ const { class: className } = Astro.props;
18
+ ---
19
+
20
+ <div class:list={["ds-topbar", className]}>
21
+ <div class="ds-topbar__inner">
22
+ <div class="ds-topbar__start">
23
+ <slot name="start" />
24
+ </div>
25
+ <div class="ds-topbar__actions">
26
+ <slot name="actions" />
27
+ </div>
28
+ </div>
29
+ </div>