@tapestry-ui/tabs 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +82 -2
- package/dist/tabs.d.ts +28 -0
- package/dist/tabs.js +66 -0
- package/package.json +49 -4
- package/styles.css +22 -0
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
|
-
#
|
|
1
|
+
# @tapestry-ui/tabs
|
|
2
2
|
|
|
3
|
-
|
|
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,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tapestry-ui/tabs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A WCAG 2.1 AA tab set for Preact — 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
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"styles.css",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"preact": ">=10.24.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"jsdom": "^25.0.1",
|
|
33
|
+
"preact": "^10.29.4",
|
|
34
|
+
"typescript": "^6.0.3",
|
|
35
|
+
"vitest": "^4.1.10"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/ryurage/brandonminton.git",
|
|
40
|
+
"directory": "packages/tapestry-ui/tabs"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"preact",
|
|
44
|
+
"tabs",
|
|
45
|
+
"tablist",
|
|
46
|
+
"a11y",
|
|
47
|
+
"wcag",
|
|
48
|
+
"headless",
|
|
49
|
+
"tapestry-ui"
|
|
50
|
+
]
|
|
51
|
+
}
|
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
|
+
}
|