Compare commits

...

11 Commits

Author SHA1 Message Date
Maria Hutt
9d2a5d2803 refactor(focus-visible): reset on onFocus 2024-11-27 14:34:12 -08:00
Maria Hutt
61d68d0d87 refactor(focus-visible): more comments 2024-11-27 14:21:27 -08:00
Maria Hutt
9f772f4ea7 refactor(focus-visible): add check for reset 2024-11-27 13:39:45 -08:00
Maria Hutt
e7b53047be refactor(focus-visible): more comments 2024-11-27 13:18:55 -08:00
Maria Hutt
ad7a02d613 refactor(focus-visible): add console logs 2024-11-26 14:21:25 -08:00
Maria Hutt
aaebad516a refactor(focus-visible): reset keyboardMode after focus 2024-11-26 10:46:46 -08:00
Maria Hutt
f6188c47e9 fix(overlays): announce info after opening based on platform (#30025)
Issue number: internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

PR https://github.com/ionic-team/ionic-framework/pull/29951 would hide
the overlays from screen readers while animating. This allows the
element to navigate to its correct destination for screen readers to
interact with. Otherwise, the focus rings would never appear. However,
this ended up breaking the interaction for iOS.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Overlays are hidden from screen readers while animating only if the
platform is `android`. Since the original issue only applied to Android
devices.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Dev build: `8.4.1-dev.11732033492.170e160c`

Test on iOS and Android devices.
2024-11-22 17:20:22 +00:00
Tanner Reits
8ee42bbc1e fix(overlays): focus management with checkbox/radio (#30026)
Issue number: resolves internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Using `Tab` or `Shift + Tab` to focus through elements in a modal won't
behave as expected when using `ion-checkbox` or `ion-radio` within an
`ion-item`. Previously, the behavior would result in the last item in a
list getting focus styling, but `document.activeElement` would still be
the first actionable item in the overlay

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

For checkboxes, the `ion-checkbox` element itself will be focused rather
than the encapsulating `ion-item`

For radios, the `ion-radio-group` will be used to focus the appropriate
element. This will be the first `ion-radio` if there is no "checked"
item, or the "checked" item if one exists.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2024-11-21 22:28:22 +00:00
Maria Hutt
23763abf79 fix(header): use aria attributes to hide small title when collapsed (#30027)
Issue number: resolves #29347 

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Focusable elements like buttons cannot be accessed within the
`ion-header` when it's collapsed. They're only accessible once the small
title is displayed.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Moved the `aria-hidden` from the header to `ion-title`, this aligns
with native.
- Updated existing test.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Dev build: `8.4.1-dev.11732064156.12837790`
2024-11-21 18:57:25 +00:00
Alexander Harding
470decca7b fix(toast): swipe gesture works with custom container layout (#29999)
Issue number: resolves #29998

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?

Applying a custom layout to `ion-toast::part(container)`, for example
`width: 50%`, will make the part of the toast outside this element's
bounds non-interactive for swiping gestures.


## What is the new behavior?

Can swipe from anywhere on the toast with custom layout applied to
::part(container)

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

| Before | After                                         |
|-------|-----------------------------------------------------|
| <video
src="https://github.com/user-attachments/assets/fc450066-5fb1-4fd9-bfbd-7f2cd55ce855"></video>
| <video
src="https://github.com/user-attachments/assets/991f6a42-f6fe-479b-9f68-7e7e35dca799"></video>
|
2024-11-14 16:39:02 +00:00
Maria Hutt
3216108ca1 test(segment): fix flaky gesture test and re-enable (#30008)
Issue number: internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

There's a flaky test that was disabled for segment. It's been known that
gesture tests are prone to be flaky.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Fixed the test by switching to the improved `dragElementBy` function

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

N/A
2024-11-08 22:54:38 +00:00
9 changed files with 99 additions and 26 deletions

View File

@@ -2303,6 +2303,7 @@ export namespace Components {
* The name of the control, which is submitted with the form data.
*/
"name": string;
"setFocus": () => Promise<void>;
/**
* the value of the radio group.
*/

View File

@@ -167,13 +167,34 @@ export const handleToolbarIntersection = (
export const setHeaderActive = (headerIndex: HeaderIndex, active = true) => {
const headerEl = headerIndex.el;
const toolbars = headerIndex.toolbars;
const ionTitles = toolbars.map((toolbar) => toolbar.ionTitleEl);
if (active) {
headerEl.classList.remove('header-collapse-condense-inactive');
headerEl.removeAttribute('aria-hidden');
ionTitles.forEach((ionTitle) => {
if (ionTitle) {
ionTitle.removeAttribute('aria-hidden');
}
});
} else {
headerEl.classList.add('header-collapse-condense-inactive');
headerEl.setAttribute('aria-hidden', 'true');
/**
* The small title should only be accessed by screen readers
* when the large title collapses into the small title due
* to scrolling.
*
* Originally, the header was given `aria-hidden="true"`
* but this caused issues with screen readers not being
* able to access any focusable elements within the header.
*/
ionTitles.forEach((ionTitle) => {
if (ionTitle) {
ionTitle.setAttribute('aria-hidden', 'true');
}
});
}
};

View File

@@ -3,13 +3,19 @@ import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('header: condense'), () => {
test('should be hidden from screen readers when collapsed', async ({ page }) => {
test('should hide small title from screen readers when collapsed', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29347',
});
await page.goto('/src/components/header/test/condense', config);
const largeTitleHeader = page.locator('#largeTitleHeader');
const smallTitleHeader = page.locator('#smallTitleHeader');
const smallTitle = smallTitleHeader.locator('ion-title');
const content = page.locator('ion-content');
await expect(smallTitleHeader).toHaveAttribute('aria-hidden', 'true');
await expect(smallTitle).toHaveAttribute('aria-hidden', 'true');
await expect(largeTitleHeader).toHaveScreenshot(screenshot(`header-condense-large-title-initial-diff`));
@@ -24,7 +30,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
* Playwright can't do .not.toHaveAttribute() because a value is expected,
* and toHaveAttribute can't accept a value of type null.
*/
const ariaHidden = await smallTitleHeader.getAttribute('aria-hidden');
const ariaHidden = await smallTitle.getAttribute('aria-hidden');
expect(ariaHidden).toBeNull();
await content.evaluate(async (el: HTMLIonContentElement) => {
@@ -32,7 +38,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
});
await page.locator('#smallTitleHeader.header-collapse-condense-inactive').waitFor();
await expect(smallTitleHeader).toHaveAttribute('aria-hidden', 'true');
await expect(smallTitle).toHaveAttribute('aria-hidden', 'true');
});
});
});

View File

@@ -1,5 +1,5 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Prop, Watch, h } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, Watch, h } from '@stencil/core';
import { renderHiddenInput } from '@utils/helpers';
import { getIonMode } from '../../global/ionic-global';
@@ -217,6 +217,13 @@ export class RadioGroup implements ComponentInterface {
}
}
/** @internal */
@Method()
async setFocus() {
const radioToFocus = this.getRadios().find((r) => r.tabIndex !== -1);
radioToFocus?.setFocus();
}
render() {
const { label, labelId, el, name, value } = this;
const mode = getIonMode(this);

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import { configs, test, dragElementBy } from '@utils/test/playwright';
/**
* This behavior does not vary across modes/directions.
@@ -105,8 +105,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
});
});
// TODO FW-3021
test.describe.skip('when the pointer is released', () => {
test.describe('when the pointer is released', () => {
test('should emit if the value has changed', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
@@ -136,14 +135,22 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const ionChangeSpy = await page.spyOnEvent('ionChange');
const segment = page.locator('ion-segment');
const firstButton = page.locator('ion-segment-button[value="1"]');
const lastButton = page.locator('ion-segment-button[value="3"]');
await firstButton.hover();
await page.mouse.down();
/*
* `dragByX` should represent the total width of all segment buttons,
* excluding the first half of the first button and the second half
* of the last button. This calculation accounts for dragging from
* the center of the first button to the center of the last button.
*/
const segmentWidth = await segment.boundingBox().then((box) => (box ? box.width : 0));
const firstButtonWidth = await firstButton.boundingBox().then((box) => (box ? box.width : 0));
const lastButtonWidth = await lastButton.boundingBox().then((box) => (box ? box.width : 0));
const dragByX = segmentWidth - firstButtonWidth / 2 - lastButtonWidth / 2;
await lastButton.hover();
await page.mouse.up();
await dragElementBy(firstButton, page, dragByX);
expect(ionChangeSpy).toHaveReceivedEventDetail({ value: '3' });
expect(ionChangeSpy).toHaveReceivedEventTimes(1);

View File

@@ -97,6 +97,8 @@
background: var(--background);
box-shadow: var(--box-shadow);
pointer-events: auto;
}
.toast-wrapper.toast-top {
@@ -115,7 +117,6 @@
display: flex;
align-items: center;
pointer-events: auto;
height: inherit;
min-height: inherit;

View File

@@ -13,7 +13,7 @@ import { focusVisibleElement } from '@utils/helpers';
* valid usage for the disabled property on ion-button.
*/
export const focusableQueryString =
'[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])';
'[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), ion-checkbox:not([tabindex^="-"]):not([hidden]):not([disabled]), ion-radio:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])';
/**
* Focuses the first descendant in a context
@@ -78,7 +78,13 @@ const focusElementInContext = <T extends HTMLElement>(
}
if (elementToFocus) {
focusVisibleElement(elementToFocus);
const radioGroup = elementToFocus.closest('ion-radio-group');
if (radioGroup) {
radioGroup.setFocus();
} else {
focusVisibleElement(elementToFocus);
}
} else {
// Focus fallback element instead of letting focus escape
fallbackElement.focus();

View File

@@ -13,6 +13,7 @@ const FOCUS_KEYS = [
'ArrowUp',
'Home',
'End',
'Meta',
];
export interface FocusVisibleUtility {
@@ -28,24 +29,34 @@ export const startFocusVisible = (rootEl?: HTMLElement): FocusVisibleUtility =>
const root = rootEl ? rootEl : document.body;
const setFocus = (elements: Element[]) => {
console.log('setFocus', elements, currentFocus);
console.log('keyboardMode', keyboardMode);
currentFocus.forEach((el) => el.classList.remove(ION_FOCUSED));
elements.forEach((el) => el.classList.add(ION_FOCUSED));
currentFocus = elements;
};
const pointerDown = () => {
// SPACE triggers on iOS device with physical keyboard
const pointerDown = (ev: Event) => {
console.log('pointerDown', ev);
console.log('keyboardMode', keyboardMode);
keyboardMode = false;
setFocus([]);
};
// Enter triggers on iOS device with physical keyboard
const onKeydown = (ev: Event) => {
console.log('onKeydown', ev);
console.log('keyboardMode', keyboardMode);
keyboardMode = FOCUS_KEYS.includes((ev as KeyboardEvent).key);
if (!keyboardMode) {
setFocus([]);
}
};
const onFocusin = (ev: Event) => {
console.log('onFocusin', ev);
console.log('keyboardMode', keyboardMode);
let toFocus: Element[] = [];
if (keyboardMode && ev.composedPath !== undefined) {
const toFocus = ev.composedPath().filter((el: any) => {
toFocus = ev.composedPath().filter((el: any) => {
// TODO(FW-2832): type
if (el.classList) {
return el.classList.contains(ION_FOCUSABLE);
@@ -54,8 +65,12 @@ export const startFocusVisible = (rootEl?: HTMLElement): FocusVisibleUtility =>
}) as Element[];
setFocus(toFocus);
}
console.log('toFocus', toFocus);
keyboardMode = true;
};
const onFocusout = () => {
console.log('onFocusout');
console.log('keyboardMode', keyboardMode);
if (ref.activeElement === root) {
setFocus([]);
}

View File

@@ -32,6 +32,7 @@ import {
removeEventListener,
} from './helpers';
import { printIonWarning } from './logging';
import { isPlatform } from './platform';
let lastOverlayIndex = 0;
let lastId = 0;
@@ -973,18 +974,26 @@ export const createTriggerController = () => {
* If the overlay is being presented, it prevents focus rings from appearing
* in incorrect positions due to the transition (specifically `transform`
* styles), ensuring that when aria-hidden is removed, the focus rings are
* correctly displayed in the final location of the elements.
* correctly displayed in the final location of the elements. This only
* applies to Android devices.
*
* If this solution is applied to iOS devices, then it leads to a bug where
* the overlays cannot be accessed by screen readers. This is due to
* VoiceOver not being able to update the accessibility tree when the
* `aria-hidden` is removed.
*
* @param overlay - The overlay that is being animated.
*/
const hideAnimatingOverlayFromScreenReaders = (overlay: HTMLIonOverlayElement) => {
if (doc === undefined) return;
/**
* Once the animation is complete, this attribute will be removed.
* This is done at the end of the `present` method.
*/
overlay.setAttribute('aria-hidden', 'true');
if (isPlatform('android')) {
/**
* Once the animation is complete, this attribute will be removed.
* This is done at the end of the `present` method.
*/
overlay.setAttribute('aria-hidden', 'true');
}
};
/**