@lmfaole/basics 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/README.md +114 -0
- package/components/basic-tabs/index.d.ts +64 -0
- package/components/basic-tabs/index.js +386 -0
- package/components/basic-tabs/register.d.ts +1 -0
- package/components/basic-tabs/register.js +3 -0
- package/components/basic-toc/index.d.ts +58 -0
- package/components/basic-toc/index.js +257 -0
- package/components/basic-toc/register.d.ts +1 -0
- package/components/basic-toc/register.js +3 -0
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/package.json +88 -0
- package/readme.mdx +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# `@lmfaole/basics`
|
|
2
|
+
|
|
3
|
+
Simple unstyled custom elements and DOM helpers.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @lmfaole/basics
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Storybook
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm storybook
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm build-storybook
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm test:storybook
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pnpm test:storybook:coverage
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Autodocs is enabled globally for the package stories, and the Docs page includes Storybook's built-in Code panel for rendered examples.
|
|
30
|
+
Storybook Test coverage is enabled through the Vitest addon. In the Storybook UI, turn coverage on in the testing panel to see the summary and open the full report at `/coverage/index.html`. From the CLI, `test:storybook:coverage` writes reports to `coverage/storybook/`.
|
|
31
|
+
|
|
32
|
+
The Visual Tests panel is provided by `@chromatic-com/storybook`. To run cloud visual checks, connect the addon to a Chromatic project from the Storybook UI.
|
|
33
|
+
|
|
34
|
+
## Basic Tabs
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<basic-tabs data-label="Eksempelkode">
|
|
38
|
+
<div data-tabs-list>
|
|
39
|
+
<button type="button" data-tab>Oversikt</button>
|
|
40
|
+
<button type="button" data-tab>Implementasjon</button>
|
|
41
|
+
<button type="button" data-tab>Tilgjengelighet</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<section data-tab-panel>
|
|
45
|
+
<p>Viser en kort oppsummering.</p>
|
|
46
|
+
</section>
|
|
47
|
+
<section data-tab-panel>
|
|
48
|
+
<p>Viser implementasjonsdetaljer.</p>
|
|
49
|
+
</section>
|
|
50
|
+
<section data-tab-panel>
|
|
51
|
+
<p>Viser tilgjengelighetsnotater.</p>
|
|
52
|
+
</section>
|
|
53
|
+
</basic-tabs>
|
|
54
|
+
|
|
55
|
+
<script type="module">
|
|
56
|
+
import "@lmfaole/basics/components/basic-tabs/register";
|
|
57
|
+
</script>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The element upgrades existing markup into an accessible tab interface without adding any styles of its own.
|
|
61
|
+
|
|
62
|
+
### Attributes
|
|
63
|
+
|
|
64
|
+
- `data-label`: sets the generated tablist's accessible name when the tablist does not already have `aria-label` or `aria-labelledby`. Defaults to `Faner`.
|
|
65
|
+
- `data-orientation`: sets arrow-key behavior and mirrors `aria-orientation` on the tablist. Supported values are `horizontal` and `vertical`.
|
|
66
|
+
- `data-activation`: chooses whether arrow-key focus changes also activate the panel. Supported values are `automatic` and `manual`.
|
|
67
|
+
- `data-selected-index`: sets the initially selected tab by zero-based index. Defaults to the first enabled tab.
|
|
68
|
+
|
|
69
|
+
### Behavior
|
|
70
|
+
|
|
71
|
+
- Missing tab and panel ids are generated automatically.
|
|
72
|
+
- `aria-selected`, `aria-controls`, `aria-labelledby`, `hidden`, and `data-selected` stay in sync with the active tab.
|
|
73
|
+
- Click, `Home`, `End`, and orientation-aware arrow keys move between tabs.
|
|
74
|
+
- Disabled tabs are skipped during keyboard navigation.
|
|
75
|
+
- In `manual` mode, arrow keys move focus and `Enter` or `Space` activates the focused tab.
|
|
76
|
+
|
|
77
|
+
### Markup Contract
|
|
78
|
+
|
|
79
|
+
- Provide one descendant element with `data-tabs-list` to hold the interactive tab controls.
|
|
80
|
+
- Provide matching counts of `[data-tab]` and `[data-tab-panel]` descendants in the same order.
|
|
81
|
+
- Prefer `<button>` elements for tabs so click and keyboard activation stay native.
|
|
82
|
+
- Keep layout and styling outside the package; the component only manages semantics, state, and keyboard behavior.
|
|
83
|
+
|
|
84
|
+
## Basic Toc
|
|
85
|
+
|
|
86
|
+
```html
|
|
87
|
+
<basic-toc data-title="Innhold">
|
|
88
|
+
<nav aria-label="Innhold" data-page-toc-nav></nav>
|
|
89
|
+
</basic-toc>
|
|
90
|
+
|
|
91
|
+
<script type="module">
|
|
92
|
+
import "@lmfaole/basics/components/basic-toc/register";
|
|
93
|
+
</script>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The element reads headings from the nearest `<main>` and updates automatically when that content changes.
|
|
97
|
+
|
|
98
|
+
### Attributes
|
|
99
|
+
|
|
100
|
+
- `data-title`: sets the generated nav's accessible label. Defaults to `Innhold`.
|
|
101
|
+
- `data-heading-selector`: limits which headings are indexed. Defaults to `h1, h2, h3, h4, h5, h6`.
|
|
102
|
+
|
|
103
|
+
### Behavior
|
|
104
|
+
|
|
105
|
+
- Missing heading ids are generated automatically.
|
|
106
|
+
- Duplicate headings receive unique fragment ids.
|
|
107
|
+
- Hidden headings are ignored.
|
|
108
|
+
- The outline is rebuilt when matching headings are added or changed.
|
|
109
|
+
|
|
110
|
+
### Markup Contract
|
|
111
|
+
|
|
112
|
+
- Render the element inside the same `<main>` that contains the content it should index.
|
|
113
|
+
- Provide a descendant element with `data-page-toc-nav` for the generated links.
|
|
114
|
+
- Keep layout and styling outside the package; the component only manages structure and link generation.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface TabsTabState {
|
|
2
|
+
disabled: boolean;
|
|
3
|
+
selected?: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type TabsActivation = "automatic" | "manual";
|
|
7
|
+
export type TabsOrientation = "horizontal" | "vertical";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Public tag name registered by `defineTabs`.
|
|
11
|
+
*/
|
|
12
|
+
export const TABS_TAG_NAME: "basic-tabs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalizes unsupported orientation values back to `"horizontal"`.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeTabsOrientation(
|
|
18
|
+
value?: string | null,
|
|
19
|
+
): TabsOrientation;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalizes unsupported activation values back to `"automatic"`.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeTabsActivation(
|
|
25
|
+
value?: string | null,
|
|
26
|
+
): TabsActivation;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the initially active tab index, preferring an explicitly selected,
|
|
30
|
+
* enabled tab and otherwise falling back to the first enabled one.
|
|
31
|
+
*/
|
|
32
|
+
export function getInitialSelectedTabIndex(
|
|
33
|
+
tabStates: TabsTabState[],
|
|
34
|
+
): number;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns the next enabled tab index, wrapping around the list when needed.
|
|
38
|
+
*/
|
|
39
|
+
export function findNextEnabledTabIndex(
|
|
40
|
+
tabStates: TabsTabState[],
|
|
41
|
+
startIndex: number,
|
|
42
|
+
direction: number,
|
|
43
|
+
): number;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Custom element that upgrades existing button-and-panel markup into
|
|
47
|
+
* an accessible tabs interface.
|
|
48
|
+
*
|
|
49
|
+
* Attributes:
|
|
50
|
+
* - `data-label`: fallback accessible name when the tablist has no own label
|
|
51
|
+
* - `data-orientation`: sets the keyboard orientation and `aria-orientation`
|
|
52
|
+
* - `data-activation`: `automatic` or `manual`
|
|
53
|
+
* - `data-selected-index`: zero-based initially selected tab index
|
|
54
|
+
*/
|
|
55
|
+
export class TabsElement extends HTMLElement {
|
|
56
|
+
static observedAttributes: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Registers the `basic-tabs` custom element if it is not already defined.
|
|
61
|
+
*/
|
|
62
|
+
export function defineTabs(
|
|
63
|
+
registry?: CustomElementRegistry,
|
|
64
|
+
): typeof TabsElement;
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
const ElementBase = globalThis.Element ?? class {};
|
|
2
|
+
const HTMLElementBase = globalThis.HTMLElement ?? class {};
|
|
3
|
+
const HTMLButtonElementBase = globalThis.HTMLButtonElement ?? class {};
|
|
4
|
+
|
|
5
|
+
export const TABS_TAG_NAME = "basic-tabs";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LABEL = "Faner";
|
|
8
|
+
const DEFAULT_ACTIVATION = "automatic";
|
|
9
|
+
const DEFAULT_ORIENTATION = "horizontal";
|
|
10
|
+
const MANUAL_ACTIVATION = "manual";
|
|
11
|
+
const VERTICAL_ORIENTATION = "vertical";
|
|
12
|
+
const TABLIST_SELECTOR = "[data-tabs-list]";
|
|
13
|
+
const TAB_SELECTOR = "[data-tab]";
|
|
14
|
+
const PANEL_SELECTOR = "[data-tab-panel]";
|
|
15
|
+
|
|
16
|
+
let nextTabsInstanceId = 1;
|
|
17
|
+
|
|
18
|
+
export function normalizeTabsOrientation(value) {
|
|
19
|
+
return value?.trim().toLowerCase() === VERTICAL_ORIENTATION
|
|
20
|
+
? VERTICAL_ORIENTATION
|
|
21
|
+
: DEFAULT_ORIENTATION;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeTabsActivation(value) {
|
|
25
|
+
return value?.trim().toLowerCase() === MANUAL_ACTIVATION
|
|
26
|
+
? MANUAL_ACTIVATION
|
|
27
|
+
: DEFAULT_ACTIVATION;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getInitialSelectedTabIndex(tabStates) {
|
|
31
|
+
for (let index = 0; index < tabStates.length; index += 1) {
|
|
32
|
+
const tabState = tabStates[index];
|
|
33
|
+
|
|
34
|
+
if (tabState?.selected && !tabState.disabled) {
|
|
35
|
+
return index;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (let index = 0; index < tabStates.length; index += 1) {
|
|
40
|
+
if (!tabStates[index]?.disabled) {
|
|
41
|
+
return index;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return -1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findNextEnabledTabIndex(tabStates, startIndex, direction) {
|
|
49
|
+
if (tabStates.length === 0) {
|
|
50
|
+
return -1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const step = direction < 0 ? -1 : 1;
|
|
54
|
+
let nextIndex = startIndex;
|
|
55
|
+
|
|
56
|
+
for (let checked = 0; checked < tabStates.length; checked += 1) {
|
|
57
|
+
nextIndex += step;
|
|
58
|
+
|
|
59
|
+
if (nextIndex < 0) {
|
|
60
|
+
nextIndex = tabStates.length - 1;
|
|
61
|
+
} else if (nextIndex >= tabStates.length) {
|
|
62
|
+
nextIndex = 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!tabStates[nextIndex]?.disabled) {
|
|
66
|
+
return nextIndex;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return -1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function collectOwnedElements(root, scope, selector) {
|
|
74
|
+
return Array.from(scope.querySelectorAll(selector)).filter(
|
|
75
|
+
(element) => element instanceof HTMLElementBase && element.closest(TABS_TAG_NAME) === root,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isTabDisabled(tab) {
|
|
80
|
+
return tab.hasAttribute("disabled") || tab.getAttribute("aria-disabled") === "true";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findLastEnabledTabIndex(tabStates) {
|
|
84
|
+
for (let index = tabStates.length - 1; index >= 0; index -= 1) {
|
|
85
|
+
if (!tabStates[index]?.disabled) {
|
|
86
|
+
return index;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return -1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class TabsElement extends HTMLElementBase {
|
|
94
|
+
static observedAttributes = [
|
|
95
|
+
"data-activation",
|
|
96
|
+
"data-label",
|
|
97
|
+
"data-orientation",
|
|
98
|
+
"data-selected-index",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
#instanceId = `${TABS_TAG_NAME}-${nextTabsInstanceId++}`;
|
|
102
|
+
#tabList = null;
|
|
103
|
+
#tabs = [];
|
|
104
|
+
#panels = [];
|
|
105
|
+
#selectedIndex = -1;
|
|
106
|
+
#focusIndex = -1;
|
|
107
|
+
#eventsBound = false;
|
|
108
|
+
|
|
109
|
+
connectedCallback() {
|
|
110
|
+
if (!this.#eventsBound) {
|
|
111
|
+
this.addEventListener("click", this.#handleClick);
|
|
112
|
+
this.addEventListener("keydown", this.#handleKeyDown);
|
|
113
|
+
this.#eventsBound = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.#sync({ resetSelection: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
disconnectedCallback() {
|
|
120
|
+
if (!this.#eventsBound) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.removeEventListener("click", this.#handleClick);
|
|
125
|
+
this.removeEventListener("keydown", this.#handleKeyDown);
|
|
126
|
+
this.#eventsBound = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
attributeChangedCallback(name) {
|
|
130
|
+
this.#sync({ resetSelection: name === "data-selected-index" });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#handleClick = (event) => {
|
|
134
|
+
if (!(event.target instanceof ElementBase)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const tab = event.target.closest(TAB_SELECTOR);
|
|
139
|
+
|
|
140
|
+
if (!(tab instanceof HTMLElementBase) || tab.closest(TABS_TAG_NAME) !== this) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const tabIndex = this.#tabs.indexOf(tab);
|
|
145
|
+
|
|
146
|
+
if (tabIndex === -1) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.#selectTab(tabIndex, { focus: true });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
#handleKeyDown = (event) => {
|
|
154
|
+
if (!(event.target instanceof ElementBase)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const currentTab = event.target.closest(TAB_SELECTOR);
|
|
159
|
+
|
|
160
|
+
if (!(currentTab instanceof HTMLElementBase) || currentTab.closest(TABS_TAG_NAME) !== this) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const tabStates = this.#getTabStates();
|
|
165
|
+
const currentIndex = this.#tabs.indexOf(currentTab);
|
|
166
|
+
const activation = this.#getActivation();
|
|
167
|
+
const orientation = this.#getOrientation();
|
|
168
|
+
let nextIndex = -1;
|
|
169
|
+
|
|
170
|
+
if (currentIndex === -1 || currentIndex >= tabStates.length) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
switch (event.key) {
|
|
175
|
+
case "ArrowRight":
|
|
176
|
+
if (orientation === DEFAULT_ORIENTATION) {
|
|
177
|
+
nextIndex = findNextEnabledTabIndex(tabStates, currentIndex, 1);
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
case "ArrowLeft":
|
|
181
|
+
if (orientation === DEFAULT_ORIENTATION) {
|
|
182
|
+
nextIndex = findNextEnabledTabIndex(tabStates, currentIndex, -1);
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
case "ArrowDown":
|
|
186
|
+
if (orientation === VERTICAL_ORIENTATION) {
|
|
187
|
+
nextIndex = findNextEnabledTabIndex(tabStates, currentIndex, 1);
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
case "ArrowUp":
|
|
191
|
+
if (orientation === VERTICAL_ORIENTATION) {
|
|
192
|
+
nextIndex = findNextEnabledTabIndex(tabStates, currentIndex, -1);
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
case "Home":
|
|
196
|
+
nextIndex = getInitialSelectedTabIndex(tabStates);
|
|
197
|
+
break;
|
|
198
|
+
case "End":
|
|
199
|
+
nextIndex = findLastEnabledTabIndex(tabStates);
|
|
200
|
+
break;
|
|
201
|
+
case " ":
|
|
202
|
+
case "Enter":
|
|
203
|
+
event.preventDefault();
|
|
204
|
+
this.#selectTab(this.#focusIndex === -1 ? currentIndex : this.#focusIndex, { focus: true });
|
|
205
|
+
return;
|
|
206
|
+
default:
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (nextIndex === -1) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
|
|
216
|
+
if (activation === MANUAL_ACTIVATION) {
|
|
217
|
+
this.#focusIndex = nextIndex;
|
|
218
|
+
this.#applyState({ focus: true });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.#selectTab(nextIndex, { focus: true });
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
#getActivation() {
|
|
226
|
+
return normalizeTabsActivation(this.getAttribute("data-activation"));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#getConfiguredSelectedIndex(tabStates) {
|
|
230
|
+
const selectedIndex = Number.parseInt(
|
|
231
|
+
this.getAttribute("data-selected-index") ?? "",
|
|
232
|
+
10,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
Number.isInteger(selectedIndex)
|
|
237
|
+
&& selectedIndex >= 0
|
|
238
|
+
&& selectedIndex < tabStates.length
|
|
239
|
+
&& !tabStates[selectedIndex]?.disabled
|
|
240
|
+
) {
|
|
241
|
+
return selectedIndex;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return getInitialSelectedTabIndex(tabStates);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#getLabel() {
|
|
248
|
+
return this.getAttribute("data-label")?.trim() || DEFAULT_LABEL;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#getOrientation() {
|
|
252
|
+
return normalizeTabsOrientation(
|
|
253
|
+
this.getAttribute("data-orientation") ?? this.#tabList?.getAttribute("aria-orientation"),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#getTabStates(configuredSelectedIndex = null) {
|
|
258
|
+
const pairCount = Math.min(this.#tabs.length, this.#panels.length);
|
|
259
|
+
|
|
260
|
+
return this.#tabs.slice(0, pairCount).map((tab, index) => ({
|
|
261
|
+
disabled: isTabDisabled(tab),
|
|
262
|
+
selected: index === configuredSelectedIndex
|
|
263
|
+
|| tab.hasAttribute("data-selected")
|
|
264
|
+
|| tab.getAttribute("aria-selected") === "true",
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#sync({ resetSelection = false } = {}) {
|
|
269
|
+
this.#tabList = collectOwnedElements(this, this, TABLIST_SELECTOR)[0] ?? null;
|
|
270
|
+
this.#tabs = this.#tabList
|
|
271
|
+
? collectOwnedElements(this, this.#tabList, TAB_SELECTOR)
|
|
272
|
+
: [];
|
|
273
|
+
this.#panels = collectOwnedElements(this, this, PANEL_SELECTOR).filter(
|
|
274
|
+
(panel) => !this.#tabList?.contains(panel),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const tabStates = this.#getTabStates();
|
|
278
|
+
|
|
279
|
+
if (resetSelection || this.#selectedIndex === -1 || tabStates[this.#selectedIndex]?.disabled) {
|
|
280
|
+
this.#selectedIndex = this.#getConfiguredSelectedIndex(tabStates);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (resetSelection || this.#focusIndex === -1 || tabStates[this.#focusIndex]?.disabled) {
|
|
284
|
+
this.#focusIndex = this.#selectedIndex;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.#applyState();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#applyState({ focus = false } = {}) {
|
|
291
|
+
if (!(this.#tabList instanceof HTMLElementBase)) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const orientation = this.#getOrientation();
|
|
296
|
+
const pairCount = Math.min(this.#tabs.length, this.#panels.length);
|
|
297
|
+
const baseId = this.id || this.#instanceId;
|
|
298
|
+
|
|
299
|
+
if (!this.#tabList.hasAttribute("aria-label") && !this.#tabList.hasAttribute("aria-labelledby")) {
|
|
300
|
+
this.#tabList.setAttribute("aria-label", this.#getLabel());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.#tabList.setAttribute("role", "tablist");
|
|
304
|
+
this.#tabList.setAttribute("aria-orientation", orientation);
|
|
305
|
+
|
|
306
|
+
for (let index = 0; index < this.#tabs.length; index += 1) {
|
|
307
|
+
const tab = this.#tabs[index];
|
|
308
|
+
const panel = index < pairCount ? this.#panels[index] : null;
|
|
309
|
+
const disabled = index >= pairCount || isTabDisabled(tab);
|
|
310
|
+
const selected = index === this.#selectedIndex && !disabled;
|
|
311
|
+
const focusable = index === this.#focusIndex && !disabled;
|
|
312
|
+
|
|
313
|
+
if (!tab.id) {
|
|
314
|
+
tab.id = `${baseId}-tab-${index + 1}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (tab instanceof HTMLButtonElementBase && !tab.hasAttribute("type")) {
|
|
318
|
+
tab.type = "button";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
tab.setAttribute("role", "tab");
|
|
322
|
+
tab.setAttribute("aria-selected", String(selected));
|
|
323
|
+
tab.tabIndex = focusable ? 0 : -1;
|
|
324
|
+
tab.toggleAttribute("data-selected", selected);
|
|
325
|
+
|
|
326
|
+
if (panel) {
|
|
327
|
+
if (!panel.id) {
|
|
328
|
+
panel.id = `${baseId}-panel-${index + 1}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
tab.setAttribute("aria-controls", panel.id);
|
|
332
|
+
} else {
|
|
333
|
+
tab.removeAttribute("aria-controls");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
for (let index = 0; index < this.#panels.length; index += 1) {
|
|
338
|
+
const panel = this.#panels[index];
|
|
339
|
+
const tab = this.#tabs[index];
|
|
340
|
+
const selected = index === this.#selectedIndex && index < pairCount;
|
|
341
|
+
|
|
342
|
+
if (!panel.id) {
|
|
343
|
+
panel.id = `${baseId}-panel-${index + 1}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
panel.setAttribute("role", "tabpanel");
|
|
347
|
+
|
|
348
|
+
if (tab?.id) {
|
|
349
|
+
panel.setAttribute("aria-labelledby", tab.id);
|
|
350
|
+
} else {
|
|
351
|
+
panel.removeAttribute("aria-labelledby");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
panel.hidden = !selected;
|
|
355
|
+
panel.toggleAttribute("data-selected", selected);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (focus && this.#focusIndex !== -1) {
|
|
359
|
+
this.#tabs[this.#focusIndex]?.focus();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#selectTab(index, { focus = false } = {}) {
|
|
364
|
+
const tabStates = this.#getTabStates();
|
|
365
|
+
|
|
366
|
+
if (index < 0 || index >= tabStates.length || tabStates[index]?.disabled) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
this.#selectedIndex = index;
|
|
371
|
+
this.#focusIndex = index;
|
|
372
|
+
this.#applyState({ focus });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function defineTabs(registry = globalThis.customElements) {
|
|
377
|
+
if (!registry?.get || !registry?.define) {
|
|
378
|
+
return TabsElement;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!registry.get(TABS_TAG_NAME)) {
|
|
382
|
+
registry.define(TABS_TAG_NAME, TabsElement);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return TabsElement;
|
|
386
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized heading data collected from the nearest `<main>`.
|
|
3
|
+
*/
|
|
4
|
+
export interface TableOfContentsHeading {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
level: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Nested outline item returned by `buildTableOfContentsTree`.
|
|
12
|
+
*/
|
|
13
|
+
export interface TableOfContentsItem extends TableOfContentsHeading {
|
|
14
|
+
children: TableOfContentsItem[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Public tag name registered by `defineTableOfContents`.
|
|
19
|
+
*/
|
|
20
|
+
export const TABLE_OF_CONTENTS_TAG_NAME: "basic-toc";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collapses repeated whitespace and trims the result.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeHeadingText(text: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Converts heading text into a stable fragment id.
|
|
28
|
+
*/
|
|
29
|
+
export function slugifyHeading(text: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Returns a unique heading id and stores it in the provided set.
|
|
32
|
+
*/
|
|
33
|
+
export function createUniqueHeadingId(baseText: string, usedIds: Set<string>): string;
|
|
34
|
+
/**
|
|
35
|
+
* Converts a flat heading list into a nested outline tree.
|
|
36
|
+
*/
|
|
37
|
+
export function buildTableOfContentsTree(
|
|
38
|
+
headings: TableOfContentsHeading[],
|
|
39
|
+
): TableOfContentsItem[];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Custom element that renders a heading outline into a descendant
|
|
43
|
+
* `[data-page-toc-nav]` container.
|
|
44
|
+
*
|
|
45
|
+
* Attributes:
|
|
46
|
+
* - `data-title`: aria-label for the generated nav. Defaults to `"Innhold"`.
|
|
47
|
+
* - `data-heading-selector`: CSS selector used to collect headings from the nearest `<main>`.
|
|
48
|
+
*/
|
|
49
|
+
export class TableOfContentsElement extends HTMLElement {
|
|
50
|
+
static observedAttributes: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Registers the `basic-toc` custom element if it is not already defined.
|
|
55
|
+
*/
|
|
56
|
+
export function defineTableOfContents(
|
|
57
|
+
registry?: CustomElementRegistry,
|
|
58
|
+
): typeof TableOfContentsElement;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
const HTMLElementBase = globalThis.HTMLElement ?? class {};
|
|
2
|
+
|
|
3
|
+
export const TABLE_OF_CONTENTS_TAG_NAME = "basic-toc";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TITLE = "Innhold";
|
|
6
|
+
const DEFAULT_HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
|
|
7
|
+
|
|
8
|
+
export function normalizeHeadingText(text) {
|
|
9
|
+
return text.replace(/\s+/g, " ").trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function slugifyHeading(text) {
|
|
13
|
+
const normalized = normalizeHeadingText(text)
|
|
14
|
+
.toLocaleLowerCase("nb")
|
|
15
|
+
.replace(/['’"]/g, "")
|
|
16
|
+
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
|
|
17
|
+
.replace(/\s+/g, "-")
|
|
18
|
+
.replace(/-+/g, "-")
|
|
19
|
+
.replace(/^-|-$/g, "");
|
|
20
|
+
|
|
21
|
+
return normalized || "overskrift";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createUniqueHeadingId(baseText, usedIds) {
|
|
25
|
+
const baseId = slugifyHeading(baseText);
|
|
26
|
+
let nextId = baseId;
|
|
27
|
+
let suffix = 2;
|
|
28
|
+
|
|
29
|
+
while (usedIds.has(nextId)) {
|
|
30
|
+
nextId = `${baseId}-${suffix}`;
|
|
31
|
+
suffix += 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
usedIds.add(nextId);
|
|
35
|
+
return nextId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildTableOfContentsTree(headings) {
|
|
39
|
+
const root = [];
|
|
40
|
+
const stack = [];
|
|
41
|
+
|
|
42
|
+
for (const heading of headings) {
|
|
43
|
+
const item = {
|
|
44
|
+
...heading,
|
|
45
|
+
children: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= item.level) {
|
|
49
|
+
stack.pop();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parent = stack[stack.length - 1];
|
|
53
|
+
|
|
54
|
+
if (parent) {
|
|
55
|
+
parent.children.push(item);
|
|
56
|
+
} else {
|
|
57
|
+
root.push(item);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
stack.push(item);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderTableOfContentsItems(items) {
|
|
67
|
+
const list = document.createElement("ol");
|
|
68
|
+
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
const listItem = document.createElement("li");
|
|
71
|
+
const link = document.createElement("a");
|
|
72
|
+
link.href = `#${item.id}`;
|
|
73
|
+
link.textContent = item.text;
|
|
74
|
+
listItem.append(link);
|
|
75
|
+
|
|
76
|
+
if (item.children.length > 0) {
|
|
77
|
+
listItem.append(renderTableOfContentsItems(item.children));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
list.append(listItem);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return list;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectTableOfContentsHeadings(main, tocRoot, headingSelector) {
|
|
87
|
+
const usedIds = new Set();
|
|
88
|
+
const headings = [];
|
|
89
|
+
|
|
90
|
+
for (const heading of main.querySelectorAll(headingSelector)) {
|
|
91
|
+
if (!(heading instanceof HTMLHeadingElement)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (tocRoot.contains(heading) || heading.closest("[hidden], [aria-hidden='true']")) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const text = normalizeHeadingText(heading.textContent ?? "");
|
|
100
|
+
|
|
101
|
+
if (!text) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const existingId = normalizeHeadingText(heading.id);
|
|
106
|
+
|
|
107
|
+
if (existingId && !usedIds.has(existingId)) {
|
|
108
|
+
usedIds.add(existingId);
|
|
109
|
+
} else if (existingId) {
|
|
110
|
+
heading.id = createUniqueHeadingId(existingId, usedIds);
|
|
111
|
+
} else {
|
|
112
|
+
heading.id = createUniqueHeadingId(text, usedIds);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
headings.push({
|
|
116
|
+
id: heading.id,
|
|
117
|
+
text,
|
|
118
|
+
level: Number(heading.tagName.slice(1)),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return headings;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class TableOfContentsElement extends HTMLElementBase {
|
|
126
|
+
static observedAttributes = ["data-title", "data-heading-selector"];
|
|
127
|
+
|
|
128
|
+
#observer = null;
|
|
129
|
+
#observedMain = null;
|
|
130
|
+
#scheduledFrame = 0;
|
|
131
|
+
|
|
132
|
+
connectedCallback() {
|
|
133
|
+
this.#ensureShell();
|
|
134
|
+
this.#syncObserver();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
disconnectedCallback() {
|
|
138
|
+
this.#teardownObserver();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
attributeChangedCallback() {
|
|
142
|
+
this.#ensureShell();
|
|
143
|
+
this.#scheduleUpdate();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#getTitle() {
|
|
147
|
+
return this.getAttribute("data-title")?.trim() || DEFAULT_TITLE;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#getHeadingSelector() {
|
|
151
|
+
return this.getAttribute("data-heading-selector")?.trim() || DEFAULT_HEADING_SELECTOR;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#getNav() {
|
|
155
|
+
const nav = this.querySelector("[data-page-toc-nav]");
|
|
156
|
+
return nav instanceof HTMLElement ? nav : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#ensureShell() {
|
|
160
|
+
if (typeof document === "undefined") {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let nav = this.#getNav();
|
|
165
|
+
|
|
166
|
+
if (!(nav instanceof HTMLElement)) {
|
|
167
|
+
nav = document.createElement("nav");
|
|
168
|
+
nav.dataset.pageTocNav = "";
|
|
169
|
+
this.replaceChildren(nav);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
nav.setAttribute("aria-label", this.#getTitle());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#scheduleUpdate() {
|
|
176
|
+
if (this.#scheduledFrame !== 0 || typeof window === "undefined") {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.#scheduledFrame = window.requestAnimationFrame(() => {
|
|
181
|
+
this.#scheduledFrame = 0;
|
|
182
|
+
this.#update();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#syncObserver() {
|
|
187
|
+
const main = this.closest("main");
|
|
188
|
+
|
|
189
|
+
if (!(main instanceof HTMLElement)) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (main !== this.#observedMain) {
|
|
194
|
+
this.#teardownObserver();
|
|
195
|
+
this.#observedMain = main;
|
|
196
|
+
this.#observer = new MutationObserver(() => {
|
|
197
|
+
this.#scheduleUpdate();
|
|
198
|
+
});
|
|
199
|
+
this.#observer.observe(main, {
|
|
200
|
+
childList: true,
|
|
201
|
+
subtree: true,
|
|
202
|
+
characterData: true,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.#update();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#teardownObserver() {
|
|
210
|
+
this.#observer?.disconnect();
|
|
211
|
+
this.#observer = null;
|
|
212
|
+
this.#observedMain = null;
|
|
213
|
+
|
|
214
|
+
if (this.#scheduledFrame !== 0 && typeof window !== "undefined") {
|
|
215
|
+
window.cancelAnimationFrame(this.#scheduledFrame);
|
|
216
|
+
this.#scheduledFrame = 0;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#update() {
|
|
221
|
+
const nav = this.#getNav();
|
|
222
|
+
const main = this.closest("main");
|
|
223
|
+
|
|
224
|
+
if (!(nav instanceof HTMLElement) || !(main instanceof HTMLElement)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const headings = collectTableOfContentsHeadings(
|
|
229
|
+
main,
|
|
230
|
+
this,
|
|
231
|
+
this.#getHeadingSelector(),
|
|
232
|
+
);
|
|
233
|
+
const nextMarkup = headings.length > 0
|
|
234
|
+
? renderTableOfContentsItems(buildTableOfContentsTree(headings)).outerHTML
|
|
235
|
+
: "";
|
|
236
|
+
|
|
237
|
+
if (nav.innerHTML !== nextMarkup) {
|
|
238
|
+
nav.innerHTML = nextMarkup;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.hidden = headings.length === 0;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function defineTableOfContents(
|
|
246
|
+
registry = globalThis.customElements,
|
|
247
|
+
) {
|
|
248
|
+
if (!registry?.get || !registry?.define) {
|
|
249
|
+
return TableOfContentsElement;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!registry.get(TABLE_OF_CONTENTS_TAG_NAME)) {
|
|
253
|
+
registry.define(TABLE_OF_CONTENTS_TAG_NAME, TableOfContentsElement);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return TableOfContentsElement;
|
|
257
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/index.d.ts
ADDED
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lmfaole/basics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Simple unstyled custom elements and DOM helpers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"./components/basic-toc/register.js",
|
|
8
|
+
"./components/basic-tabs/register.js"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"import": "./index.js"
|
|
14
|
+
},
|
|
15
|
+
"./components/basic-toc": {
|
|
16
|
+
"types": "./components/basic-toc/index.d.ts",
|
|
17
|
+
"import": "./components/basic-toc/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./components/basic-toc/register": {
|
|
20
|
+
"types": "./components/basic-toc/register.d.ts",
|
|
21
|
+
"import": "./components/basic-toc/register.js"
|
|
22
|
+
},
|
|
23
|
+
"./components/basic-tabs": {
|
|
24
|
+
"types": "./components/basic-tabs/index.d.ts",
|
|
25
|
+
"import": "./components/basic-tabs/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./components/basic-tabs/register": {
|
|
28
|
+
"types": "./components/basic-tabs/register.d.ts",
|
|
29
|
+
"import": "./components/basic-tabs/register.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"README.md",
|
|
34
|
+
"index.js",
|
|
35
|
+
"index.d.ts",
|
|
36
|
+
"components/*/index.js",
|
|
37
|
+
"components/*/index.d.ts",
|
|
38
|
+
"components/*/register.js",
|
|
39
|
+
"components/*/register.d.ts"
|
|
40
|
+
],
|
|
41
|
+
"keywords": [
|
|
42
|
+
"custom-element",
|
|
43
|
+
"basic-tabs",
|
|
44
|
+
"basic-toc",
|
|
45
|
+
"table-of-contents",
|
|
46
|
+
"tabs",
|
|
47
|
+
"tablist",
|
|
48
|
+
"toc",
|
|
49
|
+
"unstyled",
|
|
50
|
+
"vanilla-js",
|
|
51
|
+
"web-components"
|
|
52
|
+
],
|
|
53
|
+
"license": "ISC",
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/lmfaole/basics.git"
|
|
57
|
+
},
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/lmfaole/basics/issues"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/lmfaole/basics",
|
|
62
|
+
"scripts": {
|
|
63
|
+
"storybook": "storybook dev -p 6006 --no-open",
|
|
64
|
+
"build-storybook": "storybook build",
|
|
65
|
+
"test": "pnpm run test:unit && pnpm run test:storybook",
|
|
66
|
+
"test:unit": "vitest run --config vitest.config.ts",
|
|
67
|
+
"test:storybook": "vitest run --config vitest.storybook.config.ts",
|
|
68
|
+
"test:storybook:watch": "vitest --config vitest.storybook.config.ts",
|
|
69
|
+
"test:storybook:coverage": "vitest run --config vitest.storybook.config.ts --coverage"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@chromatic-com/storybook": "^5.0.2",
|
|
73
|
+
"@storybook/addon-a11y": "^10.3.1",
|
|
74
|
+
"@storybook/addon-docs": "10.3.1",
|
|
75
|
+
"@storybook/addon-vitest": "10.3.1",
|
|
76
|
+
"@storybook/web-components-vite": "^10.3.1",
|
|
77
|
+
"@vitest/browser-playwright": "4.1.0",
|
|
78
|
+
"@vitest/coverage-v8": "4.1.0",
|
|
79
|
+
"playwright": "^1.58.2",
|
|
80
|
+
"storybook": "^10.3.1",
|
|
81
|
+
"vitest": "^4.1.0"
|
|
82
|
+
},
|
|
83
|
+
"publishConfig": {
|
|
84
|
+
"access": "public",
|
|
85
|
+
"registry": "https://registry.npmjs.org/"
|
|
86
|
+
},
|
|
87
|
+
"packageManager": "pnpm@10.30.3"
|
|
88
|
+
}
|