@edvizion/astro-components 0.1.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.
@@ -0,0 +1,993 @@
1
+ import { baseComponentStyles } from '../../theme/component-styles';
2
+ import { cssVar } from '../../theme/tokens';
3
+ import {
4
+ DEFAULT_LIMIT,
5
+ DEFAULT_RECOMMEND_ENDPOINT,
6
+ DEFAULT_SEARCH_ENDPOINT,
7
+ MAX_LIMIT,
8
+ MAX_OFFSET,
9
+ RECOMMENDED_SCHOOL_ID,
10
+ recommendSchool,
11
+ SchoolSearchError,
12
+ searchSchools,
13
+ validateQuery,
14
+ } from './api';
15
+ import type {
16
+ SchoolRecommendation,
17
+ SchoolSearchErrorDetail,
18
+ SchoolSearchEventDetail,
19
+ SchoolSearchResult,
20
+ SelectedSchool,
21
+ } from './types';
22
+ import { normalizeStateCode, US_STATES } from './us-states';
23
+
24
+ export const TAG_NAME = 'edv-school-search';
25
+
26
+ const styles = /* css */ `
27
+ ${baseComponentStyles}
28
+
29
+ .root { display: grid; gap: calc(${cssVar('space')} * 2); }
30
+ .search-row { display: flex; gap: ${cssVar('space')}; }
31
+ .search-row .edv-control { flex: 1 1 auto; min-width: 0; }
32
+
33
+ .status { margin: 0; font-size: ${cssVar('fontSizeSmall')}; color: ${cssVar('colorTextMuted')}; }
34
+ .status[data-tone='error'] { color: ${cssVar('colorDanger')}; font-weight: ${cssVar('fontWeightStrong')}; }
35
+
36
+ .results {
37
+ list-style: none;
38
+ margin: 0;
39
+ padding: calc(${cssVar('space')} * 0.5);
40
+ max-height: 22rem;
41
+ overflow-y: auto;
42
+ background: ${cssVar('colorSurface')};
43
+ border: 1px solid ${cssVar('colorBorder')};
44
+ border-radius: ${cssVar('radius')};
45
+ box-shadow: ${cssVar('shadow')};
46
+ }
47
+ .option {
48
+ display: grid;
49
+ gap: 0.125rem;
50
+ padding: ${cssVar('space')} calc(${cssVar('space')} * 1.5);
51
+ border-radius: ${cssVar('radiusSmall')};
52
+ cursor: pointer;
53
+ }
54
+ .option:hover, .option[data-active] { background: ${cssVar('colorHighlight')}; }
55
+ .results:focus-visible .option[data-active] {
56
+ outline: ${cssVar('focusRingWidth')} solid ${cssVar('colorFocusRing')};
57
+ outline-offset: -${cssVar('focusRingWidth')};
58
+ }
59
+ .option-name { font-weight: ${cssVar('fontWeightStrong')}; }
60
+ .option-meta { font-size: ${cssVar('fontSizeSmall')}; color: ${cssVar('colorTextMuted')}; }
61
+
62
+ .selection {
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: space-between;
66
+ gap: calc(${cssVar('space')} * 2);
67
+ padding: calc(${cssVar('space')} * 1.5);
68
+ background: ${cssVar('colorSurfaceMuted')};
69
+ border: 1px solid ${cssVar('colorBorder')};
70
+ border-radius: ${cssVar('radius')};
71
+ }
72
+ .selection-text { display: grid; gap: 0.125rem; min-width: 0; }
73
+ .selection-name { font-weight: ${cssVar('fontWeightStrong')}; }
74
+ .selection-meta { font-size: ${cssVar('fontSizeSmall')}; color: ${cssVar('colorTextMuted')}; }
75
+
76
+ .recommend-prompt { margin: 0; font-size: ${cssVar('fontSizeSmall')}; color: ${cssVar('colorTextMuted')}; }
77
+ .edv-link {
78
+ font: inherit;
79
+ color: ${cssVar('colorPrimary')};
80
+ font-weight: ${cssVar('fontWeightStrong')};
81
+ background: none;
82
+ border: 0;
83
+ padding: 0;
84
+ text-decoration: underline;
85
+ text-underline-offset: 0.15em;
86
+ cursor: pointer;
87
+ }
88
+ .edv-link:hover { color: ${cssVar('colorPrimaryHover')}; }
89
+
90
+ .recommend {
91
+ display: grid;
92
+ gap: calc(${cssVar('space')} * 1.5);
93
+ padding: calc(${cssVar('space')} * 2);
94
+ background: ${cssVar('colorSurface')};
95
+ border: 1px solid ${cssVar('colorBorder')};
96
+ border-radius: ${cssVar('radius')};
97
+ }
98
+ .recommend-title { margin: 0; font-weight: ${cssVar('fontWeightStrong')}; }
99
+ .recommend .edv-hint { margin: 0; }
100
+ .recommend textarea.edv-control { padding-block: ${cssVar('space')}; resize: vertical; min-height: 4.5rem; }
101
+ .actions { display: flex; flex-wrap: wrap; gap: ${cssVar('space')}; }
102
+
103
+ [hidden] { display: none !important; }
104
+ `;
105
+
106
+ const template = document.createElement('template');
107
+ template.innerHTML = /* html */ `
108
+ <style>${styles}</style>
109
+ <div class="root" part="root">
110
+ <div class="field" part="state-field">
111
+ <label class="edv-label" for="state" part="label">
112
+ <span data-ref="stateLabel">State</span><span class="edv-required" aria-hidden="true">*</span>
113
+ </label>
114
+ <select id="state" class="edv-control" part="select" required aria-required="true" aria-describedby="state-error">
115
+ <option value="">Select a state</option>
116
+ ${US_STATES.map((s) => `<option value="${s.code}">${s.name}</option>`).join('')}
117
+ </select>
118
+ <p id="state-error" class="edv-error" part="error" hidden></p>
119
+ </div>
120
+
121
+ <div class="field" part="school-field" data-ref="searchField">
122
+ <label class="edv-label" for="query" part="label">
123
+ <span data-ref="schoolLabel">School</span><span class="edv-required" data-ref="schoolRequired" aria-hidden="true" hidden>*</span>
124
+ </label>
125
+ <div class="search-row" role="search" part="search-row">
126
+ <input
127
+ id="query"
128
+ class="edv-control"
129
+ part="input"
130
+ type="search"
131
+ autocomplete="off"
132
+ spellcheck="false"
133
+ enterkeyhint="search"
134
+ maxlength="200"
135
+ aria-describedby="hint school-error status"
136
+ />
137
+ <button id="search" class="edv-button" part="button search-button" type="button">Search</button>
138
+ </div>
139
+ <p id="hint" class="edv-hint" part="hint"></p>
140
+ <p id="school-error" class="edv-error" part="error" hidden></p>
141
+ </div>
142
+
143
+ <div class="selection" part="selection" data-ref="selection" hidden>
144
+ <div class="selection-text">
145
+ <span class="edv-visually-hidden">Selected school:</span>
146
+ <span class="selection-name" part="selection-name" data-ref="selectionName"></span>
147
+ <span class="selection-meta" part="selection-meta" data-ref="selectionMeta"></span>
148
+ </div>
149
+ <button class="edv-button edv-button--secondary" part="button change-button" type="button" data-ref="change">
150
+ Change<span class="edv-visually-hidden"> school</span>
151
+ </button>
152
+ </div>
153
+
154
+ <p id="status" class="status" part="status" role="status" aria-live="polite" aria-atomic="true"></p>
155
+
156
+ <ul id="results" class="results" part="results" role="listbox" tabindex="0" aria-label="School search results" hidden></ul>
157
+ <button class="edv-button edv-button--secondary" part="button more-button" type="button" data-ref="more" hidden>
158
+ Show more results
159
+ </button>
160
+
161
+ <p class="recommend-prompt" part="recommend-prompt" data-ref="recommendPrompt" hidden>
162
+ Can’t find your school?
163
+ <button class="edv-link" part="recommend-button" type="button" data-ref="recommendOpen">Recommend it</button>
164
+ </p>
165
+
166
+ <div class="recommend" part="recommend" role="group" aria-labelledby="recommend-title" data-ref="recommendPanel" hidden>
167
+ <p id="recommend-title" class="recommend-title">Recommend a school</p>
168
+ <p id="recommend-hint" class="edv-hint" part="hint" data-ref="recommendHint"></p>
169
+ <div class="field">
170
+ <label class="edv-label" for="recommend-name" part="label">
171
+ School name<span class="edv-required" aria-hidden="true">*</span>
172
+ </label>
173
+ <input
174
+ id="recommend-name"
175
+ class="edv-control"
176
+ part="input recommend-input"
177
+ type="text"
178
+ autocomplete="off"
179
+ maxlength="200"
180
+ aria-required="true"
181
+ aria-describedby="recommend-hint recommend-error"
182
+ />
183
+ </div>
184
+ <div class="field">
185
+ <label class="edv-label" for="recommend-notes" part="label">Notes (optional)</label>
186
+ <textarea
187
+ id="recommend-notes"
188
+ class="edv-control"
189
+ part="textarea recommend-notes"
190
+ rows="3"
191
+ placeholder="City, website, or anything that helps us find it"
192
+ ></textarea>
193
+ </div>
194
+ <p id="recommend-error" class="edv-error" part="error" role="alert" hidden></p>
195
+ <div class="actions">
196
+ <button class="edv-button" part="button recommend-submit" type="button" data-ref="recommendSubmit">
197
+ Submit recommendation
198
+ </button>
199
+ <button class="edv-button edv-button--secondary" part="button recommend-cancel" type="button" data-ref="recommendCancel">
200
+ Cancel
201
+ </button>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ `;
206
+
207
+ interface FormState {
208
+ state: string | null;
209
+ school: SelectedSchool | null;
210
+ }
211
+
212
+ /**
213
+ * `<edv-school-search>` — a form-associated school picker backed by the
214
+ * Edvizion School Directory.
215
+ *
216
+ * Form value: the selected school's `edvizionId` (submitted under `name`).
217
+ * Validity: `valueMissing` when `required` and no state / no school is chosen.
218
+ * Search runs only on Enter or the Search button — never while typing.
219
+ *
220
+ * If the school is missing, the user can recommend it. A successful
221
+ * recommendation sets the value to `sch_00000000-0000-0000-0000-000000000000`; details are on
222
+ * `selectedSchool.recommendation`.
223
+ *
224
+ * Events: `input` + `change` when the selection changes; `edv-search` after
225
+ * results load; `edv-search-error` when a search fails; `edv-recommend` /
226
+ * `edv-recommend-error` for recommendations.
227
+ */
228
+ export class EdvSchoolSearchElement extends HTMLElement {
229
+ static formAssociated = true;
230
+ static observedAttributes = [
231
+ 'required',
232
+ 'disabled',
233
+ 'school-label',
234
+ 'state-label',
235
+ 'placeholder',
236
+ 'hint',
237
+ 'default-state',
238
+ 'no-recommend',
239
+ ];
240
+
241
+ readonly #internals: ElementInternals;
242
+ readonly #shadow: ShadowRoot;
243
+
244
+ #stateSelect!: HTMLSelectElement;
245
+ #queryInput!: HTMLInputElement;
246
+ #searchButton!: HTMLButtonElement;
247
+ #results!: HTMLUListElement;
248
+ #more!: HTMLButtonElement;
249
+ #status!: HTMLParagraphElement;
250
+ #hint!: HTMLParagraphElement;
251
+ #stateError!: HTMLParagraphElement;
252
+ #schoolError!: HTMLParagraphElement;
253
+ #searchField!: HTMLElement;
254
+ #selection!: HTMLElement;
255
+ #selectionName!: HTMLElement;
256
+ #selectionMeta!: HTMLElement;
257
+ #change!: HTMLButtonElement;
258
+ #recommendPrompt!: HTMLElement;
259
+ #recommendOpenButton!: HTMLButtonElement;
260
+ #recommendPanel!: HTMLElement;
261
+ #recommendHint!: HTMLElement;
262
+ #recommendName!: HTMLInputElement;
263
+ #recommendNotes!: HTMLTextAreaElement;
264
+ #recommendError!: HTMLElement;
265
+ #recommendSubmit!: HTMLButtonElement;
266
+ #recommendCancel!: HTMLButtonElement;
267
+
268
+ #state: string | null = null;
269
+ #school: SelectedSchool | null = null;
270
+ #hits: SchoolSearchResult[] = [];
271
+ #activeIndex = -1;
272
+ #lastSearch: { query: string; state: string } | null = null;
273
+ #abort: AbortController | null = null;
274
+ #recommendOpen = false;
275
+ #recommendAbort: AbortController | null = null;
276
+ #formDisabled = false;
277
+ #customError = '';
278
+ #showErrors = false;
279
+ #initialized = false;
280
+
281
+ constructor() {
282
+ super();
283
+ this.#internals = this.attachInternals();
284
+ this.#shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
285
+ this.#shadow.appendChild(template.content.cloneNode(true));
286
+
287
+ const $ = <T extends Element>(selector: string) => this.#shadow.querySelector(selector) as T;
288
+ this.#stateSelect = $('#state');
289
+ this.#queryInput = $('#query');
290
+ this.#searchButton = $('#search');
291
+ this.#results = $('#results');
292
+ this.#status = $('#status');
293
+ this.#hint = $('#hint');
294
+ this.#stateError = $('#state-error');
295
+ this.#schoolError = $('#school-error');
296
+ this.#more = $('[data-ref="more"]');
297
+ this.#searchField = $('[data-ref="searchField"]');
298
+ this.#selection = $('[data-ref="selection"]');
299
+ this.#selectionName = $('[data-ref="selectionName"]');
300
+ this.#selectionMeta = $('[data-ref="selectionMeta"]');
301
+ this.#change = $('[data-ref="change"]');
302
+ this.#recommendPrompt = $('[data-ref="recommendPrompt"]');
303
+ this.#recommendOpenButton = $('[data-ref="recommendOpen"]');
304
+ this.#recommendPanel = $('[data-ref="recommendPanel"]');
305
+ this.#recommendHint = $('[data-ref="recommendHint"]');
306
+ this.#recommendName = $('#recommend-name');
307
+ this.#recommendNotes = $('#recommend-notes');
308
+ this.#recommendError = $('#recommend-error');
309
+ this.#recommendSubmit = $('[data-ref="recommendSubmit"]');
310
+ this.#recommendCancel = $('[data-ref="recommendCancel"]');
311
+
312
+ this.#stateSelect.addEventListener('change', () => this.#onStateChange());
313
+ this.#queryInput.addEventListener('keydown', (e) => this.#onQueryKeydown(e));
314
+ this.#searchButton.addEventListener('click', () => this.#runSearch());
315
+ this.#more.addEventListener('click', () => this.#runSearch({ append: true }));
316
+ this.#results.addEventListener('keydown', (e) => this.#onResultsKeydown(e));
317
+ this.#results.addEventListener('click', (e) => this.#onResultsClick(e));
318
+ this.#results.addEventListener('focus', () => {
319
+ if (this.#activeIndex < 0 && this.#hits.length) this.#setActive(0);
320
+ });
321
+ this.#change.addEventListener('click', () => this.#clearSelection({ focus: true }));
322
+ this.#recommendOpenButton.addEventListener('click', () => this.#openRecommend());
323
+ this.#recommendCancel.addEventListener('click', () => this.#closeRecommend({ focus: true }));
324
+ this.#recommendSubmit.addEventListener('click', () => this.#submitRecommendation());
325
+ this.#recommendName.addEventListener('keydown', (e) => {
326
+ if (e.key !== 'Enter') return;
327
+ e.preventDefault();
328
+ this.#submitRecommendation();
329
+ });
330
+ this.#recommendPanel.addEventListener('keydown', (e) => {
331
+ if (e.key === 'Escape') this.#closeRecommend({ focus: true });
332
+ });
333
+ // Native `input` events are composed and would escape as if the host's
334
+ // value changed. The host fires its own input/change only on selection.
335
+ this.#shadow.addEventListener('input', (e) => e.stopPropagation());
336
+ // The host is what the form reports as invalid; mirror that inside.
337
+ this.addEventListener('invalid', () => {
338
+ this.#showErrors = true;
339
+ this.#renderErrors();
340
+ });
341
+ }
342
+
343
+ connectedCallback() {
344
+ if (!this.#initialized) {
345
+ this.#initialized = true;
346
+ this.#applyInitialValue();
347
+ }
348
+ this.#renderText();
349
+ this.#render();
350
+ }
351
+
352
+ disconnectedCallback() {
353
+ this.#abort?.abort();
354
+ this.#recommendAbort?.abort();
355
+ }
356
+
357
+ attributeChangedCallback(name: string) {
358
+ if (!this.#initialized) return;
359
+ if (name === 'default-state' && !this.#state) this.#setState(this.#defaultState, { clear: false });
360
+ this.#renderText();
361
+ this.#render();
362
+ }
363
+
364
+ // ---- Form-associated lifecycle -------------------------------------------
365
+
366
+ formResetCallback() {
367
+ this.#abort?.abort();
368
+ this.#recommendAbort?.abort();
369
+ this.#showErrors = false;
370
+ this.#queryInput.value = '';
371
+ this.#resetResults();
372
+ this.#applyInitialValue();
373
+ this.#render();
374
+ }
375
+
376
+ formDisabledCallback(disabled: boolean) {
377
+ this.#formDisabled = disabled;
378
+ this.#render();
379
+ }
380
+
381
+ formStateRestoreCallback(saved: unknown) {
382
+ if (typeof saved !== 'string') return;
383
+ try {
384
+ const { state, school } = JSON.parse(saved) as FormState;
385
+ this.#state = normalizeStateCode(state);
386
+ this.#school = school?.edvizionId ? school : null;
387
+ this.#render();
388
+ } catch {
389
+ /* ignore unreadable saved state */
390
+ }
391
+ }
392
+
393
+ // ---- Public form-control API ---------------------------------------------
394
+
395
+ /** The selected school's `edvizionId`, or `""`. */
396
+ get value(): string {
397
+ return this.#school?.edvizionId ?? '';
398
+ }
399
+ set value(id: string) {
400
+ // A bare id carries no display name; prefer `selectSchool()` when you have one.
401
+ this.#school = id ? { edvizionId: id, name: this.#school?.edvizionId === id ? this.#school.name : id } : null;
402
+ this.#render();
403
+ }
404
+
405
+ /** The selected school, including display fields when chosen from results. */
406
+ get selectedSchool(): SelectedSchool | null {
407
+ return this.#school ? { ...this.#school } : null;
408
+ }
409
+
410
+ /** The chosen USPS state code, or `""`. */
411
+ get state(): string {
412
+ return this.#state ?? '';
413
+ }
414
+ set state(code: string) {
415
+ this.#setState(normalizeStateCode(code), { clear: true, notify: false });
416
+ }
417
+
418
+ /** Select a school programmatically. Does not fire `change`. */
419
+ selectSchool(school: SelectedSchool | null) {
420
+ this.#school = school?.edvizionId ? { ...school } : null;
421
+ if (this.#school?.state && !this.#state) this.#state = normalizeStateCode(this.#school.state);
422
+ this.#resetResults();
423
+ this.#render();
424
+ }
425
+
426
+ get name() {
427
+ return this.getAttribute('name') ?? '';
428
+ }
429
+ set name(v: string) {
430
+ this.setAttribute('name', v);
431
+ }
432
+ get required() {
433
+ return this.hasAttribute('required');
434
+ }
435
+ set required(v: boolean) {
436
+ this.toggleAttribute('required', v);
437
+ }
438
+ get disabled() {
439
+ return this.hasAttribute('disabled');
440
+ }
441
+ set disabled(v: boolean) {
442
+ this.toggleAttribute('disabled', v);
443
+ }
444
+
445
+ get form() {
446
+ return this.#internals.form;
447
+ }
448
+ get labels() {
449
+ return this.#internals.labels;
450
+ }
451
+ get type() {
452
+ return TAG_NAME;
453
+ }
454
+ get validity() {
455
+ return this.#internals.validity;
456
+ }
457
+ get validationMessage() {
458
+ return this.#internals.validationMessage;
459
+ }
460
+ get willValidate() {
461
+ return this.#internals.willValidate;
462
+ }
463
+ checkValidity() {
464
+ return this.#internals.checkValidity();
465
+ }
466
+ reportValidity() {
467
+ return this.#internals.reportValidity();
468
+ }
469
+ setCustomValidity(message: string) {
470
+ this.#customError = message ?? '';
471
+ this.#syncForm();
472
+ }
473
+
474
+ // ---- Configuration -------------------------------------------------------
475
+
476
+ get #endpoint() {
477
+ return this.getAttribute('endpoint') || DEFAULT_SEARCH_ENDPOINT;
478
+ }
479
+ get #recommendEndpoint() {
480
+ return this.getAttribute('recommend-endpoint') || DEFAULT_RECOMMEND_ENDPOINT;
481
+ }
482
+ get #canRecommend() {
483
+ return !this.hasAttribute('no-recommend');
484
+ }
485
+ get #limit() {
486
+ const n = Number.parseInt(this.getAttribute('limit') ?? '', 10);
487
+ return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_LIMIT) : DEFAULT_LIMIT;
488
+ }
489
+ get #defaultState() {
490
+ const raw = this.getAttribute('default-state');
491
+ const code = normalizeStateCode(raw);
492
+ if (raw && !code) console.warn(`<${TAG_NAME}>: ignoring unsupported default-state "${raw}".`);
493
+ return code;
494
+ }
495
+ get #isDisabled() {
496
+ return this.disabled || this.#formDisabled;
497
+ }
498
+
499
+ #applyInitialValue() {
500
+ const id = this.getAttribute('value');
501
+ const state = this.#defaultState;
502
+ this.#school = id ? { edvizionId: id, name: this.getAttribute('value-name') || id, state } : null;
503
+ this.#state = state;
504
+ }
505
+
506
+ // ---- Behaviour -----------------------------------------------------------
507
+
508
+ #onStateChange() {
509
+ this.#setState(normalizeStateCode(this.#stateSelect.value), { clear: true, notify: true });
510
+ if (this.#state) this.#queryInput.focus();
511
+ }
512
+
513
+ #setState(code: string | null, { clear, notify = false }: { clear: boolean; notify?: boolean }) {
514
+ if (code === this.#state) return;
515
+ this.#state = code;
516
+ if (clear) {
517
+ this.#abort?.abort();
518
+ this.#resetResults();
519
+ const hadSchool = this.#school !== null;
520
+ this.#school = null;
521
+ this.#setStatus('');
522
+ this.#render();
523
+ if (hadSchool && notify) this.#emitChange();
524
+ } else {
525
+ this.#render();
526
+ }
527
+ }
528
+
529
+ #onQueryKeydown(event: KeyboardEvent) {
530
+ if (event.key === 'Enter') {
531
+ // Never let Enter implicitly submit a surrounding form.
532
+ event.preventDefault();
533
+ this.#runSearch();
534
+ } else if (event.key === 'ArrowDown' && !this.#results.hidden && this.#hits.length) {
535
+ event.preventDefault();
536
+ this.#results.focus();
537
+ this.#setActive(Math.max(this.#activeIndex, 0));
538
+ }
539
+ }
540
+
541
+ async #runSearch({ append = false } = {}) {
542
+ if (this.#isDisabled) return;
543
+ if (!this.#state) {
544
+ this.#showErrors = true;
545
+ this.#renderErrors();
546
+ this.#stateSelect.focus();
547
+ return;
548
+ }
549
+
550
+ const query = append && this.#lastSearch ? this.#lastSearch.query : this.#queryInput.value;
551
+ const problem = validateQuery(query);
552
+ if (problem) {
553
+ this.#setStatus(problem, 'error');
554
+ this.#queryInput.setAttribute('aria-invalid', 'true');
555
+ return;
556
+ }
557
+ this.#queryInput.removeAttribute('aria-invalid');
558
+
559
+ const offset = append ? this.#hits.length : 0;
560
+ if (offset > MAX_OFFSET) return;
561
+
562
+ this.#abort?.abort();
563
+ const abort = new AbortController();
564
+ this.#abort = abort;
565
+ const state = this.#state;
566
+ this.#setBusy(true);
567
+ this.#setStatus(append ? 'Loading more schools…' : 'Searching…');
568
+
569
+ try {
570
+ const response = await searchSchools({
571
+ query,
572
+ state,
573
+ limit: this.#limit,
574
+ offset,
575
+ endpoint: this.#endpoint,
576
+ signal: abort.signal,
577
+ });
578
+ if (abort.signal.aborted) return;
579
+
580
+ this.#lastSearch = { query: query.trim(), state };
581
+ const previousCount = append ? this.#hits.length : 0;
582
+ this.#hits = append ? [...this.#hits, ...response.results] : response.results;
583
+ const hasMore = response.results.length === this.#limit && this.#hits.length <= MAX_OFFSET;
584
+ this.#renderResults(hasMore);
585
+
586
+ const total = this.#hits.length;
587
+ const stateName = US_STATES.find((s) => s.code === state)?.name ?? state;
588
+ if (total === 0) {
589
+ this.#setStatus(`No schools in ${stateName} match “${query.trim()}”. Try a different name or city.`);
590
+ } else if (append) {
591
+ this.#setStatus(`Loaded ${total - previousCount} more. ${total} schools shown.`);
592
+ this.#setActive(previousCount);
593
+ this.#results.focus();
594
+ } else {
595
+ this.#setStatus(
596
+ `${total}${hasMore ? '+' : ''} ${total === 1 ? 'school' : 'schools'} found in ${stateName}. ` +
597
+ 'Press the down arrow to browse results, Enter to choose.',
598
+ );
599
+ }
600
+
601
+ this.dispatchEvent(
602
+ new CustomEvent<SchoolSearchEventDetail>('edv-search', {
603
+ bubbles: true,
604
+ composed: true,
605
+ detail: { query: query.trim(), state, offset, results: response.results },
606
+ }),
607
+ );
608
+ } catch (error) {
609
+ if (abort.signal.aborted || (error as Error)?.name === 'AbortError') return;
610
+ const e =
611
+ error instanceof SchoolSearchError
612
+ ? error
613
+ : new SchoolSearchError('School search failed. Please try again.', null, null, null);
614
+ if (!append) {
615
+ this.#hits = [];
616
+ this.#renderResults(false);
617
+ }
618
+ this.#setStatus(e.message, 'error');
619
+ this.dispatchEvent(
620
+ new CustomEvent<SchoolSearchErrorDetail>('edv-search-error', {
621
+ bubbles: true,
622
+ composed: true,
623
+ detail: { status: e.status, code: e.code, requestId: e.requestId, message: e.message },
624
+ }),
625
+ );
626
+ } finally {
627
+ if (this.#abort === abort) {
628
+ this.#abort = null;
629
+ this.#setBusy(false);
630
+ }
631
+ }
632
+ }
633
+
634
+ #onResultsKeydown(event: KeyboardEvent) {
635
+ const last = this.#hits.length - 1;
636
+ switch (event.key) {
637
+ case 'ArrowDown':
638
+ this.#setActive(Math.min(this.#activeIndex + 1, last));
639
+ break;
640
+ case 'ArrowUp':
641
+ if (this.#activeIndex <= 0) {
642
+ this.#queryInput.focus();
643
+ this.#setActive(-1);
644
+ } else {
645
+ this.#setActive(this.#activeIndex - 1);
646
+ }
647
+ break;
648
+ case 'Home':
649
+ this.#setActive(0);
650
+ break;
651
+ case 'End':
652
+ this.#setActive(last);
653
+ break;
654
+ case 'Enter':
655
+ case ' ':
656
+ if (this.#activeIndex >= 0) this.#choose(this.#hits[this.#activeIndex]);
657
+ break;
658
+ case 'Escape':
659
+ this.#queryInput.focus();
660
+ break;
661
+ default:
662
+ return;
663
+ }
664
+ event.preventDefault();
665
+ }
666
+
667
+ #onResultsClick(event: MouseEvent) {
668
+ const option = (event.target as Element).closest<HTMLElement>('[role="option"]');
669
+ if (!option) return;
670
+ const hit = this.#hits[Number(option.dataset.index)];
671
+ if (hit) this.#choose(hit);
672
+ }
673
+
674
+ #choose(hit: SchoolSearchResult) {
675
+ this.#school = { edvizionId: hit.edvizionId, name: hit.name, city: hit.city, state: hit.state };
676
+ this.#showErrors = false;
677
+ this.#setStatus(`Selected ${hit.name}.`);
678
+ this.#render();
679
+ this.#change.focus();
680
+ this.#emitChange();
681
+ }
682
+
683
+ #clearSelection({ focus }: { focus: boolean }) {
684
+ if (!this.#school) return;
685
+ this.#school = null;
686
+ this.#setStatus(this.#hits.length ? `${this.#hits.length} results available.` : '');
687
+ this.#render();
688
+ if (focus) this.#queryInput.focus();
689
+ this.#emitChange();
690
+ }
691
+
692
+ #openRecommend() {
693
+ if (this.#isDisabled || !this.#state) return;
694
+ this.#recommendOpen = true;
695
+ if (!this.#recommendName.value) this.#recommendName.value = this.#lastSearch?.query ?? this.#queryInput.value.trim();
696
+ this.#setRecommendError('');
697
+ this.#render();
698
+ this.#recommendName.focus();
699
+ }
700
+
701
+ #closeRecommend({ focus }: { focus: boolean }) {
702
+ this.#recommendAbort?.abort();
703
+ this.#recommendOpen = false;
704
+ this.#setRecommendError('');
705
+ this.#render();
706
+ if (focus) this.#recommendOpenButton.focus();
707
+ }
708
+
709
+ async #submitRecommendation() {
710
+ if (this.#isDisabled || !this.#state || this.#recommendAbort) return;
711
+ const name = this.#recommendName.value.trim();
712
+ if (name.length < 2) {
713
+ this.#setRecommendError('Enter the school’s name.');
714
+ this.#recommendName.focus();
715
+ return;
716
+ }
717
+ const notes = this.#recommendNotes.value.trim();
718
+ const state = this.#state;
719
+
720
+ const abort = new AbortController();
721
+ this.#recommendAbort = abort;
722
+ this.#setRecommendError('');
723
+ this.#recommendSubmit.disabled = true;
724
+ this.#recommendSubmit.setAttribute('aria-busy', 'true');
725
+ this.#setStatus('Sending your recommendation…');
726
+
727
+ try {
728
+ const response = await recommendSchool({
729
+ name,
730
+ state,
731
+ notes,
732
+ endpoint: this.#recommendEndpoint,
733
+ signal: abort.signal,
734
+ });
735
+ if (abort.signal.aborted) return;
736
+
737
+ const recommendation: SchoolRecommendation = {
738
+ name: response.name || name,
739
+ state: response.state || state,
740
+ notes: notes || null,
741
+ requestId: response.requestId ?? null,
742
+ submittedAt: response.submittedAt ?? null,
743
+ };
744
+ this.#recommendOpen = false;
745
+ this.#recommendName.value = '';
746
+ this.#recommendNotes.value = '';
747
+ this.#school = {
748
+ edvizionId: RECOMMENDED_SCHOOL_ID,
749
+ name: recommendation.name,
750
+ state: recommendation.state,
751
+ recommendation,
752
+ };
753
+ this.#showErrors = false;
754
+ this.#setStatus(`Thanks — we received your recommendation for ${recommendation.name}. We’ll review it and add it to the directory.`);
755
+ this.#render();
756
+ this.#change.focus();
757
+ this.dispatchEvent(
758
+ new CustomEvent<SchoolRecommendation>('edv-recommend', { bubbles: true, composed: true, detail: recommendation }),
759
+ );
760
+ this.#emitChange();
761
+ } catch (error) {
762
+ if (abort.signal.aborted || (error as Error)?.name === 'AbortError') return;
763
+ const e =
764
+ error instanceof SchoolSearchError
765
+ ? error
766
+ : new SchoolSearchError('Your recommendation could not be sent. Please try again.', null, null, null);
767
+ this.#setStatus('');
768
+ this.#setRecommendError(e.message);
769
+ this.#recommendName.focus();
770
+ this.dispatchEvent(
771
+ new CustomEvent<SchoolSearchErrorDetail>('edv-recommend-error', {
772
+ bubbles: true,
773
+ composed: true,
774
+ detail: { status: e.status, code: e.code, requestId: e.requestId, message: e.message },
775
+ }),
776
+ );
777
+ } finally {
778
+ if (this.#recommendAbort === abort) {
779
+ this.#recommendAbort = null;
780
+ this.#recommendSubmit.disabled = this.#isDisabled;
781
+ this.#recommendSubmit.removeAttribute('aria-busy');
782
+ }
783
+ }
784
+ }
785
+
786
+ #setRecommendError(message: string) {
787
+ this.#recommendError.textContent = message;
788
+ this.#recommendError.hidden = !message;
789
+ this.#recommendName.toggleAttribute('aria-invalid', Boolean(message));
790
+ }
791
+
792
+ #emitChange() {
793
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
794
+ this.dispatchEvent(new Event('change', { bubbles: true }));
795
+ }
796
+
797
+ // ---- Rendering -----------------------------------------------------------
798
+
799
+ #resetResults() {
800
+ this.#hits = [];
801
+ this.#activeIndex = -1;
802
+ this.#lastSearch = null;
803
+ this.#recommendAbort?.abort();
804
+ this.#recommendOpen = false;
805
+ this.#recommendName.value = '';
806
+ this.#recommendNotes.value = '';
807
+ this.#setRecommendError('');
808
+ this.#renderResults(false);
809
+ }
810
+
811
+ #setActive(index: number) {
812
+ this.#activeIndex = index;
813
+ const options = this.#results.querySelectorAll<HTMLElement>('[role="option"]');
814
+ options.forEach((el, i) => {
815
+ el.toggleAttribute('data-active', i === index);
816
+ el.setAttribute('aria-selected', String(i === index));
817
+ });
818
+ const active = options[index];
819
+ if (active) {
820
+ this.#results.setAttribute('aria-activedescendant', active.id);
821
+ active.scrollIntoView({ block: 'nearest' });
822
+ } else {
823
+ this.#results.removeAttribute('aria-activedescendant');
824
+ }
825
+ }
826
+
827
+ #renderResults(hasMore: boolean) {
828
+ this.#results.replaceChildren(
829
+ ...this.#hits.map((hit, index) => {
830
+ const li = document.createElement('li');
831
+ li.id = `option-${index}`;
832
+ li.className = 'option';
833
+ li.setAttribute('part', 'option');
834
+ li.setAttribute('role', 'option');
835
+ li.setAttribute('aria-selected', 'false');
836
+ li.dataset.index = String(index);
837
+ li.dataset.edvizionId = hit.edvizionId;
838
+
839
+ const name = document.createElement('span');
840
+ name.className = 'option-name';
841
+ name.setAttribute('part', 'option-name');
842
+ name.textContent = hit.name;
843
+
844
+ const meta = document.createElement('span');
845
+ meta.className = 'option-meta';
846
+ meta.setAttribute('part', 'option-meta');
847
+ meta.textContent = describe(hit);
848
+
849
+ li.append(name, meta);
850
+ return li;
851
+ }),
852
+ );
853
+ this.#activeIndex = -1;
854
+ this.#results.removeAttribute('aria-activedescendant');
855
+ this.#more.hidden = !hasMore;
856
+ this.#render();
857
+ }
858
+
859
+ #setStatus(message: string, tone: 'info' | 'error' = 'info') {
860
+ this.#status.textContent = message;
861
+ this.#status.dataset.tone = tone;
862
+ }
863
+
864
+ #setBusy(busy: boolean) {
865
+ this.toggleAttribute('busy', busy);
866
+ this.#searchButton.disabled = busy || this.#isDisabled || !this.#state;
867
+ this.#searchButton.setAttribute('aria-busy', String(busy));
868
+ this.#more.disabled = busy;
869
+ this.#internals.states?.[busy ? 'add' : 'delete']?.('busy');
870
+ }
871
+
872
+ #renderText() {
873
+ const text = (attr: string, fallback: string) => this.getAttribute(attr) || fallback;
874
+ this.#shadow.querySelector('[data-ref="stateLabel"]')!.textContent = text('state-label', 'State');
875
+ this.#shadow.querySelector('[data-ref="schoolLabel"]')!.textContent = text('school-label', 'School');
876
+ this.#queryInput.placeholder = text('placeholder', 'School name or city');
877
+ }
878
+
879
+ #render() {
880
+ const disabled = this.#isDisabled;
881
+ const hasState = Boolean(this.#state);
882
+ const selected = this.#school;
883
+
884
+ this.#stateSelect.value = this.#state ?? '';
885
+ this.#stateSelect.disabled = disabled;
886
+ this.#queryInput.disabled = disabled || !hasState;
887
+ this.#searchButton.disabled = disabled || !hasState || this.hasAttribute('busy');
888
+ this.#change.disabled = disabled;
889
+ this.#more.disabled = disabled || this.hasAttribute('busy');
890
+ (this.#shadow.querySelector('[data-ref="schoolRequired"]') as HTMLElement).hidden = !this.required;
891
+
892
+ this.#hint.textContent = hasState
893
+ ? this.getAttribute('hint') || 'Search by school name or city, then press Enter.'
894
+ : 'Select a state to search for a school.';
895
+
896
+ this.#searchField.hidden = Boolean(selected);
897
+ this.#selection.hidden = !selected;
898
+ if (selected) {
899
+ this.#selectionName.textContent = selected.name;
900
+ const place = [selected.city, selected.state].filter(Boolean).join(', ');
901
+ this.#selectionMeta.textContent =
902
+ selected.edvizionId === RECOMMENDED_SCHOOL_ID
903
+ ? ['Recommended — pending review', place].filter(Boolean).join(' · ')
904
+ : place;
905
+ }
906
+
907
+ const recommending = this.#recommendOpen && !selected && !disabled && this.#canRecommend && hasState;
908
+ this.#results.hidden = Boolean(selected) || this.#hits.length === 0 || disabled || recommending;
909
+ if (this.#results.hidden) this.#more.hidden = true;
910
+
911
+ this.#recommendPanel.hidden = !recommending;
912
+ this.#recommendPrompt.hidden =
913
+ recommending || Boolean(selected) || disabled || !this.#canRecommend || !this.#lastSearch;
914
+ this.#recommendSubmit.disabled = disabled || Boolean(this.#recommendAbort);
915
+ this.#recommendCancel.disabled = disabled;
916
+ this.#recommendName.disabled = disabled;
917
+ this.#recommendNotes.disabled = disabled;
918
+ if (recommending) {
919
+ const stateName = US_STATES.find((s) => s.code === this.#state)?.name ?? this.#state;
920
+ this.#recommendHint.textContent =
921
+ `Tell us about a school in ${stateName} that’s missing. We’ll review it before adding it to the directory.`;
922
+ }
923
+
924
+ this.#internals.states?.[selected ? 'add' : 'delete']?.('has-value');
925
+ this.#syncForm();
926
+ }
927
+
928
+ #syncForm() {
929
+ const formState: FormState = { state: this.#state, school: this.#school };
930
+ this.#internals.setFormValue(this.#school?.edvizionId || null, JSON.stringify(formState));
931
+
932
+ if (this.#isDisabled) {
933
+ this.#internals.setValidity({});
934
+ } else if (this.#customError) {
935
+ this.#internals.setValidity({ customError: true }, this.#customError, this.#queryInput);
936
+ } else if (this.required && !this.#state) {
937
+ this.#internals.setValidity({ valueMissing: true }, 'Select a state.', this.#stateSelect);
938
+ } else if (this.required && !this.#school) {
939
+ this.#internals.setValidity({ valueMissing: true }, 'Search for and select a school.', this.#queryInput);
940
+ } else {
941
+ this.#internals.setValidity({});
942
+ }
943
+ this.#renderErrors();
944
+ }
945
+
946
+ #renderErrors() {
947
+ const validity = this.#internals.validity;
948
+ const show = this.#showErrors && !validity.valid;
949
+ const stateMissing = !this.#state && (this.required || this.#showErrors);
950
+ const stateMessage = show && stateMissing ? 'Select a state.' : '';
951
+ const schoolMessage = show && !stateMissing ? this.#internals.validationMessage : '';
952
+
953
+ this.#stateError.textContent = stateMessage;
954
+ this.#stateError.hidden = !stateMessage;
955
+ this.#stateSelect.setAttribute('aria-invalid', String(Boolean(stateMessage)));
956
+
957
+ this.#schoolError.textContent = schoolMessage;
958
+ this.#schoolError.hidden = !schoolMessage;
959
+ if (schoolMessage) this.#queryInput.setAttribute('aria-invalid', 'true');
960
+ else if (!this.#status.textContent || this.#status.dataset.tone !== 'error')
961
+ this.#queryInput.removeAttribute('aria-invalid');
962
+
963
+ this.#internals.states?.[show ? 'add' : 'delete']?.('user-invalid');
964
+ }
965
+ }
966
+
967
+ function describe(hit: SchoolSearchResult): string {
968
+ const parts: string[] = [];
969
+ const place = [hit.city, hit.state].filter(Boolean).join(', ');
970
+ if (place) parts.push(place);
971
+ const grades = formatGradeSpan(hit.gradeSpan);
972
+ if (grades) parts.push(grades);
973
+ if (hit.type) parts.push(hit.type.charAt(0).toUpperCase() + hit.type.slice(1));
974
+ return parts.join(' · ');
975
+ }
976
+
977
+ function formatGradeSpan(span: SchoolSearchResult['gradeSpan']): string {
978
+ if (!span?.low || !span?.high) return '';
979
+ const grade = (g: string) => (/^\d+$/.test(g) ? String(Number(g)) : g.toUpperCase());
980
+ const low = grade(span.low);
981
+ const high = grade(span.high);
982
+ return low === high ? `Grade ${low}` : `Grades ${low}–${high}`;
983
+ }
984
+
985
+ if (!customElements.get(TAG_NAME)) {
986
+ customElements.define(TAG_NAME, EdvSchoolSearchElement);
987
+ }
988
+
989
+ declare global {
990
+ interface HTMLElementTagNameMap {
991
+ 'edv-school-search': EdvSchoolSearchElement;
992
+ }
993
+ }