@medalsocial/meda 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +35 -1
  2. package/dist/booking/add-child-sheet.d.ts +2 -1
  3. package/dist/booking/add-child-sheet.js +2 -1
  4. package/dist/booking/booking-skeleton.d.ts +2 -1
  5. package/dist/booking/confirmation.d.ts +2 -1
  6. package/dist/booking/confirmation.js +11 -11
  7. package/dist/booking/details-screen.d.ts +2 -1
  8. package/dist/booking/details-screen.js +13 -10
  9. package/dist/booking/index.d.ts +2 -1
  10. package/dist/booking/index.js +2 -1
  11. package/dist/booking/internal/label-parts.d.ts +39 -4
  12. package/dist/booking/internal/label-parts.js +78 -12
  13. package/dist/booking/label-keys.d.ts +2 -1
  14. package/dist/booking/labels.d.ts +37 -8
  15. package/dist/booking/labels.js +45 -8
  16. package/dist/booking/login-panel.d.ts +3 -2
  17. package/dist/booking/login-panel.js +8 -7
  18. package/dist/booking/login-sheet.d.ts +2 -1
  19. package/dist/booking/login-sheet.js +2 -1
  20. package/dist/booking/manage-screen.d.ts +2 -1
  21. package/dist/booking/manage-screen.js +30 -22
  22. package/dist/booking/otp-slots.d.ts +2 -1
  23. package/dist/booking/otp-slots.js +2 -1
  24. package/dist/booking/portal/age-confirm-card.d.ts +2 -1
  25. package/dist/booking/portal/age-confirm-card.js +3 -3
  26. package/dist/booking/portal/child-cards.d.ts +2 -1
  27. package/dist/booking/portal/child-cards.js +5 -5
  28. package/dist/booking/portal/data-controls.d.ts +2 -1
  29. package/dist/booking/portal/data-controls.js +5 -4
  30. package/dist/booking/portal/family-editor.d.ts +2 -1
  31. package/dist/booking/portal/family-editor.js +9 -9
  32. package/dist/booking/portal/logout-button.d.ts +2 -1
  33. package/dist/booking/portal/logout-button.js +2 -1
  34. package/dist/booking/portal/portal-shell.d.ts +2 -1
  35. package/dist/booking/portal/portal-shell.js +3 -2
  36. package/dist/booking/portal/portal-unreachable.d.ts +2 -1
  37. package/dist/booking/portal/portal-unreachable.js +2 -2
  38. package/dist/booking/portal/profile-form.d.ts +2 -1
  39. package/dist/booking/portal/profile-form.js +3 -2
  40. package/dist/booking/portal/rebook-cards.d.ts +2 -1
  41. package/dist/booking/portal/rebook-cards.js +3 -2
  42. package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
  43. package/dist/booking/portal/upcoming-bookings.js +10 -14
  44. package/dist/booking/portal/vipps-link-row.d.ts +2 -1
  45. package/dist/booking/portal/vipps-link-row.js +7 -6
  46. package/dist/booking/portal/visit-history.d.ts +2 -1
  47. package/dist/booking/portal/visit-history.js +2 -2
  48. package/dist/booking/service-screen.d.ts +7 -3
  49. package/dist/booking/service-screen.js +9 -7
  50. package/dist/booking/stylist-screen.d.ts +15 -3
  51. package/dist/booking/stylist-screen.js +21 -17
  52. package/dist/booking/summary-bar.d.ts +2 -1
  53. package/dist/booking/time-screen.d.ts +2 -1
  54. package/dist/booking/time-screen.js +16 -15
  55. package/dist/booking/vipps-button.d.ts +2 -1
  56. package/dist/booking/vipps-button.js +2 -1
  57. package/dist/booking/who-screen.d.ts +2 -1
  58. package/dist/booking/who-screen.js +7 -6
  59. package/package.json +8 -8
  60. package/skills/components/SKILL.md +4 -0
package/README.md CHANGED
@@ -81,13 +81,47 @@ ships no default language and no brand. Override, cheapest first:
81
81
  1. **CSS variables** — the shadcn tokens the bridge maps (`--primary`, `--card`,
82
82
  `--muted`, `--border`, `--ring`, `--radius`, …). Colours come from nowhere else.
83
83
  2. **`labels`** — a flat record (`'who.heading'`, …); `BookingLabels` is the full set
84
- and `BOOKING_LABEL_KEYS` lists it. `{placeholders}` are filled by the screen.
84
+ and `BOOKING_LABEL_KEYS` lists it. `{placeholders}` are filled by the screen. A
85
+ value is a `BookingLabel`: a **string** renders as one text node once filled (like
86
+ a template literal), a **string array** renders one text node per element, each
87
+ element filled with the same values (see below).
85
88
  3. **`classNames`** — per documented slot (`root`, `card`, `cardSelected`, `chip`, …),
86
89
  merged after the defaults so an override wins.
87
90
  4. **`components`** — card renderers (`ServiceCard`, `StylistCard`, `TimeChip`,
88
91
  `DayChip`, `PersonCard`, …). The defaults are exported (`DefaultServiceCard`, …)
89
92
  so an override can wrap them.
90
93
 
