Skip to content

Commit 4db25e5

Browse files
committed
refactor(multiple): stabilize default state upon any type of interaction
1 parent 5ad259a commit 4db25e5

File tree

19 files changed

+415
-79
lines changed

19 files changed

+415
-79
lines changed

src/aria/listbox/listbox.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import {LISTBOX} from './tokens';
6464
'[attr.aria-activedescendant]': '_pattern.activeDescendant()',
6565
'(keydown)': '_pattern.onKeydown($event)',
6666
'(pointerdown)': '_pattern.onPointerdown($event)',
67-
'(focusin)': '_onFocus()',
67+
'(focusin)': '_pattern.onFocusIn()',
6868
},
6969
hostDirectives: [ComboboxPopup],
7070
providers: [{provide: LISTBOX, useExisting: Listbox}],
@@ -139,9 +139,6 @@ export class Listbox<V> {
139139
/** The Listbox UIPattern. */
140140
readonly _pattern: ListboxPattern<V>;
141141

142-
/** Whether the listbox has received focus yet. */
143-
private _hasFocused = signal(false);
144-
145142
constructor() {
146143
const inputs = {
147144
...this,
@@ -171,9 +168,7 @@ export class Listbox<V> {
171168
});
172169

173170
afterRenderEffect(() => {
174-
if (!this._hasFocused()) {
175-
this._pattern.setDefaultState();
176-
}
171+
this._pattern.setDefaultStateEffect();
177172
});
178173

179174
// Ensure that if the active item is removed from
@@ -198,10 +193,6 @@ export class Listbox<V> {
198193
});
199194
}
200195

201-
_onFocus() {
202-
this._hasFocused.set(true);
203-
}
204-
205196
scrollActiveItemIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) {
206197
this._pattern.inputs.activeItem()?.element()?.scrollIntoView(options);
207198
}

src/aria/menu/menu-bar.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,7 @@ export class MenuBar<V> {
128128
});
129129

130130
afterRenderEffect(() => {
131-
if (!this._pattern.hasBeenFocused()) {
132-
this._pattern.setDefaultState();
133-
}
131+
this._pattern.setDefaultStateEffect();
134132
});
135133
}
136134

src/aria/menu/menu.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export class Menu<V> {
160160
this._deferredContentAware?.contentVisible.set(true);
161161
} else {
162162
this._deferredContentAware?.contentVisible.set(
163-
this._pattern.visible() || !!this.parent()?._pattern.hasBeenFocused(),
163+
this._pattern.visible() || !!this.parent()?._pattern.hasBeenInteracted(),
164164
);
165165
}
166166
});
@@ -177,13 +177,7 @@ export class Menu<V> {
177177
});
178178

179179
afterRenderEffect(() => {
180-
if (
181-
!this._pattern.hasBeenFocused() &&
182-
!this._pattern.hasBeenHovered() &&
183-
this._items().length
184-
) {
185-
untracked(() => this._pattern.setDefaultState());
186-
}
180+
this._pattern.setDefaultStateEffect();
187181
});
188182
}
189183

src/aria/private/combobox/combobox.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
166166
isFocused = signal(false);
167167

168168
/** Whether the combobox has ever been focused. */
169-
hasBeenFocused = signal(false);
169+
hasBeenInteracted = signal(false);
170170

