@lmfaole/basics 0.4.0 → 0.5.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 +78 -499
- package/basic-components/basic-accordion/README.md +53 -0
- package/{components → basic-components}/basic-accordion/index.js +59 -37
- package/basic-components/basic-alert/README.md +48 -0
- package/basic-components/basic-carousel/README.md +108 -0
- package/basic-components/basic-carousel/index.d.ts +73 -0
- package/basic-components/basic-carousel/index.js +255 -0
- package/basic-components/basic-carousel/register.js +3 -0
- package/basic-components/basic-dialog/README.md +57 -0
- package/basic-components/basic-popover/README.md +56 -0
- package/basic-components/basic-summary-table/README.md +93 -0
- package/basic-components/basic-table/README.md +89 -0
- package/basic-components/basic-tabs/README.md +63 -0
- package/basic-components/basic-toast/README.md +62 -0
- package/{components → basic-components}/basic-toast/index.d.ts +3 -0
- package/{components → basic-components}/basic-toast/index.js +264 -3
- package/basic-components/basic-toc/README.md +43 -0
- package/basic-components/basic-toc/register.d.ts +1 -0
- package/basic-styling/components/basic-accordion.css +38 -4
- package/basic-styling/components/basic-carousel.css +183 -0
- package/basic-styling/components/basic-popover.css +2 -4
- package/basic-styling/components/basic-summary-table.css +27 -5
- package/basic-styling/components/basic-table.css +22 -4
- package/basic-styling/components/basic-tabs.css +26 -10
- package/basic-styling/components.css +2 -0
- package/basic-styling/forms.css +55 -0
- package/basic-styling/global.css +1 -0
- package/basic-styling/interaction.css +90 -0
- package/basic-styling/tokens/palette.css +112 -0
- package/basic-styling/tokens/palette.tokens.json +768 -0
- package/index.d.ts +10 -9
- package/index.js +10 -9
- package/package.json +49 -29
- package/readme.mdx +0 -6
- /package/{components → basic-components}/basic-accordion/index.d.ts +0 -0
- /package/{components → basic-components}/basic-accordion/register.d.ts +0 -0
- /package/{components → basic-components}/basic-accordion/register.js +0 -0
- /package/{components → basic-components}/basic-alert/index.d.ts +0 -0
- /package/{components → basic-components}/basic-alert/index.js +0 -0
- /package/{components → basic-components}/basic-alert/register.d.ts +0 -0
- /package/{components → basic-components}/basic-alert/register.js +0 -0
- /package/{components/basic-dialog → basic-components/basic-carousel}/register.d.ts +0 -0
- /package/{components → basic-components}/basic-dialog/index.d.ts +0 -0
- /package/{components → basic-components}/basic-dialog/index.js +0 -0
- /package/{components/basic-popover → basic-components/basic-dialog}/register.d.ts +0 -0
- /package/{components → basic-components}/basic-dialog/register.js +0 -0
- /package/{components → basic-components}/basic-popover/index.d.ts +0 -0
- /package/{components → basic-components}/basic-popover/index.js +0 -0
- /package/{components/basic-summary-table → basic-components/basic-popover}/register.d.ts +0 -0
- /package/{components → basic-components}/basic-popover/register.js +0 -0
- /package/{components → basic-components}/basic-summary-table/index.d.ts +0 -0
- /package/{components → basic-components}/basic-summary-table/index.js +0 -0
- /package/{components/basic-table → basic-components/basic-summary-table}/register.d.ts +0 -0
- /package/{components → basic-components}/basic-summary-table/register.js +0 -0
- /package/{components → basic-components}/basic-table/index.d.ts +0 -0
- /package/{components → basic-components}/basic-table/index.js +0 -0
- /package/{components/basic-tabs → basic-components/basic-table}/register.d.ts +0 -0
- /package/{components → basic-components}/basic-table/register.js +0 -0
- /package/{components → basic-components}/basic-tabs/index.d.ts +0 -0
- /package/{components → basic-components}/basic-tabs/index.js +0 -0
- /package/{components/basic-toast → basic-components/basic-tabs}/register.d.ts +0 -0
- /package/{components → basic-components}/basic-tabs/register.js +0 -0
- /package/{components/basic-toc → basic-components/basic-toast}/register.d.ts +0 -0
- /package/{components → basic-components}/basic-toast/register.js +0 -0
- /package/{components → basic-components}/basic-toc/index.d.ts +0 -0
- /package/{components → basic-components}/basic-toc/index.js +0 -0
- /package/{components → basic-components}/basic-toc/register.js +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# `basic-accordion`
|
|
2
|
+
|
|
3
|
+
Coordinates direct child `<details>` items into an accordion.
|
|
4
|
+
|
|
5
|
+
## Register
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import "@lmfaole/basics/basic-components/basic-accordion/register";
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<basic-accordion>
|
|
15
|
+
<details open>
|
|
16
|
+
<summary>Oversikt</summary>
|
|
17
|
+
<p>Viser en kort oppsummering.</p>
|
|
18
|
+
</details>
|
|
19
|
+
|
|
20
|
+
<details>
|
|
21
|
+
<summary>Implementasjon</summary>
|
|
22
|
+
<p>Viser implementasjonsdetaljer.</p>
|
|
23
|
+
</details>
|
|
24
|
+
</basic-accordion>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Props
|
|
28
|
+
|
|
29
|
+
| Prop | Description | Type | Default | Options |
|
|
30
|
+
| --- | --- | --- | --- | --- |
|
|
31
|
+
| `data-multiple` | Allows more than one accordion item to stay open at the same time. | boolean attribute | off | `present`, `omitted` |
|
|
32
|
+
| `data-collapsible` | Allows the last open item in single-open mode to close. | boolean attribute | off | `present`, `omitted` |
|
|
33
|
+
|
|
34
|
+
## Item Hooks
|
|
35
|
+
|
|
36
|
+
| Hook | Description | Type | Default | Options |
|
|
37
|
+
| --- | --- | --- | --- | --- |
|
|
38
|
+
| `open` on a child `<details>` | Sets an item's initial expanded state before the accordion normalizes it. | boolean attribute | closed | `present`, `omitted` |
|
|
39
|
+
| `data-disabled` on a child `<details>` | Removes the item from toggle behavior and arrow-key navigation. | boolean attribute | off | `present`, `omitted` |
|
|
40
|
+
|
|
41
|
+
## Behavior
|
|
42
|
+
|
|
43
|
+
- Preserves native `details` and `summary` semantics
|
|
44
|
+
- Keeps `data-open` in sync with the normalized open state
|
|
45
|
+
- Supports `ArrowUp`, `ArrowDown`, `Home`, and `End` between enabled summaries
|
|
46
|
+
- In single-open mode, keeps one enabled item open unless `data-collapsible` is set
|
|
47
|
+
|
|
48
|
+
## Markup Contract
|
|
49
|
+
|
|
50
|
+
- Provide direct child `<details>` items, each with a first-child `<summary>`
|
|
51
|
+
- Add `open` to any item that should start expanded
|
|
52
|
+
- Add `data-disabled` to a `<details>` item when it should be skipped by arrow-key navigation and blocked from toggling
|
|
53
|
+
- Keep layout and styling outside the package
|
|
@@ -175,13 +175,9 @@ export class AccordionElement extends HTMLElementBase {
|
|
|
175
175
|
return;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
if (
|
|
179
|
-
!this.#isMultiple()
|
|
180
|
-
&& !this.#isCollapsible()
|
|
181
|
-
&& itemStates[summaryIndex]?.open
|
|
182
|
-
&& getOpenAccordionIndexes(itemStates).length === 1
|
|
183
|
-
) {
|
|
178
|
+
if (!this.#isMultiple()) {
|
|
184
179
|
event.preventDefault();
|
|
180
|
+
this.#toggleItem(summaryIndex, itemStates);
|
|
185
181
|
}
|
|
186
182
|
};
|
|
187
183
|
|
|
@@ -215,21 +211,7 @@ export class AccordionElement extends HTMLElementBase {
|
|
|
215
211
|
case " ":
|
|
216
212
|
case "Enter":
|
|
217
213
|
event.preventDefault();
|
|
218
|
-
|
|
219
|
-
if (itemStates[currentIndex]?.disabled) {
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
!this.#isMultiple()
|
|
225
|
-
&& !this.#isCollapsible()
|
|
226
|
-
&& itemStates[currentIndex]?.open
|
|
227
|
-
&& getOpenAccordionIndexes(itemStates).length === 1
|
|
228
|
-
) {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
this.#items[currentIndex].details.open = !this.#items[currentIndex].details.open;
|
|
214
|
+
this.#toggleItem(currentIndex, itemStates);
|
|
233
215
|
return;
|
|
234
216
|
default:
|
|
235
217
|
return;
|
|
@@ -338,22 +320,7 @@ export class AccordionElement extends HTMLElementBase {
|
|
|
338
320
|
}
|
|
339
321
|
}
|
|
340
322
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
this.#isSyncingState = true;
|
|
344
|
-
|
|
345
|
-
try {
|
|
346
|
-
for (let index = 0; index < this.#items.length; index += 1) {
|
|
347
|
-
const details = this.#items[index].details;
|
|
348
|
-
const open = !itemStates[index]?.disabled && openIndexSet.has(index);
|
|
349
|
-
|
|
350
|
-
if (details.open !== open) {
|
|
351
|
-
details.open = open;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
} finally {
|
|
355
|
-
this.#isSyncingState = false;
|
|
356
|
-
}
|
|
323
|
+
this.#applyOpenState(openIndexes, itemStates);
|
|
357
324
|
}
|
|
358
325
|
|
|
359
326
|
#applyState() {
|
|
@@ -376,6 +343,61 @@ export class AccordionElement extends HTMLElementBase {
|
|
|
376
343
|
}
|
|
377
344
|
}
|
|
378
345
|
}
|
|
346
|
+
|
|
347
|
+
#toggleItem(index, itemStates = this.#getItemStates()) {
|
|
348
|
+
if (index < 0 || index >= this.#items.length || itemStates[index]?.disabled) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (this.#isMultiple()) {
|
|
353
|
+
this.#items[index].details.open = !this.#items[index].details.open;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const openIndexes = getOpenAccordionIndexes(itemStates);
|
|
358
|
+
|
|
359
|
+
if (itemStates[index]?.open) {
|
|
360
|
+
if (!this.#isCollapsible() && openIndexes.length === 1) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
this.#applyOpenState([], itemStates);
|
|
365
|
+
this.#applyState();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this.#applyOpenState([index], itemStates);
|
|
370
|
+
this.#applyState();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#applyOpenState(openIndexes, itemStates = this.#getItemStates()) {
|
|
374
|
+
const openIndexSet = new Set(openIndexes);
|
|
375
|
+
|
|
376
|
+
this.#isSyncingState = true;
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
// Close panels first so single-open accordions do not briefly expose two bodies.
|
|
380
|
+
for (let index = 0; index < this.#items.length; index += 1) {
|
|
381
|
+
const details = this.#items[index].details;
|
|
382
|
+
const open = !itemStates[index]?.disabled && openIndexSet.has(index);
|
|
383
|
+
|
|
384
|
+
if (!open && details.open) {
|
|
385
|
+
details.open = false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (let index = 0; index < this.#items.length; index += 1) {
|
|
390
|
+
const details = this.#items[index].details;
|
|
391
|
+
const open = !itemStates[index]?.disabled && openIndexSet.has(index);
|
|
392
|
+
|
|
393
|
+
if (open && !details.open) {
|
|
394
|
+
details.open = true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} finally {
|
|
398
|
+
this.#isSyncingState = false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
379
401
|
}
|
|
380
402
|
|
|
381
403
|
export function defineAccordion(registry = globalThis.customElements) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# `basic-alert`
|
|
2
|
+
|
|
3
|
+
Inline live-region alert content without opinionated styling.
|
|
4
|
+
|
|
5
|
+
## Register
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import "@lmfaole/basics/basic-components/basic-alert/register";
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<basic-alert data-label="Lagring fullfort" data-live="polite">
|
|
15
|
+
<h2 data-alert-title>Endringer lagret</h2>
|
|
16
|
+
<p>Meldingen ble lagret uten feil.</p>
|
|
17
|
+
<button type="button" data-alert-close>Dismiss</button>
|
|
18
|
+
</basic-alert>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
| Prop | Description | Type | Default | Options |
|
|
24
|
+
| --- | --- | --- | --- | --- |
|
|
25
|
+
| `data-label` | Fallback accessible name when the alert has no `aria-label`, `aria-labelledby`, or `[data-alert-title]`. | string | `Alert` | any string |
|
|
26
|
+
| `data-live` | Chooses whether the alert announces as `role="alert"` or `role="status"`. | enum string | `assertive` | `assertive`, `polite` |
|
|
27
|
+
| `data-open` | Managed visibility flag. If omitted, the alert stays visible unless `hidden` is set. | boolean-ish attribute | visible | `present`, `omitted`, `false` |
|
|
28
|
+
|
|
29
|
+
## Markup Hooks
|
|
30
|
+
|
|
31
|
+
| Hook | Description | Type | Default | Options |
|
|
32
|
+
| --- | --- | --- | --- | --- |
|
|
33
|
+
| `data-alert-title` | Makes the visible heading the alert's accessible name. | descendant heading attribute | none | present on a descendant heading |
|
|
34
|
+
| `data-alert-close` | Dismisses the alert when activated. | descendant control attribute | none | present on a descendant control |
|
|
35
|
+
|
|
36
|
+
## Behavior
|
|
37
|
+
|
|
38
|
+
- Applies the matching live-region role, `aria-live`, and `aria-atomic="true"` on the root element
|
|
39
|
+
- Uses `[data-alert-title]` as the accessible name when present, otherwise falls back to `data-label`
|
|
40
|
+
- `[data-alert-close]` hides the alert and removes its managed `data-open` state
|
|
41
|
+
- `show()` and `hide()` support programmatic visibility changes
|
|
42
|
+
|
|
43
|
+
## Markup Contract
|
|
44
|
+
|
|
45
|
+
- Put the content directly inside `<basic-alert>`
|
|
46
|
+
- Use `[data-alert-title]` when the alert should have a visible accessible name
|
|
47
|
+
- Use `[data-alert-close]` when the alert should be dismissible
|
|
48
|
+
- Keep layout and styling outside the package
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# `basic-carousel`
|
|
2
|
+
|
|
3
|
+
Named carousel regions built around a native scroll-snap track.
|
|
4
|
+
|
|
5
|
+
## Register
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import "@lmfaole/basics/basic-components/basic-carousel/register";
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<basic-carousel
|
|
15
|
+
data-label="Featured stories"
|
|
16
|
+
data-controls="both"
|
|
17
|
+
data-snapping="center"
|
|
18
|
+
>
|
|
19
|
+
<div data-carousel-track>
|
|
20
|
+
<article>
|
|
21
|
+
<h2>Launch Week</h2>
|
|
22
|
+
<p>Three product updates shipping across the design system.</p>
|
|
23
|
+
</article>
|
|
24
|
+
|
|
25
|
+
<article data-carousel-marker-label="Go to the accessibility slide">
|
|
26
|
+
<h2>Accessibility</h2>
|
|
27
|
+
<p>Keyboard and announcement details for the next release.</p>
|
|
28
|
+
</article>
|
|
29
|
+
|
|
30
|
+
<article>
|
|
31
|
+
<h2>Change Freeze Window</h2>
|
|
32
|
+
<p>Friday deployment holds and rollback owners are published for the April migration.</p>
|
|
33
|
+
</article>
|
|
34
|
+
|
|
35
|
+
<article>
|
|
36
|
+
<h2>Signup Funnel Feedback</h2>
|
|
37
|
+
<p>Eight research sessions highlighted friction around plan limits and account handoff.</p>
|
|
38
|
+
</article>
|
|
39
|
+
|
|
40
|
+
<article>
|
|
41
|
+
<h2>Tokens</h2>
|
|
42
|
+
<p>New surface and border tokens for interaction states.</p>
|
|
43
|
+
</article>
|
|
44
|
+
|
|
45
|
+
<article>
|
|
46
|
+
<h2>Migration Guides Updated</h2>
|
|
47
|
+
<p>Upgrade notes now include copy-paste examples and QA checklists for current component consumers.</p>
|
|
48
|
+
</article>
|
|
49
|
+
|
|
50
|
+
<article>
|
|
51
|
+
<h2>Bundle Audit Review</h2>
|
|
52
|
+
<p>A new audit flags duplicate helper code across package entry points and the next trim targets.</p>
|
|
53
|
+
</article>
|
|
54
|
+
|
|
55
|
+
<article>
|
|
56
|
+
<h2>Permission Model Review</h2>
|
|
57
|
+
<p>Security notes now spell out which stories need elevated browser APIs and which ones stay sandbox-safe.</p>
|
|
58
|
+
</article>
|
|
59
|
+
|
|
60
|
+
<article>
|
|
61
|
+
<h2>Pilot Teams Onboarded</h2>
|
|
62
|
+
<p>Three product squads have moved their prototypes onto the package and started filing integration feedback.</p>
|
|
63
|
+
</article>
|
|
64
|
+
|
|
65
|
+
<article>
|
|
66
|
+
<h2>Regression Sweep</h2>
|
|
67
|
+
<p>A targeted browser sweep caught contrast regressions, stale labels, and one broken close action before release cut.</p>
|
|
68
|
+
</article>
|
|
69
|
+
</div>
|
|
70
|
+
</basic-carousel>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Import the optional starter carousel CSS when you want native `::scroll-button()` and `::scroll-marker` controls where the browser supports them.
|
|
74
|
+
|
|
75
|
+
## Props
|
|
76
|
+
|
|
77
|
+
| Prop | Description | Type | Default | Options |
|
|
78
|
+
| --- | --- | --- | --- | --- |
|
|
79
|
+
| `data-label` | Accessible name for the carousel region when no own `aria-label` or `aria-labelledby` is present. | string | `Carousel` | any string |
|
|
80
|
+
| `data-controls` | Chooses which native scroll controls to expose when the browser supports them. | string | `both` | `both`, `markers`, `arrows`, `none` |
|
|
81
|
+
| `data-snapping` | Chooses where each slide snaps within the scrollport and where `scrollToItem()` aligns it. | string | `center` | `start`, `center`, `end` |
|
|
82
|
+
|
|
83
|
+
## Markup Hooks
|
|
84
|
+
|
|
85
|
+
| Hook | Description | Type | Default | Options |
|
|
86
|
+
| --- | --- | --- | --- | --- |
|
|
87
|
+
| `data-carousel-track` | Marks the scroll container that owns the slides and native CSS controls. | descendant container attribute | required | present on one descendant scroll container |
|
|
88
|
+
| direct children of `[data-carousel-track]` | Each direct child becomes one carousel slide and one generated scroll marker. | descendant item | required | present on one or more direct child elements |
|
|
89
|
+
| `data-carousel-marker-label` | Optional per-slide label used for the generated marker's accessible name. | string attribute on a direct slide child | auto-generated | any string |
|
|
90
|
+
|
|
91
|
+
## Behavior
|
|
92
|
+
|
|
93
|
+
- Applies `role="region"` and a fallback accessible label on the root element
|
|
94
|
+
- Normalizes `data-controls` to `both`, `markers`, `arrows`, or `none`
|
|
95
|
+
- Normalizes `data-snapping` to `start`, `center`, or `end`
|
|
96
|
+
- Annotates each slide with generated marker text and marker labels for CSS `content: attr(...)`
|
|
97
|
+
- Exposes `refresh()` when the slide structure changes after connection
|
|
98
|
+
- Exposes `scrollToItem(index, options)` for programmatic navigation
|
|
99
|
+
|
|
100
|
+
## Markup Contract
|
|
101
|
+
|
|
102
|
+
- Provide one descendant element with `data-carousel-track`
|
|
103
|
+
- Keep each slide as a direct child of that track
|
|
104
|
+
- Add your own slide semantics inside each item, for example `article`, `li`, or `section`
|
|
105
|
+
- Use `data-controls="none"` for no generated controls, `data-controls="markers"` for numbered markers only, `data-controls="arrows"` for arrows only, or `data-controls="both"` for both controls
|
|
106
|
+
- Use `data-snapping="start"`, `center`, or `end` to align the active slide consistently
|
|
107
|
+
- Import starter CSS if you want generated scroll buttons and scroll markers
|
|
108
|
+
- Browsers without native scroll controls still get the scroll-snap track and manual scrolling
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const CAROUSEL_TAG_NAME: "basic-carousel";
|
|
2
|
+
export type CarouselControls = "both" | "markers" | "arrows" | "none";
|
|
3
|
+
export type CarouselSnapping = "start" | "center" | "end";
|
|
4
|
+
|
|
5
|
+
export interface CarouselScrollOptions {
|
|
6
|
+
behavior?: ScrollBehavior | null;
|
|
7
|
+
snapping?: CarouselSnapping | string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalizes unsupported or empty labels back to the default `"Carousel"`.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeCarouselLabel(
|
|
14
|
+
value?: string | null,
|
|
15
|
+
): string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes carousel controls to `"both"`, `"markers"`, `"arrows"`, or `"none"`.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeCarouselControls(
|
|
21
|
+
value?: string | null,
|
|
22
|
+
): CarouselControls;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalizes scroll behaviors to `"auto"` or `"smooth"`.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeCarouselScrollBehavior(
|
|
28
|
+
value?: string | null,
|
|
29
|
+
): ScrollBehavior;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Normalizes snap positions to `"start"`, `"center"`, or `"end"`.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeCarouselSnapping(
|
|
35
|
+
value?: string | null,
|
|
36
|
+
): CarouselSnapping;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Clamps a requested item index into the available carousel range.
|
|
40
|
+
*/
|
|
41
|
+
export function clampCarouselIndex(
|
|
42
|
+
index: number,
|
|
43
|
+
itemCount: number,
|
|
44
|
+
): number;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Custom element that upgrades a scroll-snap track into a named carousel region
|
|
48
|
+
* and annotates each slide for CSS-native scroll buttons and markers.
|
|
49
|
+
*
|
|
50
|
+
* Attributes:
|
|
51
|
+
* - `data-label`: fallback accessible name for the carousel region
|
|
52
|
+
* - `data-controls`: show generated markers, arrows, both, or no generated controls where supported
|
|
53
|
+
* - `data-snapping`: align slides to the start, center, or end of the scrollport
|
|
54
|
+
*
|
|
55
|
+
* Descendant hooks:
|
|
56
|
+
* - one `[data-carousel-track]` scroll container
|
|
57
|
+
* - direct child items inside that track
|
|
58
|
+
* - optional `data-carousel-marker-label` on each item for custom marker names
|
|
59
|
+
*/
|
|
60
|
+
export class CarouselElement extends HTMLElement {
|
|
61
|
+
static observedAttributes: string[];
|
|
62
|
+
get track(): HTMLElement | null;
|
|
63
|
+
get items(): HTMLElement[];
|
|
64
|
+
refresh(): number;
|
|
65
|
+
scrollToItem(index: number, options?: CarouselScrollOptions): boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Registers the `basic-carousel` custom element if it is not already defined.
|
|
70
|
+
*/
|
|
71
|
+
export function defineCarousel(
|
|
72
|
+
registry?: CustomElementRegistry,
|
|
73
|
+
): typeof CarouselElement;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
const HTMLElementBase = globalThis.HTMLElement ?? class {};
|
|
2
|
+
|
|
3
|
+
export const CAROUSEL_TAG_NAME = "basic-carousel";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LABEL = "Carousel";
|
|
6
|
+
const DEFAULT_CONTROLS = "both";
|
|
7
|
+
const DEFAULT_SNAPPING = "center";
|
|
8
|
+
const TRACK_SELECTOR = "[data-carousel-track]";
|
|
9
|
+
const MANAGED_LABEL_ATTRIBUTE = "data-basic-carousel-managed-label";
|
|
10
|
+
const MANAGED_READY_ATTRIBUTE = "data-basic-carousel-ready";
|
|
11
|
+
|
|
12
|
+
let nextCarouselInstanceId = 1;
|
|
13
|
+
|
|
14
|
+
function collectOwnedElements(root, scope, selector) {
|
|
15
|
+
return Array.from(scope.querySelectorAll(selector)).filter(
|
|
16
|
+
(element) => element instanceof HTMLElementBase && element.closest(CAROUSEL_TAG_NAME) === root,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectCarouselItems(track) {
|
|
21
|
+
return Array.from(track.children).filter(
|
|
22
|
+
(element) => element instanceof HTMLElementBase,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeMarkerLabel(value, index, total) {
|
|
27
|
+
const normalized = value?.trim();
|
|
28
|
+
|
|
29
|
+
if (normalized) {
|
|
30
|
+
return normalized;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `Go to slide ${index} of ${total}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeCarouselLabel(value) {
|
|
37
|
+
return value?.trim() || DEFAULT_LABEL;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeCarouselControls(value) {
|
|
41
|
+
const normalized = value?.trim().toLowerCase();
|
|
42
|
+
|
|
43
|
+
if (normalized === "none") {
|
|
44
|
+
return "none";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (
|
|
48
|
+
normalized === "markers"
|
|
49
|
+
|| normalized === "numbers"
|
|
50
|
+
|| normalized === "number-buttons"
|
|
51
|
+
) {
|
|
52
|
+
return "markers";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (normalized === "arrows") {
|
|
56
|
+
return "arrows";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return DEFAULT_CONTROLS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeCarouselScrollBehavior(value) {
|
|
63
|
+
const normalized = value?.trim().toLowerCase();
|
|
64
|
+
|
|
65
|
+
return normalized === "smooth" ? "smooth" : "auto";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizeCarouselSnapping(value) {
|
|
69
|
+
const normalized = value?.trim().toLowerCase();
|
|
70
|
+
|
|
71
|
+
if (normalized === "start" || normalized === "end") {
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return DEFAULT_SNAPPING;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function clampCarouselIndex(index, itemCount) {
|
|
79
|
+
if (!Number.isInteger(index) || itemCount <= 0) {
|
|
80
|
+
return -1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Math.min(Math.max(index, 0), itemCount - 1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clampScrollOffset(offset, maxOffset) {
|
|
87
|
+
return Math.min(Math.max(offset, 0), Math.max(maxOffset, 0));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getItemInlineMetrics(track, item) {
|
|
91
|
+
const trackRect = track.getBoundingClientRect?.();
|
|
92
|
+
const itemRect = item.getBoundingClientRect?.();
|
|
93
|
+
|
|
94
|
+
if (trackRect?.width > 0 && itemRect?.width > 0) {
|
|
95
|
+
const start = itemRect.left - trackRect.left + track.scrollLeft;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
start,
|
|
99
|
+
width: itemRect.width,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
start: item.offsetLeft || 0,
|
|
105
|
+
width: item.offsetWidth || track.clientWidth || 0,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getTrackViewportWidth(track) {
|
|
110
|
+
return track.clientWidth || track.getBoundingClientRect?.().width || 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getScrollOffsetForItem(track, item, snapping) {
|
|
114
|
+
const viewportWidth = getTrackViewportWidth(track);
|
|
115
|
+
const { start, width } = getItemInlineMetrics(track, item);
|
|
116
|
+
|
|
117
|
+
if (snapping === "end") {
|
|
118
|
+
return start - (viewportWidth - width);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (snapping === "center") {
|
|
122
|
+
return start - ((viewportWidth - width) / 2);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return start;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class CarouselElement extends HTMLElementBase {
|
|
129
|
+
static observedAttributes = ["data-controls", "data-label", "data-snapping"];
|
|
130
|
+
|
|
131
|
+
#instanceId = `${CAROUSEL_TAG_NAME}-${nextCarouselInstanceId++}`;
|
|
132
|
+
#track = null;
|
|
133
|
+
#items = [];
|
|
134
|
+
|
|
135
|
+
connectedCallback() {
|
|
136
|
+
this.refresh();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
attributeChangedCallback() {
|
|
140
|
+
this.#applyState();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get track() {
|
|
144
|
+
return this.#track;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get items() {
|
|
148
|
+
return [...this.#items];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
refresh() {
|
|
152
|
+
this.#track = collectOwnedElements(this, this, TRACK_SELECTOR)[0] ?? null;
|
|
153
|
+
this.#items = this.#track ? collectCarouselItems(this.#track) : [];
|
|
154
|
+
this.#applyState();
|
|
155
|
+
return this.#items.length;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
scrollToItem(index, options = {}) {
|
|
159
|
+
const nextIndex = clampCarouselIndex(index, this.#items.length);
|
|
160
|
+
const item = nextIndex === -1 ? null : this.#items[nextIndex];
|
|
161
|
+
const track = this.#track;
|
|
162
|
+
|
|
163
|
+
if (!(item instanceof HTMLElementBase) || !(track instanceof HTMLElementBase)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const behavior = normalizeCarouselScrollBehavior(options.behavior);
|
|
168
|
+
const snapping = normalizeCarouselSnapping(options.snapping ?? this.getAttribute("data-snapping"));
|
|
169
|
+
const viewportWidth = getTrackViewportWidth(track);
|
|
170
|
+
const maxOffset = Math.max(track.scrollWidth - viewportWidth, 0);
|
|
171
|
+
const left = clampScrollOffset(getScrollOffsetForItem(track, item, snapping), maxOffset);
|
|
172
|
+
|
|
173
|
+
if (typeof track.scrollTo === "function") {
|
|
174
|
+
track.scrollTo({
|
|
175
|
+
left,
|
|
176
|
+
behavior,
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
track.scrollLeft = left;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#applyState() {
|
|
186
|
+
const baseId = this.id || this.#instanceId;
|
|
187
|
+
const controls = normalizeCarouselControls(this.getAttribute("data-controls"));
|
|
188
|
+
const snapping = normalizeCarouselSnapping(this.getAttribute("data-snapping"));
|
|
189
|
+
|
|
190
|
+
if (this.#track instanceof HTMLElementBase && !this.#track.id) {
|
|
191
|
+
this.#track.id = `${baseId}-track`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.dataset.basicCarouselControls = controls;
|
|
195
|
+
this.dataset.basicCarouselSnapping = snapping;
|
|
196
|
+
this.toggleAttribute(MANAGED_READY_ATTRIBUTE, this.#track instanceof HTMLElementBase && this.#items.length > 0);
|
|
197
|
+
this.setAttribute("role", "region");
|
|
198
|
+
this.#syncAccessibleLabel();
|
|
199
|
+
|
|
200
|
+
const total = this.#items.length;
|
|
201
|
+
|
|
202
|
+
for (const [index, item] of this.#items.entries()) {
|
|
203
|
+
item.dataset.basicCarouselMarker = String(index + 1);
|
|
204
|
+
item.dataset.basicCarouselMarkerLabel = normalizeMarkerLabel(
|
|
205
|
+
item.getAttribute("data-carousel-marker-label"),
|
|
206
|
+
index + 1,
|
|
207
|
+
total,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (!item.id) {
|
|
211
|
+
item.id = `${baseId}-item-${index + 1}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#syncAccessibleLabel() {
|
|
217
|
+
const nextLabel = normalizeCarouselLabel(this.getAttribute("data-label"));
|
|
218
|
+
const hasManagedLabel = this.hasAttribute(MANAGED_LABEL_ATTRIBUTE);
|
|
219
|
+
|
|
220
|
+
if (hasManagedLabel && this.getAttribute("aria-label") !== nextLabel) {
|
|
221
|
+
this.removeAttribute("aria-label");
|
|
222
|
+
this.removeAttribute(MANAGED_LABEL_ATTRIBUTE);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (this.hasAttribute("aria-labelledby")) {
|
|
226
|
+
if (hasManagedLabel) {
|
|
227
|
+
this.removeAttribute("aria-label");
|
|
228
|
+
this.removeAttribute(MANAGED_LABEL_ATTRIBUTE);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const hasOwnAriaLabel = this.hasAttribute("aria-label") && !hasManagedLabel;
|
|
235
|
+
|
|
236
|
+
if (hasOwnAriaLabel) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.setAttribute("aria-label", nextLabel);
|
|
241
|
+
this.setAttribute(MANAGED_LABEL_ATTRIBUTE, "");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function defineCarousel(registry = globalThis.customElements) {
|
|
246
|
+
if (!registry?.get || !registry?.define) {
|
|
247
|
+
return CarouselElement;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!registry.get(CAROUSEL_TAG_NAME)) {
|
|
251
|
+
registry.define(CAROUSEL_TAG_NAME, CarouselElement);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return CarouselElement;
|
|
255
|
+
}
|