94
+ ### Where a sentence breaks into text nodes
95
+
96
+ A browser lays text out per DOM text node, so the same sentence split differently can
97
+ land its later glyphs a sub-pixel apart. The pack decides; a screen never splits a
98
+ string on its own:
99
+
100
+ ```ts
101
+ // One text node: «Total 840 kr · paid at the salon».
102
+ const asOneNode = {
103
+ 'confirmation.party.total': 'Total {total} · paid at the salon',
104
+ } satisfies Partial<BookingLabels>;
105
+
106
+ // Four text nodes, the DOM of `Total {total}{' · '}paid at the salon` in JSX.
107
+ const asPieces = {
108
+ 'confirmation.party.total': ['Total ', '{total}', ' · ', 'paid at the salon'],
109
+ } satisfies Partial<BookingLabels>;
110
+ ```
111
+
112
+ - An element that fills to `''` renders nothing; a label that fills to nothing renders
113
+ nothing at all.
114
+ - Lines joined from several parts (the confirmation card's `service · stylist · when ·
115
+ price`, the portal's booking lines) are one text node while every part is a string,
116
+ and in pieces (each part and separator its own node) once one of their labels is an
117
+ array. `joinLabelParts(parts, separator, { pieces })` forces either.
118
+ - Text-only targets — `aria-label`, `placeholder`, file names, error and status
119
+ messages, live-region announcements, a value filled into another label, and a custom
120
+ component's `string` prop — read the label flattened (`fillLabel`, `labelText`).
121
+ - Helpers, for UI composed around the screens: `renderLabel(label, values)` (element
122
+ children), `fillLabel(label, values)` (text), `labelParts(label, { hole: <el/> })`
123
+ (element holes), `fillLabelPieces`, `labelText`, `joinLabelParts`.
124
+
91
125
  ```css
92
126
  @import 'tailwindcss';
93
127
  @import '@medalsocial/meda/styles/bridge.css'; /* your own shadcn tokens */
@@ -1,4 +1,5 @@
1
1
  import type { BookingFormat } from './format.js';
2
+ import { type BookingLabel } from './labels.js';
2
3
  import { type SlotClassNames } from './slots.js';
3
4
  import type { NewChild, SaveResult } from './types.js';
4
5
  /**
@@ -13,7 +14,7 @@ import type { NewChild, SaveResult } from './types.js';
13
14
  * save — so a child is never shown as added before it exists.
14
15
  */
15
16
  export declare const ADD_CHILD_SHEET_LABEL_KEYS: readonly ["addChild.trigger", "addChild.title", "addChild.description.saved", "addChild.description.local", "addChild.name", "addChild.birthYear", "addChild.birthYearPlaceholder", "addChild.birthMonth", "addChild.birthMonthUnknown", "addChild.notes", "addChild.missing", "addChild.submit", "addChild.close"];
16
- export type AddChildSheetLabels = Record<(typeof ADD_CHILD_SHEET_LABEL_KEYS)[number], string>;
17
+ export type AddChildSheetLabels = Record<(typeof ADD_CHILD_SHEET_LABEL_KEYS)[number], BookingLabel>;
17
18
  /**
18
19
  * - `root` — the dashed trigger card (the only element in the page flow)
19
20
  * - `content` — the sheet (`<dialog>`)
@@ -7,6 +7,7 @@ import { Input } from '../primitives/input.js';
7
7
  import { Sheet } from '../primitives/sheet.js';
8
8
  import { Textarea } from '../primitives/textarea.js';
9
9
  import { BOOKING_INPUT_CLASS, BOOKING_SELECT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
10
+ import { labelText } from './labels.js';
10
11
  import { slotClass } from './slots.js';
11
12
  /**
12
13
  * «+ Add child» — a dashed card that opens a small sheet: name, birth year,
@@ -58,7 +59,7 @@ export function AddChildSheet({ labels, format, saveNotes, disabled, onSave, cur
58
59
  event.preventDefault();
59
60
  const trimmed = name.trim();
60
61
  if (trimmed === '' || year === '') {
61
- setError(labels['addChild.missing']);
62
+ setError(labelText(labels['addChild.missing']));
62
63
  return;
63
64
  }
64
65
  startTransition(async () => {
@@ -1,7 +1,8 @@
1
+ import type { BookingLabel } from './labels.js';
1
2
  import { type SlotClassNames } from './slots.js';
2
3
  export declare const BOOKING_SKELETON_LABEL_KEYS: readonly ["bookingSkeleton.opening"];
3
4
  /** Copy for the pending skeleton: the sentence a screen reader hears while it is up. */
4
- export type BookingSkeletonLabels = Record<(typeof BOOKING_SKELETON_LABEL_KEYS)[number], string>;
5
+ export type BookingSkeletonLabels = Record<(typeof BOOKING_SKELETON_LABEL_KEYS)[number], BookingLabel>;
5
6
  /**
6
7
  * `classNames` slots: `root` the fixed overlay · `frame` the pulsing column ·
7
8
  * `bar` the active step-bar segment · `chip` one of the four person chips.
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType } from 'react';
2
2
  import type { BookingFormat } from './format.js';
3
+ import { type BookingLabel } from './labels.js';
3
4
  import { type SlotClassNames } from './slots.js';
4
5
  import type { WizardItem } from './types.js';
5
6
  /**
@@ -14,7 +15,7 @@ import type { WizardItem } from './types.js';
14
15
  * link to a page that 404s reads as a booking that did not quite happen.
15
16
  */
16
17
  export declare const CONFIRMATION_LABEL_KEYS: readonly ["confirmation.heading", "confirmation.serviceFor", "confirmation.when", "confirmation.party.heading", "confirmation.party.line", "confirmation.party.stylist", "confirmation.party.total", "confirmation.calendar", "confirmation.manage", "confirmation.manageFor", "confirmation.portal", "confirmation.startOver"];
17
- export type ConfirmationLabels = Record<(typeof CONFIRMATION_LABEL_KEYS)[number], string>;
18
+ export type ConfirmationLabels = Record<(typeof CONFIRMATION_LABEL_KEYS)[number], BookingLabel>;
18
19
  /**
19
20
  * One booked line (one person's appointment). One array rather than parallel
20
21
  * lists, because every part answers about the SAME line.
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { joinLabelParts, renderLabel } from './internal/label-parts.js';
3
4
  import { BookingButton } from './internal/ui.js';
4
- import { fillLabel } from './labels.js';
5
+ import { fillLabel, labelText } from './labels.js';
5
6
  import { slotClass } from './slots.js';
6
7
  /**
7
8
  * The confirmation screen — the one the whole flow exists to reach.
@@ -50,24 +51,21 @@ export function Confirmation({ lines: rawLines, startTs, totalOre, address = nul
50
51
  }));
51
52
  const linkClass = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4');
52
53
  const first = lines[0];
53
- const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: [
54
+ const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: joinLabelParts([
54
55
  first.item.bookedForName
55
- ? fillLabel(labels['confirmation.serviceFor'], {
56
+ ? renderLabel(labels['confirmation.serviceFor'], {
56
57
  service: first.item.service.name,
57
58
  name: first.item.bookedForName,
58
59
  })
59
60
  : first.item.service.name,
60
61
  first.stylistName,
61
- fillLabel(labels['confirmation.when'], {
62
+ renderLabel(labels['confirmation.when'], {
62
63
  day: clock.dayLabel(startTs, now),
63
64
  time: clock.formatTime(startTs),
64
65
  }),
65
66
  // This line's own price, which for one person is the whole total.
66
67
  format.price(first.priceOre),
67
- ]
68
- // Filtered, not conditionally joined: an absent stylist closes the gap.
69
- .filter((part) => Boolean(part))
70
- .join(SEPARATOR) })) : null;
68
+ ], SEPARATOR) })) : null;
71
69
  return (_jsxs("section", { "aria-labelledby": "booking-confirmed-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-confirmed-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['confirmation.heading'] }), lines.length > 1 ? (_jsx(PartyCard, { lines: lines, totalOre: totalOre, now: now, format: format, labels: labels, classNames: classNames, PartyLine: components?.PartyLine ?? DefaultPartyLine })) : (singleCard), address && (_jsx("p", { className: slotClass(classNames, 'address', 'text-sm text-muted-foreground'), children: address })), _jsxs("div", { className: "flex flex-wrap gap-4", children: [calendarHref && (_jsx("a", { href: calendarHref, download: calendarFileName, className: linkClass, children: labels['confirmation.calendar'] })), lines.length === 1 && first?.manageHref && (_jsx("a", { href: first.manageHref, className: linkClass, children: labels['confirmation.manage'] }))] }), portalHref && (_jsx("p", { className: "text-sm", children: _jsx("a", { href: portalHref, className: linkClass, children: labels['confirmation.portal'] }) })), onStartOver && (_jsx("div", { children: _jsx(BookingButton, { variant: "outline", className: slotClass(classNames, 'startOver'), onClick: onStartOver, children: labels['confirmation.startOver'] }) }))] }));
72
70
  }
73
71
  /**
@@ -77,7 +75,9 @@ export function Confirmation({ lines: rawLines, startTs, totalOre, address = nul
77
75
  function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine, }) {
78
76
  const { clock } = format;
79
77
  const firstStart = lines[0]?.startTs ?? 0;
80
- return (_jsxs("div", { className: slotClass(classNames, 'partyCard', 'space-y-2 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: fillLabel(labels['confirmation.party.heading'], { day: clock.dayLabel(firstStart, now) }) }), _jsx("ul", { className: "space-y-1", children: lines.map((line, index) => {
78
+ return (_jsxs("div", { className: slotClass(classNames, 'partyCard', 'space-y-2 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: renderLabel(labels['confirmation.party.heading'], {
79
+ day: clock.dayLabel(firstStart, now),
80
+ }) }), _jsx("ul", { className: "space-y-1", children: lines.map((line, index) => {
81
81
  const time = clock.formatTime(line.startTs);
82
82
  const text = fillLabel(labels['confirmation.party.line'], {
83
83
  time,
@@ -94,9 +94,9 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
94
94
  .trim();
95
95
  return (_jsx(PartyLine
96
96
  // biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
97
- , { line: line, text: text, manageLabel: labels['confirmation.manage'], manageAriaLabel: fillLabel(labels['confirmation.manageFor'], {
97
+ , { line: line, text: text, manageLabel: labelText(labels['confirmation.manage']), manageAriaLabel: fillLabel(labels['confirmation.manageFor'], {
98
98
  who: line.item.bookedForName ?? line.item.service.name,
99
99
  time,
100
100
  }), linkClassName: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4') }, index));
101
- }) }), _jsx("p", { className: "font-semibold", children: fillLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
101
+ }) }), _jsx("p", { className: "font-semibold", children: renderLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
102
102
  }
@@ -1,5 +1,6 @@
1
1
  import { type ComponentType } from 'react';
2
2
  import type { BookingFormat } from './format.js';
3
+ import { type BookingLabel } from './labels.js';
3
4
  import { type SlotClassNames } from './slots.js';
4
5
  import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from './types.js';
5
6
  /**
@@ -14,7 +15,7 @@ import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from
14
15
  */
15
16
  export declare const DETAILS_SCREEN_LABEL_KEYS: readonly ["details.heading", "details.phone.label", "details.phone.prefix", "details.phone.error", "details.name.label", "details.known.label", "details.known.for", "details.known.self", "details.known.adult", "details.known.child", "details.child.name", "details.child.year", "details.child.yearPlaceholder", "details.email.label", "details.email.help", "details.email.error", "details.notes.label", "details.notes.placeholder", "details.terms.text", "details.terms.link", "details.marketing.text", "details.error.maxParty", "details.error.slotTaken", "details.error.conflict", "details.error.invalidInput", "details.error.unconfigured", "details.error.upstreamError", "details.error.inProgress", "details.call.link", "details.call.none", "details.submit"];
16
17
  export type DetailsScreenLabelKey = (typeof DETAILS_SCREEN_LABEL_KEYS)[number];
17
- export type DetailsScreenLabels = Record<DetailsScreenLabelKey, string>;
18
+ export type DetailsScreenLabels = Record<DetailsScreenLabelKey, BookingLabel>;
18
19
  /**
19
20
  * The label key for each failure the wizard can carry. Exported because some
20
21
  * of them (e.g. `maxParty`) are raised on earlier steps, where the shell shows
@@ -5,8 +5,9 @@ import { Checkbox } from '../primitives/checkbox.js';
5
5
  import { Field } from '../primitives/field.js';
6
6
  import { Input } from '../primitives/input.js';
7
7
  import { Textarea } from '../primitives/textarea.js';
8
+ import { renderLabel } from './internal/label-parts.js';
8
9
  import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
9
- import { fillLabel } from './labels.js';
10
+ import { fillLabel, labelText } from './labels.js';
10
11
  import { slotClass } from './slots.js';
11
12
  /**
12
13
  * The last step of the booking wizard: contact details, who each seat is for,
@@ -153,10 +154,10 @@ function knownWho(state, index, labels) {
153
154
  const item = state.items[index];
154
155
  if (item?.adult) {
155
156
  return state.people[index]?.key === 'self'
156
- ? labels['details.known.self']
157
- : labels['details.known.adult'];
157
+ ? labelText(labels['details.known.self'])
158
+ : labelText(labels['details.known.adult']);
158
159
  }
159
- return item?.bookedForName ?? labels['details.known.child'];
160
+ return item?.bookedForName ?? labelText(labels['details.known.child']);
160
161
  }
161
162
  /**
162
163
  * Whether step 1 already said who sits in this seat: a saved person's card
@@ -199,7 +200,8 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
199
200
  const showEmailError = !emailValid && (attempted || focused !== 'email');
200
201
  const showTermsError = attempted && !state.consentTerms;
201
202
  const price = format.price(totalOre);
202
- const phonePrefix = labels['details.phone.prefix'];
203
+ // `null` when the pack leaves it blank, in either form: no box, no description.
204
+ const phonePrefix = renderLabel(labels['details.phone.prefix']);
203
205
  const FamilyChip = components?.FamilyChip ?? DefaultFamilyChip;
204
206
  function setContact(name, value) {
205
207
  onChange({ type: 'setContact', field: name, value });
@@ -285,11 +287,11 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
285
287
  }
286
288
  const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
287
289
  const fieldClass = slotClass(classNames, 'field');
288
- return (_jsxs("section", { "aria-labelledby": "booking-details-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-details-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['details.heading'] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('phone'), children: labels['details.phone.label'] }), _jsxs("div", { className: "flex items-stretch", children: [phonePrefix && (_jsx("span", { id: field('phone-prefix'), className: "flex h-12 items-center rounded-l-[4px] border border-r-0 border-input bg-muted px-4 text-base text-muted-foreground", children: phonePrefix })), _jsx(Input, { id: field('phone'), ref: phoneRef, type: "tel", inputMode: "numeric", autoComplete: "tel-national", className: inputClass(phonePrefix ? 'rounded-l-none' : ''), value: state.contact.phone, "aria-describedby": phonePrefix ? field('phone-prefix') : undefined, "aria-invalid": showPhoneError || undefined, onFocus: () => setFocused('phone'), onBlur: () => setFocused(null), onChange: (event) => setContact('phone', event.target.value) })] }), showPhoneError && _jsx(Field.Error, { children: labels['details.phone.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('name'), children: labels['details.name.label'] }), _jsx(Input, { id: field('name'), autoComplete: "name", className: inputClass(), value: state.contact.name, onChange: (event) => setContact('name', event.target.value) })] }), state.items.some((_, index) => isKnownChair(state, index)) && (_jsx("ul", { "aria-label": labels['details.known.label'], className: "space-y-1 text-sm", children: state.items.map((item, index) => isKnownChair(state, index) ? (_jsxs("li", { children: [_jsx("span", { className: "font-semibold", children: item.service.name }), ' ', fillLabel(labels['details.known.for'], { who: knownWho(state, index, labels) })] }, index)) : null) })), state.items.map((item, index) =>
290
+ return (_jsxs("section", { "aria-labelledby": "booking-details-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-details-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['details.heading'] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('phone'), children: labels['details.phone.label'] }), _jsxs("div", { className: "flex items-stretch", children: [phonePrefix && (_jsx("span", { id: field('phone-prefix'), className: "flex h-12 items-center rounded-l-[4px] border border-r-0 border-input bg-muted px-4 text-base text-muted-foreground", children: phonePrefix })), _jsx(Input, { id: field('phone'), ref: phoneRef, type: "tel", inputMode: "numeric", autoComplete: "tel-national", className: inputClass(phonePrefix ? 'rounded-l-none' : ''), value: state.contact.phone, "aria-describedby": phonePrefix ? field('phone-prefix') : undefined, "aria-invalid": showPhoneError || undefined, onFocus: () => setFocused('phone'), onBlur: () => setFocused(null), onChange: (event) => setContact('phone', event.target.value) })] }), showPhoneError && _jsx(Field.Error, { children: labels['details.phone.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('name'), children: labels['details.name.label'] }), _jsx(Input, { id: field('name'), autoComplete: "name", className: inputClass(), value: state.contact.name, onChange: (event) => setContact('name', event.target.value) })] }), state.items.some((_, index) => isKnownChair(state, index)) && (_jsx("ul", { "aria-label": labelText(labels['details.known.label']), className: "space-y-1 text-sm", children: state.items.map((item, index) => isKnownChair(state, index) ? (_jsxs("li", { children: [_jsx("span", { className: "font-semibold", children: item.service.name }), ' ', renderLabel(labels['details.known.for'], { who: knownWho(state, index, labels) })] }, index)) : null) })), state.items.map((item, index) =>
289
291
  // A seat step 1 already named is not asked again: a second answer could disagree.
290
292
  isKnownChair(state, index) ? null : (_jsx(ChildFields
291
293
  // biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
292
- , { legend: state.items.length > 1 ? item.service.name : null, nameId: field(`child-name-${index}`), yearId: field(`child-year-${index}`), value: childAt(index), family: family, labels: labels, classNames: classNames, FamilyChip: FamilyChip, onChange: (patch, personId) => setChild(index, patch, personId) }, index))), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('email'), children: labels['details.email.label'] }), _jsx(Input, { id: field('email'), ref: emailRef, type: "email", autoComplete: "email", className: inputClass(), value: state.contact.email, "aria-describedby": field('email-help'), "aria-invalid": showEmailError || undefined, onFocus: () => setFocused('email'), onBlur: () => setFocused(null), onChange: (event) => setContact('email', event.target.value) }), _jsx(Field.Description, { id: field('email-help'), children: labels['details.email.help'] }), showEmailError && _jsx(Field.Error, { children: labels['details.email.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('notes'), children: labels['details.notes.label'] }), _jsx(Textarea, { id: field('notes'), rows: 3, className: slotClass(classNames, 'input', BOOKING_TEXTAREA_CLASS), placeholder: labels['details.notes.placeholder'], value: state.notes, onChange: (event) => onChange({ type: 'setNotes', value: event.target.value }) })] }), _jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('terms'), ref: termsRef, className: BOOKING_CHECKBOX_CLASS, checked: state.consentTerms, "aria-invalid": showTermsError || undefined, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'terms', accepted }) }), _jsx(Field.Label, { htmlFor: field('terms'), className: "cursor-pointer font-normal", children: labels['details.terms.text'] }), termsHref && (_jsx("a", { href: termsHref, className: "col-start-2 text-sm font-semibold text-primary underline underline-offset-4", children: labels['details.terms.link'] }))] }), marketingConsent && (_jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('marketing'), className: BOOKING_CHECKBOX_CLASS, checked: state.consentMarketing, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'marketing', accepted }) }), _jsx(Field.Label, { htmlFor: field('marketing'), className: "cursor-pointer font-normal", children: labels['details.marketing.text'] })] })), state.error && (_jsx(SubmitFailure, { error: state.error, phone: phone, format: format, labels: labels, className: slotClass(classNames, 'error', 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm') })), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: submitting, "aria-busy": submitting || undefined, onClick: handleSubmit, children: fillLabel(labels['details.submit'], { price }) })] }));
294
+ , { legend: state.items.length > 1 ? item.service.name : null, nameId: field(`child-name-${index}`), yearId: field(`child-year-${index}`), value: childAt(index), family: family, labels: labels, classNames: classNames, FamilyChip: FamilyChip, onChange: (patch, personId) => setChild(index, patch, personId) }, index))), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('email'), children: labels['details.email.label'] }), _jsx(Input, { id: field('email'), ref: emailRef, type: "email", autoComplete: "email", className: inputClass(), value: state.contact.email, "aria-describedby": field('email-help'), "aria-invalid": showEmailError || undefined, onFocus: () => setFocused('email'), onBlur: () => setFocused(null), onChange: (event) => setContact('email', event.target.value) }), _jsx(Field.Description, { id: field('email-help'), children: labels['details.email.help'] }), showEmailError && _jsx(Field.Error, { children: labels['details.email.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('notes'), children: labels['details.notes.label'] }), _jsx(Textarea, { id: field('notes'), rows: 3, className: slotClass(classNames, 'input', BOOKING_TEXTAREA_CLASS), placeholder: labelText(labels['details.notes.placeholder']), value: state.notes, onChange: (event) => onChange({ type: 'setNotes', value: event.target.value }) })] }), _jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('terms'), ref: termsRef, className: BOOKING_CHECKBOX_CLASS, checked: state.consentTerms, "aria-invalid": showTermsError || undefined, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'terms', accepted }) }), _jsx(Field.Label, { htmlFor: field('terms'), className: "cursor-pointer font-normal", children: labels['details.terms.text'] }), termsHref && (_jsx("a", { href: termsHref, className: "col-start-2 text-sm font-semibold text-primary underline underline-offset-4", children: labels['details.terms.link'] }))] }), marketingConsent && (_jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('marketing'), className: BOOKING_CHECKBOX_CLASS, checked: state.consentMarketing, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'marketing', accepted }) }), _jsx(Field.Label, { htmlFor: field('marketing'), className: "cursor-pointer font-normal", children: labels['details.marketing.text'] })] })), state.error && (_jsx(SubmitFailure, { error: state.error, phone: phone, format: format, labels: labels, className: slotClass(classNames, 'error', 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm') })), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: submitting, "aria-busy": submitting || undefined, onClick: handleSubmit, children: renderLabel(labels['details.submit'], { price }) })] }));
293
295
  }
294
296
  /**
295
297
  * The name and birth year for one line. Wrapped in a fieldset only when there
@@ -297,7 +299,7 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
297
299
  * them is how one child's name lands on the other's booking.
298
300
  */
299
301
  function ChildFields({ legend, nameId, yearId, value, family, labels, classNames, FamilyChip, onChange, }) {
300
- const fields = (_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: nameId, children: labels['details.child.name'] }), _jsx(Input, { id: nameId, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), value: value.name, onChange: (event) => onChange({ name: event.target.value }) })] }), _jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: yearId, children: labels['details.child.year'] }), _jsx(Input, { id: yearId, inputMode: "numeric", maxLength: 4, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), placeholder: labels['details.child.yearPlaceholder'], value: value.birthYear, onChange: (event) => onChange({ birthYear: event.target.value }) })] })] }));
302
+ const fields = (_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: nameId, children: labels['details.child.name'] }), _jsx(Input, { id: nameId, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), value: value.name, onChange: (event) => onChange({ name: event.target.value }) })] }), _jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: yearId, children: labels['details.child.year'] }), _jsx(Input, { id: yearId, inputMode: "numeric", maxLength: 4, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), placeholder: labelText(labels['details.child.yearPlaceholder']), value: value.birthYear, onChange: (event) => onChange({ birthYear: event.target.value }) })] })] }));
301
303
  const withSuggestions = (_jsxs("div", { className: "space-y-3", children: [family !== undefined && family.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: family.map((member) => (_jsx(FamilyChip, { member: member, pressed: value.name === member.name && value.birthYear === String(member.birthYear), onPick: () => onChange({ name: member.name, birthYear: String(member.birthYear) }, member.personId) }, `${member.name}-${member.birthYear}`))) })), fields] }));
