tablo-ui 0.8.0

The Topcoat components Tablo renders: primitives synced from topcoat-ui-registry, plus its own composites.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
// Unit tests for `selects.js`.
//
// There is no JS test runner in this workspace — the assets are plain browser
// scripts loaded through `asset!` — so this runs on Node's built-in runner and
// reaches the script through the guarded `module.exports` at the bottom of the
// script:
//
//     cargo test -p tablo-ui            # renders and Rust-side assertions
//     node --test crates/tablo-ui/assets/selects.test.js
//
// What the cases protect:
// * the matching rule for the list, including the two easy
//   mistakes — case sensitivity and letting the placeholder eat the cap;
// * the hide decision: the native `<select>` leaves the display
//   only when the combobox that replaces it is wired, and stays the submitted
//   value carrier either way;
// * the single wiring pass. The document stand-in below records
//   the listeners `install()` registers and fires them the way a browser does
//   — every listener for the type, in registration order — so a duplicated
//   wiring block shows up as two `change` events per activation and two rows
//   moved per arrow key.

const test = require('node:test');
const assert = require('node:assert/strict');

const { listenerDocument } = require('./test-dom');

const SCRIPT = require.resolve('./selects.js');

// --- a document stand-in -----------------------------------------------------

// `selects.js` is a plain browser script: `install()` reads `document` from the
// global scope and every handler is document-delegated, so the stand-in has to
// be in place before the script is required. It is only as wide as the script
// needs: each node answers the selectors `partsOf` and the listbox read.
function standInDocument(filters) {
  return listenerDocument({
    activeElement: null,
    documentElement: {},
    querySelectorAll: (selector) =>
      selector === '[data-options-filter]' ? filters : [],
    // The `<li>`s `renderList` builds.
    createElement: () => listItem(),
  });
}

// A created `<li>`, as `renderList` fills it in.
function listItem() {
  const attrs = {};
  return {
    dataset: {},
    textContent: '',
    className: '',
    id: '',
    getAttribute: (name) => (name in attrs ? attrs[name] : null),
    setAttribute: (name, value) => {
      attrs[name] = value;
    },
    removeAttribute: (name) => {
      delete attrs[name];
    },
    scrollIntoView() {},
  };
}

// Load a fresh copy of the script against `document`. A fresh copy re-runs
// `install()`, so each case gets its own listener set and its own initial hide
// pass.
function load(document) {
  global.document = document;
  delete require.cache[SCRIPT];
  return require(SCRIPT);
}

