@gjsify/adwaita-web 0.15.1 → 0.16.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,243 @@
1
+ // DOM-level behaviour tests for <adw-data-grid>. Runs in a real browser via the
2
+ // @gjsify/adwaita-web browser test axis. Asserts column parsing + alignment
3
+ // classes, the derived grid tracks, per-cell rendering of pre-formatted strings,
4
+ // the accounting row variants (normal / section / subtotal / total), and the
5
+ // row-activated interaction (element-level + per-row, keyboard + pointer).
6
+ import { describe, it, expect } from '@gjsify/unit';
7
+
8
+ import type { AdwDataGrid, AdwDataGridColumn, AdwDataGridRow } from './elements/adw-data-grid.js';
9
+
10
+ function makeGrid(columns?: AdwDataGridColumn[], rows?: AdwDataGridRow[]): AdwDataGrid {
11
+ const el = document.createElement('adw-data-grid') as AdwDataGrid;
12
+ if (columns) el.columns = columns;
13
+ if (rows) el.rows = rows;
14
+ document.body.appendChild(el);
15
+ return el;
16
+ }
17
+
18
+ const BWA_COLUMNS: AdwDataGridColumn[] = [
19
+ { key: 'position', label: 'BWA-Position' },
20
+ { key: 'total', label: 'Σ Jahr', numeric: true },
21
+ ];
22
+
23
+ export const AdwDataGridTest = async () => {
24
+ await describe('adw-data-grid columns', async () => {
25
+ await it('renders one header cell per column with the label', async () => {
26
+ const grid = makeGrid(BWA_COLUMNS, []);
27
+ const header = grid.querySelector('.adw-data-grid-row.header');
28
+ expect(header).toBeTruthy();
29
+ const cells = grid.querySelectorAll('.adw-data-grid-row.header .adw-data-grid-cell');
30
+ expect(cells.length).toBe(2);
31
+ expect(cells[0].textContent).toBe('BWA-Position');
32
+ expect(cells[1].textContent).toBe('Σ Jahr');
33
+ grid.remove();
34
+ });
35
+
36
+ await it('falls back to the key when no label is given', async () => {
37
+ const grid = makeGrid([{ key: 'amount' }], []);
38
+ const cell = grid.querySelector('.adw-data-grid-row.header .adw-data-grid-cell');
39
+ expect(cell?.textContent).toBe('amount');
40
+ grid.remove();
41
+ });
42
+
43
+ await it('a numeric column right-aligns + gets the numeric class', async () => {
44
+ const grid = makeGrid(BWA_COLUMNS, []);
45
+ const cells = grid.querySelectorAll('.adw-data-grid-row.header .adw-data-grid-cell');
46
+ expect(cells[0].classList.contains('align-start')).toBe(true);
47
+ expect(cells[1].classList.contains('align-end')).toBe(true);
48
+ expect(cells[1].classList.contains('numeric')).toBe(true);
49
+ grid.remove();
50
+ });
51
+
52
+ await it('honours an explicit align + monospace flag', async () => {
53
+ const grid = makeGrid(
54
+ [
55
+ { key: 'a', align: 'center' },
56
+ { key: 'b', monospace: true },
57
+ ],
58
+ [],
59
+ );
60
+ const cells = grid.querySelectorAll('.adw-data-grid-row.header .adw-data-grid-cell');
61
+ expect(cells[0].classList.contains('align-center')).toBe(true);
62
+ expect(cells[1].classList.contains('mono')).toBe(true);
63
+ grid.remove();
64
+ });
65
+
66
+ await it('derives grid-template-columns from width / flex', async () => {
67
+ const grid = makeGrid(
68
+ [
69
+ { key: 'a', flex: 2 },
70
+ { key: 'b', width: '80px' },
71
+ ],
72
+ [],
73
+ );
74
+ const table = grid.querySelector('.adw-data-grid-table') as HTMLElement;
75
+ expect(table.style.gridTemplateColumns).toBe('2fr 80px');
76
+ grid.remove();
77
+ });
78
+
79
+ await it('defaults the first column to flexible so amounts hug the right edge', async () => {
80
+ const grid = makeGrid(BWA_COLUMNS, []);
81
+ const table = grid.querySelector('.adw-data-grid-table') as HTMLElement;
82
+ // The CSSOM serialises the track back as `minmax(0px, 1fr)`.
83
+ expect(table.style.gridTemplateColumns).toBe('minmax(0px, 1fr) auto');
84
+ grid.remove();
85
+ });
86
+
87
+ await it('parses columns + rows from the JSON attributes', async () => {
88
+ const grid = document.createElement('adw-data-grid') as AdwDataGrid;
89
+ grid.setAttribute('columns', '[{"key":"a","label":"A"},{"key":"b","numeric":true}]');
90
+ grid.setAttribute('rows', '[{"a":"x","b":"1,00 €"}]');
91
+ document.body.appendChild(grid);
92
+ expect(grid.columns.length).toBe(2);
93
+ expect(grid.rows.length).toBe(1);
94
+ const bodyCells = grid.querySelectorAll('.adw-data-grid-row:not(.header) .adw-data-grid-cell');
95
+ expect(bodyCells[0].textContent).toBe('x');
96
+ expect(bodyCells[1].textContent).toBe('1,00 €');
97
+ grid.remove();
98
+ });
99
+ });
100
+
101
+ await describe('adw-data-grid rows', async () => {
102
+ await it('renders a row + a cell per column with the pre-formatted string', async () => {
103
+ const grid = makeGrid(BWA_COLUMNS, [
104
+ { position: 'Umsatzerlöse', total: '120.000,00 €' },
105
+ { position: 'Sonstige Erträge', total: '5.000,00 €' },
106
+ ]);
107
+ const rows = grid.querySelectorAll('.adw-data-grid-row:not(.header)');
108
+ expect(rows.length).toBe(2);
109
+ const firstCells = rows[0].querySelectorAll('.adw-data-grid-cell');
110
+ expect(firstCells.length).toBe(2);
111
+ expect(firstCells[0].textContent).toBe('Umsatzerlöse');
112
+ expect(firstCells[1].textContent).toBe('120.000,00 €');
113
+ grid.remove();
114
+ });
115
+
116
+ await it('coerces a numeric cell value to a string', async () => {
117
+ const grid = makeGrid([{ key: 'qty', numeric: true }], [{ qty: 3 }]);
118
+ const cell = grid.querySelector('.adw-data-grid-row:not(.header) .adw-data-grid-cell');
119
+ expect(cell?.textContent).toBe('3');
120
+ grid.remove();
121
+ });
122
+
123
+ await it('renders an empty cell for a missing key', async () => {
124
+ const grid = makeGrid(BWA_COLUMNS, [{ position: 'Only label' }]);
125
+ const cells = grid.querySelectorAll('.adw-data-grid-row:not(.header) .adw-data-grid-cell');
126
+ expect(cells[1].textContent).toBe('');
127
+ grid.remove();
128
+ });
129
+ });
130
+
131
+ await describe('adw-data-grid variants', async () => {
132
+ await it('a section row spans all columns with the first column value', async () => {
133
+ const grid = makeGrid(BWA_COLUMNS, [{ variant: 'section', position: 'Betriebseinnahmen' }]);
134
+ const row = grid.querySelector('.adw-data-grid-row.variant-section') as HTMLElement;
135
+ expect(row).toBeTruthy();
136
+ const cells = row.querySelectorAll('.adw-data-grid-cell');
137
+ expect(cells.length).toBe(1);
138
+ expect(cells[0].textContent).toBe('Betriebseinnahmen');
139
+ expect((cells[0] as HTMLElement).style.gridColumn).toBe('1 / -1');
140
+ grid.remove();
141
+ });
142
+
143
+ await it('tags subtotal + total rows with their variant class', async () => {
144
+ const grid = makeGrid(BWA_COLUMNS, [
145
+ { variant: 'subtotal', position: 'Gesamtleistung', total: '125.000,00 €' },
146
+ { variant: 'total', position: 'Betriebsergebnis', total: '42.000,00 €' },
147
+ ]);
148
+ expect(grid.querySelector('.adw-data-grid-row.variant-subtotal')).toBeTruthy();
149
+ expect(grid.querySelector('.adw-data-grid-row.variant-total')).toBeTruthy();
150
+ // A subtotal / total keeps the full per-column layout (not spanning).
151
+ const subtotalCells = grid.querySelectorAll('.variant-subtotal .adw-data-grid-cell');
152
+ expect(subtotalCells.length).toBe(2);
153
+ grid.remove();
154
+ });
155
+
156
+ await it('an unknown / absent variant falls back to normal', async () => {
157
+ const grid = makeGrid(BWA_COLUMNS, [
158
+ { position: 'x' },
159
+ { variant: 'bogus', position: 'y' } as unknown as AdwDataGridRow,
160
+ ]);
161
+ const normals = grid.querySelectorAll('.adw-data-grid-row.variant-normal');
162
+ expect(normals.length).toBe(2);
163
+ grid.remove();
164
+ });
165
+ });
166
+
167
+ await describe('adw-data-grid row-activated', async () => {
168
+ await it('element-level interactive makes normal rows clickable + fires {index,row}', async () => {
169
+ const rows: AdwDataGridRow[] = [
170
+ { position: 'Row A', total: '1,00 €' },
171
+ { position: 'Row B', total: '2,00 €' },
172
+ ];
173
+ const grid = makeGrid(BWA_COLUMNS, rows);
174
+ grid.interactive = true;
175
+ let detail: { index: number; row: AdwDataGridRow } | null = null;
176
+ grid.addEventListener('row-activated', (e) => {
177
+ detail = (e as CustomEvent).detail;
178
+ });
179
+ const interactiveRows = grid.querySelectorAll('.adw-data-grid-row.interactive');
180
+ expect(interactiveRows.length).toBe(2);
181
+ (interactiveRows[1] as HTMLElement).click();
182
+ expect(detail).toBeTruthy();
183
+ expect(detail!.index).toBe(1);
184
+ expect(detail!.row.position).toBe('Row B');
185
+ grid.remove();
186
+ });
187
+
188
+ await it('section / subtotal / total rows never activate', async () => {
189
+ const grid = makeGrid(BWA_COLUMNS, [
190
+ { variant: 'section', position: 'Sec' },
191
+ { variant: 'subtotal', position: 'Sub', total: '1,00 €' },
192
+ { variant: 'total', position: 'Tot', total: '2,00 €' },
193
+ { position: 'Data', total: '3,00 €' },
194
+ ]);
195
+ grid.interactive = true;
196
+ expect(grid.querySelectorAll('.adw-data-grid-row.interactive').length).toBe(1);
197
+ grid.remove();
198
+ });
199
+
200
+ await it('a per-row interactive flag opts a single row in / out', async () => {
201
+ const grid = makeGrid(BWA_COLUMNS, [
202
+ { position: 'clickable', interactive: true },
203
+ { position: 'not clickable' },
204
+ ]);
205
+ // No element-level flag: only the opted-in row is interactive.
206
+ const interactiveRows = grid.querySelectorAll('.adw-data-grid-row.interactive');
207
+ expect(interactiveRows.length).toBe(1);
208
+ expect(interactiveRows[0].querySelector('.adw-data-grid-cell')?.textContent).toBe('clickable');
209
+ grid.remove();
210
+ });
211
+
212
+ await it('a per-row interactive:false opts out even when the element is interactive', async () => {
213
+ const grid = makeGrid(BWA_COLUMNS, [{ position: 'a' }, { position: 'b', interactive: false }]);
214
+ grid.interactive = true;
215
+ expect(grid.querySelectorAll('.adw-data-grid-row.interactive').length).toBe(1);
216
+ grid.remove();
217
+ });
218
+
219
+ await it('Enter activates a focused interactive row', async () => {
220
+ const grid = makeGrid(BWA_COLUMNS, [{ position: 'Row A', total: '1,00 €' }]);
221
+ grid.interactive = true;
222
+ let fired = 0;
223
+ grid.addEventListener('row-activated', () => fired++);
224
+ const row = grid.querySelector('.adw-data-grid-row.interactive') as HTMLElement;
225
+ expect(row.tabIndex).toBe(0);
226
+ row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
227
+ expect(fired).toBe(1);
228
+ grid.remove();
229
+ });
230
+ });
231
+
232
+ await describe('adw-data-grid caption', async () => {
233
+ await it('shows the caption from the attribute + hides it when empty', async () => {
234
+ const grid = makeGrid(BWA_COLUMNS, []);
235
+ const caption = grid.querySelector('.adw-data-grid-caption') as HTMLElement;
236
+ expect(caption.hidden).toBe(true);
237
+ grid.caption = 'Betriebswirtschaftliche Auswertung';
238
+ expect(caption.hidden).toBe(false);
239
+ expect(caption.textContent).toBe('Betriebswirtschaftliche Auswertung');
240
+ grid.remove();
241
+ });
242
+ });
243
+ };
@@ -0,0 +1,169 @@
1
+ // DOM-level behaviour tests for <adw-dialog>. Runs in a real browser via the
2
+ // @gjsify/adwaita-web browser test axis (tests/browser Playwright harness). The
3
+ // custom elements are registered by importing the package root in test.browser.mts;
4
+ // these specs create elements via document.createElement and assert their DOM
5
+ // behaviour (present/close events, can-close gating, header/content, focus return).
6
+ import { describe, it, expect } from '@gjsify/unit';
7
+
8
+ import type { AdwDialog } from './elements/adw-dialog.js';
9
+
10
+ function makeDialog(html = '<p>Body</p>'): AdwDialog {
11
+ const el = document.createElement('adw-dialog') as AdwDialog;
12
+ el.innerHTML = html;
13
+ document.body.appendChild(el);
14
+ return el;
15
+ }
16
+
17
+ export const AdwDialogTest = async () => {
18
+ await describe('adw-dialog present/close', async () => {
19
+ await it('present() reveals + fires notify::open(true)', async () => {
20
+ const dialog = makeDialog();
21
+ let notified: boolean | null = null;
22
+ dialog.addEventListener('notify::open', (e) => {
23
+ notified = (e as CustomEvent).detail.open;
24
+ });
25
+ expect(dialog.open).toBe(false);
26
+ dialog.present();
27
+ expect(dialog.open).toBe(true);
28
+ expect(dialog.classList.contains('open')).toBe(true);
29
+ expect(notified).toBe(true);
30
+ dialog.remove();
31
+ });
32
+
33
+ await it('close() hides + fires notify::open(false) then closed', async () => {
34
+ const dialog = makeDialog();
35
+ dialog.present();
36
+ const order: string[] = [];
37
+ dialog.addEventListener('notify::open', (e) => {
38
+ order.push(`notify:${(e as CustomEvent).detail.open}`);
39
+ });
40
+ dialog.addEventListener('closed', () => order.push('closed'));
41
+ dialog.close();
42
+ expect(dialog.open).toBe(false);
43
+ expect(order).toStrictEqual(['notify:false', 'closed']);
44
+ dialog.remove();
45
+ });
46
+
47
+ await it('present() on a disconnected element attaches it to the body', async () => {
48
+ const dialog = document.createElement('adw-dialog') as AdwDialog;
49
+ expect(dialog.isConnected).toBe(false);
50
+ dialog.present();
51
+ expect(dialog.isConnected).toBe(true);
52
+ expect(dialog.open).toBe(true);
53
+ dialog.remove();
54
+ });
55
+ });
56
+
57
+ await describe('adw-dialog can-close gating', async () => {
58
+ await it('close() while locked raises close-attempt and stays open', async () => {
59
+ const dialog = makeDialog();
60
+ dialog.canClose = false;
61
+ dialog.present();
62
+ let attempts = 0;
63
+ let closed = 0;
64
+ dialog.addEventListener('close-attempt', () => attempts++);
65
+ dialog.addEventListener('closed', () => closed++);
66
+ dialog.close();
67
+ expect(dialog.open).toBe(true);
68
+ expect(attempts).toBe(1);
69
+ expect(closed).toBe(0);
70
+ dialog.remove();
71
+ });
72
+
73
+ await it('forceClose() closes even while locked', async () => {
74
+ const dialog = makeDialog();
75
+ dialog.canClose = false;
76
+ dialog.present();
77
+ let closed = 0;
78
+ dialog.addEventListener('closed', () => closed++);
79
+ dialog.forceClose();
80
+ expect(dialog.open).toBe(false);
81
+ expect(closed).toBe(1);
82
+ dialog.remove();
83
+ });
84
+
85
+ await it('Escape on the box dismisses when unlocked, is gated when locked', async () => {
86
+ const dialog = makeDialog();
87
+ dialog.present();
88
+ const box = dialog.querySelector('.adw-dialog-box') as HTMLElement;
89
+ box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
90
+ expect(dialog.open).toBe(false);
91
+
92
+ const locked = makeDialog();
93
+ locked.canClose = false;
94
+ locked.present();
95
+ const lockedBox = locked.querySelector('.adw-dialog-box') as HTMLElement;
96
+ let attempts = 0;
97
+ locked.addEventListener('close-attempt', () => attempts++);
98
+ lockedBox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
99
+ expect(locked.open).toBe(true);
100
+ expect(attempts).toBe(1);
101
+ dialog.remove();
102
+ locked.remove();
103
+ });
104
+ });
105
+
106
+ await describe('adw-dialog content + header', async () => {
107
+ await it('moves author markup into the content area', async () => {
108
+ const dialog = makeDialog('<p id="hello">Hello</p>');
109
+ const content = dialog.querySelector('.adw-dialog-content');
110
+ expect(content?.querySelector('#hello')?.textContent).toBe('Hello');
111
+ });
112
+
113
+ await it('renders the header only when a title is set', async () => {
114
+ const dialog = makeDialog();
115
+ const header = dialog.querySelector('.adw-dialog-header') as HTMLElement;
116
+ expect(header.hidden).toBe(true);
117
+ dialog.title = 'Settings';
118
+ expect(header.hidden).toBe(false);
119
+ expect(dialog.querySelector('.adw-dialog-title')?.textContent).toBe('Settings');
120
+ dialog.remove();
121
+ });
122
+
123
+ await it('reflects presentation-mode + content-width', async () => {
124
+ const dialog = makeDialog();
125
+ expect(dialog.dataset.presentation).toBe('auto');
126
+ dialog.presentationMode = 'bottom-sheet';
127
+ expect(dialog.dataset.presentation).toBe('bottom-sheet');
128
+ dialog.contentWidth = 500;
129
+ const box = dialog.querySelector('.adw-dialog-box') as HTMLElement;
130
+ expect(box.style.getPropertyValue('--adw-dialog-content-width')).toBe('500px');
131
+ dialog.remove();
132
+ });
133
+
134
+ await it('the close button reflects the locked state', async () => {
135
+ const dialog = makeDialog();
136
+ dialog.title = 'X';
137
+ const closeBtn = dialog.querySelector('.adw-dialog-close') as HTMLButtonElement;
138
+ expect(closeBtn.disabled).toBe(false);
139
+ dialog.canClose = false;
140
+ expect(closeBtn.disabled).toBe(true);
141
+ expect(closeBtn.classList.contains('locked')).toBe(true);
142
+ dialog.remove();
143
+ });
144
+ });
145
+
146
+ await describe('adw-dialog focus handling', async () => {
147
+ await it('returns focus to the opener on close', async () => {
148
+ const opener = document.createElement('button');
149
+ opener.textContent = 'Open';
150
+ document.body.appendChild(opener);
151
+ opener.focus();
152
+ expect(document.activeElement).toBe(opener);
153
+
154
+ const dialog = makeDialog('<button id="inner">Inner</button>');
155
+ dialog.present();
156
+ // Move focus into the dialog explicitly (present() also does this, but
157
+ // focusing a just-shown element in the same tick is layout-timing
158
+ // sensitive under headless Chrome — so drive it deterministically here).
159
+ (dialog.querySelector('#inner') as HTMLButtonElement).focus();
160
+ // Close must return focus to the opener (never leave it trapped inside
161
+ // the dialog — the regression this guards).
162
+ dialog.close();
163
+ expect(dialog.contains(document.activeElement)).toBe(false);
164
+ expect(document.activeElement).toBe(opener);
165
+ dialog.remove();
166
+ opener.remove();
167
+ });
168
+ });
169
+ };
@@ -0,0 +1,186 @@
1
+ // DOM-level behaviour tests for <adw-drop-down>. Runs in a real browser via the
2
+ // @gjsify/adwaita-web browser test axis. Asserts option parsing, selected↔value
3
+ // sync, notify-on-change semantics, popover open/close, item selection, search
4
+ // filtering and the disabled guard.
5
+ import { describe, it, expect } from '@gjsify/unit';
6
+
7
+ import type { AdwDropDown } from './elements/adw-drop-down.js';
8
+
9
+ function makeDropDown(): AdwDropDown {
10
+ const el = document.createElement('adw-drop-down') as AdwDropDown;
11
+ document.body.appendChild(el);
12
+ return el;
13
+ }
14
+
15
+ export const AdwDropDownTest = async () => {
16
+ await describe('adw-drop-down options', async () => {
17
+ await it('accepts a string[] (value === label)', async () => {
18
+ const dd = makeDropDown();
19
+ dd.options = ['One', 'Two', 'Three'];
20
+ expect(dd.options.length).toBe(3);
21
+ expect(dd.options[0]).toStrictEqual({ value: 'One', label: 'One' });
22
+ expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('One');
23
+ dd.remove();
24
+ });
25
+
26
+ await it('accepts {value,label}[] and shows the selected label', async () => {
27
+ const dd = makeDropDown();
28
+ dd.options = [
29
+ { value: 'a', label: 'Apple' },
30
+ { value: 'b', label: 'Banana' },
31
+ ];
32
+ dd.selected = 1;
33
+ expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('Banana');
34
+ expect(dd.selectedValue).toBe('b');
35
+ dd.remove();
36
+ });
37
+
38
+ await it('parses options from the JSON attribute', async () => {
39
+ const dd = document.createElement('adw-drop-down') as AdwDropDown;
40
+ dd.setAttribute('options', '[{"value":"x","label":"Ex"},{"value":"y","label":"Why"}]');
41
+ dd.setAttribute('selected', '1');
42
+ document.body.appendChild(dd);
43
+ expect(dd.options.length).toBe(2);
44
+ expect(dd.selected).toBe(1);
45
+ expect(dd.selectedValue).toBe('y');
46
+ dd.remove();
47
+ });
48
+
49
+ await it('renders one popover item per option', async () => {
50
+ const dd = makeDropDown();
51
+ dd.options = ['a', 'b', 'c'];
52
+ expect(dd.querySelectorAll('.adw-drop-down-item').length).toBe(3);
53
+ dd.remove();
54
+ });
55
+ });
56
+
57
+ await describe('adw-drop-down selected ↔ value sync', async () => {
58
+ await it('selectedValue setter finds the index by value', async () => {
59
+ const dd = makeDropDown();
60
+ dd.options = [
61
+ { value: 'r', label: 'Red' },
62
+ { value: 'g', label: 'Green' },
63
+ { value: 'b', label: 'Blue' },
64
+ ];
65
+ dd.selectedValue = 'b';
66
+ expect(dd.selected).toBe(2);
67
+ expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('Blue');
68
+ dd.remove();
69
+ });
70
+
71
+ await it('selected reflects to the attribute + marks the row', async () => {
72
+ const dd = makeDropDown();
73
+ dd.options = ['a', 'b'];
74
+ dd.selected = 1;
75
+ expect(dd.getAttribute('selected')).toBe('1');
76
+ const items = dd.querySelectorAll('.adw-drop-down-item');
77
+ expect(items[1].classList.contains('selected')).toBe(true);
78
+ expect(items[1].getAttribute('aria-selected')).toBe('true');
79
+ dd.remove();
80
+ });
81
+
82
+ await it('selectedItem returns the descriptor', async () => {
83
+ const dd = makeDropDown();
84
+ dd.options = [{ value: 'k', label: 'Key' }];
85
+ expect(dd.selectedItem).toStrictEqual({ value: 'k', label: 'Key' });
86
+ dd.remove();
87
+ });
88
+
89
+ await it('setting options + selected before connect does not crash + applies on connect', async () => {
90
+ // Regression: the property setters ran before connectedCallback built
91
+ // the DOM, so _updateLabel touched an undefined label element.
92
+ const dd = document.createElement('adw-drop-down') as AdwDropDown;
93
+ dd.options = ['a', 'b', 'c'];
94
+ dd.selected = 2;
95
+ expect(dd.isConnected).toBe(false);
96
+ document.body.appendChild(dd);
97
+ expect(dd.selected).toBe(2);
98
+ expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('c');
99
+ expect(dd.querySelectorAll('.adw-drop-down-item').length).toBe(3);
100
+ dd.remove();
101
+ });
102
+ });
103
+
104
+ await describe('adw-drop-down notify-on-change', async () => {
105
+ await it('fires notify::selected + change on a real change only', async () => {
106
+ const dd = makeDropDown();
107
+ dd.options = ['a', 'b', 'c'];
108
+ let notifyCount = 0;
109
+ let lastChange: unknown = null;
110
+ dd.addEventListener('notify::selected', () => notifyCount++);
111
+ dd.addEventListener('change', (e) => {
112
+ lastChange = (e as CustomEvent).detail;
113
+ });
114
+ dd.selected = 2;
115
+ expect(notifyCount).toBe(1);
116
+ expect(lastChange).toStrictEqual({ index: 2, value: 'c', label: 'c' });
117
+ // Re-selecting the same index must NOT fire again.
118
+ dd.selected = 2;
119
+ expect(notifyCount).toBe(1);
120
+ dd.remove();
121
+ });
122
+
123
+ await it('a popover item click selects, fires change + closes', async () => {
124
+ const dd = makeDropDown();
125
+ dd.options = ['a', 'b', 'c'];
126
+ let change: unknown = null;
127
+ dd.addEventListener('change', (e) => {
128
+ change = (e as CustomEvent).detail;
129
+ });
130
+ (dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
131
+ expect(dd.active).toBe(true);
132
+ const items = dd.querySelectorAll('.adw-drop-down-item');
133
+ (items[1] as HTMLButtonElement).click();
134
+ expect(dd.selected).toBe(1);
135
+ expect(change).toStrictEqual({ index: 1, value: 'b', label: 'b' });
136
+ expect(dd.active).toBe(false);
137
+ dd.remove();
138
+ });
139
+ });
140
+
141
+ await describe('adw-drop-down popover behaviour', async () => {
142
+ await it('opens on button click and closes on outside pointerdown', async () => {
143
+ const dd = makeDropDown();
144
+ dd.options = ['a', 'b'];
145
+ const button = dd.querySelector('.adw-drop-down-button') as HTMLButtonElement;
146
+ const popover = dd.querySelector('.adw-drop-down-popover') as HTMLElement;
147
+ expect(popover.hidden).toBe(true);
148
+ button.click();
149
+ expect(dd.active).toBe(true);
150
+ expect(popover.hidden).toBe(false);
151
+ // Outside pointerdown dismisses.
152
+ document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
153
+ expect(dd.active).toBe(false);
154
+ expect(popover.hidden).toBe(true);
155
+ dd.remove();
156
+ });
157
+
158
+ await it('a disabled dropdown does not open', async () => {
159
+ const dd = makeDropDown();
160
+ dd.options = ['a', 'b'];
161
+ dd.setAttribute('disabled', '');
162
+ (dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
163
+ expect(dd.active).toBe(false);
164
+ dd.remove();
165
+ });
166
+ });
167
+
168
+ await describe('adw-drop-down search', async () => {
169
+ await it('renders a search entry and filters the list', async () => {
170
+ const dd = makeDropDown();
171
+ dd.options = ['Apple', 'Banana', 'Cherry'];
172
+ dd.enableSearch = true;
173
+ const search = dd.querySelector('.adw-drop-down-search') as HTMLInputElement;
174
+ expect(search).toBeTruthy();
175
+ (dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
176
+ search.value = 'ban';
177
+ search.dispatchEvent(new Event('input', { bubbles: true }));
178
+ const visible = Array.from(dd.querySelectorAll('.adw-drop-down-item')).filter(
179
+ (b) => !(b as HTMLElement).hidden,
180
+ );
181
+ expect(visible.length).toBe(1);
182
+ expect(visible[0].textContent).toContain('Banana');
183
+ dd.remove();
184
+ });
185
+ });
186
+ };