302
304
  if (legend === null)
303
305
  return withSuggestions;
@@ -309,6 +311,7 @@ function ChildFields({ legend, nameId, yearId, value, family, labels, classNames
309
311
  * visitor cannot fix by trying harder.
310
312
  */
311
313
  function SubmitFailure({ error, phone, format, labels, className, }) {
312
- const call = phone ? fillLabel(labels['details.call.link'], { phone }) : null;
313
- return (_jsxs("p", { role: "alert", className: className, children: [labels[DETAILS_ERROR_LABEL_KEYS[error]], ' ', phone && call ? (_jsx("a", { href: format.telHref(phone), "aria-label": call, className: "font-semibold text-primary underline underline-offset-4", children: call })) : (labels['details.call.none'])] }));
314
+ const template = labels['details.call.link'];
315
+ const call = phone ? fillLabel(template, { phone }) : null;
316
+ return (_jsxs("p", { role: "alert", className: className, children: [labels[DETAILS_ERROR_LABEL_KEYS[error]], ' ', phone && call ? (_jsx("a", { href: format.telHref(phone), "aria-label": call, className: "font-semibold text-primary underline underline-offset-4", children: renderLabel(template, { phone }) })) : (labels['details.call.none'])] }));
314
317
  }
@@ -3,9 +3,10 @@ export * from './booking-skeleton.js';
3
3
  export * from './confirmation.js';
4
4
  export * from './details-screen.js';
5
5
  export type { BookingClock, BookingDaypart, BookingFormat } from './format.js';
6
+ export { type JoinLabelPartsOptions, joinLabelParts, labelParts, renderLabel, } from './internal/label-parts.js';
6
7
  export * from './internal/ui.js';
7
8
  export * from './label-keys.js';
8
- export { fillLabel } from './labels.js';
9
+ export { type BookingLabel, fillLabel, fillLabelPieces, labelText } from './labels.js';
9
10
  export * from './live-status.js';
10
11
  export * from './login-panel.js';
11
12
  export * from './login-sheet.js';
@@ -10,9 +10,10 @@ export * from './add-child-sheet.js';
10
10
  export * from './booking-skeleton.js';
11
11
  export * from './confirmation.js';
12
12
  export * from './details-screen.js';
13
+ export { joinLabelParts, labelParts, renderLabel, } from './internal/label-parts.js';
13
14
  export * from './internal/ui.js';
14
15
  export * from './label-keys.js';
15
- export { fillLabel } from './labels.js';
16
+ export { fillLabel, fillLabelPieces, labelText } from './labels.js';
16
17
  export * from './live-status.js';
17
18
  export * from './login-panel.js';
18
19
  export * from './login-sheet.js';
@@ -1,8 +1,43 @@
1
1
  import { type ReactNode } from 'react';
2
+ import { type BookingLabel } from '../labels.js';
2
3
  /**
3
- * `fillLabel` for templates whose placeholders are elements (a link, a
4
+ * A filled label as element children (see `BookingLabel`): a string renders
5
+ * as one text node, an array as one text node per element. Prefer it over
6
+ * `fillLabel` wherever a label is rendered as children, so the pack decides
7
+ * where the sentence breaks.
8
+ *
9
+ * Returns the string itself for a one-node label, an array of strings for a
10
+ * split one, and `null` for a label that fills to nothing at all. Text-only
11
+ * targets (`aria-label`, `title`, a file name) keep `fillLabel`.
12
+ */
13
+ export declare function renderLabel(template: BookingLabel, values?: Readonly<Record<string, string | number>>): string | string[] | null;
14
+ /**
15
+ * `renderLabel` for templates whose placeholders include elements (a link, a
4
16
  * button) rather than strings: `'Have an account? {trigger} – …'` with
5
- * `{ trigger: <button>…</button> }`. Text parts stay text; unknown
6
- * placeholders are left as written.
17
+ * `{ trigger: <button>…</button> }`. Each element sits in its own place; the
18
+ * text around it follows the label's form — a string's text runs stay whole
19
+ * between elements, an array's elements stay separate. String and number
20
+ * values join the text they sit in. Unknown placeholders are left as written.
21
+ */
22
+ export declare function labelParts(template: BookingLabel, parts: Readonly<Record<string, ReactNode>>): ReactNode;
23
+ export interface JoinLabelPartsOptions {
24
+ /**
25
+ * `false`: the line is ONE text node (every part is flattened to text and
26
+ * joined). `true`: every part and every separator is its own text node, and
27
+ * a split part (an array, from a `string[]` label) keeps its pieces.
28
+ *
29
+ * Default: one text node when every part is plain text, pieces as soon as
30
+ * one part is split (a pack wrote one of the line's labels as an array) or
31
+ * is an element. A part that cannot be flattened (an element) always
32
+ * renders in pieces.
33
+ */
34
+ pieces?: boolean;
35
+ }
36
+ /**
37
+ * `parts.filter(Boolean).join(separator)` as element children. Blank parts
38
+ * drop out, so an absent one closes the gap rather than leaving a doubled
39
+ * separator. Whether the line is one text node or one per part and separator
40
+ * follows the parts' own form (see `JoinLabelPartsOptions.pieces`): plain
41
+ * strings join into one, as `[a, b].join(' · ')` in JSX did.
7
42
  */
8
- export declare function labelParts(template: string, parts: Readonly<Record<string, ReactNode>>): ReactNode;
43
+ export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string, options?: JoinLabelPartsOptions): ReactNode;
@@ -1,19 +1,85 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment } from 'react';
3
+ import { fillLabelPieces } from '../labels.js';
3
4
  /**
4
- * `fillLabel` for templates whose placeholders are elements (a link, a
5
+ * A filled label as element children (see `BookingLabel`): a string renders
6
+ * as one text node, an array as one text node per element. Prefer it over
7
+ * `fillLabel` wherever a label is rendered as children, so the pack decides
8
+ * where the sentence breaks.
9
+ *
10
+ * Returns the string itself for a one-node label, an array of strings for a
11
+ * split one, and `null` for a label that fills to nothing at all. Text-only
12
+ * targets (`aria-label`, `title`, a file name) keep `fillLabel`.
13
+ */
14
+ export function renderLabel(template, values = {}) {
15
+ const pieces = fillLabelPieces(template, values);
16
+ if (pieces.length === 0)
17
+ return null;
18
+ return typeof template === 'string' ? pieces[0] : pieces;
19
+ }
20
+ /**
21
+ * `renderLabel` for templates whose placeholders include elements (a link, a
5
22
  * button) rather than strings: `'Have an account? {trigger} – …'` with
6
- * `{ trigger: <button>…</button> }`. Text parts stay text; unknown
7
- * placeholders are left as written.
23
+ * `{ trigger: <button>…</button> }`. Each element sits in its own place; the
24
+ * text around it follows the label's form — a string's text runs stay whole
25
+ * between elements, an array's elements stay separate. String and number
26
+ * values join the text they sit in. Unknown placeholders are left as written.
8
27
  */