// A searchable field as the server renders it: the combobox (a filter input
// over a listbox) beside the select primitive's wrapper `<span>`, which holds
// the native `<select>` and the chevron. `server` names the field the
// overflowed relationship fetches options for.
function searchableField({ server = null } = {}) {
  const options = [
    { value: '', textContent: '-- Select --' },
    { value: 'pk-ada', textContent: 'Ada Author' },
    { value: 'pk-alan', textContent: 'Alan Author' },
  ];
  const rows = options.slice(1).map((option) => {
    const attrs = { role: 'option', 'aria-selected': 'false' };
    return {
      dataset: { value: option.value },
      textContent: option.textContent,
      getAttribute: (name) => (name in attrs ? attrs[name] : null),
      setAttribute: (name, value) => {
        attrs[name] = value;
      },
      scrollIntoView() {},
    };
  });

  const combo = {};
  const wrap = {};
  const wrapAttrs = server
    ? { 'data-options-server': 'true', 'data-options-field': server }
    : {};
  const filterAttrs = {};
  const filter = {
    value: '',
    closest: (selector) =>
      selector === '[data-options-filter]' ? filter
        : selector === '[data-options-combobox]' ? combo
          : selector === '[data-select-filterable]' ? wrap
            : null,
    getAttribute: (name) => (name in filterAttrs ? filterAttrs[name] : null),
    setAttribute: (name, value) => {
      filterAttrs[name] = value;
    },
    removeAttribute: (name) => {
      delete filterAttrs[name];
    },
  };
  const list = {
    id: 'name-options-list',
    hidden: false,
    querySelector: (selector) => {
      if (selector === '[role="option"][aria-selected="true"]') {
        return rows.find((row) => row.getAttribute('aria-selected') === 'true') || null;
      }
      return selector === '[role="option"]' ? rows[0] || null : null;
    },
    querySelectorAll: (selector) => (selector === '[role="option"]' ? rows : []),
    replaceChildren: () => rows.splice(0),
    appendChild: (row) => rows.push(row),
  };
  combo.querySelector = (selector) =>
    selector === '[data-options-filter]' ? filter
      : selector === '[data-options-list]' ? list
        : null;
  const selectAttrs = {};
  const select = {
    value: '',
    options,
    required: false,
    events: [],
    getAttribute: (name) => (name in selectAttrs ? selectAttrs[name] : null),
    setAttribute: (name, value) => {
      selectAttrs[name] = value;
    },
    querySelectorAll: (selector) => (selector === 'option' ? options : []),
    dispatchEvent(event) {
      this.events.push(event);
      return true;
    },
  };
  // The primitive's wrapper `<span>`, which draws the chevron.
  const control = { hidden: false };
  select.parentElement = control;
  wrap.querySelector = (selector) => (selector === 'select' ? select : null);
  wrap.getAttribute = (name) => (name in wrapAttrs ? wrapAttrs[name] : null);
  wrap.hasAttribute = (name) => name in wrapAttrs;
  wrap.dataset = {};
  rows.forEach((row) => {
    row.closest = (selector) =>
      selector === '[data-options-list] [role="option"]' ? row
        : selector === '[data-options-combobox]' ? combo
          : selector === '[data-select-filterable]' ? wrap
            : null;
  });
  return { combo, wrap, filter, list, select, control, rows };
}

// A non-searchable field: the field wrapper, the select primitive's wrapper
// `<span>`, and a native `<select>` with no combobox over it.
function plainField() {
  const select = { value: '', required: true };
  const control = { hidden: false };
  select.parentElement = control;
  const wrap = { querySelector: (selector) => (selector === 'select' ? select : null) };
  return { wrap, select, control };
}

// The matching cases are pure; a document with no fields is enough to load the
// script.
const {
  choiceMatches,
  matchingOptions,
  MAX_LIST_ITEMS,
  preservedOption,
  shouldHideNativeSelect,
} = load(standInDocument([]));

const PLACEHOLDER = { value: '', label: '-- Select --', selected: false };
const ada = { value: 'pk-ada', label: 'Ada Author', selected: false };
const alan = { value: 'pk-alan', label: 'Alan Author', selected: false };
const june = { value: 'pk-june', label: 'June Writer', selected: false };
const options = [PLACEHOLDER, ada, alan, june];

const labels = (needle) => matchingOptions(options, needle, MAX_LIST_ITEMS).map((r) => r.label);

test('an empty needle offers everything and the placeholder', () => {
  assert.deepEqual(labels(''), ['-- Select --', 'Ada Author', 'Alan Author', 'June Writer']);
});

test('a needle narrows by label substring', () => {
  assert.deepEqual(labels('ada'), ['Ada Author']);
  assert.deepEqual(labels('author'), ['Ada Author', 'Alan Author']);
});

test('matching is case-insensitive, in both directions', () => {
  assert.deepEqual(labels('ADA'), ['Ada Author']);
  assert.deepEqual(labels('wRiTeR'), ['June Writer']);
});

test('the needle is trimmed before matching', () => {
  assert.deepEqual(labels('  june  '), ['June Writer']);
});

test('the placeholder is offered only while the needle is empty', () => {
  // While filtering it is not a match for anything, so it must not sit at the
  // top of a narrowed list pretending to be one.
  assert.equal(matchingOptions(options, 'ada', MAX_LIST_ITEMS)[0].label, 'Ada Author');
  assert.ok(!labels('ada').includes('-- Select --'));
});

