@mk-kit/ui 0.37.0 → 0.38.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,834 @@
1
+ const NO_OP = async () => { };
2
+ /**
3
+ * A DOM element wrapped for tests: reads are synchronous, every interaction
4
+ * dispatches the same events a user would and then settles change detection
5
+ * (`fixture.detectChanges()` + `whenStable()`), so the next read sees the
6
+ * updated view.
7
+ */
8
+ class MkTestElement {
9
+ native;
10
+ settle;
11
+ constructor(
12
+ /** The underlying element — reach for it when a harness has no getter. */
13
+ native,
14
+ /** Flush change detection after an interaction. */
15
+ settle = NO_OP) {
16
+ this.native = native;
17
+ this.settle = settle;
18
+ }
19
+ /** Trimmed text content with whitespace collapsed. */
20
+ text() {
21
+ return (this.native.textContent ?? '').replace(/\s+/g, ' ').trim();
22
+ }
23
+ attr(name) {
24
+ return this.native.getAttribute(name);
25
+ }
26
+ hasClass(name) {
27
+ return this.native.classList.contains(name);
28
+ }
29
+ /** Read a DOM property (`value`, `checked`, `open`, …). */
30
+ prop(name) {
31
+ return this.native[name];
32
+ }
33
+ matches(selector) {
34
+ return this.native.matches(selector);
35
+ }
36
+ isFocused() {
37
+ return this.native.ownerDocument.activeElement === this.native;
38
+ }
39
+ /** Native `disabled`, `aria-disabled="true"` or a `--disabled` host class. */
40
+ isDisabled() {
41
+ const el = this.native;
42
+ return (el.disabled === true ||
43
+ el.getAttribute('aria-disabled') === 'true' ||
44
+ Array.from(el.classList).some((c) => c.endsWith('--disabled')));
45
+ }
46
+ query(selector) {
47
+ const el = this.native.querySelector(selector);
48
+ return el ? new MkTestElement(el, this.settle) : null;
49
+ }
50
+ queryAll(selector) {
51
+ return Array.from(this.native.querySelectorAll(selector)).map((el) => new MkTestElement(el, this.settle));
52
+ }
53
+ /** Like `query`, but throws a readable error when nothing matches. */
54
+ child(selector) {
55
+ const el = this.query(selector);
56
+ if (!el)
57
+ throw new Error(`Expected "${selector}" inside <${this.describe()}>.`);
58
+ return el;
59
+ }
60
+ /** pointerdown → mousedown → focus → pointerup → mouseup → click, like a user. */
61
+ async click() {
62
+ const el = this.native;
63
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
64
+ el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
65
+ el.focus();
66
+ el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true }));
67
+ el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
68
+ el.click();
69
+ await this.settle();
70
+ }
71
+ async hover() {
72
+ for (const type of ['pointerenter', 'mouseenter', 'pointerover', 'mouseover']) {
73
+ this.native.dispatchEvent(new MouseEvent(type, { bubbles: type.endsWith('over') }));
74
+ }
75
+ this.native.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
76
+ await this.settle();
77
+ }
78
+ async leave() {
79
+ for (const type of ['pointerleave', 'mouseleave', 'pointerout', 'mouseout']) {
80
+ this.native.dispatchEvent(new MouseEvent(type, { bubbles: type.endsWith('out') }));
81
+ }
82
+ await this.settle();
83
+ }
84
+ async focus() {
85
+ this.native.focus();
86
+ if (!this.isFocused())
87
+ this.native.dispatchEvent(new FocusEvent('focus'));
88
+ await this.settle();
89
+ }
90
+ async blur() {
91
+ this.native.blur();
92
+ this.native.dispatchEvent(new FocusEvent('blur'));
93
+ this.native.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
94
+ await this.settle();
95
+ }
96
+ /**
97
+ * Type into an input/textarea/contenteditable character by character —
98
+ * keydown, `value` append, `input`, keyup — then settle. Use `setValue`
99
+ * to replace the value in one go.
100
+ */
101
+ async type(text) {
102
+ const el = this.native;
103
+ el.focus();
104
+ for (const ch of text) {
105
+ const down = new KeyboardEvent('keydown', { key: ch, bubbles: true, cancelable: true });
106
+ el.dispatchEvent(down);
107
+ if (!down.defaultPrevented) {
108
+ if (el.isContentEditable)
109
+ el.textContent = (el.textContent ?? '') + ch;
110
+ else
111
+ el.value = (el.value ?? '') + ch;
112
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }));
113
+ }
114
+ el.dispatchEvent(new KeyboardEvent('keyup', { key: ch, bubbles: true }));
115
+ }
116
+ await this.settle();
117
+ }
118
+ /** Set the value of an input/textarea/select and fire `input` + `change`. */
119
+ async setValue(value) {
120
+ const el = this.native;
121
+ el.focus();
122
+ el.value = value;
123
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertReplacementText' }));
124
+ el.dispatchEvent(new Event('change', { bubbles: true }));
125
+ await this.settle();
126
+ }
127
+ async clear() {
128
+ const el = this.native;
129
+ el.focus();
130
+ el.value = '';
131
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }));
132
+ await this.settle();
133
+ }
134
+ /** Dispatch keydown/keyup for each key. Printable single characters are also typed. */
135
+ async sendKeys(...keys) {
136
+ for (const key of keys) {
137
+ const mods = parseKey(key);
138
+ const down = new KeyboardEvent('keydown', { ...mods, bubbles: true, cancelable: true });
139
+ this.native.dispatchEvent(down);
140
+ if (!down.defaultPrevented && mods.key.length === 1 && !mods.ctrlKey && !mods.metaKey) {
141
+ const el = this.native;
142
+ if ('value' in el && typeof el.value === 'string' && !el.isContentEditable && isTextField(el)) {
143
+ el.value += mods.key;
144
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, data: mods.key, inputType: 'insertText' }));
145
+ }
146
+ }
147
+ this.native.dispatchEvent(new KeyboardEvent('keyup', { ...mods, bubbles: true }));
148
+ }
149
+ await this.settle();
150
+ }
151
+ /** Dispatch any event on the element and settle. */
152
+ async dispatch(event) {
153
+ this.native.dispatchEvent(event);
154
+ await this.settle();
155
+ }
156
+ describe() {
157
+ const el = this.native;
158
+ const id = el.id ? `#${el.id}` : '';
159
+ const cls = el.classList.length ? `.${Array.from(el.classList).slice(0, 2).join('.')}` : '';
160
+ return `${el.tagName.toLowerCase()}${id}${cls}`;
161
+ }
162
+ }
163
+ /** `Control+a`, `Shift+Tab`, `Enter`, `a` → KeyboardEventInit. */
164
+ function parseKey(key) {
165
+ const parts = key.split('+');
166
+ const k = parts.pop() ?? key;
167
+ const has = (m) => parts.some((p) => p.toLowerCase() === m);
168
+ return {
169
+ key: k,
170
+ ctrlKey: has('control') || has('ctrl'),
171
+ metaKey: has('meta') || has('cmd'),
172
+ shiftKey: has('shift'),
173
+ altKey: has('alt'),
174
+ };
175
+ }
176
+ function isTextField(el) {
177
+ if (el instanceof HTMLTextAreaElement)
178
+ return true;
179
+ if (!(el instanceof HTMLInputElement))
180
+ return false;
181
+ return !['checkbox', 'radio', 'button', 'submit', 'reset', 'file', 'range', 'color'].includes(el.type);
182
+ }
183
+ /**
184
+ * Finds harnesses under a root element. Create one with
185
+ * `MkHarnessLoader.fromFixture(fixture)`; use `.document()` for content that
186
+ * mk-kit teleports into `document.body` (dialogs, toasts, menus, select
187
+ * panels) and `.within(el)` to scope lookups.
188
+ */
189
+ class MkHarnessLoader {
190
+ root;
191
+ settle;
192
+ constructor(root, settle) {
193
+ this.root = root;
194
+ this.settle = settle;
195
+ }
196
+ /**
197
+ * Loader rooted at the fixture's host element. Every interaction runs
198
+ * `fixture.detectChanges()` and awaits `fixture.whenStable()` afterwards,
199
+ * so it works in zoneless and zone-based TestBeds alike.
200
+ */
201
+ static fromFixture(fixture) {
202
+ const settle = async () => {
203
+ fixture.detectChanges();
204
+ await fixture.whenStable();
205
+ fixture.detectChanges();
206
+ };
207
+ return new MkHarnessLoader(fixture.nativeElement, settle);
208
+ }
209
+ /** Same settle function, rooted at `document.body` — for overlays. */
210
+ document() {
211
+ return new MkHarnessLoader(this.root.ownerDocument.body, this.settle);
212
+ }
213
+ within(root) {
214
+ return new MkHarnessLoader(root instanceof MkTestElement ? root.native : root, this.settle);
215
+ }
216
+ /** Wrap the root element itself. */
217
+ rootElement() {
218
+ return new MkTestElement(this.root, this.settle);
219
+ }
220
+ element(selector) {
221
+ const el = this.root.querySelector(selector) ?? (this.root.matches(selector) ? this.root : null);
222
+ if (!el)
223
+ throw new Error(`No element matches "${selector}" under <${this.root.tagName.toLowerCase()}>.`);
224
+ return new MkTestElement(el, this.settle);
225
+ }
226
+ elements(selector) {
227
+ return Array.from(this.root.querySelectorAll(selector)).map((el) => new MkTestElement(el, this.settle));
228
+ }
229
+ /** First matching harness; throws when there is none. */
230
+ async get(type, filters = {}) {
231
+ const [first] = await this.getAll(type, filters);
232
+ if (!first) {
233
+ throw new Error(`No ${type.name} found (host "${type.hostSelector}"${filters.selector ? `, selector "${filters.selector}"` : ''}${filters.text ? `, text ${String(filters.text)}` : ''}) under <${this.root.tagName.toLowerCase()}>.`);
234
+ }
235
+ return first;
236
+ }
237
+ async getOrNull(type, filters = {}) {
238
+ const [first] = await this.getAll(type, filters);
239
+ return first ?? null;
240
+ }
241
+ async has(type, filters = {}) {
242
+ return (await this.getAll(type, filters)).length > 0;
243
+ }
244
+ async getAll(type, filters = {}) {
245
+ await this.settle();
246
+ const hosts = new Set();
247
+ for (const part of type.hostSelector.split(',')) {
248
+ const sel = part.trim();
249
+ if (this.root.matches(sel))
250
+ hosts.add(this.root);
251
+ for (const el of Array.from(this.root.querySelectorAll(sel)))
252
+ hosts.add(el);
253
+ }
254
+ const out = [];
255
+ for (const host of hosts) {
256
+ if (filters.selector && !host.matches(filters.selector))
257
+ continue;
258
+ const el = new MkTestElement(host, this.settle);
259
+ if (filters.text !== undefined && !matchText(el.text(), filters.text))
260
+ continue;
261
+ out.push(new type(el, this));
262
+ }
263
+ // Document order.
264
+ out.sort((a, b) => (a.host.native.compareDocumentPosition(b.host.native) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
265
+ return out;
266
+ }
267
+ }
268
+ function matchText(actual, expected) {
269
+ return typeof expected === 'string' ? actual === expected : expected.test(actual);
270
+ }
271
+ /**
272
+ * Base class of every mk-kit harness. Subclasses declare `hostSelector`
273
+ * and expose user-level actions (`click()`, `selectOption()`, …) and reads
274
+ * (`isChecked()`, `text()`), so specs never depend on the component's DOM.
275
+ */
276
+ class MkHarness {
277
+ host;
278
+ loader;
279
+ static hostSelector = '';
280
+ constructor(
281
+ /** The component's host element. */
282
+ host, loader) {
283
+ this.host = host;
284
+ this.loader = loader;
285
+ }
286
+ settle() {
287
+ return this.loader.settle();
288
+ }
289
+ /** Query inside the host. */
290
+ q(selector) {
291
+ return this.host.query(selector);
292
+ }
293
+ qAll(selector) {
294
+ return this.host.queryAll(selector);
295
+ }
296
+ /** Query the whole document — for panels teleported to `document.body`. */
297
+ qDocument(selector) {
298
+ const el = this.host.native.ownerDocument.querySelector(selector);
299
+ return el ? new MkTestElement(el, this.host.settle) : null;
300
+ }
301
+ /** Element referenced by an `aria-controls` / `aria-owns` id on `from`. */
302
+ controlled(from, attribute = 'aria-controls') {
303
+ const id = from.attr(attribute);
304
+ if (!id)
305
+ return null;
306
+ const el = this.host.native.ownerDocument.getElementById(id);
307
+ return el ? new MkTestElement(el, this.host.settle) : null;
308
+ }
309
+ }
310
+ /** Resolve "by index or by label" arguments shared by option-style harnesses. */
311
+ function pickBy(items, which, label, what) {
312
+ const item = typeof which === 'number'
313
+ ? items[which]
314
+ : items.find((i) => matchText(label(i), which));
315
+ if (!item) {
316
+ throw new Error(`No ${what} ${typeof which === 'number' ? `at index ${which}` : `matching ${String(which)}`}. Available: ${items
317
+ .map((i) => JSON.stringify(label(i)))
318
+ .join(', ') || '(none)'}.`);
319
+ }
320
+ return item;
321
+ }
322
+
323
+ /** `button[mkButton]` / `a[mkButton]`. */
324
+ class MkButtonHarness extends MkHarness {
325
+ static hostSelector = 'button[mkButton], a[mkButton], .mk-button';
326
+ text() {
327
+ return this.host.text();
328
+ }
329
+ async click() {
330
+ await this.host.click();
331
+ }
332
+ async focus() {
333
+ await this.host.focus();
334
+ }
335
+ isDisabled() {
336
+ return this.host.isDisabled();
337
+ }
338
+ isLoading() {
339
+ return this.host.hasClass('mk-button--loading');
340
+ }
341
+ tone() {
342
+ return this.host.attr('data-tone');
343
+ }
344
+ /** `solid` | `soft` | `outline` | `ghost` | `link`. */
345
+ variant() {
346
+ const m = Array.from(this.host.native.classList).find((c) => /^mk-button--(solid|soft|outline|ghost|link)$/.test(c));
347
+ return m ? m.replace('mk-button--', '') : null;
348
+ }
349
+ size() {
350
+ const m = Array.from(this.host.native.classList).find((c) => /^mk-button--(sm|md|lg)$/.test(c));
351
+ return m ? m.replace('mk-button--', '') : null;
352
+ }
353
+ }
354
+ /** Native `input[mkInput]` / `textarea[mkInput]`. */
355
+ class MkInputHarness extends MkHarness {
356
+ static hostSelector = '[mkInput], .mk-input';
357
+ value() {
358
+ return this.host.prop('value') ?? '';
359
+ }
360
+ /** Replace the value (fires `input` + `change`). */
361
+ async setValue(value) {
362
+ await this.host.setValue(value);
363
+ }
364
+ /** Append text one character at a time (keydown / input / keyup). */
365
+ async type(text) {
366
+ await this.host.type(text);
367
+ }
368
+ async clear() {
369
+ await this.host.clear();
370
+ }
371
+ async focus() {
372
+ await this.host.focus();
373
+ }
374
+ async blur() {
375
+ await this.host.blur();
376
+ }
377
+ placeholder() {
378
+ return this.host.attr('placeholder') ?? '';
379
+ }
380
+ type_() {
381
+ return this.host.attr('type') ?? (this.host.native.tagName === 'TEXTAREA' ? 'textarea' : 'text');
382
+ }
383
+ isDisabled() {
384
+ return this.host.isDisabled();
385
+ }
386
+ isInvalid() {
387
+ return this.host.hasClass('mk-input--invalid') || this.host.attr('aria-invalid') === 'true';
388
+ }
389
+ isRequired() {
390
+ return this.host.prop('required') === true || this.host.attr('aria-required') === 'true';
391
+ }
392
+ isFocused() {
393
+ return this.host.isFocused();
394
+ }
395
+ }
396
+ /** `mk-checkbox`. */
397
+ class MkCheckboxHarness extends MkHarness {
398
+ static hostSelector = 'mk-checkbox';
399
+ get input() {
400
+ return this.host.child('input[type="checkbox"]');
401
+ }
402
+ label() {
403
+ return this.q('.mk-checkbox__text')?.text() ?? '';
404
+ }
405
+ isChecked() {
406
+ return this.input.prop('checked') === true;
407
+ }
408
+ isIndeterminate() {
409
+ return this.input.prop('indeterminate') === true;
410
+ }
411
+ isDisabled() {
412
+ return this.input.isDisabled();
413
+ }
414
+ isRequired() {
415
+ return this.input.prop('required') === true;
416
+ }
417
+ async toggle() {
418
+ await this.input.click();
419
+ }
420
+ async check() {
421
+ if (!this.isChecked())
422
+ await this.toggle();
423
+ }
424
+ async uncheck() {
425
+ if (this.isChecked())
426
+ await this.toggle();
427
+ }
428
+ async focus() {
429
+ await this.input.focus();
430
+ }
431
+ async blur() {
432
+ await this.input.blur();
433
+ }
434
+ }
435
+ /** `mk-switch`. */
436
+ class MkSwitchHarness extends MkHarness {
437
+ static hostSelector = 'mk-switch';
438
+ get control() {
439
+ return this.host.child('[role="switch"]');
440
+ }
441
+ label() {
442
+ return this.q('.mk-switch__label')?.text() ?? '';
443
+ }
444
+ isChecked() {
445
+ return this.control.attr('aria-checked') === 'true';
446
+ }
447
+ isDisabled() {
448
+ return this.control.isDisabled();
449
+ }
450
+ async toggle() {
451
+ await this.control.click();
452
+ }
453
+ async check() {
454
+ if (!this.isChecked())
455
+ await this.toggle();
456
+ }
457
+ async uncheck() {
458
+ if (this.isChecked())
459
+ await this.toggle();
460
+ }
461
+ async focus() {
462
+ await this.control.focus();
463
+ }
464
+ }
465
+ /** One `mk-radio` inside a group. */
466
+ class MkRadioHarness extends MkHarness {
467
+ static hostSelector = 'mk-radio';
468
+ label() {
469
+ return this.q('.mk-radio__label')?.text() ?? this.host.text();
470
+ }
471
+ isChecked() {
472
+ return this.host.attr('aria-checked') === 'true';
473
+ }
474
+ isDisabled() {
475
+ return this.host.isDisabled();
476
+ }
477
+ async select() {
478
+ await this.host.click();
479
+ }
480
+ async focus() {
481
+ await this.host.focus();
482
+ }
483
+ }
484
+ /** `mk-radio-group`. */
485
+ class MkRadioGroupHarness extends MkHarness {
486
+ static hostSelector = 'mk-radio-group';
487
+ radios() {
488
+ return this.loader.within(this.host).getAll(MkRadioHarness);
489
+ }
490
+ async labels() {
491
+ return (await this.radios()).map((r) => r.label());
492
+ }
493
+ /** Label of the checked radio, or `null`. */
494
+ async checkedLabel() {
495
+ return (await this.radios()).find((r) => r.isChecked())?.label() ?? null;
496
+ }
497
+ async checkedIndex() {
498
+ return (await this.radios()).findIndex((r) => r.isChecked());
499
+ }
500
+ /** Select by index, exact label or RegExp. */
501
+ async select(which) {
502
+ const radios = await this.radios();
503
+ await pickBy(radios, which, (r) => r.label(), 'radio').select();
504
+ }
505
+ isDisabled() {
506
+ return this.host.hasClass('mk-radio-group--disabled');
507
+ }
508
+ }
509
+ /** `mk-select` — opens the teleported listbox and picks options like a user. */
510
+ class MkSelectHarness extends MkHarness {
511
+ static hostSelector = 'mk-select';
512
+ get trigger() {
513
+ return this.host.child('[role="combobox"]');
514
+ }
515
+ /** Label of the selected option, or `''` when only the placeholder shows. */
516
+ valueText() {
517
+ const v = this.q('.mk-select__value');
518
+ return v && !v.hasClass('mk-select__value--placeholder') ? v.text() : '';
519
+ }
520
+ placeholder() {
521
+ return this.q('.mk-select__value--placeholder')?.text() ?? '';
522
+ }
523
+ isOpen() {
524
+ return this.trigger.attr('aria-expanded') === 'true';
525
+ }
526
+ isDisabled() {
527
+ return this.trigger.isDisabled();
528
+ }
529
+ isInvalid() {
530
+ return this.trigger.attr('aria-invalid') === 'true';
531
+ }
532
+ async open() {
533
+ if (!this.isOpen())
534
+ await this.trigger.click();
535
+ }
536
+ async close() {
537
+ if (this.isOpen())
538
+ await this.trigger.sendKeys('Escape');
539
+ if (this.isOpen())
540
+ await this.trigger.click();
541
+ }
542
+ async focus() {
543
+ await this.trigger.focus();
544
+ }
545
+ /** Options of the (opened) listbox. Opens it if needed. */
546
+ async options() {
547
+ const els = await this.optionElements();
548
+ return els.map((el) => ({
549
+ label: el.text(),
550
+ selected: el.attr('aria-selected') === 'true',
551
+ disabled: el.attr('aria-disabled') === 'true',
552
+ }));
553
+ }
554
+ /** Pick an option by index, exact label or RegExp (opens the list first). */
555
+ async selectOption(which) {
556
+ const els = await this.optionElements();
557
+ await pickBy(els, which, (el) => el.text(), 'option').click();
558
+ }
559
+ async optionElements() {
560
+ await this.open();
561
+ const list = this.controlled(this.trigger);
562
+ if (!list)
563
+ throw new Error('mk-select listbox did not open.');
564
+ return list.queryAll('[role="option"]');
565
+ }
566
+ }
567
+ /** `mk-form-field` — label / hint / error around any control. */
568
+ class MkFormFieldHarness extends MkHarness {
569
+ static hostSelector = 'mk-form-field';
570
+ label() {
571
+ return this.q('.mk-form-field__label-text')?.text() ?? '';
572
+ }
573
+ hint() {
574
+ return this.q('.mk-form-field__hint')?.text() ?? null;
575
+ }
576
+ /** The visible validation message, or `null` when the field is valid/untouched. */
577
+ error() {
578
+ return this.q('.mk-form-field__error')?.text() ?? null;
579
+ }
580
+ hasError() {
581
+ return this.error() !== null;
582
+ }
583
+ isRequired() {
584
+ return !!this.q('.mk-form-field__required');
585
+ }
586
+ /** The control inside the field, as a harness of the given type. */
587
+ control(type) {
588
+ return this.loader.within(this.host).get(type);
589
+ }
590
+ }
591
+
592
+ /** `mk-tabs`. */
593
+ class MkTabsHarness extends MkHarness {
594
+ static hostSelector = 'mk-tabs';
595
+ tabButtons() {
596
+ return this.qAll('[role="tab"]');
597
+ }
598
+ labels() {
599
+ return this.tabButtons().map((t) => t.text());
600
+ }
601
+ selectedIndex() {
602
+ return this.tabButtons().findIndex((t) => t.attr('aria-selected') === 'true');
603
+ }
604
+ selectedLabel() {
605
+ const i = this.selectedIndex();
606
+ return i >= 0 ? this.labels()[i] : null;
607
+ }
608
+ /** Select by index, exact label or RegExp. */
609
+ async select(which) {
610
+ await pickBy(this.tabButtons(), which, (t) => t.text(), 'tab').click();
611
+ }
612
+ isDisabled(which) {
613
+ return pickBy(this.tabButtons(), which, (t) => t.text(), 'tab').isDisabled();
614
+ }
615
+ /** The visible tab panel (`mk-tab` host). */
616
+ selectedPanel() {
617
+ const tab = this.tabButtons()[this.selectedIndex()];
618
+ return tab ? this.controlled(tab) : null;
619
+ }
620
+ /** Text of the visible panel. */
621
+ selectedPanelText() {
622
+ return this.selectedPanel()?.text() ?? '';
623
+ }
624
+ }
625
+ /**
626
+ * A `[mkMenuTriggerFor]` trigger and the `mk-menu` panel it opens. The host
627
+ * is matched by the trigger's `aria-haspopup="menu"` (the directive's
628
+ * attribute is an input binding, so it is not in the DOM).
629
+ */
630
+ class MkMenuHarness extends MkHarness {
631
+ static hostSelector = '[aria-haspopup="menu"]';
632
+ isOpen() {
633
+ return this.host.attr('aria-expanded') === 'true';
634
+ }
635
+ async open() {
636
+ if (!this.isOpen())
637
+ await this.host.click();
638
+ }
639
+ async close() {
640
+ if (this.isOpen())
641
+ await this.panel()?.sendKeys('Escape');
642
+ if (this.isOpen())
643
+ await this.host.click();
644
+ }
645
+ /** The teleported panel (`[role="menu"]`), or `null` while closed. */
646
+ panel() {
647
+ return this.controlled(this.host);
648
+ }
649
+ /** Item texts of the open menu (opens it if needed). Submenu panels excluded. */
650
+ async items() {
651
+ return (await this.itemElements()).map((i) => i.text());
652
+ }
653
+ /** Click an item by index, exact text or RegExp (opens the menu first). */
654
+ async clickItem(which) {
655
+ await pickBy(await this.itemElements(), which, (i) => i.text(), 'menu item').click();
656
+ }
657
+ async isItemDisabled(which) {
658
+ return pickBy(await this.itemElements(), which, (i) => i.text(), 'menu item').isDisabled();
659
+ }
660
+ async itemElements() {
661
+ await this.open();
662
+ const panel = this.panel();
663
+ if (!panel)
664
+ throw new Error('mk-menu panel did not open.');
665
+ return panel.queryAll('[role="menuitem"]');
666
+ }
667
+ }
668
+
669
+ /** One data row of `mk-table`. */
670
+ class MkTableRowHarness extends MkHarness {
671
+ static hostSelector = 'tr.mk-table__row';
672
+ /** Cell texts of the data columns (expand / select cells excluded). */
673
+ cells() {
674
+ return this.dataCells().map((c) => c.text());
675
+ }
676
+ cell(index) {
677
+ return this.cells()[index] ?? '';
678
+ }
679
+ isSelected() {
680
+ return this.host.hasClass('mk-table__row--selected');
681
+ }
682
+ isExpanded() {
683
+ return this.host.hasClass('mk-table__row--expanded') || this.host.attr('aria-expanded') === 'true';
684
+ }
685
+ async click() {
686
+ await this.host.click();
687
+ }
688
+ /** Toggle the selection checkbox (throws when the table is not `selectable`). */
689
+ async toggleSelected() {
690
+ const cb = await this.loader.within(this.host.child('.mk-table__td--select')).get(MkCheckboxHarness);
691
+ await cb.toggle();
692
+ }
693
+ /** Toggle the expand / tree control of the row. */
694
+ async toggleExpanded() {
695
+ const btn = this.q('.mk-table__td--expand button') ?? this.q('.mk-table__tree-toggle') ?? this.q('button[aria-expanded]');
696
+ if (!btn)
697
+ throw new Error('Row has no expand toggle (table is not expandable / has no children).');
698
+ await btn.click();
699
+ }
700
+ dataCells() {
701
+ return this.qAll('td.mk-table__td').filter((c) => !c.hasClass('mk-table__td--expand') && !c.hasClass('mk-table__td--select'));
702
+ }
703
+ }
704
+ /** `mk-table`. */
705
+ class MkTableHarness extends MkHarness {
706
+ static hostSelector = 'mk-table';
707
+ /** Header labels of the data columns, in display order. */
708
+ headers() {
709
+ return this.headerCells().map((th) => th.child('.mk-table__th-label').text());
710
+ }
711
+ rows() {
712
+ return this.loader.within(this.host).getAll(MkTableRowHarness);
713
+ }
714
+ async rowCount() {
715
+ return (await this.rows()).length;
716
+ }
717
+ /** Every data cell as text: `rows()[r].cells()`. */
718
+ async cellTexts() {
719
+ return (await this.rows()).map((r) => r.cells());
720
+ }
721
+ /** Click a sortable header (by index, exact label or RegExp). */
722
+ async sortBy(which) {
723
+ const th = pickBy(this.headerCells(), which, (h) => h.child('.mk-table__th-label').text(), 'column');
724
+ const btn = th.query('.mk-table__th-button');
725
+ if (!btn)
726
+ throw new Error(`Column "${th.text()}" is not sortable.`);
727
+ await btn.click();
728
+ }
729
+ /** `'ascending' | 'descending' | 'none' | null` (null = not sortable). */
730
+ sortDirection(which) {
731
+ return pickBy(this.headerCells(), which, (h) => h.child('.mk-table__th-label').text(), 'column').attr('aria-sort');
732
+ }
733
+ async selectedRowCount() {
734
+ return (await this.rows()).filter((r) => r.isSelected()).length;
735
+ }
736
+ /** Toggle the header "select all" checkbox. */
737
+ async toggleAll() {
738
+ const th = this.q('.mk-table__th--select');
739
+ if (!th)
740
+ throw new Error('Table is not selectable.');
741
+ await (await this.loader.within(th).get(MkCheckboxHarness)).toggle();
742
+ }
743
+ isEmpty() {
744
+ return !this.q('tr.mk-table__row');
745
+ }
746
+ headerCells() {
747
+ return this.qAll('th.mk-table__th').filter((th) => !th.hasClass('mk-table__th--expand') && !th.hasClass('mk-table__th--select'));
748
+ }
749
+ }
750
+
751
+ /**
752
+ * A dialog opened by `MkDialogService` (`open()`, `confirm()`, `alert()`,
753
+ * `prompt()`). Dialogs live in `document.body`, so look them up through
754
+ * `loader.document()`.
755
+ */
756
+ class MkDialogHarness extends MkHarness {
757
+ static hostSelector = '.mk-overlay-panel[role="dialog"], .mk-overlay-panel[role="alertdialog"]';
758
+ title() {
759
+ return this.q('.mk-dialog__title')?.text() ?? this.host.attr('aria-label') ?? '';
760
+ }
761
+ bodyText() {
762
+ return this.q('.mk-dialog__body')?.text() ?? this.host.text();
763
+ }
764
+ /** Texts of every button in the dialog (header close excluded). */
765
+ buttons() {
766
+ return this.buttonElements().map((b) => b.text());
767
+ }
768
+ /** Click a button by index, exact text or RegExp. */
769
+ async clickButton(which) {
770
+ await pickBy(this.buttonElements(), which, (b) => b.text(), 'dialog button').click();
771
+ }
772
+ /** The header × button. */
773
+ async close() {
774
+ const btn = this.q('.mk-dialog__close');
775
+ if (!btn)
776
+ throw new Error('Dialog has no close button (hideClose).');
777
+ await btn.click();
778
+ }
779
+ async pressEscape() {
780
+ await this.host.sendKeys('Escape');
781
+ }
782
+ /** The prompt dialog's text field, if any. */
783
+ input() {
784
+ return this.q('.mk-dialog__body input, .mk-dialog__body textarea');
785
+ }
786
+ buttonElements() {
787
+ return this.qAll('button').filter((b) => !b.hasClass('mk-dialog__close') && !b.hasClass('mk-dialog__grip') && !b.hasClass('mk-dialog__resizer'));
788
+ }
789
+ }
790
+ /** One toast shown by `MkToastService` (lives in `document.body` — use `loader.document()`). */
791
+ class MkToastHarness extends MkHarness {
792
+ static hostSelector = 'mk-toast';
793
+ title() {
794
+ return this.q('.mk-toast__title')?.text() ?? null;
795
+ }
796
+ message() {
797
+ return this.q('.mk-toast__message')?.text() ?? '';
798
+ }
799
+ tone() {
800
+ return this.host.attr('data-tone');
801
+ }
802
+ actionLabel() {
803
+ return this.q('.mk-toast__actions button')?.text() ?? null;
804
+ }
805
+ async clickAction() {
806
+ const btn = this.q('.mk-toast__actions button');
807
+ if (!btn)
808
+ throw new Error('Toast has no action.');
809
+ await btn.click();
810
+ }
811
+ async dismiss() {
812
+ const btn = this.q('.mk-toast__close');
813
+ if (!btn)
814
+ throw new Error('Toast is not dismissible.');
815
+ await btn.click();
816
+ }
817
+ }
818
+
819
+ /**
820
+ * `@mk-kit/ui/testing` — component test harnesses.
821
+ *
822
+ * Harnesses drive mk-kit components the way a user does (click the trigger,
823
+ * pick the option, read the label) so specs stay stable when a component's
824
+ * DOM changes. Zero dependencies beyond `@angular/core/testing`; works with
825
+ * zoneless and zone-based TestBeds. Not re-exported from the root
826
+ * `@mk-kit/ui` entry on purpose — it never belongs in an app bundle.
827
+ */
828
+
829
+ /**
830
+ * Generated bundle index. Do not edit.
831
+ */
832
+
833
+ export { MkButtonHarness, MkCheckboxHarness, MkDialogHarness, MkFormFieldHarness, MkHarness, MkHarnessLoader, MkInputHarness, MkMenuHarness, MkRadioGroupHarness, MkRadioHarness, MkSelectHarness, MkSwitchHarness, MkTableHarness, MkTableRowHarness, MkTabsHarness, MkTestElement, MkToastHarness, matchText, pickBy };
834
+ //# sourceMappingURL=mk-kit-ui-testing.mjs.map