@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 +19 -7
- package/booking.css +23 -10
- package/elements/booking-ui.js +91 -21
- package/elements/booking.js +170 -29
- package/elements/lead.js +26 -4
- package/index.mjs +2 -1
- package/lib/booking/hooks.mjs +91 -0
- package/lib/booking/render.mjs +101 -41
- package/lib/booking/words.mjs +8 -0
- package/lib/lint.mjs +37 -0
- package/package.json +5 -1
- package/types/booking.d.ts +21 -0
- package/types/elements-booking.d.ts +1 -0
- package/types/hooks.d.ts +12 -0
- package/types/index.d.ts +4 -2
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
71
|
-
legible and nothing more;
|
|
72
|
-
|
|
73
|
-
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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 {
|
|
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;
|
|
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;
|
|
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). */
|
package/elements/booking-ui.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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('
|
|
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-
|
|
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('
|
|
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.
|
|
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"
|
|
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
|
-
|
|
200
|
-
|
|
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 =
|
|
203
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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, '"')}"${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();
|