srcdev-nuxt-components 9.5.5 → 9.5.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.
- package/.claude/skills/component-compliance-checklist.md +6 -2
- package/.claude/skills/components/action-menu.md +21 -15
- package/.claude/skills/components/deep-expanding-menu.md +18 -6
- package/.claude/skills/components/display-tooltip-defined.md +2 -1
- package/.claude/skills/components/display-tooltip.md +10 -4
- package/.claude/skills/components/navigation-items.md +1 -0
- package/.claude/skills/components/opening-hours.md +12 -0
- package/.claude/skills/components/pop-over.md +11 -8
- package/.claude/skills/components/price-list.md +10 -1
- package/.claude/skills/components/responsive-header.md +25 -0
- package/.claude/skills/components/select-menu.md +18 -8
- package/.claude/skills/components/site-header.md +2 -0
- package/.claude/skills/components/ui-block-decorated.md +7 -0
- package/.claude/skills/composable-anchored-popover.md +108 -0
- package/.claude/skills/composable-tooltips-guide.md +3 -1
- package/.claude/skills/index.md +1 -0
- package/.claude/skills/storybook-add-story.md +14 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +3 -1
- package/.vscode/srcdev-component-site-header.code-snippets +3 -1
- package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
- package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
- package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
- package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
- package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
- package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
- package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
- package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
- package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
- package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
- package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
- package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
- package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
- package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
- package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
- package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
- package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
- package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
- package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
- package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
- package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
- package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
- package/app/components/02.molecules/price-list/PriceList.vue +32 -9
- package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
- package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +17 -5
- package/app/components/02.molecules/select-menu/SelectMenu.vue +51 -16
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
- package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +14 -0
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +23 -14
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +58 -30
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +149 -18
- package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +16 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +91 -6
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
- package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
- package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
- package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
- package/app/composables/tests/useTooltips.spec.ts +69 -0
- package/app/composables/useAnchoredPopover.ts +183 -0
- package/app/composables/useTooltips.ts +37 -20
- package/app/utils/opening-hours.ts +23 -2
- package/package.json +1 -1
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
1
2
|
import OpeningHours from "../OpeningHours.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
2
4
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
5
|
+
import type { MediaCanvas } from "~/types/components";
|
|
3
6
|
import type { OpeningDay, OpeningException } from "~/types/components/opening-hours";
|
|
4
7
|
|
|
8
|
+
type LineClamp = "none" | "1" | "2" | "3" | "4";
|
|
9
|
+
const lineClampOptions: LineClamp[] = ["none", "1", "2", "3", "4"];
|
|
10
|
+
|
|
11
|
+
// Story-only args: they drive CSS tokens, not OpeningHours props. See the argTypes entries.
|
|
12
|
+
type StoryArgs = InstanceType<typeof OpeningHours>["$props"] & {
|
|
13
|
+
noteLineClamp: LineClamp;
|
|
14
|
+
exceptionLabelLineClamp: LineClamp;
|
|
15
|
+
};
|
|
16
|
+
|
|
5
17
|
const shopDays: OpeningDay[] = [
|
|
6
18
|
{ day: 0, sessions: [{ opens: "09:00", closes: "17:30" }] },
|
|
7
19
|
{ day: 1, sessions: [{ opens: "09:00", closes: "17:30" }] },
|
|
@@ -38,10 +50,27 @@ const exceptions: OpeningException[] = [
|
|
|
38
50
|
{ from: isoInDays(30), label: "Bank holiday", sessions: [{ opens: "10:00", closes: "16:00" }] },
|
|
39
51
|
];
|
|
40
52
|
|
|
41
|
-
const meta: Meta<
|
|
53
|
+
const meta: Meta<StoryArgs> = {
|
|
42
54
|
title: "Molecules/OpeningHours",
|
|
43
55
|
component: OpeningHours,
|
|
44
56
|
argTypes: {
|
|
57
|
+
noteLineClamp: {
|
|
58
|
+
control: "select",
|
|
59
|
+
options: lineClampOptions,
|
|
60
|
+
description:
|
|
61
|
+
"**Story control, not a prop.** Sets the `--opening-hours-note-line-clamp` CSS token on a wrapper so you can try it here. " +
|
|
62
|
+
"To use it in an app, set the token in your own CSS, e.g. `.shop-hours { --opening-hours-note-line-clamp: 2; }`. " +
|
|
63
|
+
"`1` is single-line ellipsis, `none` (the default) shows everything.",
|
|
64
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
65
|
+
},
|
|
66
|
+
exceptionLabelLineClamp: {
|
|
67
|
+
control: "select",
|
|
68
|
+
options: lineClampOptions,
|
|
69
|
+
description:
|
|
70
|
+
"**Story control, not a prop.** Sets the `--opening-hours-exception-label-line-clamp` CSS token on a wrapper. " +
|
|
71
|
+
"To use it in an app, set the token in your own CSS, e.g. `.shop-hours { --opening-hours-exception-label-line-clamp: 1; }`.",
|
|
72
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
73
|
+
},
|
|
45
74
|
days: { control: "object", description: "One entry per day (0 = Monday); missing days are closed", table: { category: "Content" } },
|
|
46
75
|
exceptions: { control: "object", description: "Special dates that override the weekly pattern", table: { category: "Content" } },
|
|
47
76
|
locale: { control: "text", description: "BCP 47 locale for day, date and time formatting", table: { category: "Formatting", defaultValue: { summary: "en-GB" } } },
|
|
@@ -61,18 +90,53 @@ const meta: Meta<typeof OpeningHours> = {
|
|
|
61
90
|
structuredData: { control: false, table: { category: "SEO" } },
|
|
62
91
|
styleClassPassthrough: { control: "object", table: { category: "Styling" } },
|
|
63
92
|
},
|
|
64
|
-
|
|
93
|
+
args: {
|
|
94
|
+
noteLineClamp: "none",
|
|
95
|
+
exceptionLabelLineClamp: "none",
|
|
96
|
+
},
|
|
97
|
+
decorators: [
|
|
98
|
+
(story, context) => ({
|
|
99
|
+
components: { story, CanvasSwitcher },
|
|
100
|
+
setup() {
|
|
101
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
102
|
+
return { canvasName };
|
|
103
|
+
},
|
|
104
|
+
template: `
|
|
105
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
106
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
107
|
+
</div>
|
|
108
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
109
|
+
<div style="max-width: 48rem;"><story /></div>
|
|
110
|
+
</div>
|
|
111
|
+
`,
|
|
112
|
+
}),
|
|
113
|
+
],
|
|
114
|
+
parameters: {
|
|
115
|
+
layout: "fullscreen",
|
|
116
|
+
},
|
|
65
117
|
};
|
|
66
118
|
|
|
67
119
|
export default meta;
|
|
68
|
-
type Story = StoryObj<
|
|
120
|
+
type Story = StoryObj<StoryArgs>;
|
|
69
121
|
|
|
70
|
-
|
|
122
|
+
function useStorySetup(args: StoryArgs) {
|
|
123
|
+
const componentArgs = computed(() => {
|
|
124
|
+
const { noteLineClamp: _noteLineClamp, exceptionLabelLineClamp: _exceptionLabelLineClamp, ...rest } = args;
|
|
125
|
+
return rest;
|
|
126
|
+
});
|
|
127
|
+
const tokenStyles = computed(() => ({
|
|
128
|
+
"--opening-hours-note-line-clamp": args.noteLineClamp,
|
|
129
|
+
"--opening-hours-exception-label-line-clamp": args.exceptionLabelLineClamp,
|
|
130
|
+
}));
|
|
131
|
+
return { componentArgs, tokenStyles };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const render = (args: StoryArgs) => ({
|
|
71
135
|
components: { OpeningHours },
|
|
72
136
|
setup() {
|
|
73
|
-
return
|
|
137
|
+
return useStorySetup(args);
|
|
74
138
|
},
|
|
75
|
-
template: `<OpeningHours v-bind="
|
|
139
|
+
template: `<div :style="tokenStyles"><OpeningHours v-bind="componentArgs" /></div>`,
|
|
76
140
|
});
|
|
77
141
|
|
|
78
142
|
export const Default: Story = {
|
|
@@ -125,3 +189,71 @@ export const Localised: Story = {
|
|
|
125
189
|
},
|
|
126
190
|
render,
|
|
127
191
|
};
|
|
192
|
+
|
|
193
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
const stressDays: OpeningDay[] = [
|
|
196
|
+
{
|
|
197
|
+
day: 0,
|
|
198
|
+
sessions: [
|
|
199
|
+
{ opens: "09:00", closes: "12:00", label: "Vormittagssprechstunde nur für Bestandskundinnen und Bestandskunden" },
|
|
200
|
+
{ opens: "13:00", closes: "17:30", label: "Nachmittagssprechstundeohneleerzeichenunddasziemlichlang" },
|
|
201
|
+
],
|
|
202
|
+
note: "Bitte beachten Sie, dass wir an diesem Tag zusätzlich telefonisch erreichbar sind, Rückrufe aber erst ab dem nächsten Werktag erfolgen.",
|
|
203
|
+
},
|
|
204
|
+
{ day: 1, sessions: [{ opens: "22:00", closes: "02:00", label: "Overnight" }], note: "Closes after midnight" },
|
|
205
|
+
{ day: 2, sessions: [{ opens: "abc", closes: "25:99" }], note: "Malformed times are shown as written" },
|
|
206
|
+
{
|
|
207
|
+
day: 3,
|
|
208
|
+
sessions: Array.from({ length: 6 }, (_, i) => ({ opens: `${String(8 + i * 2).padStart(2, "0")}:00`, closes: `${String(9 + i * 2).padStart(2, "0")}:00` })),
|
|
209
|
+
},
|
|
210
|
+
{ day: 4, status: "open", sessions: [], note: "Status open with no sessions, shown as closed" },
|
|
211
|
+
{ day: 5, status: "by-appointment", note: "💇♀️ <b>not bold</b> https://example.com/a/very/long/booking/link/that/never/breaks/segmentsegment" },
|
|
212
|
+
{ day: 6, status: "24-hours", note: "مفتوح طوال اليوم" },
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const stressExceptions: OpeningException[] = [
|
|
216
|
+
{
|
|
217
|
+
from: isoInDays(1),
|
|
218
|
+
label: "Betriebsversammlungundinventurwochenendeohneleerzeichen und danach noch eine sehr lange Beschreibung",
|
|
219
|
+
sessions: [{ opens: "10:00", closes: "12:00" }],
|
|
220
|
+
},
|
|
221
|
+
{ from: isoInDays(20), to: isoInDays(18), label: "Reversed range (to before from)" },
|
|
222
|
+
{ from: isoInDays(5), label: "Duplicate date" },
|
|
223
|
+
{ from: isoInDays(5), label: "Duplicate date" },
|
|
224
|
+
{ from: "not-a-date", label: "Invalid date" },
|
|
225
|
+
{ from: isoInDays(40), label: "" },
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
export const StressTest: Story = {
|
|
229
|
+
name: "Stress Test (Worst-Case Data)",
|
|
230
|
+
args: {
|
|
231
|
+
days: stressDays,
|
|
232
|
+
exceptions: stressExceptions,
|
|
233
|
+
hidePastExceptions: false,
|
|
234
|
+
groupDays: true,
|
|
235
|
+
locale: "not a locale!!",
|
|
236
|
+
timeZone: "Mars/Base",
|
|
237
|
+
exceptionsHeading: "Sonderöffnungszeiten an Feiertagen, Brückentagen und während betrieblicher Veranstaltungen",
|
|
238
|
+
closedLabel: "Geschlossen",
|
|
239
|
+
open24HoursLabel: "Rund um die Uhr geöffnet, auch an Sonn- und Feiertagen",
|
|
240
|
+
byAppointmentLabel: "Ausschließlich nach vorheriger telefonischer Terminvereinbarung",
|
|
241
|
+
toLabel: "bis",
|
|
242
|
+
},
|
|
243
|
+
render,
|
|
244
|
+
parameters: {
|
|
245
|
+
initialCanvas: "mobileCanvas",
|
|
246
|
+
docs: {
|
|
247
|
+
description: {
|
|
248
|
+
story:
|
|
249
|
+
"Deliberately hostile data to find breakage: an invalid locale (falls back to en-GB) and time zone (falls back to the browser's), " +
|
|
250
|
+
"malformed times like abc and 25:99 (shown as written, not reformatted), a session crossing midnight, six sessions in a day, " +
|
|
251
|
+
"status open with no sessions (shown as closed), long German session labels, notes, status labels and exceptions heading, an unbroken exception label, " +
|
|
252
|
+
"emoji, HTML-like text (must render as text), a long URL, right-to-left Arabic, a reversed date range (shown the right way round), " +
|
|
253
|
+
"duplicate exception dates, an invalid date (shown as written) and an empty label. " +
|
|
254
|
+
"Check at every canvas width: nothing overflows, each session's times stay on one line while its label wraps, and the hours " +
|
|
255
|
+
"column never takes more than 60% of the row. Try the line-clamp controls too: they're CSS tokens set by the story, not props.",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
@@ -200,6 +200,59 @@ describe("OpeningHours", () => {
|
|
|
200
200
|
wrapper = await createWrapper();
|
|
201
201
|
expect(wrapper.find(".opening-hours__exceptions").exists()).toBe(false);
|
|
202
202
|
});
|
|
203
|
+
|
|
204
|
+
it("swaps a range whose to is before from", async () => {
|
|
205
|
+
vi.setSystemTime(new Date("2026-12-25T12:00:00Z"));
|
|
206
|
+
wrapper = await createWrapper({ exceptions: [{ from: "2026-12-26", to: "2026-12-24", label: "Reversed" }] });
|
|
207
|
+
await nextTick();
|
|
208
|
+
expect(rowTexts(wrapper, ".opening-hours__exceptions")).toEqual(["Reversed 24 December – to 26 December Closed"]);
|
|
209
|
+
expect(wrapper.find(".opening-hours__exceptions .opening-hours__row").attributes("data-today")).toBe("true");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("renders duplicate exceptions without key clashes", async () => {
|
|
213
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
214
|
+
vi.setSystemTime(new Date("2026-09-27T12:00:00Z"));
|
|
215
|
+
wrapper = await createWrapper({ exceptions: [{ from: "2026-12-25" }, { from: "2026-12-25" }] });
|
|
216
|
+
await nextTick();
|
|
217
|
+
expect(wrapper.find(".opening-hours__exceptions").findAll(".opening-hours__row").length).toBe(2);
|
|
218
|
+
expect(warn.mock.calls.some(([msg]) => String(msg).includes("Duplicate keys"))).toBe(false);
|
|
219
|
+
warn.mockRestore();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("Bad data", () => {
|
|
224
|
+
it("shows an unparseable or out-of-range time as written instead of throwing", async () => {
|
|
225
|
+
wrapper = await createWrapper({ days: [{ day: 0, sessions: [{ opens: "abc", closes: "25:99" }] }], groupDays: false });
|
|
226
|
+
expect(rowTexts(wrapper)[0]).toBe("Monday abc – to 25:99");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("shows an invalid exception date as written instead of throwing", async () => {
|
|
230
|
+
wrapper = await createWrapper({
|
|
231
|
+
exceptions: [{ from: "not-a-date", label: "Bad" }],
|
|
232
|
+
hidePastExceptions: false,
|
|
233
|
+
});
|
|
234
|
+
await nextTick();
|
|
235
|
+
expect(rowTexts(wrapper, ".opening-hours__exceptions")).toEqual(["Bad not-a-date Closed"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("shows the closed label for status open with no sessions", async () => {
|
|
239
|
+
wrapper = await createWrapper({ days: [{ day: 0, status: "open", sessions: [] }], groupDays: false });
|
|
240
|
+
const row = wrapper.find(".opening-hours__row");
|
|
241
|
+
expect(row.attributes("data-status")).toBe("closed");
|
|
242
|
+
expect(row.find(".opening-hours__status").text()).toBe("Closed");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("falls back to en-GB for an invalid locale", async () => {
|
|
246
|
+
wrapper = await createWrapper({ locale: "not a locale!!" });
|
|
247
|
+
expect(rowTexts(wrapper)[0]).toBe("Monday – to Saturday 09:00 – to 17:30");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("still mounts with an invalid timeZone", async () => {
|
|
251
|
+
vi.setSystemTime(new Date("2026-09-28T12:00:00Z"));
|
|
252
|
+
wrapper = await createWrapper({ timeZone: "Mars/Base" });
|
|
253
|
+
await nextTick();
|
|
254
|
+
expect(wrapper.find("[data-today]").exists()).toBe(true);
|
|
255
|
+
});
|
|
203
256
|
});
|
|
204
257
|
|
|
205
258
|
describe("Accessibility", () => {
|
package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap
CHANGED
|
@@ -5,7 +5,7 @@ exports[`OpeningHours > Snapshots > default 1`] = `
|
|
|
5
5
|
<dl class="opening-hours__list">
|
|
6
6
|
<div class="opening-hours__row" data-status="open" data-today="true" aria-current="date">
|
|
7
7
|
<dt class="opening-hours__days"><span>Monday</span><span aria-hidden="true"> – </span><span class="sr-only">to</span><span>Saturday</span></dt>
|
|
8
|
-
<dd class="opening-hours__hours"><span class="opening-hours__session"><!--v-if--><time datetime="09:00">09:00</time><span aria-hidden="true"> – </span><span class="sr-only">to</span><time datetime="17:30">17:30</time></span>
|
|
8
|
+
<dd class="opening-hours__hours"><span class="opening-hours__session"><!--v-if--><span class="opening-hours__session-times"><time datetime="09:00">09:00</time><span aria-hidden="true"> – </span><span class="sr-only">to</span><time datetime="17:30">17:30</time></span></span>
|
|
9
9
|
<!--v-if-->
|
|
10
10
|
</dd>
|
|
11
11
|
</div>
|
package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildOpeningHoursSpecification,
|
|
4
|
+
parseOpeningDate,
|
|
5
|
+
parseOpeningTime,
|
|
6
|
+
resolveOpeningStatus,
|
|
7
|
+
} from "~/utils/opening-hours";
|
|
8
|
+
|
|
9
|
+
describe("parseOpeningTime", () => {
|
|
10
|
+
it("parses HH:MM and H:MM", () => {
|
|
11
|
+
expect(parseOpeningTime("09:30")).toEqual({ hours: 9, minutes: 30 });
|
|
12
|
+
expect(parseOpeningTime("9:05")).toEqual({ hours: 9, minutes: 5 });
|
|
13
|
+
expect(parseOpeningTime("23:59")).toEqual({ hours: 23, minutes: 59 });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects malformed and out-of-range times", () => {
|
|
17
|
+
for (const bad of ["", "abc", "9", "24:00", "25:99", "12:60", "12:3", "-1:00"]) {
|
|
18
|
+
expect(parseOpeningTime(bad)).toBeNull();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("parseOpeningDate", () => {
|
|
24
|
+
it("parses a real YYYY-MM-DD date as UTC midnight", () => {
|
|
25
|
+
expect(parseOpeningDate("2026-02-28")?.toISOString()).toBe("2026-02-28T00:00:00.000Z");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects malformed and impossible dates", () => {
|
|
29
|
+
for (const bad of ["", "not-a-date", "2026-13-01", "2026-02-30", "26-01-01", "2026-1-1"]) {
|
|
30
|
+
expect(parseOpeningDate(bad)).toBeNull();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
3
34
|
|
|
4
35
|
describe("resolveOpeningStatus", () => {
|
|
5
36
|
it("defaults to open with sessions and closed without", () => {
|
|
@@ -7,6 +38,11 @@ describe("resolveOpeningStatus", () => {
|
|
|
7
38
|
expect(resolveOpeningStatus({})).toBe("closed");
|
|
8
39
|
expect(resolveOpeningStatus({ status: "by-appointment" })).toBe("by-appointment");
|
|
9
40
|
});
|
|
41
|
+
|
|
42
|
+
it("treats open with no sessions as closed", () => {
|
|
43
|
+
expect(resolveOpeningStatus({ status: "open" })).toBe("closed");
|
|
44
|
+
expect(resolveOpeningStatus({ status: "open", sessions: [] })).toBe("closed");
|
|
45
|
+
});
|
|
10
46
|
});
|
|
11
47
|
|
|
12
48
|
describe("buildOpeningHoursSpecification", () => {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
`PriceList` renders one or more columns of priced items (a salon/service menu). Each column is a
|
|
4
4
|
`HeroText` heading followed by a `<dl>` of description/price rows separated by dividers. Columns
|
|
5
|
-
stack
|
|
5
|
+
stack in a narrow container and sit side by side (two at most by default) once the container is
|
|
6
|
+
wide enough for each to reach `--price-list-column-min-inline-size`.
|
|
6
7
|
|
|
7
8
|
## Public token API
|
|
8
9
|
|
|
@@ -10,7 +11,17 @@ stack on narrow viewports and sit two-up from `48em`.
|
|
|
10
11
|
|
|
11
12
|
| Token | Default | Controls |
|
|
12
13
|
|---|---|---|
|
|
13
|
-
| `--price-list-column-gap` | `2.4rem` | Gap between columns (and between stacked columns
|
|
14
|
+
| `--price-list-column-gap` | `2.4rem` | Gap between columns (and between stacked columns) |
|
|
15
|
+
| `--price-list-column-min-inline-size` | `32rem` | Narrowest a column gets before the columns stack. Measured against the component's own width, not the viewport |
|
|
16
|
+
| `--price-list-max-columns` | `2` | Most columns side by side. A single column still takes only its share of the width (half, by default) |
|
|
17
|
+
|
|
18
|
+
Private `--_column-gap` and `--_max-columns` hold the two tokens above so the column formula can
|
|
19
|
+
read each twice; they are not public API.
|
|
20
|
+
|
|
21
|
+
> **Changed 2026-10-05**: the two-column switch used to be a `48em` viewport media query, so a
|
|
22
|
+
> list in a narrow container (a sidebar, a card) stayed two-up and cramped on a wide screen. It now
|
|
23
|
+
> follows the component's own width, set by `--price-list-column-min-inline-size` and
|
|
24
|
+
> `--price-list-max-columns`.
|
|
14
25
|
|
|
15
26
|
### Column heading
|
|
16
27
|
|
|
@@ -35,6 +46,7 @@ The heading is a `HeroText`, so `--hero-text-font-family` also applies to it.
|
|
|
35
46
|
| `--price-list-divider-opacity` | `0.15` | Divider opacity (0 to 1), mixed into the colour |
|
|
36
47
|
| `--price-list-description-font-size` | `1.4rem` | Item description size |
|
|
37
48
|
| `--price-list-description-colour` | `inherit` | Item description colour |
|
|
49
|
+
| `--price-list-description-line-clamp` | `none` | Cap the description at this many lines, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
|
|
38
50
|
|
|
39
51
|
### Price
|
|
40
52
|
|
|
@@ -46,8 +58,9 @@ The heading is a `HeroText`, so `--hero-text-font-family` also applies to it.
|
|
|
46
58
|
| `--price-list-from-font-size` | `1.4rem` | "from" label size |
|
|
47
59
|
| `--price-list-from-colour` | `inherit` | "from" label colour |
|
|
48
60
|
| `--price-list-from-gap` | `0.5ch` | Space between "from" and the amount |
|
|
61
|
+
| `--price-list-price-max-inline-size` | `50%` | Most of the row the price can take. A price longer than this wraps inside it, right-aligned; normal short prices stay on one line |
|
|
49
62
|
|
|
50
|
-
|
|
63
|
+
Long unbroken descriptions, headings and prices wrap anywhere rather than overflow the column.
|
|
51
64
|
|
|
52
65
|
> **Changed 2026-09-27**: `--price-list-divider-color` is now `--price-list-divider-colour`
|
|
53
66
|
> (repo spelling convention). `--price-list-price-font-size` used to be dead for the amount itself
|
|
@@ -74,8 +87,7 @@ No `data-*` attributes. Inner element classes, safe to target:
|
|
|
74
87
|
| `.price-list__from` | "from" label (only when `item.from` is true) |
|
|
75
88
|
| `.price-list__amount` | Price amount |
|
|
76
89
|
|
|
77
|
-
|
|
78
|
-
`grid-template-columns` on `.price-list` if you need a different split.
|
|
90
|
+
`.price-list__heading` is not rendered for a column whose `headingtext` is empty.
|
|
79
91
|
|
|
80
92
|
---
|
|
81
93
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<div class="price-list" :class="elementClasses">
|
|
3
3
|
<div v-for="(column, colIndex) in priceListData" :key="colIndex" class="price-list__column">
|
|
4
4
|
<HeroText
|
|
5
|
+
v-if="column.headingtext"
|
|
5
6
|
:tag="headingTag"
|
|
6
7
|
font-size="subheading"
|
|
7
8
|
:text-content="[{ text: column.headingtext }]"
|
|
@@ -49,15 +50,28 @@ watch(
|
|
|
49
50
|
<style lang="css">
|
|
50
51
|
@layer components {
|
|
51
52
|
.price-list {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
gap: var(--price-list-column-gap, 2.4rem);
|
|
53
|
+
--_column-gap: var(--price-list-column-gap, 2.4rem);
|
|
54
|
+
--_max-columns: var(--price-list-max-columns, 2);
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
display: grid;
|
|
57
|
+
grid-template-columns: repeat(
|
|
58
|
+
auto-fill,
|
|
59
|
+
minmax(
|
|
60
|
+
max(
|
|
61
|
+
min(100%, var(--price-list-column-min-inline-size, 32rem)),
|
|
62
|
+
calc((100% - (var(--_max-columns) - 1) * var(--_column-gap)) / var(--_max-columns))
|
|
63
|
+
),
|
|
64
|
+
1fr
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
gap: var(--_column-gap);
|
|
68
|
+
|
|
69
|
+
.price-list__column {
|
|
70
|
+
min-inline-size: 0;
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
.price-list__heading.hero-text {
|
|
74
|
+
overflow-wrap: anywhere;
|
|
61
75
|
font-size: var(--price-list-heading-font-size, var(--hero-text-subheading));
|
|
62
76
|
font-weight: var(--price-list-heading-font-weight, 600);
|
|
63
77
|
color: var(--price-list-heading-colour, inherit);
|
|
@@ -75,10 +89,10 @@ watch(
|
|
|
75
89
|
flex-direction: column;
|
|
76
90
|
|
|
77
91
|
.price-list__row {
|
|
78
|
-
display:
|
|
79
|
-
|
|
92
|
+
display: grid;
|
|
93
|
+
grid-template-columns: minmax(0, 1fr) fit-content(var(--price-list-price-max-inline-size, 50%));
|
|
80
94
|
align-items: center;
|
|
81
|
-
gap: var(--price-list-row-gap, 1.2rem);
|
|
95
|
+
column-gap: var(--price-list-row-gap, 1.2rem);
|
|
82
96
|
padding-block: var(--price-list-row-padding-block, 1.4rem);
|
|
83
97
|
border-block-end: var(--price-list-divider-width, 1px) solid
|
|
84
98
|
color-mix(
|
|
@@ -93,17 +107,26 @@ watch(
|
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
.price-list__description {
|
|
110
|
+
display: -webkit-box;
|
|
111
|
+
-webkit-box-orient: vertical;
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
overflow-wrap: anywhere;
|
|
114
|
+
-webkit-line-clamp: var(--price-list-description-line-clamp, none);
|
|
115
|
+
line-clamp: var(--price-list-description-line-clamp, none);
|
|
96
116
|
font-size: var(--price-list-description-font-size, 1.4rem);
|
|
97
117
|
color: var(--price-list-description-colour, inherit);
|
|
98
118
|
}
|
|
99
119
|
|
|
100
120
|
.price-list__price {
|
|
101
121
|
display: flex;
|
|
122
|
+
flex-wrap: wrap;
|
|
123
|
+
justify-content: flex-end;
|
|
102
124
|
align-items: baseline;
|
|
103
125
|
gap: var(--price-list-from-gap, 0.5ch);
|
|
104
126
|
margin: 0;
|
|
127
|
+
text-align: end;
|
|
128
|
+
overflow-wrap: anywhere;
|
|
105
129
|
font-variant-numeric: tabular-nums;
|
|
106
|
-
white-space: nowrap;
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
.price-list__from {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
1
2
|
import PriceList from "../PriceList.vue";
|
|
3
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
2
4
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
5
|
+
import type { MediaCanvas, PriceListData } from "~/types/components";
|
|
3
6
|
|
|
4
7
|
const priceListData = [
|
|
5
8
|
{
|
|
@@ -26,10 +29,26 @@ const priceListData = [
|
|
|
26
29
|
},
|
|
27
30
|
];
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
type LineClamp = "none" | "1" | "2" | "3" | "4";
|
|
33
|
+
|
|
34
|
+
// Story-only arg: drives a CSS token, not a PriceList prop. See the argTypes entry.
|
|
35
|
+
type StoryArgs = InstanceType<typeof PriceList>["$props"] & {
|
|
36
|
+
descriptionLineClamp: LineClamp;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const meta: Meta<StoryArgs> = {
|
|
30
40
|
title: "Molecules/PriceList",
|
|
31
41
|
component: PriceList,
|
|
32
42
|
argTypes: {
|
|
43
|
+
descriptionLineClamp: {
|
|
44
|
+
control: "select",
|
|
45
|
+
options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
|
|
46
|
+
description:
|
|
47
|
+
"**Story control, not a prop.** Sets the `--price-list-description-line-clamp` CSS token on a wrapper so you can try it here. " +
|
|
48
|
+
"To use it in an app, set the token in your own CSS, e.g. `.salon-menu { --price-list-description-line-clamp: 2; }`. " +
|
|
49
|
+
"`1` is single-line ellipsis, `none` (the default) shows everything.",
|
|
50
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
|
|
51
|
+
},
|
|
33
52
|
priceListData: {
|
|
34
53
|
control: "object",
|
|
35
54
|
description: "Array of columns, each with a heading and list of items",
|
|
@@ -52,10 +71,52 @@ const meta: Meta<typeof PriceList> = {
|
|
|
52
71
|
table: { category: "Styling" },
|
|
53
72
|
},
|
|
54
73
|
},
|
|
74
|
+
args: {
|
|
75
|
+
descriptionLineClamp: "none",
|
|
76
|
+
},
|
|
77
|
+
decorators: [
|
|
78
|
+
(story, context) => ({
|
|
79
|
+
components: { story, CanvasSwitcher },
|
|
80
|
+
setup() {
|
|
81
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
82
|
+
return { canvasName };
|
|
83
|
+
},
|
|
84
|
+
template: `
|
|
85
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
86
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
87
|
+
</div>
|
|
88
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
89
|
+
<story />
|
|
90
|
+
</div>
|
|
91
|
+
`,
|
|
92
|
+
}),
|
|
93
|
+
],
|
|
94
|
+
parameters: {
|
|
95
|
+
layout: "fullscreen",
|
|
96
|
+
},
|
|
55
97
|
};
|
|
56
98
|
|
|
57
99
|
export default meta;
|
|
58
|
-
type Story = StoryObj<
|
|
100
|
+
type Story = StoryObj<StoryArgs>;
|
|
101
|
+
|
|
102
|
+
function useStorySetup(args: StoryArgs) {
|
|
103
|
+
const componentArgs = computed(() => {
|
|
104
|
+
const { descriptionLineClamp: _descriptionLineClamp, ...rest } = args;
|
|
105
|
+
return rest;
|
|
106
|
+
});
|
|
107
|
+
const tokenStyles = computed(() => ({
|
|
108
|
+
"--price-list-description-line-clamp": args.descriptionLineClamp,
|
|
109
|
+
}));
|
|
110
|
+
return { componentArgs, tokenStyles };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const render = (args: StoryArgs) => ({
|
|
114
|
+
components: { PriceList },
|
|
115
|
+
setup() {
|
|
116
|
+
return useStorySetup(args);
|
|
117
|
+
},
|
|
118
|
+
template: `<div :style="tokenStyles"><PriceList v-bind="componentArgs" /></div>`,
|
|
119
|
+
});
|
|
59
120
|
|
|
60
121
|
export const Default: Story = {
|
|
61
122
|
args: {
|
|
@@ -64,13 +125,7 @@ export const Default: Story = {
|
|
|
64
125
|
fromLabel: "from",
|
|
65
126
|
styleClassPassthrough: [],
|
|
66
127
|
},
|
|
67
|
-
render
|
|
68
|
-
components: { PriceList },
|
|
69
|
-
setup() {
|
|
70
|
-
return { args };
|
|
71
|
-
},
|
|
72
|
-
template: `<PriceList v-bind="args" />`,
|
|
73
|
-
}),
|
|
128
|
+
render,
|
|
74
129
|
};
|
|
75
130
|
|
|
76
131
|
export const SingleColumn: Story = {
|
|
@@ -78,13 +133,7 @@ export const SingleColumn: Story = {
|
|
|
78
133
|
args: {
|
|
79
134
|
priceListData: [priceListData[0]!],
|
|
80
135
|
},
|
|
81
|
-
render
|
|
82
|
-
components: { PriceList },
|
|
83
|
-
setup() {
|
|
84
|
-
return { args };
|
|
85
|
-
},
|
|
86
|
-
template: `<PriceList v-bind="args" />`,
|
|
87
|
-
}),
|
|
136
|
+
render,
|
|
88
137
|
};
|
|
89
138
|
|
|
90
139
|
export const EmptyState: Story = {
|
|
@@ -92,13 +141,7 @@ export const EmptyState: Story = {
|
|
|
92
141
|
args: {
|
|
93
142
|
priceListData: priceListData.map(({ headingtext, headingIcon }) => ({ headingtext, headingIcon, items: [] })),
|
|
94
143
|
},
|
|
95
|
-
render
|
|
96
|
-
components: { PriceList },
|
|
97
|
-
setup() {
|
|
98
|
-
return { args };
|
|
99
|
-
},
|
|
100
|
-
template: `<PriceList v-bind="args" />`,
|
|
101
|
-
}),
|
|
144
|
+
render,
|
|
102
145
|
};
|
|
103
146
|
|
|
104
147
|
export const NoIcons: Story = {
|
|
@@ -106,11 +149,64 @@ export const NoIcons: Story = {
|
|
|
106
149
|
args: {
|
|
107
150
|
priceListData: priceListData.map(({ headingtext, items }) => ({ headingtext, items })),
|
|
108
151
|
},
|
|
109
|
-
render
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
152
|
+
render,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ─── Stress test ──────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
const stressPriceListData: PriceListData[] = [
|
|
158
|
+
{
|
|
159
|
+
headingtext: "Haarschnitte, Pflegebehandlungen und Kopfhautmassagen für jeden Haartyp und jede Länge",
|
|
160
|
+
headingIcon: "lucide:sparkles",
|
|
161
|
+
items: [
|
|
162
|
+
{ description: "Pneumonoultramicroscopicsilicovolcanoconiosisbehandlungohneleerzeichen", price: "£45" },
|
|
163
|
+
{
|
|
164
|
+
description: `${"Cut, wash, condition, blow dry and finish with a heat protectant serum. ".repeat(5)}`,
|
|
165
|
+
price: "£1,234.56 – £9,999.99 pro Sitzung inklusive Beratung",
|
|
166
|
+
from: true,
|
|
167
|
+
},
|
|
168
|
+
{ description: "Price with no spaces", price: "£123456789012345678901234567890.00" },
|
|
169
|
+
{ description: "From, but the price is empty", price: "", from: true },
|
|
170
|
+
{ description: "", price: "£10" },
|
|
171
|
+
{ description: "💇♀️💅🏽 Emoji first", price: "£0" },
|
|
172
|
+
{ description: "<script>alert('xss')</script> rendered as text & entities", price: "<b>£5</b>" },
|
|
173
|
+
{ description: "قص الشعر وتصفيفه", price: "٤٥ £" },
|
|
174
|
+
{ description: "X", price: "1" },
|
|
175
|
+
{ description: "See https://example.com/a/very/long/path/that/never/breaks/segmentsegmentsegment", price: "-£20" },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
headingtext: "",
|
|
180
|
+
items: Array.from({ length: 30 }, (_, i) => ({ description: `Item ${i + 1} in a column with no heading`, price: `£${(i + 1) * 5}` })),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
headingtext: "Broken icon, no items",
|
|
184
|
+
headingIcon: "lucide:this-icon-does-not-exist",
|
|
185
|
+
items: [],
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
export const StressTest: Story = {
|
|
190
|
+
name: "Stress Test (Worst-Case Data)",
|
|
191
|
+
args: {
|
|
192
|
+
priceListData: stressPriceListData,
|
|
193
|
+
headingTag: "h2",
|
|
194
|
+
fromLabel: "ab einem Mindestpreis von",
|
|
195
|
+
},
|
|
196
|
+
render,
|
|
197
|
+
parameters: {
|
|
198
|
+
initialCanvas: "mobileCanvas",
|
|
199
|
+
docs: {
|
|
200
|
+
description: {
|
|
201
|
+
story:
|
|
202
|
+
"Deliberately hostile data to find breakage: a long German heading, unbroken description and price strings, " +
|
|
203
|
+
"a long price range with a long German 'from' label, a 'from' item with an empty price, an empty description, " +
|
|
204
|
+
"emoji, HTML-like text (must render as text), right-to-left Arabic, single characters, a negative price, " +
|
|
205
|
+
"a column with no heading (no empty heading is rendered) and 30 items, a broken icon name, and a column with no items. " +
|
|
206
|
+
"Check at every canvas width: nothing overflows its column, the price column never takes more than half the row " +
|
|
207
|
+
"and wraps inside it, columns go two-up only when the container is wide enough, and a third column wraps to a new row. " +
|
|
208
|
+
"Try the Description line clamp control too: it's a CSS token set by the story, not a prop.",
|
|
209
|
+
},
|
|
113
210
|
},
|
|
114
|
-
|
|
115
|
-
}),
|
|
211
|
+
},
|
|
116
212
|
};
|
|
@@ -191,6 +191,26 @@ describe("PriceList", () => {
|
|
|
191
191
|
expect(classes).not.toContain("first-class");
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
it("does not render an empty heading when headingtext is empty", async () => {
|
|
195
|
+
wrapper = await createWrapper([
|
|
196
|
+
{ headingtext: "", items: [{ description: "Cut", price: "£45" }] },
|
|
197
|
+
{ headingtext: "Colour", items: [] },
|
|
198
|
+
]);
|
|
199
|
+
const columns = wrapper.findAll(".price-list__column");
|
|
200
|
+
expect(columns[0]!.find(".price-list__heading").exists()).toBe(false);
|
|
201
|
+
expect(columns[0]!.findAll(".price-list__row").length).toBe(1);
|
|
202
|
+
expect(columns[1]!.find(".price-list__heading").text()).toBe("Colour");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("renders HTML-like text as text", async () => {
|
|
206
|
+
wrapper = await createWrapper([
|
|
207
|
+
{ headingtext: "Cuts", items: [{ description: "<script>alert(1)</script>", price: "<b>£5</b>" }] },
|
|
208
|
+
]);
|
|
209
|
+
expect(wrapper.find("script").exists()).toBe(false);
|
|
210
|
+
expect(wrapper.find("dd b").exists()).toBe(false);
|
|
211
|
+
expect(wrapper.find(".price-list__description").text()).toBe("<script>alert(1)</script>");
|
|
212
|
+
});
|
|
213
|
+
|
|
194
214
|
it("does not render a 'from' label when item.from is omitted", async () => {
|
|
195
215
|
wrapper = await createWrapper();
|
|
196
216
|
expect(wrapper.find(".price-list__from").exists()).toBe(false);
|