srcdev-nuxt-components 9.6.5 → 9.6.7

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.
Files changed (35) hide show
  1. package/.claude/rules/consumer/a11y-no-focus-steal.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +5 -1
  3. package/.claude/skills/components/display-toast.md +6 -3
  4. package/.claude/skills/components/page-hero-highlights.md +22 -18
  5. package/.claude/skills/components/social-icons-list.md +32 -5
  6. package/.claude/skills/css-dynamic-border-radius.md +114 -0
  7. package/.claude/skills/index.md +3 -2
  8. package/.claude/skills/storybook-add-story.md +6 -0
  9. package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
  10. package/.vscode/srcdev-component-page-hero-highlights.code-snippets +2 -1
  11. package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
  12. package/.vscode/srcdev-utility-dynamic-border-radius.code-snippets +31 -0
  13. package/app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css +27 -0
  14. package/app/assets/styles/setup/06.utility-classes/index.css +1 -0
  15. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +97 -8
  16. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +80 -14
  17. package/app/components/02.molecules/social-icons-list/stories/SocialIconsList.stories.ts +230 -2
  18. package/app/components/02.molecules/social-icons-list/stories/social-icons-list-recipes.css +108 -0
  19. package/app/components/02.molecules/social-icons-list/tests/SocialIconsList.spec.ts +123 -52
  20. package/app/components/02.molecules/social-icons-list/tests/__snapshots__/SocialIconsList.spec.ts.snap +10 -4
  21. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +9 -3
  22. package/app/components/02.molecules/toast/DisplayToast.vue +10 -10
  23. package/app/components/02.molecules/toast/DisplayToastProvider.vue +12 -4
  24. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +200 -2
  25. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +33 -12
  26. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +20 -0
  27. package/app/components/04.templates/page-hero-highlights/CONSUMER-STYLING.md +32 -12
  28. package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +56 -47
  29. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +158 -307
  30. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlightsHeader.stories.ts +10 -0
  31. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +49 -27
  32. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlightsHeader.spec.ts +42 -7
  33. package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +12 -10
  34. package/app/types/components/social-icons-list.d.ts +9 -0
  35. package/package.json +1 -1
@@ -2,18 +2,68 @@
2
2
 
3
3
  ## Public token API
4
4
 
5
+ ### List
6
+
7
+ | Token | Default | Controls |
8
+ |---|---|---|
9
+ | `--social-icons-list-gap` | `1.2rem` | Gap between items |
10
+
11
+ ### Link (the tile)
12
+
5
13
  | Token | Default | Controls |
6
14
  |---|---|---|
7
- | `--social-icons-list-icon-size` | `2.4rem` | Icon size (applied as `font-size`) |
8
- | `--social-icons-list-gap` | `1.2rem` | Gap between icons |
9
- | `--social-icons-list-icon-colour` | `currentColor` | Colour of mono icons (sets that draw in `currentColor`, e.g. `mdi:instagram`). No effect on `logos:` icons, whose colours are fixed in the SVG |
15
+ | `--social-icons-list-link-padding` | `0` | Padding inside each link: set it to turn links into tiles or pills |
16
+ | `--social-icons-list-link-gap` | `0.8rem` | Gap between the icon and the visible label |
17
+ | `--social-icons-list-link-border` | `none` | Border shorthand |
18
+ | `--social-icons-list-link-radius` | `0` | Corner radius (the focus outline follows it) |
19
+ | `--social-icons-list-link-background` | `transparent` | Background |
20
+ | `--social-icons-list-link-background-hover` | the resting background | Background on hover/focus |
21
+ | `--social-icons-list-link-shadow` | `none` | Box shadow |
22
+ | `--social-icons-list-link-shadow-hover` | the resting shadow | Box shadow on hover/focus (a glow) |
10
23
  | `--social-icons-list-hover-scale` | `1.15` | Scale on hover/focus (no scaling under `prefers-reduced-motion: reduce`) |
11
24
  | `--social-icons-list-hover-opacity` | `0.85` | Opacity on hover/focus |
25
+ | `--social-icons-list-focus-ring-colour` | `var(--theme-ring, currentColor)` | Keyboard focus outline colour |
26
+
27
+ ### Icon
12
28
 
