Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9794a63f85 | ||
|
|
ed0e7de898 | ||
|
|
05928e3877 | ||
|
|
64c1373f53 | ||
|
|
01917ee0ce | ||
|
|
cdfb4f37ad | ||
|
|
1b11b82eaa | ||
|
|
e101f2e022 | ||
|
|
000f55303e | ||
|
|
6d0b4297dc | ||
|
|
270526e4f2 | ||
|
|
234d14a32d | ||
|
|
a90097cdb1 | ||
|
|
1c281dc4ee | ||
|
|
845071c97a | ||
|
|
f6188c47e9 | ||
|
|
8ee42bbc1e | ||
|
|
23763abf79 | ||
|
|
470decca7b | ||
|
|
3216108ca1 | ||
|
|
4bffe976d9 | ||
|
|
ec14e13780 | ||
|
|
fcc728faf2 | ||
|
|
89508fb891 | ||
|
|
3628ea875a | ||
|
|
0fdcb32ce0 | ||
|
|
ee2fa19a1e | ||
|
|
2d6eeee267 | ||
|
|
6dc52d2d7c | ||
|
|
ffdaa3b286 | ||
|
|
93364b93c4 | ||
|
|
c3b58f1620 | ||
|
|
5a7314553a | ||
|
|
322d7c98cf | ||
|
|
cb6007363a | ||
|
|
e32fbe0210 | ||
|
|
47ba703a57 | ||
|
|
7294e969bb | ||
|
|
be7561d0d4 | ||
|
|
c67e6299d7 | ||
|
|
bb1fb2877b | ||
|
|
b7b383bee0 | ||
|
|
cdb4456be2 | ||
|
|
bbcbf5c425 | ||
|
|
78fb1b9a06 | ||
|
|
4d0e9c4186 | ||
|
|
668b2dac57 | ||
|
|
078ed0b86a |
@@ -5,7 +5,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -11,7 +11,7 @@ runs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -11,7 +11,7 @@ runs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
working-directory: ./core
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -21,7 +21,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
# Provenance requires npm 9.5.0+
|
||||
- name: Install latest npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
|
||||
@@ -5,7 +5,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: ./core
|
||||
|
||||
@@ -15,7 +15,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -8,7 +8,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: ./core
|
||||
|
||||
@@ -8,7 +8,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -8,7 +8,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -8,7 +8,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
|
||||
@@ -9,7 +9,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
2
.github/workflows/assign-issues.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: 'Auto-assign issue'
|
||||
uses: pozil/auto-assign-issue@c5bca5027e680b9e8411b826d16947afd8c76b32 # v2.0.0
|
||||
uses: pozil/auto-assign-issue@d4b175c7e47cd6d5067f5aa83c9f21b93f8c723e # v2.1.1
|
||||
with:
|
||||
assignees: brandyscarney, thetaPC, joselrio, rugoncalves, BenOsodrac, JoaoFerreira-FrontEnd, OS-giulianasilva, tanner-reits
|
||||
numOfAssignee: 1
|
||||
|
||||
2
.github/workflows/build.yml
vendored
@@ -140,7 +140,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
apps: [ng16, ng17, ng18]
|
||||
apps: [ng16, ng17, ng18, ng19]
|
||||
needs: [build-angular, build-angular-server]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
2
.github/workflows/stencil-nightly.yml
vendored
@@ -150,7 +150,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
apps: [ng16, ng17, ng18]
|
||||
apps: [ng16, ng17, ng18, ng19]
|
||||
needs: [build-angular, build-angular-server]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
72
CHANGELOG.md
@@ -3,6 +3,78 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.4.1](https://github.com/ionic-team/ionic-framework/compare/v8.4.0...v8.4.1) (2024-11-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **header:** use aria attributes to hide small title when collapsed ([#30027](https://github.com/ionic-team/ionic-framework/issues/30027)) ([23763ab](https://github.com/ionic-team/ionic-framework/commit/23763abf797f9a4ba8262225760f718e9dcc4782)), closes [#29347](https://github.com/ionic-team/ionic-framework/issues/29347)
|
||||
* **menu:** hide from screen readers while animating ([#30036](https://github.com/ionic-team/ionic-framework/issues/30036)) ([845071c](https://github.com/ionic-team/ionic-framework/commit/845071c97a856d45eb5e0bb81d9c270bc38bb604))
|
||||
* **overlays:** announce info after opening based on platform ([#30025](https://github.com/ionic-team/ionic-framework/issues/30025)) ([f6188c4](https://github.com/ionic-team/ionic-framework/commit/f6188c47e9278fe69fd9d250c65156edbe5ef32e))
|
||||
* **overlays:** focus management with checkbox/radio ([#30026](https://github.com/ionic-team/ionic-framework/issues/30026)) ([8ee42bb](https://github.com/ionic-team/ionic-framework/commit/8ee42bbc1e0bf4731d20040c7853756722f1a4b2))
|
||||
* **toast:** swipe gesture works with custom container layout ([#29999](https://github.com/ionic-team/ionic-framework/issues/29999)) ([470decc](https://github.com/ionic-team/ionic-framework/commit/470decca7b6b89ef74095ef0bb7909b93640cd78)), closes [#29998](https://github.com/ionic-team/ionic-framework/issues/29998)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [8.4.0](https://github.com/ionic-team/ionic-framework/compare/v8.3.4...v8.4.0) (2024-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **alert:** use correct heading structure for subHeader when header exists ([#29964](https://github.com/ionic-team/ionic-framework/issues/29964)) ([0fdcb32](https://github.com/ionic-team/ionic-framework/commit/0fdcb32ce0f99b284b314f79f7d0b071bc37faec))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **menu:** pass role to ionWillClose and ionDidClose events ([#29954](https://github.com/ionic-team/ionic-framework/issues/29954)) ([ee2fa19](https://github.com/ionic-team/ionic-framework/commit/ee2fa19a1e9f09d492c7c08340d95ba6a56ebb2b))
|
||||
* **segment-view:** adds support for new `ion-segment-view` component ([#29969](https://github.com/ionic-team/ionic-framework/issues/29969)) ([89508fb](https://github.com/ionic-team/ionic-framework/commit/89508fb89172900b1d11cc3fc18883f57a7fbab6))
|
||||
* **select:** add `modal` as interface ([#29972](https://github.com/ionic-team/ionic-framework/issues/29972)) ([3628ea8](https://github.com/ionic-team/ionic-framework/commit/3628ea875a66a717783de5e0a4df440872339040))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.3.4](https://github.com/ionic-team/ionic-framework/compare/v8.3.3...v8.3.4) (2024-10-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **angular:** add missing 'compareWith' input to standalone ion-radio-group ([#29870](https://github.com/ionic-team/ionic-framework/issues/29870)) ([47ba703](https://github.com/ionic-team/ionic-framework/commit/47ba703a57d1ca506f943f6b790d0bf7583d79cb)), closes [#29826](https://github.com/ionic-team/ionic-framework/issues/29826)
|
||||
* **backdrop:** remove tabindex for improved accessibility ([#29956](https://github.com/ionic-team/ionic-framework/issues/29956)) ([7294e96](https://github.com/ionic-team/ionic-framework/commit/7294e969bb913692eaf28e54860614f445132713)), closes [#29773](https://github.com/ionic-team/ionic-framework/issues/29773)
|
||||
* **input, textarea:** ensure screen readers announce helper and error text when focused ([#29958](https://github.com/ionic-team/ionic-framework/issues/29958)) ([5a73145](https://github.com/ionic-team/ionic-framework/commit/5a7314553a8def87bd19275640c92dd72a6ef1a4))
|
||||
* **overlay:** hide from screen readers while animating ([#29951](https://github.com/ionic-team/ionic-framework/issues/29951)) ([cb60073](https://github.com/ionic-team/ionic-framework/commit/cb6007363a8d42b5f126945427c2bfc3d7209c21)), closes [#29857](https://github.com/ionic-team/ionic-framework/issues/29857)
|
||||
* **overlays:** do not hide root when toast appears ([#29962](https://github.com/ionic-team/ionic-framework/issues/29962)) ([322d7c9](https://github.com/ionic-team/ionic-framework/commit/322d7c98cf6613df0b0db3f119e3f892e6a17e7b)), closes [#29773](https://github.com/ionic-team/ionic-framework/issues/29773)
|
||||
* **overlays:** hide the focus trap div from screen readers ([#29970](https://github.com/ionic-team/ionic-framework/issues/29970)) ([c3b58f1](https://github.com/ionic-team/ionic-framework/commit/c3b58f1620bcb74db43e3983ef570b7b982abd83)), closes [#29858](https://github.com/ionic-team/ionic-framework/issues/29858)
|
||||
* **vue:** incorrect view rendered when using router.go(-n) ([#29877](https://github.com/ionic-team/ionic-framework/issues/29877)) ([e32fbe0](https://github.com/ionic-team/ionic-framework/commit/e32fbe02102fe80db29f73c26496a40852032354)), closes [#28201](https://github.com/ionic-team/ionic-framework/issues/28201) [#28201](https://github.com/ionic-team/ionic-framework/issues/28201) [#29847](https://github.com/ionic-team/ionic-framework/issues/29847)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.3.3](https://github.com/ionic-team/ionic-framework/compare/v8.3.2...v8.3.3) (2024-10-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tabs, tab-bar:** use standalone tab bar in Vue, React ([#29940](https://github.com/ionic-team/ionic-framework/issues/29940)) ([b7b383b](https://github.com/ionic-team/ionic-framework/commit/b7b383bee080b72de2e6307ff9a9a051314c69ed)), closes [#29885](https://github.com/ionic-team/ionic-framework/issues/29885) [#29924](https://github.com/ionic-team/ionic-framework/issues/29924)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.3.2](https://github.com/ionic-team/ionic-framework/compare/v8.3.1...v8.3.2) (2024-10-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **segment:** prevent flickering for scrollable on iOS ([#29884](https://github.com/ionic-team/ionic-framework/issues/29884)) ([078ed0b](https://github.com/ionic-team/ionic-framework/commit/078ed0b86a0d8e9f8457481cb739ea214195adce)), closes [#29523](https://github.com/ionic-team/ionic-framework/issues/29523)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.3.1](https://github.com/ionic-team/ionic-framework/compare/v8.3.0...v8.3.1) (2024-09-17)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,73 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.4.1](https://github.com/ionic-team/ionic-framework/compare/v8.4.0...v8.4.1) (2024-11-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **header:** use aria attributes to hide small title when collapsed ([#30027](https://github.com/ionic-team/ionic-framework/issues/30027)) ([23763ab](https://github.com/ionic-team/ionic-framework/commit/23763abf797f9a4ba8262225760f718e9dcc4782)), closes [#29347](https://github.com/ionic-team/ionic-framework/issues/29347)
|
||||
* **menu:** hide from screen readers while animating ([#30036](https://github.com/ionic-team/ionic-framework/issues/30036)) ([845071c](https://github.com/ionic-team/ionic-framework/commit/845071c97a856d45eb5e0bb81d9c270bc38bb604))
|
||||
* **overlays:** announce info after opening based on platform ([#30025](https://github.com/ionic-team/ionic-framework/issues/30025)) ([f6188c4](https://github.com/ionic-team/ionic-framework/commit/f6188c47e9278fe69fd9d250c65156edbe5ef32e))
|
||||
* **overlays:** focus management with checkbox/radio ([#30026](https://github.com/ionic-team/ionic-framework/issues/30026)) ([8ee42bb](https://github.com/ionic-team/ionic-framework/commit/8ee42bbc1e0bf4731d20040c7853756722f1a4b2))
|
||||
* **toast:** swipe gesture works with custom container layout ([#29999](https://github.com/ionic-team/ionic-framework/issues/29999)) ([470decc](https://github.com/ionic-team/ionic-framework/commit/470decca7b6b89ef74095ef0bb7909b93640cd78)), closes [#29998](https://github.com/ionic-team/ionic-framework/issues/29998)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [8.4.0](https://github.com/ionic-team/ionic-framework/compare/v8.3.4...v8.4.0) (2024-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **alert:** use correct heading structure for subHeader when header exists ([#29964](https://github.com/ionic-team/ionic-framework/issues/29964)) ([0fdcb32](https://github.com/ionic-team/ionic-framework/commit/0fdcb32ce0f99b284b314f79f7d0b071bc37faec))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **menu:** pass role to ionWillClose and ionDidClose events ([#29954](https://github.com/ionic-team/ionic-framework/issues/29954)) ([ee2fa19](https://github.com/ionic-team/ionic-framework/commit/ee2fa19a1e9f09d492c7c08340d95ba6a56ebb2b))
|
||||
* **segment-view:** adds support for new `ion-segment-view` component ([#29969](https://github.com/ionic-team/ionic-framework/issues/29969)) ([89508fb](https://github.com/ionic-team/ionic-framework/commit/89508fb89172900b1d11cc3fc18883f57a7fbab6))
|
||||
* **select:** add `modal` as interface ([#29972](https://github.com/ionic-team/ionic-framework/issues/29972)) ([3628ea8](https://github.com/ionic-team/ionic-framework/commit/3628ea875a66a717783de5e0a4df440872339040))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.3.4](https://github.com/ionic-team/ionic-framework/compare/v8.3.3...v8.3.4) (2024-10-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backdrop:** remove tabindex for improved accessibility ([#29956](https://github.com/ionic-team/ionic-framework/issues/29956)) ([7294e96](https://github.com/ionic-team/ionic-framework/commit/7294e969bb913692eaf28e54860614f445132713)), closes [#29773](https://github.com/ionic-team/ionic-framework/issues/29773)
|
||||
* **input, textarea:** ensure screen readers announce helper and error text when focused ([#29958](https://github.com/ionic-team/ionic-framework/issues/29958)) ([5a73145](https://github.com/ionic-team/ionic-framework/commit/5a7314553a8def87bd19275640c92dd72a6ef1a4))
|
||||
* **overlay:** hide from screen readers while animating ([#29951](https://github.com/ionic-team/ionic-framework/issues/29951)) ([cb60073](https://github.com/ionic-team/ionic-framework/commit/cb6007363a8d42b5f126945427c2bfc3d7209c21)), closes [#29857](https://github.com/ionic-team/ionic-framework/issues/29857)
|
||||
* **overlays:** do not hide root when toast appears ([#29962](https://github.com/ionic-team/ionic-framework/issues/29962)) ([322d7c9](https://github.com/ionic-team/ionic-framework/commit/322d7c98cf6613df0b0db3f119e3f892e6a17e7b)), closes [#29773](https://github.com/ionic-team/ionic-framework/issues/29773)
|
||||
* **overlays:** hide the focus trap div from screen readers ([#29970](https://github.com/ionic-team/ionic-framework/issues/29970)) ([c3b58f1](https://github.com/ionic-team/ionic-framework/commit/c3b58f1620bcb74db43e3983ef570b7b982abd83)), closes [#29858](https://github.com/ionic-team/ionic-framework/issues/29858)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.3.3](https://github.com/ionic-team/ionic-framework/compare/v8.3.2...v8.3.3) (2024-10-16)
|
||||
|
||||
**Note:** Version bump only for package @ionic/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.3.2](https://github.com/ionic-team/ionic-framework/compare/v8.3.1...v8.3.2) (2024-10-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **segment:** prevent flickering for scrollable on iOS ([#29884](https://github.com/ionic-team/ionic-framework/issues/29884)) ([078ed0b](https://github.com/ionic-team/ionic-framework/commit/078ed0b86a0d8e9f8457481cb739ea214195adce)), closes [#29523](https://github.com/ionic-team/ionic-framework/issues/29523)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.3.1](https://github.com/ionic-team/ionic-framework/compare/v8.3.0...v8.3.1) (2024-09-17)
|
||||
|
||||
|
||||
|
||||
24
core/api.txt
@@ -143,6 +143,7 @@ ion-alert,css-prop,--width,ios
|
||||
ion-alert,css-prop,--width,md
|
||||
|
||||
ion-app,none
|
||||
ion-app,method,setFocus,setFocus(elements: HTMLElement[]) => Promise<void>
|
||||
|
||||
ion-avatar,shadow
|
||||
ion-avatar,css-prop,--border-radius,ios
|
||||
@@ -999,15 +1000,15 @@ ion-menu,prop,menuId,string | undefined,undefined,false,true
|
||||
ion-menu,prop,side,"end" | "start",'start',false,true
|
||||
ion-menu,prop,swipeGesture,boolean,true,false,false
|
||||
ion-menu,prop,type,"overlay" | "push" | "reveal" | undefined,undefined,false,false
|
||||
ion-menu,method,close,close(animated?: boolean) => Promise<boolean>
|
||||
ion-menu,method,close,close(animated?: boolean, role?: string) => Promise<boolean>
|
||||
ion-menu,method,isActive,isActive() => Promise<boolean>
|
||||
ion-menu,method,isOpen,isOpen() => Promise<boolean>
|
||||
ion-menu,method,open,open(animated?: boolean) => Promise<boolean>
|
||||
ion-menu,method,setOpen,setOpen(shouldOpen: boolean, animated?: boolean) => Promise<boolean>
|
||||
ion-menu,method,setOpen,setOpen(shouldOpen: boolean, animated?: boolean, role?: string) => Promise<boolean>
|
||||
ion-menu,method,toggle,toggle(animated?: boolean) => Promise<boolean>
|
||||
ion-menu,event,ionDidClose,void,true
|
||||
ion-menu,event,ionDidClose,MenuCloseEventDetail,true
|
||||
ion-menu,event,ionDidOpen,void,true
|
||||
ion-menu,event,ionWillClose,void,true
|
||||
ion-menu,event,ionWillClose,MenuCloseEventDetail,true
|
||||
ion-menu,event,ionWillOpen,void,true
|
||||
ion-menu,css-prop,--background,ios
|
||||
ion-menu,css-prop,--background,md
|
||||
@@ -1084,6 +1085,7 @@ ion-modal,prop,keyboardClose,boolean,true,false,false
|
||||
ion-modal,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
|
||||
ion-modal,prop,mode,"ios" | "md",undefined,false,false
|
||||
ion-modal,prop,presentingElement,HTMLElement | undefined,undefined,false,false
|
||||
ion-modal,prop,scrollAtEdge,boolean,true,false,false
|
||||
ion-modal,prop,showBackdrop,boolean,true,false,false
|
||||
ion-modal,prop,trigger,string | undefined,undefined,false,false
|
||||
ion-modal,method,dismiss,dismiss(data?: any, role?: string) => Promise<boolean>
|
||||
@@ -1541,6 +1543,7 @@ ion-segment,css-prop,--background,ios
|
||||
ion-segment,css-prop,--background,md
|
||||
|
||||
ion-segment-button,shadow
|
||||
ion-segment-button,prop,contentId,string | undefined,undefined,false,true
|
||||
ion-segment-button,prop,disabled,boolean,false,false,false
|
||||
ion-segment-button,prop,layout,"icon-bottom" | "icon-end" | "icon-hide" | "icon-start" | "icon-top" | "label-hide" | undefined,'icon-top',false,false
|
||||
ion-segment-button,prop,mode,"ios" | "md",undefined,false,false
|
||||
@@ -1606,6 +1609,12 @@ ion-segment-button,part,indicator
|
||||
ion-segment-button,part,indicator-background
|
||||
ion-segment-button,part,native
|
||||
|
||||
ion-segment-content,shadow
|
||||
|
||||
ion-segment-view,shadow
|
||||
ion-segment-view,prop,disabled,boolean,false,false,false
|
||||
ion-segment-view,event,ionSegmentViewScroll,SegmentViewScrollEvent,true
|
||||
|
||||
ion-select,shadow
|
||||
ion-select,prop,cancelText,string,'Cancel',false,false
|
||||
ion-select,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,undefined,false,true
|
||||
@@ -1613,7 +1622,7 @@ ion-select,prop,compareWith,((currentValue: any, compareValue: any) => boolean)
|
||||
ion-select,prop,disabled,boolean,false,false,false
|
||||
ion-select,prop,expandedIcon,string | undefined,undefined,false,false
|
||||
ion-select,prop,fill,"outline" | "solid" | undefined,undefined,false,false
|
||||
ion-select,prop,interface,"action-sheet" | "alert" | "popover",'alert',false,false
|
||||
ion-select,prop,interface,"action-sheet" | "alert" | "modal" | "popover",'alert',false,false
|
||||
ion-select,prop,interfaceOptions,any,{},false,false
|
||||
ion-select,prop,justify,"end" | "space-between" | "start" | undefined,undefined,false,false
|
||||
ion-select,prop,label,string | undefined,undefined,false,false
|
||||
@@ -1671,6 +1680,11 @@ ion-select,part,label
|
||||
ion-select,part,placeholder
|
||||
ion-select,part,text
|
||||
|
||||
ion-select-modal,scoped
|
||||
ion-select-modal,prop,header,string | undefined,undefined,false,false
|
||||
ion-select-modal,prop,multiple,boolean | undefined,undefined,false,false
|
||||
ion-select-modal,prop,options,SelectModalOption[],[],false,false
|
||||
|
||||
ion-select-option,shadow
|
||||
ion-select-option,prop,disabled,boolean,false,false,false
|
||||
ion-select-option,prop,value,any,undefined,false,false
|
||||
|
||||
118
core/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.3.1",
|
||||
"version": "8.4.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/core",
|
||||
"version": "8.3.1",
|
||||
"version": "8.4.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.20.0",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@capacitor/haptics": "^6.0.0",
|
||||
"@capacitor/keyboard": "^6.0.0",
|
||||
"@capacitor/status-bar": "^6.0.0",
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@clack/prompts": "^0.9.0",
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@playwright/test": "^1.46.1",
|
||||
@@ -661,45 +661,45 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.1.2.tgz",
|
||||
"integrity": "sha512-xFy1/4qLFLp5WCIzIhtwUuVNNoz36+V7/BzHmLqgVJcvotc4MMjswW/TshnPQaLLujEOaLkA4h8ZJ0uoK3ImGg==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.0.tgz",
|
||||
"integrity": "sha512-B9IlJtDpUqhhYb+T8+cp2Db/3RETX36STgjeU2kQZBs/SLAcFiMama227o+msRjLeo3DO+7HJjWVA1+XlyyPEg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/haptics": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.1.tgz",
|
||||
"integrity": "sha512-Q8hedLwfwTSWEYc3eoATzkdKHBaIceYe5bd7FjxQCENNH0is5Ft0EjSRPz/xpTn39ebK0ooZBDBCwsyl6tjiTA==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.2.tgz",
|
||||
"integrity": "sha512-xcFdIH4iIIeW2+1lzmlYMVicqB9ytaiuZ9NE3a9laKFPvMGC7hdj6i6tHFezwPJ/96xkHOwXT2b0F8Mh9xtTWg==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-6.0.2.tgz",
|
||||
"integrity": "sha512-fOfO3rQ0ZXuTHpK03INVTwmBnpqMiH8EHPpNaHjwjKwdrVRWBvtgIFhuyHNXh53rdcXw+uHB+1RIiNabnCrITw==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-6.0.3.tgz",
|
||||
"integrity": "sha512-V/mURxBI68HvClYjrGBlOriWkwYN7r+cWid/igJz/3scNc/V81DgQ9fpoLr4W0I5NY7YxOesjIJLuLO+LT18mQ==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-6.0.1.tgz",
|
||||
"integrity": "sha512-Usd9hZZQVAqy+jJfL7jRcYI7dcsxN09Na1yttwdl+F1bk3Ztoukk7CGPDm5VgKUSs53ihQBOy1+sczCACxhNiw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-6.0.2.tgz",
|
||||
"integrity": "sha512-AmRIX6QvFemItlY7/69ARkIAqitRQqJ2qwgZmD1KqgFb78pH+XFXm1guvS/a8CuOOm/IqZ4ddDbl20yxtBqzGA==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.4.tgz",
|
||||
"integrity": "sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw==",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.0.tgz",
|
||||
"integrity": "sha512-YJCYBsyJfNDaTbvDUVSJ3SgSuPrcujarRgkJ5NLjexDZKvaOiVVJvAQYx8lIgG0qRT8ff0fPgqyBCVivanIZ+A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
@@ -707,32 +707,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz",
|
||||
"integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==",
|
||||
"bundleDependencies": [
|
||||
"is-unicode-supported"
|
||||
],
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.9.0.tgz",
|
||||
"integrity": "sha512-nGsytiExgUr4FL0pR/LeqxA28nz3E0cW7eLTSh3Iod9TGrbBt8Y7BHbV3mmkNC4G0evdYyQ3ZsbiBkk7ektArA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@clack/core": "^0.3.3",
|
||||
"is-unicode-supported": "*",
|
||||
"@clack/core": "0.4.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
||||
@@ -3183,9 +3167,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
|
||||
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.0.tgz",
|
||||
"integrity": "sha512-ZkD35Mx92acjB2yNJgziGqT9oKHEOxjTBTDRpOsRWtdecL/0jM3z5kM/CTzHWvHIen1GvkM85p6TuFfDGfc8/Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
@@ -10756,39 +10740,39 @@
|
||||
"dev": true
|
||||
},
|
||||
"@capacitor/core": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.1.2.tgz",
|
||||
"integrity": "sha512-xFy1/4qLFLp5WCIzIhtwUuVNNoz36+V7/BzHmLqgVJcvotc4MMjswW/TshnPQaLLujEOaLkA4h8ZJ0uoK3ImGg==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.0.tgz",
|
||||
"integrity": "sha512-B9IlJtDpUqhhYb+T8+cp2Db/3RETX36STgjeU2kQZBs/SLAcFiMama227o+msRjLeo3DO+7HJjWVA1+XlyyPEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"@capacitor/haptics": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.1.tgz",
|
||||
"integrity": "sha512-Q8hedLwfwTSWEYc3eoATzkdKHBaIceYe5bd7FjxQCENNH0is5Ft0EjSRPz/xpTn39ebK0ooZBDBCwsyl6tjiTA==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-6.0.2.tgz",
|
||||
"integrity": "sha512-xcFdIH4iIIeW2+1lzmlYMVicqB9ytaiuZ9NE3a9laKFPvMGC7hdj6i6tHFezwPJ/96xkHOwXT2b0F8Mh9xtTWg==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@capacitor/keyboard": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-6.0.2.tgz",
|
||||
"integrity": "sha512-fOfO3rQ0ZXuTHpK03INVTwmBnpqMiH8EHPpNaHjwjKwdrVRWBvtgIFhuyHNXh53rdcXw+uHB+1RIiNabnCrITw==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-6.0.3.tgz",
|
||||
"integrity": "sha512-V/mURxBI68HvClYjrGBlOriWkwYN7r+cWid/igJz/3scNc/V81DgQ9fpoLr4W0I5NY7YxOesjIJLuLO+LT18mQ==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@capacitor/status-bar": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-6.0.1.tgz",
|
||||
"integrity": "sha512-Usd9hZZQVAqy+jJfL7jRcYI7dcsxN09Na1yttwdl+F1bk3Ztoukk7CGPDm5VgKUSs53ihQBOy1+sczCACxhNiw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-6.0.2.tgz",
|
||||
"integrity": "sha512-AmRIX6QvFemItlY7/69ARkIAqitRQqJ2qwgZmD1KqgFb78pH+XFXm1guvS/a8CuOOm/IqZ4ddDbl20yxtBqzGA==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@clack/core": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.4.tgz",
|
||||
"integrity": "sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw==",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.0.tgz",
|
||||
"integrity": "sha512-YJCYBsyJfNDaTbvDUVSJ3SgSuPrcujarRgkJ5NLjexDZKvaOiVVJvAQYx8lIgG0qRT8ff0fPgqyBCVivanIZ+A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"picocolors": "^1.0.0",
|
||||
@@ -10796,22 +10780,14 @@
|
||||
}
|
||||
},
|
||||
"@clack/prompts": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz",
|
||||
"integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==",
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.9.0.tgz",
|
||||
"integrity": "sha512-nGsytiExgUr4FL0pR/LeqxA28nz3E0cW7eLTSh3Iod9TGrbBt8Y7BHbV3mmkNC4G0evdYyQ3ZsbiBkk7ektArA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@clack/core": "^0.3.3",
|
||||
"is-unicode-supported": "*",
|
||||
"@clack/core": "0.4.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-unicode-supported": {
|
||||
"version": "1.3.0",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@eslint-community/eslint-utils": {
|
||||
@@ -12537,9 +12513,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"chalk": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
|
||||
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.0.tgz",
|
||||
"integrity": "sha512-ZkD35Mx92acjB2yNJgziGqT9oKHEOxjTBTDRpOsRWtdecL/0jM3z5kM/CTzHWvHIen1GvkM85p6TuFfDGfc8/Q==",
|
||||
"dev": true
|
||||
},
|
||||
"chalk-template": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.3.1",
|
||||
"version": "8.4.1",
|
||||
"description": "Base components for Ionic",
|
||||
"keywords": [
|
||||
"ionic",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@capacitor/haptics": "^6.0.0",
|
||||
"@capacitor/keyboard": "^6.0.0",
|
||||
"@capacitor/status-bar": "^6.0.0",
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@clack/prompts": "^0.9.0",
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@playwright/test": "^1.46.1",
|
||||
|
||||
@@ -49,8 +49,19 @@ html.ios.ios {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "iosTestingFont", sans-serif;
|
||||
}
|
||||
|
||||
ion-content button,
|
||||
main button {
|
||||
/**
|
||||
* Button styles should only be applied
|
||||
* to native buttons that are not part of the
|
||||
* Ionic framework.
|
||||
* Otherwise, the styles may not appear correctly
|
||||
* when comparing between testing and production.
|
||||
* This issue occurs only with `scoped` components,
|
||||
* which is why `sc-ion-` is used as a filter,
|
||||
* since this class is specifically added to `scoped`
|
||||
* components.
|
||||
*/
|
||||
ion-content button:not([class*="sc-ion-"]),
|
||||
main button:not([class*="sc-ion-"]) {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
clear: both;
|
||||
@@ -63,8 +74,19 @@ main button {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
ion-content button.expand,
|
||||
main button.expand {
|
||||
/**
|
||||
* Button styles should only be applied
|
||||
* to native buttons that are not part of the
|
||||
* Ionic framework.
|
||||
* Otherwise, the styles may not appear correctly
|
||||
* when comparing between testing and production.
|
||||
* This issue occurs only with `scoped` components,
|
||||
* which is why `sc-ion-` is used as a filter,
|
||||
* since this class is specifically added to `scoped`
|
||||
* components.
|
||||
*/
|
||||
ion-content button.expand:not([class*="sc-ion-"]),
|
||||
main button.expand:not([class*="sc-ion-"]) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
128
core/src/components.d.ts
vendored
@@ -18,7 +18,7 @@ import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-int
|
||||
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
|
||||
import { SpinnerTypes } from "./components/spinner/spinner-configs";
|
||||
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
|
||||
import { MenuChangeEventDetail, MenuType, Side } from "./components/menu/menu-interface";
|
||||
import { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
|
||||
import { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
|
||||
import { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
|
||||
import { ViewController } from "./components/nav/view-controller";
|
||||
@@ -34,7 +34,9 @@ import { NavigationHookCallback } from "./components/route/route-interface";
|
||||
import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface";
|
||||
import { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface";
|
||||
import { SegmentButtonLayout } from "./components/segment-button/segment-button-interface";
|
||||
import { SegmentViewScrollEvent } from "./components/segment-view/segment-view-interface";
|
||||
import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface";
|
||||
import { SelectModalOption } from "./components/select-modal/select-modal-interface";
|
||||
import { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
|
||||
import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
|
||||
import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
|
||||
@@ -53,7 +55,7 @@ export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-int
|
||||
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
|
||||
export { SpinnerTypes } from "./components/spinner/spinner-configs";
|
||||
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
|
||||
export { MenuChangeEventDetail, MenuType, Side } from "./components/menu/menu-interface";
|
||||
export { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface";
|
||||
export { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
|
||||
export { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
|
||||
export { ViewController } from "./components/nav/view-controller";
|
||||
@@ -69,7 +71,9 @@ export { NavigationHookCallback } from "./components/route/route-interface";
|
||||
export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface";
|
||||
export { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface";
|
||||
export { SegmentButtonLayout } from "./components/segment-button/segment-button-interface";
|
||||
export { SegmentViewScrollEvent } from "./components/segment-view/segment-view-interface";
|
||||
export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface";
|
||||
export { SelectModalOption } from "./components/select-modal/select-modal-interface";
|
||||
export { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
|
||||
export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
|
||||
export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
|
||||
@@ -304,6 +308,9 @@ export namespace Components {
|
||||
"trigger": string | undefined;
|
||||
}
|
||||
interface IonApp {
|
||||
/**
|
||||
* Used to set focus on an element that uses `ion-focusable`. Do not use this if focusing the element as a result of a keyboard event as the focus utility should handle this for us. This method should be used when we want to programmatically focus an element as a result of another user action. (Ex: We focus the first element inside of a popover when the user presents it, but the popover is not always presented as a result of keyboard action.)
|
||||
*/
|
||||
"setFocus": (elements: HTMLElement[]) => Promise<void>;
|
||||
}
|
||||
interface IonAvatar {
|
||||
@@ -636,6 +643,7 @@ export namespace Components {
|
||||
* The name of the control, which is submitted with the form data.
|
||||
*/
|
||||
"name": string;
|
||||
"setFocus": () => Promise<void>;
|
||||
/**
|
||||
* The value of the checkbox does not mean if it's checked or not, use the `checked` property for that. The value of a checkbox is analogous to the value of an `<input type="checkbox">`, it's only used when the checkbox participates in a native `<form>`.
|
||||
*/
|
||||
@@ -1593,7 +1601,7 @@ export namespace Components {
|
||||
/**
|
||||
* Closes the menu. If the menu is already closed or it can't be closed, it returns `false`.
|
||||
*/
|
||||
"close": (animated?: boolean) => Promise<boolean>;
|
||||
"close": (animated?: boolean, role?: string) => Promise<boolean>;
|
||||
/**
|
||||
* The `id` of the main content. When using a router this is typically `ion-router-outlet`. When not using a router, this is typically your main view's `ion-content`. This is not the id of the `ion-content` inside of your `ion-menu`.
|
||||
*/
|
||||
@@ -1625,7 +1633,7 @@ export namespace Components {
|
||||
/**
|
||||
* Opens or closes the button. If the operation can't be completed successfully, it returns `false`.
|
||||
*/
|
||||
"setOpen": (shouldOpen: boolean, animated?: boolean) => Promise<boolean>;
|
||||
"setOpen": (shouldOpen: boolean, animated?: boolean, role?: string) => Promise<boolean>;
|
||||
/**
|
||||
* Which side of the view the menu should be placed.
|
||||
*/
|
||||
@@ -1785,6 +1793,10 @@ export namespace Components {
|
||||
* The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode.
|
||||
*/
|
||||
"presentingElement"?: HTMLElement;
|
||||
/**
|
||||
* Determines whether or not the sheet modal will only scroll when fully expanded. If the value is `true`, the modal will only scroll when fully expanded. If the value is `false`, the modal will scroll at any breakpoint.
|
||||
*/
|
||||
"scrollAtEdge": boolean;
|
||||
/**
|
||||
* Move a sheet style modal to a specific breakpoint. The breakpoint value must be a value defined in your `breakpoints` array.
|
||||
*/
|
||||
@@ -2276,7 +2288,7 @@ export namespace Components {
|
||||
*/
|
||||
"name": string;
|
||||
"setButtonTabindex": (value: number) => Promise<void>;
|
||||
"setFocus": (ev: globalThis.Event) => Promise<void>;
|
||||
"setFocus": (ev?: globalThis.Event) => Promise<void>;
|
||||
/**
|
||||
* the value of the radio.
|
||||
*/
|
||||
@@ -2295,6 +2307,7 @@ export namespace Components {
|
||||
* The name of the control, which is submitted with the form data.
|
||||
*/
|
||||
"name": string;
|
||||
"setFocus": () => Promise<void>;
|
||||
/**
|
||||
* the value of the radio group.
|
||||
*/
|
||||
@@ -2690,6 +2703,10 @@ export namespace Components {
|
||||
"value"?: SegmentValue;
|
||||
}
|
||||
interface IonSegmentButton {
|
||||
/**
|
||||
* The `id` of the segment content.
|
||||
*/
|
||||
"contentId"?: string;
|
||||
/**
|
||||
* If `true`, the user cannot interact with the segment button.
|
||||
*/
|
||||
@@ -2712,6 +2729,19 @@ export namespace Components {
|
||||
*/
|
||||
"value": SegmentValue;
|
||||
}
|
||||
interface IonSegmentContent {
|
||||
}
|
||||
interface IonSegmentView {
|
||||
/**
|
||||
* If `true`, the segment view cannot be interacted with.
|
||||
*/
|
||||
"disabled": boolean;
|
||||
/**
|
||||
* @param id : The id of the segment content to display.
|
||||
* @param smoothScroll : Whether to animate the scroll transition.
|
||||
*/
|
||||
"setContent": (id: string, smoothScroll?: boolean) => Promise<void>;
|
||||
}
|
||||
interface IonSelect {
|
||||
/**
|
||||
* The text to display on the cancel button.
|
||||
@@ -2738,11 +2768,11 @@ export namespace Components {
|
||||
*/
|
||||
"fill"?: 'outline' | 'solid';
|
||||
/**
|
||||
* The interface the select should use: `action-sheet`, `popover` or `alert`.
|
||||
* The interface the select should use: `action-sheet`, `popover`, `alert`, or `modal`.
|
||||
*/
|
||||
"interface": SelectInterface;
|
||||
/**
|
||||
* Any additional options that the `alert`, `action-sheet` or `popover` interface can take. See the [ion-alert docs](./alert), the [ion-action-sheet docs](./action-sheet) and the [ion-popover docs](./popover) for the create options for each interface. Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface.
|
||||
* Any additional options that the `alert`, `action-sheet` or `popover` interface can take. See the [ion-alert docs](./alert), the [ion-action-sheet docs](./action-sheet), the [ion-popover docs](./popover), and the [ion-modal docs](./modal) for the create options for each interface. Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface.
|
||||
*/
|
||||
"interfaceOptions": any;
|
||||
/**
|
||||
@@ -2799,6 +2829,11 @@ export namespace Components {
|
||||
*/
|
||||
"value"?: any | null;
|
||||
}
|
||||
interface IonSelectModal {
|
||||
"header"?: string;
|
||||
"multiple"?: boolean;
|
||||
"options": SelectModalOption[];
|
||||
}
|
||||
interface IonSelectOption {
|
||||
/**
|
||||
* If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons.
|
||||
@@ -3413,6 +3448,10 @@ export interface IonSegmentCustomEvent<T> extends CustomEvent<T> {
|
||||
detail: T;
|
||||
target: HTMLIonSegmentElement;
|
||||
}
|
||||
export interface IonSegmentViewCustomEvent<T> extends CustomEvent<T> {
|
||||
detail: T;
|
||||
target: HTMLIonSegmentViewElement;
|
||||
}
|
||||
export interface IonSelectCustomEvent<T> extends CustomEvent<T> {
|
||||
detail: T;
|
||||
target: HTMLIonSelectElement;
|
||||
@@ -3966,9 +4005,9 @@ declare global {
|
||||
};
|
||||
interface HTMLIonMenuElementEventMap {
|
||||
"ionWillOpen": void;
|
||||
"ionWillClose": void;
|
||||
"ionWillClose": MenuCloseEventDetail;
|
||||
"ionDidOpen": void;
|
||||
"ionDidClose": void;
|
||||
"ionDidClose": MenuCloseEventDetail;
|
||||
"ionMenuChange": MenuChangeEventDetail;
|
||||
}
|
||||
interface HTMLIonMenuElement extends Components.IonMenu, HTMLStencilElement {
|
||||
@@ -4409,6 +4448,29 @@ declare global {
|
||||
prototype: HTMLIonSegmentButtonElement;
|
||||
new (): HTMLIonSegmentButtonElement;
|
||||
};
|
||||
interface HTMLIonSegmentContentElement extends Components.IonSegmentContent, HTMLStencilElement {
|
||||
}
|
||||
var HTMLIonSegmentContentElement: {
|
||||
prototype: HTMLIonSegmentContentElement;
|
||||
new (): HTMLIonSegmentContentElement;
|
||||
};
|
||||
interface HTMLIonSegmentViewElementEventMap {
|
||||
"ionSegmentViewScroll": SegmentViewScrollEvent;
|
||||
}
|
||||
interface HTMLIonSegmentViewElement extends Components.IonSegmentView, HTMLStencilElement {
|
||||
addEventListener<K extends keyof HTMLIonSegmentViewElementEventMap>(type: K, listener: (this: HTMLIonSegmentViewElement, ev: IonSegmentViewCustomEvent<HTMLIonSegmentViewElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
|
||||
removeEventListener<K extends keyof HTMLIonSegmentViewElementEventMap>(type: K, listener: (this: HTMLIonSegmentViewElement, ev: IonSegmentViewCustomEvent<HTMLIonSegmentViewElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
|
||||
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
||||
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
||||
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
|
||||
}
|
||||
var HTMLIonSegmentViewElement: {
|
||||
prototype: HTMLIonSegmentViewElement;
|
||||
new (): HTMLIonSegmentViewElement;
|
||||
};
|
||||
interface HTMLIonSelectElementEventMap {
|
||||
"ionChange": SelectChangeEventDetail;
|
||||
"ionCancel": void;
|
||||
@@ -4431,6 +4493,12 @@ declare global {
|
||||
prototype: HTMLIonSelectElement;
|
||||
new (): HTMLIonSelectElement;
|
||||
};
|
||||
interface HTMLIonSelectModalElement extends Components.IonSelectModal, HTMLStencilElement {
|
||||
}
|
||||
var HTMLIonSelectModalElement: {
|
||||
prototype: HTMLIonSelectModalElement;
|
||||
new (): HTMLIonSelectModalElement;
|
||||
};
|
||||
interface HTMLIonSelectOptionElement extends Components.IonSelectOption, HTMLStencilElement {
|
||||
}
|
||||
var HTMLIonSelectOptionElement: {
|
||||
@@ -4718,7 +4786,10 @@ declare global {
|
||||
"ion-searchbar": HTMLIonSearchbarElement;
|
||||
"ion-segment": HTMLIonSegmentElement;
|
||||
"ion-segment-button": HTMLIonSegmentButtonElement;
|
||||
"ion-segment-content": HTMLIonSegmentContentElement;
|
||||
"ion-segment-view": HTMLIonSegmentViewElement;
|
||||
"ion-select": HTMLIonSelectElement;
|
||||
"ion-select-modal": HTMLIonSelectModalElement;
|
||||
"ion-select-option": HTMLIonSelectOptionElement;
|
||||
"ion-select-popover": HTMLIonSelectPopoverElement;
|
||||
"ion-skeleton-text": HTMLIonSkeletonTextElement;
|
||||
@@ -6361,7 +6432,7 @@ declare namespace LocalJSX {
|
||||
/**
|
||||
* Emitted when the menu is closed.
|
||||
*/
|
||||
"onIonDidClose"?: (event: IonMenuCustomEvent<void>) => void;
|
||||
"onIonDidClose"?: (event: IonMenuCustomEvent<MenuCloseEventDetail>) => void;
|
||||
/**
|
||||
* Emitted when the menu is open.
|
||||
*/
|
||||
@@ -6373,7 +6444,7 @@ declare namespace LocalJSX {
|
||||
/**
|
||||
* Emitted when the menu is about to be closed.
|
||||
*/
|
||||
"onIonWillClose"?: (event: IonMenuCustomEvent<void>) => void;
|
||||
"onIonWillClose"?: (event: IonMenuCustomEvent<MenuCloseEventDetail>) => void;
|
||||
/**
|
||||
* Emitted when the menu is about to be opened.
|
||||
*/
|
||||
@@ -6551,6 +6622,10 @@ declare namespace LocalJSX {
|
||||
* The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode.
|
||||
*/
|
||||
"presentingElement"?: HTMLElement;
|
||||
/**
|
||||
* Determines whether or not the sheet modal will only scroll when fully expanded. If the value is `true`, the modal will only scroll when fully expanded. If the value is `false`, the modal will scroll at any breakpoint.
|
||||
*/
|
||||
"scrollAtEdge"?: boolean;
|
||||
/**
|
||||
* If `true`, a backdrop will be displayed behind the modal. This property controls whether or not the backdrop darkens the screen when the modal is presented. It does not control whether or not the backdrop is active or present in the DOM.
|
||||
*/
|
||||
@@ -7447,6 +7522,10 @@ declare namespace LocalJSX {
|
||||
"value"?: SegmentValue;
|
||||
}
|
||||
interface IonSegmentButton {
|
||||
/**
|
||||
* The `id` of the segment content.
|
||||
*/
|
||||
"contentId"?: string;
|
||||
/**
|
||||
* If `true`, the user cannot interact with the segment button.
|
||||
*/
|
||||
@@ -7468,6 +7547,18 @@ declare namespace LocalJSX {
|
||||
*/
|
||||
"value"?: SegmentValue;
|
||||
}
|
||||
interface IonSegmentContent {
|
||||
}
|
||||
interface IonSegmentView {
|
||||
/**
|
||||
* If `true`, the segment view cannot be interacted with.
|
||||
*/
|
||||
"disabled"?: boolean;
|
||||
/**
|
||||
* Emitted when the segment view is scrolled.
|
||||
*/
|
||||
"onIonSegmentViewScroll"?: (event: IonSegmentViewCustomEvent<SegmentViewScrollEvent>) => void;
|
||||
}
|
||||
interface IonSelect {
|
||||
/**
|
||||
* The text to display on the cancel button.
|
||||
@@ -7494,11 +7585,11 @@ declare namespace LocalJSX {
|
||||
*/
|
||||
"fill"?: 'outline' | 'solid';
|
||||
/**
|
||||
* The interface the select should use: `action-sheet`, `popover` or `alert`.
|
||||
* The interface the select should use: `action-sheet`, `popover`, `alert`, or `modal`.
|
||||
*/
|
||||
"interface"?: SelectInterface;
|
||||
/**
|
||||
* Any additional options that the `alert`, `action-sheet` or `popover` interface can take. See the [ion-alert docs](./alert), the [ion-action-sheet docs](./action-sheet) and the [ion-popover docs](./popover) for the create options for each interface. Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface.
|
||||
* Any additional options that the `alert`, `action-sheet` or `popover` interface can take. See the [ion-alert docs](./alert), the [ion-action-sheet docs](./action-sheet), the [ion-popover docs](./popover), and the [ion-modal docs](./modal) for the create options for each interface. Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface.
|
||||
*/
|
||||
"interfaceOptions"?: any;
|
||||
/**
|
||||
@@ -7574,6 +7665,11 @@ declare namespace LocalJSX {
|
||||
*/
|
||||
"value"?: any | null;
|
||||
}
|
||||
interface IonSelectModal {
|
||||
"header"?: string;
|
||||
"multiple"?: boolean;
|
||||
"options"?: SelectModalOption[];
|
||||
}
|
||||
interface IonSelectOption {
|
||||
/**
|
||||
* If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons.
|
||||
@@ -8159,7 +8255,10 @@ declare namespace LocalJSX {
|
||||
"ion-searchbar": IonSearchbar;
|
||||
"ion-segment": IonSegment;
|
||||
"ion-segment-button": IonSegmentButton;
|
||||
"ion-segment-content": IonSegmentContent;
|
||||
"ion-segment-view": IonSegmentView;
|
||||
"ion-select": IonSelect;
|
||||
"ion-select-modal": IonSelectModal;
|
||||
"ion-select-option": IonSelectOption;
|
||||
"ion-select-popover": IonSelectPopover;
|
||||
"ion-skeleton-text": IonSkeletonText;
|
||||
@@ -8258,7 +8357,10 @@ declare module "@stencil/core" {
|
||||
"ion-searchbar": LocalJSX.IonSearchbar & JSXBase.HTMLAttributes<HTMLIonSearchbarElement>;
|
||||
"ion-segment": LocalJSX.IonSegment & JSXBase.HTMLAttributes<HTMLIonSegmentElement>;
|
||||
"ion-segment-button": LocalJSX.IonSegmentButton & JSXBase.HTMLAttributes<HTMLIonSegmentButtonElement>;
|
||||
"ion-segment-content": LocalJSX.IonSegmentContent & JSXBase.HTMLAttributes<HTMLIonSegmentContentElement>;
|
||||
"ion-segment-view": LocalJSX.IonSegmentView & JSXBase.HTMLAttributes<HTMLIonSegmentViewElement>;
|
||||
"ion-select": LocalJSX.IonSelect & JSXBase.HTMLAttributes<HTMLIonSelectElement>;
|
||||
"ion-select-modal": LocalJSX.IonSelectModal & JSXBase.HTMLAttributes<HTMLIonSelectModalElement>;
|
||||
"ion-select-option": LocalJSX.IonSelectOption & JSXBase.HTMLAttributes<HTMLIonSelectOptionElement>;
|
||||
"ion-select-popover": LocalJSX.IonSelectPopover & JSXBase.HTMLAttributes<HTMLIonSelectPopoverElement>;
|
||||
"ion-skeleton-text": LocalJSX.IonSkeletonText & JSXBase.HTMLAttributes<HTMLIonSkeletonTextElement>;
|
||||
|
||||
@@ -385,7 +385,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
>
|
||||
<ion-backdrop tappable={this.backdropDismiss} />
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
|
||||
<div class="action-sheet-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
|
||||
<div class="action-sheet-container">
|
||||
@@ -446,7 +446,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -730,10 +730,12 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
||||
const role = this.inputs.length > 0 || this.buttons.length > 0 ? 'alertdialog' : 'alert';
|
||||
|
||||
/**
|
||||
* If the header is defined, use that. Otherwise, fall back to the subHeader.
|
||||
* If neither is defined, don't set aria-labelledby.
|
||||
* Use both the header and subHeader ids if they are defined.
|
||||
* If only the header is defined, use the header id.
|
||||
* If only the subHeader is defined, use the subHeader id.
|
||||
* If neither are defined, do not set aria-labelledby.
|
||||
*/
|
||||
const ariaLabelledBy = header ? hdrId : subHeader ? subHdrId : null;
|
||||
const ariaLabelledBy = header && subHeader ? `${hdrId} ${subHdrId}` : header ? hdrId : subHeader ? subHdrId : null;
|
||||
|
||||
return (
|
||||
<Host
|
||||
@@ -757,7 +759,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
||||
>
|
||||
<ion-backdrop tappable={this.backdropDismiss} />
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
|
||||
<div class="alert-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
|
||||
<div class="alert-head">
|
||||
@@ -766,11 +768,18 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
||||
{header}
|
||||
</h2>
|
||||
)}
|
||||
{subHeader && (
|
||||
{/* If no header exists, subHeader should be the highest heading level, h2 */}
|
||||
{subHeader && !header && (
|
||||
<h2 id={subHdrId} class="alert-sub-title">
|
||||
{subHeader}
|
||||
</h2>
|
||||
)}
|
||||
{/* If a header exists, subHeader should be one level below it, h3 */}
|
||||
{subHeader && header && (
|
||||
<h3 id={subHdrId} class="alert-sub-title">
|
||||
{subHeader}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{this.renderAlertMessage(msgId)}
|
||||
@@ -779,7 +788,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
||||
{this.renderAlertButtons()}
|
||||
</div>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,27 @@ const testAria = async (
|
||||
|
||||
const alert = page.locator('ion-alert');
|
||||
|
||||
const header = alert.locator('.alert-title');
|
||||
const subHeader = alert.locator('.alert-sub-title');
|
||||
|
||||
// If a header exists, it should be an h2 element
|
||||
if ((await header.count()) > 0) {
|
||||
const headerTagName = await header.evaluate((el) => el.tagName);
|
||||
expect(headerTagName).toBe('H2');
|
||||
}
|
||||
|
||||
// If a header and subHeader exist, the subHeader should be an h3 element
|
||||
if ((await header.count()) > 0 && (await subHeader.count()) > 0) {
|
||||
const subHeaderTagName = await subHeader.evaluate((el) => el.tagName);
|
||||
expect(subHeaderTagName).toBe('H3');
|
||||
}
|
||||
|
||||
// If a subHeader exists without a header, the subHeader should be an h2 element
|
||||
if ((await header.count()) === 0 && (await subHeader.count()) > 0) {
|
||||
const subHeaderTagName = await subHeader.evaluate((el) => el.tagName);
|
||||
expect(subHeaderTagName).toBe('H2');
|
||||
}
|
||||
|
||||
/**
|
||||
* expect().toHaveAttribute() can't check for a null value, so grab and check
|
||||
* the values manually instead.
|
||||
@@ -124,16 +145,24 @@ configs({ directions: ['ltr'] }).forEach(({ config, title }) => {
|
||||
await page.goto(`/src/components/alert/test/a11y`, config);
|
||||
});
|
||||
|
||||
test('should have aria-labelledby when header is set', async ({ page }) => {
|
||||
await testAria(page, 'noMessage', 'alert-1-hdr', null);
|
||||
test('should have aria-labelledby set to both when header and subHeader are set', async ({ page }) => {
|
||||
await testAria(page, 'bothHeadersOnly', 'alert-1-hdr alert-1-sub-hdr', null);
|
||||
});
|
||||
|
||||
test('should have aria-labelledby set when only header is set', async ({ page }) => {
|
||||
await testAria(page, 'headerOnly', 'alert-1-hdr', null);
|
||||
});
|
||||
|
||||
test('should fall back to subHeader for aria-labelledby if header is not defined', async ({ page }) => {
|
||||
await testAria(page, 'subHeaderOnly', 'alert-1-sub-hdr', null);
|
||||
});
|
||||
|
||||
test('should have aria-describedby when message is set', async ({ page }) => {
|
||||
await testAria(page, 'noHeaders', null, 'alert-1-msg');
|
||||
});
|
||||
|
||||
test('should fall back to subHeader for aria-labelledby if header is not defined', async ({ page }) => {
|
||||
await testAria(page, 'subHeaderOnly', 'alert-1-sub-hdr', 'alert-1-msg');
|
||||
test('should have aria-labelledby and aria-describedby when headers and message are set', async ({ page }) => {
|
||||
await testAria(page, 'headersAndMessage', 'alert-1-hdr alert-1-sub-hdr', 'alert-1-msg');
|
||||
});
|
||||
|
||||
test('should allow for manually specifying aria attributes', async ({ page }) => {
|
||||
@@ -279,7 +308,10 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
|
||||
await expect(page).toHaveScreenshot(screenshot(`alert-radio-scale`));
|
||||
});
|
||||
test('should scale text on larger font sizes with text fields', async ({ page }) => {
|
||||
test('should scale text on larger font sizes with text fields', async ({ page, skip }) => {
|
||||
// TODO(ROU-8158): unskip this test when a solution is found
|
||||
skip.browser('chromium', 'Rendering is flaky in Chrome.');
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
<main class="ion-padding">
|
||||
<h1>Alert - A11y</h1>
|
||||
|
||||
<button class="expand" id="bothHeaders" onclick="presentBothHeaders()">Both Headers</button>
|
||||
<button class="expand" id="bothHeadersOnly" onclick="presentBothHeadersOnly()">Both Headers Only</button>
|
||||
<button class="expand" id="headerOnly" onclick="presentHeaderOnly()">Header Only</button>
|
||||
<button class="expand" id="subHeaderOnly" onclick="presentSubHeaderOnly()">Subheader Only</button>
|
||||
<button class="expand" id="noHeaders" onclick="presentNoHeaders()">No Headers</button>
|
||||
<button class="expand" id="noMessage" onclick="presentNoMessage()">No Message</button>
|
||||
<button class="expand" id="headersAndMessage" onclick="presentHeadersAndMessage()">Headers and Message</button>
|
||||
<button class="expand" id="customAria" onclick="presentCustomAria()">Custom Aria</button>
|
||||
<button class="expand" id="ariaLabelButton" onclick="presentAriaLabelButton()">Aria Label Button</button>
|
||||
<button class="expand" id="checkbox" onclick="presentAlertCheckbox()">Checkbox</button>
|
||||
@@ -34,11 +35,17 @@
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
function presentBothHeaders() {
|
||||
function presentBothHeadersOnly() {
|
||||
openAlert({
|
||||
header: 'Header',
|
||||
subHeader: 'Subtitle',
|
||||
message: 'This is an alert message.',
|
||||
buttons: ['OK'],
|
||||
});
|
||||
}
|
||||
|
||||
function presentHeaderOnly() {
|
||||
openAlert({
|
||||
header: 'Header',
|
||||
buttons: ['OK'],
|
||||
});
|
||||
}
|
||||
@@ -46,7 +53,6 @@
|
||||
function presentSubHeaderOnly() {
|
||||
openAlert({
|
||||
subHeader: 'Subtitle',
|
||||
message: 'This is an alert message.',
|
||||
buttons: ['OK'],
|
||||
});
|
||||
}
|
||||
@@ -58,10 +64,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function presentNoMessage() {
|
||||
function presentHeadersAndMessage() {
|
||||
openAlert({
|
||||
header: 'Header',
|
||||
subHeader: 'Subtitle',
|
||||
message: 'This is an alert message.',
|
||||
buttons: ['OK'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ export class App implements ComponentInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Used to set focus on an element that uses `ion-focusable`.
|
||||
* Do not use this if focusing the element as a result of a keyboard
|
||||
* event as the focus utility should handle this for us. This method
|
||||
|
||||
@@ -51,7 +51,6 @@ export class Backdrop implements ComponentInterface {
|
||||
const mode = getIonMode(this);
|
||||
return (
|
||||
<Host
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
class={{
|
||||
[mode]: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Prop, h } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
|
||||
import { createColorClasses, hostContext } from '@utils/theme';
|
||||
@@ -121,7 +121,9 @@ export class Checkbox implements ComponentInterface {
|
||||
};
|
||||
}
|
||||
|
||||
private setFocus() {
|
||||
/** @internal */
|
||||
@Method()
|
||||
async setFocus() {
|
||||
if (this.focusEl) {
|
||||
this.focusEl.focus();
|
||||
}
|
||||
|
||||
@@ -167,13 +167,34 @@ export const handleToolbarIntersection = (
|
||||
|
||||
export const setHeaderActive = (headerIndex: HeaderIndex, active = true) => {
|
||||
const headerEl = headerIndex.el;
|
||||
const toolbars = headerIndex.toolbars;
|
||||
const ionTitles = toolbars.map((toolbar) => toolbar.ionTitleEl);
|
||||
|
||||
if (active) {
|
||||
headerEl.classList.remove('header-collapse-condense-inactive');
|
||||
headerEl.removeAttribute('aria-hidden');
|
||||
|
||||
ionTitles.forEach((ionTitle) => {
|
||||
if (ionTitle) {
|
||||
ionTitle.removeAttribute('aria-hidden');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
headerEl.classList.add('header-collapse-condense-inactive');
|
||||
headerEl.setAttribute('aria-hidden', 'true');
|
||||
|
||||
/**
|
||||
* The small title should only be accessed by screen readers
|
||||
* when the large title collapses into the small title due
|
||||
* to scrolling.
|
||||
*
|
||||
* Originally, the header was given `aria-hidden="true"`
|
||||
* but this caused issues with screen readers not being
|
||||
* able to access any focusable elements within the header.
|
||||
*/
|
||||
ionTitles.forEach((ionTitle) => {
|
||||
if (ionTitle) {
|
||||
ionTitle.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,19 @@ import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('header: condense'), () => {
|
||||
test('should be hidden from screen readers when collapsed', async ({ page }) => {
|
||||
test('should hide small title from screen readers when collapsed', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/29347',
|
||||
});
|
||||
|
||||
await page.goto('/src/components/header/test/condense', config);
|
||||
const largeTitleHeader = page.locator('#largeTitleHeader');
|
||||
const smallTitleHeader = page.locator('#smallTitleHeader');
|
||||
const smallTitle = smallTitleHeader.locator('ion-title');
|
||||
const content = page.locator('ion-content');
|
||||
|
||||
await expect(smallTitleHeader).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(smallTitle).toHaveAttribute('aria-hidden', 'true');
|
||||
|
||||
await expect(largeTitleHeader).toHaveScreenshot(screenshot(`header-condense-large-title-initial-diff`));
|
||||
|
||||
@@ -24,7 +30,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
|
||||
* Playwright can't do .not.toHaveAttribute() because a value is expected,
|
||||
* and toHaveAttribute can't accept a value of type null.
|
||||
*/
|
||||
const ariaHidden = await smallTitleHeader.getAttribute('aria-hidden');
|
||||
const ariaHidden = await smallTitle.getAttribute('aria-hidden');
|
||||
expect(ariaHidden).toBeNull();
|
||||
|
||||
await content.evaluate(async (el: HTMLIonContentElement) => {
|
||||
@@ -32,7 +38,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
|
||||
});
|
||||
await page.locator('#smallTitleHeader.header-collapse-condense-inactive').waitFor();
|
||||
|
||||
await expect(smallTitleHeader).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(smallTitle).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ import { getCounterText } from './input.utils';
|
||||
export class Input implements ComponentInterface {
|
||||
private nativeInput?: HTMLInputElement;
|
||||
private inputId = `ion-input-${inputIds++}`;
|
||||
private helperTextId = `${this.inputId}-helper-text`;
|
||||
private errorTextId = `${this.inputId}-error-text`;
|
||||
private inheritedAttributes: Attributes = {};
|
||||
private isComposing = false;
|
||||
private slotMutationController?: SlotMutationController;
|
||||
@@ -573,9 +575,30 @@ export class Input implements ComponentInterface {
|
||||
* Renders the helper text or error text values
|
||||
*/
|
||||
private renderHintText() {
|
||||
const { helperText, errorText } = this;
|
||||
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
return [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
|
||||
return [
|
||||
<div id={helperTextId} class="helper-text">
|
||||
{helperText}
|
||||
</div>,
|
||||
<div id={errorTextId} class="error-text">
|
||||
{errorText}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
const { el, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
|
||||
return errorTextId;
|
||||
}
|
||||
|
||||
if (helperText) {
|
||||
return helperTextId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private renderCounter() {
|
||||
@@ -777,6 +800,8 @@ export class Input implements ComponentInterface {
|
||||
onKeyDown={this.onKeydown}
|
||||
onCompositionstart={this.onCompositionStart}
|
||||
onCompositionend={this.onCompositionEnd}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||
{...this.inheritedAttributes}
|
||||
/>
|
||||
{this.clearInput && !readonly && !disabled && (
|
||||
|
||||
@@ -68,6 +68,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
||||
await expect(helperText).toHaveText('my helper');
|
||||
await expect(errorText).toBeHidden();
|
||||
});
|
||||
test('input should have an aria-describedby attribute when helper text is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
const helperText = page.locator('ion-input .helper-text');
|
||||
const helperTextId = await helperText.getAttribute('id');
|
||||
const ariaDescribedBy = await input.getAttribute('aria-describedby');
|
||||
|
||||
expect(ariaDescribedBy).toBe(helperTextId);
|
||||
});
|
||||
test('error text should be visible when input is invalid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
@@ -96,6 +109,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
||||
const errorText = page.locator('ion-input .error-text');
|
||||
await expect(errorText).toHaveScreenshot(screenshot(`input-error-custom-color`));
|
||||
});
|
||||
test('input should have an aria-describedby attribute when error text is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
const errorText = page.locator('ion-input .error-text');
|
||||
const errorTextId = await errorText.getAttribute('id');
|
||||
const ariaDescribedBy = await input.getAttribute('aria-describedby');
|
||||
|
||||
expect(ariaDescribedBy).toBe(errorTextId);
|
||||
});
|
||||
test('input should have aria-invalid attribute when input is invalid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
|
||||
await expect(input).toHaveAttribute('aria-invalid');
|
||||
});
|
||||
test('input should not have aria-invalid attribute when input is valid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-input helper-text="my helper" error-text="my error" label="my input"></ion-input>`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
|
||||
await expect(input).not.toHaveAttribute('aria-invalid');
|
||||
});
|
||||
test('input should not have aria-describedby attribute when no hint or error text is present', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(`<ion-input label="my input"></ion-input>`, config);
|
||||
|
||||
const input = page.locator('ion-input input');
|
||||
|
||||
await expect(input).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
});
|
||||
test.describe('input: hint text rendering', () => {
|
||||
test.describe('regular inputs', () => {
|
||||
|
||||
@@ -356,7 +356,7 @@ export class Loading implements ComponentInterface, OverlayInterface {
|
||||
>
|
||||
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss} />
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
|
||||
<div class="loading-wrapper ion-overlay-wrapper">
|
||||
{spinner && (
|
||||
@@ -368,7 +368,7 @@ export class Loading implements ComponentInterface, OverlayInterface {
|
||||
{message !== undefined && this.renderLoadingMessage(msgId)}
|
||||
</div>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface MenuI {
|
||||
close(animated?: boolean): Promise<boolean>;
|
||||
toggle(animated?: boolean): Promise<boolean>;
|
||||
setOpen(shouldOpen: boolean, animated?: boolean): Promise<boolean>;
|
||||
_setOpen(shouldOpen: boolean, animated?: boolean): Promise<boolean>;
|
||||
_setOpen(shouldOpen: boolean, animated?: boolean, role?: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface MenuControllerI {
|
||||
@@ -42,7 +42,7 @@ export interface MenuControllerI {
|
||||
_createAnimation(type: string, menuCmp: MenuI): Promise<Animation>;
|
||||
_register(menu: MenuI): void;
|
||||
_unregister(menu: MenuI): void;
|
||||
_setOpen(menu: MenuI, shouldOpen: boolean, animated: boolean): Promise<boolean>;
|
||||
_setOpen(menu: MenuI, shouldOpen: boolean, animated: boolean, role?: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface MenuChangeEventDetail {
|
||||
@@ -50,6 +50,10 @@ export interface MenuChangeEventDetail {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface MenuCloseEventDetail {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface MenuCustomEvent<T = any> extends CustomEvent {
|
||||
detail: T;
|
||||
target: HTMLIonMenuElement;
|
||||
|
||||
@@ -7,14 +7,15 @@ import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
|
||||
import { menuController } from '@utils/menu-controller';
|
||||
import { getPresentedOverlay } from '@utils/overlays';
|
||||
import { BACKDROP, GESTURE, getPresentedOverlay } from '@utils/overlays';
|
||||
import { isPlatform } from '@utils/platform';
|
||||
import { hostContext } from '@utils/theme';
|
||||
|
||||
import { config } from '../../global/config';
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import type { Animation, Gesture, GestureDetail } from '../../interface';
|
||||
|
||||
import type { MenuChangeEventDetail, MenuI, MenuType, Side } from './menu-interface';
|
||||
import type { MenuChangeEventDetail, MenuCloseEventDetail, MenuI, MenuType, Side } from './menu-interface';
|
||||
|
||||
const iosEasing = 'cubic-bezier(0.32,0.72,0,1)';
|
||||
const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)';
|
||||
@@ -179,7 +180,7 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
/**
|
||||
* Emitted when the menu is about to be closed.
|
||||
*/
|
||||
@Event() ionWillClose!: EventEmitter<void>;
|
||||
@Event() ionWillClose!: EventEmitter<MenuCloseEventDetail>;
|
||||
/**
|
||||
* Emitted when the menu is open.
|
||||
*/
|
||||
@@ -188,7 +189,7 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
/**
|
||||
* Emitted when the menu is closed.
|
||||
*/
|
||||
@Event() ionDidClose!: EventEmitter<void>;
|
||||
@Event() ionDidClose!: EventEmitter<MenuCloseEventDetail>;
|
||||
|
||||
/**
|
||||
* Emitted when the menu state is changed.
|
||||
@@ -331,14 +332,14 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
if (shouldClose) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.close();
|
||||
this.close(undefined, BACKDROP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') {
|
||||
this.close();
|
||||
this.close(undefined, BACKDROP);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,8 +376,8 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
* it returns `false`.
|
||||
*/
|
||||
@Method()
|
||||
close(animated = true): Promise<boolean> {
|
||||
return this.setOpen(false, animated);
|
||||
close(animated = true, role?: string): Promise<boolean> {
|
||||
return this.setOpen(false, animated, role);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,8 +394,8 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
* If the operation can't be completed successfully, it returns `false`.
|
||||
*/
|
||||
@Method()
|
||||
setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
|
||||
return menuController._setOpen(this, shouldOpen, animated);
|
||||
setOpen(shouldOpen: boolean, animated = true, role?: string): Promise<boolean> {
|
||||
return menuController._setOpen(this, shouldOpen, animated, role);
|
||||
}
|
||||
|
||||
private trapKeyboardFocus(ev: Event, doc: Document) {
|
||||
@@ -438,13 +439,13 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
}
|
||||
}
|
||||
|
||||
async _setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
|
||||
async _setOpen(shouldOpen: boolean, animated = true, role?: string): Promise<boolean> {
|
||||
// If the menu is disabled or it is currently being animated, let's do nothing
|
||||
if (!this._isActive() || this.isAnimating || shouldOpen === this._isOpen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.beforeAnimation(shouldOpen);
|
||||
this.beforeAnimation(shouldOpen, role);
|
||||
|
||||
await this.loadAnimation();
|
||||
await this.startAnimation(shouldOpen, animated);
|
||||
@@ -459,7 +460,7 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.afterAnimation(shouldOpen);
|
||||
this.afterAnimation(shouldOpen, role);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -542,7 +543,7 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
}
|
||||
|
||||
private onWillStart(): Promise<void> {
|
||||
this.beforeAnimation(!this._isOpen);
|
||||
this.beforeAnimation(!this._isOpen, GESTURE);
|
||||
return this.loadAnimation();
|
||||
}
|
||||
|
||||
@@ -624,13 +625,30 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
|
||||
this.animation
|
||||
.easing('cubic-bezier(0.4, 0.0, 0.6, 1)')
|
||||
.onFinish(() => this.afterAnimation(shouldOpen), { oneTimeCallback: true })
|
||||
.onFinish(() => this.afterAnimation(shouldOpen, GESTURE), { oneTimeCallback: true })
|
||||
.progressEnd(playTo ? 1 : 0, this._isOpen ? 1 - newStepValue : newStepValue, 300);
|
||||
}
|
||||
|
||||
private beforeAnimation(shouldOpen: boolean) {
|
||||
private beforeAnimation(shouldOpen: boolean, role?: string) {
|
||||
assert(!this.isAnimating, '_before() should not be called while animating');
|
||||
|
||||
/**
|
||||
* When the menu 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.
|
||||
*/
|
||||
if (isPlatform('android')) {
|
||||
this.el.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
// this places the menu into the correct location before it animates in
|
||||
// this css class doesn't actually kick off any animations
|
||||
this.el.classList.add(SHOW_MENU);
|
||||
@@ -671,11 +689,11 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
if (shouldOpen) {
|
||||
this.ionWillOpen.emit();
|
||||
} else {
|
||||
this.ionWillClose.emit();
|
||||
this.ionWillClose.emit({ role });
|
||||
}
|
||||
}
|
||||
|
||||
private afterAnimation(isOpen: boolean) {
|
||||
private afterAnimation(isOpen: boolean, role?: string) {
|
||||
// keep opening/closing the menu disabled for a touch more yet
|
||||
// only add listeners/css if it's enabled and isOpen
|
||||
// and only remove listeners/css if it's not open
|
||||
@@ -687,6 +705,17 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
/**
|
||||
* When the menu is presented on an Android device, TalkBack's focus rings
|
||||
* may appear in the wrong position due to the transition (specifically
|
||||
* `transform` styles). The menu is hidden from screen readers during the
|
||||
* transition to prevent this. Once the transition is complete, the menu
|
||||
* is shown again.
|
||||
*/
|
||||
if (isPlatform('android')) {
|
||||
this.el.removeAttribute('aria-hidden');
|
||||
}
|
||||
|
||||
// emit open event
|
||||
this.ionDidOpen.emit();
|
||||
|
||||
@@ -703,6 +732,8 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
// start focus trapping
|
||||
document.addEventListener('focus', this.handleFocus, true);
|
||||
} else {
|
||||
this.el.removeAttribute('aria-hidden');
|
||||
|
||||
// remove css classes and unhide content from screen readers
|
||||
this.el.classList.remove(SHOW_MENU);
|
||||
|
||||
@@ -731,7 +762,7 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
}
|
||||
|
||||
// emit close event
|
||||
this.ionDidClose.emit();
|
||||
this.ionDidClose.emit({ role });
|
||||
|
||||
// undo focus trapping so multiple menus don't collide
|
||||
document.removeEventListener('focus', this.handleFocus, true);
|
||||
@@ -767,7 +798,7 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
* If the menu is disabled then we should
|
||||
* forcibly close the menu even if it is open.
|
||||
*/
|
||||
this.afterAnimation(false);
|
||||
this.afterAnimation(false, GESTURE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-list>
|
||||
<ion-button id="start-menu-button">Button</ion-button>
|
||||
<ion-menu-toggle>
|
||||
<ion-button id="start-menu-button">Button</ion-button>
|
||||
</ion-menu-toggle>
|
||||
<ion-item>Menu Item</ion-item>
|
||||
<ion-item>Menu Item</ion-item>
|
||||
<ion-item>Menu Item</ion-item>
|
||||
@@ -125,6 +127,19 @@
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
window.addEventListener('ionWillOpen', function (e) {
|
||||
console.log('ionWillOpen', e);
|
||||
});
|
||||
window.addEventListener('ionDidOpen', function (e) {
|
||||
console.log('ionDidOpen', e);
|
||||
});
|
||||
window.addEventListener('ionWillClose', function (e) {
|
||||
console.log('ionWillClose', e);
|
||||
});
|
||||
window.addEventListener('ionDidClose', function (e) {
|
||||
console.log('ionDidClose', e);
|
||||
});
|
||||
|
||||
async function openStart() {
|
||||
// Open the menu by menu-id
|
||||
await menuController.enable(true, 'start-menu');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import type { E2EPage, ScreenshotFn } from '@utils/test/playwright';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
import { configs, dragElementBy, test } from '@utils/test/playwright';
|
||||
|
||||
configs().forEach(({ title, config, screenshot }) => {
|
||||
test.describe(title('menu: rendering'), () => {
|
||||
@@ -140,6 +140,97 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('menu: events'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/src/components/menu/test/basic`, config);
|
||||
});
|
||||
|
||||
test('should pass role when swiping to close', async ({ page }) => {
|
||||
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
|
||||
const ionWillClose = await page.spyOnEvent('ionWillClose');
|
||||
const ionDidClose = await page.spyOnEvent('ionDidClose');
|
||||
|
||||
await page.click('#open-start');
|
||||
await ionDidOpen.next();
|
||||
|
||||
const menu = page.locator('#start-menu');
|
||||
await dragElementBy(menu, page, -150, 0);
|
||||
|
||||
await ionWillClose.next();
|
||||
await ionDidClose.next();
|
||||
await expect(ionWillClose).toHaveReceivedEventDetail({ role: 'gesture' });
|
||||
await expect(ionDidClose).toHaveReceivedEventDetail({ role: 'gesture' });
|
||||
});
|
||||
|
||||
test('should pass role when clicking backdrop to close', async ({ page }) => {
|
||||
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
|
||||
const ionWillClose = await page.spyOnEvent('ionWillClose');
|
||||
const ionDidClose = await page.spyOnEvent('ionDidClose');
|
||||
|
||||
await page.click('#open-start');
|
||||
await ionDidOpen.next();
|
||||
|
||||
const menu = page.locator('#start-menu');
|
||||
const backdrop = menu.locator('ion-backdrop');
|
||||
|
||||
/**
|
||||
* Coordinates for the click event.
|
||||
* These need to be near the right edge of the backdrop
|
||||
* in order to avoid clicking on the menu.
|
||||
*/
|
||||
const backdropBoundingBox = await backdrop.boundingBox();
|
||||
const x = backdropBoundingBox!.width - 50;
|
||||
const y = backdropBoundingBox!.height - 50;
|
||||
|
||||
// Click near the right side of the backdrop.
|
||||
await backdrop.click({
|
||||
position: { x, y },
|
||||
});
|
||||
|
||||
await ionWillClose.next();
|
||||
await ionDidClose.next();
|
||||
await expect(ionWillClose).toHaveReceivedEventDetail({ role: 'backdrop' });
|
||||
await expect(ionDidClose).toHaveReceivedEventDetail({ role: 'backdrop' });
|
||||
});
|
||||
|
||||
test('should pass role when pressing escape key to close', async ({ page }) => {
|
||||
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
|
||||
const ionWillClose = await page.spyOnEvent('ionWillClose');
|
||||
const ionDidClose = await page.spyOnEvent('ionDidClose');
|
||||
|
||||
await page.click('#open-start');
|
||||
await ionDidOpen.next();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await ionWillClose.next();
|
||||
await ionDidClose.next();
|
||||
await expect(ionWillClose).toHaveReceivedEventDetail({ role: 'backdrop' });
|
||||
await expect(ionDidClose).toHaveReceivedEventDetail({ role: 'backdrop' });
|
||||
});
|
||||
|
||||
test('should not pass role when clicking a menu toggle button to close', async ({ page }) => {
|
||||
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
|
||||
const ionWillClose = await page.spyOnEvent('ionWillClose');
|
||||
const ionDidClose = await page.spyOnEvent('ionDidClose');
|
||||
|
||||
await page.click('#open-start');
|
||||
await ionDidOpen.next();
|
||||
|
||||
await page.click('#start-menu-button');
|
||||
|
||||
await ionWillClose.next();
|
||||
await ionDidClose.next();
|
||||
await expect(ionWillClose).toHaveReceivedEventDetail({ role: undefined });
|
||||
await expect(ionDidClose).toHaveReceivedEventDetail({ role: undefined });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function testMenu(page: E2EPage, menu: Locator, menuId: string, screenshot: ScreenshotFn) {
|
||||
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
|
||||
const ionDidClose = await page.spyOnEvent('ionDidClose');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAnimation } from '@utils/animation/animation';
|
||||
import { isIonContent, findClosestIonContent } from '@utils/content';
|
||||
import { createGesture } from '@utils/gesture';
|
||||
import { clamp, raf, getElementRoot } from '@utils/helpers';
|
||||
@@ -51,7 +52,8 @@ export const createSheetGesture = (
|
||||
breakpoints: number[] = [],
|
||||
getCurrentBreakpoint: () => number,
|
||||
onDismiss: () => void,
|
||||
onBreakpointChange: (breakpoint: number) => void
|
||||
onBreakpointChange: (breakpoint: number) => void,
|
||||
scrollAtEdge: boolean
|
||||
) => {
|
||||
// Defaults for the sheet swipe animation
|
||||
const defaultBackdrop = [
|
||||
@@ -71,6 +73,10 @@ export const createSheetGesture = (
|
||||
{ offset: 1, transform: 'translateY(100%)' },
|
||||
],
|
||||
BACKDROP_KEYFRAMES: backdropBreakpoint !== 0 ? customBackdrop : defaultBackdrop,
|
||||
CONTENT_KEYFRAMES: [
|
||||
{ offset: 0, maxHeight: '100%' },
|
||||
{ offset: 1, maxHeight: '0%' },
|
||||
],
|
||||
};
|
||||
|
||||
const contentEl = baseEl.querySelector('ion-content');
|
||||
@@ -81,9 +87,20 @@ export const createSheetGesture = (
|
||||
const canDismissMaxStep = 0.95;
|
||||
const wrapperAnimation = animation.childAnimations.find((ani) => ani.id === 'wrapperAnimation');
|
||||
const backdropAnimation = animation.childAnimations.find((ani) => ani.id === 'backdropAnimation');
|
||||
let contentAnimation: Animation | undefined;
|
||||
const maxBreakpoint = breakpoints[breakpoints.length - 1];
|
||||
const minBreakpoint = breakpoints[0];
|
||||
|
||||
if (!scrollAtEdge) {
|
||||
contentAnimation = animation
|
||||
.addAnimation(
|
||||
createAnimation('contentAnimation')
|
||||
.addElement(contentEl!.parentElement!)
|
||||
.keyframes(SheetDefaults.CONTENT_KEYFRAMES)
|
||||
)
|
||||
.childAnimations.find((ani) => ani.id === 'contentAnimation');
|
||||
}
|
||||
|
||||
const enableBackdrop = () => {
|
||||
baseEl.style.setProperty('pointer-events', 'auto');
|
||||
backdropEl.style.setProperty('pointer-events', 'auto');
|
||||
@@ -138,7 +155,7 @@ export const createSheetGesture = (
|
||||
}
|
||||
}
|
||||
|
||||
if (contentEl && currentBreakpoint !== maxBreakpoint) {
|
||||
if (contentEl && currentBreakpoint !== maxBreakpoint && scrollAtEdge) {
|
||||
contentEl.scrollY = false;
|
||||
}
|
||||
|
||||
@@ -323,6 +340,20 @@ export const createSheetGesture = (
|
||||
},
|
||||
]);
|
||||
|
||||
if (contentAnimation) {
|
||||
/**
|
||||
* The modal content should scroll at any breakpoint.
|
||||
* In order to do this, the content needs to be completely
|
||||
* viewable so scrolling can access everything. Othewise,
|
||||
* the default behavior would show the content off the screen
|
||||
* and only allow scrolling when the sheet is fully expanded.
|
||||
*/
|
||||
contentAnimation.keyframes([
|
||||
{ offset: 0, maxHeight: `${(1 - breakpointOffset) * 100}%` },
|
||||
{ offset: 1, maxHeight: `${snapToBreakpoint * 100}%` },
|
||||
]);
|
||||
}
|
||||
|
||||
animation.progressStep(0);
|
||||
}
|
||||
|
||||
@@ -339,13 +370,14 @@ export const createSheetGesture = (
|
||||
}
|
||||
|
||||
/**
|
||||
* If the sheet is going to be fully expanded then we should enable
|
||||
* scrolling immediately. The sheet modal animation takes ~500ms to finish
|
||||
* If the sheet is going to be fully expanded or if the sheet has toggled
|
||||
* to scroll at any breakpoint then we should enable scrolling immediately.
|
||||
* The sheet modal animation takes ~500ms to finish
|
||||
* so if we wait until then there is a visible delay for when scrolling is
|
||||
* re-enabled. Native iOS allows for scrolling on the sheet modal as soon
|
||||
* as the gesture is released, so we align with that.
|
||||
*/
|
||||
if (contentEl && snapToBreakpoint === breakpoints[breakpoints.length - 1]) {
|
||||
if (contentEl && (snapToBreakpoint === breakpoints[breakpoints.length - 1] || !scrollAtEdge)) {
|
||||
contentEl.scrollY = true;
|
||||
}
|
||||
|
||||
@@ -365,6 +397,7 @@ export const createSheetGesture = (
|
||||
raf(() => {
|
||||
wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]);
|
||||
backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]);
|
||||
contentAnimation?.keyframes([...SheetDefaults.CONTENT_KEYFRAMES]);
|
||||
animation.progressStart(true, 1 - snapToBreakpoint);
|
||||
currentBreakpoint = snapToBreakpoint;
|
||||
onBreakpointChange(currentBreakpoint);
|
||||
|
||||
@@ -289,6 +289,17 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
*/
|
||||
@Prop() canDismiss: boolean | ((data?: any, role?: string) => Promise<boolean>) = true;
|
||||
|
||||
/**
|
||||
* Determines whether or not the sheet modal will only
|
||||
* scroll when fully expanded.
|
||||
*
|
||||
* If the value is `true`, the modal will only scroll
|
||||
* when fully expanded.
|
||||
* If the value is `false`, the modal will scroll at
|
||||
* any breakpoint.
|
||||
*/
|
||||
@Prop() scrollAtEdge = true;
|
||||
|
||||
/**
|
||||
* Emitted after the modal has presented.
|
||||
*/
|
||||
@@ -687,7 +698,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.currentBreakpoint = breakpoint;
|
||||
this.ionBreakpointDidChange.emit({ breakpoint });
|
||||
}
|
||||
}
|
||||
},
|
||||
this.scrollAtEdge
|
||||
);
|
||||
|
||||
this.gesture = gesture;
|
||||
|
||||
@@ -154,6 +154,13 @@
|
||||
</div>
|
||||
</ion-app>
|
||||
<script>
|
||||
window.addEventListener('ionModalDidDismiss', function (e) {
|
||||
console.log('DidDismiss', e);
|
||||
});
|
||||
window.addEventListener('ionModalWillDismiss', function (e) {
|
||||
console.log('WillDismiss', e);
|
||||
});
|
||||
|
||||
function createModal(options) {
|
||||
let items = '';
|
||||
|
||||
@@ -177,11 +184,17 @@
|
||||
${items}
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-title>Footer</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
`;
|
||||
|
||||
let extraOptions = {
|
||||
initialBreakpoint: 0.25,
|
||||
breakpoints: [0, 0.25, 0.5, 0.75, 1],
|
||||
scrollAtEdge: false,
|
||||
};
|
||||
|
||||
if (options) {
|
||||
|
||||
@@ -375,7 +375,7 @@ export class Picker implements ComponentInterface, OverlayInterface {
|
||||
>
|
||||
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss}></ion-backdrop>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
|
||||
<div class="picker-wrapper ion-overlay-wrapper" role="dialog">
|
||||
<div class="picker-toolbar">
|
||||
@@ -395,7 +395,7 @@ export class Picker implements ComponentInterface, OverlayInterface {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div tabindex="0"></div>
|
||||
<div tabindex="0" aria-hidden="true"></div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
ion-app > ion-content {
|
||||
--background: #dddddd;
|
||||
}
|
||||
ion-content button {
|
||||
ion-content button.trigger {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.grid {
|
||||
@@ -57,26 +57,30 @@
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>Cover</h2>
|
||||
<button id="cover-trigger">Trigger</button>
|
||||
<button id="cover-trigger" class="trigger">Trigger</button>
|
||||
<ion-popover show-backdrop="false" class="cover-popover" trigger="cover-trigger" size="cover">
|
||||
<ion-content class="ion-padding"> My really really really really long content </ion-content>
|
||||
</ion-popover>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<h2>With Event</h2>
|
||||
<button id="event-trigger" onclick="openPopover('event-popover', event, 'false')">Trigger</button>
|
||||
<button id="event-trigger" class="trigger" onclick="openPopover('event-popover', event, 'false')">
|
||||
Trigger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Auto</h2>
|
||||
<button id="auto-trigger">Trigger</button>
|
||||
<button id="auto-trigger" class="trigger">Trigger</button>
|
||||
<ion-popover show-backdrop="false" class="auto-popover" trigger="auto-trigger">
|
||||
<ion-content class="ion-padding"> My really really really really long content </ion-content>
|
||||
</ion-popover>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<h2>No Event</h2>
|
||||
<button id="no-event-trigger" onclick="openPopover('no-event-popover', null, 'true')">Trigger</button>
|
||||
<button id="no-event-trigger" class="trigger" onclick="openPopover('no-event-popover', null, 'true')">
|
||||
Trigger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Listen, Prop, Watch, h } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Listen, Method, Prop, Watch, h } from '@stencil/core';
|
||||
import { renderHiddenInput } from '@utils/helpers';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
@@ -155,7 +155,9 @@ export class RadioGroup implements ComponentInterface {
|
||||
|
||||
@Listen('keydown', { target: 'document' })
|
||||
onKeydown(ev: KeyboardEvent) {
|
||||
const inSelectPopover = !!this.el.closest('ion-select-popover');
|
||||
// We don't want the value to automatically change/emit when the radio group is part of a select interface
|
||||
// as this will cause the interface to close when navigating through the radio group options
|
||||
const inSelectInterface = !!this.el.closest('ion-select-popover') || !!this.el.closest('ion-select-modal');
|
||||
|
||||
if (ev.target && !this.el.contains(ev.target as HTMLElement)) {
|
||||
return;
|
||||
@@ -187,7 +189,7 @@ export class RadioGroup implements ComponentInterface {
|
||||
if (next && radios.includes(next)) {
|
||||
next.setFocus(ev);
|
||||
|
||||
if (!inSelectPopover) {
|
||||
if (!inSelectInterface) {
|
||||
this.value = next.value;
|
||||
this.emitValueChange(ev);
|
||||
}
|
||||
@@ -215,6 +217,13 @@ export class RadioGroup implements ComponentInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@Method()
|
||||
async setFocus() {
|
||||
const radioToFocus = this.getRadios().find((r) => r.tabIndex !== -1);
|
||||
radioToFocus?.setFocus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { label, labelId, el, name, value } = this;
|
||||
const mode = getIonMode(this);
|
||||
|
||||
@@ -126,9 +126,11 @@ export class Radio implements ComponentInterface {
|
||||
|
||||
/** @internal */
|
||||
@Method()
|
||||
async setFocus(ev: globalThis.Event) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
async setFocus(ev?: globalThis.Event) {
|
||||
if (ev !== undefined) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
this.el.focus();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
|
||||
|
||||
@State() checked = false;
|
||||
|
||||
/**
|
||||
* The `id` of the segment content.
|
||||
*/
|
||||
@Prop({ reflect: true }) contentId?: string;
|
||||
|
||||
/**
|
||||
* If `true`, the user cannot interact with the segment button.
|
||||
*/
|
||||
@@ -67,6 +72,30 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
|
||||
addEventListener(segmentEl, 'ionSelect', this.updateState);
|
||||
addEventListener(segmentEl, 'ionStyle', this.updateStyle);
|
||||
}
|
||||
|
||||
// Return if there is no contentId defined
|
||||
if (!this.contentId) return;
|
||||
|
||||
// Attempt to find the Segment Content by its contentId
|
||||
const segmentContent = document.getElementById(this.contentId) as HTMLIonSegmentContentElement | null;
|
||||
|
||||
// If no associated Segment Content exists, log an error and return
|
||||
if (!segmentContent) {
|
||||
console.error(`Segment Button: Unable to find Segment Content with id="${this.contentId}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the found element is a valid ION-SEGMENT-CONTENT
|
||||
if (segmentContent.tagName !== 'ION-SEGMENT-CONTENT') {
|
||||
console.error(`Segment Button: Element with id="${this.contentId}" is not an <ion-segment-content> element.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent buttons from being disabled when associated with segment content
|
||||
if (this.disabled) {
|
||||
console.warn(`Segment Button: Segment buttons cannot be disabled when associated with an <ion-segment-content>.`);
|
||||
this.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -161,13 +190,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
|
||||
</span>
|
||||
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
|
||||
</button>
|
||||
<div
|
||||
part="indicator"
|
||||
class={{
|
||||
'segment-button-indicator': true,
|
||||
'segment-button-indicator-animated': true,
|
||||
}}
|
||||
>
|
||||
<div part="indicator" class="segment-button-indicator segment-button-indicator-animated">
|
||||
<div part="indicator-background" class="segment-button-indicator-background"></div>
|
||||
</div>
|
||||
</Host>
|
||||
|
||||
11
core/src/components/segment-content/segment-content.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
// Segment Content
|
||||
// --------------------------------------------------
|
||||
|
||||
:host {
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
17
core/src/components/segment-content/segment-content.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ComponentInterface } from '@stencil/core';
|
||||
import { Component, Host, h } from '@stencil/core';
|
||||
|
||||
@Component({
|
||||
tag: 'ion-segment-content',
|
||||
styleUrl: 'segment-content.scss',
|
||||
shadow: true,
|
||||
})
|
||||
export class SegmentContent implements ComponentInterface {
|
||||
render() {
|
||||
return (
|
||||
<Host>
|
||||
<slot></slot>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface SegmentViewScrollEvent {
|
||||
scrollRatio: number;
|
||||
isManualScroll: boolean;
|
||||
}
|
||||
9
core/src/components/segment-view/segment-view.ios.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@import "./segment-view";
|
||||
@import "../segment-button/segment-button.ios.vars";
|
||||
|
||||
// iOS Segment View
|
||||
// --------------------------------------------------
|
||||
|
||||
:host(.segment-view-disabled) {
|
||||
opacity: $segment-button-ios-opacity-disabled;
|
||||
}
|
||||
9
core/src/components/segment-view/segment-view.md.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@import "./segment-view";
|
||||
@import "../segment-button/segment-button.md.vars";
|
||||
|
||||
// Material Design Segment View
|
||||
// --------------------------------------------------
|
||||
|
||||
:host(.segment-view-disabled) {
|
||||
opacity: $segment-button-md-opacity-disabled;
|
||||
}
|
||||
31
core/src/components/segment-view/segment-view.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
// Segment View
|
||||
// --------------------------------------------------
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
|
||||
height: 100%;
|
||||
|
||||
overflow-x: scroll;
|
||||
scroll-snap-type: x mandatory;
|
||||
|
||||
/* Hide scrollbar in Firefox */
|
||||
scrollbar-width: none;
|
||||
|
||||
/* Hide scrollbar in IE and Edge */
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar in webkit */
|
||||
:host::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host(.segment-view-disabled) {
|
||||
touch-action: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:host(.segment-view-scroll-disabled) {
|
||||
pointer-events: none;
|
||||
}
|
||||
153
core/src/components/segment-view/segment-view.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Listen, Method, Prop, State, h } from '@stencil/core';
|
||||
|
||||
import type { SegmentViewScrollEvent } from './segment-view-interface';
|
||||
|
||||
@Component({
|
||||
tag: 'ion-segment-view',
|
||||
styleUrls: {
|
||||
ios: 'segment-view.ios.scss',
|
||||
md: 'segment-view.md.scss',
|
||||
},
|
||||
shadow: true,
|
||||
})
|
||||
export class SegmentView implements ComponentInterface {
|
||||
private scrollEndTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private isTouching = false;
|
||||
|
||||
@Element() el!: HTMLElement;
|
||||
|
||||
/**
|
||||
* If `true`, the segment view cannot be interacted with.
|
||||
*/
|
||||
@Prop() disabled = false;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* If `true`, the segment view is scrollable.
|
||||
* If `false`, pointer events will be disabled. This is to prevent issues with
|
||||
* quickly scrolling after interacting with a segment button.
|
||||
*/
|
||||
@State() isManualScroll?: boolean;
|
||||
|
||||
/**
|
||||
* Emitted when the segment view is scrolled.
|
||||
*/
|
||||
@Event() ionSegmentViewScroll!: EventEmitter<SegmentViewScrollEvent>;
|
||||
|
||||
@Listen('scroll')
|
||||
handleScroll(ev: Event) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = ev.target as HTMLElement;
|
||||
const scrollRatio = scrollLeft / (scrollWidth - clientWidth);
|
||||
|
||||
this.ionSegmentViewScroll.emit({
|
||||
scrollRatio,
|
||||
isManualScroll: this.isManualScroll ?? true,
|
||||
});
|
||||
|
||||
// Reset the timeout to check for scroll end
|
||||
this.resetScrollEndTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch start event to know when the user is actively dragging the segment view.
|
||||
*/
|
||||
@Listen('touchstart')
|
||||
handleScrollStart() {
|
||||
if (this.scrollEndTimeout) {
|
||||
clearTimeout(this.scrollEndTimeout);
|
||||
this.scrollEndTimeout = null;
|
||||
}
|
||||
|
||||
this.isTouching = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch end event to know when the user is no longer dragging the segment view.
|
||||
*/
|
||||
@Listen('touchend')
|
||||
handleTouchEnd() {
|
||||
this.isTouching = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the scroll end detection timer. This is called on every scroll event.
|
||||
*/
|
||||
private resetScrollEndTimeout() {
|
||||
if (this.scrollEndTimeout) {
|
||||
clearTimeout(this.scrollEndTimeout);
|
||||
this.scrollEndTimeout = null;
|
||||
}
|
||||
this.scrollEndTimeout = setTimeout(
|
||||
() => {
|
||||
this.checkForScrollEnd();
|
||||
},
|
||||
// Setting this to a lower value may result in inconsistencies in behavior
|
||||
// across browsers (particularly Firefox).
|
||||
// Ideally, all of this logic is removed once the scroll end event is
|
||||
// supported on all browsers (https://caniuse.com/?search=scrollend)
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the scroll has ended and the user is not actively touching.
|
||||
* If the conditions are met (active content is enabled and no active touch),
|
||||
* reset the scroll position and emit the scroll end event.
|
||||
*/
|
||||
private checkForScrollEnd() {
|
||||
// Only emit scroll end event if the active content is not disabled and
|
||||
// the user is not touching the segment view
|
||||
if (!this.isTouching) {
|
||||
this.isManualScroll = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* This method is used to programmatically set the displayed segment content
|
||||
* in the segment view. Calling this method will update the `value` of the
|
||||
* corresponding segment button.
|
||||
*
|
||||
* @param id: The id of the segment content to display.
|
||||
* @param smoothScroll: Whether to animate the scroll transition.
|
||||
*/
|
||||
@Method()
|
||||
async setContent(id: string, smoothScroll = true) {
|
||||
const contents = this.getSegmentContents();
|
||||
const index = contents.findIndex((content) => content.id === id);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
this.isManualScroll = false;
|
||||
this.resetScrollEndTimeout();
|
||||
|
||||
const contentWidth = this.el.offsetWidth;
|
||||
this.el.scrollTo({
|
||||
top: 0,
|
||||
left: index * contentWidth,
|
||||
behavior: smoothScroll ? 'smooth' : 'instant',
|
||||
});
|
||||
}
|
||||
|
||||
private getSegmentContents(): HTMLIonSegmentContentElement[] {
|
||||
return Array.from(this.el.querySelectorAll('ion-segment-content'));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { disabled, isManualScroll } = this;
|
||||
|
||||
return (
|
||||
<Host
|
||||
class={{
|
||||
'segment-view-disabled': disabled,
|
||||
'segment-view-scroll-disabled': isManualScroll === false,
|
||||
}}
|
||||
>
|
||||
<slot></slot>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
}
|
||||
164
core/src/components/segment-view/test/basic/index.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>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>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>
|
||||
</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;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
173
core/src/components/segment-view/test/basic/segment-view.e2e.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('segment-view: basic'), () => {
|
||||
test('should show the first content with no initial value', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-segment>
|
||||
<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 segmentContent = page.locator('ion-segment-content[id="paid"]');
|
||||
await expect(segmentContent).toBeInViewport();
|
||||
});
|
||||
|
||||
test('should show the content matching the initial 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
|
||||
);
|
||||
|
||||
const segmentContent = page.locator('ion-segment-content[id="free"]');
|
||||
await expect(segmentContent).toBeInViewport();
|
||||
});
|
||||
|
||||
test('should update the content when changing the value by clicking a segment button', 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-button[value="top"]').click();
|
||||
|
||||
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')
|
||||
);
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
await page.locator('ion-segment-content[id="top"]').scrollIntoViewIfNeeded();
|
||||
|
||||
const segmentButton = page.locator('ion-segment-button[value="top"]');
|
||||
await expect(segmentButton).toHaveClass(/segment-button-checked/);
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('ion-segment-view')
|
||||
.evaluate(
|
||||
(segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled')
|
||||
);
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
await page.locator('ion-segment').evaluate((segment: HTMLIonSegmentElement) => (segment.value = 'top'));
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
103
core/src/components/segment-view/test/disabled/index.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Segment View - Disabled</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;
|
||||
}
|
||||
|
||||
ion-segment-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ion-segment-content:nth-of-type(1) {
|
||||
background: lightpink;
|
||||
}
|
||||
|
||||
ion-segment-content:nth-of-type(2) {
|
||||
background: lightblue;
|
||||
}
|
||||
|
||||
ion-segment-content:nth-of-type(3) {
|
||||
background: lightgreen;
|
||||
}
|
||||
|
||||
ion-segment-content:nth-of-type(4) {
|
||||
background: lightgoldenrodyellow;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Segment View - Disabled</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-segment>
|
||||
<ion-segment-button disabled content-id="all" value="all">
|
||||
<ion-label>All</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="favorites" value="favorites">
|
||||
<ion-label>Favorites</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="all">All</ion-segment-content>
|
||||
<ion-segment-content id="favorites">Favorites</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
|
||||
<ion-segment disabled value="paid">
|
||||
<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>
|
||||
|
||||
<ion-segment value="reading-list">
|
||||
<ion-segment-button content-id="bookmarks" value="bookmarks">
|
||||
<ion-label>Bookmarks</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="reading-list" value="reading-list">
|
||||
<ion-label>Reading List</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="shared-links" value="shared-links">
|
||||
<ion-label>Shared Links</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view disabled>
|
||||
<ion-segment-content id="bookmarks">Bookmarks</ion-segment-content>
|
||||
<ion-segment-content id="reading-list">Reading List</ion-segment-content>
|
||||
<ion-segment-content id="shared-links">Shared Links</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across directions
|
||||
*/
|
||||
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('segment-view: disabled'), () => {
|
||||
test('should not have visual regressions', async ({ page }) => {
|
||||
await page.goto('/src/components/segment-view/test/disabled', config);
|
||||
|
||||
await expect(page).toHaveScreenshot(screenshot(`segment-view-disabled`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('segment-view: disabled'), () => {
|
||||
test('should keep button enabled even when disabled prop is set', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-segment>
|
||||
<ion-segment-button content-id="paid" value="paid">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button disabled 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 disabled 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="free"]');
|
||||
await expect(segmentButton).not.toHaveClass(/segment-button-disabled/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -7,6 +7,7 @@ import { createColorClasses, hostContext } from '@utils/theme';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import type { Color, StyleEventDetail } from '../../interface';
|
||||
import type { SegmentViewScrollEvent } from '../segment-view/segment-view-interface';
|
||||
|
||||
import type { SegmentChangeEventDetail, SegmentValue } from './segment-interface';
|
||||
|
||||
@@ -27,6 +28,16 @@ export class Segment implements ComponentInterface {
|
||||
// Value before the segment is dragged
|
||||
private valueBeforeGesture?: SegmentValue;
|
||||
|
||||
private segmentViewEl?: HTMLIonSegmentViewElement | null = null;
|
||||
private lastNextIndex?: number;
|
||||
|
||||
/**
|
||||
* Whether to update the segment view, if exists, when the value changes.
|
||||
* This behavior is enabled by default, but is set false when scrolling content views
|
||||
* since we don't want to "double scroll" the segment view.
|
||||
*/
|
||||
private triggerScrollOnValueChange?: boolean;
|
||||
|
||||
@Element() el!: HTMLIonSegmentElement;
|
||||
|
||||
@State() activated = false;
|
||||
@@ -78,13 +89,41 @@ export class Segment implements ComponentInterface {
|
||||
@Prop({ mutable: true }) value?: SegmentValue;
|
||||
|
||||
@Watch('value')
|
||||
protected valueChanged(value: SegmentValue | undefined) {
|
||||
protected valueChanged(value: SegmentValue | undefined, oldValue?: SegmentValue | undefined) {
|
||||
// Force a value to exist if we're using a segment view
|
||||
if (this.segmentViewEl && value === undefined) {
|
||||
this.value = this.getButtons()[0].value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldValue !== undefined && value !== undefined) {
|
||||
const buttons = this.getButtons();
|
||||
const previous = buttons.find((button) => button.value === oldValue);
|
||||
const current = buttons.find((button) => button.value === value);
|
||||
|
||||
if (previous && current) {
|
||||
if (!this.segmentViewEl) {
|
||||
this.checkButton(previous, current);
|
||||
} else if (this.triggerScrollOnValueChange !== false) {
|
||||
this.updateSegmentView();
|
||||
}
|
||||
}
|
||||
} else if (value !== undefined && oldValue === undefined && this.segmentViewEl) {
|
||||
this.updateSegmentView();
|
||||
}
|
||||
|
||||
/**
|
||||
* `ionSelect` is emitted every time the value changes (internal or external changes).
|
||||
* Used by `ion-segment-button` to determine if the button should be checked.
|
||||
*/
|
||||
this.ionSelect.emit({ value });
|
||||
this.scrollActiveButtonIntoView();
|
||||
|
||||
// The scroll listener should handle scrolling the active button into view as needed
|
||||
if (!this.segmentViewEl) {
|
||||
this.scrollActiveButtonIntoView();
|
||||
}
|
||||
|
||||
this.triggerScrollOnValueChange = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,9 +157,13 @@ export class Segment implements ComponentInterface {
|
||||
disabledChanged() {
|
||||
this.gestureChanged();
|
||||
|
||||
const buttons = this.getButtons();
|
||||
for (const button of buttons) {
|
||||
button.disabled = this.disabled;
|
||||
if (!this.segmentViewEl) {
|
||||
const buttons = this.getButtons();
|
||||
for (const button of buttons) {
|
||||
button.disabled = this.disabled;
|
||||
}
|
||||
} else {
|
||||
this.segmentViewEl.disabled = this.disabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +175,12 @@ export class Segment implements ComponentInterface {
|
||||
|
||||
connectedCallback() {
|
||||
this.emitStyle();
|
||||
|
||||
this.segmentViewEl = this.getSegmentView();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.segmentViewEl = null;
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
@@ -139,6 +188,8 @@ export class Segment implements ComponentInterface {
|
||||
}
|
||||
|
||||
async componentDidLoad() {
|
||||
this.segmentViewEl = this.getSegmentView();
|
||||
|
||||
this.setCheckedClasses();
|
||||
|
||||
/**
|
||||
@@ -170,6 +221,10 @@ export class Segment implements ComponentInterface {
|
||||
if (this.disabled) {
|
||||
this.disabledChanged();
|
||||
}
|
||||
|
||||
// Update segment view based on the initial value,
|
||||
// but do not animate the scroll
|
||||
this.updateSegmentView(false);
|
||||
}
|
||||
|
||||
onStart(detail: GestureDetail) {
|
||||
@@ -192,6 +247,7 @@ export class Segment implements ComponentInterface {
|
||||
if (value !== undefined) {
|
||||
if (this.valueBeforeGesture !== value) {
|
||||
this.emitValueChange();
|
||||
this.updateSegmentView();
|
||||
}
|
||||
}
|
||||
this.valueBeforeGesture = undefined;
|
||||
@@ -208,7 +264,7 @@ export class Segment implements ComponentInterface {
|
||||
this.ionChange.emit({ value });
|
||||
}
|
||||
|
||||
private getButtons() {
|
||||
private getButtons(): HTMLIonSegmentButtonElement[] {
|
||||
return Array.from(this.el.querySelectorAll('ion-segment-button'));
|
||||
}
|
||||
|
||||
@@ -224,11 +280,7 @@ export class Segment implements ComponentInterface {
|
||||
const buttons = this.getButtons();
|
||||
|
||||
buttons.forEach((button) => {
|
||||
if (activated) {
|
||||
button.classList.add('segment-button-activated');
|
||||
} else {
|
||||
button.classList.remove('segment-button-activated');
|
||||
}
|
||||
button.classList.toggle('segment-button-activated', activated);
|
||||
});
|
||||
this.activated = activated;
|
||||
}
|
||||
@@ -293,6 +345,8 @@ export class Segment implements ComponentInterface {
|
||||
|
||||
// Remove the transform to slide the indicator back to the button clicked
|
||||
currentIndicator.style.setProperty('transform', '');
|
||||
|
||||
this.scrollActiveButtonIntoView(true);
|
||||
});
|
||||
|
||||
this.value = current.value;
|
||||
@@ -312,6 +366,74 @@ export class Segment implements ComponentInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private getSegmentView() {
|
||||
const buttons = this.getButtons();
|
||||
// Get the first button with a contentId
|
||||
const firstContentId = buttons.find((button: HTMLIonSegmentButtonElement) => button.contentId);
|
||||
// Get the segment content with an id matching the button's contentId
|
||||
const segmentContent = document.querySelector(`ion-segment-content[id="${firstContentId?.contentId}"]`);
|
||||
// Return the segment view for that matching segment content
|
||||
return segmentContent?.closest('ion-segment-view');
|
||||
}
|
||||
|
||||
@Listen('ionSegmentViewScroll', { target: 'body' })
|
||||
handleSegmentViewScroll(ev: CustomEvent<SegmentViewScrollEvent>) {
|
||||
const { scrollRatio, isManualScroll } = ev.detail;
|
||||
|
||||
if (!isManualScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dispatchedFrom = ev.target as HTMLElement;
|
||||
const segmentViewEl = this.segmentViewEl as EventTarget;
|
||||
const segmentEl = this.el;
|
||||
|
||||
// Only update the indicator if the event was dispatched from the correct segment view
|
||||
if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) {
|
||||
const buttons = this.getButtons();
|
||||
|
||||
// If no buttons are found or there is no value set then do nothing
|
||||
if (!buttons.length) return;
|
||||
|
||||
const index = buttons.findIndex((button) => button.value === this.value);
|
||||
const current = buttons[index];
|
||||
|
||||
const nextIndex = Math.round(scrollRatio * (buttons.length - 1));
|
||||
|
||||
if (this.lastNextIndex === undefined || this.lastNextIndex !== nextIndex) {
|
||||
this.lastNextIndex = nextIndex;
|
||||
this.triggerScrollOnValueChange = false;
|
||||
|
||||
this.checkButton(current, buttons[nextIndex]);
|
||||
this.emitValueChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the related segment view and sets its current content
|
||||
* based on the selected segment button. This method
|
||||
* should be called on initial load of the segment,
|
||||
* after the gesture is completed (if dragging between segments)
|
||||
* and when a segment button is clicked directly.
|
||||
*/
|
||||
private updateSegmentView(smoothScroll = true) {
|
||||
const buttons = this.getButtons();
|
||||
const button = buttons.find((btn) => btn.value === this.value);
|
||||
|
||||
// If the button does not have a contentId then there is
|
||||
// no associated segment view to update
|
||||
if (!button?.contentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentView = this.segmentViewEl;
|
||||
|
||||
if (segmentView) {
|
||||
segmentView.setContent(button.contentId, smoothScroll);
|
||||
}
|
||||
}
|
||||
|
||||
private scrollActiveButtonIntoView(smoothScroll = true) {
|
||||
const { scrollable, value, el } = this;
|
||||
|
||||
@@ -342,21 +464,35 @@ export class Segment implements ComponentInterface {
|
||||
const centeredX = activeButtonLeft - scrollContainerBox.width / 2 + activeButtonBox.width / 2;
|
||||
|
||||
/**
|
||||
* We intentionally use scrollBy here instead of scrollIntoView
|
||||
* newScrollPosition is the absolute scroll position that the
|
||||
* container needs to move to in order to center the active button.
|
||||
* It is calculated by adding the current scroll position
|
||||
* (scrollLeft) to the offset needed to center the button
|
||||
* (centeredX).
|
||||
*/
|
||||
const newScrollPosition = el.scrollLeft + centeredX;
|
||||
|
||||
/**
|
||||
* We intentionally use scrollTo here instead of scrollIntoView
|
||||
* to avoid a WebKit bug where accelerated animations break
|
||||
* when using scrollIntoView. Using scrollIntoView will cause the
|
||||
* segment container to jump during the transition and then snap into place.
|
||||
* This is because scrollIntoView can potentially cause parent element
|
||||
* containers to also scroll. scrollBy does not have this same behavior, so
|
||||
* containers to also scroll. scrollTo does not have this same behavior, so
|
||||
* we use this API instead.
|
||||
*
|
||||
* scrollTo is used instead of scrollBy because there is a
|
||||
* Webkit bug that causes scrollBy to not work smoothly when
|
||||
* the active button is near the edge of the scroll container.
|
||||
* This leads to the buttons to jump around during the transition.
|
||||
*
|
||||
* Note that if there is not enough scrolling space to center the element
|
||||
* within the scroll container, the browser will attempt
|
||||
* to center by as much as it can.
|
||||
*/
|
||||
el.scrollBy({
|
||||
el.scrollTo({
|
||||
top: 0,
|
||||
left: centeredX,
|
||||
left: newScrollPosition,
|
||||
behavior: smoothScroll ? 'smooth' : 'instant',
|
||||
});
|
||||
}
|
||||
@@ -478,7 +614,13 @@ export class Segment implements ComponentInterface {
|
||||
this.emitValueChange();
|
||||
}
|
||||
|
||||
if (this.scrollable || !this.swipeGesture) {
|
||||
if (this.segmentViewEl) {
|
||||
this.updateSegmentView();
|
||||
|
||||
if (this.scrollable && previous) {
|
||||
this.checkButton(previous, current);
|
||||
}
|
||||
} else if (this.scrollable || !this.swipeGesture) {
|
||||
if (previous) {
|
||||
this.checkButton(previous, current);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
import { configs, test, dragElementBy } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions.
|
||||
@@ -105,8 +105,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
});
|
||||
});
|
||||
|
||||
// TODO FW-3021
|
||||
test.describe.skip('when the pointer is released', () => {
|
||||
test.describe('when the pointer is released', () => {
|
||||
test('should emit if the value has changed', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
@@ -136,14 +135,22 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
|
||||
const ionChangeSpy = await page.spyOnEvent('ionChange');
|
||||
|
||||
const segment = page.locator('ion-segment');
|
||||
const firstButton = page.locator('ion-segment-button[value="1"]');
|
||||
const lastButton = page.locator('ion-segment-button[value="3"]');
|
||||
|
||||
await firstButton.hover();
|
||||
await page.mouse.down();
|
||||
/*
|
||||
* `dragByX` should represent the total width of all segment buttons,
|
||||
* excluding the first half of the first button and the second half
|
||||
* of the last button. This calculation accounts for dragging from
|
||||
* the center of the first button to the center of the last button.
|
||||
*/
|
||||
const segmentWidth = await segment.boundingBox().then((box) => (box ? box.width : 0));
|
||||
const firstButtonWidth = await firstButton.boundingBox().then((box) => (box ? box.width : 0));
|
||||
const lastButtonWidth = await lastButton.boundingBox().then((box) => (box ? box.width : 0));
|
||||
const dragByX = segmentWidth - firstButtonWidth / 2 - lastButtonWidth / 2;
|
||||
|
||||
await lastButton.hover();
|
||||
await page.mouse.up();
|
||||
await dragElementBy(firstButton, page, dragByX);
|
||||
|
||||
expect(ionChangeSpy).toHaveReceivedEventDetail({ value: '3' });
|
||||
expect(ionChangeSpy).toHaveReceivedEventTimes(1);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface SelectModalOption {
|
||||
text: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
checked: boolean;
|
||||
cssClass?: string | string[];
|
||||
handler?: (value: any) => boolean | void | { [key: string]: any };
|
||||
}
|
||||
1
core/src/components/select-modal/select-modal.ios.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "./select-modal";
|
||||
30
core/src/components/select-modal/select-modal.md.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@import "./select-modal";
|
||||
@import "../../themes/ionic.mixins.scss";
|
||||
@import "../item/item.md.vars";
|
||||
|
||||
ion-list ion-radio::part(container) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ion-list ion-radio::part(label) {
|
||||
@include margin(0);
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--inner-border-width: 0;
|
||||
}
|
||||
|
||||
.item-radio-checked {
|
||||
--background: #{ion-color(primary, base, 0.08)};
|
||||
--background-focused: #{ion-color(primary, base)};
|
||||
--background-focused-opacity: 0.2;
|
||||
--background-hover: #{ion-color(primary, base)};
|
||||
--background-hover-opacity: 0.12;
|
||||
}
|
||||
|
||||
.item-checkbox-checked {
|
||||
--background-activated: #{$item-md-color};
|
||||
--background-focused: #{$item-md-color};
|
||||
--background-hover: #{$item-md-color};
|
||||
--color: #{ion-color(primary, base)};
|
||||
}
|
||||
3
core/src/components/select-modal/select-modal.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
}
|
||||
161
core/src/components/select-modal/select-modal.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { getIonMode } from '@global/ionic-global';
|
||||
import type { ComponentInterface } from '@stencil/core';
|
||||
import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core';
|
||||
import { safeCall } from '@utils/overlays';
|
||||
import { getClassMap } from '@utils/theme';
|
||||
|
||||
import type { CheckboxCustomEvent } from '../checkbox/checkbox-interface';
|
||||
import type { RadioGroupCustomEvent } from '../radio-group/radio-group-interface';
|
||||
|
||||
import type { SelectModalOption } from './select-modal-interface';
|
||||
|
||||
@Component({
|
||||
tag: 'ion-select-modal',
|
||||
styleUrls: {
|
||||
ios: 'select-modal.ios.scss',
|
||||
md: 'select-modal.md.scss',
|
||||
ionic: 'select-modal.md.scss',
|
||||
},
|
||||
scoped: true,
|
||||
})
|
||||
export class SelectModal implements ComponentInterface {
|
||||
@Element() el!: HTMLIonSelectModalElement;
|
||||
|
||||
@Prop() header?: string;
|
||||
|
||||
@Prop() multiple?: boolean;
|
||||
|
||||
@Prop() options: SelectModalOption[] = [];
|
||||
|
||||
private closeModal() {
|
||||
const modal = this.el.closest('ion-modal');
|
||||
|
||||
if (modal) {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
private findOptionFromEvent(ev: CheckboxCustomEvent | RadioGroupCustomEvent) {
|
||||
const { options } = this;
|
||||
return options.find((o) => o.value === ev.target.value);
|
||||
}
|
||||
|
||||
private getValues(ev?: CheckboxCustomEvent | RadioGroupCustomEvent): string | string[] | undefined {
|
||||
const { multiple, options } = this;
|
||||
|
||||
if (multiple) {
|
||||
// this is a modal with checkboxes (multiple value select)
|
||||
// return an array of all the checked values
|
||||
return options.filter((o) => o.checked).map((o) => o.value);
|
||||
}
|
||||
|
||||
// this is a modal with radio buttons (single value select)
|
||||
// return the value that was clicked, otherwise undefined
|
||||
const option = ev ? this.findOptionFromEvent(ev) : null;
|
||||
return option ? option.value : undefined;
|
||||
}
|
||||
|
||||
private callOptionHandler(ev: CheckboxCustomEvent | RadioGroupCustomEvent) {
|
||||
const option = this.findOptionFromEvent(ev);
|
||||
const values = this.getValues(ev);
|
||||
if (option?.handler) {
|
||||
safeCall(option.handler, values);
|
||||
}
|
||||
}
|
||||
|
||||
private setChecked(ev: CheckboxCustomEvent): void {
|
||||
const { multiple } = this;
|
||||
const option = this.findOptionFromEvent(ev);
|
||||
|
||||
// this is a modal with checkboxes (multiple value select)
|
||||
// we need to set the checked value for this option
|
||||
if (multiple && option) {
|
||||
option.checked = ev.detail.checked;
|
||||
}
|
||||
}
|
||||
|
||||
private renderRadioOptions() {
|
||||
const checked = this.options.filter((o) => o.checked).map((o) => o.value)[0];
|
||||
|
||||
return (
|
||||
<ion-radio-group value={checked} onIonChange={(ev) => this.callOptionHandler(ev)}>
|
||||
{this.options.map((option) => (
|
||||
<ion-item
|
||||
class={{
|
||||
// TODO FW-4784
|
||||
'item-radio-checked': option.value === checked,
|
||||
...getClassMap(option.cssClass),
|
||||
}}
|
||||
>
|
||||
<ion-radio
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
justify="start"
|
||||
labelPlacement="end"
|
||||
onClick={() => this.closeModal()}
|
||||
onKeyUp={(ev) => {
|
||||
if (ev.key === ' ') {
|
||||
/**
|
||||
* Selecting a radio option with keyboard navigation,
|
||||
* either through the Enter or Space keys, should
|
||||
* dismiss the modal.
|
||||
*/
|
||||
this.closeModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.text}
|
||||
</ion-radio>
|
||||
</ion-item>
|
||||
))}
|
||||
</ion-radio-group>
|
||||
);
|
||||
}
|
||||
|
||||
private renderCheckboxOptions() {
|
||||
return this.options.map((option) => (
|
||||
<ion-item
|
||||
class={{
|
||||
// TODO FW-4784
|
||||
'item-checkbox-checked': option.checked,
|
||||
...getClassMap(option.cssClass),
|
||||
}}
|
||||
>
|
||||
<ion-checkbox
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
checked={option.checked}
|
||||
justify="start"
|
||||
labelPlacement="end"
|
||||
onIonChange={(ev) => {
|
||||
this.setChecked(ev);
|
||||
this.callOptionHandler(ev);
|
||||
// TODO FW-4784
|
||||
forceUpdate(this);
|
||||
}}
|
||||
>
|
||||
{option.text}
|
||||
</ion-checkbox>
|
||||
</ion-item>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Host class={getIonMode(this)}>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
{this.header !== undefined && <ion-title>{this.header}</ion-title>}
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button onClick={() => this.closeModal()}>Close</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-list>{this.multiple === true ? this.renderCheckboxOptions() : this.renderRadioOptions()}</ion-list>
|
||||
</ion-content>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
}
|
||||
40
core/src/components/select-modal/test/basic/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Select - Modal</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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Modal - Basic</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-modal is-open="true">
|
||||
<ion-select-modal multiple="false"></ion-select-modal>
|
||||
</ion-modal>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
const selectModal = document.querySelector('ion-select-modal');
|
||||
selectModal.options = [
|
||||
{ value: 'apple', text: 'Apple', disabled: false, checked: true },
|
||||
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
|
||||
];
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
101
core/src/components/select-modal/test/basic/select-modal.e2e.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
import type { SelectModalOption } from '../../select-modal-interface';
|
||||
import { SelectModalPage } from '../fixtures';
|
||||
|
||||
const options: SelectModalOption[] = [
|
||||
{ value: 'apple', text: 'Apple', disabled: false, checked: false },
|
||||
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
|
||||
];
|
||||
|
||||
const checkedOptions: SelectModalOption[] = [
|
||||
{ value: 'apple', text: 'Apple', disabled: false, checked: true },
|
||||
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions.
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('select-modal: basic'), () => {
|
||||
test.beforeEach(({ browserName }) => {
|
||||
test.skip(browserName === 'webkit', 'ROU-5437');
|
||||
});
|
||||
|
||||
test.describe('single selection', () => {
|
||||
let selectModalPage: SelectModalPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
selectModalPage = new SelectModalPage(page);
|
||||
});
|
||||
|
||||
test('clicking an unselected option should dismiss the modal', async () => {
|
||||
await selectModalPage.setup(config, options, false);
|
||||
|
||||
await selectModalPage.clickOption('apple');
|
||||
await selectModalPage.ionModalDidDismiss.next();
|
||||
await expect(selectModalPage.modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking a selected option should dismiss the modal', async () => {
|
||||
await selectModalPage.setup(config, checkedOptions, false);
|
||||
|
||||
await selectModalPage.clickOption('apple');
|
||||
await selectModalPage.ionModalDidDismiss.next();
|
||||
await expect(selectModalPage.modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('pressing Space on an unselected option should dismiss the modal', async () => {
|
||||
await selectModalPage.setup(config, options, false);
|
||||
|
||||
await selectModalPage.pressSpaceOnOption('apple');
|
||||
await selectModalPage.ionModalDidDismiss.next();
|
||||
await expect(selectModalPage.modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('pressing Space on a selected option should dismiss the modal', async ({ browserName }) => {
|
||||
test.skip(browserName === 'firefox', 'Same behavior as ROU-5437');
|
||||
|
||||
await selectModalPage.setup(config, checkedOptions, false);
|
||||
|
||||
await selectModalPage.pressSpaceOnOption('apple');
|
||||
await selectModalPage.ionModalDidDismiss.next();
|
||||
await expect(selectModalPage.modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking the close button should dismiss the modal', async () => {
|
||||
await selectModalPage.setup(config, options, false);
|
||||
|
||||
const closeButton = selectModalPage.modal.locator('ion-header ion-toolbar ion-button');
|
||||
await closeButton.click();
|
||||
await selectModalPage.ionModalDidDismiss.next();
|
||||
await expect(selectModalPage.modal).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This behavior does not vary across directions.
|
||||
* The components used inside of `ion-select-modal`
|
||||
* do have RTL logic, but those are tested in their
|
||||
* respective component test files.
|
||||
*/
|
||||
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('select-modal: rendering'), () => {
|
||||
let selectModalPage: SelectModalPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
selectModalPage = new SelectModalPage(page);
|
||||
});
|
||||
test('should not have visual regressions with single selection', async () => {
|
||||
await selectModalPage.setup(config, checkedOptions, false);
|
||||
await selectModalPage.screenshot(screenshot, 'select-modal-diff');
|
||||
});
|
||||
test('should not have visual regressions with multiple selection', async () => {
|
||||
await selectModalPage.setup(config, checkedOptions, true);
|
||||
await selectModalPage.screenshot(screenshot, 'select-modal-multiple-diff');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
73
core/src/components/select-modal/test/fixtures.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { E2EPage, E2ELocator, EventSpy, E2EPageOptions, ScreenshotFn } from '@utils/test/playwright';
|
||||
|
||||
import type { SelectModalOption } from '../select-modal-interface';
|
||||
|
||||
export class SelectModalPage {
|
||||
private page: E2EPage;
|
||||
private multiple?: boolean;
|
||||
private options: SelectModalOption[] = [];
|
||||
|
||||
// Locators
|
||||
modal!: E2ELocator;
|
||||
selectModal!: E2ELocator;
|
||||
|
||||
// Event spies
|
||||
ionModalDidDismiss!: EventSpy;
|
||||
|
||||
constructor(page: E2EPage) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async setup(config: E2EPageOptions, options: SelectModalOption[], multiple = false) {
|
||||
const { page } = this;
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-modal>
|
||||
<ion-select-modal></ion-select-modal>
|
||||
</ion-modal>
|
||||
<script>
|
||||
const selectModal = document.querySelector('ion-select-modal');
|
||||
selectModal.options = ${JSON.stringify(options)};
|
||||
selectModal.multiple = ${multiple};
|
||||
</script>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
this.ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
this.modal = page.locator('ion-modal');
|
||||
this.selectModal = page.locator('ion-select-modal');
|
||||
this.multiple = multiple;
|
||||
this.options = options;
|
||||
|
||||
await this.modal.evaluate((modal: HTMLIonModalElement) => modal.present());
|
||||
|
||||
await ionModalDidPresent.next();
|
||||
}
|
||||
|
||||
async screenshot(screenshot: ScreenshotFn, name: string) {
|
||||
await expect(this.selectModal).toHaveScreenshot(screenshot(name));
|
||||
}
|
||||
|
||||
async clickOption(value: string) {
|
||||
const option = this.getOption(value);
|
||||
await option.click();
|
||||
}
|
||||
|
||||
async pressSpaceOnOption(value: string) {
|
||||
const option = this.getOption(value);
|
||||
await option.press('Space');
|
||||
}
|
||||
|
||||
private getOption(value: string) {
|
||||
const { multiple, selectModal } = this;
|
||||
const selector = multiple ? 'ion-checkbox' : 'ion-radio';
|
||||
const index = this.options.findIndex((o) => o.value === value);
|
||||
|
||||
return selectModal.locator(selector).nth(index);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SelectInterface = 'action-sheet' | 'popover' | 'alert';
|
||||
export type SelectInterface = 'action-sheet' | 'popover' | 'alert' | 'modal';
|
||||
|
||||
export type SelectCompareFn = (currentValue: any, compareValue: any) => boolean;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NotchController } from '@utils/forms';
|
||||
import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms';
|
||||
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
|
||||
import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays';
|
||||
import type { OverlaySelect } from '@utils/overlays-interface';
|
||||
import { isRTL } from '@utils/rtl';
|
||||
import { createColorClasses, hostContext } from '@utils/theme';
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
CssClassMap,
|
||||
PopoverOptions,
|
||||
StyleEventDetail,
|
||||
ModalOptions,
|
||||
} from '../../interface';
|
||||
import type { ActionSheetButton } from '../action-sheet/action-sheet-interface';
|
||||
import type { AlertInput } from '../alert/alert-interface';
|
||||
@@ -98,15 +99,15 @@ export class Select implements ComponentInterface {
|
||||
@Prop() fill?: 'outline' | 'solid';
|
||||
|
||||
/**
|
||||
* The interface the select should use: `action-sheet`, `popover` or `alert`.
|
||||
* The interface the select should use: `action-sheet`, `popover`, `alert`, or `modal`.
|
||||
*/
|
||||
@Prop() interface: SelectInterface = 'alert';
|
||||
|
||||
/**
|
||||
* Any additional options that the `alert`, `action-sheet` or `popover` interface
|
||||
* can take. See the [ion-alert docs](./alert), the
|
||||
* [ion-action-sheet docs](./action-sheet) and the
|
||||
* [ion-popover docs](./popover) for the
|
||||
* [ion-action-sheet docs](./action-sheet), the
|
||||
* [ion-popover docs](./popover), and the [ion-modal docs](./modal) for the
|
||||
* create options for each interface.
|
||||
*
|
||||
* Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface.
|
||||
@@ -318,9 +319,9 @@ export class Select implements ComponentInterface {
|
||||
|
||||
await overlay.present();
|
||||
|
||||
// focus selected option for popovers
|
||||
if (this.interface === 'popover') {
|
||||
const indexOfSelected = this.childOpts.map((o) => o.value).indexOf(this.value);
|
||||
// focus selected option for popovers and modals
|
||||
if (this.interface === 'popover' || this.interface === 'modal') {
|
||||
const indexOfSelected = this.childOpts.findIndex((o) => o.value === this.value);
|
||||
|
||||
if (indexOfSelected > -1) {
|
||||
const selectedItem = overlay.querySelector<HTMLElement>(
|
||||
@@ -328,8 +329,6 @@ export class Select implements ComponentInterface {
|
||||
);
|
||||
|
||||
if (selectedItem) {
|
||||
focusVisibleElement(selectedItem);
|
||||
|
||||
/**
|
||||
* Browsers such as Firefox do not
|
||||
* correctly delegate focus when manually
|
||||
@@ -341,10 +340,17 @@ export class Select implements ComponentInterface {
|
||||
* we only need to worry about those two components
|
||||
* when focusing.
|
||||
*/
|
||||
const interactiveEl = selectedItem.querySelector<HTMLElement>('ion-radio, ion-checkbox');
|
||||
const interactiveEl = selectedItem.querySelector<HTMLElement>('ion-radio, ion-checkbox') as
|
||||
| HTMLIonRadioElement
|
||||
| HTMLIonCheckboxElement
|
||||
| null;
|
||||
if (interactiveEl) {
|
||||
interactiveEl.focus();
|
||||
// Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
|
||||
// and removing `ion-focused` style
|
||||
interactiveEl.setFocus();
|
||||
}
|
||||
|
||||
focusVisibleElement(selectedItem);
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
@@ -352,14 +358,18 @@ export class Select implements ComponentInterface {
|
||||
*/
|
||||
const firstEnabledOption = overlay.querySelector<HTMLElement>(
|
||||
'ion-radio:not(.radio-disabled), ion-checkbox:not(.checkbox-disabled)'
|
||||
);
|
||||
if (firstEnabledOption) {
|
||||
focusVisibleElement(firstEnabledOption.closest('ion-item')!);
|
||||
) as HTMLIonRadioElement | HTMLIonCheckboxElement | null;
|
||||
|
||||
if (firstEnabledOption) {
|
||||
/**
|
||||
* Focus the option for the same reason as we do above.
|
||||
*
|
||||
* Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
|
||||
* and removing `ion-focused` style
|
||||
*/
|
||||
firstEnabledOption.focus();
|
||||
firstEnabledOption.setFocus();
|
||||
|
||||
focusVisibleElement(firstEnabledOption.closest('ion-item')!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,6 +399,9 @@ export class Select implements ComponentInterface {
|
||||
if (selectInterface === 'popover') {
|
||||
return this.openPopover(ev!);
|
||||
}
|
||||
if (selectInterface === 'modal') {
|
||||
return this.openModal();
|
||||
}
|
||||
return this.openAlert();
|
||||
}
|
||||
|
||||
@@ -406,7 +419,13 @@ export class Select implements ComponentInterface {
|
||||
case 'popover':
|
||||
const popover = overlay.querySelector('ion-select-popover');
|
||||
if (popover) {
|
||||
popover.options = this.createPopoverOptions(childOpts, value);
|
||||
popover.options = this.createOverlaySelectOptions(childOpts, value);
|
||||
}
|
||||
break;
|
||||
case 'modal':
|
||||
const modal = overlay.querySelector('ion-select-modal');
|
||||
if (modal) {
|
||||
modal.options = this.createOverlaySelectOptions(childOpts, value);
|
||||
}
|
||||
break;
|
||||
case 'alert':
|
||||
@@ -475,7 +494,7 @@ export class Select implements ComponentInterface {
|
||||
return alertInputs;
|
||||
}
|
||||
|
||||
private createPopoverOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectPopoverOption[] {
|
||||
private createOverlaySelectOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectPopoverOption[] {
|
||||
const popoverOptions = data.map((option) => {
|
||||
const value = getOptionValue(option);
|
||||
|
||||
@@ -553,7 +572,7 @@ export class Select implements ComponentInterface {
|
||||
message: interfaceOptions.message,
|
||||
multiple,
|
||||
value,
|
||||
options: this.createPopoverOptions(this.childOpts, value),
|
||||
options: this.createOverlaySelectOptions(this.childOpts, value),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -647,6 +666,40 @@ export class Select implements ComponentInterface {
|
||||
return alertController.create(alertOpts);
|
||||
}
|
||||
|
||||
private openModal() {
|
||||
const { multiple, value, interfaceOptions } = this;
|
||||
const mode = getIonMode(this);
|
||||
|
||||
const modalOpts: ModalOptions = {
|
||||
...interfaceOptions,
|
||||
mode,
|
||||
|
||||
cssClass: ['select-modal', interfaceOptions.cssClass],
|
||||
component: 'ion-select-modal',
|
||||
componentProps: {
|
||||
header: interfaceOptions.header,
|
||||
multiple,
|
||||
value,
|
||||
options: this.createOverlaySelectOptions(this.childOpts, value),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Workaround for Stencil to autodefine
|
||||
* ion-select-modal and ion-modal when
|
||||
* using Custom Elements build.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
if (false) {
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
document.createElement('ion-select-modal');
|
||||
document.createElement('ion-modal');
|
||||
}
|
||||
|
||||
return modalController.create(modalOpts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the select interface.
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,14 @@
|
||||
<ion-select-option value="pears">Pears</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-select label="Modal" placeholder="Select one" interface="modal">
|
||||
<ion-select-option value="apples">Apples</ion-select-option>
|
||||
<ion-select-option value="oranges">Oranges</ion-select-option>
|
||||
<ion-select-option value="pears">Pears</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list>
|
||||
@@ -76,6 +84,15 @@
|
||||
<ion-select-option value="honeybadger">Honey Badger</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-select label="Modal" multiple="true" interface="modal">
|
||||
<ion-select-option value="bird">Bird</ion-select-option>
|
||||
<ion-select-option value="cat">Cat</ion-select-option>
|
||||
<ion-select-option value="dog">Dog</ion-select-option>
|
||||
<ion-select-option value="honeybadger">Honey Badger</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list>
|
||||
@@ -124,6 +141,16 @@
|
||||
<ion-select-option value="onions">Onions</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-item color="secondary">
|
||||
<ion-select label="Modal Sheet" id="customModalSelect" interface="modal" placeholder="Select One">
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="bacon">Bacon</ion-select-option>
|
||||
<ion-select-option value="xcheese">Extra Cheese</ion-select-option>
|
||||
<ion-select-option value="mushrooms">Mushrooms</ion-select-option>
|
||||
<ion-select-option value="onions">Onions</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
|
||||
@@ -152,6 +179,14 @@
|
||||
message: '$1.50 charge for every topping',
|
||||
};
|
||||
customActionSheetSelect.interfaceOptions = customActionSheetOptions;
|
||||
|
||||
var customModalSelect = document.getElementById('customModalSelect');
|
||||
var customModalSheetOptions = {
|
||||
header: 'Pizza Toppings',
|
||||
breakpoints: [0.5],
|
||||
initialBreakpoint: 0.5,
|
||||
};
|
||||
customModalSelect.interfaceOptions = customModalSheetOptions;
|
||||
</script>
|
||||
</ion-app>
|
||||
</body>
|
||||
|
||||
@@ -58,6 +58,24 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
await expect(popover).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('select: modal', () => {
|
||||
test('it should open a modal select', async ({ page }) => {
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#customModalSelect');
|
||||
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const modal = page.locator('ion-modal');
|
||||
|
||||
// select has no value, so first option should be focused by default
|
||||
const modalOption1 = modal.locator('.select-interface-option:first-of-type ion-radio');
|
||||
await expect(modalOption1).toBeFocused();
|
||||
|
||||
await expect(modal).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
||||
await expect(helperText).toHaveText('my helper');
|
||||
await expect(errorText).toBeHidden();
|
||||
});
|
||||
test('textarea should have an aria-describedby attribute when helper text is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
const helperText = page.locator('ion-textarea .helper-text');
|
||||
const helperTextId = await helperText.getAttribute('id');
|
||||
const ariaDescribedBy = await textarea.getAttribute('aria-describedby');
|
||||
|
||||
expect(ariaDescribedBy).toBe(helperTextId);
|
||||
});
|
||||
test('error text should be visible when textarea is invalid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
@@ -55,6 +68,48 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
||||
const errorText = page.locator('ion-textarea .error-text');
|
||||
await expect(errorText).toHaveScreenshot(screenshot(`textarea-error-custom-color`));
|
||||
});
|
||||
test('textarea should have an aria-describedby attribute when error text is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
const errorText = page.locator('ion-textarea .error-text');
|
||||
const errorTextId = await errorText.getAttribute('id');
|
||||
const ariaDescribedBy = await textarea.getAttribute('aria-describedby');
|
||||
|
||||
expect(ariaDescribedBy).toBe(errorTextId);
|
||||
});
|
||||
test('textarea should have aria-invalid attribute when input is invalid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea class="ion-invalid ion-touched" helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
|
||||
await expect(textarea).toHaveAttribute('aria-invalid');
|
||||
});
|
||||
test('textarea should not have aria-invalid attribute when input is valid', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`<ion-textarea helper-text="my helper" error-text="my error" label="my textarea"></ion-textarea>`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
|
||||
await expect(textarea).not.toHaveAttribute('aria-invalid');
|
||||
});
|
||||
test('textarea should not have aria-describedby attribute when no hint or error text is present', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(`<ion-textarea label="my textarea"></ion-textarea>`, config);
|
||||
|
||||
const textarea = page.locator('ion-textarea textarea');
|
||||
|
||||
await expect(textarea).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
});
|
||||
test.describe('textarea: hint text rendering', () => {
|
||||
test.describe('regular textareas', () => {
|
||||
|
||||
@@ -45,6 +45,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text
|
||||
export class Textarea implements ComponentInterface {
|
||||
private nativeInput?: HTMLTextAreaElement;
|
||||
private inputId = `ion-textarea-${textareaIds++}`;
|
||||
private helperTextId = `${this.inputId}-helper-text`;
|
||||
private errorTextId = `${this.inputId}-error-text`;
|
||||
/**
|
||||
* `true` if the textarea was cleared as a result of the user typing
|
||||
* with `clearOnEdit` enabled.
|
||||
@@ -576,9 +578,30 @@ export class Textarea implements ComponentInterface {
|
||||
* Renders the helper text or error text values
|
||||
*/
|
||||
private renderHintText() {
|
||||
const { helperText, errorText } = this;
|
||||
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
return [<div class="helper-text">{helperText}</div>, <div class="error-text">{errorText}</div>];
|
||||
return [
|
||||
<div id={helperTextId} class="helper-text">
|
||||
{helperText}
|
||||
</div>,
|
||||
<div id={errorTextId} class="error-text">
|
||||
{errorText}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
const { el, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
|
||||
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
|
||||
return errorTextId;
|
||||
}
|
||||
|
||||
if (helperText) {
|
||||
return helperTextId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private renderCounter() {
|
||||
@@ -703,6 +726,8 @@ export class Textarea implements ComponentInterface {
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
onKeyDown={this.onKeyDown}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||
{...this.inheritedAttributes}
|
||||
>
|
||||
{value}
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
background: var(--background);
|
||||
|
||||
box-shadow: var(--box-shadow);
|
||||
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast-wrapper.toast-top {
|
||||
@@ -115,7 +117,6 @@
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
|
||||
height: inherit;
|
||||
min-height: inherit;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { focusVisibleElement } from '@utils/helpers';
|
||||
* valid usage for the disabled property on ion-button.
|
||||
*/
|
||||
export const focusableQueryString =
|
||||
'[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])';
|
||||
'[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), ion-checkbox:not([tabindex^="-"]):not([hidden]):not([disabled]), ion-radio:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])';
|
||||
|
||||
/**
|
||||
* Focuses the first descendant in a context
|
||||
@@ -78,7 +78,13 @@ const focusElementInContext = <T extends HTMLElement>(
|
||||
}
|
||||
|
||||
if (elementToFocus) {
|
||||
focusVisibleElement(elementToFocus);
|
||||
const radioGroup = elementToFocus.closest('ion-radio-group');
|
||||
|
||||
if (radioGroup) {
|
||||
radioGroup.setFocus();
|
||||
} else {
|
||||
focusVisibleElement(elementToFocus);
|
||||
}
|
||||
} else {
|
||||
// Focus fallback element instead of letting focus escape
|
||||
fallbackElement.focus();
|
||||
|
||||
@@ -174,7 +174,7 @@ const createMenuController = (): MenuControllerI => {
|
||||
}
|
||||
};
|
||||
|
||||
const _setOpen = async (menu: MenuI, shouldOpen: boolean, animated: boolean): Promise<boolean> => {
|
||||
const _setOpen = async (menu: MenuI, shouldOpen: boolean, animated: boolean, role: string): Promise<boolean> => {
|
||||
if (isAnimatingSync()) {
|
||||
return false;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ const createMenuController = (): MenuControllerI => {
|
||||
await openedMenu.setOpen(false, false);
|
||||
}
|
||||
}
|
||||
return menu._setOpen(shouldOpen, animated);
|
||||
return menu._setOpen(shouldOpen, animated, role);
|
||||
};
|
||||
|
||||
const _createAnimation = (type: string, menuCmp: MenuI) => {
|
||||
|
||||
@@ -46,4 +46,8 @@ export interface HTMLIonOverlayElement extends HTMLStencilElement {
|
||||
present: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type OverlaySelect = HTMLIonActionSheetElement | HTMLIonAlertElement | HTMLIonPopoverElement;
|
||||
export type OverlaySelect =
|
||||
| HTMLIonActionSheetElement
|
||||
| HTMLIonAlertElement
|
||||
| HTMLIonPopoverElement
|
||||
| HTMLIonModalElement;
|
||||
|
||||