test('the placeholder never counts against the cap', () => {
  // Otherwise a full page of options would collapse to limit - 1 real choices,
  // and the row that fell off would be invisible rather than merely last.
  const many = [PLACEHOLDER];
  for (let i = 0; i < 10; i += 1) {
    many.push({ value: `pk-${i}`, label: `Option ${i}`, selected: false });
  }
  const rows = matchingOptions(many, '', 3);
  assert.equal(rows.length, 4, 'the placeholder plus the cap');
  assert.equal(rows[0].label, '-- Select --');
  assert.deepEqual(rows.slice(1).map((r) => r.label), ['Option 0', 'Option 1', 'Option 2']);
});

test('selection state rides along, so the list can mark the current choice', () => {
  const chosen = [{ ...PLACEHOLDER }, { ...ada, selected: true }];
  const rows = matchingOptions(chosen, '', MAX_LIST_ITEMS);
  assert.equal(rows.find((r) => r.value === 'pk-ada').selected, true);
  assert.equal(rows.find((r) => r.value === '').selected, false);
});

test('no match yields an empty list, which the caller reports as such', () => {
  assert.deepEqual(labels('zzz'), []);
});

// --- the native select behind the combobox --------------------------

test("a multiple choice's row shows while its label holds the trimmed needle, in any case", () => {
  assert.equal(choiceMatches('Databases', ''), true, 'an empty needle shows every row');
  assert.equal(choiceMatches('Databases', '  BASE '), true);
  assert.equal(choiceMatches('Databases', 'engineering'), false);
});

test('the native select is hidden only when the combobox over it is wired', () => {
  const wired = { combo: {}, wrap: {}, filter: {}, list: {}, select: {} };
  assert.equal(shouldHideNativeSelect(wired), true);
  // A field the script cannot drive keeps the only control it has: no
  // combobox at all is a plain, non-searchable select.
  for (const part of ['combo', 'filter', 'list', 'select']) {
    assert.equal(
      shouldHideNativeSelect({ ...wired, [part]: null }),
      false,
      `must not hide without ${part}`,
    );
  }
});

test('a wired field hides the replaced control, not the field', () => {
  const world = searchableField();
  load(standInDocument([world.filter]));
  // The chevron rides in the select primitive's wrapper, so that wrapper is
  // what leaves the display; hiding the `<select>` alone would leave it.
  assert.equal(world.control.hidden, true, 'the replaced control leaves the display');
  assert.notEqual(world.wrap.hidden, true, 'the field wrapper keeps the combobox');
  assert.equal(world.select.options.length, 3, 'the select stays the value carrier');
});

test('hiding drops the required attribute the browser would block on', () => {
  const world = searchableField();
  world.select.required = true;
  load(standInDocument([world.filter]));
  // Constraint validation still runs for a `display: none` control, and a
  // failed one cannot take focus, so the browser refuses the submit before any
  // `submit` handler sees it.
  assert.equal(world.select.required, false, 'the unfocusable control is not required');
  assert.equal(
    world.filter.getAttribute('aria-required'),
    'true',
    'the combobox carries the field requiredness',
  );
});

test('a plain select keeps its control and its required attribute', () => {
  // A non-searchable field shares the document with the wired one, so the pass
  // really runs and really leaves it alone.
  const searchable = searchableField();
  const plain = plainField();
  load(standInDocument([searchable.filter]));
  assert.equal(searchable.control.hidden, true, 'the wired field hides its control');
  assert.notEqual(plain.control.hidden, true, 'nothing replaces a select with no combobox');
  assert.equal(plain.select.required, true, 'native validation stays on the visible control');
});

test('a swapped-in field is hidden again', () => {
  const world = searchableField();
  const document = standInDocument([world.filter]);
  const observers = [];
  global.MutationObserver = class {
    constructor(callback) {
      this.callback = callback;
      observers.push(this);
    }

    observe() {}
  };
  try {
    load(document);
    assert.equal(observers.length, 1, 'the script watches for swapped markup');
    // The swap replaces the field, so the server-rendered control is visible
    // again until the observer replays the hide pass on the new nodes.
    world.control.hidden = false;
    observers[0].callback([
      { addedNodes: [{ nodeType: 1, querySelectorAll: () => [world.filter] }] },
    ]);
    assert.equal(world.control.hidden, true, 'the swapped field is hidden again');
  } finally {
    delete global.MutationObserver;
  }
});

