srcdev-nuxt-components 9.6.13 → 9.6.15
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/.claude/rules/consumer/story-register-components.md +1 -1
- package/.claude/skills/component-compliance-checklist.md +5 -0
- package/.claude/skills/components/display-qr-code.md +22 -4
- package/.claude/skills/components/eyebrow-text.md +9 -5
- package/.claude/skills/components/header-block.md +13 -5
- package/.claude/skills/components/hero-text.md +13 -9
- package/.claude/skills/components/link-text.md +8 -5
- package/.claude/skills/components/pop-over.md +17 -3
- package/.claude/skills/components/sliding-toggle.md +108 -0
- package/.claude/skills/components/tabbed-content.md +8 -3
- package/.claude/skills/composable-sliding-indicator.md +60 -0
- package/.claude/skills/index.md +2 -0
- package/.claude/skills/storybook-add-story.md +22 -2
- package/.claude/skills/testing-add-playwright.md +12 -7
- package/.claude/skills/testing-add-unit-test.md +25 -0
- package/.claude/skills/theming-component-token-pattern.md +18 -0
- package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
- package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
- package/.vscode/srcdev-component-link-text.code-snippets +3 -3
- package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
- package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
- package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
- package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
- package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
- package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
- package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
- package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
- package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
- package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
- package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
- package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
- package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
- package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
- package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
- package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
- package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
- package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
- package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
- package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
- package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
- package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
- package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
- package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
- package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
- package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
- package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
- package/app/composables/useSlidingIndicator.ts +56 -0
- package/app/composables/useTabs.ts +4 -31
- package/app/types/components/index.ts +1 -0
- package/app/types/components/sliding-toggle.d.ts +5 -0
- package/package.json +1 -1
|
@@ -396,6 +396,31 @@ afterEach(() => {
|
|
|
396
396
|
The `as unknown as Record<string, unknown>` double-cast is required because TypeScript's
|
|
397
397
|
`HTMLElement` type has no index signature — cast through `unknown` first.
|
|
398
398
|
|
|
399
|
+
## A mount helper that keeps `v-model` in sync
|
|
400
|
+
|
|
401
|
+
To test a `v-model` component through clicks and keys, feed `update:modelValue` back into the
|
|
402
|
+
props. The handler needs the wrapper it's creating, so hold it in a `const` object: a self-referencing
|
|
403
|
+
`const wrapper` is an implicit `any` under strict mode, and `let wrapper` assigned once fails
|
|
404
|
+
ESLint's `prefer-const`.
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
const mountToggle = async (props: Record<string, unknown> = {}) => {
|
|
408
|
+
const holder: { current?: Awaited<ReturnType<typeof mountSuspended>> } = {};
|
|
409
|
+
const onUpdate = (value: string | number) => {
|
|
410
|
+
void holder.current?.setProps({ modelValue: value });
|
|
411
|
+
};
|
|
412
|
+
const wrapper = await mountSuspended(SlidingToggle, {
|
|
413
|
+
props: { modelValue: "monthly", "onUpdate:modelValue": onUpdate, ...props },
|
|
414
|
+
});
|
|
415
|
+
holder.current = wrapper;
|
|
416
|
+
mounted.push(wrapper);
|
|
417
|
+
return wrapper;
|
|
418
|
+
};
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Type the handler's `value` as the model's full type (`string | number` for a generic model), not
|
|
422
|
+
the narrower one a test happens to use.
|
|
423
|
+
|
|
399
424
|
## Describe section conventions
|
|
400
425
|
|
|
401
426
|
Use these section names consistently so tests are easy to scan:
|
|
@@ -129,6 +129,24 @@ Prefer this over a prop: line count is presentation, and CSS handles it without
|
|
|
129
129
|
layout around it.
|
|
130
130
|
- Document it in `CONSUMER-STYLING.md` with its default and the `1`/`none` options.
|
|
131
131
|
|
|
132
|
+
## Corner shape tokens
|
|
133
|
+
|
|
134
|
+
`corner-shape` (CSS Borders 4: `squircle`, `superellipse(n)`, `bevel`, `scoop`, `notch`, default
|
|
135
|
+
`round`) reshapes the curve that `border-radius` draws. Give a component that wants it one public
|
|
136
|
+
token, `--<component>-corner-shape`, defaulting to `round`, and read it on every element that has a
|
|
137
|
+
radius so they stay in step:
|
|
138
|
+
|
|
139
|
+
```css
|
|
140
|
+
.sliding-toggle,
|
|
141
|
+
.sliding-toggle-option {
|
|
142
|
+
corner-shape: var(--sliding-toggle-corner-shape, round);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It's a progressive enhancement (Chromium 139+ when added on 2026-10-19): other browsers drop the
|
|
147
|
+
declaration and keep the `border-radius` corners, so never rely on it for layout or meaning. Label
|
|
148
|
+
it as Chromium-only in `CONSUMER-STYLING.md`. `SlidingToggle` is the first user.
|
|
149
|
+
|
|
132
150
|
## Text over an image: scrim tokens
|
|
133
151
|
|
|
134
152
|
Any component that puts text over a photo (a header in a picture, a card title on its image) gets
|
|
@@ -15,8 +15,19 @@
|
|
|
15
15
|
" aria-label=\"$2\"",
|
|
16
16
|
" black-color=\"${3:currentColor}\"",
|
|
17
17
|
" :radius=\"${4:0.5}\"",
|
|
18
|
-
" :variant=\"{ inner: '${5|default,circle,rounded,dots,
|
|
18
|
+
" :variant=\"{ inner: '${5|default,circle,rounded,dots,pixelated|}', marker: '${6|default,circle,rounded,dots,pixelated|}', pixel: '${7|default,circle,rounded,dots,pixelated|}' }\"",
|
|
19
19
|
"/>"
|
|
20
20
|
]
|
|
21
|
+
},
|
|
22
|
+
"SRCDEV DisplayQrCode Fallback": {
|
|
23
|
+
"description": "DisplayQrCode with a fallback for values too long to encode",
|
|
24
|
+
"scope": "vue,html",
|
|
25
|
+
"body": [
|
|
26
|
+
"<DisplayQrCode :qr-value=\"${1:url}\" aria-label=\"${2:QR code}\" @error=\"${3:onQrError}\">",
|
|
27
|
+
" <template #fallback>",
|
|
28
|
+
" $0",
|
|
29
|
+
" </template>",
|
|
30
|
+
"</DisplayQrCode>"
|
|
31
|
+
]
|
|
21
32
|
}
|
|
22
33
|
}
|
|
@@ -4,11 +4,27 @@
|
|
|
4
4
|
"scope": "vue,html",
|
|
5
5
|
"body": [
|
|
6
6
|
"<HeroText",
|
|
7
|
-
" tag=\"$
|
|
8
|
-
"
|
|
9
|
-
" :
|
|
10
|
-
" { text: '$
|
|
11
|
-
" { text: '$
|
|
7
|
+
" tag=\"${1|h1,h2,h3,h4,h5,h6|}\"",
|
|
8
|
+
" font-size=\"${2|title,display,heading,subheading,label|}\"",
|
|
9
|
+
" :text-content=\"[",
|
|
10
|
+
" { text: '${3:Designing}', styleClass: 'normal' },",
|
|
11
|
+
" { text: '${4:Artistry}', styleClass: 'accent' },",
|
|
12
|
+
" ]\"",
|
|
13
|
+
"/>"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"SRCDEV HeroText Vertical With Icon": {
|
|
17
|
+
"description": "HeroText stacked vertically with a decorative icon",
|
|
18
|
+
"scope": "vue,html",
|
|
19
|
+
"body": [
|
|
20
|
+
"<HeroText",
|
|
21
|
+
" tag=\"${1|h2,h1,h3,h4,h5,h6|}\"",
|
|
22
|
+
" axis=\"vertical\"",
|
|
23
|
+
" icon=\"${2:lucide:sparkles}\"",
|
|
24
|
+
" font-size=\"${3|display,title,heading,subheading,label|}\"",
|
|
25
|
+
" :text-content=\"[",
|
|
26
|
+
" { text: '${4:Modern}', styleClass: 'normal' },",
|
|
27
|
+
" { text: '${5:Living}', styleClass: 'accent' },",
|
|
12
28
|
" ]\"",
|
|
13
29
|
"/>"
|
|
14
30
|
]
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
"description": "CSS override scaffold for LinkText tokens",
|
|
20
20
|
"scope": "css",
|
|
21
21
|
"body": [
|
|
22
|
-
".$
|
|
22
|
+
".${1:my-page} {",
|
|
23
23
|
" --link-text-colour: $2;",
|
|
24
24
|
" --link-text-colour-hover: $3;",
|
|
25
|
-
" --link-text-decoration: $
|
|
26
|
-
" --link-text-decoration-hover: $
|
|
25
|
+
" --link-text-decoration: ${4:underline};",
|
|
26
|
+
" --link-text-decoration-hover: ${5:none};",
|
|
27
27
|
"}"
|
|
28
28
|
]
|
|
29
29
|
}
|
|
@@ -40,5 +40,33 @@
|
|
|
40
40
|
" </template>",
|
|
41
41
|
"</PopOver>"
|
|
42
42
|
]
|
|
43
|
+
},
|
|
44
|
+
"SRCDEV PopOver Localised Close": {
|
|
45
|
+
"description": "PopOver with a localised close label and a custom close icon",
|
|
46
|
+
"scope": "vue,html",
|
|
47
|
+
"body": [
|
|
48
|
+
"<PopOver close-button-aria-label=\"${1:Close}\" close-icon=\"${2:lucide:x}\">",
|
|
49
|
+
" <template #trigger>",
|
|
50
|
+
" <span>$3</span>",
|
|
51
|
+
" </template>",
|
|
52
|
+
" <template #content>",
|
|
53
|
+
" <p>$0</p>",
|
|
54
|
+
" </template>",
|
|
55
|
+
"</PopOver>"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"SRCDEV PopOver CSS Overrides": {
|
|
59
|
+
"description": "Unlayered CSS override scaffold for PopOver public tokens",
|
|
60
|
+
"scope": "css,vue",
|
|
61
|
+
"body": [
|
|
62
|
+
".${1:my-section} {",
|
|
63
|
+
" --pop-over-width: ${2:30rem};",
|
|
64
|
+
" --pop-over-gap: ${3:1rem};",
|
|
65
|
+
" --pop-over-background-colour: ${4:var(--slate-00)};",
|
|
66
|
+
" --pop-over-text-colour: ${5:var(--slate-09)};",
|
|
67
|
+
" --pop-over-border-colour: ${6:var(--slate-02)};",
|
|
68
|
+
" --pop-over-content-padding: ${7:1.6rem};$0",
|
|
69
|
+
"}"
|
|
70
|
+
]
|
|
43
71
|
}
|
|
44
72
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"SRCDEV SlidingToggle": {
|
|
3
|
+
"description": "SlidingToggle — single-choice segmented radiogroup with a sliding indicator",
|
|
4
|
+
"scope": "vue,html",
|
|
5
|
+
"body": [
|
|
6
|
+
"<SlidingToggle",
|
|
7
|
+
" v-model=\"${1:selected}\"",
|
|
8
|
+
" :options=\"[",
|
|
9
|
+
" { value: '${2:monthly}', label: '${3:Monthly}' },",
|
|
10
|
+
" { value: '${4:yearly}', label: '${5:Yearly}' },",
|
|
11
|
+
" ]\"",
|
|
12
|
+
" aria-label=\"${6:Billing period}\"",
|
|
13
|
+
"/>"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"SRCDEV SlidingToggle Options Variable": {
|
|
17
|
+
"description": "SlidingToggle bound to an options array, without hover tracking",
|
|
18
|
+
"scope": "vue,html",
|
|
19
|
+
"body": [
|
|
20
|
+
"<SlidingToggle v-model=\"${1:selected}\" :options=\"${2:options}\" aria-label=\"${3:Label}\"${4: :track-hover=\"false\"} />"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"SRCDEV SlidingToggle CSS Override": {
|
|
24
|
+
"description": "Unlayered CSS override scaffold for SlidingToggle tokens",
|
|
25
|
+
"scope": "css,vue",
|
|
26
|
+
"body": [
|
|
27
|
+
".${1:my-section} {",
|
|
28
|
+
" --sliding-toggle-surface: ${2:var(--theme-surface-subtle)};",
|
|
29
|
+
" --sliding-toggle-indicator-surface: ${3:var(--theme-on-surface)};",
|
|
30
|
+
" --sliding-toggle-marker-colour: ${4:var(--theme-accent)};",
|
|
31
|
+
" --sliding-toggle-option-flex: ${5|0 1 auto,1 1 0|};",
|
|
32
|
+
" --sliding-toggle-corner-shape: ${6|round,squircle,superellipse(3),bevel,scoop,notch|};",
|
|
33
|
+
" --sliding-toggle-indicator-stretch: ${7|1,0|};$0",
|
|
34
|
+
"}"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
" --tabs-underline-indicator-height: ;",
|
|
59
59
|
" --tabs-hover-underline-indicator-colour: ;",
|
|
60
60
|
" --tabs-hover-underline-indicator-height: ;",
|
|
61
|
-
" --tabs-
|
|
61
|
+
" --tabs-indicator-stretch: ;",
|
|
62
62
|
" --tabs-list-item-opacity: ;",
|
|
63
63
|
" --tabs-list-item-colour: ;",
|
|
64
64
|
" --tabs-list-item-colour-selected: ;",
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
| `--pop-over-gap` | `1rem` | Space between the trigger and the popover panel |
|
|
8
8
|
| `--pop-over-trigger-outline-width` | `0.1rem` | Trigger focus outline width |
|
|
9
9
|
| `--pop-over-trigger-outline-colour-hover` | `var(--theme-ring)` | Trigger outline colour on focus-visible |
|
|
10
|
-
| `--pop-over-width` | `30rem` | Popover panel width |
|
|
10
|
+
| `--pop-over-width` | `30rem` | Popover panel width, capped at the viewport width minus `3.2rem` |
|
|
11
|
+
| `--pop-over-max-block-size` | `calc(100dvh - 3.2rem)` | Popover panel maximum height; taller content scrolls inside the panel |
|
|
11
12
|
| `--pop-over-border-width` | `0.1rem` | Popover panel border width |
|
|
12
13
|
| `--pop-over-border-colour` | `var(--slate-02)` | Popover panel border colour |
|
|
13
14
|
| `--pop-over-border-radius` | `0.8rem` | Popover panel corner radius |
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
| `--pop-over-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
|
|
19
20
|
| `--pop-over-content-padding` | `1.6rem` | Padding around the `content` slot |
|
|
20
21
|
| `--pop-over-content-padding-block-start` | `3.2rem` | Extra top padding so content clears the close button |
|
|
21
|
-
| `--pop-over-close-button-offset` | `0.8rem` | Distance of the close button from the panel's top
|
|
22
|
+
| `--pop-over-close-button-offset` | `0.8rem` | Distance of the close button from the panel's block-start and inline-end edges (top right in left-to-right text, top left in right-to-left) |
|
|
22
23
|
| `--pop-over-close-button-size` | `2.4rem` | Close button box width/height |
|
|
23
24
|
| `--pop-over-close-button-border-radius` | `50%` | Close button corner radius |
|
|
24
25
|
| `--pop-over-close-button-colour` | `var(--slate-06)` | Close button icon colour |
|
|
@@ -27,6 +28,41 @@
|
|
|
27
28
|
| `--pop-over-close-button-surface-hover` | `var(--slate-01)` | Close button background on hover/focus-visible |
|
|
28
29
|
| `--pop-over-close-button-outline-colour-hover` | `var(--theme-ring)` | Close button outline colour on hover/focus-visible |
|
|
29
30
|
|
|
31
|
+
Private tokens (not public API): `--_anchor-name` (the generated CSS anchor name), and `--_anchor-top`, `--_anchor-right`, `--_anchor-bottom`, `--_anchor-left`, and their `-inverse` forms (measured trigger edges, set by `useAnchoredPopover` only where CSS anchor positioning is unsupported).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## State hooks
|
|
36
|
+
|
|
37
|
+
| Hook | Where | Meaning |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `[data-placement]` | `.pop-over` root | The `placement` prop: `top`, `right`, `bottom` or `left` |
|
|
40
|
+
| `[data-placement]` | `.pop-over-popover` | The resolved side, which can be the opposite of the prop when there is no room (only set where CSS anchor positioning is unsupported) |
|
|
41
|
+
| `:popover-open` | `.pop-over-popover` | Panel open, where the Popover API exists |
|
|
42
|
+
| `.pop-over-popover-open` | `.pop-over-popover` | Panel open on Safari 16 (no Popover API). Style it in a separate rule from `:popover-open` |
|
|
43
|
+
| `[role="dialog"]` | `.pop-over-popover` | Present only when `popoverAriaLabel` is set |
|
|
44
|
+
|
|
45
|
+
Inner class names: `.pop-over-trigger` (the trigger `<button>`), `.pop-over-popover` (the panel),
|
|
46
|
+
`.pop-over-close-button` (an `InputButton variant="inline"`, so also `.input-button`),
|
|
47
|
+
`.pop-over-close-button-icon`, `.pop-over-content` (wraps the `content` slot).
|
|
48
|
+
|
|
49
|
+
> Changed 2026-10-19: the close button is now an `InputButton` (`variant="inline"`, icon-only, with
|
|
50
|
+
> the `closeButtonAriaLabel` text visually hidden) instead of a raw `<button>`, and sits at the
|
|
51
|
+
> inline end, so it moves to the left in right-to-left text. The panel now caps at the viewport
|
|
52
|
+
> width and height and wraps unbroken strings.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Local overrides
|
|
57
|
+
|
|
58
|
+
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
59
|
+
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
60
|
+
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
61
|
+
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
62
|
+
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
63
|
+
|
|
64
|
+
### Page or section
|
|
65
|
+
|
|
30
66
|
```css
|
|
31
67
|
.my-page {
|
|
32
68
|
--pop-over-background-colour: #1a1a1a;
|
|
@@ -35,7 +71,7 @@
|
|
|
35
71
|
}
|
|
36
72
|
```
|
|
37
73
|
|
|
38
|
-
|
|
74
|
+
### One instance
|
|
39
75
|
|
|
40
76
|
```vue
|
|
41
77
|
<PopOver style-class-passthrough="promo-pop-over">...</PopOver>
|
|
@@ -43,13 +79,10 @@ Or scope to a single instance via `styleClassPassthrough`:
|
|
|
43
79
|
|
|
44
80
|
---
|
|
45
81
|
|
|
46
|
-
##
|
|
82
|
+
## Class passthrough
|
|
47
83
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
51
|
-
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
52
|
-
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
84
|
+
`style-class-passthrough` adds classes to the `.pop-over` root, which wraps both the trigger and the
|
|
85
|
+
panel, so tokens set on that class reach the panel too.
|
|
53
86
|
|
|
54
87
|
---
|
|
55
88
|
|
|
@@ -28,21 +28,25 @@
|
|
|
28
28
|
:class="{ 'pop-over-popover-open': usesFallbackPopover && isOpen }"
|
|
29
29
|
:style="positionStyle"
|
|
30
30
|
:data-placement="popoverPlacement"
|
|
31
|
+
:role="popoverAriaLabel ? 'dialog' : undefined"
|
|
31
32
|
:aria-label="popoverAriaLabel || undefined"
|
|
32
33
|
@beforetoggle="handleBeforeToggle"
|
|
33
34
|
@toggle="handleToggle"
|
|
34
35
|
>
|
|
35
|
-
<
|
|
36
|
-
|
|
36
|
+
<InputButton
|
|
37
|
+
type="button"
|
|
38
|
+
variant="inline"
|
|
37
39
|
:popovertarget="popoverId"
|
|
38
40
|
popovertargetaction="hide"
|
|
39
|
-
|
|
40
|
-
class="pop-over-close-button"
|
|
41
|
+
:button-text="closeButtonAriaLabel"
|
|
41
42
|
:aria-label="closeButtonAriaLabel"
|
|
43
|
+
style-class-passthrough="pop-over-close-button"
|
|
42
44
|
@click="hide"
|
|
43
45
|
>
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
+
<template #iconOnly>
|
|
47
|
+
<Icon :name="closeIcon" class="pop-over-close-button-icon" aria-hidden="true" />
|
|
48
|
+
</template>
|
|
49
|
+
</InputButton>
|
|
46
50
|
<div class="pop-over-content">
|
|
47
51
|
<slot name="content"></slot>
|
|
48
52
|
</div>
|
|
@@ -56,10 +60,12 @@ interface Props {
|
|
|
56
60
|
placement?: "top" | "right" | "bottom" | "left";
|
|
57
61
|
/** aria-label on the trigger button — set this when the trigger slot is icon-only. */
|
|
58
62
|
triggerAriaLabel?: string;
|
|
59
|
-
/**
|
|
63
|
+
/** Names the panel and gives it role="dialog"; set this when the content slot has no visible heading. */
|
|
60
64
|
popoverAriaLabel?: string;
|
|
61
65
|
/** aria-label on the close button — override for localisation. */
|
|
62
66
|
closeButtonAriaLabel?: string;
|
|
67
|
+
/** Iconify name for the close button icon. */
|
|
68
|
+
closeIcon?: string;
|
|
63
69
|
styleClassPassthrough?: string | string[];
|
|
64
70
|
}
|
|
65
71
|
|
|
@@ -68,6 +74,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
68
74
|
triggerAriaLabel: "",
|
|
69
75
|
popoverAriaLabel: "",
|
|
70
76
|
closeButtonAriaLabel: "Close",
|
|
77
|
+
closeIcon: "lucide:x",
|
|
71
78
|
styleClassPassthrough: () => [],
|
|
72
79
|
});
|
|
73
80
|
|
|
@@ -78,7 +85,6 @@ const anchorName = `--pop-over-anchor-${id}`;
|
|
|
78
85
|
const rootRef = ref<HTMLDivElement | null>(null);
|
|
79
86
|
const triggerRef = ref<HTMLButtonElement | null>(null);
|
|
80
87
|
const popoverRef = ref<HTMLDivElement | null>(null);
|
|
81
|
-
const closeButtonRef = ref<HTMLButtonElement | null>(null);
|
|
82
88
|
|
|
83
89
|
// Focus moves to the close button on open; closing returns it to the trigger in every mode.
|
|
84
90
|
const {
|
|
@@ -95,7 +101,7 @@ const {
|
|
|
95
101
|
triggerRef,
|
|
96
102
|
popoverRef,
|
|
97
103
|
side: () => props.placement,
|
|
98
|
-
onOpen: () =>
|
|
104
|
+
onOpen: () => popoverRef.value?.querySelector<HTMLElement>(".pop-over-close-button")?.focus(),
|
|
99
105
|
});
|
|
100
106
|
|
|
101
107
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
@@ -113,11 +119,14 @@ watch(
|
|
|
113
119
|
.pop-over {
|
|
114
120
|
display: inline-block;
|
|
115
121
|
position: relative;
|
|
122
|
+
max-inline-size: 100%;
|
|
116
123
|
|
|
117
124
|
.pop-over-trigger {
|
|
118
125
|
all: unset;
|
|
119
126
|
cursor: pointer;
|
|
120
127
|
anchor-name: var(--_anchor-name);
|
|
128
|
+
max-inline-size: 100%;
|
|
129
|
+
overflow-wrap: anywhere;
|
|
121
130
|
outline: var(--pop-over-trigger-outline-width, 0.1rem) solid transparent;
|
|
122
131
|
|
|
123
132
|
&:focus-visible {
|
|
@@ -141,7 +150,10 @@ watch(
|
|
|
141
150
|
--pop-over-shadow,
|
|
142
151
|
0 0.4rem 1.6rem rgba(0, 0, 0, 0.12)
|
|
143
152
|
);
|
|
144
|
-
width: var(--pop-over-width, 30rem);
|
|
153
|
+
width: min(var(--pop-over-width, 30rem), calc(100vw - 3.2rem));
|
|
154
|
+
max-block-size: var(--pop-over-max-block-size, calc(100dvh - 3.2rem));
|
|
155
|
+
overflow-y: auto;
|
|
156
|
+
overflow-wrap: anywhere;
|
|
145
157
|
|
|
146
158
|
opacity: 0;
|
|
147
159
|
transition:
|
|
@@ -169,8 +181,8 @@ watch(
|
|
|
169
181
|
all: unset;
|
|
170
182
|
cursor: pointer;
|
|
171
183
|
position: absolute;
|
|
172
|
-
|
|
173
|
-
|
|
184
|
+
inset-block-start: var(--pop-over-close-button-offset, 0.8rem);
|
|
185
|
+
inset-inline-end: var(--pop-over-close-button-offset, 0.8rem);
|
|
174
186
|
display: grid;
|
|
175
187
|
place-items: center;
|
|
176
188
|
width: var(--pop-over-close-button-size, 2.4rem);
|
|
@@ -186,6 +198,10 @@ watch(
|
|
|
186
198
|
outline-offset: 0.1rem;
|
|
187
199
|
}
|
|
188
200
|
|
|
201
|
+
.input-button-icon {
|
|
202
|
+
margin: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
189
205
|
.pop-over-close-button-icon {
|
|
190
206
|
width: var(--pop-over-close-button-icon-size, 1.4rem);
|
|
191
207
|
height: var(--pop-over-close-button-icon-size, 1.4rem);
|
|
@@ -18,7 +18,7 @@ const meta: Meta<typeof PopOver> = {
|
|
|
18
18
|
},
|
|
19
19
|
popoverAriaLabel: {
|
|
20
20
|
control: "text",
|
|
21
|
-
description: "
|
|
21
|
+
description: "Names the popover panel and gives it role=\"dialog\"; set this when the content slot has no visible heading.",
|
|
22
22
|
table: { category: "Accessibility" },
|
|
23
23
|
},
|
|
24
24
|
closeButtonAriaLabel: {
|
|
@@ -26,17 +26,23 @@ const meta: Meta<typeof PopOver> = {
|
|
|
26
26
|
description: "aria-label on the close button — override for localisation.",
|
|
27
27
|
table: { category: "Accessibility" },
|
|
28
28
|
},
|
|
29
|
+
closeIcon: {
|
|
30
|
+
control: "text",
|
|
31
|
+
description: "Iconify name for the close button icon.",
|
|
32
|
+
table: { category: "Basic" },
|
|
33
|
+
},
|
|
29
34
|
styleClassPassthrough: { table: { disable: true } },
|
|
30
35
|
},
|
|
31
36
|
args: {
|
|
32
37
|
placement: "right",
|
|
33
38
|
closeButtonAriaLabel: "Close",
|
|
39
|
+
closeIcon: "lucide:x",
|
|
34
40
|
},
|
|
35
41
|
parameters: {
|
|
36
42
|
docs: {
|
|
37
43
|
description: {
|
|
38
44
|
component:
|
|
39
|
-
"A generic anchor-positioned disclosure panel
|
|
45
|
+
"A generic anchor-positioned disclosure panel built on the native Popover API and CSS anchor positioning, with fallbacks for Safari 16-18. The `trigger` and `content` slots are entirely consumer-supplied; `placement` picks the side the panel opens on (it flips when there is no room), and the close button is labelled by `closeButtonAriaLabel` with its icon set by `closeIcon`. Set `triggerAriaLabel` for an icon-only trigger and `popoverAriaLabel` when the content has no visible heading. See DisplayTooltip for a fixed icon-trigger variant, or ActionMenu for one with menu semantics.",
|
|
40
46
|
},
|
|
41
47
|
},
|
|
42
48
|
},
|
|
@@ -46,6 +52,14 @@ export default meta;
|
|
|
46
52
|
type Story = StoryObj<typeof PopOver>;
|
|
47
53
|
|
|
48
54
|
export const Default: Story = {
|
|
55
|
+
parameters: {
|
|
56
|
+
docs: {
|
|
57
|
+
description: {
|
|
58
|
+
story:
|
|
59
|
+
"Plain text trigger and a single paragraph of content. Use the Controls panel to change the placement and labels.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
49
63
|
render: (args) => ({
|
|
50
64
|
components: { PopOver },
|
|
51
65
|
setup() {
|
|
@@ -67,6 +81,14 @@ export const Default: Story = {
|
|
|
67
81
|
};
|
|
68
82
|
|
|
69
83
|
export const Placements: Story = {
|
|
84
|
+
parameters: {
|
|
85
|
+
docs: {
|
|
86
|
+
description: {
|
|
87
|
+
story:
|
|
88
|
+
"One instance per `placement` value. Each panel flips to the opposite side when it would overflow the viewport.",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
70
92
|
render: (args) => ({
|
|
71
93
|
components: { PopOver },
|
|
72
94
|
setup() {
|
|
@@ -96,6 +118,14 @@ export const Placements: Story = {
|
|
|
96
118
|
};
|
|
97
119
|
|
|
98
120
|
export const IconTrigger: Story = {
|
|
121
|
+
parameters: {
|
|
122
|
+
docs: {
|
|
123
|
+
description: {
|
|
124
|
+
story:
|
|
125
|
+
"An icon-only trigger: `triggerAriaLabel` gives the button its accessible name, and `popoverAriaLabel` names the panel because the content has no visible heading.",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
99
129
|
render: (args) => ({
|
|
100
130
|
components: { PopOver },
|
|
101
131
|
setup() {
|
|
@@ -117,6 +147,14 @@ export const IconTrigger: Story = {
|
|
|
117
147
|
};
|
|
118
148
|
|
|
119
149
|
export const CustomColours: Story = {
|
|
150
|
+
parameters: {
|
|
151
|
+
docs: {
|
|
152
|
+
description: {
|
|
153
|
+
story:
|
|
154
|
+
"Overriding the public colour tokens with inline custom properties on the component.",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
120
158
|
render: (args) => ({
|
|
121
159
|
components: { PopOver },
|
|
122
160
|
setup() {
|
|
@@ -139,3 +177,77 @@ export const CustomColours: Story = {
|
|
|
139
177
|
`,
|
|
140
178
|
}),
|
|
141
179
|
};
|
|
180
|
+
|
|
181
|
+
export const StressTest: Story = {
|
|
182
|
+
name: "Stress Test (Worst-Case Data)",
|
|
183
|
+
args: {
|
|
184
|
+
triggerAriaLabel: "Weitereinformationenzurlieferunganzeigen 😀",
|
|
185
|
+
popoverAriaLabel: "Lieferbedingungenundrückgaberichtlinien",
|
|
186
|
+
closeButtonAriaLabel: "Schließenundzurückzurbestellübersicht",
|
|
187
|
+
},
|
|
188
|
+
render: (args) => ({
|
|
189
|
+
components: { PopOver },
|
|
190
|
+
setup() {
|
|
191
|
+
return { args };
|
|
192
|
+
},
|
|
193
|
+
template: `
|
|
194
|
+
<div style="display: grid; gap: 2.4rem; padding: 2.4rem; font-size: 1.4rem;">
|
|
195
|
+
<p style="margin: 0;">Long unbroken trigger text and worst-case content inside an 18rem column.</p>
|
|
196
|
+
<div style="inline-size: 18rem; outline: 0.1rem dashed currentColor;">
|
|
197
|
+
<PopOver v-bind="args">
|
|
198
|
+
<template #trigger>
|
|
199
|
+
<span>😀 Lieferkostenundversandbedingungenfürallebestellungen</span>
|
|
200
|
+
</template>
|
|
201
|
+
<template #content>
|
|
202
|
+
<h3 style="margin-block-end: 0.8rem;">Kostenloselieferungfürallebestellungenübereinemmindestbestellwert</h3>
|
|
203
|
+
<p>https://example.invalid/a/very/long/unbroken/url/that/never/offers/a/break/opportunity/anywhere/at/all</p>
|
|
204
|
+
<p><b>not bold</b> Bestellungen über fünfzig Pfund qualifizieren sich für die kostenlose Standardlieferung innerhalb des Vereinigten Königreichs, ausgenommen sind Inseln und abgelegene Regionen.</p>
|
|
205
|
+
</template>
|
|
206
|
+
</PopOver>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<p style="margin: 0;">An oversized <code>--pop-over-width</code> (80rem): the panel stays inside the viewport.</p>
|
|
210
|
+
<PopOver placement="bottom" style="--pop-over-width: 80rem;">
|
|
211
|
+
<template #trigger><span>Very wide panel</span></template>
|
|
212
|
+
<template #content><p>The panel is capped at the viewport width minus 3.2rem.</p></template>
|
|
213
|
+
</PopOver>
|
|
214
|
+
|
|
215
|
+
<p style="margin: 0;">Very tall content: the panel caps at the viewport height and scrolls.</p>
|
|
216
|
+
<PopOver placement="bottom">
|
|
217
|
+
<template #trigger><span>Very tall panel</span></template>
|
|
218
|
+
<template #content>
|
|
219
|
+
<p v-for="n in 30" :key="n">Paragraph {{ n }} of thirty.</p>
|
|
220
|
+
</template>
|
|
221
|
+
</PopOver>
|
|
222
|
+
|
|
223
|
+
<p style="margin: 0;">Empty content slot, and single characters in the trigger and content.</p>
|
|
224
|
+
<div style="display: flex; gap: 2.4rem;">
|
|
225
|
+
<PopOver placement="bottom">
|
|
226
|
+
<template #trigger><span>Empty content</span></template>
|
|
227
|
+
</PopOver>
|
|
228
|
+
<PopOver placement="bottom" trigger-aria-label="X">
|
|
229
|
+
<template #trigger><span>X</span></template>
|
|
230
|
+
<template #content><p>Y</p></template>
|
|
231
|
+
</PopOver>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<p style="margin: 0;">Right-to-left content at the right edge: the panel flips left and the close button sits at the inline end.</p>
|
|
235
|
+
<div dir="rtl" style="display: flex; justify-content: flex-start;">
|
|
236
|
+
<PopOver placement="right" close-button-aria-label="إغلاق">
|
|
237
|
+
<template #trigger><span>التوصيل المجاني</span></template>
|
|
238
|
+
<template #content><p>الطلبات التي تزيد عن خمسين جنيهًا مؤهلة للتوصيل المجاني.</p></template>
|
|
239
|
+
</PopOver>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
`,
|
|
243
|
+
}),
|
|
244
|
+
parameters: {
|
|
245
|
+
docs: {
|
|
246
|
+
story: { inline: false, height: "900px" },
|
|
247
|
+
description: {
|
|
248
|
+
story:
|
|
249
|
+
"Worst-case content. Open each popover and check: unbroken trigger text wraps inside the dashed column; unbroken heading, URL and German copy wrap inside the panel; HTML-like text renders as text; an oversized `--pop-over-width` never makes the panel wider than the viewport; very tall content scrolls inside a panel no taller than the viewport; an empty content slot leaves just the close button; right-to-left content puts the close button at the inline end and flips the panel when there is no room.",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
};
|
|
@@ -104,6 +104,16 @@ describe("PopOver", () => {
|
|
|
104
104
|
expect(wrapper.find(".pop-over-popover").attributes("aria-label")).toBe("Filter options");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
it("has no role without popoverAriaLabel", async () => {
|
|
108
|
+
wrapper = await createWrapper();
|
|
109
|
+
expect(wrapper.find(".pop-over-popover").attributes("role")).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("has role='dialog' when popoverAriaLabel is given", async () => {
|
|
113
|
+
wrapper = await createWrapper({ popoverAriaLabel: "Filter options" });
|
|
114
|
+
expect(wrapper.find(".pop-over-popover").attributes("role")).toBe("dialog");
|
|
115
|
+
});
|
|
116
|
+
|
|
107
117
|
it("sets data-placement on the root element", async () => {
|
|
108
118
|
wrapper = await createWrapper({ placement: "top" });
|
|
109
119
|
expect(wrapper.find(".pop-over").attributes("data-placement")).toBe("top");
|
|
@@ -133,6 +143,31 @@ describe("PopOver", () => {
|
|
|
133
143
|
expect(closeButton.attributes("popovertargetaction")).toBe("hide");
|
|
134
144
|
expect(closeButton.attributes("popovertarget")).toBe(vm.popoverId);
|
|
135
145
|
});
|
|
146
|
+
|
|
147
|
+
it("is an inline InputButton with type='button'", async () => {
|
|
148
|
+
wrapper = await createWrapper();
|
|
149
|
+
const closeButton = wrapper.find(".pop-over-close-button");
|
|
150
|
+
expect(closeButton.classes()).toContain("input-button");
|
|
151
|
+
expect(closeButton.attributes("data-variant")).toBe("inline");
|
|
152
|
+
expect(closeButton.attributes("type")).toBe("button");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("renders the close label as visually hidden button text", async () => {
|
|
156
|
+
wrapper = await createWrapper({ closeButtonAriaLabel: "Dismiss" });
|
|
157
|
+
const text = wrapper.find(".pop-over-close-button .input-button-text");
|
|
158
|
+
expect(text.text()).toBe("Dismiss");
|
|
159
|
+
expect(text.classes()).toContain("sr-only");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("uses lucide:x as the default close icon", async () => {
|
|
163
|
+
wrapper = await createWrapper();
|
|
164
|
+
expect(wrapper.find(".pop-over-close-button-icon").classes().join(" ")).toContain("lucide:x");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("uses a custom closeIcon", async () => {
|
|
168
|
+
wrapper = await createWrapper({ closeIcon: "mdi:close" });
|
|
169
|
+
expect(wrapper.find(".pop-over-close-button-icon").classes().join(" ")).toContain("mdi:close");
|
|
170
|
+
});
|
|
136
171
|
});
|
|
137
172
|
|
|
138
173
|
describe("handleToggle focus management", () => {
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
exports[`PopOver > renders correct HTML structure 1`] = `
|
|
4
4
|
"<div class="pop-over" style="--_anchor-name: --pop-over-anchor-v-0-0;" data-placement="right"><button popovertarget="pop-over-v-0-0" popovertargetaction="toggle" type="button" class="pop-over-trigger" aria-expanded="false"><span>Open</span></button>
|
|
5
|
-
<div id="pop-over-v-0-0" popover="" class="pop-over-popover"><button
|
|
5
|
+
<div id="pop-over-v-0-0" popover="" class="pop-over-popover"><button type="button" data-testid="input-button" data-theme="default" data-variant="inline" data-icon-only="" class="input-button pop-over-close-button" popovertarget="pop-over-v-0-0" popovertargetaction="hide" aria-label="Close">
|
|
6
|
+
<!--v-if-->
|
|
7
|
+
<!--v-if--><span class="input-button-text sr-only">Close</span>
|
|
8
|
+
<!--v-if--><span class="input-button-icon" data-slot="icon-only"><span class="iconify i-lucide:x pop-over-close-button-icon" aria-hidden="true"></span></span>
|
|
9
|
+
</button>
|
|
6
10
|
<div class="pop-over-content">
|
|
7
11
|
<p>Details</p>
|
|
8
12
|
</div>
|