Compare commits

..

24 Commits

Author SHA1 Message Date
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
143 changed files with 5875 additions and 686 deletions

View File

@@ -3,6 +3,32 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [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))
* **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,31 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [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))
* **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

49
core/package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/core",
"version": "8.7.18",
"version": "8.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/core",
"version": "8.7.18",
"version": "8.8.0",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
"@stencil/core": "4.43.0",
"ionicons": "^8.0.13",
"tslib": "^2.1.0"
},
@@ -93,6 +93,7 @@
"version": "7.16.12",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.16.7",
"@babel/generator": "^7.16.8",
@@ -632,6 +633,7 @@
"integrity": "sha512-UfMBMWc1v7J+14AhH03QmeNwV3HZx3qnOWhpwnHfzALEwAwlV/itQOQqcasMQYhOHWL0tiymc5ByaLTn7KKQxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -850,6 +852,7 @@
"version": "4.33.0",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "4.33.0",
"@typescript-eslint/types": "4.33.0",
@@ -1785,8 +1788,11 @@
}
},
"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",
"peer": true,
"bin": {
"stencil": "bin/stencil"
},
@@ -2211,6 +2217,7 @@
"version": "6.7.2",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.7.2",
"@typescript-eslint/types": "6.7.2",
@@ -2436,7 +2443,6 @@
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.25",
@@ -2451,7 +2457,6 @@
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
@@ -2464,8 +2469,7 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.25",
@@ -2473,7 +2477,6 @@
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.25",
"@vue/shared": "3.5.25"
@@ -2485,7 +2488,6 @@
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.25",
@@ -2503,8 +2505,7 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@vue/compiler-sfc/node_modules/postcss": {
"version": "8.5.6",
@@ -2526,7 +2527,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2542,7 +2542,6 @@
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/shared": "3.5.25"
@@ -2554,7 +2553,6 @@
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.25"
}
@@ -2565,7 +2563,6 @@
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.25",
"@vue/shared": "3.5.25"
@@ -2577,7 +2574,6 @@
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.25",
"@vue/runtime-core": "3.5.25",
@@ -2591,7 +2587,6 @@
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25"
@@ -2605,8 +2600,7 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@zeit/schemas": {
"version": "2.21.0",
@@ -2629,6 +2623,7 @@
"version": "7.4.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3789,8 +3784,7 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
@@ -4084,6 +4078,7 @@
"version": "7.32.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "7.12.11",
"@eslint/eslintrc": "^0.4.3",
@@ -7279,7 +7274,6 @@
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
@@ -7601,7 +7595,6 @@
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -7956,6 +7949,7 @@
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"playwright-core": "cli.js"
},
@@ -7967,6 +7961,7 @@
"version": "7.0.35",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^2.4.2",
"source-map": "^0.6.1",
@@ -8072,6 +8067,7 @@
"version": "0.36.2",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"postcss": ">=5.0.0"
}
@@ -8120,6 +8116,7 @@
"version": "2.6.1",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin-prettier.js"
},
@@ -8477,6 +8474,7 @@
"version": "2.35.1",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -8698,7 +8696,6 @@
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9819,4 +9816,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
"version": "8.7.18",
"version": "8.8.0",
"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}"`;

1179
core/src/components.d.ts vendored
View File

File diff suppressed because it is too large Load Diff

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.
*
@@ -143,9 +143,24 @@ export class Refresher implements ComponentInterface {
/**
* Emitted when the user begins to start pulling down.
* TODO(FW-7044): Remove this in a major release
*
* @deprecated Use `ionPullStart` instead.
*/
@Event() ionStart!: EventEmitter<void>;
/**
* Emitted when the user begins to start pulling down.
*/
@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));
if (useNativeRefresher && !this.nativeRefresher) {
@@ -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.

View File

@@ -17,14 +17,14 @@ export { CounterFormatter } from './components/item/item-interface';
export { ItemSlidingCustomEvent } from './components/item-sliding/item-sliding-interface';
export { LoadingOptions } from './components/loading/loading-interface';
export { MenuCustomEvent, MenuI, MenuControllerI } from './components/menu/menu-interface';
export { ModalOptions, ModalCustomEvent } from './components/modal/modal-interface';
export { ModalOptions, ModalCustomEvent, ModalDragEventDetail } from './components/modal/modal-interface';
export { NavDirection, NavCustomEvent } from './components/nav/nav-interface';
export { PickerOptions, PickerColumnOption } from './components/picker-legacy/picker-interface';
export { PopoverOptions } from './components/popover/popover-interface';
export { RadioGroupCustomEvent } from './components/radio-group/radio-group-interface';
export { RangeCustomEvent, PinFormatter } from './components/range/range-interface';
export { RouterCustomEvent } from './components/router/utils/interface';
export { RefresherCustomEvent } from './components/refresher/refresher-interface';
export { RefresherCustomEvent, RefresherPullEndCustomEvent } from './components/refresher/refresher-interface';
export {
ItemReorderCustomEvent,
ReorderEndCustomEvent,

View File

@@ -227,6 +227,11 @@ export const config: Config = {
warn: true
}],
includeGlobalScripts: false,
/**
* External Runtime uses default runtime settings instead of this file's definitions. Disabling it enables
* `experimentalSlotFixes` to be applied and prevents `@stencil/core/internal/client` from being imported, which
* contains a dynamic import that caused a warning in Angular.
*/
externalRuntime: false,
},
{

View File

@@ -23,6 +23,7 @@ It is based on <a href="https://www.webcomponents.org/introduction">Web Componen
| [Contributing](./CONTRIBUTING.md) | How to contribute including creating pull requests, commit message guidelines, and more. |
| [Component Guide](./component-guide.md) | Guidelines for implementing component states, accessibility, and more. |
| [Sass Guidelines](./sass-guidelines.md) | Outlines scenarios where Sass members and comments should be used. |
| [CSS Shadow Parts Guidelines](./shadow-parts-guidelines.md) | Guidelines for CSS shadow parts in components. |
## Packages

View File

@@ -17,6 +17,8 @@
* [Example Components](#example-components-4)
* [Component Structure](#component-structure-1)
- [Converting Scoped to Shadow](#converting-scoped-to-shadow)
- [Sass Variables](#sass-variables)
- [CSS Shadow Parts](#css-shadow-parts)
- [RTL](#rtl)
- [Adding New Components with Native Input Support](#adding-new-components-with-native-input-support)
* [Angular Integration](#angular-integration)
@@ -722,6 +724,14 @@ There will be some CSS issues when converting to shadow. Below are some of the d
:host-context(ion-toolbar:not(.ion-color)):host(:not(.ion-color)) ::slotted(ion-segment-button) {
```
## Sass Variables
For guidelines on when to use Sass Variables, see the [Sass Guidelines](./sass-guidelines.md).
## CSS Shadow Parts
For guidelines on adding CSS shadow parts, see the [CSS Shadow Parts Guidelines](./shadow-parts-guidelines.md).
## RTL
When you need to support both LTR and RTL modes, try to avoid using values such as `left` and `right`. For certain CSS properties, you can use the appropriate mixin to have this handled for you automatically.