9
28
  export function labelParts(template, parts) {
10
- const pieces = template.split(/(\{\w+\})/g);
11
- return pieces.map((piece, index) => {
12
- const match = /^\{(\w+)\}$/.exec(piece);
13
- if (match && Object.hasOwn(parts, match[1])) {
14
- // biome-ignore lint/suspicious/noArrayIndexKey: the pieces of a fixed template never reorder
15
- return _jsx(Fragment, { children: parts[match[1]] }, index);
29
+ const pieces = typeof template === 'string' ? [template] : template;
30
+ const nodes = [];
31
+ for (const piece of pieces) {
32
+ let run = '';
33
+ for (const token of piece.split(/(\{\w+\})/g)) {
34
+ const name = /^\{(\w+)\}$/.exec(token)?.[1];
35
+ const value = name !== undefined && Object.hasOwn(parts, name) ? parts[name] : token;
36
+ if (typeof value === 'string' || typeof value === 'number') {
37
+ run += String(value);
38
+ continue;
39
+ }
40
+ if (run !== '')
41
+ nodes.push(run);
42
+ run = '';
43
+ nodes.push(_jsx(Fragment, { children: value }, nodes.length));
44
+ }
45
+ if (run !== '')
46
+ nodes.push(run);
47
+ }
48
+ return nodes;
49
+ }
50
+ /** A part as plain text, or `null` when it holds an element. */
51
+ function partText(part) {
52
+ if (typeof part === 'string' || typeof part === 'number')
53
+ return String(part);
54
+ if (Array.isArray(part)) {
55
+ let text = '';
56
+ for (const piece of part) {
57
+ const pieceText = partText(piece);
58
+ if (pieceText === null)
59
+ return null;
60
+ text += pieceText;
61
+ }
62
+ return text;
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * `parts.filter(Boolean).join(separator)` as element children. Blank parts
68
+ * drop out, so an absent one closes the gap rather than leaving a doubled
69
+ * separator. Whether the line is one text node or one per part and separator
70
+ * follows the parts' own form (see `JoinLabelPartsOptions.pieces`): plain
71
+ * strings join into one, as `[a, b].join(' · ')` in JSX did.
72
+ */
73
+ export function joinLabelParts(parts, separator, options = {}) {
74
+ // Blank = renders no text: nothing at all, or text pieces that are all empty.
75
+ const present = parts.filter((part) => part !== null && part !== undefined && typeof part !== 'boolean' && partText(part) !== '');
76
+ const pieces = options.pieces ?? present.some((part) => typeof part !== 'string' && typeof part !== 'number');
77
+ if (!pieces) {
78
+ const texts = present.map(partText);
79
+ if (texts.every((text) => text !== null)) {
80
+ const line = texts.join(separator);
81
+ return line === '' ? null : line;
16
82
  }
17
- return piece;
18
- });
83
+ }
84
+ return present.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && separator, part] }, index)));
19
85
  }