// --- one wiring pass -------------------------------------------------

test('every document listener is registered once', () => {
  const world = searchableField();
  const document = standInDocument([world.filter]);
  load(document);
  assert.deepEqual(
    document.types().sort().map((type) => `${type}:${document.listeners(type).length}`),
    ['change:1', 'click:1', 'focusin:1', 'focusout:1', 'input:1', 'keydown:1', 'mousedown:1'],
    'one listener per type, so one wiring block',
  );
});

test('an activation dispatches one change', () => {
  const world = searchableField();
  const document = standInDocument([world.filter]);
  load(document);
  // Picking a row: the mousedown handler writes the choice onto the select and
  // announces it. A second wiring block would run it twice.
  document.listeners('mousedown').forEach((handler) => {
    handler({ target: world.rows[0], preventDefault() {} });
  });
  assert.equal(world.select.events.length, 1, 'one change per activation');
  assert.equal(world.select.events[0].type, 'change');
  assert.equal(world.select.value, 'pk-ada');
});

test('an arrow key advances one row', () => {
  const world = searchableField();
  const document = standInDocument([world.filter]);
  load(document);
  // No row starts selected, so `activeItem` is the first: one ArrowDown lands
  // on the second. A second wiring block re-reads the live `aria-selected` and
  // advances again, which wraps to the first.
  document.listeners('keydown').forEach((handler) => {
    handler({ key: 'ArrowDown', target: world.filter, preventDefault() {} });
  });
  const selected = world.rows.filter((row) => row.getAttribute('aria-selected') === 'true');
  assert.equal(selected.length, 1, 'one row per arrow key');
  assert.equal(selected[0].dataset.value, 'pk-alan', 'the row after the first');
});

// --- the current option, the combobox ARIA, and Enter ---------------

test('an edit form shows the current option label in the box', () => {
  // The native select is hidden and the filter input is the box left in its
  // place, so an edit form opens on the record's stored label, not empty.
  const world = searchableField();
  world.select.value = 'pk-ada';
  load(standInDocument([world.filter]));
  assert.equal(world.filter.value, 'Ada Author');
});

test("entering an edit form's field lists every option, not only the current one", () => {
  const world = searchableField();
  world.select.value = 'pk-ada';
  const document = standInDocument([world.filter]);
  load(document);
  document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
  assert.ok(
    world.rows.some((row) => row.textContent === 'Alan Author'),
    'the box shows the current label, which filters nothing',
  );
  world.filter.value = 'Ala';
  document.listeners('input').forEach((handler) => handler({ target: world.filter }));
  assert.deepEqual(
    world.rows.map((row) => row.textContent),
    ['Alan Author'],
    'typed text still filters',
  );
});

test("the box carries the hidden select's error state and description", () => {
  const world = searchableField();
  world.select.setAttribute('aria-invalid', 'true');
  world.select.setAttribute('aria-describedby', 'author-error');
  load(standInDocument([world.filter]));
  assert.equal(world.filter.getAttribute('aria-invalid'), 'true');
  assert.equal(world.filter.getAttribute('aria-describedby'), 'author-error');
});

test('a field with no current option leaves the box empty', () => {
  // The placeholder is the empty value: it is not a choice to display.
  const world = searchableField();
  load(standInDocument([world.filter]));
  assert.equal(world.filter.value, '');
});

test('a swap keeps the current option under its own label', () => {
  // The server answers the needle, not the selection; the label exists only in
  // the option the swap drops, so re-attach it by label rather than showing the
  // primary key as its own.
  assert.equal(
    preservedOption('pk-ada', 'Ada Author', '<option value="pk-ken">Ken</option>'),
    '<option value="pk-ada" selected data-options-kept="">Ada Author</option>',
  );
});

test('a swap that already carries the current option does not duplicate it', () => {
  assert.equal(
    preservedOption('pk-ada', 'Ada Author', '<option value="pk-ada">Ada Author</option>'),
    null,
  );
});

test('an empty selection preserves nothing', () => {
  assert.equal(preservedOption('', 'Ada Author', ''), null);
});