171171
/** The key used to navigate to the previous item in the list. */
172172
expandKey = computed(() => (this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'));
@@ -367,13 +367,13 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
367367

368368
/** Handles focus in events for the combobox. */
369369
onFocusIn() {
370-
if (this.inputs.alwaysExpanded() && !this.hasBeenFocused()) {
370+
if (this.inputs.alwaysExpanded() && !this.hasBeenInteracted()) {
371371
const firstSelectedItem = this.listControls()?.getSelectedItems()[0];
372372
firstSelectedItem ? this.listControls()?.focus(firstSelectedItem) : this.first();
373373
}
374374

375375
this.isFocused.set(true);
376-
this.hasBeenFocused.set(true);
376+
this.hasBeenInteracted.set(true);
377377
}
378378

379379
/** Handles focus out events for the combobox. */

src/aria/private/grid/grid.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,4 +861,53 @@ describe('Grid', () => {
861861
});
862862
});
863863
});
864+
865+
describe('#setDefaultStateEffect', () => {
866+
it('should set default state if not interacted', () => {
867+
const gridInputs = getDefaultGridInputs();
868+
const data = [{cells: [{}, {}]}, {cells: [{}, {}]}];
869+
const {grid} = createGrid(data, gridInputs);
870+
const cells = grid.cells();
871+
cells[0][1].inputs.selected.set(true); // default state will pick this since it's selected
872+
grid.setDefaultStateEffect();
873+
expect(grid.gridBehavior.focusBehavior.activeCell()).toBe(cells[0][1]); // Should set to selected cell
874+
});
875+
876+
it('should NOT set default state if keyboard interacted', () => {
877+
const gridInputs = getDefaultGridInputs();
878+
const data = [{cells: [{}, {}]}, {cells: [{}, {}]}];
879+
const {grid} = createGrid(data, gridInputs);
880+
grid.onKeydown(down()); // Interaction
881+
882+
const cells = grid.cells();
883+
cells[0][1].inputs.selected.set(true);
884+
grid.setDefaultStateEffect();
885+
expect(grid.gridBehavior.focusBehavior.activeCell()).toBeUndefined(); // Should stay undefined, meaning default state was skipped
886+
});
887+
888+
it('should NOT set default state if pointer interacted', () => {
889+
const gridInputs = getDefaultGridInputs();
890+
const data = [{cells: [{}, {}]}, {cells: [{}, {}]}];
891+
const {grid} = createGrid(data, gridInputs);
892+
const cells = grid.cells();
893+
grid.onPointerdown({target: cells[0][0].element()} as unknown as PointerEvent); // Interaction
894+
895+
cells[0][1].inputs.selected.set(true);
896+
grid.setDefaultStateEffect();
897+
expect(grid.gridBehavior.focusBehavior.activeCell()).toBe(cells[0][0]); // Should stay on interacted cell
898+
});
899+
900+
it('should NOT set default state if focus-in occurred', () => {
901+
const gridInputs = getDefaultGridInputs();
902+
const data = [{cells: [{}, {}]}, {cells: [{}, {}]}];
903+
const {grid} = createGrid(data, gridInputs);
904+
905+
grid.onFocusIn({} as FocusEvent); // Interaction
906+
907+
const cells = grid.cells();
908+
cells[0][1].inputs.selected.set(true);
909+
grid.setDefaultStateEffect();
910+
expect(grid.gridBehavior.focusBehavior.activeCell()).toBeUndefined(); // Should stay undefined due to early return
911+
});
912+
});
864913
});

