feat(segment): add keyboard navigation, add selectOnFocus property to control selection follow focus behavior (#23590)

resolves #23520
This commit is contained in:
William Martin
2021-07-20 16:45:17 -04:00
committed by GitHub
parent 82d6275f3d
commit b6c53e539b
9 changed files with 178 additions and 34 deletions

View File

@ -712,8 +712,8 @@ export class IonSearchbar {
}
export declare interface IonSegment extends Components.IonSegment {
}
@ProxyCmp({ inputs: ["color", "disabled", "mode", "scrollable", "swipeGesture", "value"] })
@Component({ selector: "ion-segment", changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-content></ng-content>", inputs: ["color", "disabled", "mode", "scrollable", "swipeGesture", "value"] })
@ProxyCmp({ inputs: ["color", "disabled", "mode", "scrollable", "selectOnFocus", "swipeGesture", "value"] })
@Component({ selector: "ion-segment", changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-content></ng-content>", inputs: ["color", "disabled", "mode", "scrollable", "selectOnFocus", "swipeGesture", "value"] })
export class IonSegment {
ionChange!: EventEmitter<CustomEvent>;
protected el: HTMLElement;

View File

@ -1095,6 +1095,7 @@ ion-segment,prop,color,string | undefined,undefined,false,true
ion-segment,prop,disabled,boolean,false,false,false
ion-segment,prop,mode,"ios" | "md",undefined,false,false
ion-segment,prop,scrollable,boolean,false,false,false
ion-segment,prop,selectOnFocus,boolean,false,false,false
ion-segment,prop,swipeGesture,boolean,true,false,false
ion-segment,prop,value,null | string | undefined,undefined,false,false
ion-segment,event,ionChange,SegmentChangeEventDetail,true

View File

@ -2265,6 +2265,10 @@ export namespace Components {
* 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.
*/
"scrollable": boolean;
/**
* 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.
*/
"selectOnFocus": boolean;
/**
* If `true`, users will be able to swipe between segment buttons to activate them.
*/
@ -5846,6 +5850,10 @@ declare namespace LocalJSX {
* 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.
*/
"scrollable"?: boolean;
/**
* 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.
*/
"selectOnFocus"?: boolean;
/**
* If `true`, users will be able to swipe between segment buttons to activate them.
*/

View File

@ -87,15 +87,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
}
private get tabIndex() {
if (this.disabled) { return -1; }
const hasTabIndex = this.el.hasAttribute('tabindex');
if (hasTabIndex) {
return this.el.getAttribute('tabindex');
}
return 0;
return this.checked && !this.disabled ? 0 : -1;
}
render() {

View File

@ -8,6 +8,20 @@ Their functionality is similar to tabs, where selecting one will deselect all ot
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.
## Accessibility
### Keyboard Navigation
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:
| Key | Function |
| ------------------ | -------------------------------------------------------------- |
| `ArrowRight` | Focuses the next focusable element. |
| `ArrowLeft` | Focuses the previous focusable element. |
| `Home` | Focuses the first focusable element. |
| `End` | Focuses the last focusable element. |
| `Space` or `Enter` | Selects the element that is currently focused. |
<!-- Auto Generated Below -->
@ -567,11 +581,12 @@ export default defineComponent({
## Properties
| Property | Attribute | Description | Type | Default |
| -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ----------- |
| --------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ----------- |
| `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` |
| `disabled` | `disabled` | If `true`, the user cannot interact with the segment. | `boolean` | `false` |
| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
| `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` |
| `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` |
| `swipeGesture` | `swipe-gesture` | If `true`, users will be able to swipe between segment buttons to activate them. | `boolean` | `true` |
| `value` | `value` | the value of the segment. | `null \| string \| undefined` | `undefined` |

View File

@ -1,4 +1,4 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop, State, Watch, h, writeTask } from '@stencil/core';
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Prop, State, Watch, h, writeTask } from '@stencil/core';
import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
@ -92,6 +92,12 @@ export class Segment implements ComponentInterface {
}
}
/**
* 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.
*/
@Prop() selectOnFocus = false;
/**
* Emitted when the value property has changed and any
* dragging pointer has been released from `ion-segment`.
@ -136,6 +142,7 @@ export class Segment implements ComponentInterface {
async componentDidLoad() {
this.setCheckedClasses();
this.ensureFocusable();
this.gesture = (await import('../../utils/gesture')).createGesture({
el: this.el,
@ -431,6 +438,74 @@ export class Segment implements ComponentInterface {
this.checked = current;
}
private getSegmentButton = (selector: 'first' | 'last' | 'next' | 'previous'): HTMLIonSegmentButtonElement | null => {
const buttons = this.getButtons().filter(button => !button.disabled);
const currIndex = buttons.findIndex(button => button === document.activeElement);
switch (selector) {
case 'first':
return buttons[0];
case 'last':
return buttons[buttons.length - 1];
case 'next':
return buttons[currIndex + 1] || buttons[0];
case 'previous':
return buttons[currIndex - 1] || buttons[buttons.length - 1];
default:
return null;
}
}
@Listen('keydown')
onKeyDown(ev: KeyboardEvent) {
const isRTL = document.dir === 'rtl';
let keyDownSelectsButton = this.selectOnFocus;
let current;
switch (ev.key) {
case 'ArrowRight':
ev.preventDefault();
current = isRTL ? this.getSegmentButton('previous') : this.getSegmentButton('next');
break;
case 'ArrowLeft':
ev.preventDefault();
current = isRTL ? this.getSegmentButton('next') : this.getSegmentButton('previous')
break;
case 'Home':
ev.preventDefault();
current = this.getSegmentButton('first');
break;
case 'End':
ev.preventDefault();
current = this.getSegmentButton('last');
break;
case ' ':
case 'Enter':
ev.preventDefault();
current = document.activeElement as HTMLIonSegmentButtonElement;
keyDownSelectsButton = true;
default:
break;
}
if (!current) { return; }
if (keyDownSelectsButton) {
const previous = this.checked || current;
this.checkButton(previous, current);
}
current.focus();
}
/* By default, focus is delegated to the selected `ion-segment-button`.
* If there is no selected button, focus will instead pass to the first child button.
**/
private ensureFocusable() {
if (this.value !== undefined) { return };
const buttons = this.getButtons();
buttons[0]?.setAttribute('tabindex', '0');
}
render() {
const mode = getIonMode(this);
return (

View File

@ -1,6 +1,11 @@
import { newE2EPage } from '@stencil/core/testing';
import { AxePuppeteer } from '@axe-core/puppeteer';
const getActiveElementText = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => el && el.innerText, activeElement);
}
test('segment: axe', async () => {
const page = await newE2EPage({
url: '/src/components/segment/test/a11y?ionic:_testing=true'
@ -9,3 +14,48 @@ test('segment: axe', async () => {
const results = await new AxePuppeteer(page).analyze();
expect(results.violations.length).toEqual(0);
});
test('segment: keyboard navigation', async () => {
const page = await newE2EPage({
url: '/src/components/segment/test/a11y?ionic:_testing=true'
});
await page.keyboard.press('Tab');
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
await page.keyboard.press('ArrowRight');
expect(await getActiveElementText(page)).toEqual('READING LIST');
await page.keyboard.press('ArrowLeft');
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
await page.keyboard.press('End');
expect(await getActiveElementText(page)).toEqual('SHARED LINKS');
await page.keyboard.press('Home');
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
// Loop to the end from the start
await page.keyboard.press('ArrowLeft');
expect(await getActiveElementText(page)).toEqual('SHARED LINKS');
// Loop to the start from the end
await page.keyboard.press('ArrowRight');
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
});
test('segment: RTL keyboard navigation', async () => {
const page = await newE2EPage({
url: '/src/components/segment/test/a11y?ionic:_testing=true&rtl=true'
});
await page.keyboard.press('Tab');
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
await page.keyboard.press('ArrowRight');
expect(await getActiveElementText(page)).toEqual('SHARED LINKS');
await page.keyboard.press('ArrowLeft');
expect(await getActiveElementText(page)).toEqual('BOOKMARKS');
});

View File

@ -13,9 +13,10 @@
</head>
<body>
<main>
<ion-app>
<ion-content>
<h1>Segment</h1>
<ion-segment aria-label="Tab Options" color="dark" value="reading-list">
<ion-segment aria-label="Tab Options" color="dark" select-on-focus>
<ion-segment-button value="bookmarks">
<ion-label>Bookmarks</ion-label>
</ion-segment-button>
@ -26,6 +27,7 @@
<ion-label>Shared Links</ion-label>
</ion-segment-button>
</ion-segment>
</main>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -662,6 +662,7 @@ export const IonSegment = /*@__PURE__*/ defineContainer<JSX.IonSegment>('ion-seg
'scrollable',
'swipeGesture',
'value',
'selectOnFocus',
'ionChange',
'ionSelect',
'ionStyle'