srcdev-nuxt-components 9.5.7 → 9.6.1
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/skills/component-compliance-checklist.md +8 -2
- package/.claude/skills/component-dynamic-slots.md +3 -3
- package/.claude/skills/component-export-types.md +10 -16
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/alert-content.md +9 -1
- package/.claude/skills/components/breadcrumb.md +5 -0
- package/.claude/skills/components/container-glow.md +21 -8
- package/.claude/skills/components/display-avatar.md +13 -7
- package/.claude/skills/components/display-chip.md +37 -24
- package/.claude/skills/components/display-pill.md +58 -8
- package/.claude/skills/components/display-prompt.md +8 -5
- package/.claude/skills/components/display-toast.md +21 -4
- package/.claude/skills/components/glowing-border.md +20 -2
- package/.claude/skills/components/input-button.md +3 -0
- package/.claude/skills/components/responsive-header.md +59 -0
- package/.claude/skills/components/select-menu.md +54 -5
- package/.claude/skills/components/service-detail.md +32 -0
- package/.claude/skills/components/service-summary.md +8 -0
- package/.claude/skills/components/services-card-grid.md +5 -3
- package/.claude/skills/components/services-card.md +22 -12
- package/.claude/skills/components/site-header.md +2 -2
- package/.claude/skills/components/slider-gallery.md +10 -4
- package/.claude/skills/components/tabbed-content.md +300 -0
- package/.claude/skills/composable-anchor-scroll.md +16 -15
- package/.claude/skills/index.md +4 -5
- package/.claude/skills/storybook-add-story.md +53 -0
- package/.claude/skills/theming-status-tokens.md +60 -0
- package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
- package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
- package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
- package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
- package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
- package/.vscode/srcdev-component-services-card.code-snippets +38 -36
- package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
- package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
- package/app/assets/styles/setup/03.theming/_status.css +24 -0
- package/app/assets/styles/setup/03.theming/index.css +1 -0
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
- package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
- package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
- package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
- package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
- package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
- package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
- package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
- package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
- package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
- package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
- package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
- package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
- package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
- package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
- package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
- package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
- package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
- package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
- package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
- package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
- package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
- package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
- package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
- package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
- package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
- package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
- package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
- package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
- package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
- package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
- package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
- package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
- package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
- package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
- package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
- package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
- package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
- package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
- package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
- package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
- package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
- package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
- package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +42 -11
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +5 -4
- package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
- package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
- package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
- package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +2 -2
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
- package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
- package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +6 -6
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
- package/app/components/05.forms/input-button/InputButton.vue +3 -1
- package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
- package/app/composables/useAnchorScroll.ts +1 -1
- package/app/composables/useTabs.ts +148 -206
- package/app/types/components/display-chip.d.ts +8 -4
- package/app/types/components/display-toast.d.ts +2 -0
- package/app/types/components/index.ts +0 -1
- package/app/types/components/select-menu.d.ts +2 -0
- package/app/utils/pausableTimeout.ts +41 -0
- package/nuxt.config.ts +1 -1
- package/package.json +1 -1
- package/.claude/skills/components/site-navigation.md +0 -145
- package/.claude/skills/components/tab-navigation.md +0 -272
- package/.claude/skills/components/tabs-core.md +0 -187
- package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
- package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
- package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
- package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
- package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
- package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
- package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
- package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
- package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
- package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
- package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
- package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
- package/app/composables/useNavCollapse.ts +0 -86
- package/app/types/components/nav-item.d.ts +0 -11
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<nav class="breadcrumb" :class="[elementClasses]" :aria-label="ariaLabel">
|
|
2
|
+
<nav v-if="visibleItems.length" class="breadcrumb" :class="[elementClasses]" :aria-label="ariaLabel">
|
|
3
3
|
<ol class="breadcrumb__list">
|
|
4
|
-
<li v-for="(item, index) in
|
|
4
|
+
<li v-for="(item, index) in visibleItems" :key="`${item.label}-${index}`" class="breadcrumb__item">
|
|
5
5
|
<NuxtLink v-if="item.to" :to="item.to" class="breadcrumb__link">{{ item.label }}</NuxtLink>
|
|
6
|
-
<span v-else class="breadcrumb__label" :aria-current="index ===
|
|
6
|
+
<span v-else class="breadcrumb__label" :aria-current="index === visibleItems.length - 1 ? 'page' : undefined">{{
|
|
7
7
|
item.label
|
|
8
8
|
}}</span>
|
|
9
|
-
<span v-if="index <
|
|
9
|
+
<span v-if="separator && index < visibleItems.length - 1" class="breadcrumb__separator" aria-hidden="true">{{ separator }}</span>
|
|
10
10
|
</li>
|
|
11
11
|
</ol>
|
|
12
12
|
</nav>
|
|
@@ -29,6 +29,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
29
29
|
styleClassPassthrough: () => [],
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// Blank labels would render an empty crumb with a dangling separator.
|
|
33
|
+
const visibleItems = computed(() => props.items.filter((item) => item.label?.trim()));
|
|
34
|
+
|
|
32
35
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
33
36
|
|
|
34
37
|
watch(
|
|
@@ -63,11 +66,19 @@ watch(
|
|
|
63
66
|
display: flex;
|
|
64
67
|
align-items: center;
|
|
65
68
|
gap: var(--_gap);
|
|
69
|
+
min-inline-size: 0;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
.breadcrumb__link,
|
|
69
73
|
.breadcrumb__label {
|
|
70
74
|
color: var(--_colour);
|
|
75
|
+
min-inline-size: 0;
|
|
76
|
+
overflow-wrap: anywhere;
|
|
77
|
+
display: -webkit-box;
|
|
78
|
+
-webkit-box-orient: vertical;
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
-webkit-line-clamp: var(--breadcrumb-item-line-clamp, none);
|
|
81
|
+
line-clamp: var(--breadcrumb-item-line-clamp, none);
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
.breadcrumb__label[aria-current="page"] {
|
|
@@ -84,6 +95,7 @@ watch(
|
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
.breadcrumb__separator {
|
|
98
|
+
flex-shrink: 0;
|
|
87
99
|
color: var(--_colour);
|
|
88
100
|
opacity: 0.6;
|
|
89
101
|
}
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
| `--breadcrumb-text-transform` | `uppercase` | Text transform of the whole trail |
|
|
10
10
|
| `--breadcrumb-letter-spacing` | `0.05em` | Letter spacing of the whole trail |
|
|
11
11
|
| `--breadcrumb-colour` | `currentColor` | Colour of links, separators, and non-current labels |
|
|
12
|
-
| `--breadcrumb-colour-current` | `currentColor` | Colour of the current-page item (the
|
|
12
|
+
| `--breadcrumb-colour-current` | `currentColor` | Colour of the current-page item (the last item, when it has no `to`) |
|
|
13
13
|
| `--breadcrumb-link-decoration-hover` | `underline` | `text-decoration` on link hover/focus |
|
|
14
|
+
| `--breadcrumb-item-line-clamp` | `none` | Max lines of each item's label, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
|
|
15
|
+
|
|
16
|
+
> Added 2026-10-06: `--breadcrumb-item-line-clamp`; long unbroken labels now wrap inside the trail.
|
|
14
17
|
|
|
15
18
|
```css
|
|
16
19
|
.breadcrumb {
|
|
@@ -21,9 +24,21 @@
|
|
|
21
24
|
|
|
22
25
|
---
|
|
23
26
|
|
|
27
|
+
## State hooks
|
|
28
|
+
|
|
29
|
+
| Hook | When |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `.breadcrumb__label[aria-current="page"]` | The last item, when it has no `to` (styled by `--breadcrumb-colour-current`) |
|
|
32
|
+
|
|
33
|
+
Inner classes: `.breadcrumb__list`, `.breadcrumb__item`, `.breadcrumb__link`, `.breadcrumb__label`,
|
|
34
|
+
`.breadcrumb__separator`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
24
38
|
## Text content — props, not CSS
|
|
25
39
|
|
|
26
|
-
`items` and `
|
|
40
|
+
`items`, `separator` and `ariaLabel` (the nav landmark's label) are props. There is no hardcoded copy to
|
|
41
|
+
override in CSS.
|
|
27
42
|
|
|
28
43
|
---
|
|
29
44
|
|
|
@@ -37,11 +52,19 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
37
52
|
|
|
38
53
|
---
|
|
39
54
|
|
|
55
|
+
## Class passthrough
|
|
56
|
+
|
|
57
|
+
`style-class-passthrough` adds classes to the root `nav.breadcrumb`. Reactive after mount.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
40
61
|
## Notes
|
|
41
62
|
|
|
42
|
-
- An item renders as a `NuxtLink` when it has a `to`, otherwise as `<span
|
|
43
|
-
omit `to` on
|
|
44
|
-
- The separator (`aria-hidden="true"`) is only rendered between items, never after the last one
|
|
63
|
+
- An item renders as a `NuxtLink` when it has a `to`, otherwise as a plain `<span>`. The last item gets
|
|
64
|
+
`aria-current="page"` when it has no `to`, so omit `to` on the current page's item.
|
|
65
|
+
- The separator (`aria-hidden="true"`) is only rendered between items, never after the last one, and
|
|
66
|
+
not at all when `separator` is `""`.
|
|
67
|
+
- Items with a blank `label` are skipped, and no `<nav>` is rendered when none are left (added 2026-10-06).
|
|
45
68
|
- `--breadcrumb-colour` is what `ServiceDetail` overrides internally (to `white` by default) so
|
|
46
69
|
the breadcrumb reads over its hero image — see `ServiceDetail`'s `CONSUMER-STYLING.md`.
|
|
47
70
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
1
2
|
import Breadcrumb from "../Breadcrumb.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
|
|
2
4
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
5
|
import type { BreadcrumbItem } from "~/types/components/breadcrumb";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
4
7
|
|
|
5
8
|
const meta: Meta<typeof Breadcrumb> = {
|
|
6
9
|
title: "Atoms/Navigation/Breadcrumb",
|
|
@@ -14,6 +17,10 @@ const meta: Meta<typeof Breadcrumb> = {
|
|
|
14
17
|
control: "text",
|
|
15
18
|
description: "Character or short string rendered between items",
|
|
16
19
|
},
|
|
20
|
+
ariaLabel: {
|
|
21
|
+
control: "text",
|
|
22
|
+
description: "aria-label on the nav landmark; override for localisation",
|
|
23
|
+
},
|
|
17
24
|
styleClassPassthrough: {
|
|
18
25
|
control: "object",
|
|
19
26
|
description: "Additional CSS classes applied to the root element",
|
|
@@ -21,6 +28,7 @@ const meta: Meta<typeof Breadcrumb> = {
|
|
|
21
28
|
},
|
|
22
29
|
args: {
|
|
23
30
|
separator: "/",
|
|
31
|
+
ariaLabel: "Breadcrumb",
|
|
24
32
|
styleClassPassthrough: [],
|
|
25
33
|
},
|
|
26
34
|
parameters: {
|
|
@@ -85,3 +93,89 @@ export const CustomSeparator: Story = {
|
|
|
85
93
|
template: `<Breadcrumb v-bind="args" />`,
|
|
86
94
|
}),
|
|
87
95
|
};
|
|
96
|
+
|
|
97
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
type LineClamp = "none" | "1" | "2" | "3";
|
|
100
|
+
|
|
101
|
+
// Story-only arg: drives a CSS token, not a Breadcrumb prop. See the argTypes entry.
|
|
102
|
+
type StressArgs = InstanceType<typeof Breadcrumb>["$props"] & { itemLineClamp: LineClamp };
|
|
103
|
+
|
|
104
|
+
const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarung";
|
|
105
|
+
|
|
106
|
+
const stressItems: BreadcrumbItem[] = [
|
|
107
|
+
{ label: "Startseite", to: "/" },
|
|
108
|
+
{ label: `Kategorie ${longGerman}`, to: "/kategorie" },
|
|
109
|
+
{ label: "" },
|
|
110
|
+
{ label: " " },
|
|
111
|
+
{ label: "😀 Emoji first", to: "/emoji" },
|
|
112
|
+
{ label: "<b>not bold</b>", to: "/html" },
|
|
113
|
+
{ label: "تسريحات شعر جديدة لموسم الخريف", to: "/rtl" },
|
|
114
|
+
{ label: "https://example.com/a/very/long/url/that/never/breaks/because/it/has/no/spaces", to: "/url" },
|
|
115
|
+
{ label: "A", to: "/a" },
|
|
116
|
+
{ label: `${longGerman} und ${longGerman}` },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export const StressTest: StoryObj<StressArgs> = {
|
|
120
|
+
name: "Stress Test (Worst-Case Data)",
|
|
121
|
+
argTypes: {
|
|
122
|
+
itemLineClamp: {
|
|
123
|
+
control: "select",
|
|
124
|
+
options: ["none", "1", "2", "3"] satisfies LineClamp[],
|
|
125
|
+
description:
|
|
126
|
+
"**Story control, not a prop.** Sets the `--breadcrumb-item-line-clamp` CSS token on a wrapper so you can try it here. " +
|
|
127
|
+
"To use it in an app, set the token in your own CSS, e.g. `.page-header { --breadcrumb-item-line-clamp: 1; }`. " +
|
|
128
|
+
"`1` is single-line ellipsis, `none` (the default) shows everything.",
|
|
129
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
args: {
|
|
133
|
+
items: stressItems,
|
|
134
|
+
separator: "→→",
|
|
135
|
+
ariaLabel: "Navigationspfad",
|
|
136
|
+
itemLineClamp: "none",
|
|
137
|
+
},
|
|
138
|
+
render: (args) => ({
|
|
139
|
+
components: { Breadcrumb },
|
|
140
|
+
setup() {
|
|
141
|
+
const componentArgs = computed(() => {
|
|
142
|
+
const { itemLineClamp: _itemLineClamp, ...rest } = args;
|
|
143
|
+
return rest;
|
|
144
|
+
});
|
|
145
|
+
const tokenStyles = computed(() => ({ "--breadcrumb-item-line-clamp": args.itemLineClamp }));
|
|
146
|
+
return { componentArgs, tokenStyles };
|
|
147
|
+
},
|
|
148
|
+
template: `<div :style="tokenStyles"><Breadcrumb v-bind="componentArgs" /></div>`,
|
|
149
|
+
}),
|
|
150
|
+
decorators: [
|
|
151
|
+
(story, context) => ({
|
|
152
|
+
components: { story, CanvasSwitcher },
|
|
153
|
+
setup() {
|
|
154
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
155
|
+
return { canvasName };
|
|
156
|
+
},
|
|
157
|
+
template: `
|
|
158
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
159
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
160
|
+
</div>
|
|
161
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
162
|
+
<story />
|
|
163
|
+
</div>
|
|
164
|
+
`,
|
|
165
|
+
}),
|
|
166
|
+
],
|
|
167
|
+
parameters: {
|
|
168
|
+
layout: "fullscreen",
|
|
169
|
+
initialCanvas: "mobileCanvas",
|
|
170
|
+
docs: {
|
|
171
|
+
description: {
|
|
172
|
+
story:
|
|
173
|
+
"Deliberately hostile data to find breakage: ten items including a long German compound word, an unbroken URL, " +
|
|
174
|
+
"emoji, HTML-like text (must render as text), right-to-left Arabic, a single character, and two blank labels " +
|
|
175
|
+
"(skipped, with no dangling separator), plus a two-character separator and a German nav label. Check at every " +
|
|
176
|
+
"canvas width: labels wrap inside the trail, separators keep their size, and the last item is the current page. " +
|
|
177
|
+
"Try the Item line clamp control too: it\x27s a CSS token set by the story, not a prop.",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
@@ -84,4 +84,31 @@ describe("Breadcrumb", () => {
|
|
|
84
84
|
expect(wrapper.classes()).not.toContain("original");
|
|
85
85
|
expect(wrapper.classes()).toContain("updated");
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
// ─── Worst-case data ───────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
it("skips blank labels without leaving a dangling separator", async () => {
|
|
91
|
+
const wrapper = await mountSuspended(Breadcrumb, {
|
|
92
|
+
props: { items: [{ label: "Services", to: "/services" }, { label: " " }, { label: "Balayage" }, { label: "" }] },
|
|
93
|
+
});
|
|
94
|
+
expect(wrapper.findAll(".breadcrumb__item")).toHaveLength(2);
|
|
95
|
+
expect(wrapper.findAll(".breadcrumb__separator")).toHaveLength(1);
|
|
96
|
+
expect(wrapper.find(".breadcrumb__label").attributes("aria-current")).toBe("page");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("renders no nav landmark when there is nothing to show", async () => {
|
|
100
|
+
const wrapper = await mountSuspended(Breadcrumb, { props: { items: [{ label: "" }] } });
|
|
101
|
+
expect(wrapper.find("nav").exists()).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders no separator element when separator is empty", async () => {
|
|
105
|
+
const wrapper = await mountSuspended(Breadcrumb, { props: { items, separator: "" } });
|
|
106
|
+
expect(wrapper.find(".breadcrumb__separator").exists()).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders HTML-like labels as text", async () => {
|
|
110
|
+
const wrapper = await mountSuspended(Breadcrumb, { props: { items: [{ label: "<b>bold</b>" }] } });
|
|
111
|
+
expect(wrapper.find(".breadcrumb__label").text()).toBe("<b>bold</b>");
|
|
112
|
+
expect(wrapper.find(".breadcrumb__label b").exists()).toBe(false);
|
|
113
|
+
});
|
|
87
114
|
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# TabbedContent — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
> **Renamed 2026-10-07**: `TabsCore` → `TabbedContent`. Root class `.tabs-core` → `.tabbed-content`,
|
|
4
|
+
> and every inner class moved under the component prefix (see State hooks). Token names are
|
|
5
|
+
> unchanged (`--tabs-*`).
|
|
6
|
+
|
|
7
|
+
## Public token API
|
|
8
|
+
|
|
9
|
+
Colour defaults come from the `--theme-*` slots, so the component follows the site theme out of the
|
|
10
|
+
box.
|
|
11
|
+
|
|
12
|
+
> **Changed 2026-10-07**: colour defaults moved from fixed `--slate-*` steps to `--theme-*` slots.
|
|
13
|
+
> The content panel used to default to a dark `--slate-09` background with no text colour, so
|
|
14
|
+
> consumer text rendered dark on dark; it's now a light `--theme-surface-subtle` panel with
|
|
15
|
+
> `--theme-text`. The active highlight is `--theme-surface` (bold) with `--theme-on-surface` text,
|
|
16
|
+
> the underline is `--theme-accent`. To keep the old look, set the tokens below to the previous
|
|
17
|
+
> `--slate-*` values.
|
|
18
|
+
|
|
19
|
+
### Tab row
|
|
20
|
+
|
|
21
|
+
| Token | Default | Controls |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `--tabs-nav-border` | `0.1rem solid color-mix(in oklch, var(--theme-text) 20%, transparent)` | Border between the tab row and the panels (bottom on `axis="x"`, left on `axis="y"`) |
|
|
24
|
+
| `--tabs-axis-y-gap` | `2em` | Gap between the tab column and the panels on `axis="y"` |
|
|
25
|
+
| `--tabs-axis-y-list-max-inline-size` | `50%` | Widest the tab column may get on `axis="y"`; long labels wrap inside it |
|
|
26
|
+
|
|
27
|
+
### Tab triggers
|
|
28
|
+
|
|
29
|
+
| Token | Default | Controls |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `--tabs-list-item-colour` | `var(--theme-text)` | Text colour of an inactive trigger |
|
|
32
|
+
| `--tabs-list-item-opacity` | `0.7` | Opacity of an inactive trigger |
|
|
33
|
+
| `--tabs-list-item-colour-selected` | falls back to `--tabs-active-indicator-text-colour` | Text colour of the active trigger |
|
|
34
|
+
| `--tabs-list-item-text-transform` | `uppercase` | `text-transform` of trigger labels |
|
|
35
|
+
| `--tabs-list-item-font-weight` | `500` | Font weight of trigger labels |
|
|
36
|
+
| `--tabs-list-item-padding-block` | `1em` | Vertical padding of a trigger |
|
|
37
|
+
| `--tabs-list-item-padding-inline` | `2em` | Horizontal padding of a trigger |
|
|
38
|
+
| `--tabs-list-item-max-inline-size` | `24rem` | Widest a trigger may get on `axis="x"`; longer labels wrap inside it |
|
|
39
|
+
| `--tabs-list-item-colour-transition-duration` | `100ms` | Transition of the trigger text colour |
|
|
40
|
+
| `--tabs-list-item-focus-outline-offset` | `-0.2rem` | Focus outline offset on a trigger |
|
|
41
|
+
| `--tabs-focus-ring-width` | `0.2rem` | Focus outline width on triggers and panels |
|
|
42
|
+
| `--tabs-focus-ring-colour` | `var(--theme-border-focus)` | Focus outline colour on triggers and panels |
|
|
43
|
+
|
|
44
|
+
> **Changed 2026-10-07**: the focus outline used `--theme-ring`, the lightest theme step, which
|
|
45
|
+
> was close to invisible on a light page. It now defaults to `--theme-border-focus` behind the new
|
|
46
|
+
> `--tabs-focus-ring-colour` token. `--tabs-list-item-focus-outline-width` is replaced by
|
|
47
|
+
> `--tabs-focus-ring-width`.
|
|
48
|
+
|
|
49
|
+
### Moving indicators
|
|
50
|
+
|
|
51
|
+
| Token | Default | Controls |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `--tabs-hover-indicator-colour` | `var(--theme-surface-subtle)` | Background of the hover highlight (`trackHover`) |
|
|
54
|
+
| `--tabs-hover-indicator-text-colour` | `var(--theme-text)` | Text colour of a hovered trigger; keep it in contrast with the hover background |
|
|
55
|
+
| `--tabs-active-indicator-colour` | `var(--theme-surface)` | Background of the active highlight (`trackActive`) |
|
|
56
|
+
| `--tabs-active-indicator-text-colour` | `var(--theme-on-surface)` | Text colour of the active trigger; keep it in contrast with the active background |
|
|
57
|
+
| `--tabs-underline-indicator-colour` | `var(--theme-accent)` | Colour of the underline/sideline (`trackIndicator`) |
|
|
58
|
+
| `--tabs-underline-indicator-height` | `0.4rem` | Thickness of the underline (`axis="x"`), also its width on `axis="y"` |
|
|
59
|
+
| `--tabs-hover-underline-indicator-colour` | `--tabs-underline-indicator-colour` | Colour of the thin underline/sideline that follows the hovered tab (`trackHover` and `trackIndicator`) |
|
|
60
|
+
| `--tabs-hover-underline-indicator-height` | `0.1rem` | Its thickness; `0` hides it. Sits under the active underline where they overlap |
|
|
61
|
+
|
|
62
|
+
### More menu (`overflowMode="menu"`, `axis="x"`)
|
|
63
|
+
|
|
64
|
+
| Token | Default | Controls |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `--tabs-more-margin-inline-start` | `auto` | Pushes the More button to the end of the tab row; `0` keeps it straight after the last visible tab |
|
|
67
|
+
| `--tabs-more-trigger-padding-inline` | `1.2em` | Horizontal padding of the More button (vertical padding follows `--tabs-list-item-padding-block`); its colours follow the trigger tokens |
|
|
68
|
+
| `--tabs-more-icon-size` | `1.25em` | More button icon size (the `⋯` state) |
|
|
69
|
+
| `--tabs-more-chevron-size` | `1em` | Chevron after the active tab's label on the More button (flips while the menu is open) |
|
|
70
|
+
| `--tabs-more-popover-surface` | `var(--page-bg, var(--theme-surface-subtle))` | Menu background |
|
|
71
|
+
| `--tabs-more-popover-text-colour` | `var(--theme-text)` | Menu text colour |
|
|
72
|
+
| `--tabs-more-popover-border` | `0.1rem solid color-mix(in oklch, var(--theme-text) 20%, transparent)` | Menu border (shorthand) |
|
|
73
|
+
| `--tabs-more-popover-border-radius` | `0.6rem` | Menu corner radius |
|
|
74
|
+
| `--tabs-more-popover-shadow` | `0 0.4rem 1.6rem rgb(0 0 0 / 12%)` | Menu shadow |
|
|
75
|
+
| `--tabs-more-popover-min-inline-size` | `16rem` | Narrowest the menu gets (widest is `--tabs-list-item-max-inline-size`, capped to the viewport) |
|
|
76
|
+
| `--tabs-more-popover-max-block-size` | `70vh` | Tallest the menu gets before it scrolls |
|
|
77
|
+
| `--tabs-more-popover-distance` | `0.4rem` | Gap between the More button and the menu |
|
|
78
|
+
| `--tabs-more-popover-transition-duration` | `200ms` | Menu fade |
|
|
79
|
+
| `--tabs-more-popover-z-index` | `999999` | Stacking where the menu can't use the top layer (Safari 16) |
|
|
80
|
+
| `--tabs-more-item-padding-block` / `-inline` | `0.8em` / `1.2em` | Menu row padding |
|
|
81
|
+
| `--tabs-more-item-surface-hover` | `color-mix(in oklch, var(--theme-text) 8%, transparent)` | Menu row background on hover/focus |
|
|
82
|
+
| `--tabs-more-item-colour-selected` | `var(--theme-accent)` | Text colour of the menu row for the active tab |
|
|
83
|
+
|
|
84
|
+
### Panels
|
|
85
|
+
|
|
86
|
+
| Token | Default | Controls |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `--tabs-content-background-colour` | `var(--theme-surface-subtle)` | Panel background |
|
|
89
|
+
| `--tabs-content-text-colour` | `var(--theme-text)` | Panel text colour |
|
|
90
|
+
| `--tabs-content-border-width` | `0.1rem` | Panel border width |
|
|
91
|
+
| `--tabs-content-border-colour` | `color-mix(in oklch, var(--theme-text) 20%, transparent)` | Panel border colour |
|
|
92
|
+
| `--tabs-content-border-radius` | `0` | Panel corner radius |
|
|
93
|
+
|
|
94
|
+
### Private tokens
|
|
95
|
+
|
|
96
|
+
`--_focus-ring` (composed from the two focus tokens), and the runtime values `useTabs` writes from
|
|
97
|
+
measured layout, per indicator (`active` / `hovered`): `--_{kind}-start` / `--_{kind}-end` (edge
|
|
98
|
+
insets from the bar's start and end along the axis), `--_{kind}-duration`,
|
|
99
|
+
`--_{kind}-start-delay` / `--_{kind}-end-delay`; each indicator reads them through its own
|
|
100
|
+
`--_start` / `--_end` / `--_duration` / `--_start-delay` / `--_end-delay`. Not public API; use the
|
|
101
|
+
`transitionDuration` prop for indicator speed (one edge moves for that long, then the other).
|
|
102
|
+
|
|
103
|
+
> **Changed 2026-10-08**: indicators were a bar-wide box moved with `translate` and sized with
|
|
104
|
+
> `scale` (`--_x-*`, `--_width-*`, `--_y-*`, a shared `--_transition-duration`), with a timer
|
|
105
|
+
> to "settle" after a stretch. Rightward moves changed both properties at once and could visibly
|
|
106
|
+
> step backwards. Each edge is now its own `left`/`right` (`top`/`bottom` on `axis="y"`) inset:
|
|
107
|
+
> the edge in the direction of travel moves first and the trailing edge follows after one
|
|
108
|
+
> duration, so the movement is the same both ways, with no timer.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## State hooks
|
|
113
|
+
|
|
114
|
+
| Hook | Where | Meaning |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `.axis-x` / `.axis-y` | root `.tabbed-content` | The `axis` prop |
|
|
117
|
+
| `.overflow-menu` / `.overflow-scroll` | root | The `overflowMode` prop |
|
|
118
|
+
| `.tabbed-content-bar` | wraps the tablist, the More menu and the indicators | Carries `--tabs-nav-border`; scrolls sideways with `overflowMode="scroll"` |
|
|
119
|
+
| `.tabbed-content-list` | `role="tablist"` | The tab row/column |
|
|
120
|
+
| `.tabbed-content-trigger[hidden]` | trigger | Collapsed into the More menu |
|
|
121
|
+
| `.tabbed-content-more` / `.is-idle` | More menu wrapper | Idle (measured but invisible) while every tab fits |
|
|
122
|
+
| `.tabbed-content-more-trigger` / `.is-active` | More button | `.is-active` while the active tab is one of the collapsed ones: the button shows that tab's label (`.tabbed-content-more-label`) and a chevron instead of `⋯`, takes the normal tab padding, and the indicators sit on it |
|
|
123
|
+
| `.tabbed-content-more-popover` | menu popover | Open: `:popover-open`, or `.tabbed-content-more-popover-open` without the Popover API (style the two in separate rules) |
|
|
124
|
+
| `.tabbed-content-more-item[aria-checked="true"]` | menu row | The active tab's row |
|
|
125
|
+
| `.tabbed-content-trigger` | each `role="tab"` button | A tab trigger |
|
|
126
|
+
| `.tabbed-content-trigger[aria-selected="true"]` | trigger | The active tab |
|
|
127
|
+
| `.tracks-active` | root | `trackActive` is on: label colour follows `[data-under-active]` instead of `aria-selected` |
|
|
128
|
+
| `[data-under-active]` | trigger, More button | The active highlight covers at least half of it right now (updated every frame while it slides) |
|
|
129
|
+
| `.tabbed-content-indicator-hover` / `-active` / `-underline` / `-underline-hover` | inside `.tabbed-content-bar` | The moving indicators, rendered only when their `track*` prop is on (`-underline-hover` needs both `trackHover` and `trackIndicator`) |
|
|
130
|
+
| `.tabbed-content-panels` | panel wrapper | Background, border and text colour live here |
|
|
131
|
+
| `.tabbed-content-panel` / `[hidden]` | each `role="tabpanel"` | A panel; inactive ones carry `hidden` |
|
|
132
|
+
|
|
133
|
+
> **Changed 2026-10-07**: `.tabs-list` → `.tabbed-content-list` (now a `<div>`, was a `<ul>` with
|
|
134
|
+
> `<li>`s), `.tabs-list-item` → `.tabbed-content-trigger`, `.nav__hovered` / `.nav__active` /
|
|
135
|
+
> `.nav__active-indicator` → `.tabbed-content-indicator-hover` / `-active` / `-underline`,
|
|
136
|
+
> `.tab-content-wrapper` → `.tabbed-content-panels`, `.tab-content` → `.tabbed-content-panel`.
|
|
137
|
+
> Inactive panels use the `hidden` attribute instead of inline `display: none` and `aria-hidden`.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Tab-label text colour tracks the indicator behind it
|
|
142
|
+
|
|
143
|
+
Hover and active can have different backgrounds, so the trigger label has a text-colour token per
|
|
144
|
+
state. If you override either indicator background, override its matching text-colour token too:
|
|
145
|
+
|
|
146
|
+
```css
|
|
147
|
+
.my-page {
|
|
148
|
+
--tabs-hover-indicator-colour: gold;
|
|
149
|
+
--tabs-hover-indicator-text-colour: black;
|
|
150
|
+
--tabs-active-indicator-colour: navy;
|
|
151
|
+
--tabs-active-indicator-text-colour: white;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
With `trackActive` on, label colour follows what's actually behind each tab while the active
|
|
156
|
+
highlight slides: a tab it covers by at least half (`[data-under-active]`) takes the active text
|
|
157
|
+
colour, and the newly selected tab keeps its resting colour until the highlight reaches it. So a
|
|
158
|
+
jump from tab 1 to tab 5 never puts dark text on the dark highlight, or light text on the light
|
|
159
|
+
row. With `trackActive` off there's no moving highlight, and `aria-selected` sets the colour as
|
|
160
|
+
before.
|
|
161
|
+
|
|
162
|
+
> **Changed 2026-10-08**: passed-over labels used to keep their resting colour while the active
|
|
163
|
+
> highlight slid under them (dark on dark), and the selected tab turned light before the
|
|
164
|
+
> highlight arrived (light on light).
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Indicator visibility is prop-driven
|
|
169
|
+
|
|
170
|
+
Whether each indicator renders is set by `trackHover` / `trackActive` / `trackIndicator`, not
|
|
171
|
+
tokens. They can change after mount.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Local overrides
|
|
176
|
+
|
|
177
|
+
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
178
|
+
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
179
|
+
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
180
|
+
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
181
|
+
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Class passthrough
|
|
186
|
+
|
|
187
|
+
`style-class-passthrough` classes land on the root `.tabbed-content`, so tokens set on that class
|
|
188
|
+
reach every part of the component.
|
|
189
|
+
|
|
190
|
+
> **Changed 2026-10-07**: passthrough classes used to land on the tab list, not the root, so tokens
|
|
191
|
+
> set on them never reached the panels.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Notes
|
|
196
|
+
|
|
197
|
+
- **Dark mode**: defaults are light-only, like the rest of the library. Override the `--theme-*`
|
|
198
|
+
slots (or these tokens) in your own dark scheme; `light-dark()` in your own override values is
|
|
199
|
+
fine, it's only kept out of the library's defaults.
|
|
200
|
+
- **Right-to-left**: indicators are positioned from measured `offsetLeft`, so they line up in RTL,
|
|
201
|
+
but arrow keys aren't mirrored (ArrowRight always moves to the next tab in DOM order).
|