srcdev-nuxt-components 9.6.4 → 9.6.6
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/a11y-no-focus-steal.md +14 -0
- package/.claude/skills/component-compliance-checklist.md +14 -2
- package/.claude/skills/components/display-toast.md +6 -3
- package/.claude/skills/components/progress-tracker.md +140 -0
- package/.claude/skills/components/social-icons-list.md +32 -5
- package/.claude/skills/components/stepper-list.md +10 -3
- package/.claude/skills/components/tabbed-content.md +4 -0
- package/.claude/skills/css-dynamic-border-radius.md +114 -0
- package/.claude/skills/index.md +4 -2
- package/.claude/skills/storybook-add-story.md +12 -0
- package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
- package/.vscode/srcdev-component-progress-tracker.code-snippets +45 -0
- package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
- package/.vscode/srcdev-component-stepper-list.code-snippets +3 -2
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -0
- package/.vscode/srcdev-utility-dynamic-border-radius.code-snippets +31 -0
- package/app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css +27 -0
- package/app/assets/styles/setup/06.utility-classes/index.css +1 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +5 -1
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +2 -2
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +26 -0
- package/app/components/02.molecules/contact-section/tests/__snapshots__/ContactSection.spec.ts.snap +28 -28
- package/app/components/02.molecules/progress-tracker/CONSUMER-STYLING.md +116 -0
- package/app/components/02.molecules/progress-tracker/ProgressTracker.vue +275 -0
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +424 -0
- package/app/components/02.molecules/progress-tracker/tests/ProgressTracker.spec.ts +253 -0
- package/app/components/02.molecules/progress-tracker/tests/__snapshots__/ProgressTracker.spec.ts.snap +46 -0
- package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +97 -8
- package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +80 -14
- package/app/components/02.molecules/social-icons-list/stories/SocialIconsList.stories.ts +230 -2
- package/app/components/02.molecules/social-icons-list/stories/social-icons-list-recipes.css +108 -0
- package/app/components/02.molecules/social-icons-list/tests/SocialIconsList.spec.ts +123 -52
- package/app/components/02.molecules/social-icons-list/tests/__snapshots__/SocialIconsList.spec.ts.snap +10 -4
- package/app/components/02.molecules/stepper-list/CONSUMER-STYLING.md +42 -7
- package/app/components/02.molecules/stepper-list/StepperList.vue +42 -19
- package/app/components/02.molecules/stepper-list/stories/StepperList.stories.ts +288 -2
- package/app/components/02.molecules/stepper-list/tests/StepperList.spec.ts +161 -34
- package/app/components/02.molecules/stepper-list/tests/__snapshots__/StepperList.spec.ts.snap +40 -40
- package/app/components/02.molecules/toast/CONSUMER-STYLING.md +9 -3
- package/app/components/02.molecules/toast/DisplayToast.vue +10 -10
- package/app/components/02.molecules/toast/DisplayToastProvider.vue +12 -4
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +200 -2
- package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +33 -12
- package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +20 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +7 -7
- package/app/types/components/index.ts +1 -0
- package/app/types/components/progress-tracker.d.ts +6 -0
- package/app/types/components/social-icons-list.d.ts +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ProgressTracker > renders correct HTML structure 1`] = `
|
|
4
|
+
"<div class="progress-tracker">
|
|
5
|
+
<div class="progress-tracker-header">
|
|
6
|
+
<p class="progress-tracker-label" aria-live="polite" tabindex="-1">Step 2 of 3</p>
|
|
7
|
+
<div class="progress-tracker-bar" role="progressbar" aria-valuemin="0" aria-valuemax="3" aria-valuenow="1" aria-valuetext="Step 2 of 3" style="--_progress-tracker-progress: 0.3333333333333333;"><span class="progress-tracker-bar-fill"></span></div>
|
|
8
|
+
</div>
|
|
9
|
+
<ol role="list" class="stepper-list has-connectors progress-tracker-list">
|
|
10
|
+
<li class="stepper-list-item has-indicator indicator-top indicator-disc" style="--_connector-left: 0px; --_connector-top: 0px; --_connector-height: 0px;">
|
|
11
|
+
<div class="stepper-list__indicator-custom"><span class="progress-tracker-indicator is-done" aria-hidden="true"><span class="iconify i-lucide:check progress-tracker-indicator-icon" aria-hidden="true"></span></span></div>
|
|
12
|
+
<div class="stepper-list-content">
|
|
13
|
+
<div class="progress-tracker-step is-done">
|
|
14
|
+
<h3 id="v-0-0-step-0" class="progress-tracker-title" tabindex="-1">Create your account<span class="sr-only">, Completed</span></h3>
|
|
15
|
+
<p class="progress-tracker-body">Enter your email and choose a password.</p>
|
|
16
|
+
<!--v-if-->
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</li>
|
|
20
|
+
<li class="stepper-list-item has-indicator indicator-top indicator-disc" style="--_connector-left: 0px; --_connector-top: 0px; --_connector-height: 0px;">
|
|
21
|
+
<div class="stepper-list__indicator-custom"><span class="progress-tracker-indicator is-current" aria-hidden="true">2</span></div>
|
|
22
|
+
<div class="stepper-list-content">
|
|
23
|
+
<div class="progress-tracker-step is-current">
|
|
24
|
+
<h3 id="v-0-0-step-1" class="progress-tracker-title" tabindex="-1" aria-current="step">Verify your email
|
|
25
|
+
<!--v-if-->
|
|
26
|
+
</h3>
|
|
27
|
+
<p class="progress-tracker-body">Click the link we sent to your inbox.</p>
|
|
28
|
+
<!--v-if-->
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</li>
|
|
32
|
+
<li class="stepper-list-item has-indicator indicator-top indicator-disc">
|
|
33
|
+
<div class="stepper-list__indicator-custom"><span class="progress-tracker-indicator is-upcoming" aria-hidden="true">3</span></div>
|
|
34
|
+
<div class="stepper-list-content">
|
|
35
|
+
<div class="progress-tracker-step is-upcoming">
|
|
36
|
+
<h3 id="v-0-0-step-2" class="progress-tracker-title" tabindex="-1">Choose a plan
|
|
37
|
+
<!--v-if-->
|
|
38
|
+
</h3>
|
|
39
|
+
<!--v-if-->
|
|
40
|
+
<!--v-if-->
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</li>
|
|
44
|
+
</ol>
|
|
45
|
+
</div>"
|
|
46
|
+
`;
|
|
@@ -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-
|
|
8
|
-
| `--social-icons-list-gap` | `
|
|
9
|
-
| `--social-icons-list-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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-
|
|
31
|
-
tab), `.social-icon` (the
|
|
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="
|
|
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="
|
|
6
|
-
class="social-
|
|
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
|
-
<
|
|
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-
|
|
84
|
+
.social-icons-list-item {
|
|
56
85
|
display: flex;
|
|
86
|
+
min-inline-size: 0;
|
|
57
87
|
|
|
58
|
-
.social-
|
|
88
|
+
.social-icons-list-link {
|
|
59
89
|
display: flex;
|
|
60
90
|
align-items: center;
|
|
61
|
-
|
|
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
|
|
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: "
|
|
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
|
+
};
|