@medalsocial/meda 3.3.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 (56) 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 +2 -2
  7. package/dist/booking/details-screen.d.ts +2 -1
  8. package/dist/booking/details-screen.js +9 -8
  9. package/dist/booking/index.d.ts +2 -2
  10. package/dist/booking/index.js +2 -2
  11. package/dist/booking/internal/label-parts.d.ts +36 -21
  12. package/dist/booking/internal/label-parts.js +71 -43
  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 +3 -2
  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 +16 -11
  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/child-cards.d.ts +2 -1
  26. package/dist/booking/portal/data-controls.d.ts +2 -1
  27. package/dist/booking/portal/data-controls.js +3 -3
  28. package/dist/booking/portal/family-editor.d.ts +2 -1
  29. package/dist/booking/portal/family-editor.js +9 -9
  30. package/dist/booking/portal/logout-button.d.ts +2 -1
  31. package/dist/booking/portal/logout-button.js +2 -1
  32. package/dist/booking/portal/portal-shell.d.ts +2 -1
  33. package/dist/booking/portal/portal-shell.js +3 -2
  34. package/dist/booking/portal/portal-unreachable.d.ts +2 -1
  35. package/dist/booking/portal/profile-form.d.ts +2 -1
  36. package/dist/booking/portal/profile-form.js +3 -2
  37. package/dist/booking/portal/rebook-cards.d.ts +2 -1
  38. package/dist/booking/portal/rebook-cards.js +3 -2
  39. package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
  40. package/dist/booking/portal/upcoming-bookings.js +3 -3
  41. package/dist/booking/portal/vipps-link-row.d.ts +2 -1
  42. package/dist/booking/portal/vipps-link-row.js +7 -6
  43. package/dist/booking/portal/visit-history.d.ts +2 -1
  44. package/dist/booking/service-screen.d.ts +2 -1
  45. package/dist/booking/service-screen.js +2 -2
  46. package/dist/booking/stylist-screen.d.ts +2 -1
  47. package/dist/booking/stylist-screen.js +7 -6
  48. package/dist/booking/summary-bar.d.ts +2 -1
  49. package/dist/booking/time-screen.d.ts +2 -1
  50. package/dist/booking/time-screen.js +6 -6
  51. package/dist/booking/vipps-button.d.ts +2 -1
  52. package/dist/booking/vipps-button.js +2 -1
  53. package/dist/booking/who-screen.d.ts +2 -1
  54. package/dist/booking/who-screen.js +3 -2
  55. package/package.json +1 -1
  56. 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.
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { joinLabelParts, renderLabel } from './internal/label-parts.js';
4
4
  import { BookingButton } from './internal/ui.js';
5
- import { fillLabel } from './labels.js';
5
+ import { fillLabel, labelText } from './labels.js';
6
6
  import { slotClass } from './slots.js';
7
7
  /**
8
8
  * The confirmation screen — the one the whole flow exists to reach.
@@ -94,7 +94,7 @@ 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));
@@ -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
@@ -7,7 +7,7 @@ import { Input } from '../primitives/input.js';
7
7
  import { Textarea } from '../primitives/textarea.js';
8
8
  import { renderLabel } from './internal/label-parts.js';
9
9
  import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
10
- import { fillLabel } from './labels.js';
10
+ import { fillLabel, labelText } from './labels.js';
11
11
  import { slotClass } from './slots.js';
12
12
  /**
13
13
  * The last step of the booking wizard: contact details, who each seat is for,
@@ -154,10 +154,10 @@ function knownWho(state, index, labels) {
154
154
  const item = state.items[index];
155
155
  if (item?.adult) {
156
156
  return state.people[index]?.key === 'self'
157
- ? labels['details.known.self']
158
- : labels['details.known.adult'];
157
+ ? labelText(labels['details.known.self'])
158
+ : labelText(labels['details.known.adult']);
159
159
  }
160
- return item?.bookedForName ?? labels['details.known.child'];
160
+ return item?.bookedForName ?? labelText(labels['details.known.child']);
161
161
  }
162
162
  /**
163
163
  * Whether step 1 already said who sits in this seat: a saved person's card
@@ -200,7 +200,8 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
200
200
  const showEmailError = !emailValid && (attempted || focused !== 'email');
201
201
  const showTermsError = attempted && !state.consentTerms;
202
202
  const price = format.price(totalOre);
203
- 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']);
204
205
  const FamilyChip = components?.FamilyChip ?? DefaultFamilyChip;
205
206
  function setContact(name, value) {
206
207
  onChange({ type: 'setContact', field: name, value });
@@ -286,11 +287,11 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
286
287
  }
287
288
  const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
288
289
  const fieldClass = slotClass(classNames, 'field');
289
- 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 }), ' ', renderLabel(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) =>
290
291
  // A seat step 1 already named is not asked again: a second answer could disagree.
291
292
  isKnownChair(state, index) ? null : (_jsx(ChildFields
292
293
  // biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
293
- , { 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: renderLabel(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 }) })] }));
294
295
  }
295
296
  /**
296
297
  * The name and birth year for one line. Wrapped in a fieldset only when there
@@ -298,7 +299,7 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
298
299
  * them is how one child's name lands on the other's booking.
299
300
  */
300
301
  function ChildFields({ legend, nameId, yearId, value, family, labels, classNames, FamilyChip, onChange, }) {
301
- 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 }) })] })] }));
302
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] }));
303
304
  if (legend === null)
