Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f332f62cbd | ||
|
|
3b80473f2f | ||
|
|
99d2b731f5 | ||
|
|
515249d2c3 | ||
|
|
a40d957ad9 | ||
|
|
5a06503d4a | ||
|
|
49f7cc7704 | ||
|
|
024d090122 | ||
|
|
36c56e71b6 | ||
|
|
9e361727b8 | ||
|
|
6d4cb0f4e2 | ||
|
|
2847681f7b | ||
|
|
4c774601ec | ||
|
|
b3b02416a3 | ||
|
|
be1f3f32f0 | ||
|
|
66f517d5b2 | ||
|
|
c339bc3682 | ||
|
|
49f96d7f1e |
@@ -3,7 +3,7 @@ description: 'Build Ionic Angular Server'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic Angular'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -9,7 +9,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Install Dependencies
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic React Router'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic React'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Builds Ionic Vue Router'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic Vue'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -19,7 +19,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
# Provenance requires npm 9.5.0+
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Test Core Clean Build'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Test Core Lint'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Install Dependencies
|
||||
|
||||
@@ -13,7 +13,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Install Dependencies
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: actions/download-artifact@v5
|
||||
|
||||
2
.github/workflows/assign-issues.yml
vendored
@@ -13,6 +13,6 @@ jobs:
|
||||
- name: 'Auto-assign issue'
|
||||
uses: pozil/auto-assign-issue@39c06395cbac76e79afc4ad4e5c5c6db6ecfdd2e # v2.2.0
|
||||
with:
|
||||
assignees: brandyscarney, ShaneK
|
||||
assignees: brandyscarney, thetaPC, ShaneK
|
||||
numOfAssignee: 1
|
||||
allowSelfAssign: false
|
||||
|
||||
2
.github/workflows/label.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
sync-labels: true
|
||||
|
||||
25
CHANGELOG.md
@@ -3,6 +3,31 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.5](https://github.com/ionic-team/ionic-framework/compare/v8.7.4...v8.7.5) (2025-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **modal:** allow sheet modals to skip focus trap ([#30689](https://github.com/ionic-team/ionic-framework/issues/30689)) ([a40d957](https://github.com/ionic-team/ionic-framework/commit/a40d957ad9c1897af365a91b45b00228a00d614c)), closes [#30684](https://github.com/ionic-team/ionic-framework/issues/30684)
|
||||
* **vue:** emit component-specific overlay events ([#30688](https://github.com/ionic-team/ionic-framework/issues/30688)) ([024d090](https://github.com/ionic-team/ionic-framework/commit/024d090122548e26ec2cdcfae4637dde8f288278)), closes [#30641](https://github.com/ionic-team/ionic-framework/issues/30641)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.4](https://github.com/ionic-team/ionic-framework/compare/v8.7.3...v8.7.4) (2025-09-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **input:** improve error text accessibility ([#30635](https://github.com/ionic-team/ionic-framework/issues/30635)) ([c339bc3](https://github.com/ionic-team/ionic-framework/commit/c339bc36827b62ef871325869a9a5db9b17ac785))
|
||||
* **overlays,picker:** remove invalid aria-hidden attribute ([#30563](https://github.com/ionic-team/ionic-framework/issues/30563)) ([49f96d7](https://github.com/ionic-team/ionic-framework/commit/49f96d7f1e9050a95e3e33a821c0467ecc0bed64)), closes [#30040](https://github.com/ionic-team/ionic-framework/issues/30040)
|
||||
* **segment-view:** scroll and select the right item when the component is in RTL context; ([#30675](https://github.com/ionic-team/ionic-framework/issues/30675)) ([66f517d](https://github.com/ionic-team/ionic-framework/commit/66f517d5b2154fff00b294a78f4107f057a580c6)), closes [#30079](https://github.com/ionic-team/ionic-framework/issues/30079)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.3](https://github.com/ionic-team/ionic-framework/compare/v8.7.2...v8.7.3) (2025-08-20)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.5](https://github.com/ionic-team/ionic-framework/compare/v8.7.4...v8.7.5) (2025-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **modal:** allow sheet modals to skip focus trap ([#30689](https://github.com/ionic-team/ionic-framework/issues/30689)) ([a40d957](https://github.com/ionic-team/ionic-framework/commit/a40d957ad9c1897af365a91b45b00228a00d614c)), closes [#30684](https://github.com/ionic-team/ionic-framework/issues/30684)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.4](https://github.com/ionic-team/ionic-framework/compare/v8.7.3...v8.7.4) (2025-09-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **input:** improve error text accessibility ([#30635](https://github.com/ionic-team/ionic-framework/issues/30635)) ([c339bc3](https://github.com/ionic-team/ionic-framework/commit/c339bc36827b62ef871325869a9a5db9b17ac785))
|
||||
* **overlays,picker:** remove invalid aria-hidden attribute ([#30563](https://github.com/ionic-team/ionic-framework/issues/30563)) ([49f96d7](https://github.com/ionic-team/ionic-framework/commit/49f96d7f1e9050a95e3e33a821c0467ecc0bed64)), closes [#30040](https://github.com/ionic-team/ionic-framework/issues/30040)
|
||||
* **segment-view:** scroll and select the right item when the component is in RTL context; ([#30675](https://github.com/ionic-team/ionic-framework/issues/30675)) ([66f517d](https://github.com/ionic-team/ionic-framework/commit/66f517d5b2154fff00b294a78f4107f057a580c6)), closes [#30079](https://github.com/ionic-team/ionic-framework/issues/30079)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.3](https://github.com/ionic-team/ionic-framework/compare/v8.7.2...v8.7.3) (2025-08-20)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Get Playwright
|
||||
FROM mcr.microsoft.com/playwright:v1.55.0
|
||||
FROM mcr.microsoft.com/playwright:v1.55.1
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /ionic
|
||||
|
||||
86
core/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.36.2",
|
||||
@@ -22,7 +22,7 @@
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@rollup/plugin-node-resolve": "^8.4.0",
|
||||
"@rollup/plugin-virtual": "^2.0.3",
|
||||
"@stencil/angular-output-target": "^0.10.0",
|
||||
@@ -681,18 +681,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.2.tgz",
|
||||
"integrity": "sha512-9We5BY1mu+QWOReDukr+6HxA4Bh0mKBU0txFtwXJdjBohttMYWJzB+dQf4oHrX8odiU2Cm/BfDdAU2wV06Cyig==",
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.3.tgz",
|
||||
"integrity": "sha512-BIBKjmky5rOYNhvYhNeDi0MMvjwYZ6YF9JoCYcGKvKY+XLJKtezsEL78XfOlgWZBkbfR8uq3tzktY6PqgoYLKA==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.2.tgz",
|
||||
"integrity": "sha512-fYYkkdzCbQV+MjZVnaQTFl5I4bddnFW8ZrPVxDjNoGVPTUG7H58Ij1+NcuNxHLXjJvZOoZeYJ3w3I16Wb2zssw==",
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.3.tgz",
|
||||
"integrity": "sha512-JyRpVnKwHij9hgPWolF6PK+HT3e2HSPjN11/h2OmKxq8GAdPGARFLv+97eZl0pvuvm0Kka/LpiLb5whXISBg7Q==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
@@ -1715,12 +1715,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
|
||||
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz",
|
||||
"integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.55.0"
|
||||
"playwright": "1.55.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3474,9 +3474,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
|
||||
"integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
@@ -8593,12 +8593,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
|
||||
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
||||
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.55.0"
|
||||
"playwright-core": "1.55.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -8611,9 +8611,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
|
||||
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
||||
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -11118,16 +11118,16 @@
|
||||
"requires": {}
|
||||
},
|
||||
"@capacitor/keyboard": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.2.tgz",
|
||||
"integrity": "sha512-9We5BY1mu+QWOReDukr+6HxA4Bh0mKBU0txFtwXJdjBohttMYWJzB+dQf4oHrX8odiU2Cm/BfDdAU2wV06Cyig==",
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.3.tgz",
|
||||
"integrity": "sha512-BIBKjmky5rOYNhvYhNeDi0MMvjwYZ6YF9JoCYcGKvKY+XLJKtezsEL78XfOlgWZBkbfR8uq3tzktY6PqgoYLKA==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@capacitor/status-bar": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.2.tgz",
|
||||
"integrity": "sha512-fYYkkdzCbQV+MjZVnaQTFl5I4bddnFW8ZrPVxDjNoGVPTUG7H58Ij1+NcuNxHLXjJvZOoZeYJ3w3I16Wb2zssw==",
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.3.tgz",
|
||||
"integrity": "sha512-JyRpVnKwHij9hgPWolF6PK+HT3e2HSPjN11/h2OmKxq8GAdPGARFLv+97eZl0pvuvm0Kka/LpiLb5whXISBg7Q==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
@@ -11863,12 +11863,12 @@
|
||||
}
|
||||
},
|
||||
"@playwright/test": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
|
||||
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz",
|
||||
"integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"playwright": "1.55.0"
|
||||
"playwright": "1.55.1"
|
||||
}
|
||||
},
|
||||
"@rollup/plugin-node-resolve": {
|
||||
@@ -13076,9 +13076,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"chalk": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
|
||||
"integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"dev": true
|
||||
},
|
||||
"chalk-template": {
|
||||
@@ -16812,19 +16812,19 @@
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
|
||||
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
||||
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fsevents": "2.3.2",
|
||||
"playwright-core": "1.55.0"
|
||||
"playwright-core": "1.55.1"
|
||||
}
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
|
||||
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
|
||||
"version": "1.55.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
||||
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
||||
"dev": true
|
||||
},
|
||||
"postcss": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"description": "Base components for Ionic",
|
||||
"keywords": [
|
||||
"ionic",
|
||||
@@ -44,7 +44,7 @@
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@rollup/plugin-node-resolve": "^8.4.0",
|
||||
"@rollup/plugin-virtual": "^2.0.3",
|
||||
"@stencil/angular-output-target": "^0.10.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component, Element, Event, Host, Method, Prop, State, Watch, h, writeTa
|
||||
import { startFocusVisible } from '@utils/focus-visible';
|
||||
import { getElementRoot, raf, renderHiddenInput } from '@utils/helpers';
|
||||
import { printIonError, printIonWarning } from '@utils/logging';
|
||||
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
|
||||
import { isRTL } from '@utils/rtl';
|
||||
import { createColorClasses } from '@utils/theme';
|
||||
import { caretDownSharp, caretUpSharp, chevronBack, chevronDown, chevronForward } from 'ionicons/icons';
|
||||
@@ -1598,7 +1599,7 @@ export class Datetime implements ComponentInterface {
|
||||
forcePresentation === 'time-date'
|
||||
? [this.renderTimePickerColumns(forcePresentation), this.renderDatePickerColumns(forcePresentation)]
|
||||
: [this.renderDatePickerColumns(forcePresentation), this.renderTimePickerColumns(forcePresentation)];
|
||||
return <ion-picker>{renderArray}</ion-picker>;
|
||||
return <ion-picker class={FOCUS_TRAP_DISABLE_CLASS}>{renderArray}</ion-picker>;
|
||||
}
|
||||
|
||||
private renderDatePickerColumns(forcePresentation: string) {
|
||||
|
||||
@@ -79,8 +79,15 @@ export class Input implements ComponentInterface {
|
||||
*/
|
||||
@State() hasFocus = false;
|
||||
|
||||
/**
|
||||
* Track validation state for proper aria-live announcements
|
||||
*/
|
||||
@State() isInvalid = false;
|
||||
|
||||
@Element() el!: HTMLIonInputElement;
|
||||
|
||||
private validationObserver?: MutationObserver;
|
||||
|
||||
/**
|
||||
* The color to use from your application's color palette.
|
||||
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
|
||||
@@ -396,6 +403,16 @@ export class Input implements ComponentInterface {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the input is in an invalid state based on Ionic validation classes
|
||||
*/
|
||||
private checkInvalidState(): boolean {
|
||||
const hasIonTouched = this.el.classList.contains('ion-touched');
|
||||
const hasIonInvalid = this.el.classList.contains('ion-invalid');
|
||||
|
||||
return hasIonTouched && hasIonInvalid;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const { el } = this;
|
||||
|
||||
@@ -406,6 +423,26 @@ export class Input implements ComponentInterface {
|
||||
() => this.labelSlot
|
||||
);
|
||||
|
||||
// Watch for class changes to update validation state
|
||||
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
|
||||
this.validationObserver = new MutationObserver(() => {
|
||||
const newIsInvalid = this.checkInvalidState();
|
||||
if (this.isInvalid !== newIsInvalid) {
|
||||
this.isInvalid = newIsInvalid;
|
||||
// Force a re-render to update aria-describedby immediately
|
||||
forceUpdate(this);
|
||||
}
|
||||
});
|
||||
|
||||
this.validationObserver.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
|
||||
// Always set initial state
|
||||
this.isInvalid = this.checkInvalidState();
|
||||
|
||||
this.debounceChanged();
|
||||
if (Build.isBrowser) {
|
||||
document.dispatchEvent(
|
||||
@@ -451,6 +488,12 @@ export class Input implements ComponentInterface {
|
||||
this.notchController.destroy();
|
||||
this.notchController = undefined;
|
||||
}
|
||||
|
||||
// Clean up validation observer to prevent memory leaks
|
||||
if (this.validationObserver) {
|
||||
this.validationObserver.disconnect();
|
||||
this.validationObserver = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -626,22 +669,22 @@ export class Input implements ComponentInterface {
|
||||
* Renders the helper text or error text values
|
||||
*/
|
||||
private renderHintText() {
|
||||
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
||||
|
||||
return [
|
||||
<div id={helperTextId} class="helper-text">
|
||||
{helperText}
|
||||
<div id={helperTextId} class="helper-text" aria-live="polite">
|
||||
{!isInvalid ? helperText : null}
|
||||
</div>,
|
||||
<div id={errorTextId} class="error-text">
|
||||
{errorText}
|
||||
<div id={errorTextId} class="error-text" role="alert">
|
||||
{isInvalid ? errorText : null}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
const { el, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
|
||||
if (isInvalid && errorText) {
|
||||
return errorTextId;
|
||||
}
|
||||
|
||||
@@ -864,7 +907,7 @@ export class Input implements ComponentInterface {
|
||||
onCompositionstart={this.onCompositionStart}
|
||||
onCompositionend={this.onCompositionEnd}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||
aria-invalid={this.isInvalid ? 'true' : undefined}
|
||||
{...this.inheritedAttributes}
|
||||
/>
|
||||
{this.clearInput && !readonly && !disabled && (
|
||||
|
||||
284
core/src/components/input/test/validation/index.html
Normal file
@@ -0,0 +1,284 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Input - Validation</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<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>
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-row-gap: 20px;
|
||||
grid-column-gap: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
|
||||
color: var(--ion-color-step-600);
|
||||
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.validation-info {
|
||||
margin: 20px;
|
||||
padding: 10px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Input - Validation Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="validation-info">
|
||||
<h2>Screen Reader Testing Instructions:</h2>
|
||||
<ol>
|
||||
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
|
||||
<li>Tab through the form fields</li>
|
||||
<li>When you tab away from an empty required field, the error should be announced immediately</li>
|
||||
<li>The error text should be announced BEFORE the next field is announced</li>
|
||||
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>Required Email Field</h2>
|
||||
<ion-input
|
||||
id="email-input"
|
||||
type="email"
|
||||
label="Email"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your email"
|
||||
helper-text="We'll never share your email"
|
||||
error-text="Please enter a valid email address"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Required Name Field</h2>
|
||||
<ion-input
|
||||
id="name-input"
|
||||
type="text"
|
||||
label="Full Name"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your full name"
|
||||
helper-text="First and last name"
|
||||
error-text="Name is required"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Phone Number (Pattern Validation)</h2>
|
||||
<ion-input
|
||||
id="phone-input"
|
||||
type="tel"
|
||||
label="Phone"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="(555) 555-5555"
|
||||
pattern="^\(\d{3}\) \d{3}-\d{4}$"
|
||||
helper-text="Format: (555) 555-5555"
|
||||
error-text="Please enter a valid phone number"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Password (Min Length)</h2>
|
||||
<ion-input
|
||||
id="password-input"
|
||||
type="password"
|
||||
label="Password"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter password"
|
||||
minlength="8"
|
||||
helper-text="At least 8 characters"
|
||||
error-text="Password must be at least 8 characters"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Age (Number Range)</h2>
|
||||
<ion-input
|
||||
id="age-input"
|
||||
type="number"
|
||||
label="Age"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your age"
|
||||
min="18"
|
||||
max="120"
|
||||
helper-text="Must be 18 or older"
|
||||
error-text="Please enter a valid age (18-120)"
|
||||
required
|
||||
></ion-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Optional Field (No Validation)</h2>
|
||||
<ion-input
|
||||
id="optional-input"
|
||||
type="text"
|
||||
label="Optional Info"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="This field is optional"
|
||||
helper-text="You can skip this field"
|
||||
></ion-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ion-padding">
|
||||
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
|
||||
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
// Simple validation logic
|
||||
const inputs = document.querySelectorAll('ion-input');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
|
||||
// Track which fields have been touched
|
||||
const touchedFields = new Set();
|
||||
|
||||
// Validation functions
|
||||
const validators = {
|
||||
'email-input': (value) => {
|
||||
if (!value) return false;
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
},
|
||||
'name-input': (value) => {
|
||||
return value && value.trim().length > 0;
|
||||
},
|
||||
'phone-input': (value) => {
|
||||
if (!value) return false;
|
||||
return /^\(\d{3}\) \d{3}-\d{4}$/.test(value);
|
||||
},
|
||||
'password-input': (value) => {
|
||||
return value && value.length >= 8;
|
||||
},
|
||||
'age-input': (value) => {
|
||||
if (!value) return false;
|
||||
const age = parseInt(value);
|
||||
return age >= 18 && age <= 120;
|
||||
},
|
||||
'optional-input': () => true, // Always valid
|
||||
};
|
||||
|
||||
function validateField(input) {
|
||||
const inputId = input.id;
|
||||
const value = input.value;
|
||||
const isValid = validators[inputId] ? validators[inputId](value) : true;
|
||||
|
||||
// Only show validation state if field has been touched
|
||||
if (touchedFields.has(inputId)) {
|
||||
if (isValid) {
|
||||
input.classList.remove('ion-invalid');
|
||||
input.classList.add('ion-valid');
|
||||
} else {
|
||||
input.classList.remove('ion-valid');
|
||||
input.classList.add('ion-invalid');
|
||||
}
|
||||
input.classList.add('ion-touched');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
let allValid = true;
|
||||
inputs.forEach((input) => {
|
||||
if (input.id !== 'optional-input') {
|
||||
const isValid = validateField(input);
|
||||
if (!isValid) {
|
||||
allValid = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
submitBtn.disabled = !allValid;
|
||||
return allValid;
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
inputs.forEach((input) => {
|
||||
// Mark as touched on blur
|
||||
input.addEventListener('ionBlur', (e) => {
|
||||
touchedFields.add(input.id);
|
||||
validateField(input);
|
||||
validateForm();
|
||||
|
||||
const isInvalid = input.classList.contains('ion-invalid');
|
||||
if (isInvalid) {
|
||||
console.log('Field marked invalid:', input.label, input.errorText);
|
||||
}
|
||||
});
|
||||
|
||||
// Validate on input
|
||||
input.addEventListener('ionInput', (e) => {
|
||||
if (touchedFields.has(input.id)) {
|
||||
validateField(input);
|
||||
validateForm();
|
||||
}
|
||||
});
|
||||
|
||||
// Also validate on focus loss via native blur
|
||||
input.addEventListener('focusout', (e) => {
|
||||
// Small delay to ensure Ionic's classes are updated
|
||||
setTimeout(() => {
|
||||
touchedFields.add(input.id);
|
||||
validateField(input);
|
||||
validateForm();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset button
|
||||
resetBtn.addEventListener('click', () => {
|
||||
inputs.forEach((input) => {
|
||||
input.value = '';
|
||||
input.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
|
||||
});
|
||||
touchedFields.clear();
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
|
||||
// Submit button
|
||||
submitBtn.addEventListener('click', () => {
|
||||
if (validateForm()) {
|
||||
alert('Form submitted successfully!');
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup
|
||||
validateForm();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -418,6 +418,9 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
*/
|
||||
@Method()
|
||||
setOpen(shouldOpen: boolean, animated = true, role?: string): Promise<boolean> {
|
||||
// Blur the active element to prevent it from being kept focused inside an element that will be set with aria-hidden="true"
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
|
||||
return menuController._setOpen(this, shouldOpen, animated, role);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,16 @@ export const createSheetGesture = (
|
||||
const contentAnimation = animation.childAnimations.find((ani) => ani.id === 'contentAnimation');
|
||||
|
||||
const enableBackdrop = () => {
|
||||
// Respect explicit opt-out of focus trapping/backdrop interactions
|
||||
// If focusTrap is false or showBackdrop is false, do not enable the backdrop or re-enable focus trap
|
||||
const el = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
|
||||
const focusTrapAttr = el.getAttribute?.('focus-trap');
|
||||
const showBackdropAttr = el.getAttribute?.('show-backdrop');
|
||||
const focusTrapDisabled = el.focusTrap === false || focusTrapAttr === 'false';
|
||||
const backdropDisabled = el.showBackdrop === false || showBackdropAttr === 'false';
|
||||
if (focusTrapDisabled || backdropDisabled) {
|
||||
return;
|
||||
}
|
||||
baseEl.style.setProperty('pointer-events', 'auto');
|
||||
backdropEl.style.setProperty('pointer-events', 'auto');
|
||||
|
||||
@@ -235,7 +245,12 @@ export const createSheetGesture = (
|
||||
* ion-backdrop and .modal-wrapper always have pointer-events: auto
|
||||
* applied, so the modal content can still be interacted with.
|
||||
*/
|
||||
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint;
|
||||
const modalEl = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
|
||||
const focusTrapAttr = modalEl.getAttribute?.('focus-trap');
|
||||
const showBackdropAttr = modalEl.getAttribute?.('show-backdrop');
|
||||
const focusTrapDisabled = modalEl.focusTrap === false || focusTrapAttr === 'false';
|
||||
const backdropDisabled = modalEl.showBackdrop === false || showBackdropAttr === 'false';
|
||||
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint && !focusTrapDisabled && !backdropDisabled;
|
||||
if (shouldEnableBackdrop) {
|
||||
enableBackdrop();
|
||||
} else {
|
||||
@@ -582,7 +597,16 @@ export const createSheetGesture = (
|
||||
* Backdrop should become enabled
|
||||
* after the backdropBreakpoint value
|
||||
*/
|
||||
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint;
|
||||
const modalEl = baseEl as HTMLIonModalElement & {
|
||||
focusTrap?: boolean;
|
||||
showBackdrop?: boolean;
|
||||
};
|
||||
const focusTrapAttr = modalEl.getAttribute?.('focus-trap');
|
||||
const showBackdropAttr = modalEl.getAttribute?.('show-backdrop');
|
||||
const focusTrapDisabled = modalEl.focusTrap === false || focusTrapAttr === 'false';
|
||||
const backdropDisabled = modalEl.showBackdrop === false || showBackdropAttr === 'false';
|
||||
const shouldEnableBackdrop =
|
||||
currentBreakpoint > backdropBreakpoint && !focusTrapDisabled && !backdropDisabled;
|
||||
if (shouldEnableBackdrop) {
|
||||
enableBackdrop();
|
||||
} else {
|
||||
|
||||
@@ -1237,6 +1237,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
const isHandleCycle = handleBehavior === 'cycle';
|
||||
const isSheetModalWithHandle = isSheetModal && showHandle;
|
||||
|
||||
const focusTrapAttr = this.el.getAttribute('focus-trap');
|
||||
return (
|
||||
<Host
|
||||
no-router
|
||||
@@ -1253,7 +1254,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
[`modal-sheet`]: isSheetModal,
|
||||
[`modal-no-expand-scroll`]: isSheetModal && !expandToScroll,
|
||||
'overlay-hidden': true,
|
||||
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
|
||||
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false || focusTrapAttr === 'false',
|
||||
...getClassMap(this.cssClass),
|
||||
}}
|
||||
onIonBackdropTap={this.onBackdropTap}
|
||||
|
||||
@@ -28,6 +28,18 @@ describe('modal: focus trap', () => {
|
||||
|
||||
expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
|
||||
});
|
||||
it('should set the focus trap class when disabled via attribute string', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal],
|
||||
html: `
|
||||
<ion-modal focus-trap="false"></ion-modal>
|
||||
`,
|
||||
});
|
||||
|
||||
const modal = page.body.querySelector('ion-modal')!;
|
||||
|
||||
expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
|
||||
});
|
||||
it('should not set the focus trap class by default', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Picker Column
|
||||
// --------------------------------------------------
|
||||
|
||||
button {
|
||||
.picker-column-option-button {
|
||||
@include padding(0);
|
||||
@include margin(0);
|
||||
|
||||
@@ -40,6 +40,6 @@ button {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
:host(.option-disabled) button {
|
||||
:host(.option-disabled) .picker-column-option-button {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -124,9 +124,9 @@ export class PickerColumnOption implements ComponentInterface {
|
||||
['option-disabled']: disabled,
|
||||
})}
|
||||
>
|
||||
<button tabindex="-1" aria-label={ariaLabel} disabled={disabled} onClick={() => this.onClick()}>
|
||||
<div class={'picker-column-option-button'} role="button" aria-label={ariaLabel} onClick={() => this.onClick()}>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ configs({ directions: ['ltr'] }).forEach(({ config, title }) => {
|
||||
test('should not have accessibility violations', async ({ page }) => {
|
||||
await page.goto(`/src/components/picker-column-option/test/a11y`, config);
|
||||
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
const results = await new AxeBuilder({ page }).disableRules('color-contrast').analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -3,7 +3,7 @@ import { newSpecPage } from '@stencil/core/testing';
|
||||
import { PickerColumnOption } from '../picker-column-option';
|
||||
|
||||
describe('picker column option', () => {
|
||||
it('button should be enabled by default', async () => {
|
||||
it('should be enabled by default', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [PickerColumnOption],
|
||||
html: `
|
||||
@@ -12,12 +12,11 @@ describe('picker column option', () => {
|
||||
});
|
||||
|
||||
const option = page.body.querySelector('ion-picker-column-option')!;
|
||||
const button = option.shadowRoot!.querySelector('button')!;
|
||||
|
||||
await expect(button.hasAttribute('disabled')).toEqual(false);
|
||||
await expect(option.classList.contains('option-disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('button should be disabled if specified', async () => {
|
||||
it('should be disabled if specified', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [PickerColumnOption],
|
||||
html: `
|
||||
@@ -26,8 +25,7 @@ describe('picker column option', () => {
|
||||
});
|
||||
|
||||
const option = page.body.querySelector('ion-picker-column-option')!;
|
||||
const button = option.shadowRoot!.querySelector('button')!;
|
||||
|
||||
await expect(button.hasAttribute('disabled')).toEqual(true);
|
||||
await expect(option.classList.contains('option-disabled')).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -653,39 +653,6 @@ export class PickerColumn implements ComponentInterface {
|
||||
return el ? el.getAttribute('aria-label') ?? el.innerText : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an element that overlays the column. This element is for assistive
|
||||
* tech to allow users to navigate the column up/down. This element should receive
|
||||
* focus as it listens for synthesized keyboard events as required by the
|
||||
* slider role: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/slider_role
|
||||
*/
|
||||
private renderAssistiveFocusable = () => {
|
||||
const { activeItem } = this;
|
||||
const valueText = this.getOptionValueText(activeItem);
|
||||
|
||||
/**
|
||||
* When using the picker, the valuetext provides important context that valuenow
|
||||
* does not. Additionally, using non-zero valuemin/valuemax values can cause
|
||||
* WebKit to incorrectly announce numeric valuetext values (such as a year
|
||||
* like "2024") as percentages: https://bugs.webkit.org/show_bug.cgi?id=273126
|
||||
*/
|
||||
return (
|
||||
<div
|
||||
ref={(el) => (this.assistiveFocusable = el)}
|
||||
class="assistive-focusable"
|
||||
role="slider"
|
||||
tabindex={this.disabled ? undefined : 0}
|
||||
aria-label={this.ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={0}
|
||||
aria-valuenow={0}
|
||||
aria-valuetext={valueText}
|
||||
aria-orientation="vertical"
|
||||
onKeyDown={(ev) => this.onKeyDown(ev)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { color, disabled, isActive, numericInput } = this;
|
||||
const mode = getIonMode(this);
|
||||
@@ -699,33 +666,21 @@ export class PickerColumn implements ComponentInterface {
|
||||
['picker-column-disabled']: disabled,
|
||||
})}
|
||||
>
|
||||
{this.renderAssistiveFocusable()}
|
||||
<slot name="prefix"></slot>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="picker-opts"
|
||||
ref={(el) => {
|
||||
this.scrollEl = el;
|
||||
}}
|
||||
/**
|
||||
* When an element has an overlay scroll style and
|
||||
* a fixed height, Firefox will focus the scrollable
|
||||
* container if the content exceeds the container's
|
||||
* dimensions.
|
||||
*
|
||||
* This causes keyboard navigation to focus to this
|
||||
* element instead of going to the next element in
|
||||
* the tab order.
|
||||
*
|
||||
* The desired behavior is for the user to be able to
|
||||
* focus the assistive focusable element and tab to
|
||||
* the next element in the tab order. Instead of tabbing
|
||||
* to this element.
|
||||
*
|
||||
* To prevent this, we set the tabIndex to -1. This
|
||||
* will match the behavior of the other browsers.
|
||||
*/
|
||||
tabIndex={-1}
|
||||
role="slider"
|
||||
tabindex={this.disabled ? undefined : 0}
|
||||
aria-label={this.ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={0}
|
||||
aria-valuenow={0}
|
||||
aria-valuetext={this.getOptionValueText(this.activeItem)}
|
||||
aria-orientation="vertical"
|
||||
onKeyDown={(ev) => this.onKeyDown(ev)}
|
||||
>
|
||||
<div class="picker-item-empty" aria-hidden="true">
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { h } from '@stencil/core';
|
||||
import { newSpecPage } from '@stencil/core/testing';
|
||||
|
||||
import { PickerColumn } from '../picker-column';
|
||||
import { PickerColumnOption } from '../../picker-column-option/picker-column-option';
|
||||
import { PickerColumn } from '../picker-column';
|
||||
|
||||
describe('picker-column: assistive element', () => {
|
||||
describe('picker-column', () => {
|
||||
beforeEach(() => {
|
||||
const mockIntersectionObserver = jest.fn();
|
||||
mockIntersectionObserver.mockReturnValue({
|
||||
@@ -22,9 +22,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-label')).not.toBe(null);
|
||||
expect(pickerOpts.getAttribute('aria-label')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should have a custom label', async () => {
|
||||
@@ -34,9 +34,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-label')).toBe('my label');
|
||||
expect(pickerOpts.getAttribute('aria-label')).toBe('my label');
|
||||
});
|
||||
|
||||
it('should update a custom label', async () => {
|
||||
@@ -46,12 +46,12 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
pickerCol.setAttribute('aria-label', 'my label');
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-label')).toBe('my label');
|
||||
expect(pickerOpts.getAttribute('aria-label')).toBe('my label');
|
||||
});
|
||||
|
||||
it('should receive keyboard focus when enabled', async () => {
|
||||
@@ -61,9 +61,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector<HTMLElement>('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector<HTMLElement>('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.tabIndex).toBe(0);
|
||||
expect(pickerOpts.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should not receive keyboard focus when disabled', async () => {
|
||||
@@ -73,9 +73,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector<HTMLElement>('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector<HTMLElement>('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.tabIndex).toBe(-1);
|
||||
expect(pickerOpts.tabIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should use option aria-label as assistive element aria-valuetext', async () => {
|
||||
@@ -91,9 +91,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-valuetext')).toBe('My Label');
|
||||
expect(pickerOpts.getAttribute('aria-valuetext')).toBe('My Label');
|
||||
});
|
||||
|
||||
it('should use option text as assistive element aria-valuetext when no label provided', async () => {
|
||||
@@ -107,8 +107,8 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-valuetext')).toBe('My Text');
|
||||
expect(pickerOpts.getAttribute('aria-valuetext')).toBe('My Text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ configs().forEach(({ title, config }) => {
|
||||
test('should not have accessibility violations', async ({ page }) => {
|
||||
await page.goto(`/src/components/picker/test/a11y`, config);
|
||||
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
const results = await new AxeBuilder({ page }).disableRules('color-contrast').analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -177,6 +177,10 @@
|
||||
'onion'
|
||||
);
|
||||
|
||||
const columnDualNumericFirst = document.querySelector('ion-picker-column#dual-numeric-first');
|
||||
columnDualNumericFirst.addEventListener('ionChange', (ev) => {
|
||||
console.log('Column change', ev.detail);
|
||||
});
|
||||
setPickerColumn(
|
||||
'#dual-numeric-first',
|
||||
[
|
||||
@@ -195,6 +199,10 @@
|
||||
],
|
||||
3
|
||||
);
|
||||
const columnDualNumericSecond = document.querySelector('ion-picker-column#dual-numeric-second');
|
||||
columnDualNumericSecond.addEventListener('ionChange', (ev) => {
|
||||
console.log('Column change', ev.detail);
|
||||
});
|
||||
setPickerColumn('#dual-numeric-second', minutes, 3);
|
||||
|
||||
setPickerColumn(
|
||||
|
||||
@@ -106,32 +106,43 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
});
|
||||
|
||||
test('tabbing should correctly move focus between columns', async ({ page }) => {
|
||||
const firstColumn = page.locator('ion-picker-column#first');
|
||||
const secondColumn = page.locator('ion-picker-column#second');
|
||||
const firstColumn = await page.evaluate(() => document.querySelector('ion-picker-column#first'));
|
||||
const secondColumn = await page.evaluate(() => document.querySelector('ion-picker-column#second'));
|
||||
|
||||
// Focus first column
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(firstColumn).toBeFocused();
|
||||
|
||||
let activeElement = await page.evaluate(() => document.activeElement);
|
||||
expect(activeElement).toEqual(firstColumn);
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// Focus second column
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(secondColumn).toBeFocused();
|
||||
|
||||
activeElement = await page.evaluate(() => document.activeElement);
|
||||
expect(activeElement).toEqual(secondColumn);
|
||||
});
|
||||
|
||||
test('tabbing should correctly move focus back', async ({ page }) => {
|
||||
const firstColumn = page.locator('ion-picker-column#first');
|
||||
const secondColumn = page.locator('ion-picker-column#second');
|
||||
const firstColumn = await page.evaluate(() => document.querySelector('ion-picker-column#first'));
|
||||
const secondColumn = await page.evaluate(() => document.querySelector('ion-picker-column#second'));
|
||||
|
||||
await secondColumn.evaluate((el: HTMLIonPickerColumnElement) => el.setFocus());
|
||||
await expect(secondColumn).toBeFocused();
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector) as HTMLElement | null;
|
||||
el?.focus();
|
||||
}, 'ion-picker-column#second');
|
||||
|
||||
let activeElement = await page.evaluate(() => document.activeElement);
|
||||
expect(activeElement).toEqual(secondColumn);
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// Focus first column
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(firstColumn).toBeFocused();
|
||||
|
||||
activeElement = await page.evaluate(() => document.activeElement);
|
||||
expect(activeElement).toEqual(firstColumn);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
@@ -38,8 +38,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
);
|
||||
|
||||
const column = page.locator('ion-picker-column');
|
||||
|
||||
const colShadowRoot = await column.evaluateHandle((el) => el.shadowRoot);
|
||||
const columnPickerOpts = await colShadowRoot.evaluateHandle((root) => root?.querySelector('.picker-opts'));
|
||||
|
||||
const ionChange = await page.spyOnEvent('ionChange');
|
||||
await column.evaluate((el: HTMLIonPickerColumnElement) => el.setFocus());
|
||||
await columnPickerOpts.evaluate((el) => el && (el as HTMLElement).focus());
|
||||
|
||||
await page.keyboard.press('Digit2');
|
||||
|
||||
@@ -99,23 +103,25 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
);
|
||||
const firstColumn = page.locator('ion-picker-column#first');
|
||||
const secondColumn = page.locator('ion-picker-column#second');
|
||||
const highlight = page.locator('ion-picker .picker-highlight');
|
||||
const firstIonChange = await (firstColumn as E2ELocator).spyOnEvent('ionChange');
|
||||
const secondIonChange = await (secondColumn as E2ELocator).spyOnEvent('ionChange');
|
||||
|
||||
const box = await highlight.boundingBox();
|
||||
if (box !== null) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
}
|
||||
const firstColShadowRoot = await firstColumn.evaluateHandle((el) => el.shadowRoot);
|
||||
const columnPickerOpts = await firstColShadowRoot.evaluateHandle((root) => root?.querySelector('.picker-opts'));
|
||||
|
||||
await expect(firstColumn).toHaveClass(/picker-column-active/);
|
||||
await expect(secondColumn).toHaveClass(/picker-column-active/);
|
||||
// Focus first column
|
||||
await columnPickerOpts.evaluate((el) => el && (el as HTMLElement).focus());
|
||||
|
||||
await page.keyboard.press('Digit2');
|
||||
|
||||
await expect(firstIonChange).toHaveReceivedEventDetail({ value: 2 });
|
||||
await expect(firstColumn).toHaveJSProperty('value', 2);
|
||||
|
||||
// Focus second column
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
await page.keyboard.press('Digit2+Digit4');
|
||||
|
||||
await expect(secondIonChange).toHaveReceivedEventDetail({ value: 24 });
|
||||
@@ -155,8 +161,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
);
|
||||
|
||||
const column = page.locator('ion-picker-column');
|
||||
|
||||
const colShadowRoot = await column.evaluateHandle((el) => el.shadowRoot);
|
||||
const columnPickerOpts = await colShadowRoot.evaluateHandle((root) => root?.querySelector('.picker-opts'));
|
||||
|
||||
const ionChange = await page.spyOnEvent('ionChange');
|
||||
await column.evaluate((el: HTMLIonPickerColumnElement) => el.setFocus());
|
||||
await columnPickerOpts.evaluate((el) => el && (el as HTMLElement).focus());
|
||||
|
||||
await page.keyboard.press('Digit0');
|
||||
|
||||
|
||||
@@ -687,6 +687,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
|
||||
const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes, focusTrap } = this;
|
||||
const desktop = isPlatform('desktop');
|
||||
const enableArrow = arrow && !parentPopover;
|
||||
const focusTrapAttr = this.el.getAttribute('focus-trap');
|
||||
|
||||
return (
|
||||
<Host
|
||||
@@ -704,7 +705,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
|
||||
'overlay-hidden': true,
|
||||
'popover-desktop': desktop,
|
||||
[`popover-side-${side}`]: true,
|
||||
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
|
||||
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false || focusTrapAttr === 'false',
|
||||
'popover-nested': !!parentPopover,
|
||||
}}
|
||||
onIonPopoverDidPresent={onLifecycle}
|
||||
|
||||
@@ -29,6 +29,18 @@ describe('popover: focus trap', () => {
|
||||
|
||||
expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
|
||||
});
|
||||
it('should set the focus trap class when disabled via attribute string', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Popover],
|
||||
html: `
|
||||
<ion-popover focus-trap="false"></ion-popover>
|
||||
`,
|
||||
});
|
||||
|
||||
const popover = page.body.querySelector('ion-popover')!;
|
||||
|
||||
expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
|
||||
});
|
||||
it('should not set the focus trap class by default', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Popover],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Listen, Method, Prop, State, h } from '@stencil/core';
|
||||
import { isRTL } from '@utils/rtl';
|
||||
|
||||
import type { SegmentViewScrollEvent } from './segment-view-interface';
|
||||
|
||||
@@ -39,7 +40,8 @@ export class SegmentView implements ComponentInterface {
|
||||
@Listen('scroll')
|
||||
handleScroll(ev: Event) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = ev.target as HTMLElement;
|
||||
const scrollRatio = scrollLeft / (scrollWidth - clientWidth);
|
||||
const max = scrollWidth - clientWidth;
|
||||
const scrollRatio = (isRTL(this.el) ? -1 : 1) * (scrollLeft / max);
|
||||
|
||||
this.ionSegmentViewScroll.emit({
|
||||
scrollRatio,
|
||||
@@ -125,9 +127,11 @@ export class SegmentView implements ComponentInterface {
|
||||
this.resetScrollEndTimeout();
|
||||
|
||||
const contentWidth = this.el.offsetWidth;
|
||||
const offset = index * contentWidth;
|
||||
|
||||
this.el.scrollTo({
|
||||
top: 0,
|
||||
left: index * contentWidth,
|
||||
left: (isRTL(this.el) ? -1 : 1) * offset,
|
||||
behavior: smoothScroll ? 'smooth' : 'instant',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
* This behavior does not vary across modes
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
configs({ modes: ['md'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('segment-view: basic'), () => {
|
||||
test('should show the first content with no initial value', async ({ page }) => {
|
||||
await page.setContent(
|
||||
@@ -88,86 +88,86 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const segmentContent = page.locator('ion-segment-content[id="top"]');
|
||||
await expect(segmentContent).toBeInViewport();
|
||||
});
|
||||
});
|
||||
|
||||
test('should set correct segment button as checked when changing the value by scrolling the segment content', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-segment value="free">
|
||||
<ion-segment-button content-id="paid" value="paid">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="free" value="free">
|
||||
<ion-label>Free</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="top" value="top">
|
||||
<ion-label>Top</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="paid">Paid</ion-segment-content>
|
||||
<ion-segment-content id="free">Free</ion-segment-content>
|
||||
<ion-segment-content id="top">Top</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('ion-segment-view')
|
||||
.evaluate(
|
||||
(segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled')
|
||||
test('should set correct segment button as checked when changing the value by scrolling the segment content', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-segment value="free">
|
||||
<ion-segment-button content-id="paid" value="paid">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="free" value="free">
|
||||
<ion-label>Free</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="top" value="top">
|
||||
<ion-label>Top</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="paid">Paid</ion-segment-content>
|
||||
<ion-segment-content id="free">Free</ion-segment-content>
|
||||
<ion-segment-content id="top">Top</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
await page.waitForChanges();
|
||||
await page
|
||||
.locator('ion-segment-view')
|
||||
.evaluate(
|
||||
(segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled')
|
||||
);
|
||||
|
||||
await page.locator('ion-segment-content[id="top"]').scrollIntoViewIfNeeded();
|
||||
await page.waitForChanges();
|
||||
|
||||
const segmentButton = page.locator('ion-segment-button[value="top"]');
|
||||
await expect(segmentButton).toHaveClass(/segment-button-checked/);
|
||||
});
|
||||
await page.locator('ion-segment-content[id="top"]').scrollIntoViewIfNeeded();
|
||||
|
||||
test('should set correct segment button as checked and show correct content when programmatically setting the segment vale', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-segment value="free">
|
||||
<ion-segment-button content-id="paid" value="paid">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="free" value="free">
|
||||
<ion-label>Free</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="top" value="top">
|
||||
<ion-label>Top</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="paid">Paid</ion-segment-content>
|
||||
<ion-segment-content id="free">Free</ion-segment-content>
|
||||
<ion-segment-content id="top">Top</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
`,
|
||||
config
|
||||
);
|
||||
const segmentButton = page.locator('ion-segment-button[value="top"]');
|
||||
await expect(segmentButton).toHaveClass(/segment-button-checked/);
|
||||
});
|
||||
|
||||
await page
|
||||
.locator('ion-segment-view')
|
||||
.evaluate(
|
||||
(segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled')
|
||||
test('should set correct segment button as checked and show correct content when programmatically setting the segment value', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-segment value="free">
|
||||
<ion-segment-button content-id="paid" value="paid">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="free" value="free">
|
||||
<ion-label>Free</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="top" value="top">
|
||||
<ion-label>Top</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="paid">Paid</ion-segment-content>
|
||||
<ion-segment-content id="free">Free</ion-segment-content>
|
||||
<ion-segment-content id="top">Top</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
await page.waitForChanges();
|
||||
await page
|
||||
.locator('ion-segment-view')
|
||||
.evaluate(
|
||||
(segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled')
|
||||
);
|
||||
|
||||
await page.locator('ion-segment').evaluate((segment: HTMLIonSegmentElement) => (segment.value = 'top'));
|
||||
await page.waitForChanges();
|
||||
|
||||
const segmentButton = page.locator('ion-segment-button[value="top"]');
|
||||
await expect(segmentButton).toHaveClass(/segment-button-checked/);
|
||||
await page.locator('ion-segment').evaluate((segment: HTMLIonSegmentElement) => (segment.value = 'top'));
|
||||
|
||||
const segmentContent = page.locator('ion-segment-content[id="top"]');
|
||||
await expect(segmentContent).toBeInViewport();
|
||||
const segmentButton = page.locator('ion-segment-button[value="top"]');
|
||||
await expect(segmentButton).toHaveClass(/segment-button-checked/);
|
||||
|
||||
const segmentContent = page.locator('ion-segment-content[id="top"]');
|
||||
await expect(segmentContent).toBeInViewport();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
194
core/src/components/segment-view/test/rtl/index.html
Normal file
@@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>RTL Segment View - Basic</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<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>
|
||||
|
||||
<style>
|
||||
ion-segment-view {
|
||||
height: 100px;
|
||||
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ion-segment-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ion-segment-content:nth-of-type(3n + 1) {
|
||||
background: lightpink;
|
||||
}
|
||||
|
||||
ion-segment-content:nth-of-type(3n + 2) {
|
||||
background: lightblue;
|
||||
}
|
||||
|
||||
ion-segment-content:nth-of-type(3n + 3) {
|
||||
background: lightgreen;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>RTL Segment View - Basic</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-segment id="noValueSegment">
|
||||
<ion-segment-button content-id="no" value="no">
|
||||
<ion-label>No</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="value" value="value">
|
||||
<ion-label>Value</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view id="noValueSegmentView">
|
||||
<ion-segment-content id="no">No</ion-segment-content>
|
||||
<ion-segment-content id="value">Value</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
|
||||
<ion-segment value="free">
|
||||
<ion-segment-button content-id="paid" value="paid">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button style="min-width: 200px" content-id="free" value="free">
|
||||
<ion-label>Free</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="top" value="top">
|
||||
<ion-label>Top</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="paid">Paid</ion-segment-content>
|
||||
<ion-segment-content id="free">Free</ion-segment-content>
|
||||
<ion-segment-content id="top">Top</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
|
||||
<ion-segment value="peach" scrollable>
|
||||
<ion-segment-button content-id="orange" value="orange">
|
||||
<ion-label>Orange</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="banana" value="banana">
|
||||
<ion-label>Banana</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="pear" value="pear">
|
||||
<ion-label>Pear</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="peach" value="peach">
|
||||
<ion-label>Peach</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="grape" value="grape">
|
||||
<ion-label>Grape</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="mango" value="mango">
|
||||
<ion-label>Mango</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="apple" value="apple">
|
||||
<ion-label>Apple</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="strawberry" value="strawberry">
|
||||
<ion-label>Strawberry</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="cherry" value="cherry">
|
||||
<ion-label>Cherry</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="orange">Orange</ion-segment-content>
|
||||
<ion-segment-content id="banana">Banana</ion-segment-content>
|
||||
<ion-segment-content id="pear">Pear</ion-segment-content>
|
||||
<ion-segment-content id="peach">Peach</ion-segment-content>
|
||||
<ion-segment-content id="grape">Grape</ion-segment-content>
|
||||
<ion-segment-content id="mango">Mango</ion-segment-content>
|
||||
<ion-segment-content id="apple">Apple</ion-segment-content>
|
||||
<ion-segment-content id="strawberry">Strawberry</ion-segment-content>
|
||||
<ion-segment-content id="cherry">Cherry</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
|
||||
<button class="expand" onClick="changeSegmentContent()">Change Segment Content</button>
|
||||
|
||||
<button class="expand" onClick="clearSegmentValue()">Clear Segment Value</button>
|
||||
|
||||
<button class="expand" onClick="addSegmentButtonAndContent()">Add New Segment Button & Content</button>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-title>Footer</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
<script>
|
||||
function changeSegmentContent() {
|
||||
const segment = document.querySelector('#noValueSegment');
|
||||
const segmentView = document.querySelector('#noValueSegmentView');
|
||||
|
||||
let currentValue = segment.value;
|
||||
|
||||
if (currentValue === 'value') {
|
||||
currentValue = 'no';
|
||||
} else {
|
||||
currentValue = 'value';
|
||||
}
|
||||
|
||||
segment.value = currentValue;
|
||||
}
|
||||
|
||||
async function clearSegmentValue() {
|
||||
const segmentView = document.querySelector('#noValueSegmentView');
|
||||
segmentView.setContent('no', false);
|
||||
|
||||
// Set timeout to ensure the value is cleared after
|
||||
// the segment content is updated
|
||||
setTimeout(() => {
|
||||
const segment = document.querySelector('#noValueSegment');
|
||||
segment.value = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
async function addSegmentButtonAndContent() {
|
||||
const segment = document.querySelector('ion-segment');
|
||||
const segmentView = document.querySelector('ion-segment-view');
|
||||
|
||||
const newButton = document.createElement('ion-segment-button');
|
||||
const newId = `new-${Date.now()}`;
|
||||
newButton.setAttribute('content-id', newId);
|
||||
newButton.setAttribute('value', newId);
|
||||
newButton.innerHTML = '<ion-label>New Button</ion-label>';
|
||||
|
||||
segment.appendChild(newButton);
|
||||
|
||||
setTimeout(() => {
|
||||
// Timeout to test waitForSegmentContent() in segment-button
|
||||
const newContent = document.createElement('ion-segment-content');
|
||||
newContent.setAttribute('id', newId);
|
||||
newContent.innerHTML = 'New Content';
|
||||
|
||||
segmentView.appendChild(newContent);
|
||||
|
||||
// Necessary timeout to ensure the value is set after the content is added.
|
||||
// Otherwise, the transition is unsuccessful and the content is not shown.
|
||||
setTimeout(() => {
|
||||
segment.setAttribute('value', newId);
|
||||
}, 200);
|
||||
}, 200);
|
||||
}
|
||||
</script>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
285
core/src/components/textarea/test/validation/index.html
Normal file
@@ -0,0 +1,285 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Textarea - Validation</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<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>
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-row-gap: 20px;
|
||||
grid-column-gap: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
|
||||
color: var(--ion-color-step-600);
|
||||
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.validation-info {
|
||||
margin: 20px;
|
||||
padding: 10px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Textarea - Validation Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="validation-info">
|
||||
<h2>Screen Reader Testing Instructions:</h2>
|
||||
<ol>
|
||||
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
|
||||
<li>Tab through the form fields</li>
|
||||
<li>When you tab away from an empty required field, the error should be announced immediately</li>
|
||||
<li>The error text should be announced BEFORE the next field is announced</li>
|
||||
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>Required Description (Min Length)</h2>
|
||||
<ion-textarea
|
||||
id="description-textarea"
|
||||
label="Description"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter a description"
|
||||
rows="4"
|
||||
minlength="20"
|
||||
helper-text="At least 20 characters"
|
||||
error-text="Description must be at least 20 characters"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Required Comments</h2>
|
||||
<ion-textarea
|
||||
id="comments-textarea"
|
||||
label="Comments"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your comments"
|
||||
rows="4"
|
||||
helper-text="Please provide your feedback"
|
||||
error-text="Comments are required"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Bio (Max Length)</h2>
|
||||
<ion-textarea
|
||||
id="bio-textarea"
|
||||
label="Bio"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Tell us about yourself"
|
||||
rows="4"
|
||||
maxlength="200"
|
||||
counter="true"
|
||||
helper-text="Maximum 200 characters"
|
||||
error-text="Bio is required"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Address (Pattern Validation)</h2>
|
||||
<ion-textarea
|
||||
id="address-textarea"
|
||||
label="Address"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Enter your full address"
|
||||
rows="3"
|
||||
helper-text="Include street, city, state, and zip"
|
||||
error-text="Please enter a complete address"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Review (Min/Max Length)</h2>
|
||||
<ion-textarea
|
||||
id="review-textarea"
|
||||
label="Product Review"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Write your review"
|
||||
rows="5"
|
||||
minlength="50"
|
||||
maxlength="500"
|
||||
counter="true"
|
||||
helper-text="Between 50-500 characters"
|
||||
error-text="Review must be between 50-500 characters"
|
||||
required
|
||||
></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Optional Notes</h2>
|
||||
<ion-textarea
|
||||
id="notes-textarea"
|
||||
label="Additional Notes"
|
||||
label-placement="floating"
|
||||
fill="outline"
|
||||
placeholder="Any additional notes (optional)"
|
||||
rows="3"
|
||||
helper-text="This field is optional"
|
||||
></ion-textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ion-padding">
|
||||
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
|
||||
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
// Simple validation logic
|
||||
const textareas = document.querySelectorAll('ion-textarea');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
|
||||
// Track which fields have been touched
|
||||
const touchedFields = new Set();
|
||||
|
||||
// Validation functions
|
||||
const validators = {
|
||||
'description-textarea': (value) => {
|
||||
return value && value.length >= 20;
|
||||
},
|
||||
'comments-textarea': (value) => {
|
||||
return value && value.trim().length > 0;
|
||||
},
|
||||
'bio-textarea': (value) => {
|
||||
return value && value.length > 0 && value.length <= 200;
|
||||
},
|
||||
'address-textarea': (value) => {
|
||||
// Simple check for address - must contain at least some text with numbers
|
||||
if (!value || value.length < 10) return false;
|
||||
// Check if it contains at least one number (for street/zip)
|
||||
return /\d/.test(value);
|
||||
},
|
||||
'review-textarea': (value) => {
|
||||
return value && value.length >= 50 && value.length <= 500;
|
||||
},
|
||||
'notes-textarea': () => true, // Always valid (optional)
|
||||
};
|
||||
|
||||
function validateField(textarea) {
|
||||
const textareaId = textarea.id;
|
||||
const value = textarea.value;
|
||||
const isValid = validators[textareaId] ? validators[textareaId](value) : true;
|
||||
|
||||
// Only show validation state if field has been touched
|
||||
if (touchedFields.has(textareaId)) {
|
||||
if (isValid) {
|
||||
textarea.classList.remove('ion-invalid');
|
||||
textarea.classList.add('ion-valid');
|
||||
} else {
|
||||
textarea.classList.remove('ion-valid');
|
||||
textarea.classList.add('ion-invalid');
|
||||
}
|
||||
textarea.classList.add('ion-touched');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
let allValid = true;
|
||||
textareas.forEach((textarea) => {
|
||||
if (textarea.id !== 'notes-textarea') {
|
||||
const isValid = validateField(textarea);
|
||||
if (!isValid) {
|
||||
allValid = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
submitBtn.disabled = !allValid;
|
||||
return allValid;
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
textareas.forEach((textarea) => {
|
||||
// Mark as touched on blur
|
||||
textarea.addEventListener('ionBlur', (e) => {
|
||||
touchedFields.add(textarea.id);
|
||||
validateField(textarea);
|
||||
validateForm();
|
||||
|
||||
const isInvalid = textarea.classList.contains('ion-invalid');
|
||||
if (isInvalid) {
|
||||
console.log('Field marked invalid:', textarea.label, textarea.errorText);
|
||||
}
|
||||
});
|
||||
|
||||
// Validate on input
|
||||
textarea.addEventListener('ionInput', (e) => {
|
||||
if (touchedFields.has(textarea.id)) {
|
||||
validateField(textarea);
|
||||
validateForm();
|
||||
}
|
||||
});
|
||||
|
||||
// Also validate on focus loss via native blur
|
||||
textarea.addEventListener('focusout', (e) => {
|
||||
// Small delay to ensure Ionic's classes are updated
|
||||
setTimeout(() => {
|
||||
touchedFields.add(textarea.id);
|
||||
validateField(textarea);
|
||||
validateForm();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset button
|
||||
resetBtn.addEventListener('click', () => {
|
||||
textareas.forEach((textarea) => {
|
||||
textarea.value = '';
|
||||
textarea.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
|
||||
});
|
||||
touchedFields.clear();
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
|
||||
// Submit button
|
||||
submitBtn.addEventListener('click', () => {
|
||||
if (validateForm()) {
|
||||
alert('Form submitted successfully!');
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup
|
||||
validateForm();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -81,6 +81,13 @@ export class Textarea implements ComponentInterface {
|
||||
*/
|
||||
@State() hasFocus = false;
|
||||
|
||||
/**
|
||||
* Track validation state for proper aria-live announcements
|
||||
*/
|
||||
@State() isInvalid = false;
|
||||
|
||||
private validationObserver?: MutationObserver;
|
||||
|
||||
/**
|
||||
* The color to use from your application's color palette.
|
||||
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
|
||||
@@ -328,6 +335,16 @@ export class Textarea implements ComponentInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the textarea is in an invalid state based on Ionic validation classes
|
||||
*/
|
||||
private checkValidationState(): boolean {
|
||||
const hasIonTouched = this.el.classList.contains('ion-touched');
|
||||
const hasIonInvalid = this.el.classList.contains('ion-invalid');
|
||||
|
||||
return hasIonTouched && hasIonInvalid;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const { el } = this;
|
||||
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
|
||||
@@ -336,6 +353,27 @@ export class Textarea implements ComponentInterface {
|
||||
() => this.notchSpacerEl,
|
||||
() => this.labelSlot
|
||||
);
|
||||
|
||||
// Watch for class changes to update validation state
|
||||
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
|
||||
this.validationObserver = new MutationObserver(() => {
|
||||
const newIsInvalid = this.checkValidationState();
|
||||
if (this.isInvalid !== newIsInvalid) {
|
||||
this.isInvalid = newIsInvalid;
|
||||
// Force a re-render to update aria-describedby immediately
|
||||
forceUpdate(this);
|
||||
}
|
||||
});
|
||||
|
||||
this.validationObserver.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
|
||||
// Always set initial state
|
||||
this.isInvalid = this.checkValidationState();
|
||||
|
||||
this.debounceChanged();
|
||||
if (Build.isBrowser) {
|
||||
document.dispatchEvent(
|
||||
@@ -364,6 +402,12 @@ export class Textarea implements ComponentInterface {
|
||||
this.notchController.destroy();
|
||||
this.notchController = undefined;
|
||||
}
|
||||
|
||||
// Clean up validation observer to prevent memory leaks
|
||||
if (this.validationObserver) {
|
||||
this.validationObserver.disconnect();
|
||||
this.validationObserver = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
@@ -628,22 +672,22 @@ export class Textarea implements ComponentInterface {
|
||||
* Renders the helper text or error text values
|
||||
*/
|
||||
private renderHintText() {
|
||||
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
||||
|
||||
return [
|
||||
<div id={helperTextId} class="helper-text">
|
||||
{helperText}
|
||||
<div id={helperTextId} class="helper-text" aria-live="polite">
|
||||
{!isInvalid ? helperText : null}
|
||||
</div>,
|
||||
<div id={errorTextId} class="error-text">
|
||||
{errorText}
|
||||
<div id={errorTextId} class="error-text" role="alert">
|
||||
{isInvalid ? errorText : null}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
const { el, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
|
||||
if (isInvalid && errorText) {
|
||||
return errorTextId;
|
||||
}
|
||||
|
||||
@@ -777,7 +821,7 @@ export class Textarea implements ComponentInterface {
|
||||
onFocus={this.onFocus}
|
||||
onKeyDown={this.onKeyDown}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||
aria-invalid={this.isInvalid ? 'true' : undefined}
|
||||
{...this.inheritedAttributes}
|
||||
>
|
||||
{value}
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
getElementRoot,
|
||||
removeEventListener,
|
||||
} from './helpers';
|
||||
import { isPlatform } from './platform';
|
||||
|
||||
let lastOverlayIndex = 0;
|
||||
let lastId = 0;
|
||||
@@ -487,7 +486,7 @@ export const getPresentedOverlay = (
|
||||
*/
|
||||
export const setRootAriaHidden = (hidden = false) => {
|
||||
const root = getAppRoot(document);
|
||||
const viewContainer = root.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root');
|
||||
const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root');
|
||||
|
||||
if (!viewContainer) {
|
||||
return;
|
||||
@@ -511,23 +510,61 @@ export const present = async <OverlayPresentOptions>(
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* When an overlay that steals focus
|
||||
* is dismissed, focus should be returned
|
||||
* to the element that was focused
|
||||
* prior to the overlay opening. Toast
|
||||
* does not steal focus and is excluded
|
||||
* from returning focus as a result.
|
||||
*/
|
||||
if (overlay.el.tagName !== 'ION-TOAST') {
|
||||
restoreElementFocus(overlay.el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Due to accessibility guidelines, toasts do not have
|
||||
* focus traps.
|
||||
*
|
||||
* All other overlays should have focus traps to prevent
|
||||
* the keyboard focus from leaving the overlay.
|
||||
* the keyboard focus from leaving the overlay unless
|
||||
* developers explicitly opt out (for example, sheet
|
||||
* modals that should permit background interaction).
|
||||
*
|
||||
* Note: Some apps move inline overlays to a specific container
|
||||
* during the willPresent lifecycle (e.g., React portals via
|
||||
* onWillPresent). Defer applying aria-hidden/inert to the app
|
||||
* root until after willPresent so we can detect where the
|
||||
* overlay is finally inserted. If the overlay is inside the
|
||||
* view container subtree, skip adding aria-hidden/inert there
|
||||
* to avoid disabling the overlay.
|
||||
*/
|
||||
if (overlay.el.tagName !== 'ION-TOAST') {
|
||||
setRootAriaHidden(true);
|
||||
document.body.classList.add(BACKDROP_NO_SCROLL);
|
||||
}
|
||||
|
||||
hideUnderlyingOverlaysFromScreenReaders(overlay.el);
|
||||
hideAnimatingOverlayFromScreenReaders(overlay.el);
|
||||
const overlayEl = overlay.el as HTMLIonOverlayElement & {
|
||||
focusTrap?: boolean;
|
||||
showBackdrop?: boolean;
|
||||
};
|
||||
const focusTrapAttr = overlayEl.getAttribute?.('focus-trap');
|
||||
const showBackdropAttr = overlayEl.getAttribute?.('show-backdrop');
|
||||
const focusTrapDisabled = overlayEl.focusTrap === false || focusTrapAttr === 'false';
|
||||
const backdropDisabled = overlayEl.showBackdrop === false || showBackdropAttr === 'false';
|
||||
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && !focusTrapDisabled;
|
||||
// Only lock out root content when backdrop is active. Developers relying on showBackdrop=false
|
||||
// expect background interaction to remain enabled.
|
||||
const shouldLockRoot = shouldTrapFocus && !backdropDisabled;
|
||||
|
||||
overlay.presented = true;
|
||||
overlay.willPresent.emit();
|
||||
|
||||
if (shouldLockRoot) {
|
||||
const root = getAppRoot(document);
|
||||
const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root');
|
||||
const overlayInsideViewContainer = viewContainer ? viewContainer.contains(overlayEl) : false;
|
||||
|
||||
if (!overlayInsideViewContainer) {
|
||||
setRootAriaHidden(true);
|
||||
}
|
||||
document.body.classList.add(BACKDROP_NO_SCROLL);
|
||||
}
|
||||
overlay.willPresentShorthand?.emit();
|
||||
|
||||
const mode = getIonMode(overlay);
|
||||
@@ -542,18 +579,6 @@ export const present = async <OverlayPresentOptions>(
|
||||
overlay.didPresentShorthand?.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* When an overlay that steals focus
|
||||
* is dismissed, focus should be returned
|
||||
* to the element that was focused
|
||||
* prior to the overlay opening. Toast
|
||||
* does not steal focus and is excluded
|
||||
* from returning focus as a result.
|
||||
*/
|
||||
if (overlay.el.tagName !== 'ION-TOAST') {
|
||||
restoreElementFocus(overlay.el);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the focused element is already
|
||||
* inside the overlay component then
|
||||
@@ -577,6 +602,7 @@ export const present = async <OverlayPresentOptions>(
|
||||
* screen readers.
|
||||
*/
|
||||
overlay.el.removeAttribute('aria-hidden');
|
||||
overlay.el.removeAttribute('inert');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -595,6 +621,9 @@ const restoreElementFocus = async (overlayEl: any) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure active element is blurred to prevent a11y warning issues
|
||||
previousElement.blur();
|
||||
|
||||
const shadowRoot = previousElement?.shadowRoot;
|
||||
if (shadowRoot) {
|
||||
// If there are no inner focusable elements, just focus the host element.
|
||||
@@ -651,22 +680,38 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
* For accessibility, toasts lack focus traps and don't receive
|
||||
* `aria-hidden` on the root element when presented.
|
||||
*
|
||||
* All other overlays use focus traps to keep keyboard focus
|
||||
* within the overlay, setting `aria-hidden` on the root element
|
||||
* to enhance accessibility.
|
||||
*
|
||||
* Therefore, we must remove `aria-hidden` from the root element
|
||||
* when the last non-toast overlay is dismissed.
|
||||
* Overlays that opt into focus trapping set `aria-hidden`
|
||||
* on the root element to keep keyboard focus and pointer
|
||||
* events inside the overlay. We must remove `aria-hidden`
|
||||
* from the root element when the last focus-trapping overlay
|
||||
* is dismissed.
|
||||
*/
|
||||
const overlaysNotToast = presentedOverlays.filter((o) => o.tagName !== 'ION-TOAST');
|
||||
|
||||
const lastOverlayNotToast = overlaysNotToast.length === 1 && overlaysNotToast[0].id === overlay.el.id;
|
||||
const overlaysLockingRoot = presentedOverlays.filter((o) => {
|
||||
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
|
||||
const focusTrapAttr = el.getAttribute?.('focus-trap');
|
||||
const showBackdropAttr = el.getAttribute?.('show-backdrop');
|
||||
const focusTrapDisabled = el.focusTrap === false || focusTrapAttr === 'false';
|
||||
const backdropDisabled = el.showBackdrop === false || showBackdropAttr === 'false';
|
||||
return el.tagName !== 'ION-TOAST' && !focusTrapDisabled && !backdropDisabled;
|
||||
});
|
||||
const overlayEl = overlay.el as HTMLIonOverlayElement & {
|
||||
focusTrap?: boolean;
|
||||
showBackdrop?: boolean;
|
||||
};
|
||||
const focusTrapAttr = overlayEl.getAttribute?.('focus-trap');
|
||||
const showBackdropAttr = overlayEl.getAttribute?.('show-backdrop');
|
||||
const focusTrapDisabled = overlayEl.focusTrap === false || focusTrapAttr === 'false';
|
||||
const backdropDisabled = overlayEl.showBackdrop === false || showBackdropAttr === 'false';
|
||||
const locksRoot = overlayEl.tagName !== 'ION-TOAST' && !focusTrapDisabled && !backdropDisabled;
|
||||
|
||||
/**
|
||||
* If this is the last visible overlay that is not a toast
|
||||
* If this is the last visible overlay that is trapping focus
|
||||
* then we want to re-add the root to the accessibility tree.
|
||||
*/
|
||||
if (lastOverlayNotToast) {
|
||||
const lastOverlayTrappingFocus =
|
||||
locksRoot && overlaysLockingRoot.length === 1 && overlaysLockingRoot[0].id === overlayEl.id;
|
||||
|
||||
if (lastOverlayTrappingFocus) {
|
||||
setRootAriaHidden(false);
|
||||
document.body.classList.remove(BACKDROP_NO_SCROLL);
|
||||
}
|
||||
@@ -674,13 +719,6 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
overlay.presented = false;
|
||||
|
||||
try {
|
||||
/**
|
||||
* There is no need to show the overlay to screen readers during
|
||||
* the dismiss animation. This is because the overlay will be removed
|
||||
* from the DOM after the animation is complete.
|
||||
*/
|
||||
hideAnimatingOverlayFromScreenReaders(overlay.el);
|
||||
|
||||
// Overlay contents should not be clickable during dismiss
|
||||
overlay.el.style.setProperty('pointer-events', 'none');
|
||||
overlay.willDismiss.emit({ data, role });
|
||||
@@ -728,8 +766,6 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
|
||||
overlay.el.remove();
|
||||
|
||||
revealOverlaysToScreenReaders();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -966,98 +1002,4 @@ export const createTriggerController = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The overlay that is being animated also needs to hide from screen
|
||||
* readers during its animation. This ensures that assistive technologies
|
||||
* like TalkBack do not announce or interact with the content until the
|
||||
* animation is complete, avoiding confusion for users.
|
||||
*
|
||||
* When the overlay is presented on an Android device, TalkBack's focus rings
|
||||
* may appear in the wrong position due to the transition (specifically
|
||||
* `transform` styles). This occurs because the focus rings are initially
|
||||
* displayed at the starting position of the elements before the transition
|
||||
* begins. This workaround ensures the focus rings do not appear in the
|
||||
* incorrect location.
|
||||
*
|
||||
* 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;
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure that underlying overlays have aria-hidden if necessary so that screen readers
|
||||
* cannot move focus to these elements. Note that we cannot rely on focus/focusin/focusout
|
||||
* events here because those events do not fire when the screen readers moves to a non-focusable
|
||||
* element such as text.
|
||||
* Without this logic screen readers would be able to move focus outside of the top focus-trapped overlay.
|
||||
*
|
||||
* @param newTopMostOverlay - The overlay that is being presented. Since the overlay has not been
|
||||
* fully presented yet at the time this function is called it will not be included in the getPresentedOverlays result.
|
||||
*/
|
||||
const hideUnderlyingOverlaysFromScreenReaders = (newTopMostOverlay: HTMLIonOverlayElement) => {
|
||||
if (doc === undefined) return;
|
||||
|
||||
const overlays = getPresentedOverlays(doc);
|
||||
|
||||
for (let i = overlays.length - 1; i >= 0; i--) {
|
||||
const presentedOverlay = overlays[i];
|
||||
const nextPresentedOverlay = overlays[i + 1] ?? newTopMostOverlay;
|
||||
|
||||
/**
|
||||
* If next overlay has aria-hidden then all remaining overlays will have it too.
|
||||
* Or, if the next overlay is a Toast that does not have aria-hidden then current overlay
|
||||
* should not have aria-hidden either so focus can remain in the current overlay.
|
||||
*/
|
||||
if (nextPresentedOverlay.hasAttribute('aria-hidden') || nextPresentedOverlay.tagName !== 'ION-TOAST') {
|
||||
presentedOverlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When dismissing an overlay we need to reveal the new top-most overlay to screen readers.
|
||||
* If the top-most overlay is a Toast we potentially need to reveal more overlays since
|
||||
* focus is never automatically moved to the Toast.
|
||||
*/
|
||||
const revealOverlaysToScreenReaders = () => {
|
||||
if (doc === undefined) return;
|
||||
|
||||
const overlays = getPresentedOverlays(doc);
|
||||
|
||||
for (let i = overlays.length - 1; i >= 0; i--) {
|
||||
const currentOverlay = overlays[i];
|
||||
|
||||
/**
|
||||
* If the current we are looking at is a Toast then we can remove aria-hidden.
|
||||
* However, we potentially need to keep looking at the overlay stack because there
|
||||
* could be more Toasts underneath. Additionally, we need to unhide the closest non-Toast
|
||||
* overlay too so focus can move there since focus is never automatically moved to the Toast.
|
||||
*/
|
||||
currentOverlay.removeAttribute('aria-hidden');
|
||||
|
||||
/**
|
||||
* If we found a non-Toast element then we can just remove aria-hidden and stop searching entirely
|
||||
* since this overlay should always receive focus. As a result, all underlying overlays should still
|
||||
* be hidden from screen readers.
|
||||
*/
|
||||
if (currentOverlay.tagName !== 'ION-TOAST') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const FOCUS_TRAP_DISABLE_CLASS = 'ion-disable-focus-trap';
|
||||
|
||||
@@ -37,6 +37,26 @@ describe('overlays: scroll blocking', () => {
|
||||
expect(body).not.toHaveClass('backdrop-no-scroll');
|
||||
});
|
||||
|
||||
it('should not block scroll when focus-trap attribute is set to "false"', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal],
|
||||
html: `
|
||||
<ion-modal focus-trap="false"></ion-modal>
|
||||
`,
|
||||
});
|
||||
|
||||
const modal = page.body.querySelector('ion-modal')!;
|
||||
const body = page.doc.querySelector('body')!;
|
||||
|
||||
await modal.present();
|
||||
|
||||
expect(body).not.toHaveClass('backdrop-no-scroll');
|
||||
|
||||
await modal.dismiss();
|
||||
|
||||
expect(body).not.toHaveClass('backdrop-no-scroll');
|
||||
});
|
||||
|
||||
it('should not block scroll when the overlay is dismissed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal],
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { newSpecPage } from '@stencil/core/testing';
|
||||
|
||||
import { Modal } from '../../../components/modal/modal';
|
||||
import { Toast } from '../../../components/toast/toast';
|
||||
import { Nav } from '../../../components/nav/nav';
|
||||
import { RouterOutlet } from '../../../components/router-outlet/router-outlet';
|
||||
import { setRootAriaHidden } from '../../overlays';
|
||||
|
||||
describe('setRootAriaHidden()', () => {
|
||||
it('should correctly remove and re-add router outlet from accessibility tree', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [RouterOutlet],
|
||||
html: `
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
`,
|
||||
});
|
||||
|
||||
const routerOutlet = page.body.querySelector('ion-router-outlet')!;
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
setRootAriaHidden(true);
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
setRootAriaHidden(false);
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should correctly remove and re-add nav from accessibility tree', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Nav],
|
||||
html: `
|
||||
<ion-nav></ion-nav>
|
||||
`,
|
||||
});
|
||||
|
||||
const nav = page.body.querySelector('ion-nav')!;
|
||||
|
||||
expect(nav.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
setRootAriaHidden(true);
|
||||
expect(nav.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
setRootAriaHidden(false);
|
||||
expect(nav.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should correctly remove and re-add custom container from accessibility tree', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [],
|
||||
html: `
|
||||
<div id="ion-view-container-root"></div>
|
||||
<div id="not-container-root"></div>
|
||||
`,
|
||||
});
|
||||
|
||||
const containerRoot = page.body.querySelector('#ion-view-container-root')!;
|
||||
const notContainerRoot = page.body.querySelector('#not-container-root')!;
|
||||
|
||||
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
setRootAriaHidden(true);
|
||||
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
setRootAriaHidden(false);
|
||||
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should not error if router outlet was not found', async () => {
|
||||
await newSpecPage({
|
||||
components: [],
|
||||
html: `
|
||||
<div></div>
|
||||
`,
|
||||
});
|
||||
|
||||
setRootAriaHidden(true);
|
||||
});
|
||||
|
||||
it('should remove router-outlet from accessibility tree when overlay is presented', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [RouterOutlet, Modal],
|
||||
html: `
|
||||
<ion-router-outlet>
|
||||
<ion-modal></ion-modal>
|
||||
</ion-router-outlet>
|
||||
`,
|
||||
});
|
||||
|
||||
const routerOutlet = page.body.querySelector('ion-router-outlet')!;
|
||||
const modal = page.body.querySelector('ion-modal')!;
|
||||
|
||||
await modal.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should add router-outlet from accessibility tree when then final overlay is dismissed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [RouterOutlet, Modal],
|
||||
html: `
|
||||
<ion-router-outlet>
|
||||
<ion-modal id="one"></ion-modal>
|
||||
<ion-modal id="two"></ion-modal>
|
||||
</ion-router-outlet>
|
||||
`,
|
||||
});
|
||||
|
||||
const routerOutlet = page.body.querySelector('ion-router-outlet')!;
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
|
||||
|
||||
await modalOne.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalTwo.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalOne.dismiss();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalTwo.dismiss();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should not hide previous overlay if top-most overlay is toast', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal, Toast],
|
||||
html: `
|
||||
<ion-modal id="m-one"></ion-modal>
|
||||
<ion-modal id="m-two"></ion-modal>
|
||||
<ion-toast id="t-one"></ion-toast>
|
||||
<ion-toast id="t-two"></ion-toast>
|
||||
`,
|
||||
});
|
||||
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-two')!;
|
||||
const toastOne = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-one')!;
|
||||
const toastTwo = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-two')!;
|
||||
|
||||
await modalOne.present();
|
||||
await modalTwo.present();
|
||||
await toastOne.present();
|
||||
await toastTwo.present();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(toastTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
await toastTwo.dismiss();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
await toastOne.dismiss();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should hide previous overlay even with a toast that is not the top-most overlay', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal, Toast],
|
||||
html: `
|
||||
<ion-modal id="m-one"></ion-modal>
|
||||
<ion-toast id="t-one"></ion-toast>
|
||||
<ion-modal id="m-two"></ion-modal>
|
||||
`,
|
||||
});
|
||||
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-two')!;
|
||||
const toastOne = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-one')!;
|
||||
|
||||
await modalOne.present();
|
||||
await toastOne.present();
|
||||
await modalTwo.present();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(toastOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
await modalTwo.dismiss();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -3,5 +3,5 @@
|
||||
"core",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "8.7.3"
|
||||
"version": "8.7.5"
|
||||
}
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.5](https://github.com/ionic-team/ionic-framework/compare/v8.7.4...v8.7.5) (2025-09-24)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.4](https://github.com/ionic-team/ionic-framework/compare/v8.7.3...v8.7.4) (2025-09-17)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.3](https://github.com/ionic-team/ionic-framework/compare/v8.7.2...v8.7.3) (2025-08-20)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
18
packages/angular-server/package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@ionic/angular-server",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/angular-server",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.3"
|
||||
"@ionic/core": "^8.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/eslint-plugin": "^16.0.0",
|
||||
@@ -1031,9 +1031,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@ionic/core": {
|
||||
"version": "8.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.3.tgz",
|
||||
"integrity": "sha512-KdyMxpMDQj+uqpztpK6yvN/T96hqcDiGXQ4T+aAZ+LW3wV3+0it6/rbh9C1B/wCl4Isnm4IRltPabgEfNJ50nw==",
|
||||
"version": "8.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.5.tgz",
|
||||
"integrity": "sha512-Uk1qdGPoLHaVhd2FnYSAvRehd3VwwcPIfXaR51qiC7C2L5VhD27VyLSgDetc15G4U+VAIFjgUSR/pKdLFEuMPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.36.2",
|
||||
@@ -7306,9 +7306,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@ionic/core": {
|
||||
"version": "8.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.3.tgz",
|
||||
"integrity": "sha512-KdyMxpMDQj+uqpztpK6yvN/T96hqcDiGXQ4T+aAZ+LW3wV3+0it6/rbh9C1B/wCl4Isnm4IRltPabgEfNJ50nw==",
|
||||
"version": "8.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.5.tgz",
|
||||
"integrity": "sha512-Uk1qdGPoLHaVhd2FnYSAvRehd3VwwcPIfXaR51qiC7C2L5VhD27VyLSgDetc15G4U+VAIFjgUSR/pKdLFEuMPA==",
|
||||
"requires": {
|
||||
"@stencil/core": "4.36.2",
|
||||
"ionicons": "^8.0.13",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ionic/angular-server",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"description": "Angular SSR Module for Ionic",
|
||||
"keywords": [
|
||||
"ionic",
|
||||
@@ -62,6 +62,6 @@
|
||||
},
|
||||
"prettier": "@ionic/prettier-config",
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.3"
|
||||
"@ionic/core": "^8.7.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,29 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.5](https://github.com/ionic-team/ionic-framework/compare/v8.7.4...v8.7.5) (2025-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **modal:** allow sheet modals to skip focus trap ([#30689](https://github.com/ionic-team/ionic-framework/issues/30689)) ([a40d957](https://github.com/ionic-team/ionic-framework/commit/a40d957ad9c1897af365a91b45b00228a00d614c)), closes [#30684](https://github.com/ionic-team/ionic-framework/issues/30684)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.4](https://github.com/ionic-team/ionic-framework/compare/v8.7.3...v8.7.4) (2025-09-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **input:** improve error text accessibility ([#30635](https://github.com/ionic-team/ionic-framework/issues/30635)) ([c339bc3](https://github.com/ionic-team/ionic-framework/commit/c339bc36827b62ef871325869a9a5db9b17ac785))
|
||||
* **overlays,picker:** remove invalid aria-hidden attribute ([#30563](https://github.com/ionic-team/ionic-framework/issues/30563)) ([49f96d7](https://github.com/ionic-team/ionic-framework/commit/49f96d7f1e9050a95e3e33a821c0467ecc0bed64)), closes [#30040](https://github.com/ionic-team/ionic-framework/issues/30040)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.3](https://github.com/ionic-team/ionic-framework/compare/v8.7.2...v8.7.3) (2025-08-20)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular
|
||||
|
||||
12
packages/angular/package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.3",
|
||||
"@ionic/core": "^8.7.5",
|
||||
"ionicons": "^8.0.13",
|
||||
"jsonc-parser": "^3.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -1398,9 +1398,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@ionic/core": {
|
||||
"version": "8.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.3.tgz",
|
||||
"integrity": "sha512-KdyMxpMDQj+uqpztpK6yvN/T96hqcDiGXQ4T+aAZ+LW3wV3+0it6/rbh9C1B/wCl4Isnm4IRltPabgEfNJ50nw==",
|
||||
"version": "8.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.5.tgz",
|
||||
"integrity": "sha512-Uk1qdGPoLHaVhd2FnYSAvRehd3VwwcPIfXaR51qiC7C2L5VhD27VyLSgDetc15G4U+VAIFjgUSR/pKdLFEuMPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.36.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.3",
|
||||
"version": "8.7.5",
|
||||
"description": "Angular specific wrappers for @ionic/core",
|
||||
"keywords": [
|
||||
"ionic",
|
||||
@@ -37,6 +37,7 @@
|
||||
"eslint": "eslint . --ext .ts",
|
||||
"prerelease": "npm run validate && np prerelease --yolo --any-branch --tag next",
|
||||
"sync": "./scripts/sync.sh",
|
||||
"local.sync.and.pack": "./scripts/sync-and-pack.sh",
|
||||
"test": "echo 'angular no tests yet'",
|
||||
"tsc": "tsc -p .",
|
||||
"validate": "npm i && npm run lint && npm run test && npm run build"
|
||||
@@ -47,7 +48,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.3",
|
||||
"@ionic/core": "^8.7.5",
|
||||
"ionicons": "^8.0.13",
|
||||
"jsonc-parser": "^3.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
|
||||
30
packages/angular/scripts/sync-and-pack.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
set -e
|
||||
|
||||
# Delete old packages
|
||||
rm -f *.tgz
|
||||
|
||||
# Pack @ionic/core
|
||||
echo "\n📦 Packing @ionic/core..."
|
||||
npm pack ../../core
|
||||
|
||||
# Update package.json with global path for the @ionic/core package
|
||||
echo "\n⚙️ Updating package.json with global path for @ionic/core..."
|
||||
CORE_PACKAGE=$(ls ionic-core-*.tgz | head -1)
|
||||
sed -i "" "s|\"@ionic/core\": \".*\"|\"@ionic/core\": \"file:$(pwd)/$CORE_PACKAGE\"|" package.json
|
||||
|
||||
# Remove package-lock.json
|
||||
rm -f package-lock.json
|
||||
|
||||
# Install Dependencies
|
||||
echo "\n🔧 Installing dependencies..."
|
||||
npm install
|
||||
|
||||
# Build the project
|
||||
echo "\n🔨 Building the project..."
|
||||
npm run build
|
||||
|
||||
# Pack @ionic/angular
|
||||
echo "\n📦 Packing @ionic/angular..."
|
||||
npm pack ./dist
|
||||
|
||||
echo "\n✅ Packed ionic-angular package!\n $(pwd)/$(ls ionic-angular-*.tgz | head -1)\n"
|
||||
4801
packages/angular/test/apps/ng20/package-lock.json
generated
@@ -16,16 +16,16 @@
|
||||
"test": "npx playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.2.0",
|
||||
"@angular/common": "^20.2.0",
|
||||
"@angular/compiler": "^20.2.0",
|
||||
"@angular/core": "^20.2.0",
|
||||
"@angular/forms": "^20.2.0",
|
||||
"@angular/platform-browser": "^20.2.0",
|
||||
"@angular/platform-browser-dynamic": "^20.2.0",
|
||||
"@angular/platform-server": "^20.2.0",
|
||||
"@angular/router": "^20.2.0",
|
||||
"@angular/ssr": "^20.2.0",
|
||||
"@angular/animations": "^20.0.0",
|
||||
"@angular/common": "^20.0.0",
|
||||
"@angular/compiler": "^20.0.0",
|
||||
"@angular/core": "^20.0.0",
|
||||
"@angular/forms": "^20.0.0",
|
||||
"@angular/platform-browser": "^20.0.0",
|
||||
"@angular/platform-browser-dynamic": "^20.0.0",
|
||||
"@angular/platform-server": "^20.0.0",
|
||||
"@angular/router": "^20.0.0",
|
||||
"@angular/ssr": "^20.0.0",
|
||||
"@ionic/angular": "^8.4.0",
|
||||
"@ionic/angular-server": "^8.4.0",
|
||||
"core-js": "^3.33.2",
|
||||
@@ -37,16 +37,16 @@
|
||||
"zone.js": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^20.2.0",
|
||||
"@angular-eslint/builder": "^20.2.0",
|
||||
"@angular-eslint/eslint-plugin": "^20.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^20.2.0",
|
||||
"@angular-eslint/schematics": "^20.2.0",
|
||||
"@angular-eslint/template-parser": "^20.2.0",
|
||||
"@angular/build": "^20.2.0",
|
||||
"@angular/cli": "^20.2.0",
|
||||
"@angular/compiler-cli": "^20.2.0",
|
||||
"@angular/language-service": "^20.2.0",
|
||||
"@angular-devkit/build-angular": "^20.0.0",
|
||||
"@angular-eslint/builder": "^20.0.0",
|
||||
"@angular-eslint/eslint-plugin": "^20.0.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^20.0.0",
|
||||
"@angular-eslint/schematics": "^20.0.0",
|
||||
"@angular-eslint/template-parser": "^20.0.0",
|
||||
"@angular/build": "^20.0.0",
|
||||
"@angular/cli": "^20.0.0",
|
||||
"@angular/compiler-cli": "^20.0.0",
|
||||
"@angular/language-service": "^20.0.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.9.3",
|
||||
@@ -57,7 +57,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"ts-loader": "^6.2.2",
|
||||
"ts-node": "^8.3.0",
|
||||
"typescript": "^5.9.0",
|
||||
"typescript": "^5.8.0",
|
||||
"wait-on": "^8.0.1",
|
||||
"webpack": "^5.61.0",
|
||||
"webpack-cli": "^4.9.2"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Modals: Dynamic Wrapper', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/lazy/modal-dynamic-wrapper');
|
||||
});
|
||||
|
||||
test('should render dynamic component inside modal', async ({ page }) => {
|
||||
await page.locator('#open-dynamic-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
await expect(page.locator('#dynamic-component-loaded')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow interacting with background content while sheet is open', async ({ page }) => {
|
||||
await page.locator('#open-dynamic-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
|
||||
await page.locator('#background-action').click();
|
||||
|
||||
await expect(page.locator('#background-action-count')).toHaveText('1');
|
||||
});
|
||||
|
||||
test('should prevent interacting with background content when focus is trapped', async ({ page }) => {
|
||||
await page.locator('#open-focused-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
|
||||
// Attempt to click the background button via coordinates; click should be intercepted by backdrop
|
||||
const box = await page.locator('#background-action').boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
}
|
||||
|
||||
await expect(page.locator('#background-action-count')).toHaveText('0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Modals: Inline Sheet', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/lazy/modal-sheet-inline');
|
||||
});
|
||||
|
||||
test('should open inline sheet modal', async ({ page }) => {
|
||||
await page.locator('#present-inline-sheet-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
await expect(page.locator('#current-breakpoint')).toHaveText('0.2');
|
||||
await expect(page.locator('ion-modal ion-item')).toHaveCount(4);
|
||||
});
|
||||
|
||||
test('should expand to 0.75 breakpoint when searchbar is clicked', async ({ page }) => {
|
||||
await page.locator('#present-inline-sheet-modal').click();
|
||||
await expect(page.locator('#current-breakpoint')).toHaveText('0.2');
|
||||
|
||||
await page.locator('ion-modal ion-searchbar').click();
|
||||
|
||||
await expect(page.locator('#current-breakpoint')).toHaveText('0.75');
|
||||
});
|
||||
|
||||
test('should allow interacting with background content while sheet is open', async ({ page }) => {
|
||||
await page.locator('#present-inline-sheet-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
|
||||
await page.locator('#background-action').click();
|
||||
|
||||
await expect(page.locator('#background-action-count')).toHaveText('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Modals: Dynamic Wrapper (standalone)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/standalone/modal-dynamic-wrapper');
|
||||
});
|
||||
|
||||
test('should render dynamic component inside modal', async ({ page }) => {
|
||||
await page.locator('#open-dynamic-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
await expect(page.locator('#dynamic-component-loaded')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow interacting with background content while sheet is open', async ({ page }) => {
|
||||
await page.locator('#open-dynamic-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
|
||||
await page.locator('#background-action').click();
|
||||
|
||||
await expect(page.locator('#background-action-count')).toHaveText('1');
|
||||
});
|
||||
|
||||
test('should prevent interacting with background content when focus is trapped', async ({ page }) => {
|
||||
await page.locator('#open-focused-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
|
||||
// Attempt to click the background button via coordinates; click should be intercepted by backdrop
|
||||
const box = await page.locator('#background-action').boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
}
|
||||
|
||||
await expect(page.locator('#background-action-count')).toHaveText('0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Modals: Inline Sheet (standalone)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/standalone/modal-sheet-inline');
|
||||
});
|
||||
|
||||
test('should open inline sheet modal', async ({ page }) => {
|
||||
await page.locator('#present-inline-sheet-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
await expect(page.locator('#current-breakpoint')).toHaveText('0.2');
|
||||
await expect(page.locator('ion-modal ion-item')).toHaveCount(4);
|
||||
});
|
||||
|
||||
test('should expand to 0.75 breakpoint when searchbar is clicked', async ({ page }) => {
|
||||
await page.locator('#present-inline-sheet-modal').click();
|
||||
await expect(page.locator('#current-breakpoint')).toHaveText('0.2');
|
||||
|
||||
await page.locator('ion-modal ion-searchbar').click();
|
||||
|
||||
await expect(page.locator('#current-breakpoint')).toHaveText('0.75');
|
||||
});
|
||||
|
||||
test('should allow interacting with background content while sheet is open', async ({ page }) => {
|
||||
await page.locator('#present-inline-sheet-modal').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
|
||||
await page.locator('#background-action').click();
|
||||
|
||||
await expect(page.locator('#background-action-count')).toHaveText('1');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { AlertComponent } from '../alert/alert.component';
|
||||
import { AccordionComponent } from '../accordion/accordion.component';
|
||||
import { AccordionModalComponent } from '../accordion/accordion-modal/accordion-modal.component';
|
||||
import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
|
||||
import { TemplateFormComponent } from '../template-form/template-form.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@@ -53,7 +54,8 @@ import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
|
||||
AlertComponent,
|
||||
AccordionComponent,
|
||||
AccordionModalComponent,
|
||||
TabsBasicComponent
|
||||
TabsBasicComponent,
|
||||
TemplateFormComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { NavigationPage3Component } from '../navigation-page3/navigation-page3.c
|
||||
import { AlertComponent } from '../alert/alert.component';
|
||||
import { AccordionComponent } from '../accordion/accordion.component';
|
||||
import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component';
|
||||
import { TemplateFormComponent } from '../template-form/template-form.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -33,8 +34,11 @@ export const routes: Routes = [
|
||||
{ path: 'textarea', loadChildren: () => import('../textarea/textarea.module').then(m => m.TextareaModule) },
|
||||
{ path: 'searchbar', loadChildren: () => import('../searchbar/searchbar.module').then(m => m.SearchbarModule) },
|
||||
{ path: 'form', component: FormComponent },
|
||||
{ path: 'template-form', component: TemplateFormComponent },
|
||||
{ path: 'modals', component: ModalComponent },
|
||||
{ path: 'modal-inline', loadChildren: () => import('../modal-inline').then(m => m.ModalInlineModule) },
|
||||
{ path: 'modal-sheet-inline', loadChildren: () => import('../modal-sheet-inline').then(m => m.ModalSheetInlineModule) },
|
||||
{ path: 'modal-dynamic-wrapper', loadChildren: () => import('../modal-dynamic-wrapper').then(m => m.ModalDynamicWrapperModule) },
|
||||
{ path: 'view-child', component: ViewChildComponent },
|
||||
{ path: 'keep-contents-mounted', loadChildren: () => import('../keep-contents-mounted').then(m => m.OverlayAutoMountModule) },
|
||||
{ path: 'overlays-inline', loadChildren: () => import('../overlays-inline').then(m => m.OverlaysInlineModule) },
|
||||
@@ -88,4 +92,3 @@ export const routes: Routes = [
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -25,11 +25,26 @@
|
||||
Form Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/lazy/template-form">
|
||||
<ion-label>
|
||||
Template-Driven Form Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/lazy/modals">
|
||||
<ion-label>
|
||||
Modals Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/lazy/modal-sheet-inline">
|
||||
<ion-label>
|
||||
Modal Sheet Inline Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/lazy/modal-dynamic-wrapper">
|
||||
<ion-label>
|
||||
Modal Dynamic Wrapper Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/lazy/router-link">
|
||||
<ion-label>
|
||||
Router link Test
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, ComponentRef, Input, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: 'app-dynamic-component-wrapper',
|
||||
template: `
|
||||
<ion-content>
|
||||
<ng-container #container></ng-container>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: false
|
||||
})
|
||||
export class DynamicComponentWrapperComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() componentRef?: ComponentRef<unknown>;
|
||||
@ViewChild('container', { read: ViewContainerRef, static: true }) container!: ViewContainerRef;
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.componentRef) {
|
||||
this.container.insert(this.componentRef.hostView);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.componentRef?.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, EventEmitter, Output } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: 'app-dynamic-modal-content',
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Dynamic Sheet Content</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p id="dynamic-component-loaded">Dynamic component rendered inside wrapper.</p>
|
||||
<ion-button id="dismiss-dynamic-modal" (click)="dismiss.emit()">Close</ion-button>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: false
|
||||
})
|
||||
export class DynamicModalContentComponent {
|
||||
@Output() dismiss = new EventEmitter<void>();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './modal-dynamic-wrapper.component';
|
||||
export * from './modal-dynamic-wrapper.module';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { ModalDynamicWrapperComponent } from ".";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: '',
|
||||
component: ModalDynamicWrapperComponent
|
||||
}
|
||||
])
|
||||
],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ModalDynamicWrapperRoutingModule { }
|
||||
@@ -0,0 +1,8 @@
|
||||
<ion-button id="open-dynamic-modal" (click)="openModal()">Open Dynamic Sheet Modal</ion-button>
|
||||
<ion-button id="open-focused-modal" color="primary" (click)="openFocusedModal()">Open Focus-Trapped Sheet Modal</ion-button>
|
||||
<ion-button id="background-action" (click)="onBackgroundActionClick()">Background Action</ion-button>
|
||||
<p>
|
||||
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
|
||||
</p>
|
||||
|
||||
<ng-template #modalHost></ng-template>
|
||||