Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04fa23e0d | ||
|
|
ce83407e1d | ||
|
|
784fdc6543 | ||
|
|
7dcefa2203 | ||
|
|
623bf0e2f2 | ||
|
|
5862405e9e | ||
|
|
e925d8543f | ||
|
|
15deeefeae | ||
|
|
8d07917cd1 | ||
|
|
72abccaad8 | ||
|
|
0e76a69370 | ||
|
|
366f00e25f | ||
|
|
d36aef38a8 | ||
|
|
1de6b7a1cb | ||
|
|
32ab505363 | ||
|
|
818c138633 | ||
|
|
af0949f5bb | ||
|
|
d29ac713fa | ||
|
|
5bcf921841 | ||
|
|
ef73476e08 | ||
|
|
f8f7ffda31 | ||
|
|
5cdeb7fd35 | ||
|
|
2be39da9d3 | ||
|
|
a2c655923b | ||
|
|
1d7b28694e | ||
|
|
90858582a6 | ||
|
|
6ea186d96d | ||
|
|
23e998b731 | ||
|
|
814c2e5ccd | ||
|
|
5cea5aeb44 | ||
|
|
55735df3fa | ||
|
|
46806bd6e2 | ||
|
|
6e4f60af4c | ||
|
|
822da428af | ||
|
|
0cf4c03e29 | ||
|
|
d74b11bc19 | ||
|
|
fac1a6673c | ||
|
|
66e1dc0e70 |
2
.github/actions/publish-npm/action.yml
vendored
@@ -22,7 +22,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: 🟢 Configure Node for Publish
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic Angular Server'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -9,7 +9,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic React Router'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic React'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Builds Ionic Vue Router'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic Vue'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -10,7 +10,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Test Core Clean Build'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Test Core Lint'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
|
||||
@@ -13,7 +13,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
@@ -66,7 +66,7 @@ runs:
|
||||
working-directory: ./core
|
||||
- name: 📦 Archive Updated Screenshots
|
||||
if: inputs.update == 'true' && steps.test-and-update.outputs.hasUpdatedScreenshots == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: updated-screenshots-${{ inputs.shard }}-${{ inputs.totalShards }}
|
||||
path: UpdatedScreenshots-${{ inputs.shard }}-${{ inputs.totalShards }}.zip
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -7,10 +7,10 @@ on:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./artifacts
|
||||
- name: 🔎 Extract Archives
|
||||
|
||||
@@ -13,7 +13,7 @@ runs:
|
||||
- name: 🗄️ Create Archive
|
||||
run: zip -q -r ${{ inputs.output }} ${{ inputs.paths }}
|
||||
shell: bash
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.output }}
|
||||
|
||||
43
CHANGELOG.md
@@ -3,6 +3,49 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.8.1](https://github.com/ionic-team/ionic-framework/compare/v8.8.0...v8.8.1) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **accordion:** update tabindex based on disabled state ([#30986](https://github.com/ionic-team/ionic-framework/issues/30986)) ([0e76a69](https://github.com/ionic-team/ionic-framework/commit/0e76a69370083702568825c29d63cf257d6b88f1))
|
||||
* **angular:** export RefresherPullEnd types ([#30991](https://github.com/ionic-team/ionic-framework/issues/30991)) ([72abcca](https://github.com/ionic-team/ionic-framework/commit/72abccaad8df3c1db004da28610fddd95ac93c02))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **toast:** add wrapper and content parts (originally intended for 8.8.0 but omitted from that release) ([#30992](https://github.com/ionic-team/ionic-framework/issues/30992)) ([366f00e](https://github.com/ionic-team/ionic-framework/commit/366f00e25f06e28aa7166275445716c2d301e44a)), closes [#30735](https://github.com/ionic-team/ionic-framework/issues/30735)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [8.8.0](https://github.com/ionic-team/ionic-framework/compare/v8.7.18...v8.8.0) (2026-03-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **angular:** add custom injector support for modal and popover controllers ([#30899](https://github.com/ionic-team/ionic-framework/issues/30899)) ([822da42](https://github.com/ionic-team/ionic-framework/commit/822da428af86cd9b036b81515272321eb8fa586c)), closes [#30638](https://github.com/ionic-team/ionic-framework/issues/30638)
|
||||
* **content:** add content-fullscreen class when fullscreen is true ([#30926](https://github.com/ionic-team/ionic-framework/issues/30926)) ([d74b11b](https://github.com/ionic-team/ionic-framework/commit/d74b11bc19d6268b256daf23ba6f107483c00320))
|
||||
* **datetime:** add header parts ([#30945](https://github.com/ionic-team/ionic-framework/issues/30945)) ([6ea186d](https://github.com/ionic-team/ionic-framework/commit/6ea186d96d80a94b774d4d0a51d536e0e5599935))
|
||||
* **datetime:** add wheel part to ion-picker-column ([#30934](https://github.com/ionic-team/ionic-framework/issues/30934)) ([0cf4c03](https://github.com/ionic-team/ionic-framework/commit/0cf4c03e298bb4f7eea71c966a1473765ebd6d7a))
|
||||
* **item-divider:** add inner and container parts ([#30928](https://github.com/ionic-team/ionic-framework/issues/30928)) ([5cdeb7f](https://github.com/ionic-team/ionic-framework/commit/5cdeb7fd357298f15e7ae29b14412d97bdc7c656))
|
||||
* **item-option:** add inner and container parts ([#30929](https://github.com/ionic-team/ionic-framework/issues/30929)) ([f8f7ffd](https://github.com/ionic-team/ionic-framework/commit/f8f7ffda318c0143d9bb5c79fe55b4ecb88e6ce3))
|
||||
* **item:** add inner and container parts ([#30927](https://github.com/ionic-team/ionic-framework/issues/30927)) ([a2c6559](https://github.com/ionic-team/ionic-framework/commit/a2c655923bb1cff51864949575e19028623c695d))
|
||||
* **list-header:** add inner part ([#30930](https://github.com/ionic-team/ionic-framework/issues/30930)) ([ef73476](https://github.com/ionic-team/ionic-framework/commit/ef73476e08670630907e775a38f9ed30a84e3f1f))
|
||||
* **modal:** add drag events for sheet and card modals ([#30962](https://github.com/ionic-team/ionic-framework/issues/30962)) ([d29ac71](https://github.com/ionic-team/ionic-framework/commit/d29ac713fad604c256fb385eb0c26eb9717e1ff4)), closes [#23955](https://github.com/ionic-team/ionic-framework/issues/23955)
|
||||
* **range:** add classes and expose parts to allow individual styling of dual knobs ([#30941](https://github.com/ionic-team/ionic-framework/issues/30941)) ([5bcf921](https://github.com/ionic-team/ionic-framework/commit/5bcf92184118055483bf306ab9e319b8e3e61870)), closes [#29862](https://github.com/ionic-team/ionic-framework/issues/29862)
|
||||
* **range:** add classes to the range when the value is at the min or max ([#30932](https://github.com/ionic-team/ionic-framework/issues/30932)) ([fac1a66](https://github.com/ionic-team/ionic-framework/commit/fac1a6673c88a531f1d79656be4eb544f235f819))
|
||||
* **refresher:** add ionPullStart and ionPullEnd events ([#30946](https://github.com/ionic-team/ionic-framework/issues/30946)) ([814c2e5](https://github.com/ionic-team/ionic-framework/commit/814c2e5ccd6d5bfda12bdf13a566cd66ff830d5b)), closes [#24524](https://github.com/ionic-team/ionic-framework/issues/24524)
|
||||
* **segment-view:** add swipeGesture property to disable swiping ([#30948](https://github.com/ionic-team/ionic-framework/issues/30948)) ([46806bd](https://github.com/ionic-team/ionic-framework/commit/46806bd6e2af90a0b31fca68f508c06d3d281ec0)), closes [#30290](https://github.com/ionic-team/ionic-framework/issues/30290)
|
||||
* **select:** add wrapper and bottom shadow parts ([#30951](https://github.com/ionic-team/ionic-framework/issues/30951)) ([5cea5ae](https://github.com/ionic-team/ionic-framework/commit/5cea5aeb44393edab7064e5980a1eb7e607d1b8d))
|
||||
* **select:** pass cancelText property to modal interface ([#30282](https://github.com/ionic-team/ionic-framework/issues/30282)) ([6e4f60a](https://github.com/ionic-team/ionic-framework/commit/6e4f60af4c188ae04028b444aa21118ae27c2ca7))
|
||||
* **textarea:** reflect disabled and readonly props ([#30910](https://github.com/ionic-team/ionic-framework/issues/30910)) ([55735df](https://github.com/ionic-team/ionic-framework/commit/55735df3fa62c8e259c56db3169f3d5459e71c0c))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.18](https://github.com/ionic-team/ionic-framework/compare/v8.7.17...v8.7.18) (2026-02-25)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,48 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.8.1](https://github.com/ionic-team/ionic-framework/compare/v8.8.0...v8.8.1) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **accordion:** update tabindex based on disabled state ([#30986](https://github.com/ionic-team/ionic-framework/issues/30986)) ([0e76a69](https://github.com/ionic-team/ionic-framework/commit/0e76a69370083702568825c29d63cf257d6b88f1))
|
||||
* **angular:** export RefresherPullEnd types ([#30991](https://github.com/ionic-team/ionic-framework/issues/30991)) ([72abcca](https://github.com/ionic-team/ionic-framework/commit/72abccaad8df3c1db004da28610fddd95ac93c02))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **toast:** add wrapper and content parts (originally intended for 8.8.0 but omitted from that release) ([#30992](https://github.com/ionic-team/ionic-framework/issues/30992)) ([366f00e](https://github.com/ionic-team/ionic-framework/commit/366f00e25f06e28aa7166275445716c2d301e44a)), closes [#30735](https://github.com/ionic-team/ionic-framework/issues/30735)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [8.8.0](https://github.com/ionic-team/ionic-framework/compare/v8.7.18...v8.8.0) (2026-03-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **content:** add content-fullscreen class when fullscreen is true ([#30926](https://github.com/ionic-team/ionic-framework/issues/30926)) ([d74b11b](https://github.com/ionic-team/ionic-framework/commit/d74b11bc19d6268b256daf23ba6f107483c00320))
|
||||
* **datetime:** add header parts ([#30945](https://github.com/ionic-team/ionic-framework/issues/30945)) ([6ea186d](https://github.com/ionic-team/ionic-framework/commit/6ea186d96d80a94b774d4d0a51d536e0e5599935))
|
||||
* **datetime:** add wheel part to ion-picker-column ([#30934](https://github.com/ionic-team/ionic-framework/issues/30934)) ([0cf4c03](https://github.com/ionic-team/ionic-framework/commit/0cf4c03e298bb4f7eea71c966a1473765ebd6d7a))
|
||||
* **item-divider:** add inner and container parts ([#30928](https://github.com/ionic-team/ionic-framework/issues/30928)) ([5cdeb7f](https://github.com/ionic-team/ionic-framework/commit/5cdeb7fd357298f15e7ae29b14412d97bdc7c656))
|
||||
* **item-option:** add inner and container parts ([#30929](https://github.com/ionic-team/ionic-framework/issues/30929)) ([f8f7ffd](https://github.com/ionic-team/ionic-framework/commit/f8f7ffda318c0143d9bb5c79fe55b4ecb88e6ce3))
|
||||
* **item:** add inner and container parts ([#30927](https://github.com/ionic-team/ionic-framework/issues/30927)) ([a2c6559](https://github.com/ionic-team/ionic-framework/commit/a2c655923bb1cff51864949575e19028623c695d))
|
||||
* **list-header:** add inner part ([#30930](https://github.com/ionic-team/ionic-framework/issues/30930)) ([ef73476](https://github.com/ionic-team/ionic-framework/commit/ef73476e08670630907e775a38f9ed30a84e3f1f))
|
||||
* **modal:** add drag events for sheet and card modals ([#30962](https://github.com/ionic-team/ionic-framework/issues/30962)) ([d29ac71](https://github.com/ionic-team/ionic-framework/commit/d29ac713fad604c256fb385eb0c26eb9717e1ff4)), closes [#23955](https://github.com/ionic-team/ionic-framework/issues/23955)
|
||||
* **range:** add classes and expose parts to allow individual styling of dual knobs ([#30941](https://github.com/ionic-team/ionic-framework/issues/30941)) ([5bcf921](https://github.com/ionic-team/ionic-framework/commit/5bcf92184118055483bf306ab9e319b8e3e61870)), closes [#29862](https://github.com/ionic-team/ionic-framework/issues/29862)
|
||||
* **range:** add classes to the range when the value is at the min or max ([#30932](https://github.com/ionic-team/ionic-framework/issues/30932)) ([fac1a66](https://github.com/ionic-team/ionic-framework/commit/fac1a6673c88a531f1d79656be4eb544f235f819))
|
||||
* **refresher:** add ionPullStart and ionPullEnd events ([#30946](https://github.com/ionic-team/ionic-framework/issues/30946)) ([814c2e5](https://github.com/ionic-team/ionic-framework/commit/814c2e5ccd6d5bfda12bdf13a566cd66ff830d5b)), closes [#24524](https://github.com/ionic-team/ionic-framework/issues/24524)
|
||||
* **segment-view:** add swipeGesture property to disable swiping ([#30948](https://github.com/ionic-team/ionic-framework/issues/30948)) ([46806bd](https://github.com/ionic-team/ionic-framework/commit/46806bd6e2af90a0b31fca68f508c06d3d281ec0)), closes [#30290](https://github.com/ionic-team/ionic-framework/issues/30290)
|
||||
* **select:** add wrapper and bottom shadow parts ([#30951](https://github.com/ionic-team/ionic-framework/issues/30951)) ([5cea5ae](https://github.com/ionic-team/ionic-framework/commit/5cea5aeb44393edab7064e5980a1eb7e607d1b8d))
|
||||
* **select:** pass cancelText property to modal interface ([#30282](https://github.com/ionic-team/ionic-framework/issues/30282)) ([6e4f60a](https://github.com/ionic-team/ionic-framework/commit/6e4f60af4c188ae04028b444aa21118ae27c2ca7))
|
||||
* **textarea:** reflect disabled and readonly props ([#30910](https://github.com/ionic-team/ionic-framework/issues/30910)) ([55735df](https://github.com/ionic-team/ionic-framework/commit/55735df3fa62c8e259c56db3169f3d5459e71c0c))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.18](https://github.com/ionic-team/ionic-framework/compare/v8.7.17...v8.7.18) (2026-02-25)
|
||||
|
||||
|
||||
|
||||
59
core/api.txt
@@ -566,9 +566,18 @@ ion-datetime,part,calendar-day
|
||||
ion-datetime,part,calendar-day active
|
||||
ion-datetime,part,calendar-day disabled
|
||||
ion-datetime,part,calendar-day today
|
||||
ion-datetime,part,calendar-days-of-week
|
||||
ion-datetime,part,calendar-header
|
||||
ion-datetime,part,datetime-header
|
||||
ion-datetime,part,datetime-selected-date
|
||||
ion-datetime,part,datetime-title
|
||||
ion-datetime,part,month-year-button
|
||||
ion-datetime,part,navigation-button
|
||||
ion-datetime,part,next-button
|
||||
ion-datetime,part,previous-button
|
||||
ion-datetime,part,time-button
|
||||
ion-datetime,part,time-button active
|
||||
ion-datetime,part,wheel
|
||||
ion-datetime,part,wheel-item
|
||||
ion-datetime,part,wheel-item active
|
||||
|
||||
@@ -703,7 +712,7 @@ ion-infinite-scroll-content,prop,loadingText,IonicSafeString | string | undefine
|
||||
|
||||
ion-input,scoped
|
||||
ion-input,prop,autocapitalize,string,'off',false,false
|
||||
ion-input,prop,autocomplete,"name" | "email" | "tel" | "url" | "on" | "off" | "honorific-prefix" | "given-name" | "additional-name" | "family-name" | "honorific-suffix" | "nickname" | "username" | "new-password" | "current-password" | "one-time-code" | "organization-title" | "organization" | "street-address" | "address-line1" | "address-line2" | "address-line3" | "address-level4" | "address-level3" | "address-level2" | "address-level1" | "country" | "country-name" | "postal-code" | "cc-name" | "cc-given-name" | "cc-additional-name" | "cc-family-name" | "cc-number" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-csc" | "cc-type" | "transaction-currency" | "transaction-amount" | "language" | "bday" | "bday-day" | "bday-month" | "bday-year" | "sex" | "tel-country-code" | "tel-national" | "tel-area-code" | "tel-local" | "tel-extension" | "impp" | "photo",'off',false,false
|
||||
ion-input,prop,autocomplete,"additional-name" | "address-level1" | "address-level2" | "address-level3" | "address-level4" | "address-line1" | "address-line2" | "address-line3" | "bday" | "bday-day" | "bday-month" | "bday-year" | "cc-additional-name" | "cc-csc" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-family-name" | "cc-given-name" | "cc-name" | "cc-number" | "cc-type" | "country" | "country-name" | "current-password" | "email" | "family-name" | "given-name" | "honorific-prefix" | "honorific-suffix" | "impp" | "language" | "name" | "new-password" | "nickname" | "off" | "on" | "one-time-code" | "organization" | "organization-title" | "photo" | "postal-code" | "sex" | "street-address" | "tel" | "tel-area-code" | "tel-country-code" | "tel-extension" | "tel-local" | "tel-national" | "transaction-amount" | "transaction-currency" | "url" | "username",'off',false,false
|
||||
ion-input,prop,autocorrect,"off" | "on",'off',false,false
|
||||
ion-input,prop,autofocus,boolean,false,false,false
|
||||
ion-input,prop,clearInput,boolean,false,false,false
|
||||
@@ -930,7 +939,9 @@ ion-item,css-prop,--ripple-color,ios
|
||||
ion-item,css-prop,--ripple-color,md
|
||||
ion-item,css-prop,--transition,ios
|
||||
ion-item,css-prop,--transition,md
|
||||
ion-item,part,container
|
||||
ion-item,part,detail-icon
|
||||
ion-item,part,inner
|
||||
ion-item,part,native
|
||||
|
||||
ion-item-divider,shadow
|
||||
@@ -957,6 +968,8 @@ ion-item-divider,css-prop,--padding-start,ios
|
||||
ion-item-divider,css-prop,--padding-start,md
|
||||
ion-item-divider,css-prop,--padding-top,ios
|
||||
ion-item-divider,css-prop,--padding-top,md
|
||||
ion-item-divider,part,container
|
||||
ion-item-divider,part,inner
|
||||
|
||||
ion-item-group,none
|
||||
|
||||
@@ -974,6 +987,8 @@ ion-item-option,css-prop,--background,ios
|
||||
ion-item-option,css-prop,--background,md
|
||||
ion-item-option,css-prop,--color,ios
|
||||
ion-item-option,css-prop,--color,md
|
||||
ion-item-option,part,container
|
||||
ion-item-option,part,inner
|
||||
ion-item-option,part,native
|
||||
|
||||
ion-item-options,none
|
||||
@@ -1018,6 +1033,7 @@ ion-list-header,css-prop,--color,ios
|
||||
ion-list-header,css-prop,--color,md
|
||||
ion-list-header,css-prop,--inner-border-width,ios
|
||||
ion-list-header,css-prop,--inner-border-width,md
|
||||
ion-list-header,part,inner
|
||||
|
||||
ion-loading,scoped
|
||||
ion-loading,prop,animated,boolean,true,false,false
|
||||
@@ -1171,6 +1187,9 @@ ion-modal,method,setCurrentBreakpoint,setCurrentBreakpoint(breakpoint: number) =
|
||||
ion-modal,event,didDismiss,OverlayEventDetail<any>,true
|
||||
ion-modal,event,didPresent,void,true
|
||||
ion-modal,event,ionBreakpointDidChange,ModalBreakpointChangeEventDetail,true
|
||||
ion-modal,event,ionDragEnd,ModalDragEventDetail,true
|
||||
ion-modal,event,ionDragMove,ModalDragEventDetail,true
|
||||
ion-modal,event,ionDragStart,void,true
|
||||
ion-modal,event,ionModalDidDismiss,OverlayEventDetail<any>,true
|
||||
ion-modal,event,ionModalDidPresent,void,true
|
||||
ion-modal,event,ionModalWillDismiss,OverlayEventDetail<any>,true
|
||||
@@ -1209,7 +1228,7 @@ ion-nav,shadow
|
||||
ion-nav,prop,animated,boolean,true,false,false
|
||||
ion-nav,prop,animation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
|
||||
ion-nav,prop,root,Function | HTMLElement | ViewController | null | string | undefined,undefined,false,false
|
||||
ion-nav,prop,rootParams,undefined | { [key: string]: any; },undefined,false,false
|
||||
ion-nav,prop,rootParams,T | undefined,undefined,false,false
|
||||
ion-nav,prop,swipeGesture,boolean | undefined,undefined,false,false
|
||||
ion-nav,method,canGoBack,canGoBack(view?: ViewController) => Promise<boolean>
|
||||
ion-nav,method,getActive,getActive() => Promise<ViewController | undefined>
|
||||
@@ -1230,7 +1249,7 @@ ion-nav,event,ionNavWillChange,void,false
|
||||
|
||||
ion-nav-link,none
|
||||
ion-nav-link,prop,component,Function | HTMLElement | ViewController | null | string | undefined,undefined,false,false
|
||||
ion-nav-link,prop,componentProps,undefined | { [key: string]: any; },undefined,false,false
|
||||
ion-nav-link,prop,componentProps,T | undefined,undefined,false,false
|
||||
ion-nav-link,prop,routerAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
|
||||
ion-nav-link,prop,routerDirection,"back" | "forward" | "root",'forward',false,false
|
||||
|
||||
@@ -1323,7 +1342,7 @@ ion-popover,prop,animated,boolean,true,false,false
|
||||
ion-popover,prop,arrow,boolean,true,false,false
|
||||
ion-popover,prop,backdropDismiss,boolean,true,false,false
|
||||
ion-popover,prop,component,Function | HTMLElement | null | string | undefined,undefined,false,false
|
||||
ion-popover,prop,componentProps,undefined | { [key: string]: any; },undefined,false,false
|
||||
ion-popover,prop,componentProps,T | undefined,undefined,false,false
|
||||
ion-popover,prop,dismissOnSelect,boolean,false,false,false
|
||||
ion-popover,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
|
||||
ion-popover,prop,event,any,undefined,false,false
|
||||
@@ -1472,11 +1491,28 @@ ion-range,css-prop,--pin-background,ios
|
||||
ion-range,css-prop,--pin-background,md
|
||||
ion-range,css-prop,--pin-color,ios
|
||||
ion-range,css-prop,--pin-color,md
|
||||
ion-range,part,activated
|
||||
ion-range,part,bar
|
||||
ion-range,part,bar-active
|
||||
ion-range,part,focused
|
||||
ion-range,part,hover
|
||||
ion-range,part,knob
|
||||
ion-range,part,knob-a
|
||||
ion-range,part,knob-b
|
||||
ion-range,part,knob-handle
|
||||
ion-range,part,knob-handle-a
|
||||
ion-range,part,knob-handle-b
|
||||
ion-range,part,knob-handle-lower
|
||||
ion-range,part,knob-handle-upper
|
||||
ion-range,part,knob-lower
|
||||
ion-range,part,knob-upper
|
||||
ion-range,part,label
|
||||
ion-range,part,pin
|
||||
ion-range,part,pin-a
|
||||
ion-range,part,pin-b
|
||||
ion-range,part,pin-lower
|
||||
ion-range,part,pin-upper
|
||||
ion-range,part,pressed
|
||||
ion-range,part,tick
|
||||
ion-range,part,tick-active
|
||||
|
||||
@@ -1492,6 +1528,8 @@ ion-refresher,method,cancel,cancel() => Promise<void>
|
||||
ion-refresher,method,complete,complete() => Promise<void>
|
||||
ion-refresher,method,getProgress,getProgress() => Promise<number>
|
||||
ion-refresher,event,ionPull,void,true
|
||||
ion-refresher,event,ionPullEnd,RefresherPullEndEventDetail,true
|
||||
ion-refresher,event,ionPullStart,void,true
|
||||
ion-refresher,event,ionRefresh,RefresherEventDetail,true
|
||||
ion-refresher,event,ionStart,void,true
|
||||
|
||||
@@ -1557,7 +1595,7 @@ ion-row,shadow
|
||||
ion-searchbar,scoped
|
||||
ion-searchbar,prop,animated,boolean,false,false,false
|
||||
ion-searchbar,prop,autocapitalize,string,'off',false,false
|
||||
ion-searchbar,prop,autocomplete,"name" | "email" | "tel" | "url" | "on" | "off" | "honorific-prefix" | "given-name" | "additional-name" | "family-name" | "honorific-suffix" | "nickname" | "username" | "new-password" | "current-password" | "one-time-code" | "organization-title" | "organization" | "street-address" | "address-line1" | "address-line2" | "address-line3" | "address-level4" | "address-level3" | "address-level2" | "address-level1" | "country" | "country-name" | "postal-code" | "cc-name" | "cc-given-name" | "cc-additional-name" | "cc-family-name" | "cc-number" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-csc" | "cc-type" | "transaction-currency" | "transaction-amount" | "language" | "bday" | "bday-day" | "bday-month" | "bday-year" | "sex" | "tel-country-code" | "tel-national" | "tel-area-code" | "tel-local" | "tel-extension" | "impp" | "photo",'off',false,false
|
||||
ion-searchbar,prop,autocomplete,"additional-name" | "address-level1" | "address-level2" | "address-level3" | "address-level4" | "address-line1" | "address-line2" | "address-line3" | "bday" | "bday-day" | "bday-month" | "bday-year" | "cc-additional-name" | "cc-csc" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-family-name" | "cc-given-name" | "cc-name" | "cc-number" | "cc-type" | "country" | "country-name" | "current-password" | "email" | "family-name" | "given-name" | "honorific-prefix" | "honorific-suffix" | "impp" | "language" | "name" | "new-password" | "nickname" | "off" | "on" | "one-time-code" | "organization" | "organization-title" | "photo" | "postal-code" | "sex" | "street-address" | "tel" | "tel-area-code" | "tel-country-code" | "tel-extension" | "tel-local" | "tel-national" | "transaction-amount" | "transaction-currency" | "url" | "username",'off',false,false
|
||||
ion-searchbar,prop,autocorrect,"off" | "on",'off',false,false
|
||||
ion-searchbar,prop,cancelButtonIcon,string,config.get('backButtonIcon', arrowBackSharp) as string,false,false
|
||||
ion-searchbar,prop,cancelButtonText,string,'Cancel',false,false
|
||||
@@ -1692,6 +1730,7 @@ ion-segment-content,shadow
|
||||
|
||||
ion-segment-view,shadow
|
||||
ion-segment-view,prop,disabled,boolean,false,false,false
|
||||
ion-segment-view,prop,swipeGesture,boolean,true,false,false
|
||||
ion-segment-view,event,ionSegmentViewScroll,SegmentViewScrollEvent,true
|
||||
|
||||
ion-select,shadow
|
||||
@@ -1756,16 +1795,20 @@ ion-select,css-prop,--placeholder-opacity,ios
|
||||
ion-select,css-prop,--placeholder-opacity,md
|
||||
ion-select,css-prop,--ripple-color,ios
|
||||
ion-select,css-prop,--ripple-color,md
|
||||
ion-select,part,bottom
|
||||
ion-select,part,container
|
||||
ion-select,part,error-text
|
||||
ion-select,part,helper-text
|
||||
ion-select,part,icon
|
||||
ion-select,part,inner
|
||||
ion-select,part,label
|
||||
ion-select,part,placeholder
|
||||
ion-select,part,supporting-text
|
||||
ion-select,part,text
|
||||
ion-select,part,wrapper
|
||||
|
||||
ion-select-modal,scoped
|
||||
ion-select-modal,prop,cancelText,string,'Close',false,false
|
||||
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
|
||||
@@ -1873,7 +1916,7 @@ ion-textarea,prop,cols,number | undefined,undefined,false,true
|
||||
ion-textarea,prop,counter,boolean,false,false,false
|
||||
ion-textarea,prop,counterFormatter,((inputLength: number, maxLength: number) => string) | undefined,undefined,false,false
|
||||
ion-textarea,prop,debounce,number | undefined,undefined,false,false
|
||||
ion-textarea,prop,disabled,boolean,false,false,false
|
||||
ion-textarea,prop,disabled,boolean,false,false,true
|
||||
ion-textarea,prop,enterkeyhint,"done" | "enter" | "go" | "next" | "previous" | "search" | "send" | undefined,undefined,false,false
|
||||
ion-textarea,prop,errorText,string | undefined,undefined,false,false
|
||||
ion-textarea,prop,fill,"outline" | "solid" | undefined,undefined,false,false
|
||||
@@ -1886,7 +1929,7 @@ ion-textarea,prop,minlength,number | undefined,undefined,false,false
|
||||
ion-textarea,prop,mode,"ios" | "md",undefined,false,false
|
||||
ion-textarea,prop,name,string,this.inputId,false,false
|
||||
ion-textarea,prop,placeholder,string | undefined,undefined,false,false
|
||||
ion-textarea,prop,readonly,boolean,false,false,false
|
||||
ion-textarea,prop,readonly,boolean,false,false,true
|
||||
ion-textarea,prop,required,boolean,false,false,false
|
||||
ion-textarea,prop,rows,number | undefined,undefined,false,false
|
||||
ion-textarea,prop,shape,"round" | undefined,undefined,false,false
|
||||
@@ -2016,9 +2059,11 @@ ion-toast,css-prop,--width,md
|
||||
ion-toast,part,button
|
||||
ion-toast,part,button cancel
|
||||
ion-toast,part,container
|
||||
ion-toast,part,content
|
||||
ion-toast,part,header
|
||||
ion-toast,part,icon
|
||||
ion-toast,part,message
|
||||
ion-toast,part,wrapper
|
||||
|
||||
ion-toggle,shadow
|
||||
ion-toggle,prop,alignment,"center" | "start" | undefined,undefined,false,false
|
||||
|
||||
16
core/package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.18",
|
||||
"version": "8.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.18",
|
||||
"version": "8.8.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.38.0",
|
||||
"@stencil/core": "4.43.0",
|
||||
"ionicons": "^8.0.13",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
@@ -627,9 +627,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.1.0.tgz",
|
||||
"integrity": "sha512-UfMBMWc1v7J+14AhH03QmeNwV3HZx3qnOWhpwnHfzALEwAwlV/itQOQqcasMQYhOHWL0tiymc5ByaLTn7KKQxw==",
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.2.0.tgz",
|
||||
"integrity": "sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1785,7 +1785,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@stencil/core": {
|
||||
"version": "4.38.0",
|
||||
"version": "4.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.0.tgz",
|
||||
"integrity": "sha512-6Uj2Z3lzLuufYAE7asZ6NLKgSwsB9uxl84Eh34PASnUjfj32GkrP4DtKK7fNeh1WFGGyffsTDka3gwtl+4reUg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"stencil": "bin/stencil"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.18",
|
||||
"version": "8.8.1",
|
||||
"description": "Base components for Ionic",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -34,7 +34,7 @@
|
||||
"loader/"
|
||||
],
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.38.0",
|
||||
"@stencil/core": "4.43.0",
|
||||
"ionicons": "^8.0.13",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,9 @@ expect.extend({
|
||||
throw new Error('expected toHaveShadowPart to be called on an element with a shadow root');
|
||||
}
|
||||
|
||||
const shadowPart = received.shadowRoot.querySelector(`[part="${part}"]`);
|
||||
// Use attribute selector with ~= to match space-separated part values
|
||||
// e.g., [part~="knob"] matches elements with part="knob" or part="knob knob-a"
|
||||
const shadowPart = received.shadowRoot.querySelector(`[part~="${part}"]`);
|
||||
const pass = shadowPart !== null;
|
||||
|
||||
const message = `expected ${received.tagName.toLowerCase()} to have shadow part "${part}"`;
|
||||
|
||||
1177
core/src/components.d.ts
vendored
@@ -514,6 +514,7 @@ export class Accordion implements ComponentInterface {
|
||||
|
||||
'accordion-animated': this.shouldAnimate(),
|
||||
}}
|
||||
tabindex={disabled ? '-1' : undefined}
|
||||
>
|
||||
<div
|
||||
onClick={() => this.toggleExpanded()}
|
||||
|
||||
@@ -127,6 +127,8 @@ export class Checkbox implements ComponentInterface {
|
||||
*/
|
||||
@State() isInvalid = false;
|
||||
|
||||
@State() private hasLabelContent = false;
|
||||
|
||||
@State() private hintTextId?: string;
|
||||
|
||||
/**
|
||||
@@ -265,6 +267,10 @@ export class Checkbox implements ComponentInterface {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onSlotChange = () => {
|
||||
this.hasLabelContent = this.el.textContent !== '';
|
||||
};
|
||||
|
||||
private getHintTextId(): string | undefined {
|
||||
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
||||
|
||||
@@ -326,7 +332,6 @@ export class Checkbox implements ComponentInterface {
|
||||
} = this;
|
||||
const mode = getIonMode(this);
|
||||
const path = getSVGPath(mode, indeterminate);
|
||||
const hasLabelContent = el.textContent !== '';
|
||||
|
||||
renderHiddenInput(true, el, name, checked ? value : '', disabled);
|
||||
|
||||
@@ -338,7 +343,7 @@ export class Checkbox implements ComponentInterface {
|
||||
aria-checked={indeterminate ? 'mixed' : `${checked}`}
|
||||
aria-describedby={this.hintTextId}
|
||||
aria-invalid={this.isInvalid ? 'true' : undefined}
|
||||
aria-labelledby={hasLabelContent ? this.inputLabelId : null}
|
||||
aria-labelledby={this.hasLabelContent ? this.inputLabelId : null}
|
||||
aria-label={inheritedAttributes['aria-label'] || null}
|
||||
aria-disabled={disabled ? 'true' : null}
|
||||
aria-required={required ? 'true' : undefined}
|
||||
@@ -376,13 +381,13 @@ export class Checkbox implements ComponentInterface {
|
||||
<div
|
||||
class={{
|
||||
'label-text-wrapper': true,
|
||||
'label-text-wrapper-hidden': !hasLabelContent,
|
||||
'label-text-wrapper-hidden': !this.hasLabelContent,
|
||||
}}
|
||||
part="label"
|
||||
id={this.inputLabelId}
|
||||
onClick={this.onDivLabelClick}
|
||||
>
|
||||
<slot></slot>
|
||||
<slot onSlotchange={this.onSlotChange}></slot>
|
||||
{this.renderHintText()}
|
||||
</div>
|
||||
<div class="native-wrapper">
|
||||
|
||||
@@ -467,6 +467,7 @@ export class Content implements ComponentInterface {
|
||||
role={isMainContent ? 'main' : undefined}
|
||||
class={createColorClasses(this.color, {
|
||||
[mode]: true,
|
||||
'content-fullscreen': this.fullscreen,
|
||||
'content-sizing': hostContext('ion-popover', this.el),
|
||||
overscroll: forceOverscroll,
|
||||
[`content-${rtl}`]: true,
|
||||
|
||||
@@ -13,5 +13,38 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
|
||||
|
||||
await expect(page).toHaveScreenshot(screenshot(`content-fullscreen`));
|
||||
});
|
||||
|
||||
/**
|
||||
* The content-fullscreen class is added when fullscreen is true. The
|
||||
* fullscreen attribute is not reflected in Angular, Vue, or React, so
|
||||
* the class is needed for users to create custom themes.
|
||||
*/
|
||||
test('should have content-fullscreen class when fullscreen is true', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-content fullscreen>
|
||||
<p>Hello</p>
|
||||
</ion-content>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const content = page.locator('ion-content');
|
||||
await expect(content).toHaveClass(/content-fullscreen/);
|
||||
});
|
||||
|
||||
test('should not have content-fullscreen class when fullscreen is false', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-content>
|
||||
<p>Hello</p>
|
||||
</ion-content>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const content = page.locator('ion-content');
|
||||
await expect(content).not.toHaveClass(/content-fullscreen/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './ut
|
||||
* @slot buttons - The buttons in the datetime.
|
||||
* @slot time-label - The label for the time selector in the datetime.
|
||||
*
|
||||
* @part wheel - The wheel container when using a wheel style layout, or in the month/year picker when using a grid style layout.
|
||||
* @part wheel-item - The individual items when using a wheel style layout, or in the
|
||||
* month/year picker when using a grid style layout.
|
||||
* @part wheel-item active - The currently selected wheel-item.
|
||||
@@ -87,14 +88,23 @@ import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './ut
|
||||
* layout with `presentation="date-time"` or `"time-date"`.
|
||||
* @part time-button active - The time picker button when the picker is open.
|
||||
*
|
||||
* @part calendar-header - The calendar header manages the date navigation controls (month/year picker and previous/next buttons) and the days of the week when using a grid style layout.
|
||||
* @part month-year-button - The button that opens the month/year picker when
|
||||
* using a grid style layout.
|
||||
* @part navigation-button - The buttons used to navigate to the next or previous month when using a grid style layout.
|
||||
* @part previous-button - The button used to navigate to the previous month when using a grid style layout.
|
||||
* @part next-button - The button used to navigate to the next month when using a grid style layout.
|
||||
* @part calendar-days-of-week - The container for the day-of-the-week header (both weekdays and weekends) when using a grid style layout.
|
||||
*
|
||||
* @part calendar-day - The individual buttons that display a day inside of the datetime
|
||||
* calendar.
|
||||
* @part calendar-day active - The currently selected calendar day.
|
||||
* @part calendar-day today - The calendar day that contains the current day.
|
||||
* @part calendar-day disabled - The calendar day that is disabled.
|
||||
*
|
||||
* @part datetime-header - The datetime header contains the content for the `title` slot and the selected date.
|
||||
* @part datetime-title - The element that contains the `title` slot content.
|
||||
* @part datetime-selected-date - The element that contains the selected date.
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-datetime',
|
||||
@@ -1728,6 +1738,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<ion-picker-column
|
||||
part={WHEEL_PART}
|
||||
aria-label="Select a date"
|
||||
class="date-column"
|
||||
color={this.color}
|
||||
@@ -1848,6 +1859,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<ion-picker-column
|
||||
part={WHEEL_PART}
|
||||
aria-label="Select a day"
|
||||
class="day-column"
|
||||
color={this.color}
|
||||
@@ -1892,6 +1904,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<ion-picker-column
|
||||
part={WHEEL_PART}
|
||||
aria-label="Select a month"
|
||||
class="month-column"
|
||||
color={this.color}
|
||||
@@ -1935,6 +1948,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<ion-picker-column
|
||||
part={WHEEL_PART}
|
||||
aria-label="Select a year"
|
||||
class="year-column"
|
||||
color={this.color}
|
||||
@@ -2009,6 +2023,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<ion-picker-column
|
||||
part={WHEEL_PART}
|
||||
aria-label="Select an hour"
|
||||
color={this.color}
|
||||
disabled={disabled}
|
||||
@@ -2049,6 +2064,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<ion-picker-column
|
||||
part={WHEEL_PART}
|
||||
aria-label="Select a minute"
|
||||
color={this.color}
|
||||
disabled={disabled}
|
||||
@@ -2092,6 +2108,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<ion-picker-column
|
||||
part={WHEEL_PART}
|
||||
aria-label="Select a day period"
|
||||
style={isDayPeriodRTL ? { order: '-1' } : {}}
|
||||
color={this.color}
|
||||
@@ -2162,7 +2179,7 @@ export class Datetime implements ComponentInterface {
|
||||
const hostDir = this.el.getAttribute('dir') || undefined;
|
||||
|
||||
return (
|
||||
<div class="calendar-header">
|
||||
<div class="calendar-header" part="calendar-header">
|
||||
<div class="calendar-action-buttons">
|
||||
<div class="calendar-month-year">
|
||||
<button
|
||||
@@ -2191,7 +2208,12 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
<div class="calendar-next-prev">
|
||||
<ion-buttons>
|
||||
<ion-button aria-label="Previous month" disabled={prevMonthDisabled} onClick={() => this.prevMonth()}>
|
||||
<ion-button
|
||||
aria-label="Previous month"
|
||||
disabled={prevMonthDisabled}
|
||||
onClick={() => this.prevMonth()}
|
||||
part="navigation-button previous-button"
|
||||
>
|
||||
<ion-icon
|
||||
dir={hostDir}
|
||||
aria-hidden="true"
|
||||
@@ -2201,7 +2223,12 @@ export class Datetime implements ComponentInterface {
|
||||
flipRtl
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button aria-label="Next month" disabled={nextMonthDisabled} onClick={() => this.nextMonth()}>
|
||||
<ion-button
|
||||
aria-label="Next month"
|
||||
disabled={nextMonthDisabled}
|
||||
onClick={() => this.nextMonth()}
|
||||
part="navigation-button next-button"
|
||||
>
|
||||
<ion-icon
|
||||
dir={hostDir}
|
||||
aria-hidden="true"
|
||||
@@ -2214,7 +2241,7 @@ export class Datetime implements ComponentInterface {
|
||||
</ion-buttons>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-days-of-week" aria-hidden="true">
|
||||
<div class="calendar-days-of-week" aria-hidden="true" part="calendar-days-of-week">
|
||||
{getDaysOfWeek(this.locale, mode, this.firstDayOfWeek % 7).map((d) => {
|
||||
return <div class="day-of-week">{d}</div>;
|
||||
})}
|
||||
@@ -2567,11 +2594,15 @@ export class Datetime implements ComponentInterface {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="datetime-header">
|
||||
<div class="datetime-title">
|
||||
<div class="datetime-header" part="datetime-header">
|
||||
<div class="datetime-title" part="datetime-title">
|
||||
<slot name="title">Select Date</slot>
|
||||
</div>
|
||||
{showExpandedHeader && <div class="datetime-selected-date">{this.getHeaderSelectedDateText()}</div>}
|
||||
{showExpandedHeader && (
|
||||
<div class="datetime-selected-date" part="datetime-selected-date">
|
||||
{this.getHeaderSelectedDateText()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2720,5 +2751,6 @@ export class Datetime implements ComponentInterface {
|
||||
let datetimeIds = 0;
|
||||
const CANCEL_ROLE = 'datetime-cancel';
|
||||
const CONFIRM_ROLE = 'datetime-confirm';
|
||||
const WHEEL_PART = 'wheel';
|
||||
const WHEEL_ITEM_PART = 'wheel-item';
|
||||
const WHEEL_ITEM_ACTIVE_PART = `active`;
|
||||
|
||||
@@ -42,6 +42,324 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
await expect(datetime).toHaveScreenshot(screenshot(`datetime-custom-calendar-days`));
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('CSS shadow parts'), () => {
|
||||
test('should be able to customize wheel part within the wheel style', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30420',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-datetime::part(wheel) {
|
||||
background-color: red;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime
|
||||
prefer-wheel="true"
|
||||
value="2020-03-14T14:23:00.000Z"
|
||||
></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const pickerColumn = datetime.locator('ion-picker-column').first();
|
||||
|
||||
const backgroundColor = await pickerColumn.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(backgroundColor).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize wheel part within the month/year picker', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30420',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-datetime::part(wheel) {
|
||||
background-color: orange;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime
|
||||
value="2020-03-14T14:23:00.000Z"
|
||||
></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const monthYearButton = datetime.locator('.calendar-month-year-toggle');
|
||||
|
||||
await monthYearButton.click();
|
||||
|
||||
const pickerColumn = datetime.locator('ion-picker-column').first();
|
||||
|
||||
const backgroundColor = await pickerColumn.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(backgroundColor).toBe('rgb(255, 165, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize wheel part within the time picker', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30420',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-picker-column {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime
|
||||
value="2020-03-14T14:23:00.000Z"
|
||||
></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const timeButton = datetime.locator('.time-body');
|
||||
|
||||
await timeButton.click();
|
||||
|
||||
const pickerColumn = page.locator('ion-picker-column').first();
|
||||
|
||||
const backgroundColor = await pickerColumn.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(backgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize wheel part when focused', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30420',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-datetime::part(wheel):focus {
|
||||
background-color: blue;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime
|
||||
prefer-wheel="true"
|
||||
value="2020-03-14T14:23:00.000Z"
|
||||
></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const pickerColumn = datetime.locator('ion-picker-column').first();
|
||||
|
||||
await pickerColumn.click({ position: { x: 10, y: 10 } });
|
||||
|
||||
const backgroundColor = await pickerColumn.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(backgroundColor).toBe('rgb(0, 0, 255)');
|
||||
});
|
||||
|
||||
test('should be able to customize datetime header parts', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30083',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-datetime::part(datetime-header) {
|
||||
background-color: orange;
|
||||
}
|
||||
|
||||
ion-datetime::part(datetime-title) {
|
||||
background-color: pink;
|
||||
}
|
||||
|
||||
ion-datetime::part(datetime-selected-date) {
|
||||
background-color: violet;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime value="2020-03-14T14:23:00.000Z">
|
||||
<span slot="title">Select Date</span>
|
||||
</ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const header = datetime.locator('.datetime-header');
|
||||
const title = datetime.locator('.datetime-title');
|
||||
const selectedDate = datetime.locator('.datetime-selected-date');
|
||||
|
||||
const headerBackgroundColor = await header.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
const titleBackgroundColor = await title.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
const selectedDateBackgroundColor = await selectedDate.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(headerBackgroundColor).toBe('rgb(255, 165, 0)');
|
||||
expect(titleBackgroundColor).toBe('rgb(255, 192, 203)');
|
||||
expect(selectedDateBackgroundColor).toBe('rgb(238, 130, 238)');
|
||||
});
|
||||
|
||||
test('should be able to customize calendar header part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-datetime::part(calendar-header) {
|
||||
background-color: orange;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime value="2020-03-14T14:23:00.000Z"></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const header = datetime.locator('.calendar-header');
|
||||
|
||||
const backgroundColor = await header.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(backgroundColor).toBe('rgb(255, 165, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize month/year picker part', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/26596',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-datetime::part(month-year-button) {
|
||||
background-color: lightblue;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime value="2020-03-14T14:23:00.000Z"></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const monthYearButton = datetime.locator('.calendar-month-year-toggle');
|
||||
|
||||
const backgroundColor = await monthYearButton.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(backgroundColor).toBe('rgb(173, 216, 230)');
|
||||
});
|
||||
|
||||
test('should be able to customize navigation button parts', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30830',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-datetime::part(navigation-button) {
|
||||
background-color: firebrick;
|
||||
}
|
||||
|
||||
ion-datetime::part(previous-button) {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
ion-datetime::part(next-button) {
|
||||
color: green;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime value="2020-03-14T14:23:00.000Z"></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const prevButton = datetime.locator('.calendar-next-prev ion-button').first();
|
||||
const nextButton = datetime.locator('.calendar-next-prev ion-button').last();
|
||||
|
||||
const prevBackgroundColor = await prevButton.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
const prevColor = await prevButton.evaluate((el) => {
|
||||
return window.getComputedStyle(el).color;
|
||||
});
|
||||
|
||||
const nextBackgroundColor = await nextButton.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
const nextColor = await nextButton.evaluate((el) => {
|
||||
return window.getComputedStyle(el).color;
|
||||
});
|
||||
|
||||
// Verify the navigation-button part applies the styles
|
||||
expect(prevBackgroundColor).toBe('rgb(178, 34, 34)');
|
||||
expect(nextBackgroundColor).toBe('rgb(178, 34, 34)');
|
||||
// Verify the previous-button part applies the styles
|
||||
expect(prevColor).toBe('rgb(0, 0, 255)');
|
||||
// Verify the next-button part applies the styles
|
||||
expect(nextColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize days of the week part', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30830',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-datetime::part(calendar-days-of-week) {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
<ion-datetime value="2020-03-14T14:23:00.000Z"></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
const daysOfWeek = datetime.locator('.calendar-days-of-week');
|
||||
|
||||
const backgroundColor = await daysOfWeek.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(backgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,13 @@
|
||||
color: rgb(128, 30, 171);
|
||||
}
|
||||
|
||||
/* Targets the month/year picker and the wheel style datetime */
|
||||
.custom-grid-wheel::part(wheel):focus,
|
||||
/* Targets the time picker */
|
||||
ion-picker-column:focus {
|
||||
background-color: rgba(138, 238, 69, 0.37);
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom Datetime Day Parts
|
||||
* -------------------------------------------
|
||||
@@ -127,6 +134,46 @@
|
||||
background-color: rgb(154 209 98 / 0.2);
|
||||
color: #9ad162;
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom Datetime Header Parts
|
||||
* -------------------------------------------
|
||||
*/
|
||||
|
||||
#custom-grid::part(calendar-header),
|
||||
#custom-title::part(datetime-header) {
|
||||
background-color: orange;
|
||||
}
|
||||
|
||||
#custom-grid::part(month-year-button) {
|
||||
background-color: lightblue;
|
||||
color: rgb(128, 30, 171);
|
||||
}
|
||||
|
||||
#custom-grid::part(navigation-button) {
|
||||
background-color: firebrick;
|
||||
}
|
||||
|
||||
#custom-grid::part(previous-button) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#custom-grid::part(next-button) {
|
||||
color: black;
|
||||
}
|
||||
|
||||
#custom-grid::part(calendar-days-of-week) {
|
||||
background-color: #9ad162;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#custom-title::part(datetime-title) {
|
||||
background-color: pink;
|
||||
}
|
||||
|
||||
#custom-title::part(datetime-selected-date) {
|
||||
background-color: violet;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -156,6 +203,11 @@
|
||||
<h2>Grid Style</h2>
|
||||
<ion-datetime id="custom-calendar-days" value="2023-06-15" presentation="date"></ion-datetime>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<ion-datetime id="custom-title" presentation="date">
|
||||
<span slot="title">Select Date</span>
|
||||
</ion-datetime>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { Color } from '../../interface';
|
||||
* @slot - Content is placed between the named slots if provided without a slot.
|
||||
* @slot start - Content is placed to the left of the divider text in LTR, and to the right in RTL.
|
||||
* @slot end - Content is placed to the right of the divider text in LTR, and to the left in RTL.
|
||||
*
|
||||
* @part inner - The inner wrapper element that arranges the divider content.
|
||||
* @part container - The wrapper element that contains the default slot.
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-item-divider',
|
||||
@@ -50,8 +53,8 @@ export class ItemDivider implements ComponentInterface {
|
||||
})}
|
||||
>
|
||||
<slot name="start"></slot>
|
||||
<div class="item-divider-inner">
|
||||
<div class="item-divider-wrapper">
|
||||
<div class="item-divider-inner" part="inner">
|
||||
<div class="item-divider-wrapper" part="container">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<slot name="end"></slot>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('item-divider: custom'), () => {
|
||||
test.describe('CSS shadow parts', () => {
|
||||
test('should be able to customize inner part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item-divider::part(inner) {
|
||||
background-color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item-divider>Divider</ion-item-divider>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const divider = page.locator('ion-item-divider');
|
||||
const backgroundColor = await divider.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const inner = shadowRoot?.querySelector('.item-divider-inner');
|
||||
return inner ? window.getComputedStyle(inner).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize container part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item-divider::part(container) {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item-divider>Divider</ion-item-divider>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const divider = page.locator('ion-item-divider');
|
||||
const backgroundColor = await divider.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const container = shadowRoot?.querySelector('.item-divider-wrapper');
|
||||
return container ? window.getComputedStyle(container).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ import type { Color } from '../../interface';
|
||||
* @slot end - Content is placed to the right of the option text in LTR, and to the left in RTL.
|
||||
*
|
||||
* @part native - The native HTML button or anchor element that wraps all child elements.
|
||||
* @part inner - The inner wrapper element that arranges the option content.
|
||||
* @part container - The container element that wraps the start, icon-only, default, and end slots.
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-item-option',
|
||||
@@ -109,9 +111,9 @@ export class ItemOption implements ComponentInterface, AnchorInterface, ButtonIn
|
||||
})}
|
||||
>
|
||||
<TagType {...attrs} class="button-native" part="native" disabled={disabled}>
|
||||
<span class="button-inner">
|
||||
<span class="button-inner" part="inner">
|
||||
<slot name="top"></slot>
|
||||
<div class="horizontal-wrapper">
|
||||
<div class="horizontal-wrapper" part="container">
|
||||
<slot name="start"></slot>
|
||||
<slot name="icon-only"></slot>
|
||||
<slot></slot>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('item-option: custom'), () => {
|
||||
test.describe('CSS shadow parts', () => {
|
||||
test('should be able to customize native part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item-option::part(native) {
|
||||
background-color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item-option>Option</ion-item-option>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const itemOption = page.locator('ion-item-option');
|
||||
const backgroundColor = await itemOption.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const native = shadowRoot?.querySelector('.button-native');
|
||||
return native ? window.getComputedStyle(native).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize inner part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item-option::part(inner) {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item-option>Option</ion-item-option>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const itemOption = page.locator('ion-item-option');
|
||||
const backgroundColor = await itemOption.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const inner = shadowRoot?.querySelector('.button-inner');
|
||||
return inner ? window.getComputedStyle(inner).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize container part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item-option::part(container) {
|
||||
background-color: blue;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item-option>Option</ion-item-option>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const itemOption = page.locator('ion-item-option');
|
||||
const backgroundColor = await itemOption.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const container = shadowRoot?.querySelector('.horizontal-wrapper');
|
||||
return container ? window.getComputedStyle(container).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(0, 0, 255)');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ import type { RouterDirection } from '../router/utils/interface';
|
||||
* @slot end - Content is placed to the right of the item text in LTR, and to the left in RTL.
|
||||
*
|
||||
* @part native - The native HTML button, anchor or div element that wraps all child elements.
|
||||
* @part inner - The inner wrapper element that arranges the item content.
|
||||
* @part container - The wrapper element that contains the default slot.
|
||||
* @part detail-icon - The chevron icon for the item. Only applies when `detail="true"`.
|
||||
*/
|
||||
@Component({
|
||||
@@ -390,8 +392,8 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
{...clickFn}
|
||||
>
|
||||
<slot name="start" onSlotchange={this.updateInteractivityOnSlotChange}></slot>
|
||||
<div class="item-inner">
|
||||
<div class="input-wrapper">
|
||||
<div class="item-inner" part="inner">
|
||||
<div class="input-wrapper" part="container">
|
||||
<slot onSlotchange={this.updateInteractivityOnSlotChange}></slot>
|
||||
</div>
|
||||
<slot name="end" onSlotchange={this.updateInteractivityOnSlotChange}></slot>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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('item: CSS variables'), () => {
|
||||
test('should not have visual regressions', async ({ page }) => {
|
||||
await page.goto(`/src/components/item/test/css-variables`, config);
|
||||
|
||||
await page.setIonViewport();
|
||||
|
||||
await expect(page).toHaveScreenshot(screenshot(`item-css-vars-diff`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
174
core/src/components/item/test/custom/item.e2e.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('item: custom'), () => {
|
||||
test.describe('CSS shadow parts', () => {
|
||||
test('should be able to customize native part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item::part(native) {
|
||||
background-color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Item</ion-label>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
const backgroundColor = await item.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const native = shadowRoot?.querySelector('.item-native');
|
||||
return native ? window.getComputedStyle(native).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize inner part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item::part(inner) {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Item</ion-label>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
const backgroundColor = await item.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const inner = shadowRoot?.querySelector('.item-inner');
|
||||
return inner ? window.getComputedStyle(inner).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize container part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item::part(container) {
|
||||
background-color: blue;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Item</ion-label>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
const backgroundColor = await item.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const container = shadowRoot?.querySelector('.input-wrapper');
|
||||
return container ? window.getComputedStyle(container).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(0, 0, 255)');
|
||||
});
|
||||
|
||||
test('should be able to customize detail-icon part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item::part(detail-icon) {
|
||||
background-color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item detail="true">
|
||||
<ion-label>Item</ion-label>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
const backgroundColor = await item.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const detailIcon = shadowRoot?.querySelector('.item-detail-icon');
|
||||
return detailIcon ? window.getComputedStyle(detailIcon).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('CSS variables', () => {
|
||||
test('should be able to customize background using css variables', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item {
|
||||
--background: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Item</ion-label>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
const backgroundColor = await item.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const native = shadowRoot?.querySelector('.item-native');
|
||||
return native ? window.getComputedStyle(native).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize padding using css variables', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-item {
|
||||
--padding-top: 20px;
|
||||
--padding-bottom: 20px;
|
||||
--padding-start: 10px;
|
||||
--padding-end: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Item</ion-label>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
const paddingValues = await item.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const native = shadowRoot?.querySelector('.item-native');
|
||||
return {
|
||||
paddingTop: native ? window.getComputedStyle(native).paddingTop : '',
|
||||
paddingBottom: native ? window.getComputedStyle(native).paddingBottom : '',
|
||||
paddingStart: native ? window.getComputedStyle(native).paddingLeft : '',
|
||||
paddingEnd: native ? window.getComputedStyle(native).paddingRight : '',
|
||||
};
|
||||
});
|
||||
expect(paddingValues.paddingTop).toBe('20px');
|
||||
expect(paddingValues.paddingBottom).toBe('20px');
|
||||
expect(paddingValues.paddingStart).toBe('10px');
|
||||
expect(paddingValues.paddingEnd).toBe('10px');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import type { Color } from '../../interface';
|
||||
|
||||
/**
|
||||
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
|
||||
*
|
||||
* @part inner - The inner wrapper element that arranges the list header content.
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-list-header',
|
||||
@@ -40,7 +42,7 @@ export class ListHeader implements ComponentInterface {
|
||||
[`list-header-lines-${lines}`]: lines !== undefined,
|
||||
})}
|
||||
>
|
||||
<div class="list-header-inner">
|
||||
<div class="list-header-inner" part="inner">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</Host>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('list-header: custom'), () => {
|
||||
test.describe('CSS shadow parts', () => {
|
||||
test('should be able to customize inner part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-list-header::part(inner) {
|
||||
background-color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-list-header>Header</ion-list-header>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const header = page.locator('ion-list-header');
|
||||
const backgroundColor = await header.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const inner = shadowRoot?.querySelector('.list-header-inner');
|
||||
return inner ? window.getComputedStyle(inner).backgroundColor : '';
|
||||
});
|
||||
expect(backgroundColor).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { createGesture } from '@utils/gesture';
|
||||
import { clamp, getElementRoot, raf } from '@utils/helpers';
|
||||
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
|
||||
|
||||
import type { Animation } from '../../../interface';
|
||||
import type { Animation, ModalDragEventDetail } from '../../../interface';
|
||||
import type { GestureDetail } from '../../../utils/gesture';
|
||||
import { getBackdropValueForSheet } from '../utils';
|
||||
|
||||
@@ -52,7 +52,10 @@ export const createSheetGesture = (
|
||||
expandToScroll: boolean,
|
||||
getCurrentBreakpoint: () => number,
|
||||
onDismiss: () => void,
|
||||
onBreakpointChange: (breakpoint: number) => void
|
||||
onBreakpointChange: (breakpoint: number) => void,
|
||||
onDragStart: () => void,
|
||||
onDragMove: (detail: ModalDragEventDetail) => void,
|
||||
onDragEnd: (detail: ModalDragEventDetail) => void
|
||||
) => {
|
||||
// Defaults for the sheet swipe animation
|
||||
const defaultBackdrop = [
|
||||
@@ -347,6 +350,8 @@ export const createSheetGesture = (
|
||||
});
|
||||
|
||||
animation.progressStart(true, 1 - currentBreakpoint);
|
||||
|
||||
onDragStart();
|
||||
};
|
||||
|
||||
const onMove = (detail: GestureDetail) => {
|
||||
@@ -423,9 +428,31 @@ export const createSheetGesture = (
|
||||
|
||||
offset = clamp(0.0001, processedStep, maxStep);
|
||||
animation.progressStep(offset);
|
||||
|
||||
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);
|
||||
|
||||
const eventDetail: ModalDragEventDetail = {
|
||||
currentY: detail.currentY,
|
||||
deltaY: detail.deltaY,
|
||||
velocityY: detail.velocityY,
|
||||
progress: calculateProgress(detail.currentY),
|
||||
snapBreakpoint: snapBreakpoint,
|
||||
};
|
||||
|
||||
onDragMove(eventDetail);
|
||||
};
|
||||
|
||||
const onEnd = (detail: GestureDetail) => {
|
||||
const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);
|
||||
|
||||
const eventDetail: ModalDragEventDetail = {
|
||||
currentY: detail.currentY,
|
||||
deltaY: detail.deltaY,
|
||||
velocityY: detail.velocityY,
|
||||
progress: calculateProgress(detail.currentY),
|
||||
snapBreakpoint,
|
||||
};
|
||||
|
||||
/**
|
||||
* If expandToScroll is disabled, we should not allow the moveSheetToBreakpoint
|
||||
* function to be called if the user is trying to swipe content upwards and the content
|
||||
@@ -440,23 +467,13 @@ export const createSheetGesture = (
|
||||
* swap to moving on drag and if we don't swap back here then the footer will get stuck.
|
||||
*/
|
||||
swapFooterPosition('stationary');
|
||||
onDragEnd(eventDetail);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the gesture releases, we need to determine
|
||||
* the closest breakpoint to snap to.
|
||||
*/
|
||||
const velocity = detail.velocityY;
|
||||
const threshold = (detail.deltaY + velocity * 350) / height;
|
||||
|
||||
const diff = currentBreakpoint - threshold;
|
||||
const closest = breakpoints.reduce((a, b) => {
|
||||
return Math.abs(b - diff) < Math.abs(a - diff) ? b : a;
|
||||
});
|
||||
|
||||
moveSheetToBreakpoint({
|
||||
breakpoint: closest,
|
||||
breakpoint: snapBreakpoint,
|
||||
breakpointOffset: offset,
|
||||
canDismiss: canDismissBlocksGesture,
|
||||
|
||||
@@ -466,6 +483,8 @@ export const createSheetGesture = (
|
||||
*/
|
||||
animated: true,
|
||||
});
|
||||
|
||||
onDragEnd(eventDetail);
|
||||
};
|
||||
|
||||
const moveSheetToBreakpoint = (options: MoveSheetToBreakpointOptions) => {
|
||||
@@ -624,6 +643,112 @@ export const createSheetGesture = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the breakpoint based on the current deltaY.
|
||||
* This determines where the sheet should snap to when the user releases the
|
||||
* gesture.
|
||||
*
|
||||
* @param deltaY The change in Y position since the gesture started.
|
||||
* @returns The snap breakpoint value.
|
||||
*/
|
||||
const calculateSnapBreakpoint = (deltaY: number): number => {
|
||||
/**
|
||||
* Calculates the real-time vertical position of the modal.
|
||||
* We combine the wrapper's current bounding box position with the
|
||||
* gesture's deltaY to account for the physical movement during the drag.
|
||||
*/
|
||||
const currentY = wrapperEl.getBoundingClientRect().top + deltaY;
|
||||
/**
|
||||
* Convert that pixel position back into a 0 to 1 progress value.
|
||||
*/
|
||||
const currentProgress = calculateProgress(currentY);
|
||||
|
||||
/**
|
||||
* Find and return the defined breakpoint that is closest to the
|
||||
* current progress.
|
||||
*/
|
||||
const snapBreakpoint = breakpoints.reduce((a, b) => {
|
||||
return Math.abs(b - currentProgress) < Math.abs(a - currentProgress) ? b : a;
|
||||
});
|
||||
|
||||
return snapBreakpoint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the progress of the swipe gesture.
|
||||
*
|
||||
* The progress is a value between 0 and 1 that represents how far
|
||||
* the swipe has progressed towards closing the modal.
|
||||
*
|
||||
* A value closer to 1 means the modal is closer to being opened,
|
||||
* while a value closer to 0 means the modal is closer to being closed.
|
||||
*
|
||||
* @param currentY The current Y position of the gesture
|
||||
* @returns The progress of the sheet gesture
|
||||
*/
|
||||
const calculateProgress = (currentY: number): number => {
|
||||
const minBreakpoint = breakpoints[0];
|
||||
const maxBreakpoint = breakpoints[breakpoints.length - 1];
|
||||
|
||||
/**
|
||||
* The lowest point the sheet can be dragged to aka the point at which
|
||||
* the sheet is fully closed.
|
||||
*/
|
||||
const maxY = convertBreakpointToY(minBreakpoint);
|
||||
/**
|
||||
* The highest point the sheet can be dragged to aka the point at which
|
||||
* the sheet is fully open.
|
||||
*/
|
||||
const minY = convertBreakpointToY(maxBreakpoint);
|
||||
// The total distance between the fully open and fully closed positions.
|
||||
const totalDistance = maxY - minY;
|
||||
// The distance from the current position to the fully closed position.
|
||||
const distanceFromBottom = maxY - currentY;
|
||||
/**
|
||||
* The progress represents how far the sheet is from the bottom relative
|
||||
* to the total distance. When the user starts swiping up, the progress
|
||||
* should be close to 1, and when the user has swiped all the way down,
|
||||
* the progress should be close to 0.
|
||||
*/
|
||||
const progress = distanceFromBottom / totalDistance;
|
||||
// Round to the nearest thousandth to avoid returning very small decimal
|
||||
const roundedProgress = Math.round(progress * 1000) / 1000;
|
||||
|
||||
return Math.max(0, Math.min(1, roundedProgress));
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a breakpoint value (0 to 1) into a pixel Y coordinate
|
||||
* on the screen.
|
||||
*
|
||||
* @param breakpoint The breakpoint value (e.g., 0.5 for half-open)
|
||||
* @returns The pixel Y coordinate on the screen
|
||||
*/
|
||||
const convertBreakpointToY = (breakpoint: number): number => {
|
||||
const rect = baseEl.getBoundingClientRect();
|
||||
const modalHeight = rect.height;
|
||||
// The bottom of the screen.
|
||||
const viewportBottom = window.innerHeight;
|
||||
/**
|
||||
* The active height is how much of the modal is actually showing
|
||||
* on the screen for this specific breakpoint.
|
||||
*/
|
||||
const activeHeight = modalHeight * breakpoint;
|
||||
|
||||
/**
|
||||
* To find the Y coordinate, start at the bottom of the screen
|
||||
* and move up by the active height of the modal.
|
||||
*
|
||||
* A breakpoint of 1.0 means the active height is the full modal height
|
||||
* (fully open). A breakpoint of 0.0 means the active height is 0
|
||||
* (fully closed).
|
||||
*
|
||||
* Since screen Y coordinates get smaller as you go up, we subtract the
|
||||
* active height from the viewport bottom.
|
||||
*/
|
||||
return viewportBottom - activeHeight;
|
||||
};
|
||||
|
||||
const gesture = createGesture({
|
||||
el: wrapperEl,
|
||||
gestureName: 'modalSheet',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createGesture } from '@utils/gesture';
|
||||
import { clamp, getElementRoot } from '@utils/helpers';
|
||||
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';
|
||||
|
||||
import type { Animation } from '../../../interface';
|
||||
import type { Animation, ModalDragEventDetail } from '../../../interface';
|
||||
import type { GestureDetail } from '../../../utils/gesture';
|
||||
import type { Style as StatusBarStyle } from '../../../utils/native/status-bar';
|
||||
import { setCardStatusBarDark, setCardStatusBarDefault } from '../utils';
|
||||
@@ -20,7 +20,10 @@ export const createSwipeToCloseGesture = (
|
||||
el: HTMLIonModalElement,
|
||||
animation: Animation,
|
||||
statusBarStyle: StatusBarStyle,
|
||||
onDismiss: () => void
|
||||
onDismiss: () => void,
|
||||
onDragStart: () => void,
|
||||
onDragMove: (detail: ModalDragEventDetail) => void,
|
||||
onDragEnd: (detail: ModalDragEventDetail) => void
|
||||
) => {
|
||||
/**
|
||||
* The step value at which a card modal
|
||||
@@ -142,6 +145,8 @@ export const createSwipeToCloseGesture = (
|
||||
}
|
||||
|
||||
animation.progressStart(true, isOpen ? 1 : 0);
|
||||
|
||||
onDragStart();
|
||||
};
|
||||
|
||||
const onMove = (detail: GestureDetail) => {
|
||||
@@ -220,6 +225,15 @@ export const createSwipeToCloseGesture = (
|
||||
}
|
||||
|
||||
lastStep = clampedStep;
|
||||
|
||||
const eventDetail: ModalDragEventDetail = {
|
||||
currentY: detail.currentY,
|
||||
deltaY: detail.deltaY,
|
||||
velocityY: detail.velocityY,
|
||||
progress: calculateProgress(el, detail.deltaY),
|
||||
};
|
||||
|
||||
onDragMove(eventDetail);
|
||||
};
|
||||
|
||||
const onEnd = (detail: GestureDetail) => {
|
||||
@@ -288,6 +302,15 @@ export const createSwipeToCloseGesture = (
|
||||
} else if (shouldComplete) {
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
const eventDetail: ModalDragEventDetail = {
|
||||
currentY: detail.currentY,
|
||||
deltaY: detail.deltaY,
|
||||
velocityY: detail.velocityY,
|
||||
progress: calculateProgress(el, detail.deltaY),
|
||||
};
|
||||
|
||||
onDragEnd(eventDetail);
|
||||
};
|
||||
|
||||
const gesture = createGesture({
|
||||
@@ -307,3 +330,43 @@ export const createSwipeToCloseGesture = (
|
||||
const computeDuration = (remaining: number, velocity: number) => {
|
||||
return clamp(400, remaining / Math.abs(velocity * 1.1), 500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the progress of the swipe gesture.
|
||||
*
|
||||
* The progress is a value between 0 and 1 that represents how far
|
||||
* the swipe has progressed towards closing the modal.
|
||||
*
|
||||
* A value closer to 1 means the modal is closer to being opened,
|
||||
* while a value closer to 0 means the modal is closer to being closed.
|
||||
*
|
||||
* @param el The modal
|
||||
* @param deltaY The change in Y position (positive when dragging down, negative when dragging up)
|
||||
* @returns The progress of the swipe gesture
|
||||
*/
|
||||
const calculateProgress = (el: HTMLIonModalElement, deltaY: number): number => {
|
||||
const windowHeight = window.innerHeight;
|
||||
// Position when fully open
|
||||
const modalTop = el.getBoundingClientRect().top;
|
||||
/**
|
||||
* The distance between the top of the modal and the bottom of the screen
|
||||
* is the total distance the modal needs to travel to be fully closed.
|
||||
*/
|
||||
const totalDistance = windowHeight - modalTop;
|
||||
/**
|
||||
* The pull percentage is how far the user has swiped compared to the total
|
||||
* distance needed to close the modal.
|
||||
*/
|
||||
const pullPercentage = deltaY / totalDistance;
|
||||
/**
|
||||
* The progress is the inverse of the pull percentage because
|
||||
* when the user starts swiping up, the progress should be close to 1,
|
||||
* and when the user has swiped all the way down, the progress should be
|
||||
* close to 0.
|
||||
*/
|
||||
const progress = 1 - pullPercentage;
|
||||
// Round to the nearest thousandth to avoid returning very small decimal
|
||||
const roundedProgress = Math.round(progress * 1000) / 1000;
|
||||
|
||||
return Math.max(0, Math.min(1, roundedProgress));
|
||||
};
|
||||
|
||||
@@ -47,3 +47,29 @@ export interface ModalCustomEvent extends CustomEvent {
|
||||
* The behavior setting for modals when the handle is pressed.
|
||||
*/
|
||||
export type ModalHandleBehavior = 'none' | 'cycle';
|
||||
|
||||
export interface ModalDragEventDetail {
|
||||
/**
|
||||
* The current Y coordinate of the drag event.
|
||||
*/
|
||||
currentY: number;
|
||||
/**
|
||||
* The change in Y coordinate since the last drag event.
|
||||
*/
|
||||
deltaY: number;
|
||||
/**
|
||||
* The velocity of the drag event in the Y direction.
|
||||
*/
|
||||
velocityY: number;
|
||||
/**
|
||||
* The progress of the drag event, represented as a value between 0 and 1.
|
||||
* A value of 0 means the modal is at its lowest point (fully closed),
|
||||
* while a value of 1 means the modal is at its highest point (fully open).
|
||||
*/
|
||||
progress: number;
|
||||
/**
|
||||
* The breakpoint that the sheet will snap to if the user releases
|
||||
* the gesture.
|
||||
*/
|
||||
snapBreakpoint?: number;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import { mdLeaveAnimation } from './animations/md.leave';
|
||||
import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
|
||||
import { createSheetGesture } from './gestures/sheet';
|
||||
import { createSwipeToCloseGesture, SwipeToCloseDefaults } from './gestures/swipe-to-close';
|
||||
import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from './modal-interface';
|
||||
import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior, ModalDragEventDetail } from './modal-interface';
|
||||
import {
|
||||
getInitialSafeAreaConfig,
|
||||
getPositionBasedSafeAreaConfig,
|
||||
@@ -80,6 +80,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
private coreDelegate: FrameworkDelegate = CoreDelegate();
|
||||
private sheetTransition?: Promise<any>;
|
||||
@State() private isSheetModal = false;
|
||||
/**
|
||||
* The breakpoint value that has been committed for a sheet modal.
|
||||
* This represents the modal's resting state when it is not being dragged
|
||||
* or animating toward a new position.
|
||||
*/
|
||||
private currentBreakpoint?: number;
|
||||
private wrapperEl?: HTMLElement;
|
||||
private backdropEl?: HTMLIonBackdropElement;
|
||||
@@ -419,6 +424,21 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
*/
|
||||
@Event() ionMount!: EventEmitter<void>;
|
||||
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture starts.
|
||||
*/
|
||||
@Event() ionDragStart!: EventEmitter<void>;
|
||||
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture moves.
|
||||
*/
|
||||
@Event() ionDragMove!: EventEmitter<ModalDragEventDetail>;
|
||||
|
||||
/**
|
||||
* Event that is emitted when the sheet modal or card modal gesture ends.
|
||||
*/
|
||||
@Event() ionDragEnd!: EventEmitter<ModalDragEventDetail>;
|
||||
|
||||
breakpointsChanged(breakpoints: number[] | undefined) {
|
||||
if (breakpoints !== undefined) {
|
||||
this.sortedBreakpoints = breakpoints.sort((a, b) => a - b);
|
||||
@@ -730,33 +750,15 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
|
||||
const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default;
|
||||
|
||||
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
|
||||
/**
|
||||
* While the gesture animation is finishing
|
||||
* it is possible for a user to tap the backdrop.
|
||||
* This would result in the dismiss animation
|
||||
* being played again. Typically this is avoided
|
||||
* by setting `presented = false` on the overlay
|
||||
* component; however, we cannot do that here as
|
||||
* that would prevent the element from being
|
||||
* removed from the DOM.
|
||||
*/
|
||||
this.gestureAnimationDismissing = true;
|
||||
|
||||
/**
|
||||
* Reset the status bar style as the dismiss animation
|
||||
* starts otherwise the status bar will be the wrong
|
||||
* color for the duration of the dismiss animation.
|
||||
* The dismiss method does this as well, but
|
||||
* in this case it's only called once the animation
|
||||
* has finished.
|
||||
*/
|
||||
setCardStatusBarDefault(this.statusBarStyle);
|
||||
this.animation!.onFinish(async () => {
|
||||
await this.dismiss(undefined, GESTURE);
|
||||
this.gestureAnimationDismissing = false;
|
||||
});
|
||||
});
|
||||
this.gesture = createSwipeToCloseGesture(
|
||||
el,
|
||||
ani,
|
||||
statusBarStyle,
|
||||
() => this.cardOnDismiss(),
|
||||
() => this.onDragStart(),
|
||||
(detail: ModalDragEventDetail) => this.onDragMove(detail),
|
||||
(detail: ModalDragEventDetail) => this.onDragEnd(detail)
|
||||
);
|
||||
this.gesture.enable(true);
|
||||
}
|
||||
|
||||
@@ -793,7 +795,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.currentBreakpoint = breakpoint;
|
||||
this.ionBreakpointDidChange.emit({ breakpoint });
|
||||
}
|
||||
}
|
||||
},
|
||||
() => this.onDragStart(),
|
||||
(detail: ModalDragEventDetail) => this.onDragMove(detail),
|
||||
(detail: ModalDragEventDetail) => this.onDragEnd(detail)
|
||||
);
|
||||
|
||||
this.gesture = gesture;
|
||||
@@ -907,6 +912,34 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
});
|
||||
}
|
||||
|
||||
private cardOnDismiss() {
|
||||
/**
|
||||
* While the gesture animation is finishing
|
||||
* it is possible for a user to tap the backdrop.
|
||||
* This would result in the dismiss animation
|
||||
* being played again. Typically this is avoided
|
||||
* by setting `presented = false` on the overlay
|
||||
* component; however, we cannot do that here as
|
||||
* that would prevent the element from being
|
||||
* removed from the DOM.
|
||||
*/
|
||||
this.gestureAnimationDismissing = true;
|
||||
|
||||
/**
|
||||
* Reset the status bar style as the dismiss animation
|
||||
* starts otherwise the status bar will be the wrong
|
||||
* color for the duration of the dismiss animation.
|
||||
* The dismiss method does this as well, but
|
||||
* in this case it's only called once the animation
|
||||
* has finished.
|
||||
*/
|
||||
setCardStatusBarDefault(this.statusBarStyle);
|
||||
this.animation!.onFinish(async () => {
|
||||
await this.dismiss(undefined, GESTURE);
|
||||
this.gestureAnimationDismissing = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the modal overlay after it has been presented.
|
||||
* This is a no-op if the overlay has not been presented yet. If you want
|
||||
@@ -1382,6 +1415,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.parentRemovalObserver = undefined;
|
||||
}
|
||||
|
||||
private onDragStart() {
|
||||
this.ionDragStart.emit();
|
||||
}
|
||||
|
||||
private onDragMove(detail: ModalDragEventDetail) {
|
||||
this.ionDragMove.emit(detail);
|
||||
}
|
||||
|
||||
private onDragEnd(detail: ModalDragEventDetail) {
|
||||
this.ionDragEnd.emit(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the context object for safe-area utilities.
|
||||
*/
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<h2>iOS only</h2>
|
||||
<button class="expand" id="card" onclick="presentModal(document.querySelectorAll('.ion-page')[1])">
|
||||
Card Modal
|
||||
</button>
|
||||
@@ -50,6 +51,7 @@
|
||||
>
|
||||
Card Modal Custom Radius
|
||||
</button>
|
||||
<button class="expand" id="drag-events" onclick="dragEvents()">Card Modal Drag Events</button>
|
||||
</ion-content>
|
||||
</div>
|
||||
</ion-app>
|
||||
@@ -162,6 +164,24 @@
|
||||
const modal = await createModal(presentingEl, opts);
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async function dragEvents() {
|
||||
const modal = await createModal(document.querySelectorAll('.ion-page')[1], { id: 'drag-events' });
|
||||
|
||||
modal.addEventListener('ionDragStart', (event) => {
|
||||
console.log('Drag started');
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragMove', (event) => {
|
||||
console.log('Drag moved', event.detail);
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragEnd', (event) => {
|
||||
console.log('Drag ended', event.detail);
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
import { configs, dragElementBy, test } from '@utils/test/playwright';
|
||||
|
||||
import { CardModalPage } from '../fixtures';
|
||||
|
||||
@@ -95,4 +95,50 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('card modal: drag events'), () => {
|
||||
test('should emit ionDragStart, ionDragMove, and ionDragEnd events', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/card', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#drag-events');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const ionDragStart = await page.spyOnEvent('ionDragStart');
|
||||
const ionDragMove = await page.spyOnEvent('ionDragMove');
|
||||
const ionDragEnd = await page.spyOnEvent('ionDragEnd');
|
||||
|
||||
const header = page.locator('.modal-card ion-header');
|
||||
|
||||
// Start the drag to verify it emits the events before the gesture ends
|
||||
await dragElementBy(header, page, 0, 50, undefined, undefined, false);
|
||||
|
||||
await ionDragStart.next();
|
||||
const dragMoveEvent = await ionDragMove.next();
|
||||
|
||||
expect(ionDragStart.length).toBe(1);
|
||||
|
||||
expect(ionDragMove.length).toBeGreaterThan(0);
|
||||
expect(Object.keys(dragMoveEvent.detail).length).toBe(4);
|
||||
|
||||
expect(ionDragEnd.length).toBe(0);
|
||||
|
||||
/**
|
||||
* Drage the modal further to verify it does:
|
||||
* - not emit the event again for `ionDragStart`
|
||||
* - emit more `ionDragMove` events
|
||||
* - emit the `ionDragEnd` event when the gesture ends
|
||||
*/
|
||||
await dragElementBy(header, page, 0, 100);
|
||||
|
||||
const dragEndEvent = await ionDragEnd.next();
|
||||
|
||||
expect(ionDragStart.length).toBe(1);
|
||||
expect(ionDragMove.length).toBeGreaterThan(0);
|
||||
|
||||
expect(ionDragEnd.length).toBe(1);
|
||||
expect(Object.keys(dragEndEvent.detail).length).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 248 KiB |
@@ -152,6 +152,8 @@
|
||||
Backdrop is inactive
|
||||
</button>
|
||||
|
||||
<button id="drag-events" onclick="dragEvents()">Drag Events</button>
|
||||
|
||||
<div class="grid">
|
||||
<div class="grid-item red"></div>
|
||||
<div class="grid-item green"></div>
|
||||
@@ -246,6 +248,27 @@
|
||||
});
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
function dragEvents() {
|
||||
const modal = createModal({
|
||||
initialBreakpoint: 0.5,
|
||||
breakpoints: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragStart', (event) => {
|
||||
console.log('Drag started');
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragMove', (event) => {
|
||||
console.log('Drag moved', event.detail);
|
||||
});
|
||||
|
||||
modal.addEventListener('ionDragEnd', (event) => {
|
||||
console.log('Drag ended', event.detail);
|
||||
});
|
||||
|
||||
modal.present();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -353,4 +353,50 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await expect(dragHandle).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('sheet modal: drag events'), () => {
|
||||
test('should emit ionDragStart, ionDragMove, and ionDragEnd events', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/sheet', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#drag-events');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const ionDragStart = await page.spyOnEvent('ionDragStart');
|
||||
const ionDragMove = await page.spyOnEvent('ionDragMove');
|
||||
const ionDragEnd = await page.spyOnEvent('ionDragEnd');
|
||||
|
||||
const header = page.locator('.modal-sheet ion-header');
|
||||
|
||||
// Start the drag to verify it emits the events before the gesture ends
|
||||
await dragElementBy(header, page, 0, 50, undefined, undefined, false);
|
||||
|
||||
await ionDragStart.next();
|
||||
const dragMoveEvent = await ionDragMove.next();
|
||||
|
||||
expect(ionDragStart.length).toBe(1);
|
||||
|
||||
expect(ionDragMove.length).toBeGreaterThan(0);
|
||||
expect(Object.keys(dragMoveEvent.detail).length).toBe(5);
|
||||
|
||||
expect(ionDragEnd.length).toBe(0);
|
||||
|
||||
/**
|
||||
* Drage the modal further to verify it does:
|
||||
* - not emit the event again for `ionDragStart`
|
||||
* - emit more `ionDragMove` events
|
||||
* - emit the `ionDragEnd` event when the gesture ends
|
||||
*/
|
||||
await dragElementBy(header, page, 0, 100);
|
||||
|
||||
const dragEndEvent = await ionDragEnd.next();
|
||||
|
||||
expect(ionDragStart.length).toBe(1);
|
||||
expect(ionDragMove.length).toBeGreaterThan(0);
|
||||
|
||||
expect(ionDragEnd.length).toBe(1);
|
||||
expect(Object.keys(dragEndEvent.detail).length).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type KnobName = 'A' | 'B' | undefined;
|
||||
|
||||
export type KnobPosition = 'lower' | 'upper' | undefined;
|
||||
|
||||
export type RangeValue = number | { lower: number; upper: number };
|
||||
|
||||
export type PinFormatter = (value: number) => number | string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
|
||||
import { Build, Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
|
||||
import { findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { inheritAriaAttributes, clamp, debounceEvent, renderHiddenInput, isSafeNumber } from '@utils/helpers';
|
||||
@@ -13,6 +13,7 @@ import { roundToMaxDecimalPlaces } from '../../utils/floating-point';
|
||||
|
||||
import type {
|
||||
KnobName,
|
||||
KnobPosition,
|
||||
RangeChangeEventDetail,
|
||||
RangeKnobMoveEndEventDetail,
|
||||
RangeKnobMoveStartEventDetail,
|
||||
@@ -29,13 +30,30 @@ import type {
|
||||
* @slot start - Content is placed to the left of the range slider in LTR, and to the right in RTL.
|
||||
* @slot end - Content is placed to the right of the range slider in LTR, and to the left in RTL.
|
||||
*
|
||||
* @part label - The label text describing the range.
|
||||
* @part tick - An inactive tick mark.
|
||||
* @part tick-active - An active tick mark.
|
||||
* @part pin - The counter that appears above a knob.
|
||||
* @part knob - The handle that is used to drag the range.
|
||||
* @part bar - The inactive part of the bar.
|
||||
* @part bar-active - The active part of the bar.
|
||||
* @part label - The label text describing the range.
|
||||
* @part knob-handle - The container that wraps the knob and handles drag interactions.
|
||||
* @part knob-handle-a - The container for the knob with the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
|
||||
* @part knob-handle-b - The container for the knob with the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
|
||||
* @part knob-handle-lower - The container for the knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which knob handle they refer to when the knobs cross.
|
||||
* @part knob-handle-upper - The container for the knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which knob handle they refer to when the knobs cross.
|
||||
* @part pin - The value indicator displayed above a knob.
|
||||
* @part pin-a - The value indicator above the knob with the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
|
||||
* @part pin-b - The value indicator above the knob with the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
|
||||
* @part pin-lower - The value indicator above the knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which pin they refer to when the knobs cross.
|
||||
* @part pin-upper - The value indicator above the knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which pin they refer to when the knobs cross.
|
||||
* @part knob - The visual knob element on the range track.
|
||||
* @part knob-a - The visual knob for the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
|
||||
* @part knob-b - The visual knob for the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
|
||||
* @part knob-lower - The visual knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which knob they refer to when the knobs cross.
|
||||
* @part knob-upper - The visual knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which knob they refer to when the knobs cross.
|
||||
* @part activated - Added to the knob-handle, knob, and pin when the knob is active. Only one set has this part at a time when `dualKnobs` is `true`.
|
||||
* @part focused - Added to the knob-handle, knob, and pin that currently has focus. Only one set has this part at a time when `dualKnobs` is `true`.
|
||||
* @part hover - Added to the knob-handle, knob, and pin when the knob has hover. Only one set has this part at a time when `dualKnobs` is `true`.
|
||||
* @part pressed - Added to the knob-handle, knob, and pin that is currently being pressed to drag. Only one set has this part at a time when `dualKnobs` is `true`.
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-range',
|
||||
@@ -57,11 +75,27 @@ export class Range implements ComponentInterface {
|
||||
private contentEl: HTMLElement | null = null;
|
||||
private initialContentScrollY = true;
|
||||
private originalIonInput?: EventEmitter<RangeChangeEventDetail>;
|
||||
/**
|
||||
* Used to avoid setting the focused state on click or tap. The focused
|
||||
* state is only set when the focus comes from the keyboard (e.g. Tab).
|
||||
* This is set to true on pointer down (mouse/touch).
|
||||
*/
|
||||
private focusFromPointer = false;
|
||||
/**
|
||||
* Observes class changes on the knob handles to keep the activatedKnob
|
||||
* state in sync with the ion-activated class. This is necessary to
|
||||
* determine which knob the user is dragging when using dual knobs and
|
||||
* apply the activated part correctly.
|
||||
*/
|
||||
private activatedObserver?: MutationObserver;
|
||||
|
||||
@Element() el!: HTMLIonRangeElement;
|
||||
|
||||
@State() private ratioA = 0;
|
||||
@State() private ratioB = 0;
|
||||
@State() private activatedKnob: KnobName;
|
||||
@State() private focusedKnob: KnobName;
|
||||
@State() private hoveredKnob: KnobName;
|
||||
@State() private pressedKnob: KnobName;
|
||||
|
||||
/**
|
||||
@@ -324,6 +358,34 @@ export class Range implements ComponentInterface {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Observes the knob handles for the ion-activated class and syncs
|
||||
* activatedKnob so the activated part is correctly set on the handle,
|
||||
* knob, and pin.
|
||||
*/
|
||||
private setupActivatedObserver = () => {
|
||||
const knobHandleA = this.el.shadowRoot!.querySelector('.range-knob-handle-a');
|
||||
const knobHandleB = this.el.shadowRoot!.querySelector('.range-knob-handle-b');
|
||||
|
||||
const syncActivated = () => {
|
||||
this.activatedKnob = (knobHandleA as HTMLElement)?.classList.contains('ion-activated')
|
||||
? 'A'
|
||||
: (knobHandleB as HTMLElement)?.classList.contains('ion-activated')
|
||||
? 'B'
|
||||
: undefined;
|
||||
};
|
||||
|
||||
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
|
||||
this.activatedObserver = new MutationObserver(syncActivated);
|
||||
this.activatedObserver.observe(this.el.shadowRoot!, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
syncActivated();
|
||||
};
|
||||
|
||||
componentWillLoad() {
|
||||
/**
|
||||
* If user has custom ID set then we should
|
||||
@@ -345,6 +407,7 @@ export class Range implements ComponentInterface {
|
||||
this.originalIonInput = this.ionInput;
|
||||
this.setupGesture();
|
||||
this.updateRatio();
|
||||
this.setupActivatedObserver();
|
||||
this.didLoad = true;
|
||||
}
|
||||
|
||||
@@ -362,6 +425,7 @@ export class Range implements ComponentInterface {
|
||||
*/
|
||||
if (this.didLoad) {
|
||||
this.setupGesture();
|
||||
this.setupActivatedObserver();
|
||||
}
|
||||
|
||||
const ionContent = findClosestIonContent(this.el);
|
||||
@@ -373,6 +437,10 @@ export class Range implements ComponentInterface {
|
||||
this.gesture.destroy();
|
||||
this.gesture = undefined;
|
||||
}
|
||||
if (this.activatedObserver) {
|
||||
this.activatedObserver.disconnect();
|
||||
this.activatedObserver = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyboard = (knob: KnobName, isIncrease: boolean) => {
|
||||
@@ -467,7 +535,7 @@ export class Range implements ComponentInterface {
|
||||
* started dragging the knob.
|
||||
*
|
||||
* This is necessary to determine which knob the user is dragging,
|
||||
* especially when it's a dual knob.
|
||||
* especially when using dual knobs.
|
||||
* Plus, it determines when to apply certain styles.
|
||||
*
|
||||
* This only needs to be done once since the knob won't change
|
||||
@@ -496,7 +564,7 @@ export class Range implements ComponentInterface {
|
||||
* dragged the knob. They just tapped on the bar.
|
||||
*
|
||||
* This is necessary to determine which knob the user is changing,
|
||||
* especially when it's a dual knob.
|
||||
* especially when using dual knobs.
|
||||
* Plus, it determines when to apply certain styles.
|
||||
*/
|
||||
if (this.pressedKnob === undefined) {
|
||||
@@ -515,6 +583,7 @@ export class Range implements ComponentInterface {
|
||||
|
||||
// update the active knob's position
|
||||
this.update(currentX);
|
||||
|
||||
/**
|
||||
* Reset the pressed knob to undefined since the user
|
||||
* may start dragging a different knob in the next gesture event.
|
||||
@@ -559,8 +628,6 @@ export class Range implements ComponentInterface {
|
||||
ratio = 1 - ratio;
|
||||
}
|
||||
this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B';
|
||||
|
||||
this.setFocus(this.pressedKnob);
|
||||
}
|
||||
|
||||
private get valA() {
|
||||
@@ -592,9 +659,26 @@ export class Range implements ComponentInterface {
|
||||
private updateRatio() {
|
||||
const value = this.getValue() as any;
|
||||
const { min, max } = this;
|
||||
|
||||
/**
|
||||
* For dual knobs, value gives lower/upper but not which is A vs B.
|
||||
* Assign (lowerRatio, upperRatio) to (ratioA, ratioB) in the way that
|
||||
* minimizes change from the current ratios so the knobs don't swap.
|
||||
*/
|
||||
if (this.dualKnobs) {
|
||||
this.ratioA = valueToRatio(value.lower, min, max);
|
||||
this.ratioB = valueToRatio(value.upper, min, max);
|
||||
const lowerRatio = valueToRatio(value.lower, min, max);
|
||||
const upperRatio = valueToRatio(value.upper, min, max);
|
||||
|
||||
if (
|
||||
Math.abs(this.ratioA - lowerRatio) + Math.abs(this.ratioB - upperRatio) <=
|
||||
Math.abs(this.ratioA - upperRatio) + Math.abs(this.ratioB - lowerRatio)
|
||||
) {
|
||||
this.ratioA = lowerRatio;
|
||||
this.ratioB = upperRatio;
|
||||
} else {
|
||||
this.ratioA = upperRatio;
|
||||
this.ratioB = lowerRatio;
|
||||
}
|
||||
} else {
|
||||
this.ratioA = valueToRatio(value, min, max);
|
||||
}
|
||||
@@ -614,20 +698,10 @@ export class Range implements ComponentInterface {
|
||||
this.noUpdate = false;
|
||||
}
|
||||
|
||||
private setFocus(knob: KnobName) {
|
||||
if (this.el.shadowRoot) {
|
||||
const knobEl = this.el.shadowRoot.querySelector(knob === 'A' ? '.range-knob-a' : '.range-knob-b') as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
if (knobEl) {
|
||||
knobEl.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onBlur = () => {
|
||||
if (this.hasFocus) {
|
||||
this.hasFocus = false;
|
||||
this.focusedKnob = undefined;
|
||||
this.ionBlur.emit();
|
||||
}
|
||||
};
|
||||
@@ -640,24 +714,20 @@ export class Range implements ComponentInterface {
|
||||
};
|
||||
|
||||
private onKnobFocus = (knob: KnobName) => {
|
||||
// Clicking focuses the range which is needed for the keyboard,
|
||||
// but we only want to add the ion-focused class when focused via Tab.
|
||||
if (!this.focusFromPointer) {
|
||||
this.focusedKnob = knob;
|
||||
} else {
|
||||
this.focusFromPointer = false;
|
||||
this.focusedKnob = undefined;
|
||||
}
|
||||
|
||||
// If the knob was not already focused, emit the focus event
|
||||
if (!this.hasFocus) {
|
||||
this.hasFocus = true;
|
||||
this.ionFocus.emit();
|
||||
}
|
||||
|
||||
// Manually manage ion-focused class for dual knobs
|
||||
if (this.dualKnobs && this.el.shadowRoot) {
|
||||
const knobA = this.el.shadowRoot.querySelector('.range-knob-a');
|
||||
const knobB = this.el.shadowRoot.querySelector('.range-knob-b');
|
||||
|
||||
// Remove ion-focused from both knobs first
|
||||
knobA?.classList.remove('ion-focused');
|
||||
knobB?.classList.remove('ion-focused');
|
||||
|
||||
// Add ion-focused only to the focused knob
|
||||
const focusedKnobEl = knob === 'A' ? knobA : knobB;
|
||||
focusedKnobEl?.classList.add('ion-focused');
|
||||
}
|
||||
};
|
||||
|
||||
private onKnobBlur = () => {
|
||||
@@ -670,20 +740,21 @@ export class Range implements ComponentInterface {
|
||||
if (!isStillFocusedOnKnob) {
|
||||
if (this.hasFocus) {
|
||||
this.hasFocus = false;
|
||||
this.focusedKnob = undefined;
|
||||
this.ionBlur.emit();
|
||||
}
|
||||
|
||||
// Remove ion-focused from both knobs when focus leaves the range
|
||||
if (this.dualKnobs && this.el.shadowRoot) {
|
||||
const knobA = this.el.shadowRoot.querySelector('.range-knob-a');
|
||||
const knobB = this.el.shadowRoot.querySelector('.range-knob-b');
|
||||
knobA?.classList.remove('ion-focused');
|
||||
knobB?.classList.remove('ion-focused');
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
private onKnobMouseEnter = (knob: KnobName) => {
|
||||
this.hoveredKnob = knob;
|
||||
};
|
||||
|
||||
private onKnobMouseLeave = () => {
|
||||
this.hoveredKnob = undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if content was passed to the "start" slot
|
||||
*/
|
||||
@@ -708,6 +779,9 @@ export class Range implements ComponentInterface {
|
||||
max,
|
||||
step,
|
||||
handleKeyboard,
|
||||
activatedKnob,
|
||||
focusedKnob,
|
||||
hoveredKnob,
|
||||
pressedKnob,
|
||||
disabled,
|
||||
pin,
|
||||
@@ -790,6 +864,9 @@ export class Range implements ComponentInterface {
|
||||
<div
|
||||
class="range-slider"
|
||||
ref={(rangeEl) => (this.rangeSlider = rangeEl)}
|
||||
onPointerDown={() => {
|
||||
this.focusFromPointer = true;
|
||||
}}
|
||||
/**
|
||||
* Since the gesture has a threshold, the value
|
||||
* won't change until the user has dragged past
|
||||
@@ -802,6 +879,8 @@ export class Range implements ComponentInterface {
|
||||
* we need to listen for the "pointerUp" event.
|
||||
*/
|
||||
onPointerUp={(ev: PointerEvent) => {
|
||||
this.focusFromPointer = false;
|
||||
|
||||
/**
|
||||
* If the user drags the knob on the web
|
||||
* version (does not occur on mobile),
|
||||
@@ -848,6 +927,11 @@ export class Range implements ComponentInterface {
|
||||
|
||||
{renderKnob(rtl, {
|
||||
knob: 'A',
|
||||
position: getKnobPosition('A', this.ratioA, this.ratioB, this.dualKnobs),
|
||||
dualKnobs: this.dualKnobs,
|
||||
activated: activatedKnob === 'A',
|
||||
focused: focusedKnob === 'A',
|
||||
hovered: hoveredKnob === 'A',
|
||||
pressed: pressedKnob === 'A',
|
||||
value: this.valA,
|
||||
ratio: this.ratioA,
|
||||
@@ -860,11 +944,18 @@ export class Range implements ComponentInterface {
|
||||
inheritedAttributes,
|
||||
onKnobFocus: this.onKnobFocus,
|
||||
onKnobBlur: this.onKnobBlur,
|
||||
onKnobMouseEnter: this.onKnobMouseEnter,
|
||||
onKnobMouseLeave: this.onKnobMouseLeave,
|
||||
})}
|
||||
|
||||
{this.dualKnobs &&
|
||||
renderKnob(rtl, {
|
||||
knob: 'B',
|
||||
position: getKnobPosition('B', this.ratioA, this.ratioB, this.dualKnobs),
|
||||
dualKnobs: this.dualKnobs,
|
||||
activated: activatedKnob === 'B',
|
||||
focused: focusedKnob === 'B',
|
||||
hovered: hoveredKnob === 'B',
|
||||
pressed: pressedKnob === 'B',
|
||||
value: this.valB,
|
||||
ratio: this.ratioB,
|
||||
@@ -877,13 +968,15 @@ export class Range implements ComponentInterface {
|
||||
inheritedAttributes,
|
||||
onKnobFocus: this.onKnobFocus,
|
||||
onKnobBlur: this.onKnobBlur,
|
||||
onKnobMouseEnter: this.onKnobMouseEnter,
|
||||
onKnobMouseLeave: this.onKnobMouseLeave,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { disabled, el, hasLabel, rangeId, pin, pressedKnob, labelPlacement, label } = this;
|
||||
const { disabled, el, hasLabel, rangeId, pin, pressedKnob, labelPlacement, label, dualKnobs, min, max } = this;
|
||||
|
||||
const inItem = hostContext('ion-item', el);
|
||||
|
||||
@@ -906,6 +999,21 @@ export class Range implements ComponentInterface {
|
||||
|
||||
const mode = getIonMode(this);
|
||||
|
||||
/**
|
||||
* Determine the name and position of the pressed knob to apply
|
||||
* Host classes for styling.
|
||||
*/
|
||||
const pressedKnobName = dualKnobs ? pressedKnob?.toLowerCase() : undefined;
|
||||
const pressedKnobPosition =
|
||||
dualKnobs && pressedKnob ? getKnobPosition(pressedKnob, this.ratioA, this.ratioB, dualKnobs) : undefined;
|
||||
|
||||
/**
|
||||
* Determine if any knob is at the min or max value to
|
||||
* apply Host classes for styling.
|
||||
*/
|
||||
const valueAtMin = dualKnobs ? this.valA === min || this.valB === min : this.valA === min;
|
||||
const valueAtMax = dualKnobs ? this.valA === max || this.valB === max : this.valA === max;
|
||||
|
||||
renderHiddenInput(true, el, this.name, JSON.stringify(this.getValue()), disabled);
|
||||
|
||||
return (
|
||||
@@ -917,11 +1025,16 @@ export class Range implements ComponentInterface {
|
||||
[mode]: true,
|
||||
'in-item': inItem,
|
||||
'range-disabled': disabled,
|
||||
'range-dual-knobs': dualKnobs,
|
||||
'range-pressed': pressedKnob !== undefined,
|
||||
[`range-pressed-${pressedKnobName}`]: pressedKnob !== undefined && pressedKnobName !== undefined,
|
||||
[`range-pressed-${pressedKnobPosition}`]: pressedKnob !== undefined && pressedKnobPosition !== undefined,
|
||||
'range-has-pin': pin,
|
||||
[`range-label-placement-${labelPlacement}`]: true,
|
||||
'range-item-start-adjustment': needsStartAdjustment,
|
||||
'range-item-end-adjustment': needsEndAdjustment,
|
||||
'range-value-min': valueAtMin,
|
||||
'range-value-max': valueAtMax,
|
||||
})}
|
||||
>
|
||||
<label class="range-wrapper" id="range-label">
|
||||
@@ -947,29 +1060,41 @@ export class Range implements ComponentInterface {
|
||||
|
||||
interface RangeKnob {
|
||||
knob: KnobName;
|
||||
position: KnobPosition;
|
||||
dualKnobs: boolean;
|
||||
value: number;
|
||||
ratio: number;
|
||||
min: number;
|
||||
max: number;
|
||||
disabled: boolean;
|
||||
pressed: boolean;
|
||||
focused: boolean;
|
||||
hovered: boolean;
|
||||
activated: boolean;
|
||||
pin: boolean;
|
||||
pinFormatter: PinFormatter;
|
||||
inheritedAttributes: Attributes;
|
||||
handleKeyboard: (name: KnobName, isIncrease: boolean) => void;
|
||||
onKnobFocus: (knob: KnobName) => void;
|
||||
onKnobBlur: () => void;
|
||||
onKnobMouseEnter: (knob: KnobName) => void;
|
||||
onKnobMouseLeave: () => void;
|
||||
}
|
||||
|
||||
const renderKnob = (
|
||||
rtl: boolean,
|
||||
{
|
||||
knob,
|
||||
position,
|
||||
dualKnobs,
|
||||
value,
|
||||
ratio,
|
||||
min,
|
||||
max,
|
||||
disabled,
|
||||
activated,
|
||||
focused,
|
||||
hovered,
|
||||
pressed,
|
||||
pin,
|
||||
handleKeyboard,
|
||||
@@ -977,6 +1102,8 @@ const renderKnob = (
|
||||
inheritedAttributes,
|
||||
onKnobFocus,
|
||||
onKnobBlur,
|
||||
onKnobMouseEnter,
|
||||
onKnobMouseLeave,
|
||||
}: RangeKnob
|
||||
) => {
|
||||
const start = rtl ? 'right' : 'left';
|
||||
@@ -1008,16 +1135,32 @@ const renderKnob = (
|
||||
}}
|
||||
onFocus={() => onKnobFocus(knob)}
|
||||
onBlur={onKnobBlur}
|
||||
onMouseEnter={() => onKnobMouseEnter(knob)}
|
||||
onMouseLeave={onKnobMouseLeave}
|
||||
class={{
|
||||
'range-knob-handle': true,
|
||||
'range-knob-a': knob === 'A',
|
||||
'range-knob-b': knob === 'B',
|
||||
'range-knob-handle-a': knob === 'A',
|
||||
'range-knob-handle-b': knob === 'B',
|
||||
'range-knob-pressed': pressed,
|
||||
'range-knob-min': value === min,
|
||||
'range-knob-max': value === max,
|
||||
'ion-activatable': true,
|
||||
'ion-focusable': true,
|
||||
'ion-focused': focused,
|
||||
}}
|
||||
part={[
|
||||
'knob-handle',
|
||||
dualKnobs && knob === 'A' && 'knob-handle-a',
|
||||
dualKnobs && knob === 'B' && 'knob-handle-b',
|
||||
dualKnobs && position === 'lower' && 'knob-handle-lower',
|
||||
dualKnobs && position === 'upper' && 'knob-handle-upper',
|
||||
pressed && 'pressed',
|
||||
focused && 'focused',
|
||||
hovered && 'hover',
|
||||
activated && 'activated',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={knobStyle()}
|
||||
role="slider"
|
||||
tabindex={disabled ? -1 : 0}
|
||||
@@ -1029,15 +1172,72 @@ const renderKnob = (
|
||||
aria-valuenow={value}
|
||||
>
|
||||
{pin && (
|
||||
<div class="range-pin" role="presentation" part="pin">
|
||||
<div
|
||||
class="range-pin"
|
||||
role="presentation"
|
||||
part={[
|
||||
'pin',
|
||||
dualKnobs && knob === 'A' && 'pin-a',
|
||||
dualKnobs && knob === 'B' && 'pin-b',
|
||||
dualKnobs && position === 'lower' && 'pin-lower',
|
||||
dualKnobs && position === 'upper' && 'pin-upper',
|
||||
pressed && 'pressed',
|
||||
focused && 'focused',
|
||||
hovered && 'hover',
|
||||
activated && 'activated',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{pinFormatter(value)}
|
||||
</div>
|
||||
)}
|
||||
<div class="range-knob" role="presentation" part="knob" />
|
||||
<div
|
||||
class="range-knob"
|
||||
role="presentation"
|
||||
part={[
|
||||
'knob',
|
||||
dualKnobs && knob === 'A' && 'knob-a',
|
||||
dualKnobs && knob === 'B' && 'knob-b',
|
||||
dualKnobs && position === 'lower' && 'knob-lower',
|
||||
dualKnobs && position === 'upper' && 'knob-upper',
|
||||
pressed && 'pressed',
|
||||
focused && 'focused',
|
||||
hovered && 'hover',
|
||||
activated && 'activated',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether the given knob is at the lower or upper position based
|
||||
* on current ratios for the given knob.
|
||||
*
|
||||
* When both knobs have the same ratio, we only want one "lower" and one
|
||||
* "upper" position so that the `lower` and `upper` parts are not applied to
|
||||
* the same knob. In that case, we treat knob "A" as the lower position and
|
||||
* knob "B" as the upper position.
|
||||
*/
|
||||
const getKnobPosition = (knob: 'A' | 'B', ratioA: number, ratioB: number, dualKnobs: boolean): 'lower' | 'upper' => {
|
||||
if (!dualKnobs) {
|
||||
return 'lower';
|
||||
}
|
||||
|
||||
if (ratioA === ratioB) {
|
||||
return knob === 'A' ? 'lower' : 'upper';
|
||||
}
|
||||
|
||||
if (knob === 'A') {
|
||||
return ratioA < ratioB ? 'lower' : 'upper';
|
||||
}
|
||||
|
||||
return ratioB < ratioA ? 'lower' : 'upper';
|
||||
};
|
||||
|
||||
const ratioToValue = (ratio: number, min: number, max: number, step: number): number => {
|
||||
let value = (max - min) * ratio;
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ describe('range: dual knobs focus management', () => {
|
||||
await page.waitForChanges();
|
||||
|
||||
// Get the knob elements
|
||||
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
|
||||
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
|
||||
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
|
||||
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
|
||||
|
||||
expect(knobA).not.toBeNull();
|
||||
expect(knobB).not.toBeNull();
|
||||
@@ -41,8 +41,8 @@ describe('range: dual knobs focus management', () => {
|
||||
const range = page.body.querySelector('ion-range');
|
||||
await page.waitForChanges();
|
||||
|
||||
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
|
||||
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
|
||||
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
|
||||
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
|
||||
|
||||
// Focus knob A
|
||||
knobA.dispatchEvent(new Event('focus'));
|
||||
@@ -73,8 +73,8 @@ describe('range: dual knobs focus management', () => {
|
||||
const range = page.body.querySelector('ion-range');
|
||||
await page.waitForChanges();
|
||||
|
||||
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
|
||||
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
|
||||
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
|
||||
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
|
||||
|
||||
// Focus knob A
|
||||
knobA.dispatchEvent(new Event('focus'));
|
||||
@@ -112,8 +112,8 @@ describe('range: dual knobs focus management', () => {
|
||||
focusEventFiredCount++;
|
||||
});
|
||||
|
||||
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
|
||||
const knobB = range.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
|
||||
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
|
||||
const knobB = range.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
|
||||
|
||||
// Focus knob A
|
||||
knobA.dispatchEvent(new Event('focus'));
|
||||
@@ -140,7 +140,7 @@ describe('range: dual knobs focus management', () => {
|
||||
blurEventFired = true;
|
||||
});
|
||||
|
||||
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
|
||||
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
|
||||
|
||||
// Focus and then blur knob A
|
||||
knobA.dispatchEvent(new Event('focus'));
|
||||
@@ -173,8 +173,8 @@ describe('range: dual knobs focus management', () => {
|
||||
const beforeButton = page.body.querySelector('#before') as HTMLElement;
|
||||
await page.waitForChanges();
|
||||
|
||||
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
|
||||
const knobB = range.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
|
||||
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
|
||||
const knobB = range.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
|
||||
|
||||
// Start with focus on element before the range
|
||||
beforeButton.focus();
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
</head>
|
||||
<style>
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
|
||||
color: #6f7378;
|
||||
|
||||
margin-top: 10px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1st Custom Range: All Parts
|
||||
* -----------------------------------
|
||||
*/
|
||||
.range-part::part(bar) {
|
||||
background: red;
|
||||
}
|
||||
@@ -51,6 +65,271 @@
|
||||
.md.range-part::part(pin)::before {
|
||||
background: orange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Custom Knob Styles
|
||||
* -----------------------------------
|
||||
*/
|
||||
.custom-knobs-range {
|
||||
--color-blue-light: #3b82f6;
|
||||
--color-blue-light-rgb: 59, 130, 246;
|
||||
|
||||
--color-blue: var(--ion-color-primary, #0054e9);
|
||||
--color-blue-rgb: var(--ion-color-primary-rgb, 0, 84, 233);
|
||||
|
||||
--color-blue-dark: #1e3a8a;
|
||||
--color-blue-dark-rgb: 30, 58, 138;
|
||||
|
||||
--color-purple: #8b5cf6;
|
||||
--color-green: #10b981;
|
||||
}
|
||||
|
||||
.custom-knobs-range::part(knob-handle) {
|
||||
--custom-knob-width: 50px;
|
||||
--custom-knob-height: 25px;
|
||||
|
||||
width: var(--custom-knob-width);
|
||||
height: var(--custom-knob-height);
|
||||
|
||||
/* Center vertically */
|
||||
top: calc((var(--height) - var(--custom-knob-height)) / 2);
|
||||
|
||||
/* Center horizontally */
|
||||
margin-inline-start: calc(0px - var(--custom-knob-width) / 2);
|
||||
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.custom-knobs-range::part(knob) {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
inset: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
/* MD scales smaller by default, make iOS scale to match */
|
||||
transform: scale(0.67);
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(3, 60, 89, 0.5);
|
||||
}
|
||||
|
||||
/* Hover/focus indicator */
|
||||
.custom-knobs-range::part(knob)::before {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Override the default hover/focus indicator */
|
||||
.custom-knobs-range::part(knob activated)::before,
|
||||
.custom-knobs-range::part(knob hover)::before,
|
||||
.custom-knobs-range::part(knob pressed)::before,
|
||||
.custom-knobs-range::part(knob focused)::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Displayed knob values */
|
||||
.custom-knobs-range .knob-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
border: 1px solid #000;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* iOS offset is larger than MD due to not having a bottom arrow */
|
||||
.ios.custom-knobs-range {
|
||||
--pin-offset: 8px;
|
||||
}
|
||||
|
||||
.md.custom-knobs-range {
|
||||
--pin-offset: 4px;
|
||||
}
|
||||
|
||||
.ios.custom-knobs-range {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.ios.custom-knobs-range::part(pin) {
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-blue);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Move the pin up so it sits above the taller custom knob when pressed/focused */
|
||||
.custom-knobs-range::part(pin pressed),
|
||||
.custom-knobs-range::part(pin focused) {
|
||||
transform: translate3d(0, calc(-100% - (var(--custom-knob-height) / 2) + var(--pin-offset)), 0) scale(1);
|
||||
}
|
||||
|
||||
.custom-knobs-range [slot='start'] {
|
||||
margin-inline-end: 36px;
|
||||
}
|
||||
|
||||
.custom-knobs-range [slot='end'] {
|
||||
margin-inline-start: 36px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2nd Custom Range: Single Knob
|
||||
* -----------------------------------
|
||||
*/
|
||||
|
||||
/* Hovered knob */
|
||||
#single-knob-range::part(knob hover) {
|
||||
background: rgba(var(--color-blue-rgb), 0.5);
|
||||
}
|
||||
|
||||
/* Pressed knob */
|
||||
#single-knob-range::part(knob pressed) {
|
||||
background: var(--color-blue);
|
||||
}
|
||||
|
||||
/* Activated knob */
|
||||
#single-knob-range::part(knob activated) {
|
||||
background: var(--color-purple);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3rd Custom Range: Dual Knobs
|
||||
* Knobs Lower & Upper
|
||||
* -----------------------------------
|
||||
*/
|
||||
|
||||
/* Style the start slot knob value when the lower knob is pressed */
|
||||
#lower-upper-dual-knobs-range.range-pressed-lower [slot='start'] {
|
||||
background: var(--color-blue-light);
|
||||
border-color: var(--color-blue-light);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Style the end slot knob value when the upper knob is pressed */
|
||||
#lower-upper-dual-knobs-range.range-pressed-upper [slot='end'] {
|
||||
background: var(--color-blue-dark);
|
||||
border-color: var(--color-blue-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Knob lower */
|
||||
#lower-upper-dual-knobs-range::part(knob-lower) {
|
||||
border-radius: 50% 0 0 50%;
|
||||
}
|
||||
|
||||
/* Knob upper */
|
||||
#lower-upper-dual-knobs-range::part(knob-upper) {
|
||||
border-radius: 0 50% 50% 0;
|
||||
}
|
||||
|
||||
/* Hovered knob lower */
|
||||
#lower-upper-dual-knobs-range::part(knob-lower hover) {
|
||||
background: rgba(var(--color-blue-light-rgb), 0.5);
|
||||
}
|
||||
|
||||
/* Pressed knob lower */
|
||||
#lower-upper-dual-knobs-range::part(knob-lower pressed) {
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
/* Activated knob lower */
|
||||
#lower-upper-dual-knobs-range::part(knob-lower activated) {
|
||||
background: var(--color-green);
|
||||
}
|
||||
|
||||
/* Hovered knob upper */
|
||||
#lower-upper-dual-knobs-range::part(knob-upper hover) {
|
||||
background: rgba(var(--color-blue-dark-rgb), 0.5);
|
||||
}
|
||||
|
||||
/* Pressed knob upper */
|
||||
#lower-upper-dual-knobs-range::part(knob-upper pressed) {
|
||||
background: var(--color-blue-dark);
|
||||
}
|
||||
|
||||
/* Activated knob upper */
|
||||
#lower-upper-dual-knobs-range::part(knob-upper activated) {
|
||||
background: var(--color-purple);
|
||||
}
|
||||
|
||||
/* Pin lower */
|
||||
#lower-upper-dual-knobs-range.range-dual-knobs::part(pin-lower),
|
||||
#lower-upper-dual-knobs-range.range-dual-knobs::part(pin-lower):before {
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
/* Pin upper */
|
||||
#lower-upper-dual-knobs-range.range-dual-knobs::part(pin-upper),
|
||||
#lower-upper-dual-knobs-range.range-dual-knobs::part(pin-upper):before {
|
||||
background: var(--color-blue-dark);
|
||||
}
|
||||
|
||||
/**
|
||||
* 4th Custom Range: Dual Knobs
|
||||
* Knobs A & B
|
||||
* -----------------------------------
|
||||
*/
|
||||
|
||||
/* Style the start slot knob value when the A knob is pressed */
|
||||
#a-b-dual-knobs-range.range-pressed-a [slot='start'] {
|
||||
background: var(--color-blue-light);
|
||||
border-color: var(--color-blue-light);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Style the end slot knob value when the B knob is pressed */
|
||||
#a-b-dual-knobs-range.range-pressed-b [slot='end'] {
|
||||
background: var(--color-blue-dark);
|
||||
border-color: var(--color-blue-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Hovered knob A */
|
||||
#a-b-dual-knobs-range::part(knob-a hover) {
|
||||
background: rgba(var(--color-blue-light-rgb), 0.5);
|
||||
}
|
||||
|
||||
/* Pressed knob A */
|
||||
#a-b-dual-knobs-range::part(knob-a pressed) {
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
/* Activated knob A */
|
||||
#a-b-dual-knobs-range::part(knob-a activated) {
|
||||
background: var(--color-green);
|
||||
}
|
||||
|
||||
/* Hovered knob B */
|
||||
#a-b-dual-knobs-range::part(knob-b hover) {
|
||||
background: rgba(var(--color-blue-dark-rgb), 0.5);
|
||||
}
|
||||
|
||||
/* Pressed knob B */
|
||||
#a-b-dual-knobs-range::part(knob-b pressed) {
|
||||
background: var(--color-blue-dark);
|
||||
}
|
||||
|
||||
/* Activated knob B */
|
||||
#a-b-dual-knobs-range::part(knob-b activated) {
|
||||
background: var(--color-purple);
|
||||
}
|
||||
|
||||
/* Pin A */
|
||||
#a-b-dual-knobs-range.range-dual-knobs::part(pin-a),
|
||||
#a-b-dual-knobs-range.range-dual-knobs::part(pin-a):before {
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
/* Pin B */
|
||||
#a-b-dual-knobs-range.range-dual-knobs::part(pin-b),
|
||||
#a-b-dual-knobs-range.range-dual-knobs::part(pin-b):before {
|
||||
background: var(--color-blue-dark);
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<ion-app>
|
||||
@@ -61,6 +340,8 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<!-- 1st Custom Range -->
|
||||
<h2>Custom Range</h2>
|
||||
<ion-range
|
||||
class="range-part"
|
||||
min="-200"
|
||||
@@ -71,6 +352,55 @@
|
||||
dual-knobs="true"
|
||||
aria-label="Custom Range"
|
||||
></ion-range>
|
||||
|
||||
<!-- 2nd Custom Range: Single Knob -->
|
||||
<h2>Custom Range: Single Knob</h2>
|
||||
<ion-range
|
||||
id="single-knob-range"
|
||||
class="custom-knobs-range"
|
||||
step="5"
|
||||
snaps="true"
|
||||
pin="true"
|
||||
value="50"
|
||||
aria-label="Custom Range"
|
||||
>
|
||||
<span slot="start">
|
||||
<ion-icon name="volume-off" size="large" color="primary"></ion-icon>
|
||||
</span>
|
||||
<span slot="end">
|
||||
<ion-icon name="volume-high" size="large" color="primary"></ion-icon>
|
||||
</span>
|
||||
</ion-range>
|
||||
|
||||
<!-- 3rd Custom Range: Dual Knobs with Knob Lower & Upper -->
|
||||
<h2>Custom Range: Dual Knobs with Knob Lower & Upper</h2>
|
||||
<ion-range
|
||||
id="lower-upper-dual-knobs-range"
|
||||
class="custom-knobs-range range-lower-upper-knobs"
|
||||
step="5"
|
||||
snaps="true"
|
||||
pin="true"
|
||||
dual-knobs="true"
|
||||
aria-label="Custom Lower & Upper Knobs Range"
|
||||
>
|
||||
<div slot="start" class="knob-value"></div>
|
||||
<div slot="end" class="knob-value"></div>
|
||||
</ion-range>
|
||||
|
||||
<!-- 4th Custom Range: Dual Knobs with Knob A & B -->
|
||||
<h2>Custom Range: Dual Knobs with Knob A & B</h2>
|
||||
<ion-range
|
||||
id="a-b-dual-knobs-range"
|
||||
class="custom-knobs-range"
|
||||
step="5"
|
||||
snaps="true"
|
||||
pin="true"
|
||||
dual-knobs="true"
|
||||
aria-label="Custom Dual Knobs Range"
|
||||
>
|
||||
<div slot="start" class="knob-value"></div>
|
||||
<div slot="end" class="knob-value"></div>
|
||||
</ion-range>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
@@ -80,6 +410,36 @@
|
||||
lower: '-100',
|
||||
upper: '100',
|
||||
};
|
||||
|
||||
const lowerUpperKnobs = document.getElementById('lower-upper-dual-knobs-range');
|
||||
lowerUpperKnobs.value = {
|
||||
lower: '40',
|
||||
upper: '60',
|
||||
};
|
||||
|
||||
const abDualKnobs = document.getElementById('a-b-dual-knobs-range');
|
||||
abDualKnobs.value = {
|
||||
lower: '25',
|
||||
upper: '75',
|
||||
};
|
||||
|
||||
function updateDisplayedKnobValues() {
|
||||
document.querySelector('#lower-upper-dual-knobs-range [slot="start"]').textContent =
|
||||
lowerUpperKnobs.value.lower;
|
||||
document.querySelector('#lower-upper-dual-knobs-range [slot="end"]').textContent = lowerUpperKnobs.value.upper;
|
||||
document.querySelector('#a-b-dual-knobs-range [slot="start"]').textContent = abDualKnobs.value.lower;
|
||||
document.querySelector('#a-b-dual-knobs-range [slot="end"]').textContent = abDualKnobs.value.upper;
|
||||
}
|
||||
|
||||
updateDisplayedKnobValues();
|
||||
|
||||
lowerUpperKnobs.addEventListener('ionChange', () => {
|
||||
updateDisplayedKnobValues();
|
||||
});
|
||||
|
||||
abDualKnobs.addEventListener('ionChange', () => {
|
||||
updateDisplayedKnobValues();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,13 +1,425 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
import { configs, dragElementBy, test } from '@utils/test/playwright';
|
||||
|
||||
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('range: customization'), () => {
|
||||
test('should be customizable', async ({ page }) => {
|
||||
await page.goto(`/src/components/range/test/custom`, config);
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('range: custom'), () => {
|
||||
test.describe(title('CSS shadow parts'), () => {
|
||||
test('should be able to customize label part', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-range::part(label) {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
await expect(range).toHaveScreenshot(screenshot(`range-custom`));
|
||||
<ion-range label="Label" value="50"></ion-range>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
|
||||
const labelColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const label = shadowRoot?.querySelector('.label-text-wrapper');
|
||||
return label ? window.getComputedStyle(label).color : '';
|
||||
});
|
||||
expect(labelColor).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize bar parts', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-range::part(bar) {
|
||||
background-color: red;
|
||||
}
|
||||
ion-range::part(bar-active) {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-range label="Label" value="50"></ion-range>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
|
||||
const barBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const bar = shadowRoot?.querySelector('.range-bar');
|
||||
return bar ? window.getComputedStyle(bar).backgroundColor : '';
|
||||
});
|
||||
expect(barBackgroundColor).toBe('rgb(255, 0, 0)');
|
||||
|
||||
const activeBarBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const activeBar = shadowRoot?.querySelector('.range-bar-active');
|
||||
return activeBar ? window.getComputedStyle(activeBar).backgroundColor : '';
|
||||
});
|
||||
expect(activeBarBackgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize pin parts', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-range::part(pin) {
|
||||
background-color: red;
|
||||
}
|
||||
ion-range::part(pin)::before {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-range label="Label" value="50" pin="true"></ion-range>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
|
||||
const pinBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const pin = shadowRoot?.querySelector('.range-pin');
|
||||
return pin ? window.getComputedStyle(pin).backgroundColor : '';
|
||||
});
|
||||
expect(pinBackgroundColor).toBe('rgb(255, 0, 0)');
|
||||
|
||||
const pinBeforeBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const pin = shadowRoot?.querySelector('.range-pin');
|
||||
if (!pin) return '';
|
||||
return window.getComputedStyle(pin, '::before').backgroundColor;
|
||||
});
|
||||
expect(pinBeforeBackgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize tick parts', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-range::part(tick) {
|
||||
background-color: red;
|
||||
}
|
||||
ion-range::part(tick-active) {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-range label="Label" value="50" snaps="true" ticks="true"></ion-range>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
|
||||
const tickBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const tick = shadowRoot?.querySelector('.range-tick:not(.range-tick-active)');
|
||||
return tick ? window.getComputedStyle(tick).backgroundColor : '';
|
||||
});
|
||||
expect(tickBackgroundColor).toBe('rgb(255, 0, 0)');
|
||||
|
||||
const activeTickBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const activeTick = shadowRoot?.querySelector('.range-tick-active');
|
||||
return activeTick ? window.getComputedStyle(activeTick).backgroundColor : '';
|
||||
});
|
||||
expect(activeTickBackgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize knob parts', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-range::part(knob-handle) {
|
||||
background-color: red;
|
||||
}
|
||||
ion-range::part(knob) {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-range label="Label" value="50"></ion-range>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
|
||||
const knobHandleBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const knobHandle = shadowRoot?.querySelector('.range-knob-handle');
|
||||
return knobHandle ? window.getComputedStyle(knobHandle).backgroundColor : '';
|
||||
});
|
||||
expect(knobHandleBackgroundColor).toBe('rgb(255, 0, 0)');
|
||||
|
||||
const knobBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const knob = shadowRoot?.querySelector('.range-knob');
|
||||
return knob ? window.getComputedStyle(knob).backgroundColor : '';
|
||||
});
|
||||
expect(knobBackgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should be able to customize dual knob a & b parts', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-range::part(knob-handle-a) {
|
||||
background-color: red;
|
||||
}
|
||||
ion-range::part(knob-handle-b) {
|
||||
background-color: green;
|
||||
}
|
||||
ion-range::part(knob-a) {
|
||||
background-color: blue;
|
||||
}
|
||||
ion-range::part(knob-b) {
|
||||
background-color: yellow;
|
||||
}
|
||||
ion-range::part(pin-a) {
|
||||
background-color: orange;
|
||||
}
|
||||
ion-range::part(pin-b) {
|
||||
background-color: purple;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-range label="Label" dual-knobs="true" pin="true"></ion-range>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
await range.evaluate((el) => {
|
||||
(el as any).value = {
|
||||
lower: 25,
|
||||
upper: 75,
|
||||
};
|
||||
});
|
||||
await page.waitForChanges();
|
||||
|
||||
const knobHandleABackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const knobHandleA = shadowRoot?.querySelector('.range-knob-handle-a');
|
||||
return knobHandleA ? window.getComputedStyle(knobHandleA).backgroundColor : '';
|
||||
});
|
||||
expect(knobHandleABackgroundColor).toBe('rgb(255, 0, 0)');
|
||||
|
||||
const knobHandleBBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const knobHandleB = shadowRoot?.querySelector('.range-knob-handle-b');
|
||||
return knobHandleB ? window.getComputedStyle(knobHandleB).backgroundColor : '';
|
||||
});
|
||||
expect(knobHandleBBackgroundColor).toBe('rgb(0, 128, 0)');
|
||||
|
||||
// We query for the knob inside knob-handle-a because the knob
|
||||
// does not get a class for the knob name, only a part.
|
||||
const knobABackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const knobA = shadowRoot?.querySelector('.range-knob-handle-a .range-knob');
|
||||
return knobA ? window.getComputedStyle(knobA).backgroundColor : '';
|
||||
});
|
||||
expect(knobABackgroundColor).toBe('rgb(0, 0, 255)');
|
||||
|
||||
// We query for the knob inside knob-handle-b because the knob
|
||||
// does not get a class for the knob name, only a part.
|
||||
const knobBBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const knobB = shadowRoot?.querySelector('.range-knob-handle-b .range-knob');
|
||||
return knobB ? window.getComputedStyle(knobB).backgroundColor : '';
|
||||
});
|
||||
expect(knobBBackgroundColor).toBe('rgb(255, 255, 0)');
|
||||
|
||||
// We query for the pin inside knob-handle-a because the pin
|
||||
// does not get a class for the knob name, only a part.
|
||||
const pinABackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const pinA = shadowRoot?.querySelector('.range-knob-handle-a .range-pin');
|
||||
return pinA ? window.getComputedStyle(pinA).backgroundColor : '';
|
||||
});
|
||||
expect(pinABackgroundColor).toBe('rgb(255, 165, 0)');
|
||||
|
||||
// We query for the pin inside knob-handle-b because the pin
|
||||
// does not get a class for the knob name, only a part.
|
||||
const pinBBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const pinB = shadowRoot?.querySelector('.range-knob-handle-b .range-pin');
|
||||
return pinB ? window.getComputedStyle(pinB).backgroundColor : '';
|
||||
});
|
||||
expect(pinBBackgroundColor).toBe('rgb(128, 0, 128)');
|
||||
});
|
||||
|
||||
test('should be able to customize dual knob lower & upper parts', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-range::part(knob-handle-lower) {
|
||||
background-color: red;
|
||||
}
|
||||
ion-range::part(knob-handle-upper) {
|
||||
background-color: green;
|
||||
}
|
||||
ion-range::part(knob-lower) {
|
||||
background-color: blue;
|
||||
}
|
||||
ion-range::part(knob-upper) {
|
||||
background-color: yellow;
|
||||
}
|
||||
ion-range::part(pin-lower) {
|
||||
background-color: orange;
|
||||
}
|
||||
ion-range::part(pin-upper) {
|
||||
background-color: purple;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-range label="Label" dual-knobs="true" pin="true"></ion-range>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
await range.evaluate((el) => {
|
||||
(el as any).value = {
|
||||
lower: 25,
|
||||
upper: 75,
|
||||
};
|
||||
});
|
||||
await page.waitForChanges();
|
||||
|
||||
// Lower & upper are added as shadow parts but not CSS classes, so we
|
||||
// have to query by their shadow parts.
|
||||
const knobHandleLowerBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const knobHandleLower = shadowRoot?.querySelector('[part~="knob-handle-lower"]');
|
||||
return knobHandleLower ? window.getComputedStyle(knobHandleLower).backgroundColor : '';
|
||||
});
|
||||
expect(knobHandleLowerBackgroundColor).toBe('rgb(255, 0, 0)');
|
||||
|
||||
const knobHandleUpperBackgroundColor = await range.evaluate((el) => {
|
||||
const shadowRoot = el.shadowRoot;
|
||||
const knobHandleUpper = shadowRoot?.querySelector('[part~="knob-handle-upper"]');
|
||||
return knobHandleUpper ? window.getComputedStyle(knobHandleUpper).backgroundColor : '';
|
||||
});
|
||||
expect(knobHandleUpperBackgroundColor).toBe('rgb(0, 128, 0)');
|
||||
|
||||
const knobLowerBackgroundColor = await range.evaluate((el) => {
|
||||
const knobLower = el.shadowRoot?.querySelector('[part~="knob-lower"]');
|
||||
return knobLower ? window.getComputedStyle(knobLower).backgroundColor : '';
|
||||
});
|
||||
expect(knobLowerBackgroundColor).toBe('rgb(0, 0, 255)');
|
||||
|
||||
const knobUpperBackgroundColor = await range.evaluate((el) => {
|
||||
const knobUpper = el.shadowRoot?.querySelector('[part~="knob-upper"]');
|
||||
return knobUpper ? window.getComputedStyle(knobUpper).backgroundColor : '';
|
||||
});
|
||||
expect(knobUpperBackgroundColor).toBe('rgb(255, 255, 0)');
|
||||
|
||||
const pinLowerBackgroundColor = await range.evaluate((el) => {
|
||||
const pinLower = el.shadowRoot?.querySelector('[part~="pin-lower"]');
|
||||
return pinLower ? window.getComputedStyle(pinLower).backgroundColor : '';
|
||||
});
|
||||
expect(pinLowerBackgroundColor).toBe('rgb(255, 165, 0)');
|
||||
|
||||
const pinUpperBackgroundColor = await range.evaluate((el) => {
|
||||
const pinUpper = el.shadowRoot?.querySelector('[part~="pin-upper"]');
|
||||
return pinUpper ? window.getComputedStyle(pinUpper).backgroundColor : '';
|
||||
});
|
||||
expect(pinUpperBackgroundColor).toBe('rgb(128, 0, 128)');
|
||||
});
|
||||
|
||||
test('should keep a & b parts on same elements and swap lower & upper parts when values swap', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-range label="Label" dual-knobs="true" pin="true"></ion-range>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const range = page.locator('ion-range');
|
||||
await range.evaluate((el) => {
|
||||
(el as any).value = { lower: 25, upper: 75 };
|
||||
});
|
||||
await page.waitForChanges();
|
||||
|
||||
// On load: query each a & b part to check lower & upper
|
||||
const handleAHasLowerOnLoad = await range.evaluate((el) => {
|
||||
const handleA = el.shadowRoot?.querySelector('[part~="knob-handle-a"]');
|
||||
const part = handleA?.getAttribute('part') ?? '';
|
||||
return part.includes('knob-handle-lower');
|
||||
});
|
||||
const handleAHasUpperOnLoad = await range.evaluate((el) => {
|
||||
const handleA = el.shadowRoot?.querySelector('[part~="knob-handle-a"]');
|
||||
const part = handleA?.getAttribute('part') ?? '';
|
||||
return part.includes('knob-handle-upper');
|
||||
});
|
||||
const handleBHasLowerOnLoad = await range.evaluate((el) => {
|
||||
const handleB = el.shadowRoot?.querySelector('[part~="knob-handle-b"]');
|
||||
const part = handleB?.getAttribute('part') ?? '';
|
||||
return part.includes('knob-handle-lower');
|
||||
});
|
||||
const handleBHasUpperOnLoad = await range.evaluate((el) => {
|
||||
const handleB = el.shadowRoot?.querySelector('[part~="knob-handle-b"]');
|
||||
const part = handleB?.getAttribute('part') ?? '';
|
||||
return part.includes('knob-handle-upper');
|
||||
});
|
||||
|
||||
// The lower knob is assigned to the a part and the
|
||||
// upper knob is assigned to the b part on load
|
||||
expect(handleAHasLowerOnLoad).toBe(true);
|
||||
expect(handleAHasUpperOnLoad).toBe(false);
|
||||
expect(handleBHasLowerOnLoad).toBe(false);
|
||||
expect(handleBHasUpperOnLoad).toBe(true);
|
||||
|
||||
// Drag the lower knob right so the two knobs swap positions
|
||||
const box = await range.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
const startX = box!.x + box!.width * 0.25;
|
||||
const startY = box!.y + box!.height / 2;
|
||||
const dragDistance = Math.round(box!.width * 0.55);
|
||||
await dragElementBy(range, page, dragDistance, 0, startX, startY);
|
||||
await page.waitForChanges();
|
||||
|
||||
// After swap: the same elements have parts A and B
|
||||
// but lower and upper have swapped positions
|
||||
const handleAHasLowerAfterSwap = await range.evaluate((el) => {
|
||||
const handleA = el.shadowRoot?.querySelector('[part~="knob-handle-a"]');
|
||||
const part = handleA?.getAttribute('part') ?? '';
|
||||
return part.includes('knob-handle-lower');
|
||||
});
|
||||
const handleAHasUpperAfterSwap = await range.evaluate((el) => {
|
||||
const handleA = el.shadowRoot?.querySelector('[part~="knob-handle-a"]');
|
||||
const part = handleA?.getAttribute('part') ?? '';
|
||||
return part.includes('knob-handle-upper');
|
||||
});
|
||||
const handleBHasLowerAfterSwap = await range.evaluate((el) => {
|
||||
const handleB = el.shadowRoot?.querySelector('[part~="knob-handle-b"]');
|
||||
const part = handleB?.getAttribute('part') ?? '';
|
||||
return part.includes('knob-handle-lower');
|
||||
});
|
||||
const handleBHasUpperAfterSwap = await range.evaluate((el) => {
|
||||
const handleB = el.shadowRoot?.querySelector('[part~="knob-handle-b"]');
|
||||
const part = handleB?.getAttribute('part') ?? '';
|
||||
return part.includes('knob-handle-upper');
|
||||
});
|
||||
|
||||
// After swap: the lower knob is assigned to the b part and the
|
||||
// upper knob is assigned to the a part
|
||||
expect(handleAHasLowerAfterSwap).toBe(false);
|
||||
expect(handleAHasUpperAfterSwap).toBe(true);
|
||||
expect(handleBHasLowerAfterSwap).toBe(true);
|
||||
expect(handleBHasUpperAfterSwap).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 751 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 782 B |
@@ -3,9 +3,12 @@ import { newSpecPage } from '@stencil/core/testing';
|
||||
import { Item } from '../../item/item';
|
||||
import { Range } from '../range';
|
||||
|
||||
const waitForEvent = (el: HTMLElement, eventName: string) =>
|
||||
new Promise<void>((resolve) => el.addEventListener(eventName, () => resolve(), { once: true }));
|
||||
|
||||
let sharedRange: Range;
|
||||
|
||||
describe('Range', () => {
|
||||
describe('range: values', () => {
|
||||
beforeEach(() => {
|
||||
sharedRange = new Range();
|
||||
});
|
||||
@@ -87,7 +90,7 @@ describe('Range', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('range id', () => {
|
||||
describe('range: id', () => {
|
||||
it('should render custom id if passed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
@@ -234,22 +237,632 @@ describe('range: item adjustments', () => {
|
||||
expect(range.classList.contains('range-item-start-adjustment')).toBe(false);
|
||||
expect(range.classList.contains('range-item-end-adjustment')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shadow parts', () => {
|
||||
it('should have shadow parts', async () => {
|
||||
describe('range: css classes', () => {
|
||||
describe('value state classes', () => {
|
||||
it('should apply range-value-min class when value is at min', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range pin="true" snaps="true" value="50" label="Label"></ion-range>`,
|
||||
html: `<ion-range min="0" max="100" value="0"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
expect(range).toHaveShadowPart('label');
|
||||
expect(range).toHaveShadowPart('pin');
|
||||
expect(range).toHaveShadowPart('knob');
|
||||
expect(range).toHaveShadowPart('bar');
|
||||
expect(range).toHaveShadowPart('bar-active');
|
||||
expect(range).toHaveShadowPart('tick');
|
||||
expect(range).toHaveShadowPart('tick-active');
|
||||
expect(range.classList.contains('range-value-min')).toBe(true);
|
||||
expect(range.classList.contains('range-value-max')).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply range-value-max class when value is at max', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range min="0" max="100" value="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
expect(range.classList.contains('range-value-max')).toBe(true);
|
||||
expect(range.classList.contains('range-value-min')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not apply range-value-min or range-value-max classes when value is in the middle', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range min="0" max="100" value="50"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
expect(range.classList.contains('range-value-min')).toBe(false);
|
||||
expect(range.classList.contains('range-value-max')).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply range-value-min class when lower knob is at min in dual knobs', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
range.value = { lower: 0, upper: 50 };
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-value-min')).toBe(true);
|
||||
expect(range.classList.contains('range-value-max')).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply range-value-max class when upper knob is at max in dual knobs', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
range.value = { lower: 50, upper: 100 };
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-value-max')).toBe(true);
|
||||
expect(range.classList.contains('range-value-min')).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply range-value-min and range-value-max classes for dual knobs when both are at boundaries', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
range.value = { lower: 0, upper: 100 };
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-value-min')).toBe(true);
|
||||
expect(range.classList.contains('range-value-max')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not apply range-value-min or range-value-max classes for dual knobs when neither is at boundaries', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
range.value = { lower: 25, upper: 75 };
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-value-min')).toBe(false);
|
||||
expect(range.classList.contains('range-value-max')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean property classes', () => {
|
||||
it('should not have any boolean classes by default', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
expect(range.classList.contains('range-disabled')).toBe(false);
|
||||
expect(range.classList.contains('range-dual-knobs')).toBe(false);
|
||||
expect(range.classList.contains('range-has-pin')).toBe(false);
|
||||
});
|
||||
|
||||
it('should have range-disabled class when disabled is true', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range disabled="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
expect(range.classList.contains('range-disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have range-dual-knobs class when dual-knobs is true true', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
expect(range.classList.contains('range-dual-knobs')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have range-has-pin class when pin is true', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
expect(range.classList.contains('range-has-pin')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pressed state classes', () => {
|
||||
it('should have range-pressed class when knob is pressed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// Simulate a pressed knob A by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.pressedKnob = 'A';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-pressed')).toBe(true);
|
||||
expect(range.classList.contains('range-pressed-a')).toBe(false);
|
||||
expect(range.classList.contains('range-pressed-lower')).toBe(false);
|
||||
expect(range.classList.contains('range-pressed-b')).toBe(false);
|
||||
expect(range.classList.contains('range-pressed-upper')).toBe(false);
|
||||
});
|
||||
|
||||
it('should have range-pressed-lower class when lower knob is pressed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// Simulate a pressed knob A by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.pressedKnob = 'A';
|
||||
|
||||
component.ratioA = 0.5;
|
||||
component.ratioB = 0.8;
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-pressed-lower')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have range-pressed-upper class when upper knob is pressed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// Simulate a pressed knob B by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.pressedKnob = 'B';
|
||||
|
||||
component.ratioA = 0.5;
|
||||
component.ratioB = 0.8;
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-pressed-upper')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have range-pressed-a class when knob A is pressed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// Simulate a pressed knob A by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.pressedKnob = 'A';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-pressed-a')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have range-pressed-b class when knob B is pressed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
|
||||
});
|
||||
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// Simulate a pressed knob B by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.pressedKnob = 'B';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(range.classList.contains('range-pressed-b')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('range: shadow parts', () => {
|
||||
describe('static shadow parts', () => {
|
||||
it('should have default shadow parts', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// bar and bar-active parts always exist
|
||||
expect(range).toHaveShadowPart('bar');
|
||||
expect(range).toHaveShadowPart('bar-active');
|
||||
|
||||
// label part always exists
|
||||
expect(range).toHaveShadowPart('label');
|
||||
|
||||
// knob-handle and knob parts always exist
|
||||
expect(range).toHaveShadowPart('knob-handle');
|
||||
expect(range).toHaveShadowPart('knob');
|
||||
|
||||
// knob-handle a, b, lower, and upper parts only exist when dualKnobs is true
|
||||
expect(range).not.toHaveShadowPart('knob-handle-a');
|
||||
expect(range).not.toHaveShadowPart('knob-handle-b');
|
||||
expect(range).not.toHaveShadowPart('knob-handle-lower');
|
||||
expect(range).not.toHaveShadowPart('knob-handle-upper');
|
||||
|
||||
// knob a, b, lower, and upper parts only exist when dualKnobs is true
|
||||
expect(range).not.toHaveShadowPart('knob-a');
|
||||
expect(range).not.toHaveShadowPart('knob-b');
|
||||
expect(range).not.toHaveShadowPart('knob-lower');
|
||||
expect(range).not.toHaveShadowPart('knob-upper');
|
||||
|
||||
// pin only exists when pin is true
|
||||
expect(range).not.toHaveShadowPart('pin');
|
||||
|
||||
// pin a, b, lower, and upper only exist when both pin and dualKnobs are true
|
||||
expect(range).not.toHaveShadowPart('pin-a');
|
||||
expect(range).not.toHaveShadowPart('pin-b');
|
||||
expect(range).not.toHaveShadowPart('pin-lower');
|
||||
expect(range).not.toHaveShadowPart('pin-upper');
|
||||
|
||||
// ticks only exist when ticks is true
|
||||
expect(range).not.toHaveShadowPart('tick');
|
||||
expect(range).not.toHaveShadowPart('tick-active');
|
||||
});
|
||||
|
||||
it('should have tick shadow parts', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range snaps="true" ticks="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// ticks only exist when both snaps and ticks are true
|
||||
expect(range).toHaveShadowPart('tick');
|
||||
expect(range).toHaveShadowPart('tick-active');
|
||||
});
|
||||
|
||||
it('should have pin shadow part', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// pin only exists when pin is true
|
||||
expect(range).toHaveShadowPart('pin');
|
||||
|
||||
// pin a, b, lower, and upper only exist when both pin and dualKnobs are true
|
||||
expect(range).not.toHaveShadowPart('pin-a');
|
||||
expect(range).not.toHaveShadowPart('pin-b');
|
||||
expect(range).not.toHaveShadowPart('pin-lower');
|
||||
expect(range).not.toHaveShadowPart('pin-upper');
|
||||
});
|
||||
|
||||
it('should have dual knob shadow parts', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
|
||||
// knob-handle a, b, lower, and upper parts only exist when dualKnobs is true
|
||||
expect(range).toHaveShadowPart('knob-handle-a');
|
||||
expect(range).toHaveShadowPart('knob-handle-b');
|
||||
expect(range).toHaveShadowPart('knob-handle-lower');
|
||||
expect(range).toHaveShadowPart('knob-handle-upper');
|
||||
|
||||
// knob a, b, lower, and upper parts only exist when dualKnobs is true
|
||||
expect(range).toHaveShadowPart('knob-a');
|
||||
expect(range).toHaveShadowPart('knob-b');
|
||||
expect(range).toHaveShadowPart('knob-lower');
|
||||
expect(range).toHaveShadowPart('knob-upper');
|
||||
|
||||
// pin only exists when pin is true
|
||||
expect(range).toHaveShadowPart('pin');
|
||||
|
||||
// pin a, b, lower, and upper only exist when both pin and dualKnobs are true
|
||||
expect(range).toHaveShadowPart('pin-a');
|
||||
expect(range).toHaveShadowPart('pin-b');
|
||||
expect(range).toHaveShadowPart('pin-lower');
|
||||
expect(range).toHaveShadowPart('pin-upper');
|
||||
});
|
||||
});
|
||||
|
||||
describe('state shadow parts', () => {
|
||||
it('should have pressed shadow part when pressed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
const shadowRoot = range.shadowRoot!;
|
||||
|
||||
// The pressed part should not exist on the knob by default
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).toBeNull();
|
||||
|
||||
// Simulate a pressed knob by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.pressedKnob = 'A';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The pressed part should exist on the knob when pressed
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).not.toBeNull();
|
||||
|
||||
// Clear the pressed knob
|
||||
component.pressedKnob = undefined;
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The pressed part should not exist after clearing the pressed knob
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should have focused shadow part when focused', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
const shadowRoot = range.shadowRoot!;
|
||||
|
||||
// The focused part should not exist on the knob by default
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).toBeNull();
|
||||
|
||||
// Focus the knob handle
|
||||
const knobHandle = shadowRoot.querySelector('.range-knob-handle') as HTMLElement;
|
||||
knobHandle.focus();
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The focused part should exist on the knob when focused
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).not.toBeNull();
|
||||
|
||||
// Blur the knob handle
|
||||
knobHandle.blur();
|
||||
await waitForEvent(range, 'ionBlur');
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The focused part should not exist after blur
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should have activated shadow part when activated', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
const shadowRoot = range.shadowRoot!;
|
||||
|
||||
// The activated part should not exist on the knob by default
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="activated"]')).toBeNull();
|
||||
|
||||
// Simulate an activated knob by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.activatedKnob = 'A';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The activated part should exist on the knob when activated
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="activated"]')).not.toBeNull();
|
||||
|
||||
// Clear the activated knob
|
||||
component.activatedKnob = undefined;
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The activated part should not exist after clearing the activated knob
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="activated"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should have hover shadow part when hovered', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
const shadowRoot = range.shadowRoot!;
|
||||
|
||||
// The hover part should not exist on the knob by default
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="hover"]')).toBeNull();
|
||||
|
||||
// Simulate a hovered knob by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.hoveredKnob = 'A';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The hover part should exist on the knob when hovered
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="hover"]')).not.toBeNull();
|
||||
|
||||
// Clear the hovered knob
|
||||
component.hoveredKnob = undefined;
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The hover part should not exist after clearing the hovered knob
|
||||
expect(shadowRoot.querySelector('[part~="knob"][part~="hover"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should have pressed shadow part on only one knob when dual-knobs is true', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
const shadowRoot = range.shadowRoot!;
|
||||
|
||||
// The pressed part should not exist on either knob by default
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
|
||||
|
||||
// Simulate a pressed knob A by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.pressedKnob = 'A';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The pressed part should exist on knob A only
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).not.toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
|
||||
|
||||
// Simulate a pressed knob B by setting state on component instance
|
||||
component.pressedKnob = 'B';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The pressed part should now exist on knob B only
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).not.toBeNull();
|
||||
|
||||
// Clear the pressed knob
|
||||
component.pressedKnob = undefined;
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The pressed part should not exist after clearing the pressed knob
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should have focused shadow part on only one knob when dual-knobs is true', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
const shadowRoot = range.shadowRoot!;
|
||||
|
||||
// The focused part should not exist on either knob by default
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
|
||||
|
||||
// Focus knob handle A
|
||||
const knobHandleA = shadowRoot.querySelector('.range-knob-handle-a') as HTMLElement;
|
||||
knobHandleA.focus();
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The focused part should exist on knob A only
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).not.toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
|
||||
|
||||
// Focus knob handle B
|
||||
const knobHandleB = shadowRoot.querySelector('.range-knob-handle-b') as HTMLElement;
|
||||
knobHandleB.focus();
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The focused part should now exist on knob B only
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).not.toBeNull();
|
||||
|
||||
// Blur knob handle B
|
||||
knobHandleB.blur();
|
||||
await waitForEvent(range, 'ionBlur');
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The focused part should not exist after blurring the knob handle
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should have activated shadow part on only one knob when dual-knobs is true', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
const shadowRoot = range.shadowRoot!;
|
||||
|
||||
// The activated part should not exist on either knob by default
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="activated"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="activated"]')).toBeNull();
|
||||
|
||||
// Simulate an activated knob A by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.activatedKnob = 'A';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The activated part should exist on knob A only
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="activated"]')).not.toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="activated"]')).toBeNull();
|
||||
|
||||
// Simulate an activated knob B by setting state on component instance
|
||||
component.activatedKnob = 'B';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The activated part should now exist on knob B only
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="activated"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="activated"]')).not.toBeNull();
|
||||
|
||||
// Clear the activated knob
|
||||
component.activatedKnob = undefined;
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The activated part should not exist after clearing the activated knob
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="activated"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="activated"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should have hover shadow part on only one knob when dual-knobs is true', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Range],
|
||||
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
|
||||
});
|
||||
const range = page.body.querySelector('ion-range')!;
|
||||
const shadowRoot = range.shadowRoot!;
|
||||
|
||||
// The hover part should not exist on either knob by default
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="hover"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="hover"]')).toBeNull();
|
||||
|
||||
// Simulate a hovered knob A by setting state on component instance
|
||||
const component = page.rootInstance;
|
||||
component.hoveredKnob = 'A';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The hover part should exist on knob A only
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="hover"]')).not.toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="hover"]')).toBeNull();
|
||||
|
||||
// Simulate a hovered knob B by setting state on component instance
|
||||
component.hoveredKnob = 'B';
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The hover part should now exist on knob B only
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="hover"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="hover"]')).not.toBeNull();
|
||||
|
||||
// Clear the hovered knob
|
||||
component.hoveredKnob = undefined;
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
// The hover part should not exist after clearing the hovered knob
|
||||
expect(shadowRoot.querySelector('[part~="knob-a"][part~="hover"]')).toBeNull();
|
||||
expect(shadowRoot.querySelector('[part~="knob-b"][part~="hover"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,16 @@ export interface RefresherEventDetail {
|
||||
complete(): void;
|
||||
}
|
||||
|
||||
export interface RefresherPullEndEventDetail {
|
||||
reason: 'complete' | 'cancel';
|
||||
}
|
||||
|
||||
export interface RefresherCustomEvent extends CustomEvent {
|
||||
detail: RefresherEventDetail;
|
||||
target: HTMLIonRefresherElement;
|
||||
}
|
||||
|
||||
export interface RefresherPullEndCustomEvent extends CustomEvent {
|
||||
detail: RefresherPullEndEventDetail;
|
||||
target: HTMLIonRefresherElement;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ImpactStyle, hapticImpact } from '@utils/native/haptic';
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import type { Animation, Gesture, GestureDetail } from '../../interface';
|
||||
|
||||
import type { RefresherEventDetail } from './refresher-interface';
|
||||
import type { RefresherEventDetail, RefresherPullEndEventDetail } from './refresher-interface';
|
||||
import {
|
||||
createPullingAnimation,
|
||||
createSnapBackAnimation,
|
||||
@@ -107,8 +107,8 @@ export class Refresher implements ComponentInterface {
|
||||
* than `1`. The default value is `1` which is equal to the speed of the cursor.
|
||||
* If a negative value is passed in, the factor will be `1` instead.
|
||||
*
|
||||
* For example: If the value passed is `1.2` and the content is dragged by
|
||||
* `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels
|
||||
* For example, If the value passed is `1.2` and the content is dragged by
|
||||
* `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels
|
||||
* (an increase of 20 percent). If the value passed is `0.8`, the dragged amount
|
||||
* will be `8` pixels, less than the amount the cursor has moved.
|
||||
*
|
||||
@@ -141,10 +141,25 @@ export class Refresher implements ComponentInterface {
|
||||
*/
|
||||
@Event() ionPull!: EventEmitter<void>;
|
||||
|
||||
// TODO(FW-7044): Remove this in a major release
|
||||
/**
|
||||
* Emitted when the user begins to start pulling down.
|
||||
*
|
||||
* @deprecated Use `ionPullStart` instead.
|
||||
*/
|
||||
@Event() ionStart!: EventEmitter<void>;
|
||||
|
||||
/**
|
||||
* Emitted when the user begins to start pulling down.
|
||||
*/
|
||||
@Event() ionStart!: EventEmitter<void>;
|
||||
@Event() ionPullStart!: EventEmitter<void>;
|
||||
|
||||
/**
|
||||
* Emitted when the refresher has returned to the inactive state
|
||||
* after a pull gesture. This fires whether the refresh completed
|
||||
* successfully or was canceled.
|
||||
*/
|
||||
@Event() ionPullEnd!: EventEmitter<RefresherPullEndEventDetail>;
|
||||
|
||||
private async checkNativeRefresher() {
|
||||
const useNativeRefresher = await shouldUseNativeRefresher(this.el, getIonMode(this));
|
||||
@@ -182,6 +197,10 @@ export class Refresher implements ComponentInterface {
|
||||
this.progress = 0;
|
||||
|
||||
this.state = RefresherState.Inactive;
|
||||
|
||||
this.ionPullEnd.emit({
|
||||
reason: state === RefresherState.Completing ? 'complete' : 'cancel',
|
||||
});
|
||||
}
|
||||
|
||||
private async setupiOSNativeRefresher(
|
||||
@@ -224,6 +243,7 @@ export class Refresher implements ComponentInterface {
|
||||
if (!this.didStart) {
|
||||
this.didStart = true;
|
||||
this.ionStart.emit();
|
||||
this.ionPullStart.emit();
|
||||
}
|
||||
|
||||
// emit "pulling" on every move
|
||||
@@ -308,6 +328,7 @@ export class Refresher implements ComponentInterface {
|
||||
this.lastVelocityY = ev.velocityY;
|
||||
},
|
||||
onEnd: () => {
|
||||
const hadStarted = this.didStart;
|
||||
this.pointerDown = false;
|
||||
this.didStart = false;
|
||||
|
||||
@@ -316,6 +337,12 @@ export class Refresher implements ComponentInterface {
|
||||
this.needsCompletion = false;
|
||||
} else if (this.didRefresh) {
|
||||
readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`));
|
||||
} else if (hadStarted) {
|
||||
/**
|
||||
* User started pulling but released before reaching the refresh threshold.
|
||||
* Emit ionPullEnd to complete the event pair.
|
||||
*/
|
||||
this.ionPullEnd.emit({ reason: 'cancel' });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -378,6 +405,7 @@ export class Refresher implements ComponentInterface {
|
||||
ev.data.animation = animation;
|
||||
animation.progressStart(false, 0);
|
||||
this.ionStart.emit();
|
||||
this.ionPullStart.emit();
|
||||
this.animations.push(animation);
|
||||
|
||||
return;
|
||||
@@ -405,6 +433,7 @@ export class Refresher implements ComponentInterface {
|
||||
this.animations = [];
|
||||
this.gesture!.enable(true);
|
||||
this.state = RefresherState.Inactive;
|
||||
this.ionPullEnd.emit({ reason: 'cancel' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -684,6 +713,7 @@ export class Refresher implements ComponentInterface {
|
||||
if (!this.didStart) {
|
||||
this.didStart = true;
|
||||
this.ionStart.emit();
|
||||
this.ionPullStart.emit();
|
||||
}
|
||||
|
||||
// emit "pulling" on every move
|
||||
@@ -731,6 +761,16 @@ export class Refresher implements ComponentInterface {
|
||||
* available right away.
|
||||
*/
|
||||
this.restoreOverflowStyle();
|
||||
|
||||
/**
|
||||
* If ionPullStart was emitted, we need to emit ionPullEnd
|
||||
* even though the gesture was aborted before reaching the
|
||||
* pulling threshold.
|
||||
*/
|
||||
if (this.didStart) {
|
||||
this.didStart = false;
|
||||
this.ionPullEnd.emit({ reason: 'cancel' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,6 +823,10 @@ export class Refresher implements ComponentInterface {
|
||||
if (this.contentFullscreen && this.backgroundContentEl) {
|
||||
this.backgroundContentEl?.style.removeProperty('--offset-top');
|
||||
}
|
||||
|
||||
this.ionPullEnd.emit({
|
||||
reason: state === RefresherState.Completing ? 'complete' : 'cancel',
|
||||
});
|
||||
}, 600);
|
||||
|
||||
// reset the styles on the scroll element
|
||||
|
||||
@@ -56,6 +56,17 @@
|
||||
window.dispatchEvent(new CustomEvent('ionRefreshComplete'));
|
||||
});
|
||||
|
||||
// Event listeners for new ionPullStart and ionPullEnd events
|
||||
refresher.addEventListener('ionPullStart', function () {
|
||||
console.log('ionPullStart fired');
|
||||
window.dispatchEvent(new CustomEvent('ionPullStartFired'));
|
||||
});
|
||||
|
||||
refresher.addEventListener('ionPullEnd', function (event) {
|
||||
console.log('ionPullEnd fired', event.detail);
|
||||
window.dispatchEvent(new CustomEvent('ionPullEndFired', { detail: event.detail }));
|
||||
});
|
||||
|
||||
function render() {
|
||||
let html = '';
|
||||
for (let item of items) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
import { configs, dragElementByYAxis, test } from '@utils/test/playwright';
|
||||
|
||||
import { pullToRefresh } from '../test.utils';
|
||||
|
||||
@@ -22,6 +22,37 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
|
||||
expect(await items.count()).toBe(60);
|
||||
});
|
||||
|
||||
test('should emit ionPullStart and ionPullEnd with reason complete', async ({ page }) => {
|
||||
const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired');
|
||||
const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired');
|
||||
|
||||
await pullToRefresh(page);
|
||||
|
||||
// Wait for the close animation to complete
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
expect(ionPullStartEvent).toHaveReceivedEventTimes(1);
|
||||
expect(ionPullEndEvent).toHaveReceivedEventTimes(1);
|
||||
expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'complete' });
|
||||
});
|
||||
|
||||
test('should emit ionPullEnd with reason cancel when pull is released early', async ({ page }) => {
|
||||
const target = page.locator('body');
|
||||
|
||||
const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired');
|
||||
const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired');
|
||||
|
||||
// Pull down only 40px (less than pullMin of 60px) to trigger cancel
|
||||
await dragElementByYAxis(target, page, 40);
|
||||
|
||||
// Wait for the cancel animation to complete
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
expect(ionPullStartEvent).toHaveReceivedEventTimes(1);
|
||||
expect(ionPullEndEvent).toHaveReceivedEventTimes(1);
|
||||
expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'cancel' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('native refresher', () => {
|
||||
@@ -41,6 +72,28 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
|
||||
expect(await items.count()).toBe(60);
|
||||
});
|
||||
|
||||
test('should emit ionPullStart and ionPullEnd with reason complete', async ({ page }) => {
|
||||
const refresherContent = page.locator('ion-refresher-content');
|
||||
refresherContent.evaluateHandle((el: any) => {
|
||||
// Resets the pullingIcon to enable the native refresher
|
||||
el.pullingIcon = undefined;
|
||||
});
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired');
|
||||
const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired');
|
||||
|
||||
await pullToRefresh(page);
|
||||
|
||||
// Wait for the reset animation to complete (native refresher takes longer due to CSS transitions)
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
expect(ionPullStartEvent).toHaveReceivedEventTimes(1);
|
||||
expect(ionPullEndEvent).toHaveReceivedEventTimes(1);
|
||||
expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'complete' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host(.segment-view-disabled) {
|
||||
:host(.segment-view-disabled),
|
||||
:host(.segment-view-swipe-disabled) {
|
||||
touch-action: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export class SegmentView implements ComponentInterface {
|
||||
*/
|
||||
@Prop() disabled = false;
|
||||
|
||||
/**
|
||||
* If `true`, users will be able to swipe the segment view to navigate between segment contents.
|
||||
*/
|
||||
@Prop() swipeGesture = true;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
@@ -141,13 +146,14 @@ export class SegmentView implements ComponentInterface {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { disabled, isManualScroll } = this;
|
||||
const { disabled, isManualScroll, swipeGesture } = this;
|
||||
|
||||
return (
|
||||
<Host
|
||||
class={{
|
||||
'segment-view-disabled': disabled,
|
||||
'segment-view-scroll-disabled': isManualScroll === false,
|
||||
'segment-view-swipe-disabled': swipeGesture === false,
|
||||
}}
|
||||
>
|
||||
<slot></slot>
|
||||
|
||||
147
core/src/components/segment-view/test/swipe-gesture/index.html
Normal file
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Segment View - Swipe Gesture</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>
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
|
||||
color: #6f7378;
|
||||
|
||||
margin: 24px 16px 8px;
|
||||
}
|
||||
|
||||
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 - Swipe Gesture</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<h2>
|
||||
Swipe Gesture: Segment <ion-text color="success">Enabled</ion-text>; Segment View
|
||||
<ion-text color="success">Enabled</ion-text>
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
<h2>
|
||||
Swipe Gesture: Segment <ion-text color="danger">Disabled</ion-text>; Segment View
|
||||
<ion-text color="success">Enabled</ion-text>
|
||||
</h2>
|
||||
<ion-segment swipe-gesture="false" value="free2">
|
||||
<ion-segment-button content-id="paid2" value="paid2">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="free2" value="free2">
|
||||
<ion-label>Free</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="top2" value="top2">
|
||||
<ion-label>Top</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view>
|
||||
<ion-segment-content id="paid2">Paid</ion-segment-content>
|
||||
<ion-segment-content id="free2">Free</ion-segment-content>
|
||||
<ion-segment-content id="top2">Top</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
|
||||
<h2>
|
||||
Swipe Gesture: Segment <ion-text color="success">Enabled</ion-text>; Segment View
|
||||
<ion-text color="danger">Disabled</ion-text>
|
||||
</h2>
|
||||
<ion-segment value="free3">
|
||||
<ion-segment-button content-id="paid3" value="paid3">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="free3" value="free3">
|
||||
<ion-label>Free</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="top3" value="top3">
|
||||
<ion-label>Top</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view swipe-gesture="false">
|
||||
<ion-segment-content id="paid3">Paid</ion-segment-content>
|
||||
<ion-segment-content id="free3">Free</ion-segment-content>
|
||||
<ion-segment-content id="top3">Top</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
|
||||
<h2>
|
||||
Swipe Gesture: Segment <ion-text color="danger">Disabled</ion-text>; Segment View
|
||||
<ion-text color="danger">Disabled</ion-text>
|
||||
</h2>
|
||||
<ion-segment swipe-gesture="false" value="free4">
|
||||
<ion-segment-button content-id="paid4" value="paid4">
|
||||
<ion-label>Paid</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="free4" value="free4">
|
||||
<ion-label>Free</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button content-id="top4" value="top4">
|
||||
<ion-label>Top</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
<ion-segment-view swipe-gesture="false">
|
||||
<ion-segment-content id="paid4">Paid</ion-segment-content>
|
||||
<ion-segment-content id="free4">Free</ion-segment-content>
|
||||
<ion-segment-content id="top4">Top</ion-segment-content>
|
||||
</ion-segment-view>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
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: swipe gesture'), () => {
|
||||
test('should allow swiping the segment view by default', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<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 segmentView = page.locator('ion-segment-view');
|
||||
|
||||
const allowsSwipe = await segmentView.evaluate((el: HTMLElement) => {
|
||||
const style = getComputedStyle(el);
|
||||
return style.overflowX !== 'hidden' && style.touchAction !== 'none';
|
||||
});
|
||||
expect(allowsSwipe).toBe(true);
|
||||
});
|
||||
|
||||
test('should not allow swiping the segment view when swipeGesture is false', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-segment-view swipe-gesture="false">
|
||||
<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 segmentView = page.locator('ion-segment-view');
|
||||
|
||||
const allowsSwipe = await segmentView.evaluate((el: HTMLElement) => {
|
||||
const style = getComputedStyle(el);
|
||||
return style.overflowX !== 'hidden' && style.touchAction !== 'none';
|
||||
});
|
||||
expect(allowsSwipe).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,11 @@ export class SelectModal implements ComponentInterface {
|
||||
|
||||
@Prop() header?: string;
|
||||
|
||||
/**
|
||||
* The text to display on the cancel button.
|
||||
*/
|
||||
@Prop() cancelText = 'Close';
|
||||
|
||||
@Prop() multiple?: boolean;
|
||||
|
||||
@Prop() options: SelectModalOption[] = [];
|
||||
@@ -149,7 +154,7 @@ export class SelectModal implements ComponentInterface {
|
||||
{this.header !== undefined && <ion-title>{this.header}</ion-title>}
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button onClick={() => this.closeModal()}>Close</ion-button>
|
||||
<ion-button onClick={() => this.closeModal()}>{this.cancelText}</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Item - CSS Variables</title>
|
||||
<title>Select - Custom</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
@@ -14,36 +14,27 @@
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
</head>
|
||||
|
||||
<style>
|
||||
ion-item {
|
||||
--padding-top: 20px;
|
||||
--background: #eee;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Item CSS variables</ion-title>
|
||||
<ion-title>Select Modal - Custom</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-vertical">
|
||||
<ion-list class="basic">
|
||||
<ion-item>
|
||||
<ion-label>Item 1</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Item 2</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Item 3</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
<ion-content>
|
||||
<ion-modal is-open="true">
|
||||
<ion-select-modal multiple="false" cancel-text="Close me"></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>
|
||||
@@ -0,0 +1,45 @@
|
||||
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 },
|
||||
];
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions.
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('select-modal: custom'), () => {
|
||||
let selectModalPage: SelectModalPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
selectModalPage = new SelectModalPage(page);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
test('should render custom cancel text when prop is provided', async ({ page: _page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30295',
|
||||
});
|
||||
|
||||
await selectModalPage.setup(config, options, false);
|
||||
|
||||
const cancelButton = selectModalPage.selectModal.locator('ion-button');
|
||||
|
||||
// Verify the default text on the cancel button
|
||||
await expect(cancelButton).toHaveText('Close');
|
||||
|
||||
await selectModalPage.selectModal.evaluate((selectModal: HTMLIonSelectModalElement) => {
|
||||
selectModal.cancelText = 'Close me';
|
||||
});
|
||||
|
||||
// Verify the cancel button text has been updated
|
||||
await expect(cancelButton).toHaveText('Close me');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,9 @@ import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from '
|
||||
* @part supporting-text - Supporting text displayed beneath the select.
|
||||
* @part helper-text - Supporting text displayed beneath the select when the select is valid.
|
||||
* @part error-text - Supporting text displayed beneath the select when the select is invalid and touched.
|
||||
* @part bottom - The container element for helper text, error text, and counter.
|
||||
* @part wrapper - The clickable label element that wraps the entire form field (label text, slots, selected values or placeholder, and toggle icons).
|
||||
* @part inner - The inner element of the wrapper that manages the slots, selected values or placeholder, and toggle icons.
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-select',
|
||||
@@ -795,6 +798,7 @@ export class Select implements ComponentInterface {
|
||||
component: 'ion-select-modal',
|
||||
componentProps: {
|
||||
header: interfaceOptions.header,
|
||||
cancelText: this.cancelText,
|
||||
multiple,
|
||||
value,
|
||||
options: this.createOverlaySelectOptions(this.childOpts, value),
|
||||
@@ -1172,7 +1176,11 @@ export class Select implements ComponentInterface {
|
||||
return;
|
||||
}
|
||||
|
||||
return <div class="select-bottom">{this.renderHintText()}</div>;
|
||||
return (
|
||||
<div class="select-bottom" part="bottom">
|
||||
{this.renderHintText()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -1245,9 +1253,9 @@ export class Select implements ComponentInterface {
|
||||
[`select-label-placement-${labelPlacement}`]: true,
|
||||
})}
|
||||
>
|
||||
<label class="select-wrapper" id="select-label" onClick={this.onLabelClick}>
|
||||
<label class="select-wrapper" id="select-label" onClick={this.onLabelClick} part="wrapper">
|
||||
{this.renderLabelContainer()}
|
||||
<div class="select-wrapper-inner">
|
||||
<div class="select-wrapper-inner" part="inner">
|
||||
<slot name="start"></slot>
|
||||
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
|
||||
{this.renderSelectText()}
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -72,5 +72,142 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
const wrapper = page.locator('.wrapper');
|
||||
await expect(wrapper).toHaveScreenshot(screenshot(`select-custom-parts-diff`));
|
||||
});
|
||||
|
||||
test('should be able to customize wrapper and bottom using css parts', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/29918',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
ion-select::part(wrapper) {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
ion-select::part(inner) {
|
||||
background-color: orange;
|
||||
}
|
||||
|
||||
ion-select::part(bottom) {
|
||||
background-color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ion-select label="Select" label-placement="stacked" placeholder="Fruits" helper-text="Helper text">
|
||||
<ion-select-option value="a">Apple</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
const wrapper = select.locator('.select-wrapper');
|
||||
const wrapperInner = select.locator('.select-wrapper-inner');
|
||||
const bottom = select.locator('.select-bottom');
|
||||
|
||||
const wrapperBackgroundColor = await wrapper.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
const wrapperInnerBackgroundColor = await wrapperInner.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
const bottomBackgroundColor = await bottom.evaluate((el) => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
expect(wrapperBackgroundColor).toBe('rgb(255, 0, 0)');
|
||||
expect(wrapperInnerBackgroundColor).toBe('rgb(255, 165, 0)');
|
||||
expect(bottomBackgroundColor).toBe('rgb(0, 128, 0)');
|
||||
});
|
||||
|
||||
test('should render custom cancel text when prop is provided with alert interface', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select label="Fruit" interface="alert" value="bananas" cancel-text="Close me">
|
||||
<ion-select-option value="apples">Apples</ion-select-option>
|
||||
<ion-select-option value="bananas">Bananas</ion-select-option>
|
||||
<ion-select-option value="oranges">Oranges</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
|
||||
|
||||
await select.click();
|
||||
await ionAlertDidPresent.next();
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
const alert = page.locator('ion-alert');
|
||||
const cancelButton = alert.locator('.alert-button-role-cancel');
|
||||
|
||||
// Verify the cancel button text
|
||||
await expect(cancelButton).toHaveText('Close me');
|
||||
});
|
||||
|
||||
test('should render custom cancel text when prop is provided with action sheet interface', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select label="Fruit" interface="action-sheet" value="bananas" cancel-text="Close me">
|
||||
<ion-select-option value="apples">Apples</ion-select-option>
|
||||
<ion-select-option value="bananas">Bananas</ion-select-option>
|
||||
<ion-select-option value="oranges">Oranges</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
|
||||
|
||||
await select.click();
|
||||
await ionActionSheetDidPresent.next();
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
const actionSheet = page.locator('ion-action-sheet');
|
||||
const cancelButton = actionSheet.locator('.action-sheet-cancel');
|
||||
|
||||
// Verify the cancel button text
|
||||
await expect(cancelButton).toHaveText('Close me');
|
||||
});
|
||||
|
||||
test('should render custom cancel text when prop is provided with modal interface', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30295',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select label="Fruit" interface="modal" value="bananas" cancel-text="Close me">
|
||||
<ion-select-option value="apples">Apples</ion-select-option>
|
||||
<ion-select-option value="bananas">Bananas</ion-select-option>
|
||||
<ion-select-option value="oranges">Oranges</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await select.click();
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
const modal = page.locator('ion-modal');
|
||||
const cancelButton = modal.locator('ion-button');
|
||||
|
||||
// Verify the cancel button text
|
||||
await expect(cancelButton).toHaveText('Close me');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ export class Textarea implements ComponentInterface {
|
||||
/**
|
||||
* If `true`, the user cannot interact with the textarea.
|
||||
*/
|
||||
@Prop() disabled = false;
|
||||
@Prop({ reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* The fill for the item. If `"solid"` the item will have a background. If
|
||||
@@ -177,7 +177,7 @@ export class Textarea implements ComponentInterface {
|
||||
/**
|
||||
* If `true`, the user cannot modify the value.
|
||||
*/
|
||||
@Prop() readonly = false;
|
||||
@Prop({ reflect: true }) readonly = false;
|
||||
|
||||
/**
|
||||
* If `true`, the user must fill in a value before submitting a form.
|
||||
|
||||