srcdev-nuxt-components 9.5.6 → 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/navigation-items.md +1 -0
- package/.claude/skills/components/opening-hours.md +12 -0
- package/.claude/skills/components/price-list.md +10 -1
- package/.claude/skills/components/responsive-header.md +25 -0
- package/.claude/skills/components/site-header.md +2 -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/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/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 +55 -27
- 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/utils/opening-hours.ts +23 -2
- package/package.json +1 -1
|
@@ -13,7 +13,8 @@ skip silently. Notes marked **Existing component** only apply when migrating.
|
|
|
13
13
|
|
|
14
14
|
A component passes when its Component Ledger row (`node .claude/component-ledger/build.mjs`, then
|
|
15
15
|
`.claude/component-ledger/audit.json`) shows `score: 5`, a real tier, and `variants`, `legacy_props`,
|
|
16
|
-
`story_args_bug`, `eslint_issues`, `redundant_priv_tokens
|
|
16
|
+
`story_args_bug`, `eslint_issues`, `redundant_priv_tokens`, `styling_doc_outdated` and `missing_stress_story`
|
|
17
|
+
all `false`.
|
|
17
18
|
The ledger can't see items 9, 10, 11 and 14, so check those by hand.
|
|
18
19
|
|
|
19
20
|
## The checklist
|
|
@@ -131,7 +132,10 @@ The ledger can't see items 9, 10, 11 and 14, so check those by hand.
|
|
|
131
132
|
covers ellipsis too (`1` is single-line ellipsis). Default it to `none` unless the layout
|
|
132
133
|
clearly needs a cap, and never clamp text that must stay readable (legal or attribution text,
|
|
133
134
|
link text that names its destination). Shape and rules: `theming-component-token-pattern.md`,
|
|
134
|
-
"Line-clamp tokens".
|
|
135
|
+
"Line-clamp tokens". Give each line-clamp token a Controls-panel select in the stories (set on
|
|
136
|
+
a wrapper `:style`, stripped from the component's args), labelled as a story-only CSS token so
|
|
137
|
+
nobody mistakes it for a prop: `storybook-add-story.md`, "Label token controls as story-only".
|
|
138
|
+
Add a unit test for any logic fix. A component that takes no data or copy
|
|
135
139
|
(e.g. a pure layout wrapper) still gets one with oversized slot content.
|
|
136
140
|
7. **Skill doc** — create or update `.claude/skills/components/<component-name>.md`, following the
|
|
137
141
|
pattern of an existing one. A labelled `<Name>Field` wrapper (or any other wrapper) is documented
|
|
@@ -18,6 +18,7 @@ active-route highlighting, hover indicator).
|
|
|
18
18
|
| `mainNavigationState` | `ResponsiveHeaderState` | `{ clonedNavLinks: {}, navListVisibility: {}, hasSecondNav: false }` | Shared geometry/visibility state, normally supplied by the parent `ResponsiveHeader` — not something a consumer constructs by hand outside of tests/stories. |
|
|
19
19
|
| `panelVariant` | `"modern" \| "classic"` | `"classic"` | Which panel component renders a dropdown's submenu: `ExpandingPanel` (`"modern"`) or `ExpandingPanelClassic` (`"classic"`, default). `"modern"` is known not to work correctly on WebKit. Normally set via `ResponsiveHeader`'s own `panelVariant` prop rather than directly. See CLAUDE.md pitfall #19. |
|
|
20
20
|
| `ariaLabel` | `string` | `"Overflow navigation menu"` | aria-label on the root `role="menu"` element — override for localisation. Normally set via `ResponsiveHeader`'s own `overflowMenuAriaLabel` prop rather than directly. |
|
|
21
|
+
| `submenuAriaLabel` | `string` | `"{title} submenu"` | aria-label on each submenu summary; `{title}` is replaced with `childLinksTitle` (or `name`). Normally set via `ResponsiveHeader`'s `submenuAriaLabel`. |
|
|
21
22
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.overflow-navigation-wrapper` element. |
|
|
22
23
|
|
|
23
24
|
---
|
|
@@ -78,6 +78,18 @@ earlier than `opens` means the session runs past midnight and is displayed as gi
|
|
|
78
78
|
- Grouping compares status, sessions and note; only **consecutive** days (in `week-starts-on`
|
|
79
79
|
order) group. No wrap-around from Sunday to Monday.
|
|
80
80
|
- Exceptions don't alter the weekly table; they are listed separately.
|
|
81
|
+
- **Bad data never throws** (since 2026-10-05): a time that isn't a valid `HH:MM` from `00:00` to
|
|
82
|
+
`23:59` (e.g. `"abc"`, `"25:99"`, `"24:00"`) and an exception date that isn't a real
|
|
83
|
+
`YYYY-MM-DD` are shown exactly as written. An invalid `locale` falls back to `en-GB`, and an
|
|
84
|
+
invalid `timeZone` to the browser's own. An exception whose `to` is before its `from` is shown
|
|
85
|
+
the right way round, and duplicate exception dates both render. The checks are the
|
|
86
|
+
auto-imported `parseOpeningTime` and `parseOpeningDate` utils (`app/utils/opening-hours.ts`).
|
|
87
|
+
- A session that closes after midnight (`22:00`–`02:00`) displays as written; schema.org reads
|
|
88
|
+
`closes` before `opens` as the next day.
|
|
89
|
+
- `status: "open"` with no sessions is treated as closed (since 2026-10-05): the row shows
|
|
90
|
+
`closedLabel`, gets `data-status="closed"`, and structured data treats it as closed too.
|
|
91
|
+
- Long text wraps rather than overflowing; notes and exception labels have line-clamp tokens
|
|
92
|
+
(`CONSUMER-STYLING.md`). The `StressTest` story covers the worst-case data.
|
|
81
93
|
- Today's highlight and past-exception hiding run in `onMounted`, never on the server, so cached
|
|
82
94
|
HTML can't carry a stale day. Past exceptions are briefly visible before hydration.
|
|
83
95
|
- **Structured data**: emits `{ "@type": structuredData.type ?? "LocalBusiness", "@id", name,
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
`PriceList` (`app/components/02.molecules/price-list/`) renders a service/menu-style price list:
|
|
6
6
|
one or more columns, each with a heading (optional icon) and a `<dl>` of description/price rows.
|
|
7
|
-
Columns stack
|
|
7
|
+
Columns stack in a narrow container and sit two-up once the component itself is wide enough
|
|
8
|
+
(a container-width grid since 2026-10-05, not a viewport media query). Not a SaaS plan comparison, use `PricingCard`
|
|
8
9
|
for that.
|
|
9
10
|
|
|
10
11
|
---
|
|
@@ -48,6 +49,8 @@ No slots, no events.
|
|
|
48
49
|
- Column titles are `HeroText` headings at `heading-tag`. Prices are plain `<span>`s inside `<dd>`,
|
|
49
50
|
not headings (before 2026-09-27 each price was an `<h2>`, which flooded the heading outline).
|
|
50
51
|
- Rows are `<div>`-wrapped `<dt>`/`<dd>` pairs, valid inside `<dl>`.
|
|
52
|
+
- A column with an empty `headingtext` renders no heading at all (no empty `<h2>`); its rows still
|
|
53
|
+
render.
|
|
51
54
|
|
|
52
55
|
---
|
|
53
56
|
|
|
@@ -57,6 +60,12 @@ All tokens are `--price-list-*`; full table in `CONSUMER-STYLING.md` next to the
|
|
|
57
60
|
Common ones: `--price-list-divider-colour`, `--price-list-divider-opacity`,
|
|
58
61
|
`--price-list-price-font-size`, `--price-list-heading-margin-block-end`.
|
|
59
62
|
|
|
63
|
+
Layout and long data: `--price-list-column-min-inline-size` (`32rem`) and `--price-list-max-columns`
|
|
64
|
+
(`2`) control when columns sit side by side; `--price-list-price-max-inline-size` (`50%`) caps the
|
|
65
|
+
price so a long price wraps instead of crushing the description; `--price-list-description-line-clamp`
|
|
66
|
+
(`none`) caps description lines. Unbroken strings wrap anywhere. The `StressTest` story covers the
|
|
67
|
+
worst-case data.
|
|
68
|
+
|
|
60
69
|
---
|
|
61
70
|
|
|
62
71
|
## Usage
|
|
@@ -110,8 +110,33 @@ buggy toggle-based version too.
|
|
|
110
110
|
| `mainNavAriaLabel` | `string` | `"Main navigation"` | aria-label on the primary nav landmark — override for localisation. |
|
|
111
111
|
| `secondaryNavAriaLabel` | `string` | `"Secondary navigation"` | aria-label on the secondary (overflow) nav landmark — override for localisation. |
|
|
112
112
|
| `overflowMenuAriaLabel` | `string` | `"Overflow navigation menu"` | Forwarded to `NavigationItems`' `ariaLabel` — override for localisation. |
|
|
113
|
+
| `overflowButtonLabel` | `string` | `"More navigation"` | Accessible name of the overflow/burger button, which shows only icons. |
|
|
114
|
+
| `submenuAriaLabel` | `string` | `"{title} submenu"` | aria-label on each dropdown summary, top bar and overflow panel; `{title}` is replaced with `childLinksTitle` (or `name`). Forwarded to `NavigationItems`. |
|
|
113
115
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.navigation` element. |
|
|
114
116
|
|
|
117
|
+
## Data edge cases (since 2026-10-05)
|
|
118
|
+
|
|
119
|
+
- A dropdown with no `childLinksTitle` shows its `name` instead (it used to render an empty
|
|
120
|
+
summary labelled "undefined submenu").
|
|
121
|
+
- Child links are keyed by index, so duplicate names are fine.
|
|
122
|
+
- The overflow button appears whenever any item is hidden, whatever the group keys are called.
|
|
123
|
+
It used to read `navListVisibility.firstNav`/`secondNav` only, so a group named anything else
|
|
124
|
+
(e.g. `main`) kept the burger showing even when everything fitted. Empty nav data never shows
|
|
125
|
+
it, and still completes the geometry pass.
|
|
126
|
+
- The top-bar dropdown and the overflow panel are capped in width and height (tokens in
|
|
127
|
+
`CONSUMER-STYLING.md`); long names wrap and long lists scroll inside them.
|
|
128
|
+
- **Fit check (fixed 2026-10-05):** an item is visible when its right edge is at or before the main
|
|
129
|
+
nav's own measured right edge (which already has the secondary nav and gap reserved as
|
|
130
|
+
`margin-inline-end`). It used to compare against the wrapper while adding the gap a second time
|
|
131
|
+
with a strict `<`, so the last item of a flush-right second group (e.g. "Contact") was always
|
|
132
|
+
sent to the overflow menu, however wide the screen.
|
|
133
|
+
- The component copies `responsiveNavLinks` before measuring. It used to write each item's
|
|
134
|
+
`config` straight into the consumer's own nav data object.
|
|
135
|
+
- The `StressTest` story covers all of the above.
|
|
136
|
+
- **Alignment (added 2026-10-05):** `--responsive-header-main-nav-justify-content`
|
|
137
|
+
(default `space-between`) sets how the main nav's groups sit along the bar; use `safe end` /
|
|
138
|
+
`safe center`, never plain `end`/`center`. The `MainNavAlignment` story shows the options.
|
|
139
|
+
|
|
115
140
|
## Slots
|
|
116
141
|
|
|
117
142
|
| Slot | Purpose |
|
|
@@ -27,6 +27,8 @@ Branding (logo/wordmark) is entirely consumer-authored via the `#branding` slot
|
|
|
27
27
|
| `mainNavAriaLabel` | `string` | `"Main navigation"` | Forwarded to `ResponsiveHeader`'s `mainNavAriaLabel` — override for localisation. |
|
|
28
28
|
| `secondaryNavAriaLabel` | `string` | `"Secondary navigation"` | Forwarded to `ResponsiveHeader`'s `secondaryNavAriaLabel` — override for localisation. |
|
|
29
29
|
| `overflowMenuAriaLabel` | `string` | `"Overflow navigation menu"` | Forwarded to `ResponsiveHeader`'s `overflowMenuAriaLabel` — override for localisation. |
|
|
30
|
+
| `overflowButtonLabel` | `string` | `"More navigation"` | Forwarded to `ResponsiveHeader`'s `overflowButtonLabel`, the burger button's accessible name. |
|
|
31
|
+
| `submenuAriaLabel` | `string` | `"{title} submenu"` | Forwarded to `ResponsiveHeader`'s `submenuAriaLabel`; `{title}` is replaced with the item's title. |
|
|
30
32
|
|
|
31
33
|
---
|
|
32
34
|
|
|
@@ -271,6 +271,20 @@ Key points:
|
|
|
271
271
|
- Extra args must be stripped before `v-bind` — spreading unknown keys onto a component makes them unknown HTML attributes.
|
|
272
272
|
- CSS custom properties set via `:style` on the component root are picked up by `var()` in the component's scoped CSS.
|
|
273
273
|
|
|
274
|
+
### Label token controls as story-only
|
|
275
|
+
|
|
276
|
+
A control that drives a CSS token (a line clamp, a colour) looks exactly like a prop in the
|
|
277
|
+
Controls panel and the generated docs table, so a developer reading the story can assume it's a
|
|
278
|
+
prop and try to pass it. Make it unmistakable:
|
|
279
|
+
|
|
280
|
+
- Put it in the category `"CSS tokens (story only, set in your CSS)"`.
|
|
281
|
+
- Start the description with `**Story control, not a prop.**`, name the token, and show the CSS a
|
|
282
|
+
consumer would write instead, e.g.
|
|
283
|
+
`` `.salon-menu { --price-list-description-line-clamp: 2; }` ``.
|
|
284
|
+
- If a story's docs text tells people to try the control, say there too that it's a CSS token.
|
|
285
|
+
|
|
286
|
+
`PriceList.stories.ts` and `GoogleReviews.stories.ts` (line-clamp controls) follow this.
|
|
287
|
+
|
|
274
288
|
## Notes
|
|
275
289
|
|
|
276
290
|
- **Colour scheme:** `.storybook/preview.ts` pins every story to the light scheme
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
" main-nav-aria-label=\"${2:Main navigation}\"",
|
|
25
25
|
" secondary-nav-aria-label=\"${3:Secondary navigation}\"",
|
|
26
26
|
" overflow-menu-aria-label=\"${4:Overflow navigation menu}\"",
|
|
27
|
-
"
|
|
27
|
+
" overflow-button-label=\"${5:More navigation}\"",
|
|
28
|
+
" submenu-aria-label=\"${6:{title\\} submenu}\"",
|
|
29
|
+
"></ResponsiveHeader>"
|
|
28
30
|
]
|
|
29
31
|
},
|
|
30
32
|
"SRCDEV ResponsiveHeader Nav Data (with dropdown)": {
|
|
@@ -40,9 +40,11 @@
|
|
|
40
40
|
" main-nav-aria-label=\"${3:Main navigation}\"",
|
|
41
41
|
" secondary-nav-aria-label=\"${4:Secondary navigation}\"",
|
|
42
42
|
" overflow-menu-aria-label=\"${5:Overflow navigation menu}\"",
|
|
43
|
+
" overflow-button-label=\"${6:More navigation}\"",
|
|
44
|
+
" submenu-aria-label=\"${7:{title\\} submenu}\"",
|
|
43
45
|
">",
|
|
44
46
|
" <template #branding>",
|
|
45
|
-
" <NuxtLink to=\"/\" class=\"home-link\">$
|
|
47
|
+
" <NuxtLink to=\"/\" class=\"home-link\">$8Brand</NuxtLink>",
|
|
46
48
|
" </template>",
|
|
47
49
|
"</SiteHeader>"
|
|
48
50
|
]
|
|
@@ -25,6 +25,7 @@ deliberately matches `PriceList`.
|
|
|
25
25
|
| `--opening-hours-day-font-size` | `1.4rem` | Day name / exception label size |
|
|
26
26
|
| `--opening-hours-day-colour` | `inherit` | Day name / exception label colour |
|
|
27
27
|
| `--opening-hours-hours-font-size` | `1.4rem` | Times and status text size |
|
|
28
|
+
| `--opening-hours-hours-max-inline-size` | `60%` | Most of the row the hours column can take. Longer content (long session labels, notes, status labels) wraps inside it |
|
|
28
29
|
| `--opening-hours-closed-colour` | `inherit` | Hours colour on closed rows |
|
|
29
30
|
|
|
30
31
|
### Sessions and notes
|
|
@@ -36,6 +37,9 @@ deliberately matches `PriceList`.
|
|
|
36
37
|
| `--opening-hours-session-label-colour` | `inherit` | Session label colour |
|
|
37
38
|
| `--opening-hours-note-font-size` | `1.2rem` | Note size |
|
|
38
39
|
| `--opening-hours-note-colour` | `inherit` | Note colour |
|
|
40
|
+
| `--opening-hours-note-line-clamp` | `none` | Cap notes at this many lines, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
|
|
41
|
+
|
|
42
|
+
Each session's times stay on one line; a long session label wraps before them.
|
|
39
43
|
|
|
40
44
|
### Today
|
|
41
45
|
|
|
@@ -54,8 +58,13 @@ deliberately matches `PriceList`.
|
|
|
54
58
|
| `--opening-hours-exceptions-heading-margin-block-end` | `1.2rem` | Space below the exceptions heading |
|
|
55
59
|
| `--opening-hours-date-font-size` | `1.2rem` | Exception date size |
|
|
56
60
|
| `--opening-hours-date-colour` | `inherit` | Exception date colour |
|
|
61
|
+
| `--opening-hours-exception-label-line-clamp` | `none` | Cap exception labels at this many lines. `1` is single-line ellipsis, `none` shows everything |
|
|
62
|
+
|
|
63
|
+
There are no private `--_` tokens. Long unbroken text anywhere in the component wraps rather than overflowing.
|
|
57
64
|
|
|
58
|
-
|
|
65
|
+
> **Changed 2026-10-05**: rows are a grid instead of a flex row, so the day column can shrink and
|
|
66
|
+
> the hours column is capped by `--opening-hours-hours-max-inline-size`. `.opening-hours__session`
|
|
67
|
+
> is no longer `nowrap`; the new `.opening-hours__session-times` wrapper is.
|
|
59
68
|
|
|
60
69
|
---
|
|
61
70
|
|
|
@@ -77,6 +86,7 @@ Inner element classes, safe to target:
|
|
|
77
86
|
| `.opening-hours__hours` | `<dd>` |
|
|
78
87
|
| `.opening-hours__session` | One opening session |
|
|
79
88
|
| `.opening-hours__session-label` | Session label ("Lunch") |
|
|
89
|
+
| `.opening-hours__session-times` | The opens–closes pair, kept on one line |
|
|
80
90
|
| `.opening-hours__status` | "Closed" / "Open 24 hours" / "By appointment only" |
|
|
81
91
|
| `.opening-hours__note` | Per-day or per-exception note |
|
|
82
92
|
| `.opening-hours__exceptions` | Exceptions wrapper |
|
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
<template v-if="row.status === 'open'">
|
|
22
22
|
<span v-for="(session, index) in row.sessions" :key="index" class="opening-hours__session">
|
|
23
23
|
<span v-if="session.label" class="opening-hours__session-label">{{ session.label }}</span>
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
<span class="opening-hours__session-times">
|
|
25
|
+
<time :datetime="session.opens">{{ formatTime(session.opens) }}</time>
|
|
26
|
+
<span aria-hidden="true"> – </span>
|
|
27
|
+
<span class="sr-only"> {{ toLabel }} </span>
|
|
28
|
+
<time :datetime="session.closes">{{ formatTime(session.closes) }}</time>
|
|
29
|
+
</span>
|
|
28
30
|
</span>
|
|
29
31
|
</template>
|
|
30
32
|
<span v-else class="opening-hours__status">{{ statusLabel(row.status) }}</span>
|
|
@@ -61,10 +63,12 @@
|
|
|
61
63
|
<template v-if="row.status === 'open'">
|
|
62
64
|
<span v-for="(session, index) in row.sessions" :key="index" class="opening-hours__session">
|
|
63
65
|
<span v-if="session.label" class="opening-hours__session-label">{{ session.label }}</span>
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
<span class="opening-hours__session-times">
|
|
67
|
+
<time :datetime="session.opens">{{ formatTime(session.opens) }}</time>
|
|
68
|
+
<span aria-hidden="true"> – </span>
|
|
69
|
+
<span class="sr-only"> {{ toLabel }} </span>
|
|
70
|
+
<time :datetime="session.closes">{{ formatTime(session.closes) }}</time>
|
|
71
|
+
</span>
|
|
68
72
|
</span>
|
|
69
73
|
</template>
|
|
70
74
|
<span v-else class="opening-hours__status">{{ statusLabel(row.status) }}</span>
|
|
@@ -138,29 +142,35 @@ watch(
|
|
|
138
142
|
const todayIso = ref<string | null>(null);
|
|
139
143
|
|
|
140
144
|
onMounted(() => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "2-digit", day: "2-digit" };
|
|
146
|
+
let formatter: Intl.DateTimeFormat;
|
|
147
|
+
try {
|
|
148
|
+
formatter = new Intl.DateTimeFormat("en-CA", { ...options, timeZone: props.timeZone });
|
|
149
|
+
} catch {
|
|
150
|
+
formatter = new Intl.DateTimeFormat("en-CA", options);
|
|
151
|
+
}
|
|
152
|
+
todayIso.value = formatter.format(new Date());
|
|
147
153
|
});
|
|
148
154
|
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
return
|
|
152
|
-
};
|
|
155
|
+
const todayWeekday = computed<OpeningWeekday | null>(() => {
|
|
156
|
+
const today = todayIso.value ? parseOpeningDate(todayIso.value) : null;
|
|
157
|
+
return today ? (((today.getUTCDay() + 6) % 7) as OpeningWeekday) : null;
|
|
158
|
+
});
|
|
153
159
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
);
|
|
160
|
+
const safeLocale = computed(() => {
|
|
161
|
+
try {
|
|
162
|
+
return Intl.getCanonicalLocales(props.locale)[0] ?? "en-GB";
|
|
163
|
+
} catch {
|
|
164
|
+
return "en-GB";
|
|
165
|
+
}
|
|
166
|
+
});
|
|
157
167
|
|
|
158
168
|
const dayFormatter = computed(
|
|
159
|
-
() => new Intl.DateTimeFormat(
|
|
169
|
+
() => new Intl.DateTimeFormat(safeLocale.value, { weekday: props.dayFormat, timeZone: "UTC" })
|
|
160
170
|
);
|
|
161
171
|
const timeFormatter = computed(
|
|
162
172
|
() =>
|
|
163
|
-
new Intl.DateTimeFormat(
|
|
173
|
+
new Intl.DateTimeFormat(safeLocale.value, {
|
|
164
174
|
hour: props.hour12 ? "numeric" : "2-digit",
|
|
165
175
|
minute: "2-digit",
|
|
166
176
|
hour12: props.hour12,
|
|
@@ -168,15 +178,20 @@ const timeFormatter = computed(
|
|
|
168
178
|
})
|
|
169
179
|
);
|
|
170
180
|
const dateFormatter = computed(
|
|
171
|
-
() => new Intl.DateTimeFormat(
|
|
181
|
+
() => new Intl.DateTimeFormat(safeLocale.value, { day: "numeric", month: "long", timeZone: "UTC" })
|
|
172
182
|
);
|
|
173
183
|
|
|
174
184
|
// 1 Jan 2024 was a Monday
|
|
175
185
|
const dayName = (day: OpeningWeekday) => dayFormatter.value.format(new Date(Date.UTC(2024, 0, 1 + day)));
|
|
176
186
|
|
|
177
187
|
const formatTime = (time: string) => {
|
|
178
|
-
const
|
|
179
|
-
return timeFormatter.value.format(new Date(Date.UTC(2024, 0, 1,
|
|
188
|
+
const parsed = parseOpeningTime(time);
|
|
189
|
+
return parsed ? timeFormatter.value.format(new Date(Date.UTC(2024, 0, 1, parsed.hours, parsed.minutes))) : time;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const formatDate = (iso: string) => {
|
|
193
|
+
const date = parseOpeningDate(iso);
|
|
194
|
+
return date ? dateFormatter.value.format(date) : iso;
|
|
180
195
|
};
|
|
181
196
|
|
|
182
197
|
const statusLabel = (status: OpeningStatus) => {
|
|
@@ -225,21 +240,22 @@ const weeklyRows = computed(() => {
|
|
|
225
240
|
|
|
226
241
|
const exceptionRows = computed(() =>
|
|
227
242
|
props.exceptions
|
|
228
|
-
.filter((exception) =>
|
|
229
|
-
|
|
230
|
-
|
|
243
|
+
.filter((exception) => {
|
|
244
|
+
const last = exception.to && exception.to > exception.from ? exception.to : exception.from;
|
|
245
|
+
return !(props.hidePastExceptions && todayIso.value && last < todayIso.value);
|
|
246
|
+
})
|
|
247
|
+
.map((exception, index) => {
|
|
248
|
+
const [from, rawTo] =
|
|
249
|
+
exception.to && exception.to < exception.from ? [exception.to, exception.from] : [exception.from, exception.to];
|
|
250
|
+
const to = rawTo && rawTo !== from ? rawTo : null;
|
|
231
251
|
return {
|
|
232
|
-
key: `${
|
|
252
|
+
key: `${index}-${from}-${to ?? ""}`,
|
|
233
253
|
label: exception.label,
|
|
234
|
-
from
|
|
254
|
+
from,
|
|
235
255
|
to,
|
|
236
|
-
start:
|
|
237
|
-
end: to ?
|
|
238
|
-
isToday:
|
|
239
|
-
props.highlightToday &&
|
|
240
|
-
todayIso.value !== null &&
|
|
241
|
-
exception.from <= todayIso.value &&
|
|
242
|
-
todayIso.value <= (to ?? exception.from),
|
|
256
|
+
start: formatDate(from),
|
|
257
|
+
end: to ? formatDate(to) : null,
|
|
258
|
+
isToday: props.highlightToday && todayIso.value !== null && from <= todayIso.value && todayIso.value <= (to ?? from),
|
|
243
259
|
...resolve(exception),
|
|
244
260
|
};
|
|
245
261
|
})
|
|
@@ -269,6 +285,7 @@ if (props.structuredData) {
|
|
|
269
285
|
gap: var(--opening-hours-section-gap, 3.2rem);
|
|
270
286
|
|
|
271
287
|
.opening-hours__exceptions-heading {
|
|
288
|
+
overflow-wrap: anywhere;
|
|
272
289
|
font-size: var(--opening-hours-exceptions-heading-font-size, 1.6rem);
|
|
273
290
|
font-weight: var(--opening-hours-exceptions-heading-font-weight, 600);
|
|
274
291
|
margin: 0 0 var(--opening-hours-exceptions-heading-margin-block-end, 1.2rem);
|
|
@@ -279,10 +296,10 @@ if (props.structuredData) {
|
|
|
279
296
|
padding: 0;
|
|
280
297
|
|
|
281
298
|
.opening-hours__row {
|
|
282
|
-
display:
|
|
283
|
-
|
|
299
|
+
display: grid;
|
|
300
|
+
grid-template-columns: minmax(0, 1fr) fit-content(var(--opening-hours-hours-max-inline-size, 60%));
|
|
284
301
|
align-items: baseline;
|
|
285
|
-
gap: var(--opening-hours-row-gap, 1.2rem);
|
|
302
|
+
column-gap: var(--opening-hours-row-gap, 1.2rem);
|
|
286
303
|
padding-block: var(--opening-hours-row-padding-block, 1.2rem);
|
|
287
304
|
padding-inline: var(--opening-hours-row-padding-inline, 0);
|
|
288
305
|
border-block-end: var(--opening-hours-divider-width, 1px) solid
|
|
@@ -308,12 +325,17 @@ if (props.structuredData) {
|
|
|
308
325
|
}
|
|
309
326
|
|
|
310
327
|
.opening-hours__days {
|
|
328
|
+
overflow-wrap: anywhere;
|
|
311
329
|
font-size: var(--opening-hours-day-font-size, 1.4rem);
|
|
312
330
|
color: var(--opening-hours-day-colour, inherit);
|
|
313
331
|
}
|
|
314
332
|
|
|
315
333
|
.opening-hours__exception-label {
|
|
316
|
-
display:
|
|
334
|
+
display: -webkit-box;
|
|
335
|
+
-webkit-box-orient: vertical;
|
|
336
|
+
overflow: hidden;
|
|
337
|
+
-webkit-line-clamp: var(--opening-hours-exception-label-line-clamp, none);
|
|
338
|
+
line-clamp: var(--opening-hours-exception-label-line-clamp, none);
|
|
317
339
|
}
|
|
318
340
|
|
|
319
341
|
.opening-hours__date {
|
|
@@ -329,11 +351,12 @@ if (props.structuredData) {
|
|
|
329
351
|
gap: var(--opening-hours-session-gap, 0.4rem);
|
|
330
352
|
margin: 0;
|
|
331
353
|
text-align: end;
|
|
354
|
+
overflow-wrap: anywhere;
|
|
332
355
|
font-size: var(--opening-hours-hours-font-size, 1.4rem);
|
|
333
356
|
font-variant-numeric: tabular-nums;
|
|
334
357
|
}
|
|
335
358
|
|
|
336
|
-
.opening-hours__session {
|
|
359
|
+
.opening-hours__session-times {
|
|
337
360
|
white-space: nowrap;
|
|
338
361
|
}
|
|
339
362
|
|
|
@@ -343,6 +366,11 @@ if (props.structuredData) {
|
|
|
343
366
|
}
|
|
344
367
|
|
|
345
368
|
.opening-hours__note {
|
|
369
|
+
display: -webkit-box;
|
|
370
|
+
-webkit-box-orient: vertical;
|
|
371
|
+
overflow: hidden;
|
|
372
|
+
-webkit-line-clamp: var(--opening-hours-note-line-clamp, none);
|
|
373
|
+
line-clamp: var(--opening-hours-note-line-clamp, none);
|
|
346
374
|
font-size: var(--opening-hours-note-font-size, 1.2rem);
|
|
347
375
|
color: var(--opening-hours-note-colour, inherit);
|
|
348
376
|
}
|
|
@@ -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
|
+
};
|