test('a record label cannot break out of the option markup', () => {
  assert.equal(
    preservedOption('pk-1', 'A "quoted" <name>', ''),
    '<option value="pk-1" selected data-options-kept="">A &quot;quoted&quot; &lt;name&gt;</option>',
  );
});

test('opening the combobox expands it and names its active row', () => {
  const world = searchableField();
  const document = standInDocument([world.filter]);
  load(document);
  document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
  assert.equal(world.list.hidden, false, 'the list is showing');
  assert.equal(world.filter.getAttribute('aria-expanded'), 'true');
  assert.equal(
    world.filter.getAttribute('aria-activedescendant'),
    'name-options-list-option-0',
    'the first row is the active descendant',
  );
});

test('an arrow key moves the active descendant with the selection', () => {
  const world = searchableField();
  const document = standInDocument([world.filter]);
  load(document);
  document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
  document.listeners('keydown').forEach((handler) => {
    handler({ key: 'ArrowDown', target: world.filter, preventDefault() {} });
  });
  assert.equal(
    world.filter.getAttribute('aria-activedescendant'),
    'name-options-list-option-1',
    'the active row follows the arrow',
  );
});

// An Escape keydown on `world`'s filter, reporting whether it was prevented.
function escape(document, world) {
  const event = {
    key: 'Escape',
    target: world.filter,
    prevented: false,
    preventDefault() {
      this.prevented = true;
    },
  };
  document.listeners('keydown').forEach((handler) => handler(event));
  return event.prevented;
}

test('closing the combobox collapses it and clears the active row', () => {
  const world = searchableField();
  const document = standInDocument([world.filter]);
  load(document);
  document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
  // Unprevented, the keystroke would also close a dialog the field sits in.
  assert.equal(escape(document, world), true, 'Escape closes the list alone');
  assert.equal(world.list.hidden, true);
  assert.equal(world.filter.getAttribute('aria-expanded'), 'false');
  assert.equal(world.filter.getAttribute('aria-activedescendant'), null);
  assert.equal(escape(document, world), false, 'a closed list leaves Escape to the page');
});

test('Escape closes a list showing a status line, and the search landing keeps it closed', async () => {
  const world = searchableField({ server: 'author_id' });
  const document = standInDocument([world.filter]);
  const realSetTimeout = global.setTimeout;
  let search;
  global.setTimeout = (callback) => {
    search = callback;
    return 0;
  };
  global.window = { location: { pathname: '/admin/books/-/create' } };
  global.fetch = async () => ({ ok: false, headers: { get: () => null }, text: async () => '' });
  try {
    load(document);
    document.listeners('input').forEach((handler) => handler({ target: world.filter }));
    assert.equal(world.list.hidden, false, 'the status row shows the search');
    assert.equal(escape(document, world), true);
    assert.equal(world.list.hidden, true);
    await search();
    assert.equal(world.list.hidden, true, 'the landed search does not reopen the list');
  } finally {
    global.setTimeout = realSetTimeout;
    delete global.fetch;
    delete global.window;
  }
});

test('Enter while the combobox has focus never submits the form', () => {
  // The list is collapsed: the keystroke is still the combobox's, not the
  // form's implicit submit.
  const world = searchableField();
  const document = standInDocument([world.filter]);
  load(document);
  world.list.hidden = true;
  const event = {
    key: 'Enter',
    target: world.filter,
    prevented: false,
    preventDefault() {
      this.prevented = true;
    },
  };
  document.listeners('keydown').forEach((handler) => handler(event));
  assert.equal(event.prevented, true, 'the form must not submit');
  assert.equal(world.select.events.length, 0, 'nothing was chosen');
});

