Compare commits

..

7 Commits

Author SHA1 Message Date
amandaesmith3
fd6df84e2a more comments 2024-02-15 12:37:35 -06:00
amandaesmith3
d422302d98 add in progress fix with comments 2024-02-15 12:24:23 -06:00
amandaesmith3
c483ad6fb2 add safe area option to existing test 2024-02-12 09:12:39 -06:00
Amanda Johnston
ba4ba6161c fix(overlays): ensure that only topmost overlay is announced by screen readers (#28997)
Issue number: resolves #23472

---------

<!-- 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. -->

If multiple overlays are presented at the same time, none of them
receive `aria-hidden="true"`. This means that screen readers can read
contents from overlays behind the current one, which can be confusing
for users.

The original issue also reports router outlets getting `aria-hidden`
removed when any overlay is dismissed, not just the last one, but we've
since fixed that:
35ab6b4816/core/src/utils/overlays.ts (L573-L576)

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

All overlays besides the topmost one now receive `aria-hidden="true"`.
This means that screen readers will only announce the topmost overlay.

## 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/.github/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-02-09 15:43:54 +00:00
dependabot[bot]
adc5655d95 chore(deps-dev): Bump @capacitor/core from 5.6.0 to 5.7.0 in /core (#28998)
Bumps [@capacitor/core](https://github.com/ionic-team/capacitor) from
5.6.0 to 5.7.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ionic-team/capacitor/releases"><code>@​capacitor/core</code>'s
releases</a>.</em></p>
<blockquote>
<h2>5.7.0</h2>
<h1><a
href="https://github.com/ionic-team/capacitor/compare/5.6.0...5.7.0">5.7.0</a>
(2024-02-07)</h1>
<h3>Bug Fixes</h3>
<ul>
<li><strong>cli:</strong> correctly build and sign Android apps using
Flavors (<a
href="https://redirect.github.com/ionic-team/capacitor/issues/7211">#7211</a>)
(<a
href="af97904d05">af97904</a>)</li>
<li><strong>http:</strong> better handling of active requests and
shutting down gracefully (<a
href="a56e84546d">a56e845</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li><strong>webview:</strong> add setServerAssetPath method (<a
href="4e8449c1b5">4e8449c</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ionic-team/capacitor/blob/5.7.0/CHANGELOG.md"><code>@​capacitor/core</code>'s
changelog</a>.</em></p>
<blockquote>
<h1><a
href="https://github.com/ionic-team/capacitor/compare/5.6.0...5.7.0">5.7.0</a>
(2024-02-07)</h1>
<h3>Bug Fixes</h3>
<ul>
<li><strong>cli:</strong> correctly build and sign Android apps using
Flavors (<a
href="https://redirect.github.com/ionic-team/capacitor/issues/7211">#7211</a>)
(<a
href="af97904d05">af97904</a>)</li>
<li><strong>http:</strong> better handling of active requests and
shutting down gracefully (<a
href="a56e84546d">a56e845</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li><strong>webview:</strong> add setServerAssetPath method (<a
href="4e8449c1b5">4e8449c</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="e1a358d071"><code>e1a358d</code></a>
Release 5.7.0</li>
<li><a
href="a56e84546d"><code>a56e845</code></a>
fix(http): better handling of active requests and shutting down
gracefully</li>
<li><a
href="af97904d05"><code>af97904</code></a>
fix(cli): correctly build and sign Android apps using Flavors (<a
href="https://redirect.github.com/ionic-team/capacitor/issues/7211">#7211</a>)</li>
<li><a
href="bbba372adf"><code>bbba372</code></a>
chore(android): Deprecate PluginCall hasOption (<a
href="https://redirect.github.com/ionic-team/capacitor/issues/7212">#7212</a>)</li>
<li><a
href="4e8449c1b5"><code>4e8449c</code></a>
feat(webview): add setServerAssetPath method</li>
<li>See full diff in <a
href="https://github.com/ionic-team/capacitor/compare/5.6.0...5.7.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@capacitor/core&package-manager=npm_and_yarn&previous-version=5.6.0&new-version=5.7.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-09 01:30:03 +00:00
Liam DeBeasi
1ca9aa5246 test(toast): reset config to avoid unnecessary setTimeouts (#29004)
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. -->

Team members are running into unexpected errors running spec tests:

```
TypeError: Cannot read properties of undefined (reading '$instanceValues$')
```

This line is the culprit:
f885a5526a/core/src/components/toast/test/toast.spec.tsx (L108)
We set the Ionic config for toastDuration to 5000. This means that on
present a setTimeout callback will fire after 5000ms and dismiss the
toast. For this test, this is fine because we never present the toast
therefore the setTimeout is never created.

The problem is that this config is not automatically reset between
tests. As a result, when we have tests that only present the toast (and
never dismiss it) the duration is also 5000 there:
f885a5526a/core/src/components/toast/test/toast.spec.tsx (L179-L184)

This results in a bunch of setTimeouts being created. The timeout
callback runs dismiss, and the body of that function tries to access
data on the host toast. Since the toast instance has already been torn
down (since the tests are done), the undefined error occurs.


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

- Reset the Ionic config after the test that sets `toastDuration` to
`5000`

## 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/.github/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-02-08 18:50:48 +00:00
Liam DeBeasi
e833ad4649 docs(overlays): clarify how to remove an overlay (#28989)
In https://github.com/ionic-team/ionic-framework/issues/28981 there was
some confusion surrounding how to remove an overlay from the DOM if it
was never presented. The `dismiss` method will remove the overlay from
the DOM, but only if the overlay is visible. Otherwise, it's a no-op.

This PR updates the `dismiss` method docs for each overlay component to
note that developers can use the browser's remove method to remove the
element from the DOM.
2024-02-08 18:36:24 +00:00
20 changed files with 381 additions and 108 deletions

14
core/package-lock.json generated
View File

@@ -15,7 +15,7 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.8.4",
"@capacitor/core": "^5.6.0",
"@capacitor/core": "^5.7.0",
"@capacitor/haptics": "^5.0.7",
"@capacitor/keyboard": "^5.0.8",
"@capacitor/status-bar": "^5.0.7",
@@ -634,9 +634,9 @@
"dev": true
},
"node_modules/@capacitor/core": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.6.0.tgz",
"integrity": "sha512-xJhCOUGPHw0QYDA3YH+CmL6qiV9DH4Ij3yPxSenymjrtLuXI197u9ddCZwGEwgVIkh9kGZBBKzsNkn89SZ2gdQ==",
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.7.0.tgz",
"integrity": "sha512-wa9Fao+Axa1t2ZERMyQD9r0xyfglQyC4DHQKintzKaIqcRuVe9J31TmfD3IxROYi9LGpY4X8cq4m4bjb0W94Qg==",
"dev": true,
"dependencies": {
"tslib": "^2.1.0"
@@ -11324,9 +11324,9 @@
"dev": true
},
"@capacitor/core": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.6.0.tgz",
"integrity": "sha512-xJhCOUGPHw0QYDA3YH+CmL6qiV9DH4Ij3yPxSenymjrtLuXI197u9ddCZwGEwgVIkh9kGZBBKzsNkn89SZ2gdQ==",
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.7.0.tgz",
"integrity": "sha512-wa9Fao+Axa1t2ZERMyQD9r0xyfglQyC4DHQKintzKaIqcRuVe9J31TmfD3IxROYi9LGpY4X8cq4m4bjb0W94Qg==",
"dev": true,
"requires": {
"tslib": "^2.1.0"

View File

@@ -37,7 +37,7 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.8.4",
"@capacitor/core": "^5.6.0",
"@capacitor/core": "^5.7.0",
"@capacitor/haptics": "^5.0.7",
"@capacitor/keyboard": "^5.0.8",
"@capacitor/status-bar": "^5.0.7",

View File

@@ -160,7 +160,7 @@ export namespace Components {
/**
* Dismiss the action sheet overlay after it has been presented.
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the action sheet. This can be useful in a button handler for determining which button was clicked to dismiss the action sheet. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
* @param role The role of the element that is dismissing the action sheet. This can be useful in a button handler for determining which button was clicked to dismiss the action sheet. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`. This is a no-op if the overlay has not been presented yet. If you want to remove an overlay from the DOM that was never presented, use the [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
"dismiss": (data?: any, role?: string) => Promise<boolean>;
/**
@@ -239,7 +239,7 @@ export namespace Components {
/**
* Dismiss the alert overlay after it has been presented.
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the alert. This can be useful in a button handler for determining which button was clicked to dismiss the alert. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
* @param role The role of the element that is dismissing the alert. This can be useful in a button handler for determining which button was clicked to dismiss the alert. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`. This is a no-op if the overlay has not been presented yet. If you want to remove an overlay from the DOM that was never presented, use the [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
"dismiss": (data?: any, role?: string) => Promise<boolean>;
/**
@@ -1527,7 +1527,7 @@ export namespace Components {
/**
* Dismiss the loading overlay after it has been presented.
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the loading. This can be useful in a button handler for determining which button was clicked to dismiss the loading. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
* @param role The role of the element that is dismissing the loading. This can be useful in a button handler for determining which button was clicked to dismiss the loading. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`. This is a no-op if the overlay has not been presented yet. If you want to remove an overlay from the DOM that was never presented, use the [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
"dismiss": (data?: any, role?: string) => Promise<boolean>;
/**
@@ -1720,7 +1720,7 @@ export namespace Components {
/**
* Dismiss the modal overlay after it has been presented.
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the modal. For example, 'cancel' or 'backdrop'.
* @param role The role of the element that is dismissing the modal. For example, 'cancel' or 'backdrop'. This is a no-op if the overlay has not been presented yet. If you want to remove an overlay from the DOM that was never presented, use the [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
"dismiss": (data?: any, role?: string) => Promise<boolean>;
/**
@@ -1973,7 +1973,7 @@ export namespace Components {
/**
* Dismiss the picker overlay after it has been presented.
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the picker. This can be useful in a button handler for determining which button was clicked to dismiss the picker. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
* @param role The role of the element that is dismissing the picker. This can be useful in a button handler for determining which button was clicked to dismiss the picker. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`. This is a no-op if the overlay has not been presented yet. If you want to remove an overlay from the DOM that was never presented, use the [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
"dismiss": (data?: any, role?: string) => Promise<boolean>;
/**
@@ -2110,7 +2110,7 @@ export namespace Components {
* Dismiss the popover overlay after it has been presented.
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the popover. For example, 'cancel' or 'backdrop'.
* @param dismissParentPopover If `true`, dismissing this popover will also dismiss a parent popover if this popover is nested. Defaults to `true`.
* @param dismissParentPopover If `true`, dismissing this popover will also dismiss a parent popover if this popover is nested. Defaults to `true`. This is a no-op if the overlay has not been presented yet. If you want to remove an overlay from the DOM that was never presented, use the [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
"dismiss": (data?: any, role?: string, dismissParentPopover?: boolean) => Promise<boolean>;
/**
@@ -3111,7 +3111,7 @@ export namespace Components {
/**
* Dismiss the toast overlay after it has been presented.
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the toast. This can be useful in a button handler for determining which button was clicked to dismiss the toast. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
* @param role The role of the element that is dismissing the toast. This can be useful in a button handler for determining which button was clicked to dismiss the toast. Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`. This is a no-op if the overlay has not been presented yet. If you want to remove an overlay from the DOM that was never presented, use the [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
"dismiss": (data?: any, role?: string) => Promise<boolean>;
/**

View File

@@ -216,6 +216,10 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
* This can be useful in a button handler for determining which button was
* clicked to dismiss the action sheet.
* Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
*
* This is a no-op if the overlay has not been presented yet. If you want
* to remove an overlay from the DOM that was never presented, use the
* [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
@Method()
async dismiss(data?: any, role?: string): Promise<boolean> {

View File

@@ -411,6 +411,10 @@ export class Alert implements ComponentInterface, OverlayInterface {
* This can be useful in a button handler for determining which button was
* clicked to dismiss the alert.
* Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
*
* This is a no-op if the overlay has not been presented yet. If you want
* to remove an overlay from the DOM that was never presented, use the
* [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
@Method()
async dismiss(data?: any, role?: string): Promise<boolean> {

View File

@@ -84,9 +84,6 @@ export class InfiniteScroll implements ComponentInterface {
*/
@Event() ionInfinite!: EventEmitter<void>;
private scrollHeight: number = 0;
async connectedCallback() {
const contentEl = findClosestIonContent(this.el);
if (!contentEl) {
@@ -105,33 +102,6 @@ export class InfiniteScroll implements ComponentInterface {
}
}
async componentDidLoad() {
const contentEl = findClosestIonContent(this.el)!;
const scrollEl = await getScrollElement(contentEl);
const mo = new MutationObserver(async () => {
// wait for items to by hydrated so they have a dimension
const item = document.querySelectorAll('ion-item');
const lastItem = item[0];
await lastItem.componentOnReady();
// restore scroll position
const newScrollTop = scrollEl.scrollHeight - this.scrollHeight;
// TODO not sure why we need to set scrollTop twice
// TODO every once in a while the first ionInfinite callback
// still has a flicker
scrollEl.scrollTop = newScrollTop;
requestAnimationFrame(() => {
scrollEl.scrollTop = newScrollTop;
});
this.isBusy = false;
this.didFire = false;
});
mo.observe(findClosestIonContent(this.el)!, { subtree: true, characterData: true, childList: true });
}
disconnectedCallback() {
this.enableScrollEvents(false);
this.scrollEl = undefined;
@@ -162,10 +132,6 @@ export class InfiniteScroll implements ComponentInterface {
if (!this.didFire) {
this.isLoading = true;
this.didFire = true;
// cache the scroll position before the DOM updates
this.scrollHeight = scrollEl.scrollHeight;
this.ionInfinite.emit();
return 3;
}
@@ -213,6 +179,28 @@ export class InfiniteScroll implements ComponentInterface {
* Done.
*/
this.isBusy = true;
// ******** DOM READ ****************
// Save the current content dimensions before the UI updates
const prev = scrollEl.scrollHeight - scrollEl.scrollTop;
// ******** DOM READ ****************
requestAnimationFrame(() => {
readTask(() => {
// UI has updated, save the new content dimensions
const scrollHeight = scrollEl.scrollHeight;
// New content was added on top, so the scroll position should be changed immediately to prevent it from jumping around
const newScrollTop = scrollHeight - prev;
// ******** DOM WRITE ****************
requestAnimationFrame(() => {
writeTask(() => {
scrollEl.scrollTop = newScrollTop;
this.isBusy = false;
this.didFire = false;
});
});
});
});
} else {
this.didFire = false;
}

View File

@@ -12,7 +12,6 @@
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
@@ -20,49 +19,42 @@
<ion-header>
<ion-toolbar>
<ion-title>Infinite Scroll - Basic</ion-title>
<ion-buttons slot="end">
<ion-button onclick="doScroll()">scroll to top</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding" id="content">
<ion-infinite-scroll position="top" threshold="100px" id="infinite-scroll">
<ion-button onclick="toggleInfiniteScroll()" expand="block"> Toggle InfiniteScroll </ion-button>
<ion-list id="list"></ion-list>
<ion-infinite-scroll threshold="100px" id="infinite-scroll">
<ion-infinite-scroll-content loading-spinner="crescent" loading-text="Loading more data...">
</ion-infinite-scroll-content>
</ion-infinite-scroll>
<ion-list id="list"></ion-list>
</ion-content>
</ion-app>
<script>
const list = document.getElementById('list');
const infiniteScroll = document.getElementById('infinite-scroll');
const content = document.querySelector('ion-content');
let count = 0;
function toggleInfiniteScroll() {
infiniteScroll.disabled = !infiniteScroll.disabled;
}
infiniteScroll.addEventListener('ionInfinite', async function () {
await wait(500);
appendItems();
infiniteScroll.complete();
appendItems();
// Custom event consumed in the e2e tests
window.dispatchEvent(new CustomEvent('ionInfiniteComplete'));
});
function appendItems() {
const c = count;
for (var i = count; i < c + 30; i++) {
for (var i = 0; i < 30; i++) {
const el = document.createElement('ion-item');
el.textContent = `${1 + i}`;
list.prepend(el);
count += 1;
list.appendChild(el);
}
}
@@ -75,25 +67,6 @@
}
appendItems();
// this piece is only needed if items are Ionic components instead of divs
// wait for Angular to load items into the DOM
const observer = new MutationObserver(async () => {
const firstItem = document.querySelector('ion-item');
// wait for item component to be hydrated
await firstItem.componentOnReady();
observer.disconnect();
content.scrollToBottom();
});
observer.observe(list, { childList: true });
const doScroll = () => {
content.scrollToTop();
}
</script>
</body>
</html>

View File

@@ -268,6 +268,10 @@ export class Loading implements ComponentInterface, OverlayInterface {
* This can be useful in a button handler for determining which button was
* clicked to dismiss the loading.
* Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
*
* This is a no-op if the overlay has not been presented yet. If you want
* to remove an overlay from the DOM that was never presented, use the
* [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
@Method()
async dismiss(data?: any, role?: string): Promise<boolean> {

View File

@@ -662,6 +662,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
*
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the modal. For example, 'cancel' or 'backdrop'.
*
* This is a no-op if the overlay has not been presented yet. If you want
* to remove an overlay from the DOM that was never presented, use the
* [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
@Method()
async dismiss(data?: any, role?: string): Promise<boolean> {

View File

@@ -248,6 +248,10 @@ export class Picker implements ComponentInterface, OverlayInterface {
* This can be useful in a button handler for determining which button was
* clicked to dismiss the picker.
* Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
*
* This is a no-op if the overlay has not been presented yet. If you want
* to remove an overlay from the DOM that was never presented, use the
* [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
@Method()
async dismiss(data?: any, role?: string): Promise<boolean> {

View File

@@ -53,7 +53,15 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
);
const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
const margin = size === 'cover' ? 0 : 25;
/**
* NOTE: The original experience guessed at a safe area margin of 25
* for non-cover popovers. This was changed to always be 0 here for
* debugging purposes, so we can be sure any safe area adjustment we
* see is only from what we implement for this fix.
*/
// const margin = size === 'cover' ? 0 : 25;
const margin = 0;
const {
originX,
@@ -61,8 +69,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
top,
left,
bottom,
checkSafeAreaLeft,
checkSafeAreaRight,
// checkSafeAreaLeft,
// checkSafeAreaRight,
arrowTop,
arrowLeft,
addPopoverBottomClass,
@@ -122,29 +130,88 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
contentEl.style.setProperty('bottom', `${bottom}px`);
}
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
let leftValue = `${left}px`;
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
/**
* NOTE: We account for safe area through pure CSS by clamping the popover position
* between the safe area bounds. This works except for the arrow position in certain
* circumstances. (See comments on arrow position below for what.) Breakdown of
* values used for the top position:
*
* - Min: calc(var(--ion-safe-area-top, 0px) + var(--offset-y, 0px) + ${arrowHeight}px)
* Top edge of safe area, plus custom y offset, plus a little gap to make room for the arrow.
* Note that arrowHeight resolves to 0px if the arrow is not displayed.
*
* - Preferred: calc(${top}px + var(--offset-y, 0px))
* Normal calculated value of the popover, including any custom offset. The value of top
* already accounts for all conditions aside from safe area, since it's what we were using
* before the fix.
*
* - Max: calc(100% - ${contentHeight + arrowHeight}px - var(--ion-safe-area-bottom) + var(--offset-y, 0px))
* Bottom edge of screen, minus the total height of the popover (content + arrow), minus
* the bottom safe area margin, including custom y offset.
*
* The left position is the same, but using x-axis values instead. We do this through
* pure CSS to avoid needing to call window.getComputedStyle() to figure out the computed
* safe area, which is very expensive and would introduce performance issues.
*
* The clamp() function is available in all browsers we support as of Ionic v7, except for
* Firefox. The function was introduced in Firefox 75, which is conveniently the new minimum
* supported version in Ionic v8. As such, we'll probably want to just push the fix to v8,
* if v8 isn't already out by the time we get to this.
*/
contentEl.style.setProperty('top', `clamp(calc(var(--ion-safe-area-top, 0px) + var(--offset-y, 0px) + ${arrowHeight}px), calc(${top}px + var(--offset-y, 0px)), calc(100% - ${contentHeight + arrowHeight}px - var(--ion-safe-area-bottom) + var(--offset-y, 0px)))`);
contentEl.style.setProperty('left', `clamp(calc(var(--ion-safe-area-left, 0px) + var(--offset-x, 0px)), calc(${left}px + var(--offset-x, 0px)), calc(100% - ${contentWidth}px - var(--ion-safe-area-right) + var(--offset-x, 0px)))`);
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
if (arrowEl !== null) {
const didAdjustBounds = results.top !== top || results.left !== left;
const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger);
/**
* NOTE: Currently the fix assumes a default value for the side prop, which always
* puts the arrow either above or below the popover. There are additional tweaks
* needed to handle the arrow being on the left or right side.
*/
if (showArrow) {
arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`);
arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`);
/**
* NOTE: Basically the same positioning logic as the popover content, but using the
* existing arrowTop and arrowLeft values instead. The hardcoded 5px in the left
* position was an early attempt at preventing the arrow from being flush with
* the left/right edge of the content, which causes it to look disconnected due
* to the content's border radius.
*
* The problem with this approach is that the popover content hits the max value
* sooner than the arrow, due to being taller. If you present a popover low enough
* on the screen to need safe area adjustment, but not so low that the popover flips
* to present above the trigger, the arrow will render overlapping the popover because
* the content has been adjusted for safe area but the arrow has not.
*
* In theory, the most straightforward way of fixing this would be to calculate the
* final rendered position of the popover and position the arrow relative to that.
* However, this would require using window.getComputedStyle(), which is exactly
* what we're trying to avoid.
*/
arrowEl.style.setProperty('top', `clamp(calc(var(--ion-safe-area-top, 0px) + var(--offset-y, 0px)), calc(${arrowTop}px + var(--offset-y, 0px)), calc(100% - ${arrowHeight + contentHeight}px - var(--ion-safe-area-bottom) + var(--offset-y, 0px)))`);
arrowEl.style.setProperty('left', `clamp(calc(var(--ion-safe-area-left, 0px) + var(--offset-x, 0px) + 5px), calc(${arrowLeft}px + var(--offset-x, 0px)), calc(100% - ${arrowWidth}px - var(--ion-safe-area-right) + var(--offset-x, 0px) - 5px))`);
/**
* NOTE: An early attempt at positioning the arrow relative to the content.
* See comments in popover.tsx for details.
*/
// arrowEl.style.setProperty('top', `-${arrowHeight}px`);
// arrowEl.style.setProperty('left', `calc(${contentWidth / 2}px - ${arrowWidth / 2}px)`);
/**
* NOTE: Some quick and dirty debugging code which will position some debug elements
* in the adjustment test template at the min, preferred, and max values for the
* popover content's position. This can help visualize how things are being calculated.
*/
// const minLine = document.querySelector('#min-line') as HTMLElement;
// const preferredLine = document.querySelector('#preferred-line') as HTMLElement;
// const maxLine = document.querySelector('#max-line') as HTMLElement;
// minLine!.style.top = `calc(var(--ion-safe-area-top, 0px) + var(--offset-y, 0px))`;
// preferredLine!.style.top = `calc(${arrowTop}px + var(--offset-y, 0px))`;
// maxLine!.style.top = `calc(100% - ${arrowHeight + contentHeight}px - var(--ion-safe-area-bottom) + var(--offset-y, 0px))`;
} else {
arrowEl.style.setProperty('display', 'none');
}

View File

@@ -78,11 +78,19 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
wrapperAnimation.addElement(root.querySelector('.popover-wrapper')!).duration(150).fromTo('opacity', 0.01, 1);
/**
* NOTE: See ios.enter.ts for details on what this is doing. For MD, this should work great.
* Technically, the logic for whether to flip the direction of the opening animation doesn't
* account for safe area, so there's a safe-area-margin-sized window where it flips in
* the wrong direction. However, because the margin is usually pretty small, this is barely
* noticeable in real world contexts. The popover is always positioned in a way that "feels"
* correct, and IMO this is an acceptable compromise.
*/
contentAnimation
.addElement(contentEl)
.beforeStyles({
top: `calc(${top}px + var(--offset-y, 0px))`,
left: `calc(${left}px + var(--offset-x, 0px))`,
top: `clamp(calc(var(--ion-safe-area-top, 0) + var(--offset-y)), calc(${top}px + var(--offset-y, 0px)), calc(100% - ${contentHeight}px - var(--ion-safe-area-bottom)))`,
left: `clamp(calc(var(--ion-safe-area-left, 0) + var(--offset-x)), calc(${left}px + var(--offset-x, 0px)), calc(100% - ${contentWidth}px - var(--ion-safe-area-right)))`,
'transform-origin': `${originY} ${originX}`,
})
.beforeAddWrite(() => {

View File

@@ -42,6 +42,9 @@
* will allow the arrow to render above the backdrop.
*/
z-index: 11;
// NOTE: Useful for debugging purposes when the arrow overlaps the content.
// --background: pink;
}
.popover-arrow::after {

View File

@@ -525,6 +525,10 @@ export class Popover implements ComponentInterface, PopoverInterface {
* @param role The role of the element that is dismissing the popover. For example, 'cancel' or 'backdrop'.
* @param dismissParentPopover If `true`, dismissing this popover will also dismiss
* a parent popover if this popover is nested. Defaults to `true`.
*
* This is a no-op if the overlay has not been presented yet. If you want
* to remove an overlay from the DOM that was never presented, use the
* [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
@Method()
async dismiss(data?: any, role?: string, dismissParentPopover = true): Promise<boolean> {
@@ -690,6 +694,21 @@ export class Popover implements ComponentInterface, PopoverInterface {
{!parentPopover && <ion-backdrop tappable={this.backdropDismiss} visible={this.showBackdrop} part="backdrop" />}
<div class="popover-wrapper ion-overlay-wrapper" onClick={dismissOnSelect ? () => this.dismiss() : undefined}>
{/**
* NOTE: One possibility is moving the arrow inside .popover-content and positioning the arrow relative
* to the content instead of to the whole screen. This has a few issues that we didn't have time to
* sort out:
* 1. The content hides overflow, so the arrow is no longer visible. However, setting overflow:visible
* also breaks the border radius for some reason. (Unsure why yet since border radius is set on the
* content el itself, so in theory overflow:hidden shouldn't be needed.)
* 2. Additional logic is needed to handle the arrow being above or below the content based on whether
* the popover has to flip due to being close to the bottom edge of the screen. calculateWindowAdjustment
* returns an addPopoverBottomClass var in the iOS enter animation that we may be able to use for this.
* (There are additional considerations for when popover's side prop is used to move the arrow, though.)
* 3. Additional logic is needed to handle the arrow moving horizontally to point at the trigger/event
* position. For example, if the popover is presented near the right edge of the screen, the arrow
* might be positioned more to the right instead of centered.
*/}
{enableArrow && <div class="popover-arrow" part="arrow"></div>}
<div class="popover-content" part="content">
<slot></slot>

View File

@@ -16,27 +16,112 @@
import { popoverController } from '../../../../dist/ionic/index.esm.js';
window.popoverController = popoverController;
</script>
<!-- <style>
.line {
position: absolute;
width: 100%;
height: 1px;
left: 0;
}
#min-line {
background-color: blue;
}
#preferred-line {
background-color: green;
}
#max-line {
background-color: red;
}
</style> -->
</head>
<body>
<ion-app>
<ion-content>
<p style="text-align: center">Click everywhere to open the popover.</p>
<!-- NOTE: Debugging elements to help visualize how the popover's position
is calculated. See comments near the bottom of ios.enter.ts for details. -->
<!-- <div id="min-line" class="line"></div>
<div id="preferred-line" class="line"></div>
<div id="max-line" class="line"></div> -->
<div style="text-align: center">
<p>Click everywhere to open the popover.</p>
<ion-button id="options-btn">Options</ion-button>
<ion-popover trigger="options-btn" class="options-popover">
<ion-list>
<ion-item>
<ion-checkbox id="safe-area-cb">Enable Safe Area</ion-checkbox>
</ion-item>
<ion-item>
<ion-checkbox id="offset-cb">Enable Popover Offset</ion-checkbox>
</ion-item>
</ion-list>
</ion-popover>
</div>
</ion-content>
</ion-app>
<script>
document.querySelector('ion-content').addEventListener('click', handleButtonClick);
document.querySelector('#safe-area-cb').addEventListener('ionChange', toggleSafeArea);
document.querySelector('#offset-cb').addEventListener('ionChange', toggleOffset);
async function handleButtonClick(ev) {
const targetEl = ev.target.tagName;
if (targetEl === 'ION-CHECKBOX' || targetEl === 'ION-BUTTON') return;
const popover = await popoverController.create({
component: 'popover-example-page',
event: ev,
reference: 'event',
reference: 'event'
});
popover.present();
}
function toggleStyles(ev, styles, styleId) {
if(ev.detail.checked) {
document.head.insertAdjacentHTML('beforeend', `
<style id="${styleId}">
${styles}
</style>
`);
} else {
const styles = document.querySelector(`#${styleId}`);
if(styles) styles.remove();
}
}
function toggleSafeArea(ev) {
toggleStyles(ev, `
html {
--ion-safe-area-top: 50px;
--ion-safe-area-right: 50px;
--ion-safe-area-bottom: 50px;
--ion-safe-area-left: 50px;
}
ion-content::part(background) {
box-shadow: inset 0 0 0 50px lightblue;
}
`, 'safe-area-styles');
}
/**
* NOTE: Make sure to also check any further fixes against a negative offset.
*/
function toggleOffset(ev) {
toggleStyles(ev, `
ion-popover:not(.options-popover) {
--offset-x: 25px;
--offset-y: 25px;
}
`, 'offset-styles');
}
customElements.define(
'popover-example-page',
class PopoverContent extends HTMLElement {

View File

@@ -93,6 +93,19 @@ describe('toast: a11y smoke test', () => {
});
describe('toast: duration config', () => {
afterEach(() => {
/**
* Important: Reset the config
* after each test as it is not
* automatically reset.
* Otherwise, toasts in other tests
* will take on any toastDuration value
* set and timeouts will potentially run
* after tests are finished.
*/
config.reset({});
});
it('should have duration set to 0', async () => {
const page = await newSpecPage({
components: [Toast],

View File

@@ -401,6 +401,10 @@ export class Toast implements ComponentInterface, OverlayInterface {
* This can be useful in a button handler for determining which button was
* clicked to dismiss the toast.
* Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
*
* This is a no-op if the overlay has not been presented yet. If you want
* to remove an overlay from the DOM that was never presented, use the
* [remove](https://developer.mozilla.org/en-US/docs/Web/API/Element/remove) method.
*/
@Method()
async dismiss(data?: any, role?: string): Promise<boolean> {

View File

@@ -491,6 +491,16 @@ export const present = async <OverlayPresentOptions>(
setRootAriaHidden(true);
/**
* Hide all other overlays from screen readers so only this one
* can be read. Note that presenting an overlay always makes
* it the topmost one.
*/
if (doc !== undefined) {
const presentedOverlays = getPresentedOverlays(doc);
presentedOverlays.forEach((o) => o.setAttribute('aria-hidden', 'true'));
}
overlay.presented = true;
overlay.willPresent.emit();
overlay.willPresentShorthand?.emit();
@@ -528,6 +538,15 @@ export const present = async <OverlayPresentOptions>(
if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) {
overlay.el.focus();
}
/**
* If this overlay was previously dismissed without being
* the topmost one (such as by manually calling dismiss()),
* it would still have aria-hidden on being presented again.
* Removing it here ensures the overlay is visible to screen
* readers.
*/
overlay.el.removeAttribute('aria-hidden');
};
/**
@@ -625,6 +644,15 @@ export const dismiss = async <OverlayDismissOptions>(
}
overlay.el.remove();
/**
* If there are other overlays presented, unhide the new
* topmost one from screen readers.
*/
if (doc !== undefined) {
getPresentedOverlay(doc)?.removeAttribute('aria-hidden');
}
return true;
};

View File

@@ -62,7 +62,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
Modal Content
Modal ${id}
<ion-item>
<ion-input label="Text Input" class="modal-input modal-input-${id}"></ion-input>

View File

@@ -129,3 +129,68 @@ describe('setRootAriaHidden()', () => {
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
});
});
describe('aria-hidden on individual overlays', () => {
it('should hide non-topmost overlays from screen readers', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal id="one"></ion-modal>
<ion-modal id="two"></ion-modal>
`,
});
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
await modalOne.present();
await modalTwo.present();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
});
it('should unhide new topmost overlay from screen readers when topmost is dismissed', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal id="one"></ion-modal>
<ion-modal id="two"></ion-modal>
`,
});
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
await modalOne.present();
await modalTwo.present();
// dismiss modalTwo so that modalOne becomes the new topmost overlay
await modalTwo.dismiss();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
});
it('should not keep overlays hidden from screen readers if presented after being dismissed while non-topmost', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal id="one"></ion-modal>
<ion-modal id="two"></ion-modal>
`,
});
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
await modalOne.present();
await modalTwo.present();
// modalOne is not the topmost overlay at this point and is hidden from screen readers
await modalOne.dismiss();
// modalOne will become the topmost overlay; ensure it isn't still hidden from screen readers
await modalOne.present();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
});
});