Compare commits

..

38 Commits
8.7.x ... main

Author SHA1 Message Date
OS-jacobbell
f04fa23e0d test(angular): validate checkbox and toggle in lazy template-form (#31005)
## What is the current behavior?
Checkbox and toggle components are not validated to be ticked/on in
`packages/angular/test/base/src/app/lazy/template-form`. This prevents
the error text from being displayed. While they have the `required`
attribute, this only applies to accessibility for
[checkbox](https://ionicframework.com/docs/api/checkbox#required) and
[toggle](https://ionicframework.com/docs/api/toggle#required).

## What is the new behavior?
- Use an Angular validator directive for checkbox and toggle.
- Make template-form an Angular module so the validator directive can be
imported.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No
2026-03-12 19:29:07 +00:00
OS-jacobbell
ce83407e1d fix(checkbox): re-evaluate label visibility when label is updated (#30980)
## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->
Checkbox's render function applies the `label-text-wrapper-hidden` css
class when there is no label text to prevent extra margin from being
added. The render function is not re-evaluated if the label is updated.
This causes a problem in Angular where dynamic variables get applied
after the page is loaded, and a checkbox using a variable as a label
gets stuck with its label hidden until something else triggers a
re-render, e.g. ticking the box.
## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->
- The checkbox will be re-rendered, and css classes will be updated,
when the label text is changed.
- Updated tests to check that the label is visible after changing from
blank to having content.
## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->
2026-03-12 18:51:20 +00:00
renovate[bot]
784fdc6543 chore(deps): update download + upload artifacts (major) (#30974)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/download-artifact](https://redirect.github.com/actions/download-artifact)
| action | major | `v7` → `v8` |
|
[actions/upload-artifact](https://redirect.github.com/actions/upload-artifact)
| action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>actions/download-artifact (actions/download-artifact)</summary>

###
[`v8`](https://redirect.github.com/actions/download-artifact/compare/v7...v8)

[Compare
Source](https://redirect.github.com/actions/download-artifact/compare/v7...v8)

</details>

<details>
<summary>actions/upload-artifact (actions/upload-artifact)</summary>

###
[`v7`](https://redirect.github.com/actions/upload-artifact/compare/v6...v7)

[Compare
Source](https://redirect.github.com/actions/upload-artifact/compare/v6...v7)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4zNi4yIiwidXBkYXRlZEluVmVyIjoiNDMuMzYuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 16:20:18 +00:00
renovate[bot]
7dcefa2203 chore(deps): update actions/setup-node action to v6.3.0 (#30987)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-node](https://redirect.github.com/actions/setup-node) |
action | minor | `v6.2.0` → `v6.3.0` |

---

### Release Notes

<details>
<summary>actions/setup-node (actions/setup-node)</summary>

###
[`v6.3.0`](https://redirect.github.com/actions/setup-node/releases/tag/v6.3.0)

[Compare
Source](https://redirect.github.com/actions/setup-node/compare/v6.2.0...v6.3.0)

##### What's Changed

##### Enhancements:

- Support parsing `devEngines` field by
[@&#8203;susnux](https://redirect.github.com/susnux) in
[#&#8203;1283](https://redirect.github.com/actions/setup-node/pull/1283)

> When using node-version-file: package.json, setup-node now
prefers devEngines.runtime over engines.node.

##### Dependency updates:

- Fix npm audit issues by
[@&#8203;gowridurgad](https://redirect.github.com/gowridurgad) in
[#&#8203;1491](https://redirect.github.com/actions/setup-node/pull/1491)
- Replace uuid with crypto.randomUUID() by
[@&#8203;trivikr](https://redirect.github.com/trivikr) in
[#&#8203;1378](https://redirect.github.com/actions/setup-node/pull/1378)
- Upgrade minimatch from 3.1.2 to 3.1.5 by
[@&#8203;dependabot](https://redirect.github.com/dependabot) in
[#&#8203;1498](https://redirect.github.com/actions/setup-node/pull/1498)

##### Bug fixes:

- Remove hardcoded bearer for mirror-url
[@&#8203;marco-ippolito](https://redirect.github.com/marco-ippolito) in
[#&#8203;1467](https://redirect.github.com/actions/setup-node/pull/1467)
- Scope test lockfiles by package manager and update cache tests by
[@&#8203;gowridurgad](https://redirect.github.com/gowridurgad) in
[#&#8203;1495](https://redirect.github.com/actions/setup-node/pull/1495)

##### New Contributors

- [@&#8203;susnux](https://redirect.github.com/susnux) made their first
contribution in
[#&#8203;1283](https://redirect.github.com/actions/setup-node/pull/1283)

**Full Changelog**:
<https://github.com/actions/setup-node/compare/v6...v6.3.0>

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My40OC4xIiwidXBkYXRlZEluVmVyIjoiNDMuNDguMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 16:11:57 +00:00
renovate[bot]
623bf0e2f2 chore(deps): update dependency @capacitor/core to v8.2.0 (#30997)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@capacitor/core](https://capacitorjs.com)
([source](https://redirect.github.com/ionic-team/capacitor)) | [`8.1.0`
→
`8.2.0`](https://renovatebot.com/diffs/npm/@capacitor%2fcore/8.1.0/8.2.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fcore/8.2.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fcore/8.1.0/8.2.0?slim=true)
|

---

### Release Notes

<details>
<summary>ionic-team/capacitor (@&#8203;capacitor/core)</summary>

###
[`v8.2.0`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#820-2026-03-06)

[Compare
Source](https://redirect.github.com/ionic-team/capacitor/compare/8.1.0...09770f7d2dc8d566bb377f9968355090f20918b6)

##### Bug Fixes

- **android:** Add missing null checks in BridgeActivity
([#&#8203;8185](https://redirect.github.com/ionic-team/capacitor/issues/8185))
([bd29b99](bd29b9913a))
- **android:** Concurrent Range Requests for assets
([#&#8203;8357](https://redirect.github.com/ionic-team/capacitor/issues/8357))
([5e82c89](5e82c89f1b))
- **android:** handle lowercase range header
([#&#8203;8368](https://redirect.github.com/ionic-team/capacitor/issues/8368))
([ae0e2dd](ae0e2ddccb))
- **android:** invalid http range seeking
([#&#8203;8369](https://redirect.github.com/ionic-team/capacitor/issues/8369))
([3109d22](3109d22547))
- **cli:** Allow to run update on non macOS
([#&#8203;8344](https://redirect.github.com/ionic-team/capacitor/issues/8344))
([a441280](a441280d7c))
- **cli:** Don't overwrite config.server section with `--live-reload`
([#&#8203;7528](https://redirect.github.com/ionic-team/capacitor/issues/7528))
([782b9d9](782b9d9c26))
- **cli:** use 8.0.0 as default Capacitor SPM dependency version
([#&#8203;8341](https://redirect.github.com/ionic-team/capacitor/issues/8341))
([a55dc5e](a55dc5ee4d))
- **docs:** fix typo in CapApp-SPM README
([#&#8203;8348](https://redirect.github.com/ionic-team/capacitor/issues/8348))
([7d001ac](7d001ac4c5))
- **ios:** remove tmpWindow usages on presentVC/dismissVC
([#&#8203;8338](https://redirect.github.com/ionic-team/capacitor/issues/8338))
([fc9647f](fc9647f26f))

##### Features

- **cli:** Add --https option for --live-reload
([#&#8203;8194](https://redirect.github.com/ionic-team/capacitor/issues/8194))
([5db81e6](5db81e68c6))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41Ni4wIiwidXBkYXRlZEluVmVyIjoiNDMuNTYuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 15:58:55 +00:00
Brandy Smith
5862405e9e merge release-8.8.1 (#30998)
v8.8.1
2026-03-06 13:04:17 -05:00
Brandy Smith
e925d8543f chore(): update changelog files 2026-03-06 12:43:03 -05:00
ionitron
15deeefeae chore(): update package lock files 2026-03-06 17:15:31 +00:00
ionitron
8d07917cd1 v8.8.1 2026-03-06 17:14:07 +00:00
Shane
72abccaad8 fix(angular): export RefresherPullEnd types (#30991)
Issue number: internal

---------

## What is the current behavior?
Currently, the 8.8 refresher events are not exported to Angular

## What is the new behavior?
With this change, we export the new events and also add Angular tests to
validate that they work and will continue working in the future.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No
2026-03-05 23:56:32 +00:00
David Lourenço
0e76a69370 fix(accordion): update tabindex based on disabled state (#30986)
Issue number: resolves internal

---------

## What is the current behavior?
When an accordion item was set as disabled, it was still possible to
focus using keyboard navigation.

## What is the new behavior?
When an accordion item is disabled, `tabindex` is set as -1 to stop
being focusable using the keyboard navigation.

## Does this introduce a breaking change?

- [ ] Yes
- [ ] No

## Other information


[preview](https://ionic-framework-qznd9tqne-ionic1.vercel.app/src/components/accordion-group/test/states/)

---------

Co-authored-by: Maria Hutt <maria.hutt@outsystems.com>
2026-03-05 18:45:32 +00:00
Shane
366f00e25f feat(toast): add wrapper and content parts (#30992)
Issue number: resolves #30735

---------

## What is the current behavior?
Toast's wrapper and content divs do not have part attributes.

## What is the new behavior?
- Adds `wrapper` and `content` parts to Toast.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

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


## Other information

This PR was originally opened by @Hurubon here in #30737. It was lost in
`feature-8.8` due to a bad merge, so this PR is to re-apply this feature
so it can go out in 8.8.1 (it was originally supposed to go out in
8.8.0)

Co-authored-by: Hurubon <58346722+Hurubon@users.noreply.github.com>

Co-authored-by: Hurubon <58346722+Hurubon@users.noreply.github.com>
2026-03-05 16:34:20 +00:00
Brandy Smith
d36aef38a8 merge release-8.8.0 (#30989)
v8.8.0
2026-03-04 16:10:22 -05:00
ionitron
1de6b7a1cb chore(): update package lock files 2026-03-04 20:45:22 +00:00
ionitron
32ab505363 v8.8.0 2026-03-04 20:43:44 +00:00
Brandy Smith
818c138633 chore: merge main (#30988) 2026-03-04 15:33:15 -05:00
Brandy Smith
af0949f5bb Merge branch 'main' into update-from-main 2026-03-04 15:16:31 -05:00
Maria Hutt
d29ac713fa feat(modal): add drag events for sheet and card modals (#30962)
Issue number: internal

---------

## What is the current behavior?
The sheet and card modal can be dragged to view content. However, there
are no events that determine when drag has started or ended.

## What is the new behavior?
- Added drag events for sheet and card modal: `ionDragStart`, `ionDragMove`, `ionDragEnd`
- Added a drag interface
- Added tests

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

---------

Co-authored-by: Shane <shane@shanessite.net>
Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
2026-03-04 14:53:50 -05:00
Brandy Smith
5bcf921841 feat(range): add classes and expose parts to allow individual styling of dual knobs (#30941)
Issue number: resolves #29862

---------

## What is the current behavior?
Range exposes a single part for both knobs & pins. This makes it impossible to style the knobs/pins differently when dual knobs is enabled.

## What is the new behavior?
- Fixes a bug where the knobs would swap A & B when they cross over each other
- Fixes the focus behavior so that dual knobs act the same as a single knob range when focusing a knob
- Adds the following classes to the host element when `dualKnobs` is enabled:
  - `range-dual-knobs`
  - `range-pressed-a` when the knob with name A is pressed
  - `range-pressed-b` when the knob with name B is pressed
  - `range-pressed-lower` when the lower knob is pressed
  - `range-pressed-upper` when the upper knob is pressed
- Adds parts for the following:
  - `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.
  - `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.
  - `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.
  - `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.
  - `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.
  - `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.
  - `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.
  - `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.
  - `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.
  - `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.
  - `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.
  - `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.
  - `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`.
  - `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`.
  - `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`.
  - `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`.
- Adds e2e tests for the following:
  - customizing label part
  - customizing bar parts
  - customizing pin parts
  - customizing tick parts
  - customizing knob parts
  - customizing dual knob a & b parts
  - customizing dual knob lower & upper parts
- verifies that a & b parts stay on the original elements but lower & upper parts swap when the values swap
- Adds spec tests for the following:
  - css classes
    - value state classes
    - boolean property classes
    - pressed state classes
  - shadow parts
    - static shadow parts
- verifies the shadow parts exist based on the existence of certain range properties
  - state shadow parts
- verifies the shadow parts exist based on the state of the range knob (pressed, focused, activated, hover)

## Does this introduce a breaking change?
- [ ] Yes
- [x] No

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-03-04 10:36:50 -05:00
Brandy Smith
ef73476e08 feat(list-header): add inner part (#30930)
Issue number: N/A

---------

## What is the current behavior?
The inner structural element of list-header is not exposed as a shadow part, preventing users from being able to customize its styles directly.

## What is the new behavior?
- Exposes `inner` shadow part
- Adds e2e test coverage for customizing the shadow part

## Does this introduce a breaking change?
- [ ] Yes
- [x] No

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-02-27 13:40:42 -05:00
Brandy Smith
f8f7ffda31 feat(item-option): add inner and container parts (#30929)
Issue number: N/A

---------

## What is the current behavior?
The inner structural elements of item-option are not exposed as shadow parts, preventing users from being able to customize their styles directly.

## What is the new behavior?
- Exposes `inner` and `container` shadow parts
- Adds e2e test coverage for customizing the shadow parts

## Does this introduce a breaking change?
- [ ] Yes
- [x] No

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-02-27 13:26:01 -05:00
Brandy Smith
5cdeb7fd35 feat(item-divider): add inner and container parts (#30928)
Issue number: N/A

---------

## What is the current behavior?
The inner structural elements of item-divider are not exposed as shadow parts, preventing users from being able to customize their styles directly.

## What is the new behavior?
- Exposes `inner` and `container` shadow parts
- Adds e2e test coverage for customizing the shadow parts

## Does this introduce a breaking change?
- [ ] Yes
- [x] No

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-02-27 12:53:49 -05:00
OS-jacobbell
2be39da9d3 docs(stencil): document externalRuntime: false in config (#30975)
## Does this introduce a breaking change?

- [ ] Yes
- [X] No
2026-02-27 16:22:36 +00:00
Brandy Smith
a2c655923b feat(item): add inner and container parts (#30927)
Issue number: N/A

---------

## What is the current behavior?
The inner structural elements of item are not exposed as shadow parts, preventing users from being able to customize their styles directly.

## What is the new behavior?
- Exposes `inner` and `container` shadow parts
- Adds e2e test coverage for customizing the shadow parts
- Removes css variables test, combining it with the shadow parts test

## Does this introduce a breaking change?
- [ ] Yes
- [x] No

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-02-27 11:18:27 -05:00
Brandy Smith
1d7b28694e docs(core): add guidelines for naming CSS shadow parts (#30931)
Adds guidelines document establishing standardized naming conventions for CSS Shadow Parts in Ionic Framework components.

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-02-27 10:59:46 -05:00
Brandy Smith
90858582a6 merge release-8.7.18 (#30971)
v8.7.18
2026-02-25 15:11:47 -05:00
Maria Hutt
6ea186d96d feat(datetime): add header parts (#30945)
Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
2026-02-20 13:36:39 -08:00
OS-jacobbell
23e998b731 chore(deps): update stencil/core to 4.43.0 (#30960)
Stencil core updated to 4.43.0.
2026-02-19 13:27:25 -07:00
Shane
814c2e5ccd feat(refresher): add ionPullStart and ionPullEnd events (#30946)
Issue number: resolves #24524

---------

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

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

## What is the current behavior?
There is no way to know when the refresher has fully returned to its
inactive state after a pull gesture. The existing `ionStart` event fires
when pulling begins, but there is no corresponding end event. Watching
the progress property is insufficient because hitting zero doesn’t
necessarily mean the user has completed the pull gesture.


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

Two new events are added to the refresher component:
- `ionPullStart`: Emitted when the user begins pulling down (same as
`ionStart`, which is now deprecated)
- `ionPullEnd`: Emitted when the refresher returns to inactive state,
with a `reason` property of `'complete'` or `'cancel'` indicating
whether the refresh operation completed successfully or was cancelled

This allows you to know both when the user is no longer touching the
screen AND when the refresher is ready to be pulled again.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

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


## Other information

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

Test page:

https://ionic-framework-git-fw-6591-ionic1.vercel.app/src/components/refresher/test/basic/index.html

Current dev build:
```
8.7.17-dev.11770319814.172b4f50
```

---------

Co-authored-by: Patrick McDonald <764290+WhatsThatItsPat@users.noreply.github.com>
2026-02-19 08:57:48 -08:00
Maria Hutt
5cea5aeb44 feat(select): add wrapper and bottom shadow parts (#30951) 2026-02-17 12:50:22 -08:00
Bengt Weiße
55735df3fa feat(textarea): reflect disabled and readonly props (#30910)
Co-authored-by: Maria Hutt <maria.hutt@outsystems.com>
2026-02-10 16:24:39 -08:00
Brandy Smith
46806bd6e2 feat(segment-view): add swipeGesture property to disable swiping (#30948)
Issue number: resolves #30290

---------

## What is the current behavior?
The segment view swipe gesture can only be disabled by adding the `disabled` property and setting `opacity: 1`.

## What is the new behavior?
- Adds a new property, `swipeGesture`, to disable swiping on the segment view
- Adds an e2e test which verifies the styles blocking the swipe are correctly applied when `swipeGesture` is `false`

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-02-10 09:59:24 -05:00
Nicolás Naso
6e4f60af4c feat(select): pass cancelText property to modal interface (#30282)
Co-authored-by: Nicolas Naso <nnaso@eiwa.ag>
Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com>
2026-02-03 15:02:27 -08:00
Shane
822da428af feat(angular): add custom injector support for modal and popover controllers (#30899)
Issue number: resolves #30638

---------

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

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

## What is the current behavior?
When using `ModalController.create()` or `PopoverController.create()` in
Angular, components rendered inside overlays cannot access non-global
services or tokens from the component tree. For example, route-scoped
services or Angular's Dir directive for bidirectional text support are
not accessible from within a modal, requiring complex workarounds with
wrapper components.

## What is the new behavior?
`ModalController.create()` and `PopoverController.create()` now accept
an optional injector property that allows passing a custom Angular
Injector. This enables overlay components to access services and tokens
that are not available in the root injector, such as route-scoped
services or the Dir directive from Angular CDK.

```typescript
const customInjector = Injector.create({
  providers: [{ provide: MyService, useValue: myServiceInstance }],
  parent: this.injector,
});
```

```typescript
const modal = await this.modalController.create({
  component: MyModalComponent,
  injector: customInjector,
});
```

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

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


## Other information

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

Current dev build:
```
8.7.17-dev.11769628168.11eca7cd
```
2026-01-29 08:35:06 -08:00
Maria Hutt
0cf4c03e29 feat(datetime): add wheel part to ion-picker-column (#30934) 2026-01-29 06:32:23 -08:00
Brandy Smith
d74b11bc19 feat(content): add content-fullscreen class when fullscreen is true (#30926)
## What is the current behavior?
Content does not reflect the `fullscreen` property or add a class when it is enabled, making it harder to style.

## What is the new behavior?
- Adds `content-fullscreen` class to content when `fullscreen` is true
- Adds an e2e test verifying the class is applied

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-01-27 14:43:29 -05:00
Brandy Smith
fac1a6673c feat(range): add classes to the range when the value is at the min or max (#30932)
## What is the current behavior?
Range adds classes to the knobs at `min` and `max`, but the host element doesn't reflect those states.

## What is the new behavior?
- Adds `range-value-min` and `range-value-max` when the value is at the `min` or `max`, respectively.
- Adds a spec test verifying the classes are applied properly.

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-01-27 14:20:25 -05:00
Shane
66e1dc0e70 release-8.7.16 (#30902)
v8.7.16
2025-12-31 13:32:29 -08:00
178 changed files with 6427 additions and 781 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
View File

@@ -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"

View File

@@ -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"
},

View File

@@ -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
View File

File diff suppressed because it is too large Load Diff

View File

@@ -514,6 +514,7 @@ export class Accordion implements ComponentInterface {
'accordion-animated': this.shouldAnimate(),
}}
tabindex={disabled ? '-1' : undefined}
>
<div
onClick={() => this.toggleExpanded()}

View File

@@ -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">

View File

@@ -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,

View File

@@ -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/);
});
});
});

View File

@@ -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`;

View File

@@ -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)');
});
});
});
/**

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)');
});
});
});
});

View File

@@ -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>

View File

@@ -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)');
});
});
});
});

View File

@@ -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>

View File

@@ -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`));
});
});
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

View 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');
});
});
});
});

View File

@@ -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>

View File

@@ -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)');
});
});
});
});

View File

@@ -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',

View File

@@ -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));
};

View File

@@ -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;
}

View File

@@ -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.
*/

View File

@@ -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>

View File

@@ -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);
});
});
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 251 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 251 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 197 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 248 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 198 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -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>

View File

@@ -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);
});
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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>

View File

@@ -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);
});
});
});
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 B

View File

@@ -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();
});
});
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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' });
});
});
});
});

View File

@@ -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;
}

View File

@@ -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>

View 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>

View File

@@ -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);
});
});
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');
});
});
});

View File

@@ -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()}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -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');
});
});
});

View File

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

Some files were not shown because too many files have changed in this diff Show More