diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts index 87568a06d2..47301060d4 100644 --- a/angular/src/directives/proxies.ts +++ b/angular/src/directives/proxies.ts @@ -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: "", inputs: ["color", "disabled", "mode", "scrollable", "swipeGesture", "value"] }) +@ProxyCmp({ inputs: ["color", "disabled", "mode", "scrollable", "selectOnFocus", "swipeGesture", "value"] }) +@Component({ selector: "ion-segment", changeDetection: ChangeDetectionStrategy.OnPush, template: "", inputs: ["color", "disabled", "mode", "scrollable", "selectOnFocus", "swipeGesture", "value"] }) export class IonSegment { ionChange!: EventEmitter; protected el: HTMLElement; diff --git a/core/api.txt b/core/api.txt index fde54923b6..2e4fda2c20 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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 diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 78eb2c0fa7..294b6291ae 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -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. */ diff --git a/core/src/components/segment-button/segment-button.tsx b/core/src/components/segment-button/segment-button.tsx index 92468d0977..3a9c00f754 100644 --- a/core/src/components/segment-button/segment-button.tsx +++ b/core/src/components/segment-button/segment-button.tsx @@ -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() { diff --git a/core/src/components/segment/readme.md b/core/src/components/segment/readme.md index dd4ab5ac8a..d4fa8ed2e8 100644 --- a/core/src/components/segment/readme.md +++ b/core/src/components/segment/readme.md @@ -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. | + @@ -566,14 +580,15 @@ 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` | -| `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` | +| 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` | ## Events diff --git a/core/src/components/segment/segment.tsx b/core/src/components/segment/segment.tsx index 0a8c032708..ef71327841 100644 --- a/core/src/components/segment/segment.tsx +++ b/core/src/components/segment/segment.tsx @@ -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 ( diff --git a/core/src/components/segment/test/a11y/e2e.ts b/core/src/components/segment/test/a11y/e2e.ts index 4c425f42f7..e9f4d6789c 100644 --- a/core/src/components/segment/test/a11y/e2e.ts +++ b/core/src/components/segment/test/a11y/e2e.ts @@ -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'); +}); \ No newline at end of file diff --git a/core/src/components/segment/test/a11y/index.html b/core/src/components/segment/test/a11y/index.html index 0f4956255e..901c6c08a2 100644 --- a/core/src/components/segment/test/a11y/index.html +++ b/core/src/components/segment/test/a11y/index.html @@ -13,19 +13,21 @@ -
-

Segment

- - - Bookmarks - - - Reading List - - - Shared Links - - -
+ + +

Segment

+ + + Bookmarks + + + Reading List + + + Shared Links + + +
+
diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index ec73600912..696e1e50f6 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -662,6 +662,7 @@ export const IonSegment = /*@__PURE__*/ defineContainer('ion-seg 'scrollable', 'swipeGesture', 'value', + 'selectOnFocus', 'ionChange', 'ionSelect', 'ionStyle'