304
305
  return withSuggestions;
@@ -3,10 +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 { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
6
+ export { type JoinLabelPartsOptions, joinLabelParts, labelParts, renderLabel, } from './internal/label-parts.js';
7
7
  export * from './internal/ui.js';
8
8
  export * from './label-keys.js';
9
- export { fillLabel } from './labels.js';
9
+ export { type BookingLabel, fillLabel, fillLabelPieces, labelText } from './labels.js';
10
10
  export * from './live-status.js';
11
11
  export * from './login-panel.js';
12
12
  export * from './login-sheet.js';
@@ -10,10 +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
+ export { joinLabelParts, labelParts, renderLabel, } from './internal/label-parts.js';
14
14
  export * from './internal/ui.js';
15
15
  export * from './label-keys.js';
16
- export { fillLabel } from './labels.js';
16
+ export { fillLabel, fillLabelPieces, labelText } from './labels.js';
17
17
  export * from './live-status.js';
18
18
  export * from './login-panel.js';
19
19
  export * from './login-sheet.js';
@@ -1,28 +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
- * button) rather than strings: `'Have an account? {trigger} – …'` with
5
- * `{ trigger: <button>…</button> }`. Text parts stay text; unknown
6
- * placeholders are left as written.
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`.
7
12
  */
8
- export declare function labelParts(template: string, parts: Readonly<Record<string, ReactNode>>): ReactNode;
13
+ export declare function renderLabel(template: BookingLabel, values?: Readonly<Record<string, string | number>>): string | string[] | null;
9
14
  /**
10
- * A filled label as element children: the template split around its
11
- * `{placeholders}`, so React renders one text node per literal run and one
12
- * per value — the same DOM as writing the sentence out in JSX
13
- * (`Total {total} · paid at the salon`). Prefer it over `fillLabel` wherever
14
- * a label is rendered as children; a browser lays text out per text node, so
15
- * one joined string can land a line's glyphs a sub-pixel away from the same
16
- * sentence composed in JSX.
17
- *
18
- * Text-only targets (`aria-label`, `title`, a file name) keep `fillLabel`.
19
- * Empty literal runs and empty values render nothing, as in JSX; a label
20
- * that fills to nothing at all is `null`.
15
+ * `renderLabel` for templates whose placeholders include elements (a link, a
16
+ * button) rather than strings: `'Have an account? {trigger} – …'` with
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
21
  */
22
- export declare function renderLabel(template: string, values: Readonly<Record<string, string | number>>): ReactNode;
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
+ }
23
36
  /**
24
- * `parts.filter(Boolean).join(separator)` as element children: every part
25
- * and every separator its own text piece. Blank parts drop out, so an absent
26
- * one closes the gap rather than leaving a doubled separator.
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.
27
42
  */
28
- export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string): ReactNode;
43
+ export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string, options?: JoinLabelPartsOptions): ReactNode;
@@ -1,57 +1,85 @@
1
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
- * button) rather than strings: `'Have an account? {trigger} – …'` with
6
- * `{ trigger: <button>…</button> }`. Text parts stay text; unknown
7
- * placeholders are left as written.
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`.
8
13
  */
9
- 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);
16
- }
17
- return piece;
18
- });
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
19
  }
20
20
  /**
21
- * A filled label as element children: the template split around its
22
- * `{placeholders}`, so React renders one text node per literal run and one
23
- * per value — the same DOM as writing the sentence out in JSX
24
- * (`Total {total} · paid at the salon`). Prefer it over `fillLabel` wherever
25
- * a label is rendered as children; a browser lays text out per text node, so
26
- * one joined string can land a line's glyphs a sub-pixel away from the same
27
- * sentence composed in JSX.
28
- *
29
- * Text-only targets (`aria-label`, `title`, a file name) keep `fillLabel`.
30
- * Empty literal runs and empty values render nothing, as in JSX; a label
31
- * that fills to nothing at all is `null`.
21
+ * `renderLabel` for templates whose placeholders include elements (a link, a
22
+ * button) rather than strings: `'Have an account? {trigger} – …'` with
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.
32
27
  */
33
- export function renderLabel(template, values) {
34
- const pieces = template.split(/(\{\w+\})/g);
28
+ export function labelParts(template, parts) {
29
+ const pieces = typeof template === 'string' ? [template] : template;
35
30
  const nodes = [];
36
31
  for (const piece of pieces) {
37
- const match = /^\{(\w+)\}$/.exec(piece);
38
- const value = match && Object.hasOwn(values, match[1]) ? values[match[1]] : piece;
39
- if (value !== '')
40
- nodes.push(value);
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;
41
63
  }
42
- return nodes.length > 0 ? nodes : null;
64
+ return null;
43
65
  }
44
66
  /**
45
- * `parts.filter(Boolean).join(separator)` as element children: every part
46
- * and every separator its own text piece. Blank parts drop out, so an absent
47
- * one closes the gap rather than leaving a doubled separator.
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.
48
72
  */
49
- export function joinLabelParts(parts, separator) {
50
- return parts
51
- .filter((part) => part !== null &&
52
- part !== undefined &&
53
- part !== false &&
54
- part !== '' &&
55
- !(Array.isArray(part) && part.length === 0))
56
- .map((part, index) => (_jsxs(Fragment, { children: [index > 0 && separator, part] }, index)));
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;
82
+ }
83
+ }
84
+ return present.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && separator, part] }, index)));
57
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[];