src/aria/private/grid/grid.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ export class GridPattern {
8080
/** Whether the focus is in the grid. */
8181
readonly isFocused = signal(false);
8282

83-
/** Whether the grid has been focused once. */
84-
readonly hasBeenFocused = signal(false);
83+
/** Whether the grid has received focus once. */
84+
readonly hasBeenInteracted = signal(false);
8585

8686
/** Whether the user is currently dragging to select a range of cells. */
8787
readonly dragging = signal(false);
@@ -252,6 +252,7 @@ export class GridPattern {
252252
onKeydown(event: KeyboardEvent) {
253253
if (this.disabled()) return;
254254

255+
this.hasBeenInteracted.set(true);
255256
this.activeCell()?.onKeydown(event);
256257
this.keydown().handle(event);
257258
}
@@ -260,6 +261,7 @@ export class GridPattern {
260261
onPointerdown(event: PointerEvent) {
261262
if (this.disabled()) return;
262263

264+
this.hasBeenInteracted.set(true);
263265
this.pointerdown().handle(event);
264266
}
265267

@@ -285,7 +287,7 @@ export class GridPattern {
285287
/** Handles focusin events on the grid. */
286288
onFocusIn(event: FocusEvent) {
287289
this.isFocused.set(true);
288-
this.hasBeenFocused.set(true);
290+
this.hasBeenInteracted.set(true);
289291

290292
// Skip if in the middle of range selection.
291293
if (this.dragging()) return;
@@ -328,7 +330,7 @@ export class GridPattern {
328330

329331
/** Sets the default active state of the grid before receiving focus the first time. */
330332
setDefaultStateEffect(): void {
331-
if (this.hasBeenFocused()) return;
333+
if (this.hasBeenInteracted()) return;
332334

333335
this.gridBehavior.setDefaultState();
334336
}

src/aria/private/listbox/listbox.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,5 +861,42 @@ describe('Listbox Pattern', () => {
861861
listbox.setDefaultState();
862862
expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]);
863863
});
864+
865+
describe('#setDefaultStateEffect', () => {
866+
it('should set default state if not interacted', () => {
867+
const {listbox, options} = getDefaultPatterns();
868+
listbox.inputs.values.set(['Banana']); // Banana is options[2]
869+
listbox.setDefaultStateEffect();
870+
expect(listbox.inputs.activeItem()).toBe(options[2]); // Should reset to selected Banana
871+
});
872+
873+
it('should NOT set default state if keyboard interacted', () => {
874+
const {listbox, options} = getDefaultPatterns();
875+
listbox.onKeydown(down()); // Interaction (ArrowDown moves to Apricot, options[1])
876+
877+
listbox.inputs.values.set(['Banana']); // Banana is options[2]
878+
listbox.setDefaultStateEffect();
879+
expect(listbox.inputs.activeItem()).toBe(options[1]); // Should stay on Apricot
880+
});
881+
882+
it('should NOT set default state if pointer interacted', () => {
883+
const {listbox, options} = getDefaultPatterns();
884+
const clickEvent = {target: options[1].element()} as any as PointerEvent;
885+
listbox.onPointerdown(clickEvent); // Interaction
886+
887+
listbox.inputs.values.set(['Banana']); // Banana is options[2]
888+
listbox.setDefaultStateEffect();
889+
expect(listbox.inputs.activeItem()).toBe(options[1]); // Should stay on Apricot
890+
});
891+
892+
it('should NOT set default state if focus-in interacted', () => {
893+
const {listbox, options} = getDefaultPatterns();
894+
listbox.onFocusIn(); // Interaction
895+
896+
listbox.inputs.values.set(['Banana']); // Banana is options[2]
897+
listbox.setDefaultStateEffect();
898+
expect(listbox.inputs.activeItem()).toBe(options[0]); // Should stay on Apple
899+
});
900+
});
864901
});
865902
});

