srcdev-nuxt-components 9.6.1 → 9.6.2
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 +14 -1
- package/.claude/skills/components/display-avatar.md +14 -3
- package/.claude/skills/components/display-chip.md +2 -1
- package/.claude/skills/components/google-reviews.md +2 -2
- package/.claude/skills/components/profile-section.md +59 -21
- package/.claude/skills/components/social-icons-list.md +2 -0
- package/.claude/skills/index.md +4 -3
- package/.claude/skills/storybook-add-story.md +2 -0
- package/.claude/skills/theming-component-token-pattern.md +48 -0
- package/.vscode/srcdev-component-display-avatar.code-snippets +7 -0
- package/.vscode/srcdev-component-profile-section.code-snippets +27 -4
- package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +26 -10
- package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +75 -34
- package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +84 -3
- package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +85 -13
- package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +3 -3
- package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +7 -2
- package/app/components/02.molecules/profile-section/CONSUMER-STYLING.md +103 -13
- package/app/components/02.molecules/profile-section/ProfileSection.vue +123 -23
- package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +353 -115
- package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +144 -11
- package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +6 -11
- package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +6 -2
- package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +1 -0
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +1 -1
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +6 -6
- package/package.json +1 -1
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
:is="chip ? DisplayChip : as"
|
|
4
4
|
v-bind="chip ? { tag: chipTag, config: chipConfig, status, statusLabel } : {}"
|
|
5
5
|
class="display-avatar"
|
|
6
|
-
:class="
|
|
6
|
+
:class="elementClasses"
|
|
7
|
+
:data-size="size"
|
|
7
8
|
>
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
<span class="display-avatar-surface">
|
|
10
|
+
<slot name="default">
|
|
11
|
+
<NuxtImg
|
|
12
|
+
v-if="src && !imageFailed"
|
|
13
|
+
:src
|
|
14
|
+
:alt="alt ?? ''"
|
|
15
|
+
:width="imageSize"
|
|
16
|
+
:height="imageSize"
|
|
17
|
+
class="display-avatar-image"
|
|
18
|
+
@error="imageFailed = true"
|
|
19
|
+
/>
|
|
20
|
+
<template v-else>
|
|
21
|
+
<span class="display-avatar-fallback" :aria-hidden="alt ? 'true' : undefined">{{ fallback }}</span>
|
|
22
|
+
<span v-if="alt" class="sr-only">{{ alt }}</span>
|
|
23
|
+
</template>
|
|
24
|
+
</slot>
|
|
25
|
+
<slot name="icon"></slot>
|
|
26
|
+
</span>
|
|
16
27
|
</component>
|
|
17
28
|
</template>
|
|
18
29
|
|
|
@@ -29,6 +40,7 @@ interface Props {
|
|
|
29
40
|
chip?: boolean | DisplayChipConfig;
|
|
30
41
|
status?: DisplayChipStatus;
|
|
31
42
|
statusLabel?: string;
|
|
43
|
+
imageSize?: number;
|
|
32
44
|
styleClassPassthrough?: string | string[];
|
|
33
45
|
}
|
|
34
46
|
|
|
@@ -41,6 +53,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
41
53
|
chip: undefined,
|
|
42
54
|
status: undefined,
|
|
43
55
|
statusLabel: undefined,
|
|
56
|
+
imageSize: 112,
|
|
44
57
|
styleClassPassthrough: () => [],
|
|
45
58
|
});
|
|
46
59
|
|
|
@@ -51,14 +64,26 @@ watch(
|
|
|
51
64
|
() => resetElementClasses(props.styleClassPassthrough)
|
|
52
65
|
);
|
|
53
66
|
|
|
67
|
+
const imageFailed = ref(false);
|
|
68
|
+
|
|
69
|
+
watch(
|
|
70
|
+
() => props.src,
|
|
71
|
+
() => (imageFailed.value = false)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
75
|
+
const firstGrapheme = (word: string) => graphemes.segment(word)[Symbol.iterator]().next().value?.segment ?? "";
|
|
76
|
+
|
|
54
77
|
const fallback = computed(
|
|
55
78
|
() =>
|
|
56
|
-
props.text ||
|
|
79
|
+
props.text?.trim() ||
|
|
57
80
|
(props.alt || "")
|
|
58
|
-
.
|
|
59
|
-
.
|
|
81
|
+
.trim()
|
|
82
|
+
.split(/\s+/)
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.slice(0, 2)
|
|
85
|
+
.map(firstGrapheme)
|
|
60
86
|
.join("")
|
|
61
|
-
.substring(0, 2)
|
|
62
87
|
);
|
|
63
88
|
|
|
64
89
|
const chipDefaultConfig: DisplayChipConfig = {
|
|
@@ -76,36 +101,52 @@ const chipTag = computed((): "div" | "span" => (props.as === "div" || props.as =
|
|
|
76
101
|
<style lang="css">
|
|
77
102
|
@layer components {
|
|
78
103
|
.display-avatar {
|
|
79
|
-
--
|
|
80
|
-
--
|
|
104
|
+
--_avatar-size: var(--display-avatar-size-md, 4rem);
|
|
105
|
+
--_avatar-font-size: var(--display-avatar-font-size-md, 1.6rem);
|
|
81
106
|
|
|
82
107
|
display: flex;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
font-size: var(--display-avatar-font-size, var(--_font-size));
|
|
108
|
+
flex-shrink: 0;
|
|
109
|
+
width: var(--display-avatar-size, var(--_avatar-size));
|
|
110
|
+
height: var(--display-avatar-size, var(--_avatar-size));
|
|
111
|
+
font-size: var(--display-avatar-font-size, var(--_avatar-font-size));
|
|
88
112
|
border-radius: var(--display-avatar-border-radius, 50%);
|
|
89
|
-
background-color: var(--display-avatar-background, var(--theme-surface-subtle));
|
|
90
|
-
color: var(--display-avatar-text-colour, var(--theme-text));
|
|
91
113
|
|
|
92
114
|
isolation: isolate;
|
|
93
115
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
116
|
+
/* Fills the root so DisplayChip's per-child cutout mask is sized to the whole avatar. */
|
|
117
|
+
.display-avatar-surface {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
justify-content: center;
|
|
121
|
+
width: 100%;
|
|
122
|
+
height: 100%;
|
|
123
|
+
border-radius: inherit;
|
|
124
|
+
background-color: var(--display-avatar-background, var(--theme-surface-subtle));
|
|
125
|
+
color: var(--display-avatar-text-colour, var(--theme-text));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
&[data-size="xs"] {
|
|
129
|
+
--_avatar-size: var(--display-avatar-size-xs, 2.4rem);
|
|
130
|
+
--_avatar-font-size: var(--display-avatar-font-size-xs, 1.2rem);
|
|
131
|
+
}
|
|
132
|
+
&[data-size="s"] {
|
|
133
|
+
--_avatar-size: var(--display-avatar-size-s, 3.2rem);
|
|
134
|
+
--_avatar-font-size: var(--display-avatar-font-size-s, 1.4rem);
|
|
97
135
|
}
|
|
98
|
-
|
|
99
|
-
--
|
|
100
|
-
--
|
|
136
|
+
&[data-size="lg"] {
|
|
137
|
+
--_avatar-size: var(--display-avatar-size-lg, 4.8rem);
|
|
138
|
+
--_avatar-font-size: var(--display-avatar-font-size-lg, 1.8rem);
|
|
101
139
|
}
|
|
102
|
-
|
|
103
|
-
--
|
|
104
|
-
--
|
|
140
|
+
&[data-size="xl"] {
|
|
141
|
+
--_avatar-size: var(--display-avatar-size-xl, 5.6rem);
|
|
142
|
+
--_avatar-font-size: var(--display-avatar-font-size-xl, 2rem);
|
|
105
143
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
144
|
+
|
|
145
|
+
.display-avatar-fallback {
|
|
146
|
+
min-inline-size: 0;
|
|
147
|
+
max-inline-size: 100%;
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
white-space: nowrap;
|
|
109
150
|
}
|
|
110
151
|
|
|
111
152
|
.display-avatar-image {
|
|
@@ -98,6 +98,16 @@ export default {
|
|
|
98
98
|
},
|
|
99
99
|
} as Meta<typeof StorybookComponent>;
|
|
100
100
|
|
|
101
|
+
// Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
|
|
102
|
+
const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
|
|
103
|
+
const googleReviewsNote = `
|
|
104
|
+
<p style="${storyNoteStyle}">
|
|
105
|
+
GoogleReviewCard puts an avatar beside each reviewer's name, with their photo or initials and its own avatar colour tokens. See the
|
|
106
|
+
<a href="/?path=/story/organisms-google-reviews--default" target="_top">Google Reviews</a> story, and
|
|
107
|
+
<a href="/?path=/story/organisms-google-reviews--no-avatars" target="_top">No Avatars</a> for the initials fallback.
|
|
108
|
+
</p>
|
|
109
|
+
`;
|
|
110
|
+
|
|
101
111
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
112
|
const useChip = (args: any) =>
|
|
103
113
|
computed(() => ({
|
|
@@ -113,7 +123,8 @@ const Template: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
|
113
123
|
return { args, chip: useChip(args) };
|
|
114
124
|
},
|
|
115
125
|
template: `
|
|
116
|
-
|
|
126
|
+
${googleReviewsNote}
|
|
127
|
+
<div style="display: flex; align-items: center; justify-content: center; min-height: 80vh;">
|
|
117
128
|
<StorybookComponent
|
|
118
129
|
:size="args.size"
|
|
119
130
|
:src="args.src"
|
|
@@ -135,7 +146,8 @@ export const Initials: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
|
135
146
|
return { args, chip: useChip(args) };
|
|
136
147
|
},
|
|
137
148
|
template: `
|
|
138
|
-
|
|
149
|
+
${googleReviewsNote}
|
|
150
|
+
<div style="display: flex; align-items: center; justify-content: center; gap: 1.6rem; min-height: 80vh;">
|
|
139
151
|
<StorybookComponent :size="args.size" :alt="args.alt" :chip="chip" :status="args.status" :status-label="args.statusLabel" :style-class-passthrough="args.styleClassPassthrough" />
|
|
140
152
|
<StorybookComponent :size="args.size" text="?" :chip="chip" :status="args.status" :status-label="args.statusLabel" :style-class-passthrough="args.styleClassPassthrough" />
|
|
141
153
|
</div>
|
|
@@ -150,7 +162,8 @@ export const Sizes: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
|
150
162
|
return { args, chip: useChip(args), sizes: ["xs", "s", "md", "lg", "xl"] };
|
|
151
163
|
},
|
|
152
164
|
template: `
|
|
153
|
-
|
|
165
|
+
${googleReviewsNote}
|
|
166
|
+
<div style="display: flex; align-items: center; justify-content: center; gap: 1.6rem; min-height: 80vh;">
|
|
154
167
|
<StorybookComponent
|
|
155
168
|
v-for="size in sizes"
|
|
156
169
|
:key="size"
|
|
@@ -167,3 +180,71 @@ export const Sizes: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
|
167
180
|
});
|
|
168
181
|
Sizes.args = { src: undefined, alt: "Jane Smith" };
|
|
169
182
|
Sizes.argTypes = { size: { table: { disable: true } } };
|
|
183
|
+
|
|
184
|
+
const stressAvatars = [
|
|
185
|
+
{ key: "long-text", caption: "Long text prop", props: { text: "Administrator account" } },
|
|
186
|
+
{ key: "unbroken", caption: "Unbroken alt", props: { alt: "Pneumonoultramicroscopicsilicovolcanoconiosis" } },
|
|
187
|
+
{ key: "german", caption: "Long German name", props: { alt: "Maximilian-Alexander von Hohenzollern-Sigmaringen" } },
|
|
188
|
+
{ key: "emoji", caption: "Emoji first", props: { alt: "🦄 Unicorn Team" } },
|
|
189
|
+
{ key: "zwj", caption: "ZWJ family emoji", props: { alt: "👩👩👧 Family" } },
|
|
190
|
+
{ key: "flags", caption: "Flag emoji", props: { alt: "🇬🇧 🇫🇷" } },
|
|
191
|
+
{ key: "whitespace", caption: "Extra whitespace", props: { alt: " Jane Smith " } },
|
|
192
|
+
{ key: "text-blank", caption: "Whitespace text prop", props: { alt: "Jane Smith", text: " " } },
|
|
193
|
+
{ key: "single", caption: "Single character", props: { alt: "J" } },
|
|
194
|
+
{ key: "empty", caption: "No alt, text or src", props: {} },
|
|
195
|
+
{ key: "html", caption: "HTML-like alt", props: { alt: "<b>Bold</b> <script>" } },
|
|
196
|
+
{ key: "rtl", caption: "Right-to-left", props: { alt: "محمد علي" }, dir: "rtl" },
|
|
197
|
+
{ key: "wide", caption: "Widest glyphs", props: { text: "WW" } },
|
|
198
|
+
{ key: "broken", caption: "Broken src", props: { src: "/does-not-exist.jpg", alt: "Jane Smith" } },
|
|
199
|
+
{ key: "broken-no-alt", caption: "Broken src, no alt", props: { src: "/does-not-exist.jpg" } },
|
|
200
|
+
{ key: "custom-size", caption: "Unknown size string", props: { alt: "Jane Smith", size: "huge" } },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
export const StressTest: StoryFn<typeof StorybookComponent> = () => ({
|
|
204
|
+
components: { StorybookComponent },
|
|
205
|
+
setup() {
|
|
206
|
+
return { stressAvatars, sizes: ["xs", "s", "md", "lg", "xl"] };
|
|
207
|
+
},
|
|
208
|
+
template: `
|
|
209
|
+
${googleReviewsNote}
|
|
210
|
+
<div style="display: grid; gap: 4.8rem; padding: 4rem 2rem; font: 1.2rem sans-serif;">
|
|
211
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 3.2rem 2.4rem;">
|
|
212
|
+
<figure
|
|
213
|
+
v-for="avatar in stressAvatars"
|
|
214
|
+
:key="avatar.key"
|
|
215
|
+
:dir="avatar.dir"
|
|
216
|
+
style="margin: 0; display: grid; justify-items: center; align-content: start; gap: 1.2rem; min-inline-size: 0;"
|
|
217
|
+
>
|
|
218
|
+
<StorybookComponent v-bind="avatar.props" :chip="true" status="online" status-label="Online" />
|
|
219
|
+
<figcaption style="text-align: center; overflow-wrap: anywhere;">{{ avatar.caption }}</figcaption>
|
|
220
|
+
</figure>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div style="display: flex; align-items: center; gap: 1.6rem;">
|
|
224
|
+
<StorybookComponent v-for="size in sizes" :key="size" :size="size" text="WW" />
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div style="display: grid; gap: 1.2rem; inline-size: 20rem; border: 1px dashed currentColor; padding: 0.8rem;">
|
|
228
|
+
<div v-for="size in sizes" :key="size" style="display: flex; align-items: center; gap: 0.8rem;">
|
|
229
|
+
<StorybookComponent :size="size" alt="Jane Smith" />
|
|
230
|
+
<span style="min-inline-size: 0; overflow-wrap: anywhere;">Averyveryverylongunbrokenusername@example.com</span>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
`,
|
|
235
|
+
});
|
|
236
|
+
StressTest.storyName = "Stress Test (Worst-Case Data)";
|
|
237
|
+
StressTest.parameters = {
|
|
238
|
+
// Fixed showcase: hardcoded values, so Controls would do nothing here.
|
|
239
|
+
controls: { disable: true },
|
|
240
|
+
docs: {
|
|
241
|
+
description: {
|
|
242
|
+
story:
|
|
243
|
+
"Hostile names and sources: long and unbroken text, emoji (including ZWJ and flags) at the start of a name, extra whitespace, a whitespace-only " +
|
|
244
|
+
"text prop, a single character, nothing at all, HTML-like and right-to-left text, the widest Latin glyphs at every size, broken image URLs with and " +
|
|
245
|
+
"without alt, and an unknown size string. The last block puts each size in a narrow flex row next to a long unbroken name. " +
|
|
246
|
+
"Check that initials are whole characters (never a broken half emoji), overlong text is clipped inside the circle rather than spilling out, " +
|
|
247
|
+
"a broken image falls back to initials (or an empty circle with no alt), an unknown size renders at md, and no avatar squashes into an oval in the narrow rows.",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
};
|
|
@@ -64,14 +64,15 @@ describe("DisplayAvatar", () => {
|
|
|
64
64
|
|
|
65
65
|
// ─── Size ─────────────────────────────────────────────────────────────────
|
|
66
66
|
|
|
67
|
-
it("
|
|
67
|
+
it("sets data-size=\"md\" by default", async () => {
|
|
68
68
|
const wrapper = await mountSuspended(DisplayAvatar);
|
|
69
|
-
expect(wrapper.
|
|
69
|
+
expect(wrapper.attributes("data-size")).toBe("md");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
it.each(["xs", "s", "md", "lg", "xl"] as const)("
|
|
72
|
+
it.each(["xs", "s", "md", "lg", "xl"] as const)("sets data-size=\"%s\" when size='%s'", async (size) => {
|
|
73
73
|
const wrapper = await mountSuspended(DisplayAvatar, { props: { size } });
|
|
74
|
-
expect(wrapper.
|
|
74
|
+
expect(wrapper.attributes("data-size")).toBe(size);
|
|
75
|
+
expect(wrapper.classes()).not.toContain(size);
|
|
75
76
|
});
|
|
76
77
|
|
|
77
78
|
// ─── Fallback text ────────────────────────────────────────────────────────
|
|
@@ -80,33 +81,33 @@ describe("DisplayAvatar", () => {
|
|
|
80
81
|
const wrapper = await mountSuspended(DisplayAvatar, {
|
|
81
82
|
props: { alt: "John Doe" },
|
|
82
83
|
});
|
|
83
|
-
expect(wrapper.find(".display-avatar
|
|
84
|
+
expect(wrapper.find(".display-avatar-fallback").text()).toBe("JD");
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
it("caps initials at two characters", async () => {
|
|
87
88
|
const wrapper = await mountSuspended(DisplayAvatar, {
|
|
88
89
|
props: { alt: "Alice Bob Charlie" },
|
|
89
90
|
});
|
|
90
|
-
expect(wrapper.find(".display-avatar
|
|
91
|
+
expect(wrapper.find(".display-avatar-fallback").text()).toBe("AB");
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
it("shows single initial for a single-word alt", async () => {
|
|
94
95
|
const wrapper = await mountSuspended(DisplayAvatar, {
|
|
95
96
|
props: { alt: "Alice" },
|
|
96
97
|
});
|
|
97
|
-
expect(wrapper.find(".display-avatar
|
|
98
|
+
expect(wrapper.find(".display-avatar-fallback").text()).toBe("A");
|
|
98
99
|
});
|
|
99
100
|
|
|
100
101
|
it("shows text prop instead of alt-derived initials when text is set", async () => {
|
|
101
102
|
const wrapper = await mountSuspended(DisplayAvatar, {
|
|
102
103
|
props: { alt: "John Doe", text: "?" },
|
|
103
104
|
});
|
|
104
|
-
expect(wrapper.find(".display-avatar
|
|
105
|
+
expect(wrapper.find(".display-avatar-fallback").text()).toBe("?");
|
|
105
106
|
});
|
|
106
107
|
|
|
107
108
|
it("shows empty fallback when neither src, text, nor alt are set", async () => {
|
|
108
109
|
const wrapper = await mountSuspended(DisplayAvatar);
|
|
109
|
-
expect(wrapper.find(".display-avatar
|
|
110
|
+
expect(wrapper.find(".display-avatar-fallback").text()).toBe("");
|
|
110
111
|
});
|
|
111
112
|
|
|
112
113
|
// ─── Image ────────────────────────────────────────────────────────────────
|
|
@@ -210,13 +211,13 @@ describe("DisplayAvatar", () => {
|
|
|
210
211
|
|
|
211
212
|
it("hides the initials from screen readers and announces the alt instead", async () => {
|
|
212
213
|
const wrapper = await mountSuspended(DisplayAvatar, { props: { alt: "John Doe" } });
|
|
213
|
-
expect(wrapper.find(".display-avatar
|
|
214
|
+
expect(wrapper.find(".display-avatar-fallback").attributes("aria-hidden")).toBe("true");
|
|
214
215
|
expect(wrapper.find(".sr-only").text()).toBe("John Doe");
|
|
215
216
|
});
|
|
216
217
|
|
|
217
218
|
it("leaves text-only fallback readable when there is no alt", async () => {
|
|
218
219
|
const wrapper = await mountSuspended(DisplayAvatar, { props: { text: "?" } });
|
|
219
|
-
expect(wrapper.find(".display-avatar
|
|
220
|
+
expect(wrapper.find(".display-avatar-fallback").attributes("aria-hidden")).toBeUndefined();
|
|
220
221
|
expect(wrapper.find(".sr-only").exists()).toBe(false);
|
|
221
222
|
});
|
|
222
223
|
|
|
@@ -225,11 +226,12 @@ describe("DisplayAvatar", () => {
|
|
|
225
226
|
expect(wrapper.attributes("style-class-passthrough")).toBeUndefined();
|
|
226
227
|
});
|
|
227
228
|
|
|
228
|
-
it("puts display-avatar, size and passthrough classes on the DisplayChip root", async () => {
|
|
229
|
+
it("puts display-avatar, data-size and passthrough classes on the DisplayChip root", async () => {
|
|
229
230
|
const wrapper = await mountSuspended(DisplayAvatar, {
|
|
230
231
|
props: { chip: true, size: "lg", styleClassPassthrough: ["online"] },
|
|
231
232
|
});
|
|
232
|
-
expect(wrapper.classes()).toEqual(expect.arrayContaining(["display-chip", "display-avatar", "
|
|
233
|
+
expect(wrapper.classes()).toEqual(expect.arrayContaining(["display-chip", "display-avatar", "online"]));
|
|
234
|
+
expect(wrapper.attributes("data-size")).toBe("lg");
|
|
233
235
|
});
|
|
234
236
|
|
|
235
237
|
it("forwards status and statusLabel to the chip", async () => {
|
|
@@ -256,4 +258,74 @@ describe("DisplayAvatar", () => {
|
|
|
256
258
|
await wrapper.setProps({ chip: { size: "20px", maskWidth: "2px", offset: "0px", angle: "0deg" } });
|
|
257
259
|
expect(wrapper.findComponent(DisplayChip).props("config")).toMatchObject({ size: "20px" });
|
|
258
260
|
});
|
|
261
|
+
|
|
262
|
+
// ─── Stress test 2026-10-14 ────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
const initialsFor = async (alt: string) => {
|
|
265
|
+
const wrapper = await mountSuspended(DisplayAvatar, { props: { alt } });
|
|
266
|
+
return wrapper.find(".display-avatar-fallback").text();
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
it("takes whole emoji as initials, not half a surrogate pair", async () => {
|
|
270
|
+
expect(await initialsFor("🦄 Unicorn")).toBe("🦄U");
|
|
271
|
+
expect(await initialsFor("👩👩👧 Family")).toBe("👩👩👧F");
|
|
272
|
+
expect(await initialsFor("🇬🇧 Team")).toBe("🇬🇧T");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("ignores leading, trailing and repeated whitespace in alt", async () => {
|
|
276
|
+
expect(await initialsFor(" Jane Smith ")).toBe("JS");
|
|
277
|
+
expect(await initialsFor("Jane\tSmith")).toBe("JS");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("falls back to alt initials when text is whitespace only", async () => {
|
|
281
|
+
const wrapper = await mountSuspended(DisplayAvatar, { props: { alt: "Jane Smith", text: " " } });
|
|
282
|
+
expect(wrapper.find(".display-avatar-fallback").text()).toBe("JS");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("renders HTML-like alt as text", async () => {
|
|
286
|
+
const wrapper = await mountSuspended(DisplayAvatar, { props: { alt: "<b>Bold</b> <script>" } });
|
|
287
|
+
expect(wrapper.find("b").exists()).toBe(false);
|
|
288
|
+
expect(wrapper.find(".sr-only").text()).toBe("<b>Bold</b> <script>");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("passes a numeric width and height to the image", async () => {
|
|
292
|
+
const wrapper = await mountSuspended(DisplayAvatar, { props: { src: "/avatar.jpg" } });
|
|
293
|
+
const img = wrapper.find(".display-avatar-image");
|
|
294
|
+
expect(img.attributes("width")).toBe("112");
|
|
295
|
+
expect(img.attributes("height")).toBe("112");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("uses imageSize for the image's width and height", async () => {
|
|
299
|
+
const wrapper = await mountSuspended(DisplayAvatar, { props: { src: "/avatar.jpg", imageSize: 240 } });
|
|
300
|
+
expect(wrapper.find(".display-avatar-image").attributes("width")).toBe("240");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("shows the initials fallback when the image fails to load", async () => {
|
|
304
|
+
const wrapper = await mountSuspended(DisplayAvatar, { props: { src: "/broken.jpg", alt: "Jane Smith" } });
|
|
305
|
+
await wrapper.find(".display-avatar-image").trigger("error");
|
|
306
|
+
expect(wrapper.find(".display-avatar-image").exists()).toBe(false);
|
|
307
|
+
expect(wrapper.find(".display-avatar-fallback").text()).toBe("JS");
|
|
308
|
+
expect(wrapper.find(".sr-only").text()).toBe("Jane Smith");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("tries the image again when src changes after a failure", async () => {
|
|
312
|
+
const wrapper = await mountSuspended(DisplayAvatar, { props: { src: "/broken.jpg", alt: "Jane Smith" } });
|
|
313
|
+
await wrapper.find(".display-avatar-image").trigger("error");
|
|
314
|
+
await wrapper.setProps({ src: "/working.jpg" });
|
|
315
|
+
expect(wrapper.find(".display-avatar-image").exists()).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("wraps all content in one surface so the chip's per-child mask covers the whole avatar", async () => {
|
|
319
|
+
const wrapper = await mountSuspended(DisplayAvatar, {
|
|
320
|
+
props: { alt: "Jane Smith", chip: true },
|
|
321
|
+
slots: { icon: "<span class='status-icon'>●</span>" },
|
|
322
|
+
});
|
|
323
|
+
const masked = Array.from((wrapper.element as HTMLElement).children).filter(
|
|
324
|
+
(el) => !el.matches(".display-chip-icon, .display-chip-label, .sr-only")
|
|
325
|
+
);
|
|
326
|
+
expect(masked).toHaveLength(1);
|
|
327
|
+
expect(masked[0]!.classList.contains("display-avatar-surface")).toBe(true);
|
|
328
|
+
expect(masked[0]!.querySelector(".display-avatar-fallback")?.textContent).toBe("JS");
|
|
329
|
+
expect(masked[0]!.querySelector(".status-icon")).not.toBeNull();
|
|
330
|
+
});
|
|
259
331
|
});
|
package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
|
-
exports[`DisplayAvatar > renders correct HTML structure (default) 1`] = `"<span class="display-avatar md"><span aria-hidden="true">JD</span><span class="sr-only">John Doe</span></span>"`;
|
|
3
|
+
exports[`DisplayAvatar > renders correct HTML structure (default) 1`] = `"<span class="display-avatar" data-size="md"><span class="display-avatar-surface"><span class="display-avatar-fallback" aria-hidden="true">JD</span><span class="sr-only">John Doe</span></span></span>"`;
|
|
4
4
|
|
|
5
5
|
exports[`DisplayAvatar > renders correct HTML structure (with chip) 1`] = `
|
|
6
|
-
"<span class="display-chip display-avatar
|
|
6
|
+
"<span class="display-chip display-avatar" data-shape="circle" data-status="offline" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;" data-size="md"><span class="display-avatar-surface"><span class="display-avatar-fallback" aria-hidden="true">JD</span><span class="sr-only">John Doe</span></span>
|
|
7
7
|
<!--v-if-->
|
|
8
8
|
<!--v-if-->
|
|
9
9
|
<!--v-if--></span>"
|
|
10
10
|
`;
|
|
11
11
|
|
|
12
|
-
exports[`DisplayAvatar > renders correct HTML structure (with src) 1`] = `"<span class="display-avatar lg"><img data-nuxt-img="" srcset="/_ipx/
|
|
12
|
+
exports[`DisplayAvatar > renders correct HTML structure (with src) 1`] = `"<span class="display-avatar" data-size="lg"><span class="display-avatar-surface"><img width="112" height="112" data-nuxt-img="" srcset="/_ipx/s_112x112/avatar.jpg 1x, /_ipx/s_224x224/avatar.jpg 2x" alt="John Doe" class="display-avatar-image" src="/_ipx/s_112x112/avatar.jpg"></span></span>"`;
|
|
@@ -58,7 +58,10 @@ maths (`--_mask-diameter`, `--_position-x`, `--_position-y`, `--_offset`, `--_ci
|
|
|
58
58
|
|
|
59
59
|
Inner elements: the dot is `.display-chip::after`; `.display-chip-icon` and `.display-chip-label`
|
|
60
60
|
sit on top of it. The `statusLabel` text is a `.sr-only` span. Every other direct child gets the
|
|
61
|
-
cutout `mask-image`.
|
|
61
|
+
cutout `mask-image`. The mask is positioned in percentages of each child's own box, so the slot
|
|
62
|
+
should be one element that fills the chip and carries any background: a small child (a bare text
|
|
63
|
+
span) gets the notch cut into its own content, and a background on the root is never masked.
|
|
64
|
+
`DisplayAvatar` uses a full-size `.display-avatar-surface` for this.
|
|
62
65
|
|
|
63
66
|
> Changed 2026-10-08: shape and status are `data-shape`/`data-status` attributes instead of the
|
|
64
67
|
> `.circle`/`.square` and `.online`/`.idle`/`.dnd` classes, so they can't collide with a
|
|
@@ -87,4 +90,6 @@ let the token apply.
|
|
|
87
90
|
## Class passthrough
|
|
88
91
|
|
|
89
92
|
`style-class-passthrough` adds classes to the root `.display-chip`. Reactive after mount.
|
|
90
|
-
`DisplayAvatar` with `chip` renders this component as its root, so the same applies there.
|
|
93
|
+
`DisplayAvatar` with `chip` renders this component as its root, so the same applies there. Because the
|
|
94
|
+
root element is shared, a wrapping component must not reuse this component's private token names
|
|
95
|
+
(`--_size` especially) for its own values.
|
|
@@ -2,35 +2,89 @@
|
|
|
2
2
|
|
|
3
3
|
## Public token API
|
|
4
4
|
|
|
5
|
+
### Grid and picture
|
|
6
|
+
|
|
5
7
|
| Token | Default | Controls |
|
|
6
8
|
|---|---|---|
|
|
7
|
-
| `--profile-section-gap` | `2rem` | Gap between picture and info, single column (
|
|
8
|
-
| `--profile-section-gap-wide` | `4rem` | Gap between picture and info columns (768px
|
|
9
|
-
| `--profile-section-picture-width` | `
|
|
9
|
+
| `--profile-section-gap` | `2rem` | Gap between picture and info, single column (component under 768px wide) |
|
|
10
|
+
| `--profile-section-gap-wide` | `4rem` | Gap between picture and info columns (component 768px wide or more) |
|
|
11
|
+
| `--profile-section-picture-width` | `38.4rem` | Maximum picture column width in the two-column layout. The column can shrink below it |
|
|
10
12
|
| `--profile-section-picture-aspect-ratio` | `3 / 4` | Picture frame aspect ratio |
|
|
11
|
-
| `--profile-section-picture-border-radius` | `
|
|
13
|
+
| `--profile-section-picture-border-radius` | `0.8rem` | Picture frame corner rounding |
|
|
12
14
|
| `--profile-section-info-block-gap` | `1.5rem` | Space after each `profile-info-N` block |
|
|
13
15
|
| `--profile-section-links-gap` | `1rem` | Gap between items in the `profileLinks` slot |
|
|
16
|
+
| `--profile-section-info-text-colour` | `inherit` | Text colour of the info column (blocks and links) |
|
|
17
|
+
|
|
18
|
+
### Info block highlights
|
|
19
|
+
|
|
20
|
+
| Token | Default | Controls |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `--profile-section-highlight-colour` | `var(--colour-text-accent)` | `.highlight` inside a `.location` element |
|
|
23
|
+
| `--profile-section-link-colour` | `var(--colour-link-default)` | `.highlight` inside a `.services` element |
|
|
24
|
+
| `--profile-section-link-colour-hover` | `var(--colour-link-hover)` | The same, on hover |
|
|
25
|
+
| `--profile-section-highlight-font-weight` | `600` | Weight of both highlight kinds (also sets the `wght` variation axis) |
|
|
26
|
+
|
|
27
|
+
The defaults read the global `--colour-*` tokens, so setting those on an ancestor still works.
|
|
14
28
|
|
|
15
|
-
|
|
16
|
-
`--colour-link-default` and `--colour-link-hover` (see **State hooks**).
|
|
29
|
+
### Header in picture
|
|
17
30
|
|
|
31
|
+
These only apply when `header-placement` is `picture-bottom` or `picture-top`. The scrim fades
|
|
32
|
+
upward from the bottom edge for `picture-bottom` and downward from the top edge for `picture-top`.
|
|
33
|
+
|
|
34
|
+
| Token | Default | Controls |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `--profile-section-picture-header-scrim-colour` | `#000` | Colour the scrim starts from at the frame's edge |
|
|
37
|
+
| `--profile-section-picture-header-scrim-opacity` | `75%` | Opacity of the scrim colour at its start |
|
|
38
|
+
| `--profile-section-picture-header-scrim-solid-stop` | `0%` | How far into the header the scrim stays at full strength before it starts to fade (e.g. `55%` keeps it solid behind the text) |
|
|
39
|
+
| `--profile-section-picture-header-scrim-target-colour` | `transparent` | Colour the scrim fades into at the header's inner edge. Set it to roughly the photo's colour there (often translucent) so the fade blends into the image instead of leaving a grey band or a visible edge |
|
|
40
|
+
| `--profile-section-picture-header-scrim` | gradient from the scrim colour | The whole scrim `background`; set `none` to remove it |
|
|
41
|
+
| `--profile-section-picture-header-text-colour` | `contrast-color(<scrim colour>)`, `#fff` without support | Eyebrow and heading text colour |
|
|
42
|
+
| `--profile-section-picture-header-eyebrow-colour` | `inherit` | Eyebrow colour only |
|
|
43
|
+
| `--profile-section-picture-header-padding` | `2.4rem` | Padding around the header inside the picture |
|
|
44
|
+
| `--profile-section-picture-header-gap` | `0.4rem` | Gap between eyebrow and heading |
|
|
45
|
+
|
|
46
|
+
Matching the photo is a judgement call per image: pick the target colour from the part of the photo
|
|
47
|
+
the fade ends on, and add end padding (`--profile-section-picture-header-padding`, e.g.
|
|
48
|
+
`3.2rem 3.2rem 6.4rem` for `picture-top`) so the fade sits past the text. The header only rounds the
|
|
49
|
+
corners on the frame's edge, so an opaque target colour doesn't show rounded inner corners.
|
|
50
|
+
Browsers without `color-mix()` (before Safari 16.2) start the scrim from 75% black whatever the scrim
|
|
51
|
+
colour and opacity; the solid stop and target colour still apply.
|
|
52
|
+
|
|
53
|
+
`contrast-color()` only reads the scrim colour, not the photo. If you remove the scrim, set
|
|
54
|
+
`--profile-section-picture-header-text-colour` yourself to suit the image. The eyebrow's own
|
|
55
|
+
`--eyebrow-text-bg-img` is set to `none` inside the picture.
|
|
56
|
+
|
|
57
|
+
> Changed 2026-10-14: new `header-placement` prop and its tokens, and `--profile-section-info-text-colour`. The scrim
|
|
58
|
+
> gained opacity, solid-stop and target-colour tokens. The image is now absolutely positioned
|
|
59
|
+
> inside the frame (no visual change), so a header in the frame can grow it past its aspect ratio.
|
|
60
|
+
>
|
|
61
|
+
> Changed 2026-10-14: the highlight colours read the global `--colour-*` tokens directly; they now
|
|
62
|
+
> go through the `--profile-section-*` tokens above, with the same defaults. `384px` and `8px`
|
|
63
|
+
> became the same values in rem.
|
|
64
|
+
>
|
|
18
65
|
> Changed 2026-09-27: all of these were hardcoded values; they're now tokens with the same
|
|
19
66
|
> defaults.
|
|
20
67
|
|
|
21
|
-
|
|
68
|
+
Private tokens (not public API): `--_scrim-colour`, the resolved scrim colour used by the gradient
|
|
69
|
+
and `contrast-color()`, `--_scrim-direction` (`to top` or `to bottom`, set by the placement), and
|
|
70
|
+
`--_scrim-start-colour`, `--_scrim-solid-stop` and `--_scrim-target-colour`, which compose the
|
|
71
|
+
gradient from the tokens above.
|
|
22
72
|
|
|
23
73
|
---
|
|
24
74
|
|
|
25
75
|
## State hooks
|
|
26
76
|
|
|
27
|
-
Inner classes: `.profile-section-header
|
|
28
|
-
`.profile-section-picture
|
|
29
|
-
|
|
30
|
-
|
|
77
|
+
Inner classes: `.profile-section-header` (only rendered when the `eyebrowText` or `heroText` slot
|
|
78
|
+
is given; it moves inside `.profile-section-picture`, before the image, when `header-placement` is
|
|
79
|
+
`picture-bottom` or `picture-top`), `.profile-section-inner` (the grid), `.profile-section-picture` (frame),
|
|
80
|
+
`.profile-section-image` (the `NuxtImg`), `.profile-section-info`,
|
|
81
|
+
`.profile-section-info-content`, `.profile-section-info-block` (one per `profile-info-N` slot
|
|
82
|
+
given), `.profile-section-links`.
|
|
31
83
|
|
|
32
|
-
|
|
33
|
-
|
|
84
|
+
`data-header-placement` on the root: `above`, `picture-bottom` or `picture-top`.
|
|
85
|
+
|
|
86
|
+
Content hooks inside info blocks: a `.highlight` inside an element with class `location` or
|
|
87
|
+
`services` gets the highlight or link colour above.
|
|
34
88
|
|
|
35
89
|
> Changed 2026-09-27: renamed from `.picture`, `.profile-picture`, `.profile-info`,
|
|
36
90
|
> `.profile-info-content`, `.profile-info-block` and `.profile-links`. `.picture` in particular
|
|
@@ -38,6 +92,35 @@ Content hooks inside info blocks: an element with class `location` or `services`
|
|
|
38
92
|
|
|
39
93
|
---
|
|
40
94
|
|
|
95
|
+
## Layout
|
|
96
|
+
|
|
97
|
+
The root is a size container named `profile-section` (`container: profile-section / inline-size`).
|
|
98
|
+
The grid switches to two columns when **the component itself** is 768px wide or more, not the
|
|
99
|
+
viewport, so it stays stacked in a narrow column or sidebar. Because it's an inline-size container,
|
|
100
|
+
its width can't come from its content: in a flex row or other shrink-to-fit context, give it a
|
|
101
|
+
width (`flex: 1`, `width: 100%`) or it collapses.
|
|
102
|
+
|
|
103
|
+
Both grid columns are `minmax(0, …)`, so long unbroken text wraps (the root sets
|
|
104
|
+
`overflow-wrap: anywhere`) instead of widening the grid. Profile links wrap onto more lines.
|
|
105
|
+
|
|
106
|
+
To target the two-column layout in your own CSS, query the same container:
|
|
107
|
+
|
|
108
|
+
```css
|
|
109
|
+
.my-page .profile-section-inner {
|
|
110
|
+
@container profile-section (min-width: 768px) {
|
|
111
|
+
align-items: center;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
> Changed 2026-10-14: the breakpoint was a viewport `@media (min-width: 768px)` query. A
|
|
117
|
+
> `@media (min-width: 768px)` override of `.profile-section-inner`'s `grid-template-columns` now
|
|
118
|
+
> applies at a different point from the component's own switch. Remove it and set
|
|
119
|
+
> `--profile-section-picture-width` instead (`minmax(0, 320px) 1fr` is
|
|
120
|
+
> `--profile-section-picture-width: 32rem`, since the column is now `minmax(0, …)` by default).
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
41
124
|
## Local overrides
|
|
42
125
|
|
|
43
126
|
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
@@ -51,3 +134,10 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
51
134
|
## Class passthrough
|
|
52
135
|
|
|
53
136
|
`style-class-passthrough` adds classes to the root element. Reactive after mount.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Notes
|
|
141
|
+
|
|
142
|
+
There are no line-clamp tokens. All text comes through slots, so it is your own markup: clamp it
|
|
143
|
+
there, for example with `-webkit-line-clamp` on your own paragraph class.
|