@jtakeit/kit 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -54,9 +54,17 @@ Next export `dangerouslySetInnerHTML`; in a script, a template literal.
54
54
 
55
55
  The markup comes wrapped in `<jtk-booking>`, and the element wires the form
56
56
  when it reaches the document — on load, and when the panel places it later.
57
- A form written by hand is wired too, as long as it keeps the `data-*` hooks
58
- and the input `name`s of the rendered one: `wireAll()` walks the document at
59
- load and again on the panel's `fl:placed`.
57
+
58
+ **Or write the markup yourself**, in the site's design. The element finds
59
+ everything by a hook — a `data-*` attribute or an input's `name`, never a
60
+ class — so a form rebuilt in any markup works as long as it keeps them. The
61
+ list, with what each is for and which may be left out, is
62
+ `@jtakeit/kit/booking/hooks` (`ALWAYS`, `ASKED`, `SAID`); `bookingForm(props)`
63
+ gives the facts to write it from — the words, the services in their runs with
64
+ «45 min · CHF 95», the days, and the `<form>`'s attributes to spread on it.
65
+ `@jtakeit/astro`'s `BookingForm.astro` is such a form, written out, and the
66
+ quickest start: copy it and design it. `wireAll()` walks the document at load
67
+ and again on the panel's `fl:placed`, so a bare form is wired too.
60
68
 
61
69
  **By default the form arrives dressed** — two steps, the day as a week of
62
70
  seven that never offers yesterday, the times by the part of the day, a drawn
@@ -67,10 +75,14 @@ switched on by `renderBookingForm`'s `ui`, `false` for the plain form). Pass
67
75
  `open` (the days the business opens) and each resource's `does` for the week
68
76
  and the greying to know.
69
77
 
70
- **The look** is the site's to give. `@jtakeit/kit/booking.css` makes the form
71
- legible and nothing more; `field`, `field__input`, `booking__slot`,
72
- `booking__submit` are names to restyle with the site's own fields and
73
- buttons. Keep the `[hidden]` rule.
78
+ **The look** is the site's to give. `@jtakeit/kit/booking.css` makes
79
+ `renderBookingForm`'s markup legible and nothing more; restyle it with the
80
+ site's own fields and buttons. The rules the behaviour needs — `[hidden]`,
81
+ which step shows, the native control under a drawn one — are put on the page
82
+ by the element, not by the stylesheet. What the element draws itself carries
83
+ `booking__*` classes (`booking__slot`, `booking__day`, `booking__part`, the
84
+ steps, the drawn choice) with its state in ARIA: chosen is
85
+ `aria-pressed="true"`.
74
86
 
75
87
  Under `base` — a site served from a path, which every preview is — pass
76
88
  `api` as your generator writes a root-relative address, so the form posts to
package/booking.css CHANGED
@@ -15,9 +15,9 @@
15
15
  .booking__times { border: 0; padding: 0; margin: 0; }
16
16
  .booking__services { border: 0; padding: 0; margin: 0; display: grid; gap: 0.75rem; }
17
17
  .booking__run { display: grid; gap: 0.35rem; }