src/aria/private/listbox/listbox.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export type ListboxInputs<V> = ListInputs<OptionPattern<V>, V> & {
2424
export class ListboxPattern<V> {
2525
listBehavior: List<OptionPattern<V>, V>;
2626

27+
/** Whether the listbox has been interacted with. */
28+
readonly hasBeenInteracted = signal(false);
29+
2730
/** Whether the list is vertically or horizontally oriented. */
2831
orientation: SignalLike<'vertical' | 'horizontal'>;
2932

@@ -218,16 +221,22 @@ export class ListboxPattern<V> {
218221
/** Handles keydown events for the listbox. */
219222
onKeydown(event: KeyboardEvent) {
220223
if (!this.disabled()) {
224+
this.hasBeenInteracted.set(true);
221225
this.keydown().handle(event);
222226
}
223227
}
224228

225229
onPointerdown(event: PointerEvent) {
226230
if (!this.disabled()) {
231+
this.hasBeenInteracted.set(true);
227232
this.pointerdown().handle(event);
228233
}
229234
}
230235

236+
onFocusIn() {
237+
this.hasBeenInteracted.set(true);
238+
}
239+
231240
/**
232241
* Sets the listbox to it's default initial state.
233242
*
@@ -258,6 +267,15 @@ export class ListboxPattern<V> {
258267
}
259268
}
260269

270+
/**
271+
* Sets the default active state of the listbox before receiving interaction for the first time.
272+
*/
273+
setDefaultStateEffect(): void {
274+
if (this.hasBeenInteracted()) return;
275+
276+
this.setDefaultState();
277+
}
278+
261279
protected _getItem(e: PointerEvent) {
262280
if (!(e.target instanceof HTMLElement)) {
263281
return;

src/aria/private/menu/menu.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,50 @@ describe('Standalone Menu Pattern', () => {
443443
expect(submenu.visible()).toBe(false);
444444
});
445445
});
446+
447+
describe('#setDefaultStateEffect', () => {
448+
it('should set default state if not interacted', () => {
449+
const items = menu.inputs.items() as TestMenuItem[];
450+
menu.inputs.activeItem.set(undefined);
451+
items[0].inputs.disabled.set(true);
452+
items[1].inputs.disabled.set(true);
453+
menu.setDefaultStateEffect();
454+
expect(menu.inputs.activeItem()?.id()).toBe(items[0].id()); // Should reset to item0 because softDisabled is true
455+
});
456+
457+
it('should NOT set default state if keyboard interacted', () => {
458+
const items = menu.inputs.items() as TestMenuItem[];
459+
menu.inputs.activeItem.set(undefined);
460+
menu.onKeydown(down()); // Interaction (ArrowDown moves to item0)
461+
462+
items[0].inputs.disabled.set(true);
463+
items[1].inputs.disabled.set(true);
464+
menu.setDefaultStateEffect();
465+
expect(menu.inputs.activeItem()).toBe(items[0]); // Should stay on item0
466+
});
467+
468+
it('should NOT set default state if pointer interacted', () => {
469+
const items = menu.inputs.items() as TestMenuItem[];
470+
menu.inputs.activeItem.set(undefined);
471+
menu.onMouseOver({target: items[0].element()} as unknown as MouseEvent); // Interaction
472+
473+
items[0].inputs.disabled.set(true);
474+
items[1].inputs.disabled.set(true);
475+
menu.setDefaultStateEffect();
476+
expect(menu.inputs.activeItem()).toBe(items[0]); // Should stay on item0
477+
});
478+
479+
it('should NOT set default state if focus-in occurred', () => {
480+
const items = menu.inputs.items() as TestMenuItem[];
481+
menu.inputs.activeItem.set(undefined);
482+
menu.onFocusIn(); // Interaction
483+
484+
items[0].inputs.disabled.set(true);
485+
items[1].inputs.disabled.set(true);
486+
menu.setDefaultStateEffect();
487+
expect(menu.inputs.activeItem()).toBeUndefined(); // Should stay undefined
488+
});
489+
});
446490
});
447491

448492
describe('Menu Trigger Pattern', () => {
@@ -918,5 +962,38 @@ describe('Menu Bar Pattern', () => {
918962
expect(menubar.inputs.activeItem()).toBe(menubarItems[0]);
919963
});
920964
});
965+
966+
describe('#setDefaultStateEffect', () => {
967+
it('should set default state if not interacted', () => {
968+
const items = menubar.inputs.items() as TestMenuItem[];
969+
menubar.inputs.activeItem.set(undefined);
970+
items[0].inputs.disabled.set(true);
971+
items[1].inputs.disabled.set(true);
972+
menubar.setDefaultStateEffect();
973+
expect(menubar.inputs.activeItem()?.id()).toBe(items[0].id()); // Should reset to item0 because softDisabled is true
974+
});
975+
976+
it('should NOT set default state if keyboard interacted', () => {
977+
const items = menubar.inputs.items() as TestMenuItem[];
978+
menubar.inputs.activeItem.set(undefined);
979+
menubar.onKeydown(down()); // Interaction (ArrowDown moves to item1)
980+
981+
items[0].inputs.disabled.set(true);
982+
items[1].inputs.disabled.set(true);
983+
menubar.setDefaultStateEffect();
984+
expect(menubar.inputs.activeItem()).toBeUndefined(); // Should stay undefined
985+
});
986+
987+
it('should NOT set default state if focus-in occurred', () => {
988+
const items = menubar.inputs.items() as TestMenuItem[];
989+
menubar.inputs.activeItem.set(undefined);
990+
menubar.onFocusIn(); // Interaction (stays on item0)
991+
992+
items[0].inputs.disabled.set(true);
993+
items[1].inputs.disabled.set(true);
994+
menubar.setDefaultStateEffect();
995+
expect(menubar.inputs.activeItem()).toBeUndefined(); // Should stay undefined
996+
});
997+
});
921998
});
922999
});

0 commit comments

Comments
 (0)