@tapestry-ui/tabs 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Minton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # @tapestry-ui/tabs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A WCAG 2.1 AA tab set for **Preact**. Headless behavior, skin via CSS.
4
+ Third citizen of Tapestry UI, after
5
+ [@tapestry-ui/accordion](https://www.npmjs.com/package/@tapestry-ui/accordion)
6
+ and [@tapestry-ui/modal](https://www.npmjs.com/package/@tapestry-ui/modal).
7
+
8
+ ```sh
9
+ npm i @tapestry-ui/tabs
10
+ ```
11
+
12
+ ```tsx
13
+ import { Tabs } from '@tapestry-ui/tabs';
14
+ import '@tapestry-ui/tabs/styles.css'; // structural only — optional
15
+
16
+ <Tabs
17
+ label="Dashboard sections"
18
+ items={[
19
+ { id: 'lodge', label: 'The Lodge', content: <LodgeTab /> },
20
+ { id: 'eig', label: 'Entelechy', content: <CompanyTab /> },
21
+ ]}
22
+ />
23
+
24
+ // controlled, when the URL or a parent owns the selection
25
+ <Tabs label="…" items={items} value={tab} onChange={setTab} />
26
+ ```
27
+
28
+ ## Accessibility (WCAG 2.1 AA by design, not by audit)
29
+ - The strip is **one tab stop**. A roving `tabindex` puts the selected tab
30
+ in the page's tab order and takes the others out, so Tab moves past the
31
+ whole set the way the WAI-ARIA pattern requires.
32
+ - **Arrow keys** move between tabs, wrapping; **Home**/**End** jump to the
33
+ ends. Disabled tabs are skipped, never landed on.
34
+ - `role="tablist"` / `role="tab"` / `role="tabpanel"`, with `aria-selected`,
35
+ `aria-controls` and `aria-labelledby` wired both ways.
36
+ - `label` is required and becomes the tablist's accessible name — a strip
37
+ of unnamed tabs tells a screen-reader user nothing.
38
+ - The panel carries `tabIndex={0}` so a keyboard can reach and scroll it.
39
+ - `:focus-visible` outlines ship in the structural CSS and are yours to
40
+ restyle, never to remove.
41
+
42
+ ## Automatic vs manual activation
43
+ `activation="automatic"` (default) selects as focus moves — the pattern's
44
+ advice when panels are cheap to render. Pass `activation="manual"` when a
45
+ panel costs something (a fetch, a chart): arrows then move focus only, and
46
+ Enter or Space selects.
47
+
48
+ ## Only the selected panel renders
49
+ Unselected panels are **absent from the DOM**, not hidden — the same ruling
50
+ `@tapestry-ui/accordion` makes about closed bodies. Two consequences worth
51
+ knowing: a panel's component state resets when you leave and come back, and
52
+ nothing invisible is left for assistive tech to walk through.
53
+
54
+ ## ⭐ The container contract
55
+ A tab set never owns scrolling and never constrains its panel. When panel
56
+ content can exceed the space, some ancestor **you choose** must be the
57
+ scroll region:
58
+
59
+ ```css
60
+ .my-panel-wrapper { /* ONE scrollable region… */
61
+ flex: 1 1 auto;
62
+ min-height: 0;
63
+ overflow-y: auto; /* …owns ALL the growth */
64
+ }
65
+ ```
66
+
67
+ Putting a tab set inside `@tapestry-ui/modal` gives you that for free: the
68
+ modal's middle is already the one scrollable region.
69
+
70
+ ## Theming
71
+ Structural CSS only — nothing here picks a color.
72
+
73
+ | custom property | default | what it tunes |
74
+ | --- | --- | --- |
75
+ | `--tui-tabs-gap` | `0.5em` | space between tabs |
76
+ | `--tui-tabs-tab-pad` | `0.4em 0.8em` | padding inside each tab |
77
+ | `--tui-tabs-focus` | `2px solid currentColor` | the focus ring |
78
+
79
+ Style `[data-part="root" \| "list" \| "tab" \| "panel"]` and
80
+ `[data-state="selected" \| "idle"]` for anything deeper.
81
+
82
+ ## License
83
+ MIT © Brandon Minton
package/dist/tabs.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { type ComponentChildren, type JSX } from 'preact';
2
+ export interface TabItem {
3
+ /** stable id — defaults to the item's position, so reordering data without
4
+ * ids will move the selection; give real ids when the list can change */
5
+ id?: string;
6
+ label: ComponentChildren;
7
+ content: ComponentChildren;
8
+ disabled?: boolean;
9
+ }
10
+ export interface TabsProps {
11
+ items: TabItem[];
12
+ /** accessible name for the strip: assistive tech announces it before the tabs */
13
+ label: string;
14
+ /** uncontrolled initial selection (default: the first enabled tab) */
15
+ defaultValue?: string;
16
+ /** controlled selection — the component follows it and only reports intent */
17
+ value?: string;
18
+ onChange?: (id: string) => void;
19
+ /** 'automatic' (default) selects on focus; 'manual' waits for Enter/Space */
20
+ activation?: 'automatic' | 'manual';
21
+ /** skin hooks — tapestry-ui components style through CSS, never props */
22
+ class?: string;
23
+ listClass?: string;
24
+ tabClass?: string;
25
+ panelClass?: string;
26
+ id?: string;
27
+ }
28
+ export declare function Tabs(props: TabsProps): JSX.Element;
package/dist/tabs.js ADDED
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { useId, useRef, useState } from 'preact/hooks';
3
+ export function Tabs(props) {
4
+ const autoId = useId();
5
+ const base = props.id ?? `tui-tabs-${autoId}`;
6
+ const ids = props.items.map((item, index) => item.id ?? `${base}-${index}`);
7
+ const listRef = useRef(null);
8
+ const firstEnabledIndex = props.items.findIndex((item) => !item.disabled);
9
+ const controlled = props.value !== undefined;
10
+ const [ownValue, setOwnValue] = useState(props.defaultValue ?? ids[firstEnabledIndex === -1 ? 0 : firstEnabledIndex]);
11
+ const current = controlled ? props.value : ownValue;
12
+ const foundIndex = ids.indexOf(current);
13
+ const selectedIndex = foundIndex === -1 ? (firstEnabledIndex === -1 ? 0 : firstEnabledIndex) : foundIndex;
14
+ const select = (index) => {
15
+ const item = props.items[index];
16
+ if (!item || item.disabled)
17
+ return;
18
+ if (!controlled)
19
+ setOwnValue(ids[index]);
20
+ props.onChange?.(ids[index]);
21
+ };
22
+ const focusTab = (index) => {
23
+ listRef.current?.querySelectorAll('[data-part="tab"]')[index]?.focus();
24
+ };
25
+ /** the next enabled tab in `step` direction, wrapping; a disabled tab is never a destination */
26
+ const nextEnabled = (from, step) => {
27
+ const count = props.items.length;
28
+ for (let hop = 1; hop <= count; hop++) {
29
+ const index = (((from + step * hop) % count) + count) % count;
30
+ if (!props.items[index].disabled)
31
+ return index;
32
+ }
33
+ return from;
34
+ };
35
+ const handleKey = (event, index) => {
36
+ const moveTo = {
37
+ ArrowRight: () => nextEnabled(index, 1),
38
+ ArrowLeft: () => nextEnabled(index, -1),
39
+ Home: () => nextEnabled(-1, 1),
40
+ End: () => nextEnabled(0, -1),
41
+ };
42
+ const move = moveTo[event.key];
43
+ if (move) {
44
+ event.preventDefault();
45
+ const target = move();
46
+ focusTab(target);
47
+ if (props.activation !== 'manual')
48
+ select(target);
49
+ return;
50
+ }
51
+ // automatic activation already selected on focus; manual waits to be told
52
+ const isActivating = event.key === 'Enter' || event.key === ' ';
53
+ if (isActivating && props.activation === 'manual') {
54
+ event.preventDefault();
55
+ select(index);
56
+ }
57
+ };
58
+ const selected = props.items[selectedIndex];
59
+ return (_jsxs("div", { id: base, class: cx('tui-tabs', props.class), "data-part": "root", children: [_jsx("div", { ref: listRef, class: cx('tui-tabs__list', props.listClass), "data-part": "list", role: "tablist", "aria-label": props.label, children: props.items.map((item, index) => {
60
+ const isSelected = index === selectedIndex;
61
+ return (_jsx("button", { type: "button", role: "tab", "data-part": "tab", "data-state": isSelected ? 'selected' : 'idle', id: `${base}-tab-${index}`, class: cx('tui-tabs__tab', props.tabClass), "aria-selected": isSelected, "aria-controls": `${base}-panel-${index}`, tabIndex: isSelected ? 0 : -1, disabled: item.disabled, onClick: () => select(index), onKeyDown: (event) => handleKey(event, index), children: item.label }, ids[index]));
62
+ }) }), _jsx("div", { role: "tabpanel", "data-part": "panel", id: `${base}-panel-${selectedIndex}`, class: cx('tui-tabs__panel', props.panelClass), "aria-labelledby": `${base}-tab-${selectedIndex}`, tabIndex: 0, children: selected?.content })] }));
63
+ }
64
+ function cx(...parts) {
65
+ return parts.filter(Boolean).join(' ');
66
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@tapestry-ui/tabs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "A WCAG 2.1 AA tab set for Preact \u2014 roving tabindex, arrow-key navigation, headless behavior, skin via CSS. Forged inside a woven-tapestry game; ships with its container contract, not just its markup.",
5
+ "license": "MIT",
6
+ "author": "Brandon Minton",
7
+ "type": "module",
8
+ "sideEffects": [
9
+ "*.css"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/tabs.d.ts",
14
+ "default": "./dist/tabs.js"
15
+ },
16
+ "./styles.css": "./styles.css"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "styles.css",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.build.json",
28
+ "test": "vitest run",
29
+ "prepublishOnly": "npm run test && npm run build"
30
+ },
31
+ "peerDependencies": {
32
+ "preact": ">=10.24.0"
33
+ },
34
+ "devDependencies": {
35
+ "jsdom": "^25.0.1",
36
+ "preact": "^10.29.4",
37
+ "typescript": "^6.0.3",
38
+ "vitest": "^4.1.10"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/ryurage/brandonminton.git",
43
+ "directory": "packages/tapestry-ui/tabs"
44
+ },
45
+ "keywords": [
46
+ "preact",
47
+ "tabs",
48
+ "tablist",
49
+ "a11y",
50
+ "wcag",
51
+ "headless",
52
+ "tapestry-ui"
53
+ ]
54
+ }
package/styles.css ADDED
@@ -0,0 +1,22 @@
1
+ /* @tapestry-ui/tabs — STRUCTURAL styles only. The skin is yours:
2
+ override the custom properties, or ignore this file entirely and
3
+ style [data-part] hooks from scratch. Nothing here picks a color. */
4
+ .tui-tabs__list {
5
+ display: flex;
6
+ gap: var(--tui-tabs-gap, 0.5em);
7
+ flex-wrap: wrap;
8
+ }
9
+ .tui-tabs__tab {
10
+ font: inherit;
11
+ color: inherit;
12
+ background: none;
13
+ border: none;
14
+ cursor: pointer;
15
+ padding: var(--tui-tabs-tab-pad, 0.4em 0.8em);
16
+ }
17
+ .tui-tabs__tab:disabled { cursor: default; opacity: 0.5; }
18
+ .tui-tabs__tab:focus-visible,
19
+ .tui-tabs__panel:focus-visible {
20
+ outline: var(--tui-tabs-focus, 2px solid currentColor);
21
+ outline-offset: 2px;
22
+ }