mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-17 18:54:11 +08:00
feat(segment): add keyboard navigation, add selectOnFocus property to control selection follow focus behavior (#23590)
resolves #23520
This commit is contained in:
@ -712,8 +712,8 @@ export class IonSearchbar {
|
|||||||
}
|
}
|
||||||
export declare interface IonSegment extends Components.IonSegment {
|
export declare interface IonSegment extends Components.IonSegment {
|
||||||
}
|
}
|
||||||
@ProxyCmp({ 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", "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 {
|
export class IonSegment {
|
||||||
ionChange!: EventEmitter<CustomEvent>;
|
ionChange!: EventEmitter<CustomEvent>;
|
||||||
protected el: HTMLElement;
|
protected el: HTMLElement;
|
||||||
|
@ -1095,6 +1095,7 @@ ion-segment,prop,color,string | undefined,undefined,false,true
|
|||||||
ion-segment,prop,disabled,boolean,false,false,false
|
ion-segment,prop,disabled,boolean,false,false,false
|
||||||
ion-segment,prop,mode,"ios" | "md",undefined,false,false
|
ion-segment,prop,mode,"ios" | "md",undefined,false,false
|
||||||
ion-segment,prop,scrollable,boolean,false,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,swipeGesture,boolean,true,false,false
|
||||||
ion-segment,prop,value,null | string | undefined,undefined,false,false
|
ion-segment,prop,value,null | string | undefined,undefined,false,false
|
||||||
ion-segment,event,ionChange,SegmentChangeEventDetail,true
|
ion-segment,event,ionChange,SegmentChangeEventDetail,true
|
||||||
|
8
core/src/components.d.ts
vendored
8
core/src/components.d.ts
vendored
@ -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.
|
* 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;
|
"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.
|
* 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.
|
* 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;
|
"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.
|
* If `true`, users will be able to swipe between segment buttons to activate them.
|
||||||
*/
|
*/
|
||||||
|
@ -87,15 +87,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get tabIndex() {
|
private get tabIndex() {
|
||||||
if (this.disabled) { return -1; }
|
return this.checked && !this.disabled ? 0 : -1;
|
||||||
|
|
||||||
const hasTabIndex = this.el.hasAttribute('tabindex');
|
|
||||||
|
|
||||||
if (hasTabIndex) {
|
|
||||||
return this.el.getAttribute('tabindex');
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -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.
|
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 -->
|
<!-- Auto Generated Below -->
|
||||||
|
|
||||||
|
|
||||||
@ -567,11 +581,12 @@ export default defineComponent({
|
|||||||
## Properties
|
## Properties
|
||||||
|
|
||||||
| Property | Attribute | Description | Type | Default |
|
| 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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `value` | `value` | the value of the segment. | `null \| string \| undefined` | `undefined` |
|
||||||
|
|
||||||
|
@ -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 { config } from '../../global/config';
|
||||||
import { getIonMode } from '../../global/ionic-global';
|
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
|
* Emitted when the value property has changed and any
|
||||||
* dragging pointer has been released from `ion-segment`.
|
* dragging pointer has been released from `ion-segment`.
|
||||||
@ -136,6 +142,7 @@ export class Segment implements ComponentInterface {
|
|||||||
|
|
||||||
async componentDidLoad() {
|
async componentDidLoad() {
|
||||||
this.setCheckedClasses();
|
this.setCheckedClasses();
|
||||||
|
this.ensureFocusable();
|
||||||
|
|
||||||
this.gesture = (await import('../../utils/gesture')).createGesture({
|
this.gesture = (await import('../../utils/gesture')).createGesture({
|
||||||
el: this.el,
|
el: this.el,
|
||||||
@ -431,6 +438,74 @@ export class Segment implements ComponentInterface {
|
|||||||
this.checked = current;
|
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() {
|
render() {
|
||||||
const mode = getIonMode(this);
|
const mode = getIonMode(this);
|
||||||
return (
|
return (
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { newE2EPage } from '@stencil/core/testing';
|
import { newE2EPage } from '@stencil/core/testing';
|
||||||
import { AxePuppeteer } from '@axe-core/puppeteer';
|
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 () => {
|
test('segment: axe', async () => {
|
||||||
const page = await newE2EPage({
|
const page = await newE2EPage({
|
||||||
url: '/src/components/segment/test/a11y?ionic:_testing=true'
|
url: '/src/components/segment/test/a11y?ionic:_testing=true'
|
||||||
@ -9,3 +14,48 @@ test('segment: axe', async () => {
|
|||||||
const results = await new AxePuppeteer(page).analyze();
|
const results = await new AxePuppeteer(page).analyze();
|
||||||
expect(results.violations.length).toEqual(0);
|
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');
|
||||||
|
});
|
@ -13,9 +13,10 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<ion-app>
|
||||||
|
<ion-content>
|
||||||
<h1>Segment</h1>
|
<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-segment-button value="bookmarks">
|
||||||
<ion-label>Bookmarks</ion-label>
|
<ion-label>Bookmarks</ion-label>
|
||||||
</ion-segment-button>
|
</ion-segment-button>
|
||||||
@ -26,6 +27,7 @@
|
|||||||
<ion-label>Shared Links</ion-label>
|
<ion-label>Shared Links</ion-label>
|
||||||
</ion-segment-button>
|
</ion-segment-button>
|
||||||
</ion-segment>
|
</ion-segment>
|
||||||
</main>
|
</ion-content>
|
||||||
|
</ion-app>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -662,6 +662,7 @@ export const IonSegment = /*@__PURE__*/ defineContainer<JSX.IonSegment>('ion-seg
|
|||||||
'scrollable',
|
'scrollable',
|
||||||
'swipeGesture',
|
'swipeGesture',
|
||||||
'value',
|
'value',
|
||||||
|
'selectOnFocus',
|
||||||
'ionChange',
|
'ionChange',
|
||||||
'ionSelect',
|
'ionSelect',
|
||||||
'ionStyle'
|
'ionStyle'
|
||||||
|
Reference in New Issue
Block a user