18
- .booking__run-title { margin: 0.5rem 0 0.15rem; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; opacity: 0.7; }
18
+ .booking__run-title { margin: 0.5rem 0 0.15rem; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--booking-quiet); }
19
19
  .booking__service { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6rem; align-items: baseline; padding: 0.45rem 0; border-block-end: 1px solid var(--line, #e5e1d8); cursor: pointer; }
20
- .booking__service-meta { font-size: 0.85em; opacity: 0.7; white-space: nowrap; }
20
+ .booking__service-meta { font-size: 0.85em; color: var(--booking-quiet); white-space: nowrap; }
21
21
  .booking__total { margin: 0.25rem 0 0; font-weight: 600; }
22
22
  .booking__slots { display: flex; flex-wrap: wrap; gap: 0.4rem; }
23
23
  .booking__slot {
@@ -25,7 +25,7 @@
25
25
  border: 1px solid var(--color-line, currentColor); background: transparent; color: inherit; cursor: pointer;
26
26
  }
27
27
  .booking__slot.is-chosen { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
28
- .booking__hint, .booking__note { margin: 0; opacity: 0.75; }
28
+ .booking__hint, .booking__note { margin: 0; color: var(--booking-quiet); }
29
29
  .booking__links { display: flex; flex-wrap: wrap; gap: 0.75rem 1.25rem; margin: 0; }
30
30
  .booking__done-line { font-weight: 600; margin: 0; }
31
31
  .booking__done-when { margin: 0; }
@@ -35,7 +35,7 @@
35
35
  .booking > * { min-width: 0; }
36
36
  .booking__ends { margin: 0.35rem 0 0; }
37
37
  .booking__service.is-off { opacity: 0.5; cursor: default; }
38
- .booking__service-off { font-size: 0.8em; opacity: 0.75; }
38
+ .booking__service-off { font-size: 0.8em; color: var(--booking-quiet); }
39
39
  /* the native control stays for the form; the drawn one is what is seen */
40
40
  .booking .is-replaced { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); opacity: 0; pointer-events: none; }
41
41
  /* the week of days */
@@ -45,7 +45,7 @@
45
45
  font: inherit; color: inherit; background: transparent; border: 1px solid var(--color-line, currentColor); border-radius: var(--radius-sm, 0.5rem); cursor: pointer;
46
46
  }
47
47
  .booking__day { display: grid; gap: 0.1rem; padding: 0.4rem 0.1rem; text-align: center; line-height: 1.2; }
48
- .booking__day-wd { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
48
+ .booking__day-wd { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; color: var(--booking-quiet); }
49
49
  .booking__day:disabled, .booking__days-arrow:disabled { opacity: 0.35; cursor: default; }
50
50
  .booking__day.is-chosen { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
51
51
  .booking__days-arrow { padding: 0.3rem 0.6rem; }
@@ -53,21 +53,21 @@
53
53
  .booking__parts { display: flex; flex-wrap: wrap; gap: 0.25rem; margin: 0 0 0.5rem; }
54
54
  .booking__part { padding: 0.3rem 0.7rem; border-radius: 999px; }
55
55
  .booking__part.is-current { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
56
- .booking__part-n { font-size: 0.8em; opacity: 0.75; margin-left: 0.35rem; }
56
+ .booking__part-n { font-size: 0.8em; color: var(--booking-quiet); margin-left: 0.35rem; }
57
57
  /* the two steps */
58
- .booking__stepper { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0; padding: 0; list-style: none; opacity: 0.75; }
58
+ .booking__stepper { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0; padding: 0; list-style: none; color: var(--booking-quiet); }
59
59
  .booking__step { display: inline-flex; align-items: center; gap: 0.4rem; }
60
60
  .booking__step-n { display: inline-grid; place-items: center; width: 1.4rem; height: 1.4rem; border: 1px solid currentColor; border-radius: 999px; font-size: 0.8em; }
61
- .booking__step.is-current { opacity: 1; font-weight: 600; }
61
+ .booking__step.is-current { color: inherit; color: var(--ink, currentColor); font-weight: 600; }
62
62
  .booking__step.is-current .booking__step-n, .booking__step.is-done .booking__step-n { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
63
63
  .booking[data-stage='1'] > [data-step='2'], .booking[data-stage='2'] > [data-step='1'] { display: none; }
64
64
  .booking[data-stage='done'] > .booking__stepper, .booking[data-stage='done'] > .booking__summary, .booking[data-stage='done'] > .booking__need, .booking[data-stage='done'] > .booking__next { display: none; }
65
65
  .booking__next { padding: 0.6rem 1.2rem; }
66
66
  .booking__need { margin: 0; color: var(--color-danger, #b3261e); }
67
67
  .booking__summary { display: grid; gap: 0.6rem; padding: 0.9rem 1rem; border: 1px solid var(--line, #e5e1d8); border-radius: var(--radius-sm, 0.5rem); }
68
- .booking__summary-label { margin: 0; font-size: 0.75em; letter-spacing: 0.1em; text-transform: uppercase; opacity: 0.7; }
68
+ .booking__summary-label { margin: 0; font-size: 0.75em; letter-spacing: 0.1em; text-transform: uppercase; color: var(--booking-quiet); }
69
69
  .booking__summary-list { display: grid; gap: 0.4rem; margin: 0; }
70
- .booking__summary-row dt { font-size: 0.8em; opacity: 0.7; }
70
+ .booking__summary-row dt { font-size: 0.8em; color: var(--booking-quiet); }
71
71
  .booking__summary-row dd { margin: 0; }
72
72
  .booking__back { justify-self: start; padding: 0.3rem 0.8rem; border-radius: 999px; }
73
73
  /* the drawn choice of whom */
@@ -84,6 +84,19 @@
84
84
  .booking__keep-link { flex: 1; min-width: 12rem; font: inherit; }
85
85
  .booking__keep-copy, .booking__keep-open { display: inline-flex; align-items: center; padding: 0.45rem 0.8rem; text-decoration: none; }
86
86
 
87
+ /* Words for a screen reader alone — the month a day button is in, «copied»
88
+ — and a focus ring for a site that has none of its own: the lowest
89
+ specificity there is, so any ring the site draws wins. */
90
+ .booking__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
91
+ :where(.booking :focus-visible) { outline: 2px solid currentColor; outline-offset: 2px; }
92
+
93
+ /* The quiet text of the form — a duration, a hint, a weekday — in its own
94
+ colour, never by opacity: opacity on a colour already quiet read 2.9:1 on
95
+ white and 1.9:1 on a dark panel (WCAG 1.4.3, 12 October 2026). The site's
96
+ quiet ink where it has one, otherwise the text's own colour. A disabled
97
+ control keeps its opacity: WCAG asks no contrast of it. */
98
+ .booking { --booking-quiet: var(--ink-quiet, currentColor); }
99
+
87
100
  /* `hidden` means hidden, whatever the element's class says: every state here
88
101
  is shown and hidden by the attribute, and a class that sets `display` would
89
102
  otherwise win (an agent's rehearsal, 30 September 2026). */
@@ -64,6 +64,9 @@ export function weekStrip(input, options) {
64
64
  const max = parseDay(input.max) ?? new Date(floor.getFullYear(), floor.getMonth() + 2, floor.getDate());
65
65
  const monthName = new Intl.DateTimeFormat(options.lang, { month: 'long', year: 'numeric' });
66
66
  const weekday = new Intl.DateTimeFormat(options.lang, { weekday: 'short' });
67
+ // The month, for a screen reader: «Mon 12» read alone is a day of no
68
+ // month, and a week that runs from one month into the next has two.
69
+ const month = new Intl.DateTimeFormat(options.lang, { month: 'long' });
67
70
  const plus = (d, n) => { const out = new Date(d); out.setDate(out.getDate() + n); return out; };
68
71
  const choosable = (d) => d >= floor && d <= max && open.has(DAY_NAMES[(d.getDay() + 6) % 7]);
69
72
 
@@ -72,6 +75,7 @@ export function weekStrip(input, options) {
72
75
  root.setAttribute('role', 'group');
73
76
  root.setAttribute('aria-label', input.labels?.[0]?.textContent?.trim() || options.words.day || 'Day');
74
77
  input.classList.add('is-replaced');
78
+ input.dataset.replaced = '';
75
79
  input.setAttribute('aria-hidden', 'true');
76
80
  input.tabIndex = -1;
77
81
  input.insertAdjacentElement('afterend', root);
@@ -96,10 +100,19 @@ export function weekStrip(input, options) {
96
100
  ${week.map((d) => {
97
101
  const value = ymd(d);
98
102
  const ok = choosable(d);
99
- return `<button type="button" class="booking__day${value === chosen ? ' is-chosen' : ''}" data-day="${value}"${ok ? '' : ' disabled'} aria-pressed="${value === chosen}"><span class="booking__day-wd">${weekday.format(d)}</span><span class="booking__day-n">${d.getDate()}</span></button>`;
103
+ return `<button type="button" class="booking__day${value === chosen ? ' is-chosen' : ''}" data-day="${value}"${ok ? '' : ' disabled'} aria-pressed="${value === chosen}"><span class="booking__day-wd">${weekday.format(d)}</span> <span class="booking__day-n">${d.getDate()}</span><span class="booking__sr"> ${month.format(d)}</span></button>`;
100
104
  }).join('')}
101
105
  </div>`;
102
106
  }
107
+ // A redraw replaces the buttons, and the one that had the focus with them:
108
+ // a keyboard was left on the page's top after every press (WCAG 2.4.3,
109
+ // 12 October 2026). The same button, or the arrow beside a disabled one,
110
+ // takes it back.
111
+ const refocus = (selector) => {
112
+ const again = /** @type {HTMLButtonElement | null} */ (root.querySelector(selector));
113
+ const to = again && !again.disabled ? again : /** @type {HTMLButtonElement | null} */ (root.querySelector('[data-go]:not(:disabled), [data-day]:not(:disabled)'));
114
+ to?.focus();
115
+ };
103
116
  root.addEventListener('click', (event) => {
104
117
  const target = event.target instanceof Element ? event.target : null;
105
118
  const arrow = target?.closest('[data-go]');
@@ -107,16 +120,28 @@ export function weekStrip(input, options) {
107
120
  start = plus(start, Number(arrow.dataset.go));
108
121
  if (start < floor) start = new Date(floor);
109
122
  draw();
123
+ refocus(`[data-go="${arrow.dataset.go}"]`);
110
124
  return;
111
125
  }
112
126
  const dayButton = target?.closest('[data-day]');
113
127
  if (dayButton instanceof HTMLButtonElement && !dayButton.disabled) {
114
- input.value = dayButton.dataset.day ?? '';
128
+ const value = dayButton.dataset.day ?? '';
129
+ input.value = value;
115
130
  input.dispatchEvent(new Event('change', { bubbles: true }));
116
131
  draw();
132
+ refocus(`[data-day="${value}"]`);
117
133
  }
118
134
  });
119
- input.addEventListener('change', draw);
135
+ input.addEventListener('change', () => {
136
+ const had = root.contains(document.activeElement) ? /** @type {HTMLElement} */ (document.activeElement).dataset : null;
137
+ draw();
138
+ if (had?.day) refocus(`[data-day="${had.day}"]`);
139
+ });
140
+ // The label still names the hidden input; a press on it goes to the days.
141
+ input.labels?.[0]?.addEventListener('click', (event) => {
142
+ event.preventDefault();
143
+ refocus('[data-day][aria-pressed="true"]');
144
+ });
120
145
  draw();
121
146
  }
122
147
 
@@ -134,7 +159,10 @@ export function partsOfDay(form, words) {
134
159
  slots.dataset.parts = '1';
135
160
  const tabs = document.createElement('div');
136
161
  tabs.className = 'booking__parts';
137
- tabs.setAttribute('role', 'tablist');
162
+ // Pressed buttons in a group, not tabs: a tablist promises arrow keys and
163
+ // panels, and this has neither — the times under it are the fieldset's.
164
+ tabs.setAttribute('role', 'group');
165
+ if (words.time) tabs.setAttribute('aria-label', words.time);
138
166
  tabs.hidden = true;
139
167
  slots.insertAdjacentElement('beforebegin', tabs);
140
168
  const timeOf = (pill) => to24((pill.textContent ?? '').trim()).slice(0, 5);
@@ -144,26 +172,26 @@ export function partsOfDay(form, words) {
144
172
  let current = '';
145
173
 
146
174
  const show = () => {
147
- for (const pill of /** @type {NodeListOf<HTMLElement>} */ (slots.querySelectorAll('.booking__slot'))) {
175
+ for (const pill of /** @type {NodeListOf<HTMLElement>} */ (slots.querySelectorAll('[data-slot]'))) {
148
176
  pill.hidden = current !== '' && parts.get(pill) !== current;
149
177
  }
150
178
  for (const tab of /** @type {NodeListOf<HTMLElement>} */ (tabs.querySelectorAll('.booking__part'))) {
151
179
  tab.classList.toggle('is-current', tab.dataset.part === current);
152
- tab.setAttribute('aria-selected', String(tab.dataset.part === current));
180
+ tab.setAttribute('aria-pressed', String(tab.dataset.part === current));
153
181
  }
154
182
  };
155
183
  const sort = () => {
156
- const pills = /** @type {HTMLElement[]} */ ([...slots.querySelectorAll('.booking__slot')]);
184
+ const pills = /** @type {HTMLElement[]} */ ([...slots.querySelectorAll('[data-slot]')]);
157
185
  // A time on the pill is the platform's clock in the site's language;
158
186
  // the hour read off it on 24 hours whatever that clock says.
159
187
  const sorted = partsOf(pills.map(hourOf));
160
188
  pills.forEach((pill, i) => parts.set(pill, sorted[i]));
161
189
  const have = new Set(sorted);
162
- const chosen = pills.find((pill) => pill.classList.contains('is-chosen'));
190
+ const chosen = pills.find((pill) => pill.getAttribute('aria-pressed') === 'true');
163
191
  tabs.innerHTML = PARTS.filter((part) => have.has(part))
164
192
  .map((part) => {
165
193
  const own = pills.filter((pill) => parts.get(pill) === part).map(timeOf);
166
- return `<button type="button" class="booking__part" role="tab" data-part="${part}">${words[part] ?? part}<span class="booking__part-n">${own[0]}–${own[own.length - 1]}</span></button>`;
194
+ return `<button type="button" class="booking__part" data-part="${part}">${words[part] ?? part}<span class="booking__part-n">${own[0]}–${own[own.length - 1]}</span></button>`;
167
195
  })
168
196
  .join('');
169
197
  tabs.hidden = have.size < 2;
@@ -193,21 +221,30 @@ export function partsOfDay(form, words) {
193
221
  * @param {string} lang
194
222
  */
195
223
  export function twoSteps(form, words, lang) {
224
+ // A scroll the visitor did not ask for glides only for one who has not
225
+ // asked for less motion.
226
+ const smooth = () => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth');
196
227
  if (form.dataset.steps === '1') return;
197
228
  form.dataset.steps = '1';
198
229
  const firstHalf = new Set(['service', 'resource', 'day', 'back', 'hours', 'party', 'quantity', 'class']);
199
- for (const child of /** @type {HTMLElement[]} */ ([...form.children])) {
200
- const control = child.querySelector('input, select, textarea');
230
+ // The pieces sorted into the steps: every field by its hook, wherever the
231
+ // site's markup put it — in a grid, in two columns, in a card. A form of an
232
+ // older kit marked no fields, and its children are the pieces.
233
+ const marked = /** @type {HTMLElement[]} */ ([...form.querySelectorAll('[data-field], [data-times], [data-challenge], [data-submit]')]);
234
+ const pieces = marked.some((el) => el.matches('[data-field]')) ? marked : /** @type {HTMLElement[]} */ ([...form.children]);
235
+ for (const piece of pieces) {
236
+ const control = piece.matches('input, select, textarea') ? piece : piece.querySelector('input, select, textarea');
201
237
  const name = control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement ? control.name : '';
202
- const first = child.matches('[data-times]') || firstHalf.has(name) || child.tagName === 'NOSCRIPT' || child.matches('[data-done]');
203
- child.dataset.step = first ? '1' : '2';
238
+ const first = piece.matches('[data-times]') || firstHalf.has(name) || piece.tagName === 'NOSCRIPT';
239
+ piece.dataset.step = first ? '1' : '2';
204
240
  }
205
241
  for (const el of form.querySelectorAll('[data-done], [data-booking-error]')) /** @type {HTMLElement} */ (el).dataset.step = 'both';
206
242
 
207
243
  const stepper = document.createElement('ol');
208
244
  stepper.className = 'booking__stepper';
245
+ stepper.dataset.stepUi = '';
209
246
  stepper.innerHTML = [words.stepWhat ?? 'What and when', words.stepWho ?? 'Who is coming']
210
- .map((name, i) => `<li class="booking__step" data-at="${i + 1}"><span class="booking__step-n">${i + 1}</span>${name}</li>`)
247
+ .map((name, i) => `<li class="booking__step" data-at="${i + 1}" tabindex="-1"><span class="booking__step-n">${i + 1}</span>${name}</li>`)
211
248
  .join('');
212
249
  form.prepend(stepper);
213
250
 
@@ -215,10 +252,14 @@ export function twoSteps(form, words, lang) {
215
252
  next.type = 'button';
216
253
  next.className = 'booking__next';
217
254
  next.dataset.step = '1';
255
+ next.dataset.stepUi = '';
218
256
  next.textContent = words.next ?? 'Continue';
219
257
  const need = document.createElement('p');
220
258
  need.className = 'booking__need';
221
259
  need.dataset.step = '1';
260
+ need.dataset.stepUi = '';
261
+ // Said as it appears, not only drawn in red.
262
+ need.setAttribute('role', 'alert');
222
263
  need.hidden = true;
223
264
  need.textContent = words.needAll ?? 'Pick a service, a day and a time first.';
224
265
  const times = form.querySelector('[data-times]');
@@ -228,17 +269,21 @@ export function twoSteps(form, words, lang) {
228
269
  const summary = document.createElement('div');
229
270
  summary.className = 'booking__summary';
230
271
  summary.dataset.step = '2';
231
- form.insertBefore(summary, form.querySelector('[data-step="2"]'));
272
+ summary.dataset.stepUi = '';
273
+ // Before the first piece of the second step, in whatever holds it.
274
+ const firstOfTwo = form.querySelector('[data-step="2"]');
275
+ if (firstOfTwo?.parentElement) firstOfTwo.parentElement.insertBefore(summary, firstOfTwo);
276
+ else form.append(summary);
232
277
 
233
278
  const chosen = () => {
234
279
  const names = /** @type {HTMLInputElement[]} */ ([...form.querySelectorAll('input[name=service]:checked')])
235
- .map((box) => box.closest('.booking__service')?.querySelector('.booking__service-name')?.textContent?.trim() ?? box.value);
280
+ .map((box) => box.closest('[data-service-row], .booking__service, label')?.querySelector('[data-service-name], .booking__service-name')?.textContent?.trim() ?? box.value);
236
281
  const single = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=service]'));
237
282
  if (single) names.push(single.options[single.selectedIndex]?.textContent?.trim() ?? '');
238
283
  const who = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=resource]'));
239
284
  const dayValue = /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=day]'))?.value ?? '';
240
285
  const dayDate = parseDay(dayValue);
241
- const slot = /** @type {HTMLElement | null} */ (form.querySelector('.booking__slot.is-chosen'));
286
+ const slot = /** @type {HTMLElement | null} */ (form.querySelector('[data-slot][aria-pressed="true"]'));
242
287
  const ends = /** @type {HTMLElement | null} */ (form.querySelector('[data-ends]'));
243
288
  return {
244
289
  services: names.filter(Boolean),
@@ -259,6 +304,11 @@ export function twoSteps(form, words, lang) {
259
304
  for (const step of /** @type {NodeListOf<HTMLElement>} */ (stepper.querySelectorAll('.booking__step'))) {
260
305
  step.classList.toggle('is-current', step.dataset.at === to);
261
306
  step.classList.toggle('is-done', to === 'done' || Number(step.dataset.at) < Number(to));
307
+ // The step's state as an attribute too, so a site styles a step behind
308
+ // without a class of the kit's: `data-state="done|current|ahead"`.
309
+ step.dataset.state = step.dataset.at === to ? 'current' : to === 'done' || Number(step.dataset.at) < Number(to) ? 'done' : 'ahead';
310
+ if (step.dataset.at === to) step.setAttribute('aria-current', 'step');
311
+ else step.removeAttribute('aria-current');
262
312
  }
263
313
  if (to === '2') {
264
314
  const c = chosen();
@@ -273,15 +323,25 @@ export function twoSteps(form, words, lang) {
273
323
  </dl>
274
324
  <button type="button" class="booking__back">${words.change ?? 'Change'}</button>`;
275
325
  summary.querySelector('.booking__back')?.addEventListener('click', () => stage('1'));
276
- /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=name]'))?.focus({ preventScroll: true });
277
326
  }
278
- if (moved && to !== 'done') form.scrollIntoView({ block: 'start', behavior: 'smooth' });
327
+ // A move to another step takes the focus to that step's name, so a
328
+ // screen reader says where it is and the next Tab is the step's first
329
+ // control. Only a move: a field changed on the second step redraws the
330
+ // summary and leaves the focus where the visitor is — it went back to
331
+ // the name on every Tab out of it (WCAG 2.4.3, 12 October 2026). The
332
+ // answer takes the focus itself (the element's `done`).
333
+ if (moved && to !== 'done') {
334
+ /** @type {HTMLElement | null} */ (stepper.querySelector('.booking__step.is-current'))?.focus({ preventScroll: true });
335
+ form.scrollIntoView({ block: 'start', behavior: smooth() });
336
+ }
279
337
  };
280
338
 
281
339
  next.addEventListener('click', () => {
282
340
  const c = chosen();
283
341
  const ready = c.services.length > 0 && /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=day]'))?.value && c.time;
284
342
  need.hidden = Boolean(ready);
343
+ // Written again each time, so a second press is said a second time.
344
+ if (!ready) need.textContent = words.needAll ?? 'Pick a service, a day and a time first.';
285
345
  if (ready) stage('2');
286
346
  });
287
347
  form.addEventListener('change', () => {
@@ -292,7 +352,7 @@ export function twoSteps(form, words, lang) {
292
352
  const settle = () => {
293
353
  if (done.hidden || form.dataset.stage === 'done') return;
294
354
  stage('done');
295
- form.scrollIntoView({ block: 'start', behavior: 'smooth' });
355
+ form.scrollIntoView({ block: 'start', behavior: smooth() });
296
356
  };
297
357
  new MutationObserver(settle).observe(done, { attributes: true, attributeFilter: ['hidden'] });
298
358
  settle();
@@ -324,15 +384,22 @@ export function drawnSelect(select) {
324
384
  list.hidden = true;
325
385
  root.append(trigger, list);
326
386
  select.classList.add('is-replaced');
387
+ select.dataset.replaced = '';
327
388
  select.setAttribute('aria-hidden', 'true');
328
389
  select.tabIndex = -1;
329
390
  select.insertAdjacentElement('afterend', root);
330
391
  const label = select.labels?.[0];
331
- if (label) trigger.setAttribute('aria-label', label.textContent?.trim() ?? '');
392
+ // A press on the label goes to the drawn control, not the hidden one.
393
+ label?.addEventListener('click', (event) => { event.preventDefault(); trigger.focus(); });
394
+ if (label) list.setAttribute('aria-label', label.textContent?.trim() ?? '');
332
395
 
333
396
  const draw = () => {
334
397
  const current = select.options[select.selectedIndex];
335
398
  trigger.innerHTML = `<span class="booking__dd-value">${current?.textContent ?? ''}</span><span class="booking__dd-mark" aria-hidden="true">▾</span>`;
399
+ // Named by the label and the value both: the label alone hid what is
400
+ // chosen, and broke the rule that a control's name holds the words drawn
401
+ // on it (WCAG 2.5.3) — a voice user says what they see.
402
+ if (label) trigger.setAttribute('aria-label', `${label.textContent?.trim() ?? ''}: ${current?.textContent?.trim() ?? ''}`);
336
403
  list.innerHTML = [...select.options]
337
404
  .map((o) => `<button type="button" class="booking__dd-option${o.selected ? ' is-chosen' : ''}" role="option" aria-selected="${o.selected}" data-value="${o.value.replace(/"/g, '&quot;')}"${o.disabled ? ' disabled' : ''}>${o.textContent}</button>`)
338
405
  .join('');
@@ -365,6 +432,9 @@ export function drawnSelect(select) {
365
432
  document.addEventListener('click', (event) => {
366
433
  if (!list.hidden && event.target instanceof Node && !root.contains(event.target)) open(false);
367
434
  });
435
+ root.addEventListener('focusout', (event) => {
436
+ if (!list.hidden && !(event.relatedTarget instanceof Node && root.contains(event.relatedTarget))) open(false);
437
+ });
368
438
  new MutationObserver(draw).observe(select, { childList: true, subtree: true, attributes: true });
369
439
  select.addEventListener('change', draw);
370
440
  draw();