Skip to content

Commit c6ffd70

Browse files
asynclizcopybara-github
authored andcommitted
feat(menu): add no-navigation-wrap to fix select accessibility
PiperOrigin-RevId: 610514684
1 parent ec0a8eb commit c6ffd70

File tree

4 files changed

+85
-12
lines changed

4 files changed

+85
-12
lines changed

list/internal/list-controller.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export interface ListControllerConfig<Item extends ListItem> {
7171
* disabled.
7272
*/
7373
isActivatable?: (item: Item) => boolean;
74+
/**
75+
* Whether or not navigating past the end of the list wraps to the beginning
76+
* and vice versa. Defaults to true.
77+
*/
78+
wrapNavigation?: () => boolean;
7479
}
7580

7681
/**
@@ -84,6 +89,7 @@ export class ListController<Item extends ListItem> {
8489
private readonly activateItem: (item: Item) => void;
8590
private readonly isNavigableKey: (key: string) => boolean;
8691
private readonly isActivatable?: (item: Item) => boolean;
92+
private readonly wrapNavigation: () => boolean;
8793

8894
constructor(config: ListControllerConfig<Item>) {
8995
const {
@@ -94,6 +100,7 @@ export class ListController<Item extends ListItem> {
94100
activateItem,
95101
isNavigableKey,
96102
isActivatable,
103+
wrapNavigation,
97104
} = config;
98105
this.isItem = isItem;
99106
this.getPossibleItems = getPossibleItems;
@@ -102,6 +109,7 @@ export class ListController<Item extends ListItem> {
102109
this.activateItem = activateItem;
103110
this.isNavigableKey = isNavigableKey;
104111
this.isActivatable = isActivatable;
112+
this.wrapNavigation = wrapNavigation ?? (() => true);
105113
}
106114

107115
/**
@@ -149,10 +157,6 @@ export class ListController<Item extends ListItem> {
149157

150158
const activeItemRecord = getActiveItem(items, this.isActivatable);
151159

152-
if (activeItemRecord) {
153-
activeItemRecord.item.tabIndex = -1;
154-
}
155-
156160
event.preventDefault();
157161

158162
const isRtl = this.isRtl();
@@ -163,32 +167,53 @@ export class ListController<Item extends ListItem> {
163167
? NavigableKeys.ArrowLeft
164168
: NavigableKeys.ArrowRight;
165169

170+
let nextActiveItem: Item | null = null;
166171
switch (key) {
167172
// Activate the next item
168173
case NavigableKeys.ArrowDown:
169174
case inlineNext:
170-
activateNextItem(items, activeItemRecord, this.isActivatable);
175+
nextActiveItem = activateNextItem(
176+
items,
177+
activeItemRecord,
178+
this.isActivatable,
179+
this.wrapNavigation(),
180+
);
171181
break;
172182

173183
// Activate the previous item
174184
case NavigableKeys.ArrowUp:
175185
case inlinePrevious:
176-
activatePreviousItem(items, activeItemRecord, this.isActivatable);
186+
nextActiveItem = activatePreviousItem(
187+
items,
188+
activeItemRecord,
189+
this.isActivatable,
190+
this.wrapNavigation(),
191+
);
177192
break;
178193

179194
// Activate the first item
180195
case NavigableKeys.Home:
181-
activateFirstItem(items, this.isActivatable);
196+
nextActiveItem = activateFirstItem(items, this.isActivatable);
182197
break;
183198

184199
// Activate the last item
185200
case NavigableKeys.End:
186-
activateLastItem(items, this.isActivatable);
201+
nextActiveItem = activateLastItem(items, this.isActivatable);
187202
break;
188203

189204
default:
190205
break;
191206
}
207+
208+
if (
209+
nextActiveItem &&
210+
activeItemRecord &&
211+
activeItemRecord.item !== nextActiveItem
212+
) {
213+
// If a new item was activated, remove the tabindex of the previous
214+
// activated item.
215+
activeItemRecord.item.tabIndex = -1;
216+
}
192217
};
193218

194219
/**
@@ -203,7 +228,12 @@ export class ListController<Item extends ListItem> {
203228
if (activeItemRecord) {
204229
activeItemRecord.item.tabIndex = -1;
205230
}
206-
return activateNextItem(items, activeItemRecord, this.isActivatable);
231+
return activateNextItem(
232+
items,
233+
activeItemRecord,
234+
this.isActivatable,
235+
this.wrapNavigation(),
236+
);
207237
}
208238

209239
/**
@@ -218,7 +248,12 @@ export class ListController<Item extends ListItem> {
218248
if (activeItemRecord) {
219249
activeItemRecord.item.tabIndex = -1;
220250
}
221-
return activatePreviousItem(items, activeItemRecord, this.isActivatable);
251+
return activatePreviousItem(
252+
items,
253+
activeItemRecord,
254+
this.isActivatable,
255+
this.wrapNavigation(),
256+
);
222257
}
223258

224259
/**

list/internal/list-navigation-helpers.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,15 +162,23 @@ export function getLastActivatableItem<Item extends ListItem>(
162162
* @param index {{index: number}} The index to search from.
163163
* @param isActivatable Function to determine if an item can be activated.
164164
* Defaults to non-disabled items.
165+
* @param wrap If true, then the next item at the end of the list is the first
166+
* item. Defaults to true.
165167
* @return The next activatable item or `null` if none are activatable.
166168
*/
167169
export function getNextItem<Item extends ListItem>(
168170
items: Item[],
169171
index: number,
170172
isActivatable = isItemNotDisabled<Item>,
173+
wrap = true,
171174
) {
172175
for (let i = 1; i < items.length; i++) {
173176
const nextIndex = (i + index) % items.length;
177+
if (nextIndex < index && !wrap) {
178+
// Return if the index loops back to the beginning and not wrapping.
179+
return null;
180+
}
181+
174182
const item = items[nextIndex];
175183
if (isActivatable(item)) {
176184
return item;
@@ -187,15 +195,23 @@ export function getNextItem<Item extends ListItem>(
187195
* @param index {{index: number}} The index to search from.
188196
* @param isActivatable Function to determine if an item can be activated.
189197
* Defaults to non-disabled items.
198+
* @param wrap If true, then the previous item at the beginning of the list is
199+
* the last item. Defaults to true.
190200
* @return The previous activatable item or `null` if none are activatable.
191201
*/
192202
export function getPrevItem<Item extends ListItem>(
193203
items: Item[],
194204
index: number,
195205
isActivatable = isItemNotDisabled<Item>,
206+
wrap = true,
196207
) {
197208
for (let i = 1; i < items.length; i++) {
198209
const prevIndex = (index - i + items.length) % items.length;
210+
if (prevIndex > index && !wrap) {
211+
// Return if the index loops back to the end and not wrapping.
212+
return null;
213+
}
214+
199215
const item = items[prevIndex];
200216

201217
if (isActivatable(item)) {
@@ -214,9 +230,15 @@ export function activateNextItem<Item extends ListItem>(
214230
items: Item[],
215231
activeItemRecord: null | ItemRecord<Item>,
216232
isActivatable = isItemNotDisabled<Item>,
233+
wrap = true,
217234
): Item | null {
218235
if (activeItemRecord) {
219-
const next = getNextItem(items, activeItemRecord.index, isActivatable);
236+
const next = getNextItem(
237+
items,
238+
activeItemRecord.index,
239+
isActivatable,
240+
wrap,
241+
);
220242

221243
if (next) {
222244
next.tabIndex = 0;
@@ -237,9 +259,15 @@ export function activatePreviousItem<Item extends ListItem>(
237259
items: Item[],
238260
activeItemRecord: null | ItemRecord<Item>,
239261
isActivatable = isItemNotDisabled<Item>,
262+
wrap = true,
240263
): Item | null {
241264
if (activeItemRecord) {
242-
const prev = getPrevItem(items, activeItemRecord.index, isActivatable);
265+
const prev = getPrevItem(
266+
items,
267+
activeItemRecord.index,
268+
isActivatable,
269+
wrap,
270+
);
243271
if (prev) {
244272
prev.tabIndex = 0;
245273
prev.focus();

menu/internal/menu.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ export abstract class Menu extends LitElement {
226226
@property({attribute: 'default-focus'})
227227
defaultFocus: FocusState = FocusState.FIRST_ITEM;
228228

229+
/**
230+
* Turns off navigation wrapping. By default, navigating past the end of the
231+
* menu items will wrap focus back to the beginning and vice versa. Use this
232+
* for ARIA patterns that do not wrap focus, like combobox.
233+
*/
234+
@property({type: Boolean, attribute: 'no-navigation-wrap'})
235+
noNavigationWrap = false;
236+
229237
@queryAssignedElements({flatten: true}) protected slotItems!: HTMLElement[];
230238
@state() private typeaheadActive = true;
231239

@@ -282,6 +290,7 @@ export abstract class Menu extends LitElement {
282290

283291
return submenuNavKeys.has(key);
284292
},
293+
wrapNavigation: () => !this.noNavigationWrap,
285294
});
286295

287296
/**

select/internal/select.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ export abstract class Select extends selectBaseClass {
474474
? `${this.selectWidth}px`
475475
: undefined,
476476
})}
477+
no-navigation-wrap
477478
.open=${this.open}
478479
.quick=${this.quick}
479480
.positioning=${this.menuPositioning}

0 commit comments

Comments
 (0)