test('Enter while the server is searching does not submit', () => {
  // The status row ("Searching…") offers no option to pick, and the keystroke
  // must not submit the record the reader is editing.
  const world = searchableField({ server: 'author_id' });
  const document = standInDocument([world.filter]);
  const realSetTimeout = global.setTimeout;
  const realClearTimeout = global.clearTimeout;
  global.setTimeout = () => 0;
  global.clearTimeout = () => {};
  try {
    load(document);
    document.listeners('input').forEach((handler) => handler({ target: world.filter }));
    assert.equal(world.list.hidden, false, 'the status row shows the search');
    const event = {
      key: 'Enter',
      target: world.filter,
      prevented: false,
      preventDefault() {
        this.prevented = true;
      },
    };
    document.listeners('keydown').forEach((handler) => handler(event));
    assert.equal(event.prevented, true, 'the form must not submit');
    assert.equal(
      world.filter.getAttribute('aria-expanded'),
      'true',
      'the status popup is expanded',
    );
    assert.equal(
      world.filter.getAttribute('aria-activedescendant'),
      null,
      'a status line has no active row',
    );
  } finally {
    global.setTimeout = realSetTimeout;
    global.clearTimeout = realClearTimeout;
  }
});

// --- a server answer ---------------------------------------------------------

// An `<option>` of a server answer, as `optionRows` reads it.
function answered(value, textContent, attrs = {}) {
  return {
    value,
    textContent,
    disabled: 'disabled' in attrs,
    selected: 'selected' in attrs,
    hasAttribute: (name) => name in attrs,
  };
}

// The list a focus on `world`'s filter renders, holding `options` for `needle`.
function listFor(world, options, needle) {
  world.select.options.splice(0, world.select.options.length, ...options);
  world.list.replaceChildren = (...items) => {
    world.rows.splice(0, world.rows.length, ...items);
  };
  world.filter.value = needle;
  const document = standInDocument([world.filter]);
  load(document);
  document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
  return world.rows;
}

test('a server answer is offered whatever its labels hold', () => {
  // The server matched the needle on a column the label does not show.
  const world = searchableField({ server: 'author_id' });
  const rows = listFor(
    world,
    [
      answered('', '-- Select --'),
      answered('pk-ken', 'Ken Writer', { selected: '', 'data-options-kept': '' }),
      answered('pk-ada', 'Ada Author'),
    ],
    'ada@example.com',
  );
  assert.deepEqual(
    rows.map((row) => row.dataset.value),
    ['pk-ada'],
    'the kept current option is judged by its label',
  );
});

test("an overflowed server answer shows its status line, not 'no match'", () => {
  const world = searchableField({ server: 'author_id' });
  const status = answered('', 'Too many results', { disabled: '' });
  const rows = listFor(world, [answered('', '-- Select --'), status], 'a');
  assert.equal(rows.length, 1);
  assert.equal(rows[0].textContent, status.textContent);
  assert.equal(rows[0].getAttribute('role'), null, 'a status line is not an option');
});

// --- dependent choices -------------------------------------------------------

// A dependent `city_id` choice over a `country_id` parent, in one form. The
// select parses the markup a refresh writes into it, enough for `optionFor`.
function dependentWorld({ parentValue, current }) {
  const select = {
    value: current,
    options: [
      { value: '', textContent: '-- Select --', outerHTML: '<option value="">-- Select --</option>' },
      { value: current, textContent: current },
    ],
    events: [],
    querySelector(selector) {
      return selector === 'option[value=""]' ? this.options[0] : null;
    },
    set innerHTML(html) {
      this.options = Array.from(html.matchAll(/value="([^"]*)"/g), (m) => ({ value: m[1] }));
    },
    dispatchEvent(event) {
      this.events.push(event);
      return true;
    },
  };
  const attrs = {
    'data-options-parent': 'country_id',
    'data-options-field': 'city_id',
    'data-options-parent-value': 'fr',
  };
  const wrap = {
    getAttribute: (name) => (name in attrs ? attrs[name] : null),
    setAttribute: (name, value) => {
      attrs[name] = value;
    },
    removeAttribute: (name) => {
      delete attrs[name];
    },
    hasAttribute: (name) => name in attrs,
    contains: () => false,
    querySelector: (selector) => (selector === 'select' ? select : null),
  };
  const form = {
    querySelectorAll: (selector) => (selector === '[data-options-parent]' ? [wrap] : []),
  };
  const parent = { name: 'country_id', value: parentValue, form, closest: () => null };
  return { attrs, select, parent };
}

