staffa 0.7.3 → 0.8.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.
- package/README.md +113 -7
- package/dist/components/autocomplete.js +9 -3
- package/dist/components/box.d.ts +20 -0
- package/dist/components/box.js +43 -3
- package/dist/components/dialog.js +17 -10
- package/dist/components/layers.d.ts +330 -0
- package/dist/components/layers.js +888 -0
- package/dist/components/main.d.ts +98 -6
- package/dist/components/main.js +222 -37
- package/dist/components/menu.d.ts +14 -1
- package/dist/components/menu.js +32 -4
- package/dist/components/panels.d.ts +349 -0
- package/dist/components/panels.js +933 -0
- package/dist/components/tabs.d.ts +5 -0
- package/dist/components/tabs.js +125 -19
- package/dist/core.d.ts +7 -0
- package/dist/core.js +7 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/staffa.esm.js +1 -1
- package/package.json +7 -5
- package/skill/BoxOptions.md +18 -0
- package/skill/MainOptions.md +99 -3
- package/skill/Page.md +108 -0
- package/skill/PathParams.md +7 -0
- package/skill/SKILL.md +185 -7
- package/skill/SegParams.md +8 -0
- package/skill/box.md +4 -0
- package/skill/isFloatingMenuOpen.md +12 -0
- package/skill/main.md +14 -2
- package/skill/panels.md +10 -0
- package/skill/tabs.md +5 -0
- package/src/components/autocomplete.ts +8 -2
- package/src/components/box.ts +57 -2
- package/src/components/dialog.ts +16 -10
- package/src/components/main.ts +314 -40
- package/src/components/menu.ts +33 -5
- package/src/components/panels.ts +1167 -0
- package/src/components/tabs.ts +126 -19
- package/src/core.ts +8 -0
- package/src/index.ts +3 -2
package/src/components/tabs.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { type Bindable, type Slot, type Attributes, drawSlot, uniqueId } from "../core.js";
|
|
3
|
+
import { mk } from "../icons-helpers.js";
|
|
3
4
|
|
|
4
5
|
/** A single tab definition. */
|
|
5
6
|
export interface Tab {
|
|
@@ -30,19 +31,55 @@ export interface TabsOptions {
|
|
|
30
31
|
contentAttrs?: Attributes;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// Chevrons for the scroll buttons, as inline SVG (the icon set's own helper, so
|
|
35
|
+
// no icon data comes along) rather than `‹`/`›` characters — matching Lucide's
|
|
36
|
+
// `chevron-left`/`chevron-right`.
|
|
37
|
+
const chevronLeft = mk('<path d="m15 18-6-6 6-6"/>');
|
|
38
|
+
const chevronRight = mk('<path d="m9 18 6-6-6-6"/>');
|
|
39
|
+
|
|
33
40
|
A.insertGlobalCss({
|
|
34
41
|
".s-tabs": {
|
|
35
42
|
"&": "display:flex flex-direction:column gap:$3",
|
|
36
|
-
|
|
43
|
+
// The bar owns the hairline (so it runs the full width, under the scroll
|
|
44
|
+
// buttons too) and is the positioning context they overlay from.
|
|
45
|
+
".s-tabbar": "position:relative display:flex border-bottom: 1px solid $s-faint;",
|
|
46
|
+
// The strip scrolls horizontally when the tabs outgrow it. Its own scrollbar
|
|
47
|
+
// is hidden — a raw scrollbar under a tab row reads as a mistake — so the
|
|
48
|
+
// affordance is the pair of buttons below, plus the fade they sit in.
|
|
49
|
+
// `overflow-y:hidden` is load-bearing: `overflow-x:auto` alone forces the
|
|
50
|
+
// computed `overflow-y` off `visible`, which turned the active tab's 1px
|
|
51
|
+
// overhang into a stray couple of pixels of *vertical* scroll.
|
|
52
|
+
".s-tablist":
|
|
53
|
+
"display:flex gap:$1 align-items:stretch flex:1 min-width:0 " +
|
|
54
|
+
"overflow-x:auto overflow-y:hidden scrollbar-width:none scroll-behavior:smooth " +
|
|
55
|
+
// Pulls the strip 1px down over the bar's hairline, so the active tab's
|
|
56
|
+
// underline lands *on* it rather than stacking above it. On the strip, not
|
|
57
|
+
// the tabs: a negative margin inside a scroll container is overflow.
|
|
58
|
+
"margin-bottom:-1px",
|
|
37
59
|
".s-tablist::-webkit-scrollbar": "display:none",
|
|
38
60
|
".s-tab":
|
|
39
61
|
"display:inline-flex align-items:center gap:$2 cursor:pointer background:transparent " +
|
|
40
|
-
"border:0 color: $s-muted; font-weight:600 padding: 0.6em 0.9em; " +
|
|
41
|
-
"border-bottom: 3px solid transparent;
|
|
62
|
+
"border:0 color: $s-muted; font-weight:600 padding: 0.6em 0.9em; white-space:nowrap " +
|
|
63
|
+
"border-bottom: 3px solid transparent; " +
|
|
42
64
|
"transition: color 0.15s, background 0.15s, border-color 0.15s;",
|
|
43
65
|
".s-tab:hover:not(:disabled), .s-tab[aria-selected=true]": "color: $s-text;",
|
|
44
|
-
|
|
66
|
+
// An inset ring: the strip is a scroll container, so it clips its own
|
|
67
|
+
// painting, and an outset ring on the first/last tab would be shaved off.
|
|
68
|
+
".s-tab:focus-visible": "outline:none box-shadow: inset 0 0 0 2px $s-focus; r: $s-radius;",
|
|
45
69
|
".s-tab[aria-selected=true]": "border-image: $s-gradient 1;",
|
|
70
|
+
// The scroll buttons overlay the strip's ends rather than sitting beside it,
|
|
71
|
+
// so no width is reserved when there's nothing to scroll — and the tabs slide
|
|
72
|
+
// out from under a fade instead of stopping at a hard edge.
|
|
73
|
+
".s-tabscroll":
|
|
74
|
+
"position:absolute top:0 bottom:0 z-index:1 display:none align-items:center justify-content:center " +
|
|
75
|
+
"width:2.4em border:0 padding:0 cursor:pointer fg:$s-muted " +
|
|
76
|
+
"transition: color 0.15s;",
|
|
77
|
+
".s-tabscroll:hover": "fg:$s-text",
|
|
78
|
+
".s-tabscroll-left": "left:0 justify-content:flex-start background: linear-gradient(to right, $s-bg 45%, transparent)",
|
|
79
|
+
".s-tabscroll-right": "right:0 justify-content:flex-end background: linear-gradient(to left, $s-bg 45%, transparent)",
|
|
80
|
+
// Shown only for the direction there is actually something to scroll towards,
|
|
81
|
+
// so the pair doubles as a position indicator.
|
|
82
|
+
".s-tabbar.s-can-left > .s-tabscroll-left, .s-tabbar.s-can-right > .s-tabscroll-right": "display:flex",
|
|
46
83
|
".s-tabpanel": "display:block",
|
|
47
84
|
},
|
|
48
85
|
});
|
|
@@ -51,6 +88,11 @@ A.insertGlobalCss({
|
|
|
51
88
|
* A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
|
|
52
89
|
* for the selected tab. Supports keyboard navigation (left/right/home/end).
|
|
53
90
|
*
|
|
91
|
+
* More tabs than fit make the strip scroll sideways, with a ‹ / › button
|
|
92
|
+
* appearing over whichever end still has something to reach — a bare scroll area
|
|
93
|
+
* says nothing about itself to a mouse. Selecting a tab that's out of view
|
|
94
|
+
* (arrow keys, or a `bind` written from elsewhere) scrolls it back in.
|
|
95
|
+
*
|
|
54
96
|
* @example
|
|
55
97
|
* ```ts
|
|
56
98
|
* S.tabs({ tabs: [
|
|
@@ -79,23 +121,33 @@ export function tabs(opts: TabsOptions): void {
|
|
|
79
121
|
};
|
|
80
122
|
|
|
81
123
|
A("div.s-tabs", opts.attrs, () => {
|
|
82
|
-
A("div.s-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
A(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
124
|
+
A("div.s-tabbar", () => {
|
|
125
|
+
const listEl = A("div.s-tablist role=tablist", () => {
|
|
126
|
+
opts.tabs.forEach((tab, index) => {
|
|
127
|
+
const key = keyOf(tab, index);
|
|
128
|
+
const tabEl = A("button.s-tab type=button role=tab", () => {
|
|
129
|
+
A(`id=${groupId}-tab-${key} aria-controls=${groupId}-panel-${key}`);
|
|
130
|
+
A(() => {
|
|
131
|
+
const selected = $sel.value === key;
|
|
132
|
+
A("aria-selected=", selected ? "true" : "false");
|
|
133
|
+
A("tabindex=", selected ? "0" : "-1");
|
|
134
|
+
// Selecting a tab that's (partly) scrolled out brings it into
|
|
135
|
+
// view, so the strip follows the selection however it was made:
|
|
136
|
+
// a click, the arrow keys, or a `bind` written from elsewhere.
|
|
137
|
+
if (selected) requestAnimationFrame(() => reveal(tabEl as HTMLElement));
|
|
138
|
+
});
|
|
139
|
+
if (tab.disabled) A("disabled=true");
|
|
140
|
+
A("click=", () => select(tab, index));
|
|
141
|
+
A("keydown=", (e: KeyboardEvent) => onKey(e, opts.tabs, index, select));
|
|
142
|
+
drawSlot(tab.icon);
|
|
143
|
+
drawSlot(tab.label);
|
|
91
144
|
});
|
|
92
|
-
if (tab.disabled) A("disabled=true");
|
|
93
|
-
A("click=", () => select(tab, index));
|
|
94
|
-
A("keydown=", (e: KeyboardEvent) => onKey(e, opts.tabs, index, select));
|
|
95
|
-
drawSlot(tab.icon);
|
|
96
|
-
drawSlot(tab.label);
|
|
97
145
|
});
|
|
98
|
-
});
|
|
146
|
+
}) as HTMLElement;
|
|
147
|
+
|
|
148
|
+
drawScrollButton(listEl, -1);
|
|
149
|
+
drawScrollButton(listEl, 1);
|
|
150
|
+
watchScroll(listEl);
|
|
99
151
|
});
|
|
100
152
|
|
|
101
153
|
A("div.s-tabpanel role=tabpanel", opts.contentAttrs, () => {
|
|
@@ -111,6 +163,61 @@ export function tabs(opts: TabsOptions): void {
|
|
|
111
163
|
});
|
|
112
164
|
}
|
|
113
165
|
|
|
166
|
+
/**
|
|
167
|
+
* One of the two scroll buttons overlaying the ends of the strip. `dir` is -1 for
|
|
168
|
+
* the left one and 1 for the right. A page at a time (not a tab at a time): the
|
|
169
|
+
* strip scrolls smoothly, so a nudge that moved one tab would read as a twitch.
|
|
170
|
+
*/
|
|
171
|
+
function drawScrollButton(list: HTMLElement, dir: -1 | 1): void {
|
|
172
|
+
A(`button.s-tabscroll.s-tabscroll-${dir < 0 ? "left" : "right"} type=button`, () => {
|
|
173
|
+
// The tabs themselves are the real control; this is a convenience the strip
|
|
174
|
+
// offers a mouse. Keeping it out of the tab order means Tab still steps from
|
|
175
|
+
// the tablist straight into the panel.
|
|
176
|
+
A("tabindex=-1 aria-hidden=true");
|
|
177
|
+
A("click=", () => list.scrollBy({ left: dir * list.clientWidth * 0.8, behavior: "smooth" }));
|
|
178
|
+
(dir < 0 ? chevronLeft : chevronRight)({ size: "1.1em" });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Keep the `.s-can-left` / `.s-can-right` classes on the bar in step with what
|
|
184
|
+
* there is left to scroll towards, so each button appears exactly when it has
|
|
185
|
+
* somewhere to go. Watches the strip's own scrolling *and* its size (a resize, or
|
|
186
|
+
* tabs being added or removed, changes the answer without any scrolling at all).
|
|
187
|
+
*/
|
|
188
|
+
function watchScroll(list: HTMLElement): void {
|
|
189
|
+
const bar = list.parentElement;
|
|
190
|
+
if (!bar || typeof ResizeObserver === "undefined") return; // No-op outside the browser.
|
|
191
|
+
const update = () => {
|
|
192
|
+
// A sub-pixel slack: fractional layout widths otherwise leave a permanent
|
|
193
|
+
// half-pixel of "scrollable" at an end that is plainly already reached.
|
|
194
|
+
const max = list.scrollWidth - list.clientWidth;
|
|
195
|
+
bar.classList.toggle("s-can-left", list.scrollLeft > 1);
|
|
196
|
+
bar.classList.toggle("s-can-right", list.scrollLeft < max - 1);
|
|
197
|
+
};
|
|
198
|
+
list.addEventListener("scroll", update, { passive: true });
|
|
199
|
+
const ro = new ResizeObserver(update);
|
|
200
|
+
ro.observe(list);
|
|
201
|
+
for (const tab of Array.from(list.children)) ro.observe(tab);
|
|
202
|
+
update();
|
|
203
|
+
A.clean(() => {
|
|
204
|
+
list.removeEventListener("scroll", update);
|
|
205
|
+
ro.disconnect();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Scroll `el`'s strip just far enough to clear the buttons overlaying its ends. */
|
|
210
|
+
function reveal(el: HTMLElement): void {
|
|
211
|
+
const list = el.parentElement;
|
|
212
|
+
if (!list || !el.isConnected) return;
|
|
213
|
+
// The overlays are 2.4em wide; clearing a little more than that keeps the tab
|
|
214
|
+
// from sitting right against one.
|
|
215
|
+
const pad = parseFloat(getComputedStyle(list).fontSize) * 2.6;
|
|
216
|
+
const tab = el.getBoundingClientRect(), strip = list.getBoundingClientRect();
|
|
217
|
+
if (tab.left < strip.left + pad) list.scrollBy({ left: tab.left - strip.left - pad, behavior: "smooth" });
|
|
218
|
+
else if (tab.right > strip.right - pad) list.scrollBy({ left: tab.right - strip.right + pad, behavior: "smooth" });
|
|
219
|
+
}
|
|
220
|
+
|
|
114
221
|
/** Roving-tabindex keyboard handling for the tab strip. */
|
|
115
222
|
function onKey(
|
|
116
223
|
e: KeyboardEvent,
|
package/src/core.ts
CHANGED
|
@@ -54,6 +54,14 @@ export interface ContentOptions {
|
|
|
54
54
|
content?: Slot;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Shell width — not viewport width — at or below which the app shell goes
|
|
59
|
+
* "narrow": the nav sidebar collapses to a hamburger, and a routed panel stack
|
|
60
|
+
* has room for exactly one full-bleed column. Shared by the `@container` queries
|
|
61
|
+
* that do the switching and by the JS that has to agree with them.
|
|
62
|
+
*/
|
|
63
|
+
export const NARROW_PX = 640;
|
|
64
|
+
|
|
57
65
|
let idCounter = 0;
|
|
58
66
|
/** Generates a process-unique id, used to wire `<label for>` to its control. */
|
|
59
67
|
export function uniqueId(prefix = "s"): string {
|
package/src/index.ts
CHANGED
|
@@ -41,8 +41,9 @@ export { buttonGroup, type ButtonGroupOptions } from "./components/buttonGroup.j
|
|
|
41
41
|
export { checkbox, type CheckboxOptions } from "./components/checkbox.js";
|
|
42
42
|
export { form, type FormOptions } from "./components/form.js";
|
|
43
43
|
export { main, type MainOptions } from "./components/main.js";
|
|
44
|
-
export {
|
|
45
|
-
export {
|
|
44
|
+
export { panels, type Page, type Routes, type RouteHandler, type RouteTable, type PathParams, type SegParams } from "./components/panels.js";
|
|
45
|
+
export { menuButton, showFloatingMenu, addContextMenu, isFloatingMenuOpen, closeFloatingMenu, type MenuOptions, type MenuEntry, type MenuItem, type MenuSeparator, type FloatingMenuOptions, type ContextMenuOptions } from "./components/menu.js";
|
|
46
|
+
export { dialog, alert, confirm, prompt, isDialogOpen, type DialogOptions } from "./components/dialog.js";
|
|
46
47
|
export { select, type SelectOptions, type SelectOptionInput } from "./components/select.js";
|
|
47
48
|
export { tabs, type Tab, type TabsOptions } from "./components/tabs.js";
|
|
48
49
|
export { textarea, type TextareaOptions } from "./components/textarea.js";
|