@jtakeit/kit 0.5.2 → 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.
@@ -14,6 +14,8 @@
14
14
  * POST /api/book → { id, state, amount_minor, currency, token, manage_path, telegram_url }
15
15
  * POST /api/pay → { url } — only when amount_minor > 0
16
16
  *
17
+ * Every request names the protocol version: `X-Jtk-Booking: 1` (BOOKING_PROTOCOL).
18
+ *
17
19
  * The visitor's own page — cancel, move, add to calendar — is served by the
18
20
  * platform at `manage_path`; this form only has to link to it.
19
21
  *
@@ -22,9 +24,14 @@
22
24
  * Import this module once on the page and every `<form data-booking>` is
23
25
  * wired: the ones inside a `<jtk-booking>` by the element's own lifecycle,
24
26
  * which also covers markup the panel places later (`fl:placed`); the ones
25
- * written bare by a walk of the document at load. The markup is
26
- * `renderBookingForm`'s (`@jtakeit/kit/booking`); a hand-written form keeps
27
- * its `data-*` hooks and input `name`s.
27
+ * written bare by a walk of the document at load. The markup is the site's —
28
+ * the scaffold's `BookingForm.astro` written out, or `renderBookingForm`'s
29
+ * (`@jtakeit/kit/booking`) — and the element finds everything in it by a
30
+ * `data-*` hook or an input's `name`, never by a class: the list is
31
+ * `@jtakeit/kit/booking/hooks`. A hook left out is said in the console and
32
+ * its field is never shown; the CSS the behaviour needs (`[hidden]`, the
33
+ * steps, the native control under a drawn one) and the ARIA it depends on are
34
+ * put on the page by the element, so a form restyled from nothing keeps them.
28
35
  *
29
36
  * ── a stay of several nights ────────────────────────────────────────────────
30
37
  *
@@ -47,6 +54,40 @@
47
54
  import { money } from '../lib/money.mjs';
48
55
  import { wordsFor } from '../lib/booking/words.mjs';
49
56
  import { dressBooking } from './booking-ui.js';
57
+ import { lacking } from '../lib/booking/hooks.mjs';
58
+
59
+ /**
60
+ * The rules the behaviour cannot work without, whatever the site's own CSS
61
+ * says — put on the page once, by the element, at the lowest specificity
62
+ * that still wins where it must. Not a look: every state here is shown and
63
+ * hidden by the `hidden` attribute, the two steps by `data-stage`, a drawn
64
+ * control over a native one by `data-replaced`; a form an agent dressed from
65
+ * nothing lost all three with `booking.css` (12 October 2026). The first two
66
+ * are `!important`, as `hidden` is: a site's `display: grid` on a piece of
67
+ * the second step drew it on the first.
68
+ */
69
+ const BASE = `
70
+ [data-booking] [hidden] { display: none !important; }
71
+ [data-booking][data-unsaid] :is([data-say], [data-say-wait]) { visibility: hidden; }
72
+ :where([data-booking]) :where([data-replaced], .is-replaced, [data-sr], .booking__sr) {
73
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
74
+ clip-path: inset(50%); white-space: nowrap; border: 0;
75
+ }
76
+ :where([data-booking]) :where([data-replaced], .is-replaced) { opacity: 0; pointer-events: none; }
77
+ [data-booking][data-stage='1'] [data-step='2'], [data-booking][data-stage='2'] [data-step='1'],
78
+ [data-booking][data-stage='done'] [data-step-ui] { display: none !important; }
79
+ :where([data-booking] :focus-visible) { outline: 2px solid currentColor; outline-offset: 2px; }
80
+ `;
81
+
82
+ function putBase() {
83
+ if (typeof document === 'undefined' || document.querySelector('style[data-jtk-booking]')) return;
84
+ const style = document.createElement('style');
85
+ style.dataset.jtkBooking = '';
86
+ style.textContent = BASE;
87
+ // First in <head>, so a site's own rule of the same weight wins.
88
+ document.head.prepend(style);
89
+ }
90
+ let wiredCount = 0;
50
91
 
51
92
  // The words that depend on the kind of business, as the platform says them:
52
93
  // its availability answer carries the form's, in every language, the site's
