staffa 0.18.2 → 0.18.4
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/README.md +20 -0
- package/dist/components/autocomplete.d.ts +4 -0
- package/dist/components/autocomplete.js +83 -35
- package/dist/components/button.d.ts +45 -9
- package/dist/components/button.js +101 -25
- package/dist/components/dialog.js +40 -6
- package/dist/components/form.d.ts +5 -1
- package/dist/components/form.js +17 -2
- package/dist/components/menu.js +8 -11
- package/dist/components/tooltip.js +8 -10
- package/dist/core.d.ts +18 -0
- package/dist/core.js +53 -7
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.js +8 -1
- package/package.json +1 -1
- package/skill/ButtonOptions.md +21 -1
- package/skill/FormOptions.md +5 -1
- package/skill/IconButtonOptions.md +18 -4
- package/skill/SKILL.md +20 -0
- package/skill/autocomplete.md +4 -0
- package/skill/button.md +5 -3
- package/src/components/autocomplete.ts +102 -38
- package/src/components/button.ts +141 -30
- package/src/components/dialog.ts +40 -6
- package/src/components/form.ts +22 -3
- package/src/components/menu.ts +8 -11
- package/src/components/tooltip.ts +9 -11
- package/src/core.ts +51 -8
- package/src/theme.ts +8 -2
package/dist/components/menu.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
|
|
3
|
-
import { drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
3
|
+
import { drawSlot, followAnchor, mountPortal, focusFirst } from "../core.js";
|
|
4
4
|
import { menu as menuIcon, chevronRight, externalLink as newTabIcon, link as linkIcon } from "../icons.js";
|
|
5
5
|
import { button } from "./button.js";
|
|
6
6
|
import { toast } from "./toast.js";
|
|
@@ -551,17 +551,14 @@ mountPortal(() => {
|
|
|
551
551
|
document.removeEventListener("click", onClick, true);
|
|
552
552
|
document.removeEventListener("keydown", onKey, true);
|
|
553
553
|
});
|
|
554
|
-
//
|
|
554
|
+
// At the supplied point when given — the pointer location for a context menu
|
|
555
|
+
// — otherwise below the anchor.
|
|
556
|
+
followAnchor(f.at ? new DOMRect(f.at.x, f.at.y, 0, 0) : f.anchor, (rect) => positionMenu(menuEl, rect));
|
|
557
|
+
// Once it can take focus: the current-page item if there is one, else the
|
|
558
|
+
// first focusable element (covers custom slot content, not just `.s-menu-item`s).
|
|
555
559
|
requestAnimationFrame(() => {
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
// Position at the supplied point (a zero-size rect) when given — e.g. the
|
|
559
|
-
// pointer location for a context menu — otherwise below the anchor.
|
|
560
|
-
const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
|
|
561
|
-
positionMenu(menuEl, rect);
|
|
562
|
-
// Focus the current-page item if there is one, else the first focusable
|
|
563
|
-
// element (covers custom slot content, not just `.s-menu-item`s).
|
|
564
|
-
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
560
|
+
if (document.body.contains(menuEl))
|
|
561
|
+
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
565
562
|
});
|
|
566
563
|
});
|
|
567
564
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { drawSlot, mountPortal } from "../core.js";
|
|
2
|
+
import { drawSlot, followAnchor, mountPortal } from "../core.js";
|
|
3
3
|
// Background, ink, border, radius and elevation come from the `.s-s.neutral.shadow`
|
|
4
4
|
// surface; portalled to <body>, it renders at the page's next neutral shade.
|
|
5
5
|
A.insertGlobalCss({
|
|
@@ -14,10 +14,6 @@ A.insertGlobalCss({
|
|
|
14
14
|
// At most one tooltip at a time; the anchor is the element whose rect positions it.
|
|
15
15
|
const $ttActive = A.proxy(undefined);
|
|
16
16
|
let hideTimer = null;
|
|
17
|
-
// Hide tooltip when the page scrolls (anchor has moved).
|
|
18
|
-
if (typeof window !== "undefined") {
|
|
19
|
-
window.addEventListener("scroll", () => { $ttActive.value = undefined; }, { capture: true, passive: true });
|
|
20
|
-
}
|
|
21
17
|
function computePos(rect, tipW, tipH, placement) {
|
|
22
18
|
const gap = 7;
|
|
23
19
|
const vw = window.innerWidth;
|
|
@@ -72,7 +68,7 @@ mountPortal(() => {
|
|
|
72
68
|
return;
|
|
73
69
|
const { opts, anchor } = active;
|
|
74
70
|
const placement = opts.placement ?? "top";
|
|
75
|
-
const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip
|
|
71
|
+
const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip", opts.attrs, () => {
|
|
76
72
|
A("mouseenter=", () => {
|
|
77
73
|
if (hideTimer) {
|
|
78
74
|
clearTimeout(hideTimer);
|
|
@@ -82,14 +78,16 @@ mountPortal(() => {
|
|
|
82
78
|
A("mouseleave=", scheduleHide);
|
|
83
79
|
drawSlot(opts.tip);
|
|
84
80
|
});
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
followAnchor(anchor, (rect) => {
|
|
82
|
+
// Out of the viewport, or on a panel that slid off and went inert: nothing
|
|
83
|
+
// left to explain, and no `mouseleave` is coming to say so.
|
|
84
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth || anchor.closest("[inert]")) {
|
|
85
|
+
$ttActive.value = undefined;
|
|
87
86
|
return;
|
|
88
|
-
|
|
87
|
+
}
|
|
89
88
|
const { x, y } = computePos(rect, tipEl.offsetWidth, tipEl.offsetHeight, placement);
|
|
90
89
|
tipEl.style.left = x + "px";
|
|
91
90
|
tipEl.style.top = y + "px";
|
|
92
|
-
tipEl.style.visibility = "";
|
|
93
91
|
});
|
|
94
92
|
});
|
|
95
93
|
// ─── Public component ────────────────────────────────────────────────────────
|
package/dist/core.d.ts
CHANGED
|
@@ -62,6 +62,11 @@ export declare function uniqueId(prefix?: string): string;
|
|
|
62
62
|
* `rich` markup (`*italic*`, `**bold**`, `` `code` ``, `[link](/path)`).
|
|
63
63
|
*/
|
|
64
64
|
export declare function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefined, ...args: Args): void;
|
|
65
|
+
/**
|
|
66
|
+
* The focusable elements inside `container`, in tab order — disabled,
|
|
67
|
+
* `aria-disabled`, `tabindex=-1` and hidden ones left out.
|
|
68
|
+
*/
|
|
69
|
+
export declare function focusables(container: HTMLElement): HTMLElement[];
|
|
65
70
|
/**
|
|
66
71
|
* Move keyboard focus to the first focusable element inside `container`, skipping
|
|
67
72
|
* disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
|
|
@@ -81,3 +86,16 @@ export declare function focusFirst(container: HTMLElement, prefer?: string): boo
|
|
|
81
86
|
* `<body>` gets its content first and the overlays stay at the end.
|
|
82
87
|
*/
|
|
83
88
|
export declare function mountPortal(draw: () => void): void;
|
|
89
|
+
/**
|
|
90
|
+
* Keep a `position:fixed` overlay glued to its anchor. `place` gets the anchor's
|
|
91
|
+
* viewport rect right away, and again whenever it changes: frame by frame while
|
|
92
|
+
* the anchor is on the move — riding a dialog's opening animation or a panel's
|
|
93
|
+
* slide, or scrolling — and not at all once it has been at rest for half a
|
|
94
|
+
* second, until one of the events that precede any movement wakes the loop. So
|
|
95
|
+
* an overlay left standing open doesn't keep the page awake. Stops with the
|
|
96
|
+
* current scope; a point (a `DOMRect` of no size) is followed like an element.
|
|
97
|
+
*
|
|
98
|
+
* Returns a function to call when the overlay's own size changed (its rows
|
|
99
|
+
* redrawn, say), so it gets placed again.
|
|
100
|
+
*/
|
|
101
|
+
export declare function followAnchor(anchor: Element | DOMRect, place: (rect: DOMRect) => void): () => void;
|
package/dist/core.js
CHANGED
|
@@ -26,6 +26,19 @@ export function drawSlot(slot, ...args) {
|
|
|
26
26
|
}
|
|
27
27
|
/** Selector matching the natively focusable elements we care about. */
|
|
28
28
|
const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]";
|
|
29
|
+
/** Whether this element can actually take focus right now. */
|
|
30
|
+
const isFocusable = (el) => el instanceof HTMLElement &&
|
|
31
|
+
!el.hasAttribute("disabled") &&
|
|
32
|
+
el.getAttribute("aria-disabled") !== "true" &&
|
|
33
|
+
el.tabIndex >= 0 &&
|
|
34
|
+
el.getClientRects().length > 0;
|
|
35
|
+
/**
|
|
36
|
+
* The focusable elements inside `container`, in tab order — disabled,
|
|
37
|
+
* `aria-disabled`, `tabindex=-1` and hidden ones left out.
|
|
38
|
+
*/
|
|
39
|
+
export function focusables(container) {
|
|
40
|
+
return [...container.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
|
|
41
|
+
}
|
|
29
42
|
/**
|
|
30
43
|
* Move keyboard focus to the first focusable element inside `container`, skipping
|
|
31
44
|
* disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
|
|
@@ -36,13 +49,8 @@ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]
|
|
|
36
49
|
* the DOM and laid out — typically inside a `requestAnimationFrame`.
|
|
37
50
|
*/
|
|
38
51
|
export function focusFirst(container, prefer) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
el.getAttribute("aria-disabled") !== "true" &&
|
|
42
|
-
el.tabIndex >= 0 &&
|
|
43
|
-
el.getClientRects().length > 0;
|
|
44
|
-
const target = (prefer ? [...container.querySelectorAll(prefer)].find(ok) : undefined) ??
|
|
45
|
-
[...container.querySelectorAll(FOCUSABLE_SELECTOR)].find(ok);
|
|
52
|
+
const target = (prefer ? [...container.querySelectorAll(prefer)].find(isFocusable) : undefined) ??
|
|
53
|
+
focusables(container)[0];
|
|
46
54
|
target?.focus();
|
|
47
55
|
return target != null;
|
|
48
56
|
}
|
|
@@ -57,3 +65,41 @@ export function focusFirst(container, prefer) {
|
|
|
57
65
|
export function mountPortal(draw) {
|
|
58
66
|
queueMicrotask(() => A(draw));
|
|
59
67
|
}
|
|
68
|
+
/** Something scrolled, the window resized, or an animation began: an anchor may be on the move. */
|
|
69
|
+
const WAKE_EVENTS = ["scroll", "resize", "transitionstart", "animationstart"];
|
|
70
|
+
/**
|
|
71
|
+
* Keep a `position:fixed` overlay glued to its anchor. `place` gets the anchor's
|
|
72
|
+
* viewport rect right away, and again whenever it changes: frame by frame while
|
|
73
|
+
* the anchor is on the move — riding a dialog's opening animation or a panel's
|
|
74
|
+
* slide, or scrolling — and not at all once it has been at rest for half a
|
|
75
|
+
* second, until one of the events that precede any movement wakes the loop. So
|
|
76
|
+
* an overlay left standing open doesn't keep the page awake. Stops with the
|
|
77
|
+
* current scope; a point (a `DOMRect` of no size) is followed like an element.
|
|
78
|
+
*
|
|
79
|
+
* Returns a function to call when the overlay's own size changed (its rows
|
|
80
|
+
* redrawn, say), so it gets placed again.
|
|
81
|
+
*/
|
|
82
|
+
export function followAnchor(anchor, place) {
|
|
83
|
+
let placedAt = "", raf = 0, still = 0;
|
|
84
|
+
const track = () => {
|
|
85
|
+
const r = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
|
|
86
|
+
const at = `${r.left} ${r.top} ${r.bottom} ${r.width}`;
|
|
87
|
+
if (at !== placedAt) {
|
|
88
|
+
placedAt = at;
|
|
89
|
+
place(r);
|
|
90
|
+
still = 0;
|
|
91
|
+
}
|
|
92
|
+
raf = ++still > 30 ? 0 : requestAnimationFrame(track);
|
|
93
|
+
};
|
|
94
|
+
const wake = () => { still = 0; if (!raf)
|
|
95
|
+
track(); };
|
|
96
|
+
for (const ev of WAKE_EVENTS)
|
|
97
|
+
window.addEventListener(ev, wake, true);
|
|
98
|
+
A.clean(() => {
|
|
99
|
+
cancelAnimationFrame(raf);
|
|
100
|
+
for (const ev of WAKE_EVENTS)
|
|
101
|
+
window.removeEventListener(ev, wake, true);
|
|
102
|
+
});
|
|
103
|
+
track();
|
|
104
|
+
return () => { placedAt = ""; wake(); };
|
|
105
|
+
}
|