Skip to content

Commit b6c53e5

Browse files
authored
feat(segment): add keyboard navigation, add selectOnFocus property to control selection follow focus behavior (#23590)
resolves #23520
1 parent 82d6275 commit b6c53e5

File tree

9 files changed

+178
-34
lines changed

9 files changed

+178
-34
lines changed

angular/src/directives/proxies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -712,8 +712,8 @@ export class IonSearchbar {
712712
}
713713
export declare interface IonSegment extends Components.IonSegment {
714714
}
715-
@ProxyCmp({ inputs: ["color", "disabled", "mode", "scrollable", "swipeGesture", "value"] })
716-
@Component({ selector: "ion-segment", changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-content></ng-content>", inputs: ["color", "disabled", "mode", "scrollable", "swipeGesture", "value"] })
715+
@ProxyCmp({ inputs: ["color", "disabled", "mode", "scrollable", "selectOnFocus", "swipeGesture", "value"] })
716+
@Component({ selector: "ion-segment", changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-content></ng-content>", inputs: ["color", "disabled", "mode", "scrollable", "selectOnFocus", "swipeGesture", "value"] })
717717
export class IonSegment {
718718
ionChange!: EventEmitter<CustomEvent>;
719719
protected el: HTMLElement;

core/api.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,7 @@ ion-segment,prop,color,string | undefined,undefined,false,true
10951095
ion-segment,prop,disabled,boolean,false,false,false
10961096
ion-segment,prop,mode,"ios" | "md",undefined,false,false
10971097
ion-segment,prop,scrollable,boolean,false,false,false
1098+
ion-segment,prop,selectOnFocus,boolean,false,false,false
10981099
ion-segment,prop,swipeGesture,boolean,true,false,false
10991100
ion-segment,prop,value,null | string | undefined,undefined,false,false
11001101
ion-segment,event,ionChange,SegmentChangeEventDetail,true

core/src/components.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2265,6 +2265,10 @@ export namespace Components {
22652265
* If `true`, the segment buttons will overflow and the user can swipe to see them. In addition, this will disable the gesture to drag the indicator between the buttons in order to swipe to see hidden buttons.
22662266
*/
22672267
"scrollable": boolean;
2268+
/**
2269+
* If `true`, navigating to an `ion-segment-button` with the keyboard will focus and select the element. If `false`, keyboard navigation will only focus the `ion-segment-button` element.
2270+
*/
2271+
"selectOnFocus": boolean;
22682272
/**
22692273
* If `true`, users will be able to swipe between segment buttons to activate them.
22702274
*/
@@ -5846,6 +5850,10 @@ declare namespace LocalJSX {
58465850
* If `true`, the segment buttons will overflow and the user can swipe to see them. In addition, this will disable the gesture to drag the indicator between the buttons in order to swipe to see hidden buttons.
58475851
*/
58485852
"scrollable"?: boolean;
5853+
/**
5854+
* If `true`, navigating to an `ion-segment-button` with the keyboard will focus and select the element. If `false`, keyboard navigation will only focus the `ion-segment-button` element.
5855+
*/
5856+
"selectOnFocus"?: boolean;
58495857
/**
58505858
* If `true`, users will be able to swipe between segment buttons to activate them.
58515859
*/

core/src/components/segment-button/segment-button.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
8787
}
8888

8989
private get tabIndex() {
90-
if (this.disabled) { return -1; }
91-
92-
const hasTabIndex = this.el.hasAttribute('tabindex');
93-
94-
if (hasTabIndex) {
95-
return this.el.getAttribute('tabindex');
96-
}
97-
98-
return 0;
90+
return this.checked && !this.disabled ? 0 : -1;
9991
}
10092

10193
render() {

core/src/components/segment/readme.md

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ Their functionality is similar to tabs, where selecting one will deselect all ot
88

99
Segments are not scrollable by default. Each segment button has a fixed width, and the width is determined by dividing the number of segment buttons by the screen width. This ensures that each segment button can be displayed on the screen without having to scroll. As a result, some segment buttons with longer labels may get cut off. To avoid this we recommend either using a shorter label or switching to a scrollable segment by setting the `scrollable` property to `true`. This will cause the segment to scroll horizontally, but will allow each segment button to have a variable width.
1010

11+
## Accessibility
12+
13+
### Keyboard Navigation
14+
15+
The component has full keyboard support for navigating between and selecting `ion-segment-button` elements. By default, keyboard navigation will only focus `ion-segment-button` elements, but you can use the `selectOnFocus` property to ensure that they get selected on focus as well. The following table details what each key does:
16+
17+
| Key | Function |
18+
| ------------------ | -------------------------------------------------------------- |
19+
| `ArrowRight` | Focuses the next focusable element. |
20+
| `ArrowLeft` | Focuses the previous focusable element. |
21+
| `Home` | Focuses the first focusable element. |
22+
| `End` | Focuses the last focusable element. |
23+
| `Space` or `Enter` | Selects the element that is currently focused. |
24+
1125
<!-- Auto Generated Below -->
1226

1327

@@ -566,14 +580,15 @@ export default defineComponent({
566580

567581
## Properties
568582

569-
| Property | Attribute | Description | Type | Default |
570-
| -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ----------- |
571-
| `color` | `color` | The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). | `string \| undefined` | `undefined` |
572-
| `disabled` | `disabled` | If `true`, the user cannot interact with the segment. | `boolean` | `false` |
573-
| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
574-
| `scrollable` | `scrollable` | If `true`, the segment buttons will overflow and the user can swipe to see them. In addition, this will disable the gesture to drag the indicator between the buttons in order to swipe to see hidden buttons. | `boolean` | `false` |
575-
| `swipeGesture` | `swipe-gesture` | If `true`, users will be able to swipe between segment buttons to activate them. | `boolean` | `true` |
576-
| `value` | `value` | the value of the segment. | `null \| string \| undefined` | `undefined` |
583+
| Property | Attribute | Description | Type | Default |
584+
| --------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ----------- |
585+
| `color` | `color` | The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). | `string \| undefined` | `undefined` |
586+
| `disabled` | `disabled` | If `true`, the user cannot interact with the segment. | `boolean` | `false` |
587+
| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
588+
| `scrollable` | `scrollable` | If `true`, the segment buttons will overflow and the user can swipe to see them. In addition, this will disable the gesture to drag the indicator between the buttons in order to swipe to see hidden buttons. | `boolean` | `false` |
589+
| `selectOnFocus` | `select-on-focus` | If `true`, navigating to an `ion-segment-button` with the keyboard will focus and select the element. If `false`, keyboard navigation will only focus the `ion-segment-button` element. | `boolean` | `false` |
590+
| `swipeGesture` | `swipe-gesture` | If `true`, users will be able to swipe between segment buttons to activate them. | `boolean` | `true` |
591+
| `value` | `value` | the value of the segment. | `null \| string \| undefined` | `undefined` |
577592

578593

579594
## Events

core/src/components/segment/segment.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop, State, Watch, h, writeTask } from '@stencil/core';
1+
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Prop, State, Watch, h, writeTask } from '@stencil/core';
22

33
import { config } from '../../global/config';
44
import { getIonMode } from '../../global/ionic-global';
@@ -92,6 +92,12 @@ export class Segment implements ComponentInterface {
9292
}
9393
}
9494

95+
/**
96+
* If `true`, navigating to an `ion-segment-button` with the keyboard will focus and select the element.
97+
* If `false`, keyboard navigation will only focus the `ion-segment-button` element.
98+
*/
99+
@Prop() selectOnFocus = false;
100+
95101
/**
96102
* Emitted when the value property has changed and any
97103
* dragging pointer has been released from `ion-segment`.
@@ -136,6 +142,7 @@ export class Segment implements ComponentInterface {
136142

137143
async componentDidLoad() {
138144
this.setCheckedClasses();
145+
this.ensureFocusable();
139146

140147
this.gesture = (await import('../../utils/gesture')).createGesture({
141148
el: this.el,
@@ -431,6 +438,74 @@ export class Segment implements ComponentInterface {
431438
this.checked = current;
432439
}
433440

441+
private getSegmentButton = (selector: 'first' | 'last' | 'next' | 'previous'): HTMLIonSegmentButtonElement | null => {
442+
const buttons = this.getButtons().filter(button => !button.disabled);
443+
const currIndex = buttons.findIndex(button => button === document.activeElement);
444+
445+
switch (selector) {
446+
case 'first':
447+
return buttons[0];
448+
case 'last':
449+
return buttons[buttons.length - 1];
450+
case 'next':
451+
return buttons[currIndex + 1] || buttons[0];
452+
case 'previous':
453+
return buttons[currIndex - 1] || buttons[buttons.length - 1];
454+
default:
455+
return null;
456+
}
457+
}
458+
459+
@Listen('keydown')
460+
onKeyDown(ev: KeyboardEvent) {
461+
const isRTL = document.dir === 'rtl';
462+
let keyDownSelectsButton = this.selectOnFocus;
463+
let current;
464+
switch (ev.key) {
465+
case 'ArrowRight':
466+
ev.preventDefault();
467+
current = isRTL ? this.getSegmentButton('previous') : this.getSegmentButton('next');
468+
break;
469+
case 'ArrowLeft':
470+
ev.preventDefault();
471+
current = isRTL ? this.getSegmentButton('next') : this.getSegmentButton('previous')
472+
break;
473+
case 'Home':
474+
ev.preventDefault();
475+
current = this.getSegmentButton('first');
476+
break;
477+
case 'End':
478+
ev.preventDefault();
479+
current = this.getSegmentButton('last');
480+
break;
481+
case ' ':
482+
case 'Enter':
483+
ev.preventDefault();
484+
current = document.activeElement as HTMLIonSegmentButtonElement;
485+
keyDownSelectsButton = true;
486+
default:
487+
break;
488+
}
489+
490+
if (!current) { return; }
491+
492+
if (keyDownSelectsButton) {
493+
const previous = this.checked || current;
494+
this.checkButton(previous, current);
495+
}
496+
current.focus();
497+
}
498+
499+
/* By default, focus is delegated to the selected `ion-segment-button`.
500+
* If there is no selected button, focus will instead pass to the first child button.
501+
**/
502+
private ensureFocusable() {
503+
if (this.value !== undefined) { return };
504+
505+
const buttons = this.getButtons();
506+
buttons[0]?.setAttribute('tabindex', '0');
507+
}
508+
434509
render() {
435510
const mode = getIonMode(this);
436511
return (

core/src/components/segment/test/a11y/e2e.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { newE2EPage } from '@stencil/core/testing';
22
import { AxePuppeteer } from '@axe-core/puppeteer';
33

4+
const getActiveElementText = async (page) => {
5+
const activeElement = await page.evaluateHandle(() => document.activeElement);
6+
return await page.evaluate(el => el && el.innerText, activeElement);
7+
}
8+
49
test('segment: axe', async () => {
510
const page = await newE2EPage({
611
url: '/src/components/segment/test/a11y?ionic:_testing=true'
@@ -9,3 +14,48 @@ test('segment: axe', async () => {
914
const results = await new AxePuppeteer(page).analyze();
1015
expect(results.violations.length).toEqual(0);
1116
});
17+
18+
19+
test('segment: keyboard navigation', async () => {
20+
const page = await newE2EPage({
21+
url: '/src/components/segment/test/a11y?ionic:_testing=true'
22+
});
23+
24+
await page.keyboard.press('Tab');
25+
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
26+
27+
await page.keyboard.press('ArrowRight');
28+
expect(await getActiveElementText(page)).toEqual('READING LIST');
29+
30+
await page.keyboard.press('ArrowLeft');
31+
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
32+
33+
await page.keyboard.press('End');
34+
expect(await getActiveElementText(page)).toEqual('SHARED LINKS');
35+
36+
await page.keyboard.press('Home');
37+
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
38+
39+
// Loop to the end from the start
40+
await page.keyboard.press('ArrowLeft');
41+
expect(await getActiveElementText(page)).toEqual('SHARED LINKS');
42+
43+
// Loop to the start from the end
44+
await page.keyboard.press('ArrowRight');
45+
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
46+
});
47+
48+
test('segment: RTL keyboard navigation', async () => {
49+
const page = await newE2EPage({
50+
url: '/src/components/segment/test/a11y?ionic:_testing=true&rtl=true'
51+
});
52+
53+
await page.keyboard.press('Tab');
54+
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
55+
56+
await page.keyboard.press('ArrowRight');
57+
expect(await getActiveElementText(page)).toEqual('SHARED LINKS');
58+
59+
await page.keyboard.press('ArrowLeft');
60+
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
61+
});

core/src/components/segment/test/a11y/index.html

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,21 @@
1313
</head>
1414

1515
<body>
16-
<main>
17-
<h1>Segment</h1>
18-
<ion-segment aria-label="Tab Options" color="dark" value="reading-list">
19-
<ion-segment-button value="bookmarks">
20-
<ion-label>Bookmarks</ion-label>
21-
</ion-segment-button>
22-
<ion-segment-button value="reading-list">
23-
<ion-label>Reading List</ion-label>
24-
</ion-segment-button>
25-
<ion-segment-button value="shared-links">
26-
<ion-label>Shared Links</ion-label>
27-
</ion-segment-button>
28-
</ion-segment>
29-
</main>
16+
<ion-app>
17+
<ion-content>
18+
<h1>Segment</h1>
19+
<ion-segment aria-label="Tab Options" color="dark" select-on-focus>
20+
<ion-segment-button value="bookmarks">
21+
<ion-label>Bookmarks</ion-label>
22+
</ion-segment-button>
23+
<ion-segment-button value="reading-list">
24+
<ion-label>Reading List</ion-label>
25+
</ion-segment-button>
26+
<ion-segment-button value="shared-links">
27+
<ion-label>Shared Links</ion-label>
28+
</ion-segment-button>
29+
</ion-segment>
30+
</ion-content>
31+
</ion-app>
3032
</body>
3133
</html>

packages/vue/src/proxies.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ export const IonSegment = /*@__PURE__*/ defineContainer<JSX.IonSegment>('ion-seg
662662
'scrollable',
663663
'swipeGesture',
664664
'value',
665+
'selectOnFocus',
665666
'ionChange',
666667
'ionSelect',
667668
'ionStyle'

0 commit comments

Comments
 (0)