13
- The focus outline uses `--theme-ring`, falling back to `currentColor`. `logos:` icons carry their own brand colours. Some are dark (`logos:instagram-icon`, `logos:x`,
14
- `logos:github-icon`) and vanish on a dark background: use a mono icon there (`mdi:instagram`,
15
- `bi:instagram`, ...) and colour it with `--social-icons-list-icon-colour`.
29
+ | Token | Default | Controls |
30
+ |---|---|---|
31
+ | `--social-icons-list-icon-size` | `2.4rem` | Icon size (a `font-size` on the icon wrapper) |
32
+ | `--social-icons-list-icon-colour` | `currentColor` | Colour of mono icons (sets that draw in `currentColor`, e.g. `bi:instagram`). No effect on `logos:` icons, whose colours are fixed in the SVG |
33
+ | `--social-icons-list-icon-colour-hover` | the resting icon colour | Mono icon colour on hover/focus |
34
+ | `--social-icons-list-icon-wrapper-padding` | `0` | Padding around the icon's fixed `1em` square: uniform padding with a background and `50%` radius gives a circle, whatever the `icon` slot holds |
35
+ | `--social-icons-list-icon-wrapper-border` | `none` | Border shorthand around the icon |
36
+ | `--social-icons-list-icon-wrapper-radius` | `0` | Corner radius of the icon wrapper |
37
+ | `--social-icons-list-icon-wrapper-background` | `transparent` | Background behind the icon |
38
+
39
+ ### Label (`show-label` or the `label` slot)
40
+
41
+ | Token | Default | Controls |
42
+ |---|---|---|
43
+ | `--social-icons-list-label-colour` | `currentColor` | Label text colour (set on the link) |
44
+ | `--social-icons-list-label-font-size` | `var(--step-4, 1.6rem)` | Label font size |
45
+ | `--social-icons-list-label-font-weight` | `600` | Label font weight |
16
46
 
47
+ ### Brand colour (data, not a default)
48
+
49
+ `--social-icons-list-brand-colour` is set inline on each `.social-icons-list-item` from that item's
50
+ `brandColour`. The component doesn't use it; it's there so your tokens can (hover colour, glow,
51
+ circle fill). See the caveat under Local overrides.
52
+
53
+ `logos:` icons carry their own brand colours. Some are dark (`logos:instagram-icon`, `logos:x`,
54
+ `logos:github-icon`) and vanish on a dark background: use a mono icon there (`bi:instagram`,
55
+ `mdi:instagram`, ...) and colour it with `--social-icons-list-icon-colour`.
56
+
57
+ > Changed 2026-10-16: the inner classes were renamed so they can't collide with a site's own
58
+ > `.social-icon` styles: `.social-icon-item` is now `.social-icons-list-item`, `.social-icon-link` is
59
+ > `.social-icons-list-link` and `.social-icon` is `.social-icons-list-icon`. Update any selector that
60
+ > used the old names. The icon now sits in a `.social-icons-list-icon-wrapper` span, which carries
61
+ > the icon size (so a missing icon still leaves a focus target). New: the link, icon wrapper and
62
+ > label tokens above, `--social-icons-list-focus-ring-colour` (the outline read `--theme-ring`
63
+ > directly), and per-item `--social-icons-list-brand-colour`. Items whose href isn't http(s),
64
+ > mailto, tel or site-relative are skipped. The focus outline's fixed `2px` radius is now
65
+ > `--social-icons-list-link-radius` (default `0`).
66
+ >
17
67
  > Changed 2026-10-14: new `--social-icons-list-icon-colour` token for mono icons.
18
68
  >
19
69
  > Changed 2026-09-28: `--theme-social-icon-size` and `--theme-social-icon-gap` were renamed to
@@ -27,8 +77,10 @@ No private `--_` tokens.
27
77
 
28
78
  ## State hooks
29
79
 
30
- Inner classes: `.social-icon-item` (each `<li>`), `.social-icon-link` (the `<a>`, opens in a new
31
- tab), `.social-icon` (the icon, `aria-hidden`).
80
+ Inner classes: `.social-icons-list-item` (each `<li>`, carries `--social-icons-list-brand-colour`),
81
+ `.social-icons-list-link` (the `<a>`, opens in a new tab), `.social-icons-list-icon-wrapper` (the
82
+ `aria-hidden` span around the icon or the `icon` slot), `.social-icons-list-icon` (the default
83
+ icon), `.social-icons-list-label` (visible text, only with `show-label` or the `label` slot).
32
84
 
33
85
  ---
34
86
 