@@ -2,6 +2,7 @@ import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
2
2
  import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
3
3
  import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
4
4
  import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
5
+ import type { BookingLabel } from './labels.js';
5
6
  import { LOGIN_SHEET_LABEL_KEYS } from './login-sheet.js';
6
7
  import { MANAGE_SCREEN_LABEL_KEYS } from './manage-screen.js';
7
8
  import { CHILD_CARDS_LABEL_KEYS } from './portal/child-cards.js';
@@ -31,4 +32,4 @@ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
31
32
  export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "upcoming.heading" | "upcoming.empty" | "upcoming.book" | "upcoming.next" | "upcoming.today" | "upcoming.tomorrow" | "upcoming.inDays" | "upcoming.when" | "upcoming.serviceFallback" | "upcoming.anyStylist" | "upcoming.manage" | "upcoming.noManage" | "upcoming.noManageWithPhone" | "upcoming.call" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.heading" | "service.categoriesLegend" | "service.sameAsLast" | "service.chooseOther" | "service.duration" | "service.phoneOnly" | "service.phoneOnlyLinkLabel" | "service.ageDivider.named" | "service.ageDivider.unnamed" | "service.party.listLabel" | "service.party.adultAlone" | "service.party.nothingFits" | "service.party.remove" | "stylist.heading" | "stylist.firstAvailable" | "stylist.firstAvailableSubtitle" | "stylist.firstAvailableBadge" | "stylist.pendingName" | "stylist.nextAvailable" | "stylist.loading" | "stylist.party.legend" | "stylist.party.sequential" | "stylist.party.sequentialMinutes" | "stylist.party.parallel.two" | "stylist.party.parallel.three" | "stylist.party.parallel.other" | "stylist.party.parallelMinutes" | "stylist.party.parallelNote.two" | "stylist.party.parallelNote.three" | "stylist.party.parallelNote.other" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit")[];
