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/src/components/menu.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
|
|
3
|
-
import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
3
|
+
import { type Slot, type Attributes, 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, type ButtonOptions } from "./button.js";
|
|
6
6
|
import { toast } from "./toast.js";
|
|
@@ -647,7 +647,7 @@ export function closeFloatingMenu(anchor?: HTMLElement): void {
|
|
|
647
647
|
if (isFloatingMenuOpen(anchor)) closeFloating();
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
-
function positionMenu(menuEl: HTMLElement, rect:
|
|
650
|
+
function positionMenu(menuEl: HTMLElement, rect: DOMRect): void {
|
|
651
651
|
const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
|
|
652
652
|
const vw = window.innerWidth, vh = window.innerHeight;
|
|
653
653
|
const gap = 4;
|
|
@@ -717,16 +717,13 @@ mountPortal(() => {
|
|
|
717
717
|
document.removeEventListener("keydown", onKey, true);
|
|
718
718
|
});
|
|
719
719
|
|
|
720
|
-
//
|
|
720
|
+
// At the supplied point when given — the pointer location for a context menu
|
|
721
|
+
// — otherwise below the anchor.
|
|
722
|
+
followAnchor(f.at ? new DOMRect(f.at.x, f.at.y, 0, 0) : f.anchor, (rect) => positionMenu(menuEl, rect));
|
|
723
|
+
// Once it can take focus: the current-page item if there is one, else the
|
|
724
|
+
// first focusable element (covers custom slot content, not just `.s-menu-item`s).
|
|
721
725
|
requestAnimationFrame(() => {
|
|
722
|
-
if (
|
|
723
|
-
// Position at the supplied point (a zero-size rect) when given — e.g. the
|
|
724
|
-
// pointer location for a context menu — otherwise below the anchor.
|
|
725
|
-
const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
|
|
726
|
-
positionMenu(menuEl, rect);
|
|
727
|
-
// Focus the current-page item if there is one, else the first focusable
|
|
728
|
-
// element (covers custom slot content, not just `.s-menu-item`s).
|
|
729
|
-
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
726
|
+
if (document.body.contains(menuEl)) focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
730
727
|
});
|
|
731
728
|
});
|
|
732
729
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
|
|
2
|
+
import { type Slot, type Attributes, drawSlot, followAnchor, mountPortal } from "../core.js";
|
|
3
3
|
|
|
4
4
|
/** Options for {@link addTooltip}. */
|
|
5
5
|
export interface TooltipOptions {
|
|
@@ -32,11 +32,6 @@ A.insertGlobalCss({
|
|
|
32
32
|
const $ttActive = A.proxy<{ opts: TooltipOptions; anchor: HTMLElement } | undefined>(undefined);
|
|
33
33
|
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
34
|
|
|
35
|
-
// Hide tooltip when the page scrolls (anchor has moved).
|
|
36
|
-
if (typeof window !== "undefined") {
|
|
37
|
-
window.addEventListener("scroll", () => { $ttActive.value = undefined; }, { capture: true, passive: true });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
35
|
function computePos(rect: DOMRect, tipW: number, tipH: number, placement: string): { x: number; y: number } {
|
|
41
36
|
const gap = 7;
|
|
42
37
|
const vw = window.innerWidth;
|
|
@@ -84,7 +79,7 @@ mountPortal(() => {
|
|
|
84
79
|
const { opts, anchor } = active;
|
|
85
80
|
const placement = opts.placement ?? "top";
|
|
86
81
|
|
|
87
|
-
const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip
|
|
82
|
+
const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip", opts.attrs, () => {
|
|
88
83
|
A("mouseenter=", () => {
|
|
89
84
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
|
90
85
|
});
|
|
@@ -92,13 +87,16 @@ mountPortal(() => {
|
|
|
92
87
|
drawSlot(opts.tip);
|
|
93
88
|
}) as HTMLElement;
|
|
94
89
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
followAnchor(anchor, (rect) => {
|
|
91
|
+
// Out of the viewport, or on a panel that slid off and went inert: nothing
|
|
92
|
+
// left to explain, and no `mouseleave` is coming to say so.
|
|
93
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth || anchor.closest("[inert]")) {
|
|
94
|
+
$ttActive.value = undefined;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
98
97
|
const { x, y } = computePos(rect, tipEl.offsetWidth, tipEl.offsetHeight, placement);
|
|
99
98
|
tipEl.style.left = x + "px";
|
|
100
99
|
tipEl.style.top = y + "px";
|
|
101
|
-
tipEl.style.visibility = "";
|
|
102
100
|
});
|
|
103
101
|
});
|
|
104
102
|
|
package/src/core.ts
CHANGED
|
@@ -80,6 +80,22 @@ export function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefin
|
|
|
80
80
|
/** Selector matching the natively focusable elements we care about. */
|
|
81
81
|
const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]";
|
|
82
82
|
|
|
83
|
+
/** Whether this element can actually take focus right now. */
|
|
84
|
+
const isFocusable = (el: Element): el is HTMLElement =>
|
|
85
|
+
el instanceof HTMLElement &&
|
|
86
|
+
!el.hasAttribute("disabled") &&
|
|
87
|
+
el.getAttribute("aria-disabled") !== "true" &&
|
|
88
|
+
el.tabIndex >= 0 &&
|
|
89
|
+
el.getClientRects().length > 0;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The focusable elements inside `container`, in tab order — disabled,
|
|
93
|
+
* `aria-disabled`, `tabindex=-1` and hidden ones left out.
|
|
94
|
+
*/
|
|
95
|
+
export function focusables(container: HTMLElement): HTMLElement[] {
|
|
96
|
+
return [...container.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
/**
|
|
84
100
|
* Move keyboard focus to the first focusable element inside `container`, skipping
|
|
85
101
|
* disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
|
|
@@ -90,15 +106,9 @@ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]
|
|
|
90
106
|
* the DOM and laid out — typically inside a `requestAnimationFrame`.
|
|
91
107
|
*/
|
|
92
108
|
export function focusFirst(container: HTMLElement, prefer?: string): boolean {
|
|
93
|
-
const ok = (el: Element): el is HTMLElement =>
|
|
94
|
-
el instanceof HTMLElement &&
|
|
95
|
-
!el.hasAttribute("disabled") &&
|
|
96
|
-
el.getAttribute("aria-disabled") !== "true" &&
|
|
97
|
-
el.tabIndex >= 0 &&
|
|
98
|
-
el.getClientRects().length > 0;
|
|
99
109
|
const target =
|
|
100
|
-
(prefer ? [...container.querySelectorAll(prefer)].find(
|
|
101
|
-
|
|
110
|
+
(prefer ? [...container.querySelectorAll(prefer)].find(isFocusable) : undefined) ??
|
|
111
|
+
focusables(container)[0];
|
|
102
112
|
target?.focus();
|
|
103
113
|
return target != null;
|
|
104
114
|
}
|
|
@@ -114,3 +124,36 @@ export function focusFirst(container: HTMLElement, prefer?: string): boolean {
|
|
|
114
124
|
export function mountPortal(draw: () => void): void {
|
|
115
125
|
queueMicrotask(() => A(draw));
|
|
116
126
|
}
|
|
127
|
+
|
|
128
|
+
/** Something scrolled, the window resized, or an animation began: an anchor may be on the move. */
|
|
129
|
+
const WAKE_EVENTS = ["scroll", "resize", "transitionstart", "animationstart"];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Keep a `position:fixed` overlay glued to its anchor. `place` gets the anchor's
|
|
133
|
+
* viewport rect right away, and again whenever it changes: frame by frame while
|
|
134
|
+
* the anchor is on the move — riding a dialog's opening animation or a panel's
|
|
135
|
+
* slide, or scrolling — and not at all once it has been at rest for half a
|
|
136
|
+
* second, until one of the events that precede any movement wakes the loop. So
|
|
137
|
+
* an overlay left standing open doesn't keep the page awake. Stops with the
|
|
138
|
+
* current scope; a point (a `DOMRect` of no size) is followed like an element.
|
|
139
|
+
*
|
|
140
|
+
* Returns a function to call when the overlay's own size changed (its rows
|
|
141
|
+
* redrawn, say), so it gets placed again.
|
|
142
|
+
*/
|
|
143
|
+
export function followAnchor(anchor: Element | DOMRect, place: (rect: DOMRect) => void): () => void {
|
|
144
|
+
let placedAt = "", raf = 0, still = 0;
|
|
145
|
+
const track = () => {
|
|
146
|
+
const r = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
|
|
147
|
+
const at = `${r.left} ${r.top} ${r.bottom} ${r.width}`;
|
|
148
|
+
if (at !== placedAt) { placedAt = at; place(r); still = 0; }
|
|
149
|
+
raf = ++still > 30 ? 0 : requestAnimationFrame(track);
|
|
150
|
+
};
|
|
151
|
+
const wake = () => { still = 0; if (!raf) track(); };
|
|
152
|
+
for (const ev of WAKE_EVENTS) window.addEventListener(ev, wake, true);
|
|
153
|
+
A.clean(() => {
|
|
154
|
+
cancelAnimationFrame(raf);
|
|
155
|
+
for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake, true);
|
|
156
|
+
});
|
|
157
|
+
track();
|
|
158
|
+
return () => { placedAt = ""; wake(); };
|
|
159
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -229,8 +229,14 @@ A.insertGlobalCss({
|
|
|
229
229
|
|
|
230
230
|
// Accent variants: the fill colour becomes the ink, over a soft self-tint
|
|
231
231
|
// (`tonal`) or a transparent body with a colour edge (`outlined`).
|
|
232
|
-
".s-s:not(.neutral).tonal, .s-s:not(.neutral).outlined":
|
|
233
|
-
"--s-text:$s-bg --s-accent:$s-bg --s-link-fg:$s-bg --s-faint: color-mix(in srgb, $s-bg 30%, transparent); --s-muted: color-mix(in srgb, $s-bg 70%, transparent);",
|
|
232
|
+
".s-s:not(.neutral).tonal, .s-s:not(.neutral).outlined": {
|
|
233
|
+
"&": "--s-text:$s-bg --s-accent:$s-bg --s-link-fg:$s-bg --s-faint: color-mix(in srgb, $s-bg 30%, transparent); --s-muted: color-mix(in srgb, $s-bg 70%, transparent);",
|
|
234
|
+
// The ink *is* the surface colour here, so the fill `code` and `pre` mix
|
|
235
|
+
// from their text/background pair would land invisibly on itself. The
|
|
236
|
+
// monospace face carries inline code on its own; a block keeps a hairline.
|
|
237
|
+
code: "background:transparent padding:0",
|
|
238
|
+
pre: "background:transparent border: 1px solid $s-faint;",
|
|
239
|
+
},
|
|
234
240
|
".s-s:not(.neutral).tonal":
|
|
235
241
|
"background: color-mix(in srgb, $s-bg 15%, transparent); border: 1px solid $s-faint;",
|
|
236
242
|
".s-s:not(.neutral).outlined":
|