@@ -40,6 +92,43 @@ Set the tokens above on an element you own (a page or section class, or a class
40
92
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
41
93
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
42
94
 
95
+ **Caveat:** a token whose value reads `--social-icons-list-brand-colour` must be set on
96
+ `.social-icons-list-item` (or deeper), not on the list or an ancestor. A `var()` inside a custom
97
+ property resolves on the element that declares it, and the brand colour only exists on each item,
98
+ so set on the root it finds nothing and falls back to the default.
99
+
100
+ ```css
101
+ .my-section .social-icons-list-item {
102
+ --social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour);
103
+ }
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Recipes
109
+
110
+ Four looks built only from the tokens above and consumer CSS, each on a class passed through
111
+ `style-class-passthrough`. The full CSS is in `stories/social-icons-list-recipes.css` next to the
112
+ component, and each has a Storybook story (`Recipe: …`). All use mono `bi:` icons plus a
113
+ `brandColour` per item.
114
+
115
+ - **Mono tiles, brand colour on hover**: tile tokens (`link-padding`, `-radius`, `-background`,
116
+ `-background-hover`), then `--social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour)`
117
+ on `.social-icons-list-item`.
118
+ - **Dark dock with glow**: the dock is the list root (padding, border, radius and
119
+ `inline-size: fit-content` on your class), dark tiles inside, and
120
+ `--social-icons-list-link-shadow-hover` mixed from the brand colour on `.social-icons-list-item`.
121
+ Black brand colours (X, TikTok) don't show on a dark dock: give those items a light or accent
122
+ `brandColour` there.
123
+ - **Labelled pills**: `show-label`, a pill link (`link-padding`, `-border`, `99rem` radius), and the
124
+ icon wrapper as a brand-coloured circle (`icon-wrapper-padding`, `50%` radius,
125
+ `icon-wrapper-background` from the brand colour on the item).
126
+ - **Overlapping stack**: circles from the icon wrapper tokens, a negative `margin-inline-start` on
127
+ every `.social-icons-list-item + .social-icons-list-item`, reset on the list's `:hover` and
128
+ `:focus-within` so keyboard users get the fan-out too. Put the transition inside
129
+ `@media (prefers-reduced-motion: no-preference)`. A "Follow us" caption is your own text beside
130
+ the component.
131
+
43
132
  ---
44
133
 
45
134
  ## Class passthrough
@@ -1,35 +1,64 @@
1
1
  <template>
2
- <ul class="social-icons-list" :class="[elementClasses]" :aria-label="label">
3
- <li v-for="item in items" :key="item.networkName" class="social-icon-item">
2
+ <ul class="social-icons-list" :class="[elementClasses]" role="list" :aria-label="label">
3
+ <li v-for="entry in entries" :key="entry.key" class="social-icons-list-item" :style="brandStyle(entry.item)">
4
4
  <a
5
- :href="`${item.baseHref}${item.profileId}`"
6
- class="social-icon-link"
7
- :aria-label="linkLabel(item)"
5
+ :href="entry.href"
6
+ class="social-icons-list-link"
7
+ :aria-label="linkLabel(entry.item)"
8
8
  rel="noopener noreferrer"
9
9
  target="_blank"
10
10
  >
11
- <Icon :name="item.iconName" class="social-icon" aria-hidden="true" />
11
+ <span class="social-icons-list-icon-wrapper" aria-hidden="true">
12
+ <slot name="icon" :item="entry.item">
13
+ <Icon :name="entry.item.iconName" class="social-icons-list-icon" />
14
+ </slot>
15
+ </span>
16
+ <span v-if="hasLabel()" class="social-icons-list-label">
17
+ <slot name="label" :item="entry.item">{{ entry.item.networkName }}</slot>
18
+ </span>
12
19
  </a>
13
20
  </li>
14
21
  </ul>
15
22
  </template>
16
23
 
17
24
  <script setup lang="ts">
18
- import type { ISocialIcon } from "~/types/components/social-icons-list.d";
25
+ import type { ISocialIcon, SocialIconsListSlots } from "~/types/components/social-icons-list.d";
19
26
 
20
27
  interface Props {
21
28
  items: ISocialIcon[];
22
29
  label?: string;
23
30
  linkLabelTemplate?: string;
31
+ showLabel?: boolean;
24
32
  styleClassPassthrough?: string | string[];
25
33
  }
26
34
 
27
35
  const props = withDefaults(defineProps<Props>(), {
28
36
  label: "Social media profiles",
29
37
  linkLabelTemplate: "{network} profile (opens in a new tab)",
38
+ showLabel: false,
30
39
  styleClassPassthrough: () => [],
31
40
  });
32
41
 
42
+ const slots = defineSlots<SocialIconsListSlots>();
43
+
44
+ const SAFE_HREF = /^(https?:|mailto:|tel:|\/(?!\/))/i;
45
+
46
+ const entries = computed(() =>
47
+ props.items.flatMap((item, index) => {
48
+ const href = `${item.baseHref}${item.profileId}`.trim();
49
+ if (!SAFE_HREF.test(href)) {
50
+ if (import.meta.dev) console.warn(`[SocialIconsList] Skipped "${item.networkName}": unsafe href "${href}".`);
51
+ return [];
52
+ }
53
+ return [{ item, href, key: `${index}-${item.networkName}` }];
54
+ })
55
+ );
56
+
57
+ const hasLabel = () => props.showLabel || !!slots.label;
58
+
59
+ const brandStyle = (item: ISocialIcon) =>
60
+ item.brandColour ? { "--social-icons-list-brand-colour": item.brandColour } : undefined;
61
+
33
62
  const linkLabel = (item: ISocialIcon) => item.label ?? props.linkLabelTemplate.replace("{network}", item.networkName);
34
63
 
35
64
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
@@ -52,37 +81,74 @@ watch(
52
81
  padding: 0;
53
82
  margin: 0;
54
83
 
55
- .social-icon-item {
84
+ .social-icons-list-item {
56
85
  display: flex;
86
+ min-inline-size: 0;
57
87
 
58
- .social-icon-link {
88
+ .social-icons-list-link {
59
89
  display: flex;
60
90
  align-items: center;
61
- justify-content: center;
91
+ gap: var(--social-icons-list-link-gap, 0.8rem);
92
+ min-inline-size: 0;
93
+ padding: var(--social-icons-list-link-padding, 0);
94
+ border: var(--social-icons-list-link-border, none);
95
+ border-radius: var(--social-icons-list-link-radius, 0);
96
+ background: var(--social-icons-list-link-background, transparent);
97
+ box-shadow: var(--social-icons-list-link-shadow, none);
98
+ color: var(--social-icons-list-label-colour, currentColor);
62
99
  text-decoration: none;
63
100
  transition:
64
101
  transform 200ms ease,
65
- opacity 200ms ease;
102
+ opacity 200ms ease,
103
+ background-color 200ms ease,
104
+ box-shadow 200ms ease;
66
105
 
67
106
  &:hover,
68
107
  &:focus-visible {
69
108
  transform: scale(var(--social-icons-list-hover-scale, 1.15));
70
109
  opacity: var(--social-icons-list-hover-opacity, 0.85);
110
+ background: var(--social-icons-list-link-background-hover, var(--social-icons-list-link-background, transparent));
111
+ box-shadow: var(--social-icons-list-link-shadow-hover, var(--social-icons-list-link-shadow, none));
71
112
 
72
113
  @media (prefers-reduced-motion: reduce) {
73
114
  transform: none;
74
115
  }
116
+
117
+ .social-icons-list-icon {
118
+ color: var(--social-icons-list-icon-colour-hover, var(--social-icons-list-icon-colour, currentColor));
119
+ }
75
120
  }
76
121
 
77
122
  &:focus-visible {
78
- outline: 2px solid var(--theme-ring, currentColor);
123
+ outline: 2px solid var(--social-icons-list-focus-ring-colour, var(--theme-ring, currentColor));
79
124
  outline-offset: 3px;
80
- border-radius: 2px;
81
125
  }
82
126
 
83
- .social-icon {
127
+ .social-icons-list-icon-wrapper {
128
+ display: flex;
129
+ flex-shrink: 0;
130
+ align-items: center;
131
+ justify-content: center;
132
+ box-sizing: content-box;
133
+ inline-size: 1em;
134
+ block-size: 1em;
135
+ padding: var(--social-icons-list-icon-wrapper-padding, 0);
136
+ border: var(--social-icons-list-icon-wrapper-border, none);
137
+ border-radius: var(--social-icons-list-icon-wrapper-radius, 0);
138
+ background: var(--social-icons-list-icon-wrapper-background, transparent);
84
139
  font-size: var(--social-icons-list-icon-size, 2.4rem);
140
+ }
141
+
142
+ .social-icons-list-icon {
85
143
  color: var(--social-icons-list-icon-colour, currentColor);
144
+ transition: color 200ms ease;
145
+ }
146
+
147
+ .social-icons-list-label {
148
+ min-inline-size: 0;
149
+ overflow-wrap: anywhere;
150
+ font-size: var(--social-icons-list-label-font-size, var(--step-4, 1.6rem));
151
+ font-weight: var(--social-icons-list-label-font-weight, 600);
86
152
  }
87
153
  }
88
154
  }
@@ -1,6 +1,10 @@
1
+ import { ref } from "vue";
1
2
  import SocialIconsList from "../SocialIconsList.vue";
3
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
5
  import type { ISocialIcon } from "~/types/components/social-icons-list.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+ import "./social-icons-list-recipes.css";
4
8
 
5
9
  const sampleItems: ISocialIcon[] = [
6
10
  {
@@ -42,7 +46,7 @@ const meta: Meta<typeof SocialIconsList> = {
42
46
  items: {
43
47
  control: "object",
44
48
  description:
45
- "Array of social network items. Each item requires networkName, iconName (Iconify logos: collection), baseHref, and profileId.",
49
+ "Array of social network items. Each item needs networkName, iconName (any Iconify icon), baseHref and profileId; the link is baseHref + profileId. Items whose href isn't http(s), mailto, tel or site-relative are skipped.",
46
50
  },
47
51
  label: {
48
52
  control: "text",
@@ -52,6 +56,10 @@ const meta: Meta<typeof SocialIconsList> = {
52
56
  control: "text",
53
57
  description: "aria-label for each link; {network} is replaced with the item's networkName",
54
58
  },
59
+ showLabel: {
60
+ control: "boolean",
61
+ description: "Show each item's networkName as visible text after the icon (or fill the `label` slot)",
62
+ },
55
63
  styleClassPassthrough: {
56
64
  control: "object",
57
65
  description: "Additional CSS classes applied to the root element",
@@ -61,8 +69,18 @@ const meta: Meta<typeof SocialIconsList> = {
61
69
  items: sampleItems,
62
70
  label: "Social media profiles",
63
71
  linkLabelTemplate: "{network} profile (opens in a new tab)",
72
+ showLabel: false,
64
73
  styleClassPassthrough: ["m-40"],
65
74
  },
75
+ parameters: {
76
+ docs: {
77
+ description: {
78
+ component:
79
+ "A wrapping row of icon links to social profiles, built from the `items` array (`ISocialIcon`: `networkName`, `iconName`, `baseHref`, `profileId`, optional `label`). " +
80
+ "Links open in a new tab and are named from `linkLabelTemplate` or each item's `label`; `label` names the list; `showLabel` (or the scoped `label` slot) adds visible text, and the scoped `icon` slot replaces the icon. An item's optional `brandColour` is exposed as `--social-icons-list-brand-colour` for recipes. Size, tiles, hover and focus come from `--social-icons-list-*` tokens; the Recipe stories show four alternative looks built only from tokens and consumer CSS.",
81
+ },
82
+ },
83
+ },
66
84
  };
67
85
 
68
86
  export default meta;
@@ -91,6 +109,9 @@ export const LargeIcons: Story = {
91
109
  },
92
110
  template: `<SocialIconsList v-bind="args" style="--social-icons-list-icon-size: 4rem;" />`,
93
111
  }),
112
+ parameters: {
113
+ docs: { description: { story: "`--social-icons-list-icon-size: 4rem` set on the component." } },
114
+ },
94
115
  };
95
116
 
96
117
  /** Single network — only one item in the list. */
@@ -102,7 +123,7 @@ export const SingleNetwork: Story = {
102
123
  networkName: "Instagram",
103
124
  iconName: "logos:instagram-icon",
104
125
  baseHref: "https://www.instagram.com/",
105
- profileId: "luxurylocs",
126
+ profileId: "yourprofile",
106
127
  },
107
128
  ],
108
129
  },
@@ -114,3 +135,210 @@ export const SingleNetwork: Story = {
114
135
  template: `<SocialIconsList v-bind="args" />`,
115
136
  }),
116
137
  };
138
+
139
+ const stressItems: ISocialIcon[] = [
140
+ ...sampleItems,
141
+ { networkName: "Instagram", iconName: "logos:instagram-icon", baseHref: "https://www.instagram.com/", profileId: "second-account" },
142
+ {
143
+ networkName: "LinkedIn",
144
+ iconName: "logos:linkedin-icon",
145
+ baseHref: "https://www.linkedin.com/in/",
146
+ profileId: "a-very-long-profile-identifier-without-any-spaces-at-all-in-it-1234567890",
147
+ },
148
+ { networkName: "Pinterest", iconName: "logos:pinterest", baseHref: "https://www.pinterest.com/", profileId: "" },
149
+ { networkName: "Unknown icon", iconName: "logos:this-icon-does-not-exist", baseHref: "https://example.com/", profileId: "x" },
150
+ { networkName: "<script>alert('xss')</script>", iconName: "mdi:web", baseHref: "https://example.com/", profileId: "html" },
151
+ { networkName: "إنستغرام", iconName: "mdi:instagram", baseHref: "https://www.instagram.com/", profileId: "rtl", label: "ملفنا على إنستغرام" },
152
+ { networkName: "Unsafe", iconName: "mdi:alert", baseHref: "javascript:", profileId: "alert(1)" },
153
+ { networkName: "Email", iconName: "mdi:email", baseHref: "mailto:", profileId: "hello@example.com" },
154
+ { networkName: "Phone", iconName: "mdi:phone", baseHref: "tel:", profileId: "+441234567890" },
155
+ ...Array.from({ length: 10 }, (_, i) => ({
156
+ networkName: `Network ${i + 1}`,
157
+ iconName: "mdi:web",
158
+ baseHref: "https://example.com/",
159
+ profileId: `profile-${i + 1}`,
160
+ })),
161
+ ];
162
+
163
+ /**
164
+ * Stress test — duplicate networks, unbroken and empty profile IDs, a missing icon, HTML-like and
165
+ * right-to-left names, an unsafe href and twenty-plus items, inside the CanvasSwitcher.
166
+ */
167
+ export const StressTest: Story = {
168
+ name: "Stress Test (Worst-Case Data)",
169
+ args: {
170
+ items: stressItems,
171
+ label: "Datenschutzfreundliche Profile in sozialen Netzwerken unseres Unternehmens",
172
+ linkLabelTemplate: "Unser {network}-Profil (öffnet sich in einem neuen Tab)",
173
+ styleClassPassthrough: [],
174
+ },
175
+ decorators: [
176
+ (story, context) => ({
177
+ components: { story, CanvasSwitcher },
178
+ setup() {
179
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
180
+ return { canvasName };
181
+ },
182
+ template: `
183
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
184
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
185
+ </div>
186
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
187
+ <story />
188
+ </div>
189
+ `,
190
+ }),
191
+ ],
192
+ render: (args) => ({
193
+ components: { SocialIconsList },
194
+ setup() {
195
+ return { args };
196
+ },
197
+ template: `<SocialIconsList v-bind="args" />`,
198
+ }),
199
+ parameters: {
200
+ initialCanvas: "mobileCanvas",
201
+ docs: {
202
+ description: {
203
+ story:
204
+ "Check at every canvas width: the row wraps and never overflows the dashed box. Both Instagram accounts render (they used to share a `:key`). " +
205
+ "The `javascript:` item is skipped, so there's no 'Unsafe' link; mailto and tel links stay. The unknown icon still leaves a 1em focus target; tab through to check the focus ring on every link. " +
206
+ "Inspect the links for the German template, the Arabic `label` override, and the HTML-like name rendered as plain text in its `aria-label`.",
207
+ },
208
+ },
209
+ },
210
+ };
211
+
212
+ // ─── Recipes ─────────────────────────────────────────────────────────────────
213
+ // Each look is tokens plus consumer CSS on a passthrough class: see social-icons-list-recipes.css
214
+ // and the Recipes section of CONSUMER-STYLING.md.
215
+
216
+ const brandItems: ISocialIcon[] = [
217
+ { networkName: "Facebook", iconName: "bi:facebook", baseHref: "https://www.facebook.com/", profileId: "yourprofile", brandColour: "#1877f2" },
218
+ { networkName: "X", iconName: "bi:twitter-x", baseHref: "https://x.com/", profileId: "yourhandle", brandColour: "#000000" },
219
+ { networkName: "Instagram", iconName: "bi:instagram", baseHref: "https://www.instagram.com/", profileId: "yourprofile", brandColour: "#e1306c" },
220
+ { networkName: "YouTube", iconName: "bi:youtube", baseHref: "https://www.youtube.com/@", profileId: "yourchannel", brandColour: "#ff0000" },
221
+ { networkName: "TikTok", iconName: "bi:tiktok", baseHref: "https://www.tiktok.com/@", profileId: "yourprofile", brandColour: "#000000" },
222
+ ];
223
+
224
+ // Black brand colours vanish on a dark dock, so X and TikTok glow in their light/accent colours there.
225
+ const darkBrandItems: ISocialIcon[] = brandItems.map((item) =>
226
+ item.networkName === "X"
227
+ ? { ...item, brandColour: "#ffffff" }
228
+ : item.networkName === "TikTok"
229
+ ? { ...item, brandColour: "#25f4ee" }
230
+ : item
231
+ );
232
+
233
+ const recipeStory = (panelStyle: string, before = ""): Story["render"] => (args) => ({
234
+ components: { SocialIconsList },
235
+ setup() {
236
+ return { args, panelStyle };
237
+ },
238
+ template: `
239
+ <div :style="'display: flex; align-items: center; justify-content: center; gap: 1.6rem; flex-wrap: wrap; min-block-size: 24rem; padding: 4rem 2.4rem; border-radius: 2.4rem; font-family: sans-serif; ' + panelStyle">
240
+ ${before}
241
+ <SocialIconsList v-bind="args" />
242
+ </div>
243
+ `,
244
+ });
245
+
246
+ /** Mono icons on soft tiles; each icon takes its item's brandColour on hover or keyboard focus. */
247
+ export const RecipeMonoTiles: Story = {
248
+ name: "Recipe: Mono Tiles",
249
+ args: { items: brandItems, styleClassPassthrough: ["demo-social-mono-tiles"] },
250
+ render: recipeStory("background: #ffffff;"),
251
+ parameters: {
252
+ docs: {
253
+ description: {
254
+ story:
255
+ "Mono `bi:` icons, tile tokens (`--social-icons-list-link-padding`, `-radius`, `-background`, `-background-hover`) and " +
256
+ "`--social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour)` set on `.social-icons-list-item`.",
257
+ },
258
+ },
259
+ },
260
+ };
261
+
262
+ /** A dark dock around dark tiles; the hovered or focused tile glows in its brand colour. */
263
+ export const RecipeDarkDock: Story = {
264
+ name: "Recipe: Dark Dock with Glow",
265
+ args: { items: darkBrandItems, styleClassPassthrough: ["demo-social-dark-dock"] },
266
+ render: recipeStory("background: #16171b;"),
267
+ parameters: {
268
+ docs: {
269
+ description: {
270
+ story:
271
+ "The dock is the list root itself (padding, border, radius and `inline-size: fit-content` on the passthrough class). The glow is " +
272
+ "`--social-icons-list-link-shadow-hover` built from the brand colour with `color-mix()`. X and TikTok use white and cyan here, since black wouldn't show.",
273
+ },
274
+ },
275
+ },
276
+ };
277
+
278
+ /** Visible labels with the icon in a brand-coloured circle. */
279
+ export const RecipeLabelledPills: Story = {
280
+ name: "Recipe: Labelled Pills",
281
+ args: { items: brandItems, showLabel: true, styleClassPassthrough: ["demo-social-pills"] },
282
+ render: recipeStory("background: #f5f4f0;"),
283
+ parameters: {
284
+ docs: {
285
+ description: {
286
+ story:
287
+ "`show-label` adds each `networkName` as visible text; the circle is the icon wrapper (`--social-icons-list-icon-wrapper-padding`, `-radius`, " +
288
+ "and `-background` from the brand colour). The links keep their `aria-label`, which contains the visible name (WCAG 2.5.3).",
289
+ },
290
+ },
291
+ },
292
+ };
293
+
294
+ /** Overlapping circles that fan out when the row is hovered or a link has keyboard focus. */
295
+ export const RecipeOverlappingStack: Story = {
296
+ name: "Recipe: Overlapping Stack",
297
+ args: { items: brandItems, styleClassPassthrough: ["demo-social-stack"] },
298
+ render: recipeStory(
299
+ "background: #ffffff;",
300
+ `<span style="font-size: 1.6rem; font-weight: 600; color: #4a4b52;">Follow us</span>`
301
+ ),
302
+ parameters: {
303
+ docs: {
304
+ description: {
305
+ story:
306
+ "Negative `margin-inline-start` on every item after the first, reset on `:hover`/`:focus-within` of the list, so keyboard users get the fan-out too " +
307
+ "(no transition under reduced motion). \"Follow us\" is the consumer's own text beside the component, not part of it.",
308
+ },
309
+ },
310
+ },
311
+ };
312
+
313
+ /** The scoped `icon` and `label` slots: custom markup per item. */
314
+ export const CustomSlots: Story = {
315
+ name: "Custom Slots",
316
+ args: { items: brandItems, styleClassPassthrough: ["demo-social-pills"] },
317
+ render: (args) => ({
318
+ components: { SocialIconsList },
319
+ setup() {
320
+ return { args };
321
+ },
322
+ template: `
323
+ <div style="padding: 4rem 2.4rem; border-radius: 2.4rem; background: #f5f4f0; font-family: sans-serif;">
324
+ <SocialIconsList v-bind="args">
325
+ <template #icon="{ item }">
326
+ <span style="font-size: 0.8em; font-weight: 700; color: #ffffff; line-height: 1;">{{ Array.from(item.networkName)[0] }}</span>
327
+ </template>
328
+ <template #label="{ item }">
329
+ {{ item.networkName }} <small style="font-weight: 400;">@{{ item.profileId }}</small>
330
+ </template>
331
+ </SocialIconsList>
332
+ </div>
333
+ `,
334
+ }),
335
+ parameters: {
336
+ docs: {
337
+ description: {
338
+ story:
339
+ "The `icon` slot replaces the icon inside the wrapper (here the network's first letter), and the `label` slot replaces the visible text " +
340
+ "(filling it shows the label without `show-label`). Both receive `{ item }`. Slot content sits inside the link, so keep it non-interactive.",
341
+ },
342
+ },
343
+ },
344
+ };
@@ -0,0 +1,108 @@
1
+ /*
2
+ * SocialIconsList recipes, as a consumer would write them: unlayered, on a class passed through
3
+ * style-class-passthrough. Tokens that read --social-icons-list-brand-colour are set on
4
+ * .social-icons-list-item, the element that defines it (a var() in a custom property resolves where
5
+ * that property is declared, so setting them on the root would find no brand colour).
6
+ */
7
+
8
+ /* 2a: mono tiles, brand colour on hover */
9
+ .demo-social-mono-tiles {
10
+ --social-icons-list-gap: 1rem;
11
+ --social-icons-list-icon-size: 2rem;
12
+ --social-icons-list-icon-colour: #16171b;
13
+ --social-icons-list-link-padding: 1.2rem;
14
+ --social-icons-list-link-radius: 1.2rem;
15
+ --social-icons-list-link-background: #f1f2f5;
16
+ --social-icons-list-link-background-hover: #e6e8ee;
17
+ --social-icons-list-hover-scale: 1.04;
18
+ --social-icons-list-hover-opacity: 1;
19
+
20
+ .social-icons-list-item {
21
+ --social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour);
22
+ }
23
+ }
24
+
25
+ /* 2b: dark dock with glow */
26
+ .demo-social-dark-dock {
27
+ inline-size: fit-content;
28
+ padding: 0.8rem;
29
+ border: 1px solid #34353b;
30
+ border-radius: 2.2rem;
31
+ background: #232428;
32
+ --social-icons-list-gap: 0.8rem;
33
+ --social-icons-list-icon-size: 2rem;
34
+ --social-icons-list-icon-colour: #ffffff;
35
+ --social-icons-list-link-padding: 1.2rem;
36
+ --social-icons-list-link-radius: 1.4rem;
37
+ --social-icons-list-link-background: #1a1b1f;
38
+ --social-icons-list-hover-scale: 1;
39
+ --social-icons-list-hover-opacity: 1;
40
+
41
+ .social-icons-list-item {
42
+ --social-icons-list-link-shadow-hover:
43
+ 0 0 0 1px color-mix(in oklab, var(--social-icons-list-brand-colour) 60%, transparent),
44
+ 0 0 1.6rem color-mix(in oklab, var(--social-icons-list-brand-colour) 55%, transparent);
45
+ --social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour);
46
+ }
47
+ }
48
+
49
+ /* 2c: labelled pills (show-label) */
50
+ .demo-social-pills {
51
+ justify-content: center;
52
+ --social-icons-list-gap: 1rem;
53
+ --social-icons-list-icon-size: 1.6rem;
54
+ --social-icons-list-icon-colour: #ffffff;
55
+ --social-icons-list-link-padding: 0.4rem 1.4rem 0.4rem 0.4rem;
56
+ --social-icons-list-link-gap: 0.8rem;
57
+ --social-icons-list-link-border: 1.5px solid #16171b;
58
+ --social-icons-list-link-radius: 99rem;
59
+ --social-icons-list-link-background: #ffffff;
60
+ --social-icons-list-label-colour: #16171b;
61
+ --social-icons-list-icon-wrapper-padding: 0.6rem;
62
+ --social-icons-list-icon-wrapper-radius: 50%;
63
+ --social-icons-list-hover-scale: 1.03;
64
+ --social-icons-list-hover-opacity: 1;
65
+
66
+ .social-icons-list-item {
67
+ --social-icons-list-icon-wrapper-background: var(--social-icons-list-brand-colour);
68
+ }
69
+ }
70
+
71
+ /* 2d: overlapping stack, fans out on hover or keyboard focus */
72
+ .demo-social-stack {
73
+ --social-icons-list-gap: 0;
74
+ --social-icons-list-icon-size: 1.8rem;
75
+ --social-icons-list-icon-colour: #ffffff;
76
+ --social-icons-list-icon-wrapper-padding: 1rem;
77
+ --social-icons-list-icon-wrapper-radius: 50%;
78
+ --social-icons-list-icon-wrapper-border: 2px solid #ffffff;
79
+ --social-icons-list-link-radius: 50%;
80
+ --social-icons-list-hover-scale: 1.08;
81
+ --social-icons-list-hover-opacity: 1;
82
+
83
+ .social-icons-list-item {
84
+ --social-icons-list-icon-wrapper-background: var(--social-icons-list-brand-colour);
85
+
86
+ & + .social-icons-list-item {
87
+ margin-inline-start: -1.2rem;
88
+ }
89
+
90
+ &:hover,
91
+ &:focus-within {
92
+ z-index: 1;
93
+ }
94
+ }
95
+
96
+ &:hover,
97
+ &:focus-within {
98
+ .social-icons-list-item + .social-icons-list-item {
99
+ margin-inline-start: 0.4rem;
100
+ }
101
+ }
102
+
103
+ @media (prefers-reduced-motion: no-preference) {
104
+ .social-icons-list-item {
105
+ transition: margin-inline-start 250ms ease-out;
106
+ }
107
+ }
108
+ }