32
33
  export type BookingLabelKey = (typeof WHO_SCREEN_LABEL_KEYS)[number] | (typeof ADD_CHILD_SHEET_LABEL_KEYS)[number] | (typeof SERVICE_SCREEN_LABEL_KEYS)[number] | (typeof STYLIST_SCREEN_LABEL_KEYS)[number] | (typeof TIME_SCREEN_LABEL_KEYS)[number] | (typeof MANAGE_SCREEN_LABEL_KEYS)[number] | (typeof DETAILS_SCREEN_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_LABEL_KEYS)[number] | (typeof CONFIRMATION_LABEL_KEYS)[number] | (typeof BOOKING_SKELETON_LABEL_KEYS)[number] | (typeof LOGIN_SHEET_LABEL_KEYS)[number] | (typeof PORTAL_SHELL_LABEL_KEYS)[number] | (typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number] | (typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number] | (typeof VISIT_HISTORY_LABEL_KEYS)[number] | (typeof REBOOK_CARDS_LABEL_KEYS)[number] | (typeof CHILD_CARDS_LABEL_KEYS)[number] | (typeof FAMILY_EDITOR_LABEL_KEYS)[number] | (typeof PROFILE_FORM_LABEL_KEYS)[number] | (typeof DATA_CONTROLS_LABEL_KEYS)[number] | (typeof LOGOUT_BUTTON_LABEL_KEYS)[number] | (typeof VIPPS_LINK_ROW_LABEL_KEYS)[number];