// Fire the parent's `change` and let the refresh land.
async function changeParent(document, parent) {
  document.listeners('change').forEach((handler) => handler({ target: parent }));
  await new Promise((resolve) => setImmediate(resolve));
}

test('a blank parent clears its dependent choice without a fetch', async () => {
  const world = dependentWorld({ parentValue: '', current: 'paris' });
  const document = standInDocument([]);
  load(document);
  global.fetch = () => assert.fail('a blank parent fetches nothing');
  try {
    await changeParent(document, world.parent);
  } finally {
    delete global.fetch;
  }
  assert.deepEqual(world.select.options.map((o) => o.value), ['']);
  assert.equal(world.select.value, '');
  assert.equal(world.attrs['data-options-parent-value'], '');
  assert.equal(world.select.events.length, 1, 'the dropped choice announces its change');
});

test('a parent value fetches the options it offers, keeping a choice among them', async () => {
  global.window = { location: { pathname: '/admin/addresses/-/create' } };
  const urls = [];
  global.fetch = async (url) => {
    urls.push(url);
    return {
      ok: true,
      headers: { get: () => null },
      text: async () => '<option value="paris">Paris</option><option value="lyon">Lyon</option>',
    };
  };
  try {
    const kept = dependentWorld({ parentValue: 'fr', current: 'paris' });
    const document = standInDocument([]);
    load(document);
    await changeParent(document, kept.parent);
    assert.deepEqual(urls, ['/admin/addresses/-/options?field=city_id&parent=fr']);
    assert.equal(kept.select.value, 'paris');
    assert.equal(kept.select.events.length, 0, 'a kept choice does not change');

    const dropped = dependentWorld({ parentValue: 'fr', current: 'berlin' });
    await changeParent(document, dropped.parent);
    assert.equal(dropped.select.value, '');
    assert.equal(dropped.select.events.length, 1, 'the dropped choice announces its change');
  } finally {
    delete global.fetch;
    delete global.window;
  }
});

// Fire the parent's change with `fetch` answering `answer`.
async function refreshWith(answer) {
  global.window = { location: { pathname: '/admin/addresses/-/create' } };
  global.fetch = answer;
  try {
    const world = dependentWorld({ parentValue: 'fr', current: 'paris' });
    const document = standInDocument([]);
    load(document);
    await changeParent(document, world.parent);
    return world;
  } finally {
    delete global.fetch;
    delete global.window;
  }
}

test('an overflowed answer offers a choice with no filter nothing to type into', async () => {
  const world = await refreshWith(async () => ({
    ok: true,
    headers: { get: (name) => (name === 'x-options-overflow' ? 'true' : null) },
    text: async () => '<option value="" disabled>Too many results — keep typing</option>',
  }));
  assert.deepEqual(world.select.options.map((o) => o.value), ['']);
  assert.equal(world.select.value, '');
  assert.equal(world.attrs['data-options-server'], undefined, 'no filter, no server search');
});

test('a failed fetch offers nothing rather than the old parent value\'s options', async () => {
  for (const answer of [
    async () => ({ ok: false, headers: { get: () => null }, text: async () => '' }),
    async () => {
      throw new TypeError('network');
    },
  ]) {
    const world = await refreshWith(answer);
    assert.deepEqual(world.select.options.map((o) => o.value), ['']);
    assert.equal(world.select.events.length, 1, 'the dropped choice announces its change');
  }
});

test('a choice whose wrapper names its options route fetches from it, not the page\'s', async () => {
  global.window = { location: { pathname: '/admin/addresses' } };
  const urls = [];
  global.fetch = async (url) => {
    urls.push(url);
    return { ok: true, headers: { get: () => null }, text: async () => '' };
  };
  try {
    const world = dependentWorld({ parentValue: 'fr', current: '' });
    world.attrs['data-options-url'] = '/admin/addresses/-/actions/move/options';
    const document = standInDocument([]);
    load(document);
    await changeParent(document, world.parent);
    assert.deepEqual(urls, ['/admin/addresses/-/actions/move/options?field=city_id&parent=fr']);
  } finally {
    delete global.fetch;
    delete global.window;
  }
});