@stnd/ui 0.5.0 → 0.5.2
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.
- package/Accordion.astro +10 -8
- package/AccordionItem.astro +10 -7
- package/Alert.svelte +14 -10
- package/AlertDialog.svelte +16 -9
- package/CHANGELOG.md +12 -0
- package/CfImage.astro +19 -13
- package/Combobox.svelte +17 -11
- package/ContextMenu.svelte +73 -88
- package/ContextMenuItem.svelte +7 -1
- package/ContextMenuItemCheckbox.svelte +6 -0
- package/ContextMenuLabel.svelte +6 -0
- package/ContextMenuSeparator.svelte +7 -0
- package/Dialog.svelte +60 -0
- package/DialogManager.svelte +12 -0
- package/Dropdown.svelte +46 -11
- package/DropdownItem.svelte +7 -1
- package/DropdownLabel.svelte +8 -0
- package/DropdownSeparator.svelte +7 -0
- package/LauncherHint.astro +7 -3
- package/Pagination.astro +9 -10
- package/Panel.svelte +2 -2
- package/Popover.svelte +83 -0
- package/README.md +45 -4
- package/Scroller.astro +6 -3
- package/Scroller.svelte +6 -0
- package/TableOfContents.astro +33 -16
- package/Toast.astro +21 -17
- package/menu.css +12 -0
- package/menu.js +33 -0
- package/menu.test.js +44 -0
- package/package.json +7 -5
package/Dropdown.svelte
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import { setContext } from "svelte";
|
|
2
|
+
import { setContext, tick } from "svelte";
|
|
3
|
+
import "./menu.css";
|
|
4
|
+
import { menuItems, navigateMenu } from "./menu.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @component Dropdown
|
|
8
|
+
* @description Accessible, keyboard-navigable dropdown menu. Use the
|
|
9
|
+
* `trigger` slot for the button that opens it, the default slot for its
|
|
10
|
+
* items — typically `<DropdownItem>`/`<DropdownLabel>`/`<DropdownSeparator>`.
|
|
11
|
+
*/
|
|
3
12
|
|
|
4
13
|
/**
|
|
5
14
|
* @typedef {Object} DropdownProps
|
|
@@ -7,6 +16,9 @@
|
|
|
7
16
|
* @property {import('svelte').Snippet} children - The menu items
|
|
8
17
|
* @property {'start' | 'end'} [align] - Alignment of the dropdown
|
|
9
18
|
* @property {string} [class] - Optional class
|
|
19
|
+
* @property {string} [label] - Accessible native trigger label (recommended)
|
|
20
|
+
* @property {string} [triggerClass] - Classes on the native trigger
|
|
21
|
+
* @property {boolean} [open] - Bindable open state
|
|
10
22
|
*/
|
|
11
23
|
|
|
12
24
|
/** @type {DropdownProps} */
|
|
@@ -15,19 +27,26 @@
|
|
|
15
27
|
children,
|
|
16
28
|
align = "start",
|
|
17
29
|
class: className = "",
|
|
30
|
+
label = "",
|
|
31
|
+
triggerClass = "",
|
|
32
|
+
open = $bindable(false),
|
|
18
33
|
...props
|
|
19
34
|
} = $props();
|
|
20
35
|
|
|
21
|
-
let open = $state(false);
|
|
22
36
|
let triggerElement = $state();
|
|
23
37
|
let menuElement = $state();
|
|
38
|
+
const id = $props.id();
|
|
24
39
|
|
|
25
40
|
function toggle() {
|
|
26
41
|
open = !open;
|
|
42
|
+
if (open) tick().then(() => {
|
|
43
|
+
if (open && menuElement) (menuItems(menuElement)[0] ?? menuElement).focus();
|
|
44
|
+
});
|
|
27
45
|
}
|
|
28
46
|
|
|
29
|
-
function close() {
|
|
47
|
+
function close(restore = true) {
|
|
30
48
|
open = false;
|
|
49
|
+
if (restore) (triggerElement?.querySelector("button, a") ?? triggerElement)?.focus();
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
setContext("dropdown", {
|
|
@@ -41,41 +60,54 @@
|
|
|
41
60
|
!menuElement.contains(e.target) &&
|
|
42
61
|
(!triggerElement || !triggerElement.contains(e.target))
|
|
43
62
|
) {
|
|
44
|
-
close();
|
|
63
|
+
close(false);
|
|
45
64
|
}
|
|
46
65
|
}
|
|
47
66
|
|
|
48
|
-
function
|
|
49
|
-
if (!open)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
function triggerKeydown(e) {
|
|
68
|
+
if (e.key === "ArrowDown") { e.preventDefault(); if (!open) toggle(); }
|
|
69
|
+
// Legacy wrapper: native children already synthesize their own click.
|
|
70
|
+
else if (e.target === e.currentTarget && !label && (e.key === "Enter" || e.key === " ")) {
|
|
71
|
+
e.preventDefault(); toggle();
|
|
53
72
|
}
|
|
54
73
|
}
|
|
55
74
|
</script>
|
|
56
75
|
|
|
57
|
-
<svelte:window onclick={handleWindowClick}
|
|
76
|
+
<svelte:window onclick={handleWindowClick} />
|
|
58
77
|
|
|
59
78
|
<div class="std-dropdown {className}" {...props}>
|
|
79
|
+
{#if label}
|
|
80
|
+
<button bind:this={triggerElement} type="button" class={triggerClass}
|
|
81
|
+
aria-label={label} title={label} aria-haspopup="menu" aria-expanded={open} aria-controls={id}
|
|
82
|
+
onclick={toggle} onkeydown={triggerKeydown}>
|
|
83
|
+
{@render trigger?.()}
|
|
84
|
+
</button>
|
|
85
|
+
{:else}
|
|
60
86
|
<div
|
|
61
87
|
bind:this={triggerElement}
|
|
62
88
|
class="std-dropdown-trigger"
|
|
63
89
|
onclick={toggle}
|
|
64
90
|
role="button"
|
|
65
91
|
tabindex="0"
|
|
66
|
-
onkeydown={
|
|
92
|
+
onkeydown={triggerKeydown}
|
|
67
93
|
aria-haspopup="menu"
|
|
68
94
|
aria-expanded={open}
|
|
95
|
+
aria-controls={id}
|
|
69
96
|
>
|
|
70
97
|
{@render trigger?.()}
|
|
71
98
|
</div>
|
|
99
|
+
{/if}
|
|
72
100
|
|
|
73
101
|
{#if open}
|
|
74
102
|
<div
|
|
75
103
|
bind:this={menuElement}
|
|
104
|
+
{id}
|
|
76
105
|
class="std-menu-content std-dropdown-content no-rhythm"
|
|
77
106
|
class:align-end={align === "end"}
|
|
78
107
|
role="menu"
|
|
108
|
+
aria-label={label || undefined}
|
|
109
|
+
tabindex="-1"
|
|
110
|
+
onkeydown={(event) => navigateMenu(event, menuElement, close)}
|
|
79
111
|
>
|
|
80
112
|
{@render children?.()}
|
|
81
113
|
</div>
|
|
@@ -98,6 +130,9 @@
|
|
|
98
130
|
top: 100%;
|
|
99
131
|
margin-top: var(--space-d2);
|
|
100
132
|
min-width: var(--space-6);
|
|
133
|
+
max-height: 70vh;
|
|
134
|
+
overflow-y: auto;
|
|
135
|
+
z-index: 100;
|
|
101
136
|
|
|
102
137
|
&.align-end {
|
|
103
138
|
right: 0;
|
package/DropdownItem.svelte
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
import { getContext } from "svelte";
|
|
3
3
|
import Icon from "@stnd/icon/Icon.svelte";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @component DropdownItem
|
|
7
|
+
* @description One clickable row inside a `<Dropdown>`. Either provide
|
|
8
|
+
* `label` or the `children` snippet, not both.
|
|
9
|
+
*/
|
|
10
|
+
|
|
5
11
|
/**
|
|
6
12
|
* @typedef {Object} DropdownItemProps
|
|
7
13
|
* @property {import('svelte').Snippet} [children] - Item label
|
|
@@ -14,7 +20,7 @@
|
|
|
14
20
|
* @property {(e: MouseEvent) => void} [onclick] - Click handler
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
|
-
/** @type {DropdownItemProps} */
|
|
23
|
+
/** @type {DropdownItemProps & import('svelte/elements').HTMLAttributes<HTMLElement>} */
|
|
18
24
|
let {
|
|
19
25
|
children,
|
|
20
26
|
label,
|
package/DropdownLabel.svelte
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
<script>
|
|
2
|
+
/**
|
|
3
|
+
* @component DropdownLabel
|
|
4
|
+
* @description A non-interactive heading row inside a `<Dropdown>`, used
|
|
5
|
+
* to group related items.
|
|
6
|
+
*
|
|
7
|
+
* @property {import('svelte').Snippet} [children] - Label content
|
|
8
|
+
* @property {string} [label] - Label as a string, if not using the children snippet
|
|
9
|
+
*/
|
|
2
10
|
let { children, label, ...props } = $props();
|
|
3
11
|
</script>
|
|
4
12
|
|
package/DropdownSeparator.svelte
CHANGED
package/LauncherHint.astro
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* LauncherHint
|
|
3
|
+
* @component LauncherHint
|
|
4
|
+
* @description A small launcher hint button (⌘K) — visibility/positioning
|
|
5
|
+
* is handled by whatever container places it (e.g. header slots), this
|
|
6
|
+
* component only renders the button itself.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
8
|
+
* @property {string} [title] - Accessible label / tooltip text. Default: "Open Launcher — Cmd/Ctrl+K".
|
|
9
|
+
* @property {string} [kbd] - Shortcut text shown inside the button. Default: "⌘K".
|
|
10
|
+
* @property {string} [class] - Extra class(es) for the button.
|
|
7
11
|
*/
|
|
8
12
|
const {
|
|
9
13
|
title = "Open Launcher — Cmd/Ctrl+K",
|
package/Pagination.astro
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Pagination
|
|
3
|
+
* @component Pagination
|
|
4
|
+
* @description Zero-JS pagination nav — previous / page numbers / next,
|
|
5
|
+
* using standard button classes (`ghost`, `outline`) from `@stnd/styles`.
|
|
6
|
+
* URL pattern: page 1 links to `firstPageUrl` (or `baseUrl` if omitted),
|
|
7
|
+
* page N links to `baseUrl/N` (e.g. `/chroniques/page/2`).
|
|
4
8
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* <Pagination currentPage={2} totalPages={5} baseUrl="/chroniques/page" firstPageUrl="/chroniques" />
|
|
10
|
-
*
|
|
11
|
-
* URL pattern:
|
|
12
|
-
* Page 1 → firstPageUrl (or baseUrl if omitted)
|
|
13
|
-
* Page N → baseUrl/N (e.g. /chroniques/page/2)
|
|
9
|
+
* @example astro
|
|
10
|
+
* <Pagination currentPage={2} totalPages={5} baseUrl="/chroniques/page" firstPageUrl="/chroniques" />
|
|
14
11
|
*/
|
|
15
12
|
import Icon from "@stnd/icon/Icon.astro";
|
|
16
13
|
|
|
17
14
|
interface Props {
|
|
15
|
+
/** 1-indexed current page. */
|
|
18
16
|
currentPage: number;
|
|
19
17
|
totalPages: number;
|
|
18
|
+
/** Base URL pages 2+ are appended to as `baseUrl/N`. */
|
|
20
19
|
baseUrl: string;
|
|
21
20
|
/** URL for page 1 when it differs from baseUrl (e.g. "/chroniques" vs "/chroniques/page") */
|
|
22
21
|
firstPageUrl?: string;
|
package/Panel.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
/**
|
|
3
|
-
* @
|
|
4
|
-
* Edge Panel — a collapsible side panel fixed to the screen edge.
|
|
3
|
+
* @component Panel
|
|
4
|
+
* @description Edge Panel — a collapsible side panel fixed to the screen edge.
|
|
5
5
|
*
|
|
6
6
|
* Shows only a thin 6px handle strip by default. Clicking the handle slides
|
|
7
7
|
* the panel open. A pin button in the header locks it open so it won't close
|
package/Popover.svelte
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import "./menu.css";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Anchored, nonmodal popup for forms/controls. The trigger snippet receives
|
|
7
|
+
* native-button attributes to spread on ONE button (no nested button wrapper).
|
|
8
|
+
*/
|
|
9
|
+
let {
|
|
10
|
+
open = $bindable(false), label, trigger, children, align = "start",
|
|
11
|
+
side = "bottom", class: className = "", onclose = () => {},
|
|
12
|
+
} = $props();
|
|
13
|
+
const id = $props.id();
|
|
14
|
+
let root = $state();
|
|
15
|
+
let content = $state();
|
|
16
|
+
let triggerElement;
|
|
17
|
+
|
|
18
|
+
$effect(() => {
|
|
19
|
+
if (!open || !content) return;
|
|
20
|
+
const popup = content;
|
|
21
|
+
const triggerButton = triggerElement;
|
|
22
|
+
return () => {
|
|
23
|
+
if (popup.contains(document.activeElement) || document.activeElement === document.body) {
|
|
24
|
+
triggerButton?.focus();
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function close(restore = false) {
|
|
30
|
+
open = false;
|
|
31
|
+
if (restore) triggerElement?.focus();
|
|
32
|
+
onclose();
|
|
33
|
+
}
|
|
34
|
+
function toggle(event) {
|
|
35
|
+
triggerElement = event.currentTarget;
|
|
36
|
+
if (open) close();
|
|
37
|
+
else {
|
|
38
|
+
open = true;
|
|
39
|
+
tick().then(() => content?.querySelector("input, button, select, textarea, [tabindex]")?.focus());
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<svelte:window onclick={(event) => { if (open && !root?.contains(event.target)) close(); }} />
|
|
45
|
+
|
|
46
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
47
|
+
<div bind:this={root} class="std-popover {className}"
|
|
48
|
+
onkeydown={(event) => {
|
|
49
|
+
if (open && event.key === "Escape") {
|
|
50
|
+
event.stopPropagation(); event.preventDefault(); close(true);
|
|
51
|
+
}
|
|
52
|
+
}}>
|
|
53
|
+
{@render trigger({
|
|
54
|
+
type: "button", "aria-haspopup": "dialog", "aria-expanded": open,
|
|
55
|
+
"aria-controls": id, onclick: toggle,
|
|
56
|
+
})}
|
|
57
|
+
{#if open}
|
|
58
|
+
<div bind:this={content} {id} class="std-menu-content std-popover-content no-rhythm"
|
|
59
|
+
class:align-end={align === "end"} class:above={side === "top"}
|
|
60
|
+
role="dialog" aria-label={label} tabindex="-1"
|
|
61
|
+
onkeydown={(event) => {
|
|
62
|
+
event.stopPropagation();
|
|
63
|
+
if (event.key === "Escape") { event.preventDefault(); close(true); }
|
|
64
|
+
}}
|
|
65
|
+
onfocusout={(event) => {
|
|
66
|
+
if (event.relatedTarget && !root?.contains(event.relatedTarget)) close();
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{@render children?.()}
|
|
70
|
+
</div>
|
|
71
|
+
{/if}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<style>
|
|
75
|
+
.std-popover { position: relative; display: inline-block; }
|
|
76
|
+
.std-popover-content {
|
|
77
|
+
position: absolute; top: 100%; left: 0; margin-top: var(--space-d2);
|
|
78
|
+
width: max-content; min-width: 180px; max-width: min(320px, calc(100vw - 16px));
|
|
79
|
+
max-height: 70vh; overflow-y: auto; z-index: 100;
|
|
80
|
+
}
|
|
81
|
+
.align-end { left: auto; right: 0; }
|
|
82
|
+
.above { top: auto; bottom: 100%; margin-top: 0; margin-bottom: var(--space-d2); }
|
|
83
|
+
</style>
|
package/README.md
CHANGED
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
title: "@stnd/ui"
|
|
3
3
|
aliases: []
|
|
4
4
|
created: 2026-07-05 07:47
|
|
5
|
-
modified: 2026-
|
|
5
|
+
modified: 2026-09-16T18:33:35.950Z
|
|
6
6
|
last_audited: 2026-07-14
|
|
7
7
|
audit_interval_days: 90
|
|
8
8
|
next_audit: 2026-10-12
|
|
9
9
|
audit_priority: 3
|
|
10
10
|
maturity: sprout
|
|
11
11
|
mode: read
|
|
12
|
-
publish:
|
|
12
|
+
publish: true
|
|
13
13
|
status: active
|
|
14
14
|
tags:
|
|
15
15
|
- package
|
|
16
16
|
- stnd
|
|
17
17
|
theme: kernel
|
|
18
18
|
type: package
|
|
19
|
-
visibility:
|
|
19
|
+
visibility: public
|
|
20
|
+
garden-url: https://standard.garden/@francis/readme
|
|
21
|
+
garden-short: https://stnd.gd/Yl18ps
|
|
22
|
+
permalink: readme
|
|
20
23
|
---
|
|
21
24
|
|
|
22
25
|
# @[stnd](../README)/ui
|
|
@@ -77,6 +80,8 @@ You should only import components from this package when you need:
|
|
|
77
80
|
|
|
78
81
|
## 🧩 Component API Reference
|
|
79
82
|
|
|
83
|
+
::widget UiShowcase
|
|
84
|
+
|
|
80
85
|
### Astro Components (Zero JS)
|
|
81
86
|
|
|
82
87
|
#### `<Accordion />`
|
|
@@ -101,8 +106,10 @@ Semantic feedback banners.
|
|
|
101
106
|
|
|
102
107
|
#### `<Dropdown />`
|
|
103
108
|
Accessible, keyboard-navigable dropdown menus.
|
|
104
|
-
- `align` (string): Alignment of the menu (`start`, `
|
|
109
|
+
- `align` (string): Alignment of the menu (`start`, `end`). Defaults to `start`.
|
|
110
|
+
- `label` (string): Recommended accessible name for the native trigger button. When supplied, the `trigger` snippet contains only its content (not another button). `triggerClass` styles that button; `open` is bindable. Existing trigger snippets without `label` remain supported.
|
|
105
111
|
- **Slots**: `trigger` (the button that opens the menu), `default` (the menu items).
|
|
112
|
+
- Arrow keys and Home/End move among enabled items; Escape dismisses and restores trigger focus. Item activation closes the menu; Tab dismisses and continues focus navigation. Native button keyboard activation does not toggle twice.
|
|
106
113
|
|
|
107
114
|
**Related Sub-components**:
|
|
108
115
|
- `<DropdownItem icon="ph:user" shortcut="⌘P">Profile</DropdownItem>`
|
|
@@ -112,6 +119,38 @@ Accessible, keyboard-navigable dropdown menus.
|
|
|
112
119
|
#### `<ContextMenu />`
|
|
113
120
|
Viewport-aware right-click menus.
|
|
114
121
|
- **Slots**: `default` (the area that triggers the menu on right-click), `content` (the menu items).
|
|
122
|
+
- For delegated/virtualized targets, omit the default snippet and pass `open`, `position={{ x, y }}` (viewport coordinates), `label`, and `onclose`. `open` is bindable. The shared menu measures its actual content, clamps to the viewport, and scrolls oversized menus.
|
|
123
|
+
- Arrow keys and Home/End move among enabled items. Escape, Tab, outside clicks, viewport resize, and scrolling outside the menu dismiss it. Focus returns to the previous element when appropriate. Context-menu item components use native disabled state as well as ARIA.
|
|
124
|
+
|
|
125
|
+
#### `<Dialog />`
|
|
126
|
+
|
|
127
|
+
A general-purpose native modal shell for forms and other arbitrary content. This complements `AlertDialog`/`confirm()`; it does not add confirmation semantics.
|
|
128
|
+
|
|
129
|
+
```svelte
|
|
130
|
+
<Dialog bind:open label="Preferences" onclose={discardDraft}
|
|
131
|
+
style="--dialog-width: 580px; --dialog-max-height: 88vh">
|
|
132
|
+
<!-- Form content and semantic buttons belong to the consumer. -->
|
|
133
|
+
</Dialog>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`label` is the accessible name; `children` supplies content; `class` and `style` customize the shell. `onkeydown` can implement a content-specific shortcut. The component owns `showModal()`, background inertness, native focus behavior, Escape/backdrop dismissal, and focus restoration. It stops keyboard propagation to application shortcut handlers. Use it for modal interactions, not for live nonmodal monitors or native OS windows.
|
|
137
|
+
|
|
138
|
+
#### `<Popover />`
|
|
139
|
+
|
|
140
|
+
An anchored, nonmodal popup for forms and mixed controls. Use `Dropdown` for menus of actions instead.
|
|
141
|
+
|
|
142
|
+
```svelte
|
|
143
|
+
<Popover label="Display options" align="end">
|
|
144
|
+
{#snippet trigger(attributes)}
|
|
145
|
+
<button class="btn ghost" {...attributes}>Display</button>
|
|
146
|
+
{/snippet}
|
|
147
|
+
<!-- Native inputs and content-specific buttons. -->
|
|
148
|
+
</Popover>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Spread the trigger attributes onto a single native button; they supply its click behavior and ARIA relationship. Props: bindable `open`, required `label`, `trigger` and `children` snippets, `align` (`start`/`end`), `side` (`bottom`/`top`), optional wrapper `class`, and `onclose`. Escape restores trigger focus; outside click or focus leaving the popup/trigger dismisses it. Keyboard events inside the content do not reach application-wide shortcuts.
|
|
152
|
+
|
|
153
|
+
The shared menu geometry/navigation helpers have dependency-free tests: `node --test packages/ui/menu.test.js` from the repository root. Reveal's browser UI regression suite also exercises controlled and wrapped context menus, dropdowns, popovers, and native dialogs with synthetic data; see [Reveal UI validation](../../apps/reveal/README.md#ui-validation).
|
|
115
154
|
|
|
116
155
|
---
|
|
117
156
|
|
|
@@ -119,6 +158,8 @@ Viewport-aware right-click menus.
|
|
|
119
158
|
|
|
120
159
|
The `DialogManager` allows you to trigger accessible confirmation dialogs programmatically from any script using a simple Promise-based API. This prevents littering your DOM with hidden modal HTML.
|
|
121
160
|
|
|
161
|
+
`AlertDialog` can also be composed directly inside an existing `Dialog` when confirmation belongs to that form. Its title provides the accessible dialog name, keyboard events stay within the confirmation instead of reaching parent form shortcuts, and a programmatic close after confirmation does not also call the cancellation callback.
|
|
162
|
+
|
|
122
163
|
### 1. Mount the Manager
|
|
123
164
|
Ensure `<DialogManagerComponent client:load />` is mounted once in your root layout (Standard does this by default).
|
|
124
165
|
|
package/Scroller.astro
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Scroller
|
|
3
|
+
* @component Scroller
|
|
4
|
+
* @description Astro wrapper for `Scroller.svelte` that ensures it always
|
|
5
|
+
* hydrates on load — use this instead of the Svelte component directly so
|
|
6
|
+
* you don't have to remember `client:load` yourself.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
8
|
+
* @example astro
|
|
9
|
+
* <Scroller />
|
|
7
10
|
*/
|
|
8
11
|
import SvelteScroller from "./Scroller.svelte";
|
|
9
12
|
---
|
package/Scroller.svelte
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
<script>
|
|
2
|
+
/**
|
|
3
|
+
* @component Scroller
|
|
4
|
+
* @description A thin fixed reading-progress bar across the top of the
|
|
5
|
+
* viewport, filling as the page scrolls. No props — use the `<Scroller />`
|
|
6
|
+
* Astro wrapper instead of this directly, so hydration isn't your problem.
|
|
7
|
+
*/
|
|
2
8
|
let progress = $state(0);
|
|
3
9
|
|
|
4
10
|
function updateProgress() {
|
package/TableOfContents.astro
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
* @description Subtle Notion-style TOC — small lines fixed to the right side
|
|
5
5
|
* that expand with heading labels on hover and highlight the current section.
|
|
6
6
|
*
|
|
7
|
-
* Usage
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* @example astro - Usage
|
|
8
|
+
* import TableOfContents from "@stnd/ui/TableOfContents.astro";
|
|
9
|
+
* <TableOfContents {headings} />
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* @example js - Extracting 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
18
|
*/
|
|
19
19
|
|
|
20
20
|
interface Heading {
|
|
@@ -24,6 +24,7 @@ interface Heading {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
interface Props {
|
|
27
|
+
/** The headings to show — typically extracted from rendered HTML, see the example below. */
|
|
27
28
|
headings: Heading[];
|
|
28
29
|
/** Minimum headings to show the TOC. Default: 2 */
|
|
29
30
|
minHeadings?: number;
|
|
@@ -31,13 +32,13 @@ interface Props {
|
|
|
31
32
|
breakpoint?: number;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
const { headings, minHeadings = 2 } = Astro.props;
|
|
35
|
+
const { headings, minHeadings = 2, breakpoint = 1100 } = Astro.props;
|
|
35
36
|
const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|
36
37
|
---
|
|
37
38
|
|
|
38
39
|
{
|
|
39
40
|
tocHeadings.length >= minHeadings && (
|
|
40
|
-
<nav class="stnd-toc" aria-label="Table des matières">
|
|
41
|
+
<nav class="stnd-toc" aria-label="Table des matières" data-breakpoint={breakpoint}>
|
|
41
42
|
{tocHeadings.map((h) => (
|
|
42
43
|
<a
|
|
43
44
|
href={`#${h.slug}`}
|
|
@@ -152,10 +153,12 @@ const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|
|
152
153
|
opacity: 1 !important;
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
/* Set by the script below via matchMedia(breakpoint) — a CSS @media
|
|
157
|
+
condition can't take its threshold from a prop (media queries need a
|
|
158
|
+
literal value at parse time, not a per-instance one), so the
|
|
159
|
+
hide-below-breakpoint behavior has to live in JS instead. */
|
|
160
|
+
.stnd-toc[data-below-breakpoint] {
|
|
161
|
+
display: none;
|
|
159
162
|
}
|
|
160
163
|
</style>
|
|
161
164
|
|
|
@@ -165,6 +168,20 @@ const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|
|
165
168
|
const tocLinks = document.querySelectorAll<HTMLElement>(".toc-item[data-slug]");
|
|
166
169
|
if (tocLinks.length === 0) return;
|
|
167
170
|
|
|
171
|
+
// Hide below `breakpoint` (default 1100, set via the `breakpoint` prop
|
|
172
|
+
// and read back from data-breakpoint) — a plain CSS @media rule can't
|
|
173
|
+
// take its threshold from a prop, so this does the equivalent in JS.
|
|
174
|
+
if (toc) {
|
|
175
|
+
// `|| 1100` would silently discard an intentional breakpoint={0}
|
|
176
|
+
// (0 is falsy) — parse explicitly instead.
|
|
177
|
+
const parsedBreakpoint = Number(toc.dataset.breakpoint);
|
|
178
|
+
const breakpoint = Number.isFinite(parsedBreakpoint) ? parsedBreakpoint : 1100;
|
|
179
|
+
const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
|
180
|
+
const applyBreakpoint = () => toc.toggleAttribute("data-below-breakpoint", mql.matches);
|
|
181
|
+
applyBreakpoint();
|
|
182
|
+
mql.addEventListener("change", applyBreakpoint);
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
// Show on scroll or mouse move, hide after inactivity
|
|
169
186
|
let hideTimer: ReturnType<typeof setTimeout>;
|
|
170
187
|
const show = () => {
|
package/Toast.astro
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* @component Toast
|
|
4
|
+
* @description Zero-dependency notification system. Mount once (Standard
|
|
5
|
+
* does this by default); afterward, call the global `window.toast()`
|
|
6
|
+
* helper from anywhere — no import needed, since the component itself
|
|
7
|
+
* dispatches/listens for a `toast` custom event.
|
|
8
|
+
*
|
|
9
|
+
* @example js - Fire a toast
|
|
10
|
+
* window.toast("Saved!", "success");
|
|
11
|
+
* window.toast("Something went wrong", "error", 6000, {
|
|
12
|
+
* label: "Retry",
|
|
13
|
+
* onClick: "myRetryFn",
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
3
16
|
---
|
|
4
17
|
|
|
5
18
|
<div id="toast-container" aria-live="polite" aria-atomic="true"></div>
|
|
@@ -87,18 +100,16 @@
|
|
|
87
100
|
}
|
|
88
101
|
</style>
|
|
89
102
|
|
|
90
|
-
<script>
|
|
91
|
-
import { navigate } from "astro:transitions/client";
|
|
92
|
-
|
|
103
|
+
<script is:inline>
|
|
93
104
|
function initToastSystem() {
|
|
94
105
|
// Prevent duplicate listeners across re-renders
|
|
95
|
-
if (
|
|
106
|
+
if (window._toastListenerAdded) return;
|
|
96
107
|
|
|
97
108
|
const container = document.getElementById("toast-container");
|
|
98
109
|
if (!container) return;
|
|
99
110
|
|
|
100
111
|
// Listen for custom 'toast' events (only add once)
|
|
101
|
-
window.addEventListener("toast", (
|
|
112
|
+
window.addEventListener("toast", (e) => {
|
|
102
113
|
const { message, type = "default", duration = 4000, action } = e.detail;
|
|
103
114
|
|
|
104
115
|
// Create toast element
|
|
@@ -175,23 +186,16 @@
|
|
|
175
186
|
// Wait for CSS transition before removing from DOM
|
|
176
187
|
setTimeout(() => toast.remove(), 1000);
|
|
177
188
|
}, duration);
|
|
178
|
-
})
|
|
189
|
+
});
|
|
179
190
|
|
|
180
|
-
|
|
191
|
+
window._toastListenerAdded = true;
|
|
181
192
|
}
|
|
182
193
|
|
|
183
194
|
// Initialize
|
|
184
195
|
initToastSystem();
|
|
185
196
|
|
|
186
|
-
// Global
|
|
187
|
-
|
|
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
|
-
) => {
|
|
197
|
+
// Global helper for non-module scripts
|
|
198
|
+
window.toast = (message, type = "default", duration = 2000, action = null) => {
|
|
195
199
|
const detail = { message, type, duration };
|
|
196
200
|
if (action) detail.action = action;
|
|
197
201
|
window.dispatchEvent(new CustomEvent("toast", { detail }));
|
package/menu.css
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Shared keyboard affordance, independent of hover or an app's button reset. */
|
|
2
|
+
.std-menu-content [role^="menuitem"]:focus-visible,
|
|
3
|
+
.std-dropdown > button:focus-visible,
|
|
4
|
+
.std-dropdown-trigger:focus-visible,
|
|
5
|
+
.std-popover > button:focus-visible {
|
|
6
|
+
outline: 2px solid var(--color-accent);
|
|
7
|
+
outline-offset: -2px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.std-menu-content [role^="menuitem"]:focus-visible {
|
|
11
|
+
background: var(--color-surface-highest);
|
|
12
|
+
}
|
package/menu.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Shared menu navigation; no application shortcuts belong in a menu. */
|
|
2
|
+
export function menuItems(element) {
|
|
3
|
+
return Array.from(element.querySelectorAll('[role^="menuitem"]')).filter(
|
|
4
|
+
(item) => !item.disabled && item.getAttribute("aria-disabled") !== "true",
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function navigateMenu(event, element, close) {
|
|
9
|
+
event.stopPropagation();
|
|
10
|
+
const items = menuItems(element);
|
|
11
|
+
const current = items.indexOf(element.ownerDocument.activeElement);
|
|
12
|
+
let index;
|
|
13
|
+
if (event.key === "ArrowDown") index = (current + 1) % items.length;
|
|
14
|
+
else if (event.key === "ArrowUp") index = (current - 1 + items.length) % items.length;
|
|
15
|
+
else if (event.key === "Home") index = 0;
|
|
16
|
+
else if (event.key === "End") index = items.length - 1;
|
|
17
|
+
else if (event.key === "Escape" || event.key === "Tab") {
|
|
18
|
+
if (event.key === "Escape") event.preventDefault();
|
|
19
|
+
close();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (index !== undefined) {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
items[index]?.focus();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clampMenuPosition(x, y, width, height, viewportWidth, viewportHeight) {
|
|
29
|
+
return {
|
|
30
|
+
x: Math.max(8, Math.min(x, viewportWidth - width - 8)),
|
|
31
|
+
y: Math.max(8, Math.min(y, viewportHeight - height - 8)),
|
|
32
|
+
};
|
|
33
|
+
}
|