33
34
  /** A complete label pack for one locale. */
34
- export type BookingLabels = Record<BookingLabelKey, string>;
35
+ export type BookingLabels = Record<BookingLabelKey, BookingLabel>;
@@ -3,13 +3,42 @@
3
3
  *
4
4
  * Every word a booking screen shows arrives through its `labels` prop — meda
5
5
  * ships no default brand and no default language. A screen's labels are a flat
6
- * record of strings keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one
7
- * merged pack (`BookingLabels`) can be handed to every screen, and a customer
8
- * can override a single sentence without touching the rest.
6
+ * record keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one merged pack
7
+ * (`BookingLabels`) can be handed to every screen, and a customer can override
8
+ * a single sentence without touching the rest.
9
9
  *
10
- * Values are plain strings, so a pack is serialisable (it can travel from a
11
- * server component to the client as a prop). Variable parts are `{name}`
12
- * placeholders, filled by `fillLabel`.
10
+ * A label is a `BookingLabel`: a string, or an array of strings. Both are
11
+ * serialisable, so a pack can travel from a server component to the client as
12
+ * a prop. Variable parts are `{name}` placeholders.
13
+ *
14
+ * The two forms differ only in the DOM they render as element children:
15
+ *
16
+ * - a **string** renders as ONE text node once filled, like a template
17
+ * literal: `'Total {total} · paid at the salon'` → «Total 840 kr · paid at
18
+ * the salon», one node;
19
+ * - an **array** renders one text node per element, each element filled with
20
+ * the same values: `['Total ', '{total}', ' · ', 'paid at the salon']` →
21
+ * four nodes, the same DOM as `Total {total}{' · '}paid at the salon` in JSX.
22
+ * An element that fills to `''` renders nothing.
23
+ *
24
+ * A browser lays text out per text node, so the same sentence split
25
+ * differently can land its later glyphs a sub-pixel apart. The pack decides
26
+ * where a sentence breaks; a screen never splits a string on its own.
27
+ *
28
+ * Text-only targets (`aria-label`, `title`, a file name, an error string) read
29
+ * a label through `fillLabel` / `labelText`, which join an array's elements.
30
+ */
31
+ /** One label: a string (one text node) or one string per text node. */
32
+ export type BookingLabel = string | readonly string[];
33
+ /** A label as plain text: an array's elements joined, a string as it is. */
34
+ export declare function labelText(label: BookingLabel): string;
35
+ /**
36
+ * Replaces `{key}` placeholders in `template`, as plain text. An array is
37
+ * filled element by element and joined. Unknown keys are left as written.
38
+ */
39
+ export declare function fillLabel(template: BookingLabel, values: Readonly<Record<string, string | number>>): string;
40
+ /**
41
+ * The text nodes a label fills to: one for a string, one per element for an
42
+ * array, with empty ones dropped. `[]` when the label fills to nothing.
13
43
  */
14
- /** Replaces `{key}` placeholders in `template`. Unknown keys are left as written. */
15
- export declare function fillLabel(template: string, values: Readonly<Record<string, string | number>>): string;
44
+ export declare function fillLabelPieces(template: BookingLabel, values: Readonly<Record<string, string | number>>): string[];