@stnd/ui 0.5.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.
@@ -0,0 +1,217 @@
1
+ ---
2
+ /**
3
+ * @component TableOfContents
4
+ * @description Subtle Notion-style TOC — small lines fixed to the right side
5
+ * that expand with heading labels on hover and highlight the current section.
6
+ *
7
+ * Usage:
8
+ * import TableOfContents from "@stnd/ui/TableOfContents.astro";
9
+ * <TableOfContents {headings} />
10
+ *
11
+ * Extract headings from rendered HTML:
12
+ * const headings = [];
13
+ * const headingRegex = /<h([23])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/gi;
14
+ * let m;
15
+ * while ((m = headingRegex.exec(html)) !== null) {
16
+ * headings.push({ depth: parseInt(m[1]), text: m[3].replace(/<[^>]+>/g, "").trim(), slug: m[2] });
17
+ * }
18
+ */
19
+
20
+ interface Heading {
21
+ depth: number;
22
+ text: string;
23
+ slug: string;
24
+ }
25
+
26
+ interface Props {
27
+ headings: Heading[];
28
+ /** Minimum headings to show the TOC. Default: 2 */
29
+ minHeadings?: number;
30
+ /** Breakpoint to hide on mobile. Default: 1100 */
31
+ breakpoint?: number;
32
+ }
33
+
34
+ const { headings, minHeadings = 2 } = Astro.props;
35
+ const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
36
+ ---
37
+
38
+ {
39
+ tocHeadings.length >= minHeadings && (
40
+ <nav class="stnd-toc" aria-label="Table des matières">
41
+ {tocHeadings.map((h) => (
42
+ <a
43
+ href={`#${h.slug}`}
44
+ class="toc-item"
45
+ data-slug={h.slug}
46
+ data-depth={h.depth}
47
+ aria-label={h.text}
48
+ >
49
+ <span class="toc-label">{h.text}</span>
50
+ <span class="toc-bar" />
51
+ </a>
52
+ ))}
53
+ </nav>
54
+ )
55
+ }
56
+
57
+ <style>
58
+ .stnd-toc {
59
+ position: fixed;
60
+ right: var(--space);
61
+ top: 50%;
62
+ transform: translateY(-50%);
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: var(--space-d8);
66
+ z-index: 10;
67
+ padding: var(--space-d2);
68
+ align-items: flex-end;
69
+ opacity: 0;
70
+ transition: all var(--transition);
71
+ }
72
+
73
+ .stnd-toc[data-visible],
74
+ .stnd-toc:hover {
75
+ opacity: 1;
76
+ }
77
+
78
+ .stnd-toc:hover {
79
+ gap: var(--space);
80
+ }
81
+
82
+ .toc-item {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: var(--space-d4);
86
+ text-decoration: none;
87
+ cursor: pointer;
88
+ }
89
+
90
+ .toc-label {
91
+ font-size: var(--size-sm);
92
+ color: var(--color-foreground);
93
+ font-family: var(--font-interface);
94
+ font-weight: var(--font-weight-bold);
95
+
96
+ white-space: nowrap;
97
+ max-width: 0;
98
+ overflow: hidden;
99
+ text-overflow: ellipsis;
100
+ opacity: 0;
101
+ transition: all var(--transition-fast);
102
+ }
103
+
104
+ .toc-bar {
105
+ display: block;
106
+ height: var(--space-d6);
107
+ border-radius: var(--radius);
108
+ background: var(--color-subtle);
109
+ opacity: 0.35;
110
+ transition: all var(--transition);
111
+ flex-shrink: 0;
112
+ }
113
+
114
+ .toc-item[data-depth="2"] .toc-bar {
115
+ width: var(--space);
116
+ }
117
+
118
+ .toc-item[data-depth="3"] .toc-label {
119
+ margin-right: var(--space-d2);
120
+ }
121
+ .toc-item[data-depth="3"] .toc-bar {
122
+ width: var(--space-d2);
123
+ }
124
+
125
+ /* Hover: expand labels, brighten bars */
126
+ .stnd-toc:hover .toc-label {
127
+ max-width: 16em;
128
+ opacity: 0.6;
129
+ }
130
+
131
+ .stnd-toc:hover .toc-item:hover .toc-label {
132
+ opacity: 1;
133
+ }
134
+
135
+ .stnd-toc:hover .toc-bar {
136
+ opacity: 1;
137
+ background: var(--color-muted);
138
+ }
139
+
140
+ .toc-item:hover .toc-bar {
141
+ opacity: 1 !important;
142
+ }
143
+
144
+ /* Active section */
145
+ .toc-item[data-active] .toc-bar {
146
+ opacity: 1;
147
+ background: var(--color-foreground);
148
+
149
+ }
150
+
151
+ .toc-item[data-active] .toc-label {
152
+ opacity: 1 !important;
153
+ }
154
+
155
+ @media (max-width: 1100px) {
156
+ .stnd-toc {
157
+ display: none;
158
+ }
159
+ }
160
+ </style>
161
+
162
+ <script>
163
+ document.addEventListener("astro:page-load", () => {
164
+ const toc = document.querySelector<HTMLElement>(".stnd-toc");
165
+ const tocLinks = document.querySelectorAll<HTMLElement>(".toc-item[data-slug]");
166
+ if (tocLinks.length === 0) return;
167
+
168
+ // Show on scroll or mouse move, hide after inactivity
169
+ let hideTimer: ReturnType<typeof setTimeout>;
170
+ const show = () => {
171
+ toc?.setAttribute("data-visible", "");
172
+ clearTimeout(hideTimer);
173
+ hideTimer = setTimeout(() => toc?.removeAttribute("data-visible"), 2500);
174
+ };
175
+ window.addEventListener("scroll", show, { passive: true });
176
+ window.addEventListener("mousemove", show, { passive: true });
177
+ toc?.addEventListener("mouseenter", () => {
178
+ clearTimeout(hideTimer);
179
+ toc.setAttribute("data-visible", "");
180
+ });
181
+ toc?.addEventListener("mouseleave", () => {
182
+ hideTimer = setTimeout(() => toc.removeAttribute("data-visible"), 2500);
183
+ });
184
+
185
+ const ids = [...tocLinks].map((a) => a.dataset.slug!);
186
+ const targets = ids
187
+ .map((id) => document.getElementById(id))
188
+ .filter(Boolean) as HTMLElement[];
189
+
190
+ if (targets.length === 0) return;
191
+
192
+ let current = ids[0];
193
+
194
+ const observer = new IntersectionObserver(
195
+ (entries) => {
196
+ const visible = entries
197
+ .filter((e) => e.isIntersecting)
198
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
199
+
200
+ if (visible.length > 0) {
201
+ current = visible[0].target.id;
202
+ }
203
+
204
+ tocLinks.forEach((link) => {
205
+ if (link.dataset.slug === current) {
206
+ link.setAttribute("data-active", "");
207
+ } else {
208
+ link.removeAttribute("data-active");
209
+ }
210
+ });
211
+ },
212
+ { rootMargin: "0px 0px -65% 0px", threshold: 0 },
213
+ );
214
+
215
+ targets.forEach((el) => observer.observe(el));
216
+ });
217
+ </script>
package/Toast.astro ADDED
@@ -0,0 +1,202 @@
1
+ ---
2
+ // sToastSystem.astro - Zero-dependency notification system
3
+ ---
4
+
5
+ <div id="toast-container" aria-live="polite" aria-atomic="true"></div>
6
+
7
+ <style>
8
+ #toast-container {
9
+ position: fixed;
10
+ top: var(--space-2);
11
+ left: 50%;
12
+ transform: translateX(-50%);
13
+ z-index: 9999999;
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: var(--space-half);
17
+ pointer-events: none;
18
+ margin: 0;
19
+ }
20
+
21
+ :global(.toast-item) {
22
+ color: var(--color-foreground);
23
+ border-radius: var(--radius);
24
+ border: var(--border);
25
+ padding: var(--space-half) var(--space-half);
26
+ font-family: var(--font-interface);
27
+ font-size: var(--scale-d3);
28
+ pointer-events: auto;
29
+ box-shadow: var(--shadow-glow);
30
+ backdrop-filter: var(--filter-glass);
31
+ background: var(--color-glass);
32
+ margin: 0 auto;
33
+
34
+ /* Animation setup */
35
+ opacity: 0;
36
+ filter: var(--filter-glass);
37
+ transition: all var(--transition);
38
+ }
39
+
40
+ :global(.toast-item.show) {
41
+ opacity: 1;
42
+ filter: blur(0);
43
+ }
44
+
45
+ :global(.toast-item.error) {
46
+ border-color: color-mix(
47
+ in srgb,
48
+ var(--color-border) 75%,
49
+ var(--color-error)
50
+ );
51
+ }
52
+ :global(.toast-item.success) {
53
+ border-color: color-mix(
54
+ in srgb,
55
+ var(--color-border) 75%,
56
+ var(--color-success)
57
+ );
58
+ }
59
+ :global(.toast-item.info) {
60
+ border-color: color-mix(
61
+ in srgb,
62
+ var(--color-border) 75%,
63
+ var(--color-info)
64
+ );
65
+ }
66
+ :global(.toast-item.warning) {
67
+ border-color: color-mix(
68
+ in srgb,
69
+ var(--color-border) 75,
70
+ var(--color-warning)
71
+ );
72
+ }
73
+
74
+ /* Mobile adjustments */
75
+ @media (max-width: 640px) {
76
+ #toast-container {
77
+ left: var(--space);
78
+ right: var(--space);
79
+ bottom: var(--space);
80
+ transform: none;
81
+ }
82
+
83
+ :global(.toast-item) {
84
+ text-align: center;
85
+ min-width: auto;
86
+ }
87
+ }
88
+ </style>
89
+
90
+ <script>
91
+ import { navigate } from "astro:transitions/client";
92
+
93
+ function initToastSystem() {
94
+ // Prevent duplicate listeners across re-renders
95
+ if ((window as any)._toastListenerAdded) return;
96
+
97
+ const container = document.getElementById("toast-container");
98
+ if (!container) return;
99
+
100
+ // Listen for custom 'toast' events (only add once)
101
+ window.addEventListener("toast", ((e: CustomEvent) => {
102
+ const { message, type = "default", duration = 4000, action } = e.detail;
103
+
104
+ // Create toast element
105
+ const toast = document.createElement("div");
106
+ toast.className = `toast-item ${type}`;
107
+
108
+ const messageSpan = document.createElement("span");
109
+ messageSpan.className = "toast-message";
110
+ messageSpan.textContent = message;
111
+ toast.appendChild(messageSpan);
112
+
113
+ // If an action was provided, render a button
114
+ if (action && action.label) {
115
+ const btn = document.createElement("button");
116
+ btn.className = "toast-action";
117
+ btn.type = "button";
118
+ btn.textContent = action.label;
119
+ btn.addEventListener("click", (ev) => {
120
+ ev.stopPropagation();
121
+ // Optionally close toast immediately
122
+ toast.classList.remove("show");
123
+
124
+ // Use declarative data-gate attributes so StandardGate handles it
125
+ if (action && action.view) {
126
+ try {
127
+ // If the view looks like a command (starts with ::), use data-gate-trigger
128
+ if (String(action.view).startsWith("::")) {
129
+ const tmp = document.createElement("button");
130
+ tmp.setAttribute("data-gate-trigger", action.view);
131
+ tmp.style.display = "none";
132
+ document.body.appendChild(tmp);
133
+ tmp.click();
134
+ tmp.remove();
135
+ } else {
136
+ // Otherwise treat as a prompt (initial query)
137
+ const tmp = document.createElement("button");
138
+ tmp.setAttribute("data-gate-prompt", action.view);
139
+ tmp.style.display = "none";
140
+ document.body.appendChild(tmp);
141
+ tmp.click();
142
+ tmp.remove();
143
+ }
144
+ } catch (err) {
145
+ console.warn("toast action view failed", err);
146
+ }
147
+ } else if (
148
+ action &&
149
+ action.onClick &&
150
+ typeof window[action.onClick] === "function"
151
+ ) {
152
+ try {
153
+ window[action.onClick]();
154
+ } catch (err) {
155
+ console.warn("toast action failed", err);
156
+ }
157
+ }
158
+ });
159
+ toast.appendChild(btn);
160
+ }
161
+
162
+ // Add to DOM
163
+ container.appendChild(toast);
164
+
165
+ // Trigger animation (next frame)
166
+ requestAnimationFrame(() => {
167
+ toast.classList.add("show");
168
+ });
169
+
170
+ // Remove after duration
171
+ setTimeout(() => {
172
+ toast.classList.remove("show");
173
+ toast.style.opacity = "0";
174
+
175
+ // Wait for CSS transition before removing from DOM
176
+ setTimeout(() => toast.remove(), 1000);
177
+ }, duration);
178
+ }) as EventListener);
179
+
180
+ (window as any)._toastListenerAdded = true;
181
+ }
182
+
183
+ // Initialize
184
+ initToastSystem();
185
+
186
+ // Global helpers for non-module scripts
187
+ const win = window as any;
188
+
189
+ win.toast = (
190
+ message: string,
191
+ type: string = "default",
192
+ duration: number = 2000,
193
+ action: { label?: string; view?: string; onClick?: string } | null = null,
194
+ ) => {
195
+ const detail = { message, type, duration };
196
+ if (action) detail.action = action;
197
+ window.dispatchEvent(new CustomEvent("toast", { detail }));
198
+ };
199
+
200
+ // Re-initialize after Astro view transitions (but only add listener once)
201
+ document.addEventListener("astro:page-load", initToastSystem);
202
+ </script>
package/dialog.js ADDED
@@ -0,0 +1,53 @@
1
+ import { writable } from "svelte/store";
2
+ import { provide } from "@stnd/store";
3
+
4
+ /**
5
+ * @typedef {Object} DialogOptions
6
+ * @property {string} [title]
7
+ * @property {string} [description]
8
+ * @property {string} [confirmLabel]
9
+ * @property {string} [cancelLabel]
10
+ * @property {'neutral' | 'danger'} [intent]
11
+ */
12
+
13
+ export const dialogState = writable({
14
+ open: false,
15
+ title: "",
16
+ description: "",
17
+ confirmLabel: "Continue",
18
+ cancelLabel: "Cancel",
19
+ intent: "danger",
20
+ onconfirm: null,
21
+ oncancel: null,
22
+ });
23
+
24
+ /**
25
+ * Open a confirmation dialog
26
+ * @param {DialogOptions} options
27
+ * @returns {Promise<boolean>} Resolves true if confirmed, false if canceled
28
+ */
29
+ export function confirm(options) {
30
+ return new Promise((resolve) => {
31
+ dialogState.set({
32
+ open: true,
33
+ title: options.title || "Are you sure?",
34
+ description: options.description || "",
35
+ confirmLabel: options.confirmLabel || "Continue",
36
+ cancelLabel: options.cancelLabel || "Cancel",
37
+ intent: options.intent || "neutral",
38
+ onconfirm: () => {
39
+ resolve(true);
40
+ dialogState.update((s) => ({ ...s, open: false }));
41
+ },
42
+ oncancel: () => {
43
+ resolve(false);
44
+ dialogState.update((s) => ({ ...s, open: false }));
45
+ },
46
+ });
47
+ });
48
+ }
49
+
50
+ // Register the confirm function and dialogState in the runtime store so any
51
+ // module can trigger a dialog via inject("dialog").confirm(...) or
52
+ // subscribe to inject("dialog").state.
53
+ provide("dialog", { confirm, state: dialogState });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@stnd/ui",
3
+ "version": "0.5.0",
4
+ "type": "module",
5
+ "svelte": "./Combobox.svelte",
6
+ "exports": {
7
+ "./transitions.js": "./transitions.js",
8
+ "./ui/*.astro": "./ui/*.astro",
9
+ "./ui/*.svelte": "./ui/*.svelte",
10
+ "./ui/*": {
11
+ "astro": "./ui/*.astro",
12
+ "svelte": "./ui/*.svelte",
13
+ "default": "./ui/*.astro"
14
+ },
15
+ "./*.astro": "./*.astro",
16
+ "./*.svelte": "./*.svelte",
17
+ "./*": {
18
+ "astro": "./*.astro",
19
+ "svelte": "./*.svelte",
20
+ "default": "./*.astro"
21
+ }
22
+ },
23
+ "peerDependencies": {
24
+ "astro": ">=5.0.0"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "wrangler": "^4.105.0",
31
+ "@stnd/icon": "0.2.0",
32
+ "@stnd/store": "0.5.0",
33
+ "@stnd/utils": "0.5.0"
34
+ }
35
+ }
package/transitions.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Standard Garden Centralized Transitions
3
+ * Combines standard Svelte transitions with custom UI transitions (blur, scale)
4
+ * using unified default durations and easings.
5
+ */
6
+
7
+ import { fade as svelteFade, slide as svelteSlide, fly as svelteFly } from "svelte/transition";
8
+ import { quintOut } from "svelte/easing";
9
+
10
+ // Standard default transition settings matching Standard UI tokens
11
+ const DEFAULT_DURATION = 250;
12
+ const DEFAULT_EASING = quintOut;
13
+
14
+ /**
15
+ * Standard Fade
16
+ */
17
+ export function fade(node, { duration = DEFAULT_DURATION, easing = DEFAULT_EASING, ...opts } = {}) {
18
+ return svelteFade(node, { duration, easing, ...opts });
19
+ }
20
+
21
+ /**
22
+ * Standard Slide
23
+ */
24
+ export function slide(node, { duration = DEFAULT_DURATION, easing = DEFAULT_EASING, ...opts } = {}) {
25
+ return svelteSlide(node, { duration, easing, ...opts });
26
+ }
27
+
28
+ /**
29
+ * Standard Fly
30
+ */
31
+ export function fly(node, { duration = DEFAULT_DURATION, easing = DEFAULT_EASING, ...opts } = {}) {
32
+ return svelteFly(node, { duration, easing, ...opts });
33
+ }
34
+
35
+ /**
36
+ * Fade + Blur transition
37
+ * Animates opacity and backdrop-filter blur together
38
+ */
39
+ export function blurFade(node, { duration = DEFAULT_DURATION, maxBlur = 20, easing = DEFAULT_EASING } = {}) {
40
+ return {
41
+ duration,
42
+ easing,
43
+ css: (t) => `
44
+ opacity: ${t};
45
+ backdrop-filter: blur(${t * maxBlur}px);
46
+ `,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Scale + Blur + Fade transition
52
+ * Combines scale, blur, and fade for modal-like elements
53
+ */
54
+ export function scaleBlurFade(
55
+ node,
56
+ { duration = DEFAULT_DURATION, maxBlur = 20, start = 0.95, easing = DEFAULT_EASING } = {},
57
+ ) {
58
+ return {
59
+ duration,
60
+ easing,
61
+ css: (t) => `
62
+ opacity: ${t};
63
+ transform: scale(${start + (1 - start) * t});
64
+ backdrop-filter: blur(${t * maxBlur}px);
65
+ `,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Blur transition (backdrop only, no fade)
71
+ */
72
+ export function blur(node, { duration = DEFAULT_DURATION, maxBlur = 8, easing = DEFAULT_EASING } = {}) {
73
+ return {
74
+ duration,
75
+ easing,
76
+ css: (t) => `backdrop-filter: blur(${t * maxBlur}px);`,
77
+ };
78
+ }