View File

@@ -1,5 +1,6 @@
# Sass Guidelines
<sub><b>TABLE OF CONTENTS</b></sub>
- [Definitions](#definitions)
- [Scope](#scope)
- [Historical Usage](#historical-usage)

View File

@@ -0,0 +1,277 @@
# CSS Shadow Parts Guidelines
<sub><b>TABLE OF CONTENTS</b></sub>
- [Definitions](#definitions)
- [Scope](#scope)
- [General Guidelines](#general-guidelines)
- [Standard Parts](#standard-parts)
- [Specialized Parts](#specialized-parts)
- [Documentation](#documentation)
## Definitions
**CSS Shadow Parts:** The CSS shadow parts module defines the [::part()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::part) pseudo-element that can be set on a [shadow host](https://developer.mozilla.org/en-US/docs/Glossary/Shadow_tree). Using this pseudo-element, you can enable shadow hosts to expose the selected element in the shadow tree to the outside page for styling purposes. [^1]
## Scope
Ionic Framework components that use Shadow DOM expose CSS Shadow Parts to enable custom styling by end users.
This document establishes a standardized naming convention for CSS Shadow Parts in Ionic Framework components.
## General Guidelines
1. **Attempt to use standard parts first**: Use `native`, `wrapper`, `inner`, `container`, and `content` wherever they apply before inventing new names.
2. **Use semantic, kebab-case names**: Choose descriptive names that communicate the role of the element (for example, `detail-icon`, `supporting-text`).
3. **Reuse names for the same concept**: Use the same part name across components when the element serves the same role (for example, `backdrop`, `handle`, `label`).
## Standard Parts
**Name parts by what the element does, not where it appears.** Ask what role the element plays:
| Name | Role |
| --- | --- |
| `native` | Is it a native HTML element that the user interacts with (e.g. `<button>`, `<a>`, `<input>`, `<textarea>`)? |
| `wrapper` | Is it a native `<label>` element that wraps the whole form control? |
| `inner` | Is it the inner layout wrapper around the main content? It may wrap only the default slot (e.g. `ion-list-header`), or a container plus slot(s) (e.g. `ion-item`, `ion-item-divider`, `ion-item-option`) when present. |
| `container` | Does it wrap the main content itself (default slot or native control)? |
| `content` | Is it the main user-content area of an overlay or primary content region? |
The following examples show the correct usage for the standard parts.
### `native`
**What it does:** The element the user directly interacts with - the native button, anchor, or form control (e.g. `<button>`, `<a>`, `<textarea>`, `<input>`).
- **Use when**: The element receives click/focus/input from the user.
- **Examples**: `ion-item`, `ion-button`, `ion-textarea`.
**ion-item** - the interactive element is the `<button>`, `<a>`, or `<div>` (`TagType`):
```tsx
const TagType = clickable ? (href === undefined ? 'button' : 'a') : ('div' as any);
return (
<Host>
<TagType class="item-native" part="native">
<slot name="start"></slot>
<div class="item-inner" part="inner">
<div class="input-wrapper" part="container">
<slot></slot>
</div>
<slot name="end"></slot>
</div>
</TagType>
</Host>
);
```
**ion-textarea** - the interactive element is the native `<textarea>`:
```tsx
<div class="native-wrapper" part="container">
<textarea class="native-textarea" part="native">
{value}
</textarea>
</div>
```
### `wrapper`
**What it does:** The HTML `<label>` element that wraps the entire form control. Clicking anywhere on it focuses the control.
- **Use when**: The element is the `<label>` that wraps the form control.
- **Examples**: `ion-select`, `ion-textarea`, `ion-input`, `ion-checkbox`, `ion-toggle`, `ion-radio`, `ion-range`.
**ion-select** - the `<label>` has `part="wrapper"`:
```tsx
<label class="select-wrapper" part="wrapper">
{this.renderLabelContainer()}
<div class="select-wrapper-inner" part="inner">
<slot name="start"></slot>
<div class="native-wrapper" part="container">...</div>
<slot name="end"></slot>
</div>
</label>
```
**ion-textarea** - the `<label>` has `part="wrapper"`:
```tsx
<label class="textarea-wrapper" part="wrapper">
{this.renderLabelContainer()}
<div class="textarea-wrapper-inner" part="inner">
<slot name="start"></slot>
<div class="native-wrapper" part="container">...</div>
<slot name="end"></slot>
</div>
</label>
```
### `inner`
**What it does:** The inner layout wrapper around the main content. It may wrap only the default slot (e.g. `ion-list-header`), or it may wrap a container and the slot(s) (e.g. `start`, `end`) that sit alongside the main content. In `ion-item`, and `ion-item-divider`, the `start` slot is a sibling of this element. In `ion-select`, both `start` and `end` slots are inside this element.
- **Use when**: The element is the inner layout wrapper (with or without a separate container and `start`/`end` slots).
- **Examples**: `ion-list-header` (`.list-header-inner` wraps only the default slot), `ion-item` (`.item-inner`), `ion-item-divider` (`.item-divider-inner`), `ion-select` (`.select-wrapper-inner`).
**ion-list-header** - `.list-header-inner` wraps only the default slot (no container, no `start`/`end` slots):
```tsx
<div class="list-header-inner" part="inner">
<slot></slot>
</div>
```
**ion-item** - `.item-inner` wraps the container and `end` slot (`start` slot is a sibling):
```tsx
<slot name="start"></slot>
<div class="item-inner" part="inner">
<div class="input-wrapper" part="container">
<slot></slot>
</div>
<slot name="end"></slot>
</div>
```
**ion-item-divider** - `.item-divider-inner` wraps the container and `end` slot (`start` slot is a sibling):
```tsx
<slot name="start"></slot>
<div class="item-divider-inner" part="inner">
<div class="item-divider-wrapper" part="container">
<slot></slot>
</div>
<slot name="end"></slot>
</div>
```
**ion-select** - `.select-wrapper-inner` arranges `start` slot, container, `end` slot:
```tsx
<div class="select-wrapper-inner" part="inner">
<slot name="start"></slot>
<div class="native-wrapper" part="container"></div>
<slot name="end"></slot>
</div>
```
### `container`
**What it does:** Wraps the main content - either the default slot (for item-like components) or the native control and its immediate content (for form controls like select, textarea).
- **Use when**: The element wraps the default slot, or wraps the native control (and any immediate content like listbox or slots inside it).
- **Dont use when**: The element is the main content area of an overlay (use `content` instead).
- **Examples**: `ion-item` (`.input-wrapper` around default slot), `ion-item-divider` (`.item-divider-wrapper`), `ion-select` (`.native-wrapper` around select text + listbox), `ion-textarea` (`.native-wrapper` around `<textarea>`).
From the examples above:
**ion-item** - `.input-wrapper` wraps the default `<slot>`:
```tsx
<slot name="start"></slot>
<div class="item-inner" part="inner">
<div class="input-wrapper" part="container">
<slot></slot>
</div>
<slot name="end"></slot>
</div>
```
**ion-select** - `.native-wrapper` wraps the select text and listbox:
```tsx
<div class="select-wrapper-inner" part="inner">
<slot name="start"></slot>
<div class="native-wrapper" part="container">
{this.renderSelectText()}
{this.renderListbox()}
</div>
<slot name="end"></slot>
</div>
```
**ion-textarea** - `.native-wrapper` wraps the `<textarea>`:
```tsx
<label class="textarea-wrapper" part="wrapper">
{this.renderLabelContainer()}
<div class="textarea-wrapper-inner" part="inner">
<slot name="start"></slot>
<div class="native-wrapper" part="container">
<textarea class="native-textarea" part="native">
{value}
</textarea>
</div>
<slot name="end"></slot>
</div>
</label>
```
### `content`
**What it does:** The main user-content area of an overlay or the primary content region (e.g. modal body, toolbars main slot).
- **Use when**: The element is the primary content area where users see the main content (overlay body, or primary slot inside something like a toolbar).
- **Examples**: `ion-modal`, `ion-popover`, `ion-accordion`, `ion-toolbar` (the div that wraps the default slot inside the toolbar container).
**ion-modal** - `content` wraps the default `<slot>` which is the primary content:
```tsx
<div class="modal-content" part="content">
<slot></slot>
</div>
```
**ion-toolbar** - `content` wraps the default `<slot>` which is the primary content:
```tsx
<div class="toolbar-container" part="container">
<slot name="start"></slot>
<slot name="secondary"></slot>
<div class="toolbar-content" part="content">
<slot></slot>
</div>
<slot name="primary"></slot>
<slot name="end"></slot>
</div>
```
## Specialized Parts
Components may also expose specialized parts for specific elements. The following parts are reused across multiple components:
| Name | Description |
| --- | --- |
| `background` | Background elements (e.g., `ion-content`, `ion-toolbar`) |
| `backdrop` | Backdrop elements. **Must only be used on `<ion-backdrop>` components.** (e.g., `ion-modal`, `ion-popover`, `ion-menu`) |
| `label` | Label text elements - not the HTML `<label>` (see standard part `wrapper`) |
| `supporting-text` | Supporting text elements |
| `helper-text` | Helper text elements |
| `error-text` | Error text elements |
| `icon` | Icon elements. **Must only be used on `<ion-icon>` components.** Use specific names like `detail-icon`, `close-icon` when the icon serves a distinct purpose (e.g., `ion-item` uses `detail-icon`, `ion-fab-button` uses `close-icon`) |
| `handle` | Handle elements (e.g., `ion-modal`, `ion-toggle`) |
| `track` | Track elements (e.g., `ion-toggle`, `ion-progress-bar`) |
| `mark` | Checkmark or indicator marks (e.g., `ion-checkbox`, `ion-radio`) |
**When to create new specialized parts:**
- Use standard parts (`native`, `wrapper`, `inner`, `container`, `content`) when they apply
- Reuse existing specialized parts (listed above) when they match the element's role
- Create component-specific specialized parts for elements that don't fit standard patterns or existing specialized parts
- Use descriptive, semantic names (e.g., `header`, `text`, `arrow`, `scroll` for component-specific elements)
## Documentation
Shadow parts must be documented in the component's JSDoc comments using the `@part` tag. The following example demonstrates the proper documentation format:
```tsx
/**
* @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"`.
*/
```
[^1]: MDN Documentation - CSS shadow parts, https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Shadow_parts

View File

@@ -3,5 +3,5 @@
"core",
"packages/*"
],
"version": "8.7.18"
"version": "8.8.0"
}

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [8.8.0](https://github.com/ionic-team/ionic-framework/compare/v8.7.18...v8.8.0) (2026-03-04)
**Note:** Version bump only for package @ionic/angular-server
## [8.7.18](https://github.com/ionic-team/ionic-framework/compare/v8.7.17...v8.7.18) (2026-02-25)
**Note:** Version bump only for package @ionic/angular-server

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/angular-server",
"version": "8.7.18",
"version": "8.8.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@ionic/angular-server",
"version": "8.7.18",
"version": "8.8.0",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.18"
"@ionic/core": "^8.8.0"
},
"devDependencies": {
"@angular-eslint/eslint-plugin": "^16.0.0",
@@ -11289,4 +11289,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/angular-server",
"version": "8.7.18",
"version": "8.8.0",
"description": "Angular SSR Module for Ionic",
"keywords": [
"ionic",
@@ -62,6 +62,6 @@
},
"prettier": "@ionic/prettier-config",
"dependencies": {
"@ionic/core": "^8.7.18"
"@ionic/core": "^8.8.0"
}
}

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [8.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)
* **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))
* **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:** 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

@@ -9,6 +9,7 @@ export { AngularDelegate, bindLifecycleEvents, IonModalToken } from './providers
export type { IonicWindow } from './types/interfaces';
export type { ViewDidEnter, ViewDidLeave, ViewWillEnter, ViewWillLeave } from './types/ionic-lifecycle-hooks';
export type { ModalOptions, PopoverOptions } from './types/overlay-options';
export { NavParams } from './directives/navigation/nav-params';

View File

@@ -7,7 +7,7 @@ import {
NgZone,
TemplateRef,
} from '@angular/core';
import type { Components, ModalBreakpointChangeEventDetail } from '@ionic/core/components';
import type { Components, ModalBreakpointChangeEventDetail, ModalDragEventDetail } from '@ionic/core/components';
import { ProxyCmp, proxyOutputs } from '../utils/proxy';
@@ -32,6 +32,18 @@ export declare interface IonModal extends Components.IonModal {
* Emitted after the modal breakpoint has changed.
*/
ionBreakpointDidChange: EventEmitter<CustomEvent<ModalBreakpointChangeEventDetail>>;
/**
* Emitted when the sheet or card modal has started being dragged.
*/
ionDragStart: EventEmitter<void>;
/**
* Emitted while the sheet or card modal is being dragged.
*/
ionDragMove: EventEmitter<CustomEvent<ModalDragEventDetail>>;
/**
* Emitted when the sheet or card modal has finished being dragged.
*/
ionDragEnd: EventEmitter<CustomEvent<ModalDragEventDetail>>;
/**
* Emitted after the modal has presented. Shorthand for ionModalDidPresent.
*/
@@ -130,6 +142,9 @@ export class IonModal {
'willPresent',
'willDismiss',
'didDismiss',
'ionDragStart',
'ionDragMove',
'ionDragEnd',
]);
}
}

View File

@@ -36,7 +36,8 @@ export class AngularDelegate {
create(
environmentInjector: EnvironmentInjector,
injector: Injector,
elementReferenceKey?: string
elementReferenceKey?: string,
customInjector?: Injector
): AngularFrameworkDelegate {
return new AngularFrameworkDelegate(
environmentInjector,
@@ -44,7 +45,8 @@ export class AngularDelegate {
this.applicationRef,
this.zone,
elementReferenceKey,
this.config.useSetInputAPI ?? false
this.config.useSetInputAPI ?? false,
customInjector
);
}
}
@@ -59,7 +61,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
private applicationRef: ApplicationRef,
private zone: NgZone,
private elementReferenceKey?: string,
private enableSignalsSupport?: boolean
private enableSignalsSupport?: boolean,
private customInjector?: Injector
) {}
attachViewToDom(container: any, component: any, params?: any, cssClasses?: string[]): Promise<any> {
@@ -93,7 +96,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
componentProps,
cssClasses,
this.elementReferenceKey,
this.enableSignalsSupport
this.enableSignalsSupport,
this.customInjector
);
resolve(el);
});
@@ -131,7 +135,8 @@ export const attachView = (
params: any,
cssClasses: string[] | undefined,
elementReferenceKey: string | undefined,
enableSignalsSupport: boolean | undefined
enableSignalsSupport: boolean | undefined,
customInjector?: Injector
): any => {
/**
* Wraps the injector with a custom injector that
@@ -158,7 +163,7 @@ export const attachView = (
const childInjector = Injector.create({
providers,
parent: injector,
parent: customInjector ?? injector,
});
const componentRef = createComponent<any>(component, {

View File

@@ -0,0 +1,18 @@
import type { Injector } from '@angular/core';
import type { ModalOptions as CoreModalOptions, PopoverOptions as CorePopoverOptions } from '@ionic/core/components';
/**
* Modal options with Angular-specific injector support.
* Extends @ionic/core ModalOptions with an optional injector property.
*/
export type ModalOptions = CoreModalOptions & {
injector?: Injector;
};
/**
* Popover options with Angular-specific injector support.
* Extends @ionic/core PopoverOptions with an optional injector property.
*/
export type PopoverOptions = CorePopoverOptions & {
injector?: Injector;
};

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/angular",
"version": "8.7.18",
"version": "8.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/angular",
"version": "8.7.18",
"version": "8.8.0",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.18",
"@ionic/core": "^8.8.0",
"ionicons": "^8.0.13",
"jsonc-parser": "^3.0.0",
"tslib": "^2.3.0"
@@ -9095,4 +9095,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/angular",
"version": "8.7.18",
"version": "8.8.0",
"description": "Angular specific wrappers for @ionic/core",
"keywords": [
"ionic",
@@ -48,7 +48,7 @@
}
},
"dependencies": {
"@ionic/core": "^8.7.18",
"@ionic/core": "^8.8.0",
"ionicons": "^8.0.13",
"jsonc-parser": "^3.0.0",
"tslib": "^2.3.0"

View File

@@ -1810,12 +1810,13 @@ export class IonRefresher {
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
c.detach();
this.el = r.nativeElement;
proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart']);
proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart', 'ionPullStart', 'ionPullEnd']);
}
}
import type { RefresherEventDetail as IIonRefresherRefresherEventDetail } from '@ionic/core';
import type { RefresherPullEndEventDetail as IIonRefresherRefresherPullEndEventDetail } from '@ionic/core';
export declare interface IonRefresher extends Components.IonRefresher {
/**
@@ -1831,8 +1832,19 @@ called when the async operation has completed.
ionPull: EventEmitter<CustomEvent<void>>;
/**
* Emitted when the user begins to start pulling down.
TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead.
*/
ionStart: EventEmitter<CustomEvent<void>>;
/**
* Emitted when the user begins to start pulling down.
*/
ionPullStart: EventEmitter<CustomEvent<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.
*/
ionPullEnd: EventEmitter<CustomEvent<IIonRefresherRefresherPullEndEventDetail>>;
}
@@ -2113,14 +2125,14 @@ export declare interface IonSegmentContent extends Components.IonSegmentContent
@ProxyCmp({
inputs: ['disabled']
inputs: ['disabled', 'swipeGesture']
})
@Component({
selector: 'ion-segment-view',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['disabled'],
inputs: ['disabled', 'swipeGesture'],
})
export class IonSegmentView {
protected el: HTMLIonSegmentViewElement;
@@ -2192,14 +2204,14 @@ This event will not emit when programmatically setting the `value` property.
@ProxyCmp({
inputs: ['header', 'multiple', 'options']
inputs: ['cancelText', 'header', 'multiple', 'options']
})
@Component({
selector: 'ion-select-modal',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['header', 'multiple', 'options'],
inputs: ['cancelText', 'header', 'multiple', 'options'],
})
export class IonSelectModal {
protected el: HTMLIonSelectModalElement;

View File

@@ -32,6 +32,7 @@ export {
ViewDidEnter,
ViewDidLeave,
} from '@ionic/angular/common';
export type { ModalOptions, PopoverOptions } from '@ionic/angular/common';
export { AlertController } from './providers/alert-controller';
export { AnimationController } from './providers/animation-controller';
export { ActionSheetController } from './providers/action-sheet-controller';
@@ -98,14 +99,13 @@ export {
IonicSafeString,
LoadingOptions,
MenuCustomEvent,
ModalOptions,
ModalDragEventDetail,
NavCustomEvent,
PickerOptions,
PickerButton,
PickerColumn,
PickerColumnOption,
PlatformConfig,
PopoverOptions,
RadioGroupCustomEvent,
RadioGroupChangeEventDetail,
RangeCustomEvent,

View File

@@ -1,6 +1,6 @@
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { ModalOptions } from '@ionic/core';
import type { ModalOptions } from '@ionic/angular/common';
import { modalController } from '@ionic/core';
@Injectable()
@@ -14,9 +14,10 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
}
create(opts: ModalOptions): Promise<HTMLIonModalElement> {
const { injector: customInjector, ...restOpts } = opts;
return super.create({
...opts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal'),
...restOpts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal', customInjector),
});
}
}

View File

@@ -1,6 +1,6 @@
import { Injector, inject, EnvironmentInjector } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { PopoverOptions } from '@ionic/core';
import type { PopoverOptions } from '@ionic/angular/common';
import { popoverController } from '@ionic/core';
export class PopoverController extends OverlayBaseController<PopoverOptions, HTMLIonPopoverElement> {
@@ -13,9 +13,10 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
}
create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
const { injector: customInjector, ...restOpts } = opts;
return super.create({
...opts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover'),
...restOpts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover', customInjector),
});
}
}

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