@@ -90,10 +131,25 @@ export function lasting(minutes, words) {
90
131
  * Wires one form. Idempotent: a form already wired is left alone.
91
132
  * @param {HTMLFormElement} form
92
133
  */
134
+ /**
135
+ * The booking protocol's version this element speaks, named on every request
136
+ * (the platform's wiki/79). A page is a file in a build: when the platform
137
+ * changes, a site keeps the kit it was built with, and the platform answers it
138
+ * by this number for as long as a live site speaks it — so it moves only with
139
+ * a change the platform cannot make by adding, and the platform says which
140
+ * versions it answers when it refuses one.
141
+ */
142
+ export const BOOKING_PROTOCOL = 1;
143
+ const SPOKEN = { 'x-jtk-booking': String(BOOKING_PROTOCOL) };
144
+
93
145
  export function wireBooking(form) {
94
146
  if (form.dataset.jtkWired === '1') return;
95
147
  form.dataset.jtkWired = '1';
96
148
 
149
+ putBase();
150
+ const missing = lacking(form);
151
+ if (missing.length > 0) console.error(`booking form: it has no ${missing.join(', ')}, and cannot book without them — the hooks are listed in @jtakeit/kit/booking/hooks`);
152
+
97
153
  /** @type {Record<string, string>} */
98
154
  // The form's own words under the page's: a form rendered by an older kit
99
155
  // carries none of the newer ones.
@@ -102,8 +158,10 @@ export function wireBooking(form) {
102
158
  // once there is one, wins — an owner may switch without a rebuild.
103
159
  let daily = form.dataset.scale === 'daily';
104
160
  const api = form.dataset.api ?? '/api/';
105
- /** @template {HTMLElement} T @param {string} sel @returns {T} */
106
- const q = (sel) => /** @type {any} */ (form.querySelector(sel));
161
+ // A hook the form left out is an element of its kind that is never on the
162
+ // page: the field it stands for is never shown, and nothing here throws.
163
+ /** @template {HTMLElement} T @param {string} sel @param {string} [tag] @returns {T} */
164
+ const q = (sel, tag = 'div') => /** @type {any} */ (form.querySelector(sel) ?? document.createElement(tag));
107
165
  const lang = form.dataset.locale ?? 'en';
108
166
  const pageLang = () => document.documentElement.lang || 'en';
109
167
 
@@ -111,6 +169,12 @@ export function wireBooking(form) {
111
169
  const select = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=service]'));
112
170
  const boxes = /** @type {HTMLInputElement[]} */ ([...form.querySelectorAll('input[type=checkbox][name=service]')]);
113
171
  const total = /** @type {HTMLElement | null} */ (form.querySelector('[data-total]'));
172
+ // A checkbox's row and the service's name in it, by their hooks; the
173
+ // classes of an older kit's markup where a form has no hooks.
174
+ /** @param {HTMLInputElement} box */
175
+ const rowOf = (box) => /** @type {HTMLElement | null} */ (box.closest('[data-service-row], .booking__service, label'));
176
+ /** @param {Element} row */
177
+ const nameIn = (row) => /** @type {HTMLElement | null} */ (row.querySelector('[data-service-name], .booking__service-name'));
114
178
  const picked = () => (select ? (select.value ? [select.value] : []) : boxes.filter((b) => b.checked).map((b) => b.value));
115
179
  function showTotal() {
116
180
  if (!total) return;
@@ -126,14 +190,14 @@ export function wireBooking(form) {
126
190
  total.hidden = false;
127
191
  }
128
192
  const resource = /** @type {HTMLSelectElement | null} */ (form.querySelector('[name=resource]'));
129
- /** @type {HTMLSelectElement} */ const party = q('[name=party]');
130
- /** @type {HTMLSelectElement} */ const quantity = q('[name=quantity]');
193
+ /** @type {HTMLSelectElement} */ const party = q('[name=party]', 'select');
194
+ /** @type {HTMLSelectElement} */ const quantity = q('[name=quantity]', 'select');
131
195
  /** @type {HTMLElement} */ const quantityField = q('[data-quantity-field]');
132
- /** @type {HTMLSelectElement} */ const hours = q('[name=hours]');
196
+ /** @type {HTMLSelectElement} */ const hours = q('[name=hours]', 'select');
133
197
  /** @type {HTMLElement} */ const hoursField = q('[data-hours-field]');
134
198
  /** @type {HTMLElement} */ const partyField = q('[data-party-field]');
135
- /** @type {HTMLInputElement} */ const day = q('[name=day]');
136
- /** @type {HTMLElement | null} */ const ends = q('[data-ends]');
199
+ /** @type {HTMLInputElement} */ const day = q('[name=day]', 'input');
200
+ /** @type {HTMLElement | null} */ const ends = form.querySelector('[data-ends]');
137
201
  // The day's floor is the browser's today, not the build's: `min` was
138
202
  // written when the site was built, and a site built on Friday is read on
139
203
  // Saturday (10 October 2026).
@@ -156,11 +220,25 @@ export function wireBooking(form) {
156
220
  /** @type {HTMLElement} */ const error = q('[data-booking-error]');
157
221
  /** @type {HTMLButtonElement} */ const submit = q('[data-submit]');
158
222
  /** @type {HTMLElement} */ const challenge = q('[data-challenge]');
159
- /** @type {HTMLInputElement} */ const back = q('[name=back]');
223
+ /** @type {HTMLInputElement} */ const back = q('[name=back]', 'input');
160
224
  /** @type {HTMLElement} */ const backField = q('[data-back-field]');
161
225
  /** @type {HTMLElement} */ const lengthHint = q('[data-length]');
162
226
  /** @type {HTMLElement} */ const stayTotal = q('[data-stay-total]');
163
227
 
228
+ // The ARIA the behaviour depends on, whatever the copy kept: the hint is
229
+ // read as it changes, the refusal as it appears, the answer takes the focus
230
+ // and is read, and a refusal is tied to its fields by the error's id.
231
+ if (!hint.hasAttribute('aria-live')) hint.setAttribute('aria-live', 'polite');
232
+ if (!error.getAttribute('role')) error.setAttribute('role', 'alert');
233
+ if (!error.id) error.id = `jtk-booking-error-${++wiredCount}`;
234
+ {
235
+ /** @type {HTMLElement} */ const answerBox = q('[data-done]');
236
+ if (!answerBox.getAttribute('role')) answerBox.setAttribute('role', 'status');
237
+ if (!answerBox.hasAttribute('tabindex')) answerBox.tabIndex = -1;
238
+ const keepSaid = form.querySelector('[data-keep-said]');
239
+ if (keepSaid && !keepSaid.getAttribute('role')) keepSaid.setAttribute('role', 'status');
240
+ }
241
+
164
242
  /** @param {Record<string, Record<string, string>>} [all] */
165
243
  function hear(all) {
166
244
  const mine = all?.[lang] ?? all?.en;
@@ -172,11 +250,15 @@ export function wireBooking(form) {
172
250
  el.textContent = words[el.dataset.say ?? ''] ?? el.textContent;
173
251
  }
174
252
  }
253
+ unsay();
254
+ }
255
+ function unsay() {
175
256
  form.classList.remove('booking--unsaid');
257
+ delete form.dataset.unsaid;
176
258
  }
177
259
  // Held back at most this long: a platform that is slow to answer is not
178
260
  // a reason for a form without a button.
179
- setTimeout(() => form.classList.remove('booking--unsaid'), 3000);
261
+ setTimeout(unsay, 3000);
180
262
  {
181
263
  const first = select?.options[0]?.value ?? boxes[0]?.value ?? '';
182
264
  const from = new Date();
@@ -185,10 +267,11 @@ export function wireBooking(form) {
185
267
  to.setDate(to.getDate() + 1);
186
268
  if (first) {
187
269
  const params = new URLSearchParams({ service: first, from: from.toISOString(), to: to.toISOString() });
188
- void fetch(api + 'availability?' + params.toString(), { cache: 'no-store' })
270
+ void fetch(api + 'availability?' + params.toString(), { cache: 'no-store', headers: SPOKEN })
189
271
  .then((r) => (r.ok ? r.json() : {}))
190
272
  .then((data) => {
191
273
  hear(data.words);
274
+ hearCombine(data.combine);
192
275
  sayEmptyServices(data.empty_services ?? []);
193
276
  })
194
277
  .catch(() => hear());
@@ -207,6 +290,23 @@ export function wireBooking(form) {
207
290
  // sign made up while it is asked (11 October 2026).
208
291
  let currency = form.dataset.currency || '';
209
292
 
293
+ // One service or several, as the platform answers it now: the owner flips
294
+ // `combine` in the panel and a checklist built while it was on keeps to one
295
+ // choice once it is off, rather than offering two the booking refuses.
296
+ let single = false;
297
+ /** @param {unknown} said */
298
+ function hearCombine(said) {
299
+ if (typeof said !== 'boolean' || boxes.length === 0) return;
300
+ single = !said;
301
+ if (!single) return;
302
+ let kept = false;
303
+ for (const box of boxes) {
304
+ if (box.checked && kept) box.checked = false;
305
+ if (box.checked) kept = true;
306
+ }
307
+ showTotal();
308
+ }
309
+
210
310
  /** @type {string | null} */ let chosen = null;
211
311
  let zone = '';
212
312
  let turnstileToken = '';
@@ -223,7 +323,7 @@ export function wireBooking(form) {
223
323
 
224
324
  // The widget, if this host has one. Asked at runtime so the site carries
225
325
  // no key: which widget covers a host is the platform's to decide.
226
- void fetch(api + 'turnstile', { cache: 'no-store' })
326
+ void fetch(api + 'turnstile', { cache: 'no-store', headers: SPOKEN })
227
327
  .then((r) => r.json())
228
328
  .then((data) => {
229
329
  if (!data.sitekey) return;
@@ -294,10 +394,25 @@ export function wireBooking(form) {
294
394
  function quantityChosen() {
295
395
  return quantityField.hidden ? 1 : Number(quantity.value) || 1;
296
396
  }
297
- /** @param {string} text */
298
- function say(text) {
397
+ // The fields the last refusal was about: marked invalid and described by
398
+ // the sentence, so a screen reader reads it on the field itself and not
399
+ // only as it appears (WCAG 3.3.1, 12 October 2026).
400
+ /** @type {HTMLElement[]} */ let blamed = [];
401
+ /** @param {string} text @param {HTMLElement[]} [fields] the fields it is about */
402
+ function say(text, fields = []) {
299
403
  error.textContent = text;
300
404
  error.hidden = text === '';
405
+ for (const field of blamed) {
406
+ field.removeAttribute('aria-invalid');
407
+ const rest = (field.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id && id !== error.id);
408
+ if (rest.length) field.setAttribute('aria-describedby', rest.join(' '));
409
+ else field.removeAttribute('aria-describedby');
410
+ }
411
+ blamed = text === '' || !error.id ? [] : fields;
412
+ for (const field of blamed) {
413
+ field.setAttribute('aria-invalid', 'true');
414
+ field.setAttribute('aria-describedby', [field.getAttribute('aria-describedby'), error.id].filter(Boolean).join(' '));
415
+ }
301
416
  }
302
417
 
303
418
  // A refusal as a sentence in the visitor's language. The platform answers
@@ -485,7 +600,7 @@ export function wireBooking(form) {
485
600
  const can = who && !who.startsWith('class:') && !who.startsWith('empty:') && does.has(who) ? does.get(who) : null;
486
601
  let moved = false;
487
602
  for (const box of boxes) {
488
- const row = box.closest('.booking__service');
603
+ const row = rowOf(box);
489
604
  const off = can !== null && !can.has(box.value);
490
605
  if (off && !box.disabled) {
491
606
  box.disabled = true;
@@ -496,12 +611,14 @@ export function wireBooking(form) {
496
611
  offByMe.delete(box);
497
612
  }
498
613
  row?.classList.toggle('is-off', offByMe.has(box));
499
- let note = row?.querySelector('.booking__service-off') ?? null;
614
+ if (row) row.toggleAttribute('data-off', offByMe.has(box));
615
+ let note = row?.querySelector('[data-service-off], .booking__service-off') ?? null;
500
616
  if (offByMe.has(box) && row) {
501
- const nameEl = row.querySelector('.booking__service-name');
617
+ const nameEl = nameIn(row);
502
618
  if (!note && nameEl) {
503
619
  note = document.createElement('span');
504
620
  note.className = 'booking__service-off';
621
+ note.setAttribute('data-service-off', '');
505
622
  nameEl.insertAdjacentElement('afterend', note);
506
623
  }
507
624
  if (note) note.textContent = words.notWith.replace('{who}', name.split(' ')[0] || name);
@@ -550,7 +667,8 @@ export function wireBooking(form) {
550
667
  const box = boxes.find((b) => b.value === one.slug);
551
668
  if (box && !box.disabled) {
552
669
  box.disabled = true;
553
- const name = box.closest('.booking__service')?.querySelector('.booking__service-name');
670
+ const row = rowOf(box);
671
+ const name = row ? nameIn(row) : null;
554
672
  if (name) name.textContent = said;
555
673
  if (box.checked) { box.checked = false; showTotal(); moved = true; }
556
674
  }
@@ -579,6 +697,7 @@ export function wireBooking(form) {
579
697
  return;
580
698
  }
581
699
  hint.textContent = '…';
700
+ slots.setAttribute('aria-busy', 'true');
582
701
 
583
702
  const from = new Date(day.value + 'T00:00:00');
584
703
  const to = new Date(from);
@@ -596,11 +715,12 @@ export function wireBooking(form) {
596
715
  if (n !== null) params.set('count', String(n));
597
716
 
598
717
  try {
599
- const answer = await fetch(api + 'availability?' + params.toString(), { cache: 'no-store' });
718
+ const answer = await fetch(api + 'availability?' + params.toString(), { cache: 'no-store', headers: SPOKEN }).finally(() => slots.removeAttribute('aria-busy'));
600
719
  if (!answer.ok) throw new Error(await answer.text());
601
720
  if (mine !== asking) return;
602
721
  const data = await answer.json();
603
722
  hear(data.words);
723
+ hearCombine(data.combine);
604
724
  lastClasses = data.classes ?? [];
605
725
  lastResources = data.resources ?? [];
606
726
  offerChoices(lastClasses, lastResources, data.empty_classes ?? []);
@@ -646,6 +766,7 @@ export function wireBooking(form) {
646
766
  const button = document.createElement('button');
647
767
  button.type = 'button';
648
768
  button.className = 'booking__slot';
769
+ button.dataset.slot = '';
649
770
  // Which one is chosen, said as well as drawn.
650
771
  button.setAttribute('aria-pressed', 'false');
651
772
  button.textContent = daily
@@ -660,7 +781,7 @@ export function wireBooking(form) {
660
781
  if (hint.textContent === words.retime) hint.textContent = '';
661
782
  sayEnds(slot);
662
783
  offerQuantity(data.quantity_max ?? 1, (slot.free ?? []).length);
663
- for (const other of slots.querySelectorAll('.booking__slot')) {
784
+ for (const other of slots.querySelectorAll('[data-slot]')) {
664
785
  other.classList.remove('is-chosen');
665
786
  other.setAttribute('aria-pressed', 'false');
666
787
  }
@@ -705,7 +826,13 @@ export function wireBooking(form) {
705
826
  // The look — the steps, the week, the parts of the day, the drawn choice
706
827
  // — over the form, where the form asks for it.
707
828
  if (form.dataset.ui) dressBooking(form, words);
708
- for (const box of boxes) box.addEventListener('change', () => { showTotal(); void load(); });
829
+ for (const box of boxes) {
830
+ box.addEventListener('change', () => {
831
+ if (single && box.checked) for (const other of boxes) if (other !== box) other.checked = false;
832
+ showTotal();
833
+ void load();
834
+ });
835
+ }
709
836
  resource?.addEventListener('change', () => { greyByResource(); void load(); });
710
837
  greyByResource();
711
838
  // A new first date keeps the stay's length: five nights from the 10th
@@ -729,8 +856,14 @@ export function wireBooking(form) {
729
856
  const email = String(data.get('email') ?? '').trim();
730
857
  if (picked().length === 0) { say(words.pickService); return; }
731
858
  if (!chosen) { say(words.pick); return; }
732
- if (name === '') { /** @type {HTMLInputElement} */ (q('[name=name]')).focus(); return; }
733
- if (phone === '' && email === '') { say(words.needContact); return; }
859
+ /** @type {HTMLInputElement} */ const nameInput = q('[name=name]');
860
+ if (name === '') { say(words.needName, [nameInput]); nameInput.focus(); return; }
861
+ if (phone === '' && email === '') {
862
+ /** @type {HTMLInputElement} */ const phoneInput = q('[name=phone]');
863
+ say(words.needContact, [phoneInput, q('[name=email]')]);
864
+ phoneInput.focus();
865
+ return;
866
+ }
734
867
 
735
868
  submit.disabled = true;
736
869
  submit.textContent = words.sending;
@@ -763,7 +896,7 @@ export function wireBooking(form) {
763
896
  }
764
897
  const answer = await fetch(api + 'book', {
765
898
  method: 'POST',
766
- headers: { 'content-type': 'application/json' },
899
+ headers: { 'content-type': 'application/json', ...SPOKEN },
767
900
  body: JSON.stringify(body),
768
901
  });
769
902
  if (answer.status === 409) { say(words.taken); await load(); return; }
@@ -783,7 +916,11 @@ export function wireBooking(form) {
783
916
  * @param {{ state: string; start: string; ends: string; amount_minor: number; currency: string; id: string; manage_path: string; telegram_url: string; resource?: string; class?: string; group?: { quantity: number; amount_minor: number; bookings: { resource: string; resource_name?: string }[] } }} made
784
917
  */
785
918
  function done(made) {
786
- for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('.field, .booking__times, .booking__services, [data-submit], [data-challenge]'))) el.hidden = true;
919
+ // Everything that asks, gone; the answer stays. By the hooks — the
920
+ // classes are an older kit's markup.
921
+ for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('[data-field], [data-times], [data-services], [data-submit], [data-challenge], .field, .booking__times, .booking__services'))) {
922
+ if (!el.contains(form.querySelector('[data-done]'))) el.hidden = true;
923
+ }
787
924
  /** @type {HTMLElement} */ const box = q('[data-done]');
788
925
  box.hidden = false;
789
926
  // After the lines are written below — see the end of this function.
@@ -824,10 +961,14 @@ export function wireBooking(form) {
824
961
  link.setAttribute('aria-label', words.manage);
825
962
  link.addEventListener('focus', () => link.select());
826
963
  copy.textContent = words.copy;
964
+ /** @type {HTMLElement | null} */ const told = q('[data-keep-said]');
827
965
  copy.addEventListener('click', async () => {
828
966
  try { await navigator.clipboard.writeText(href); } catch { link.select(); document.execCommand('copy'); }
829
967
  copy.textContent = words.copied;
830
- setTimeout(() => { copy.textContent = words.copy; }, 1600);
968
+ // Said as well as drawn: a button's own words changing under the
969
+ // focus is read by some screen readers and not by others.
970
+ if (told) told.textContent = words.copied;
971
+ setTimeout(() => { copy.textContent = words.copy; if (told) told.textContent = ''; }, 1600);
831
972
  });
832
973
  open.href = href;
833
974
  open.textContent = words.open;
@@ -851,7 +992,7 @@ export function wireBooking(form) {
851
992
  event.preventDefault();
852
993
  const answer = await fetch(api + 'pay', {
853
994
  method: 'POST',
854
- headers: { 'content-type': 'application/json' },
995
+ headers: { 'content-type': 'application/json', ...SPOKEN },
855
996
  body: JSON.stringify({ booking: made.id, return: made.manage_path || '/' }),
856
997
  });
857
998
  if (!answer.ok) { say(await reason(answer)); return; }
package/elements/lead.js CHANGED
@@ -27,18 +27,39 @@ export function wireLead(form) {
27
27
  if (!status || !button) return;
28
28
  const original = label?.textContent ?? '';
29
29
 
30
+ /*
31
+ * A sentence about a field is said on the field (WCAG 3.3.1): the input is
32
+ * marked invalid and described by the sentence, so a screen reader reads
33
+ * it when the field is reached and not only when it appears. It was drawn
34
+ * under the field and tied to nothing (12 October 2026).
35
+ */
36
+ /** @param {string} field */
37
+ const inputOf = (field) => /** @type {HTMLInputElement | null} */ (form.elements.namedItem(field));
38
+
30
39
  /** @param {string} field @param {string} message */
31
40
  const showError = (field, message) => {
32
41
  /** @type {HTMLElement | null} */ const el = form.querySelector(`[data-error-for="${field}"]`);
33
42
  if (!el) return;
34
43
  el.textContent = message;
35
44
  el.hidden = !message;
45
+ const input = inputOf(field);
46
+ if (!input || !message) return;
47
+ if (!el.id) el.id = `${form.id || 'lead'}-${field}-error`;
48
+ input.setAttribute('aria-invalid', 'true');
49
+ const described = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
50
+ if (!described.includes(el.id)) input.setAttribute('aria-describedby', [...described, el.id].join(' '));
36
51
  };
37
52
 
38
53
  const clearErrors = () => {
39
54
  for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('[data-error-for]'))) {
40
55
  el.textContent = '';
41
56
  el.hidden = true;
57
+ const input = inputOf(el.dataset.errorFor ?? '');
58
+ if (!input) continue;
59
+ input.removeAttribute('aria-invalid');
60
+ const rest = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id && id !== el.id);
61
+ if (rest.length) input.setAttribute('aria-describedby', rest.join(' '));
62
+ else input.removeAttribute('aria-describedby');
42
63
  }
43
64
  };
44
65
 
@@ -49,15 +70,16 @@ export function wireLead(form) {
49
70
  form.removeAttribute('data-state');
50
71
 
51
72
  const data = new FormData(form);
52
- let ok = true;
73
+ /** @type {HTMLInputElement | null} */ let first = null;
53
74
  for (const field of ['name', 'contact']) {
54
- const input = /** @type {HTMLInputElement | null} */ (form.elements.namedItem(field));
75
+ const input = inputOf(field);
55
76
  if (!input?.value.trim()) {
56
77
  showError(field, input?.dataset.message ?? '');
57
- ok = false;
78
+ first ??= input;
58
79
  }
59
80
  }
60
- if (!ok) return;
81
+ // And the visitor is taken to the first thing to fix (3.3.1).
82
+ if (first) { first.focus(); return; }
61
83
 
62
84
  button.disabled = true;
63
85
  if (label) label.textContent = form.dataset.sending ?? original;
package/index.mjs CHANGED
@@ -15,7 +15,8 @@
15
15
  * and the enquiry form the same way: `@jtakeit/kit/lead`,
16
16
  * `@jtakeit/kit/elements/lead`, `@jtakeit/kit/lead.css`.
17
17
  */
18
- export { renderBookingForm, SENDS } from './lib/booking/render.mjs';
18
+ export { renderBookingForm, bookingForm, SENDS } from './lib/booking/render.mjs';
19
+ export { ALWAYS, ASKED, SAID, HOOKS } from './lib/booking/hooks.mjs';
19
20
  export { renderLeadForm, inputType } from './lib/lead/render.mjs';
20
21
  export { wordsFor, LOCALES } from './lib/booking/words.mjs';
21
22
  export { money } from './lib/money.mjs';
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What `<jtk-booking>` reads off a booking form — the whole of the contract
3
+ * between a form's markup and the element that makes it work.
4
+ *
5
+ * The element finds everything by these: a `data-*` attribute or an input's
6
+ * `name`, never a class. Classes, wrappers, the order of the fields, the
7
+ * words and the look are the site's; a form an agent copies and dresses in
8
+ * its own design keeps working as long as these are on it.
9
+ *
10
+ * Three kinds:
11
+ *
12
+ * ALWAYS every form has them, or it cannot book — the element says which
13
+ * is missing in the console, and the platform's preflight fails a
14
+ * booking page without them;
15
+ * ASKED a field the element shows only when the platform's answer asks
16
+ * for it — a party, how many, how long, a second date. A form for
17
+ * a business that never asks may leave one out; preflight fails it
18
+ * where the business does ask;
19
+ * SAID where the element writes what happened — the running total, when
20
+ * the visit ends, what was taken, the link to keep. Left out, that
21
+ * line is not said and nothing else changes.
22
+ *
23
+ * The test (kit/test/hooks.test.mjs) holds `renderBookingForm` and the
24
+ * scaffold's `BookingForm.astro` to ALWAYS, ASKED and SAID; the platform's
25
+ * preflight keeps its own copy of ALWAYS and ASKED, written from this one.
26
+ */
27
+
28
+ /** @typedef {{ hook: string; says: string }} Hook */
29
+
30
+ /** @type {readonly Hook[]} */
31
+ export const ALWAYS = Object.freeze([
32
+ { hook: 'form[data-booking]', says: 'the form; `data-api`, `data-sends` and `data-locale` on it' },
33
+ { hook: '[name=service]', says: 'a `<select>` for one service, or checkboxes for several (`combine`)' },
34
+ { hook: '[name=day]', says: 'an `<input type="date">`; the week of days is drawn over it' },
35
+ { hook: '[data-times]', says: 'around the times: the hint, the times and when the visit ends' },
36
+ { hook: '[data-slots]', says: 'empty; the element writes a button per free time into it' },
37
+ { hook: '[data-times-hint]', says: '«pick a day», «nothing free», «…» while it asks' },
38
+ { hook: '[name=name]', says: 'the visitor\'s name' },
39
+ { hook: '[name=phone]', says: 'a phone or an email, one of the two is needed' },
40
+ { hook: '[name=email]', says: 'a phone or an email, one of the two is needed' },
41
+ { hook: '[data-challenge]', says: 'empty and hidden; the anti-bot widget goes here where the host has one' },
42
+ { hook: '[data-booking-error]', says: 'the refusal, said; tied to the fields it is about' },
43
+ { hook: '[data-submit]', says: 'the submit button' },
44
+ { hook: '[data-done]', says: 'hidden; shown once booked, with the lines below inside it' },
45
+ { hook: '[data-done-line]', says: '«you are booked» or «waiting for the business»' },
46
+ { hook: '[data-done-when]', says: 'the day and the time booked' },
47
+ { hook: '[data-manage]', says: 'a link to the visitor\'s own page: cancel, move' },
48
+ { hook: '[data-pay-note]', says: 'the sentence before paying, where the booking is paid online' },
49
+ { hook: '[data-pay]', says: 'the link that pays, where the booking is paid online' },
50
+ ]);
51
+
52
+ /** @type {readonly (Hook & { when: string })[]} */
53
+ export const ASKED = Object.freeze([
54
+ { hook: '[data-party-field]', when: 'party_max > 1', says: 'hidden; around `[name=party]`, a `<select>` the element fills' },
55
+ { hook: '[name=party]', when: 'party_max > 1', says: 'how many people' },
56
+ { hook: '[data-quantity-field]', when: 'quantity_max > 1', says: 'hidden; around `[name=quantity]`' },
57
+ { hook: '[name=quantity]', when: 'quantity_max > 1', says: 'how many of a class at once' },
58
+ { hook: '[data-hours-field]', when: 'an hourly service with most > fewest', says: 'hidden; around `[name=hours]`' },
59
+ { hook: '[name=hours]', when: 'an hourly service with most > fewest', says: 'how long, in the service\'s units' },
60
+ { hook: '[data-back-field]', when: 'a daily service with most > fewest', says: 'hidden; around `[name=back]` and `[data-length]`' },
61
+ { hook: '[name=back]', when: 'a daily service with most > fewest', says: 'the second date: until when' },
62
+ { hook: '[data-length]', when: 'a daily service with most > fewest', says: 'how many nights may be taken' },
63
+ { hook: '[name=resource]', when: 'the visitor chooses whom', says: 'a `<select>`: anyone, a class, or one by name' },
64
+ ]);
65
+
66
+ /** @type {readonly Hook[]} */
67
+ export const SAID = Object.freeze([
68
+ { hook: '[data-field]', says: 'on each field\'s wrapper: what the two steps sort and what is hidden once booked' },
69
+ { hook: '[data-services]', says: 'the checklist\'s fieldset' },
70
+ { hook: '[data-service-row]', says: 'the `<label>` around each checkbox: greyed with `data-off` when the one chosen does not do it' },
71
+ { hook: '[data-service-name]', says: 'the service\'s name inside the row' },
72
+ { hook: '[data-total]', says: 'the running total of a checklist' },
73
+ { hook: '[data-ends]', says: '«until about 10:45 · 1 h 15 min»' },
74
+ { hook: '[data-stay-total]', says: 'the price of a stay' },
75
+ { hook: '[data-done-what]', says: 'what was taken: «with Noa», «any of Economy»' },
76
+ { hook: '[data-keep]', says: 'hidden; the link to keep, with `[data-keep-note]`, `[data-keep-link]`, `[data-keep-copy]`, `[data-keep-open]`, `[data-keep-said]`' },
77
+ { hook: '[data-telegram]', says: 'a link to the business\'s Telegram bot, where it has one' },
78
+ { hook: '[name=note]', says: 'a note to the business' },
79
+ ]);
80
+
81
+ /** Every hook, for a check that reads them all. */
82
+ export const HOOKS = Object.freeze([...ALWAYS, ...ASKED, ...SAID].map((one) => one.hook));
83
+
84
+ /**
85
+ * The hooks in `ALWAYS` a form lacks — what the element says in the console
86
+ * when it wires one.
87
+ * @param {ParentNode & { matches?: (s: string) => boolean }} form
88
+ */
89
+ export function lacking(form) {
90
+ return ALWAYS.filter(({ hook }) => hook !== 'form[data-booking]' && !form.querySelector(hook)).map(({ hook }) => hook);
91
+ }