Compare commits

..

11 Commits

Author SHA1 Message Date
Sean Perkins
918edf2f72 feat(input): mask controller set-up (#27346)
Issue number: N/A

---------

<!-- Please refer to our contributing documentation for any questions on
submitting a pull request, or let us know here if you need any help:
https://ionicframework.com/docs/building/contributing -->

<!-- Some docs updates need to be made in the `ionic-docs` repo, in a
separate PR. See
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#modifying-documentation
for details. -->

<!-- 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?
<!-- Please describe the current behavior that you are modifying. -->

N/A

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

Introduces a `MaskController` class to handle the implementation details
of input masking. The implementation is stubbed out to guide future PRs
that will implement the underlying details.

The design is implemented based on
[maskito](https://github.com/Tinkoff/maskito). Ionic Framework supports
newer versions of Firefox than this library targets, so we can diverge
on the implementation and access modern APIs in portions of the
implementation.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

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

In terms of testing, you could run the mask test template and verify
that different `console.debug` messages are logging when interacting
with the control.
2023-05-05 13:38:36 -04:00
Sean Perkins
ba894d05a8 feat(input): component api for input masking (#27339)
Issue number: Internal

---------

<!-- Please refer to our contributing documentation for any questions on
submitting a pull request, or let us know here if you need any help:
https://ionicframework.com/docs/building/contributing -->

<!-- Some docs updates need to be made in the `ionic-docs` repo, in a
separate PR. See
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#modifying-documentation
for details. -->

<!-- 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?
<!-- Please describe the current behavior that you are modifying. -->

N/A

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

- Adds `mask`, `maskVisibility` and `maskPlaceholder` properties to
`ion-input` for input masking

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

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

---------
2023-05-03 15:25:24 -04:00
Sean Perkins
9313a914b7 fix(overlays): assign incremental id to overlay host (#27278)
Issue number: Internal

---------

<!-- Please refer to our contributing documentation for any questions on
submitting a pull request, or let us know here if you need any help:
https://ionicframework.com/docs/building/contributing -->

<!-- Some docs updates need to be made in the `ionic-docs` repo, in a
separate PR. See
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#modifying-documentation
for details. -->

<!-- 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?
<!-- Please describe the current behavior that you are modifying. -->

The counter for incrementing the `id` and `z-index` of an overlay is
incremented whenever the `connectedCallback` is fired for an overlay.

When an overlay is presented and/or conditionally rendered, the overlay
`id` can increment by `n+2` instead of `n+1`.

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

- Increments all overlay ids consistently
- Removes legacy `ion-modal-{id}` and `ion-popover-{id}` logic
- Adds unit tests for the id behavior
- Tests are split up into separate files so that the counter is always
starting from `0`
- Adds an integration test with the Angular test app to verify
conditional rendering behavior

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2023-05-03 17:24:19 +00:00
Liam DeBeasi
27a9aaaedc chore(ci): update workflow for upcoming deprecation (#27366)
Issue number: N/A

---------

<!-- 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?
<!-- Please describe the current behavior that you are modifying. -->

`set-output` usage is deprecated in favor of environment files.

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

- Updates the `test-core-screenshot` workflow to remove `set-output` in
favor of environment files.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2023-05-03 17:09:03 +00:00
Liam DeBeasi
35256d70ec test(radio, radio-group): migrate to generators (#27365)
Issue number: N/A

---------

<!-- 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?
<!-- Please describe the current behavior that you are modifying. -->

Radio and radio group tests are using legacy syntax

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

- Radio and radio group tests are using generator syntax 


c0256f3ce1

- The radio group basic interaction tests do not vary across modes, so I
removed those extra checks.


579bcacab2

- The basic radio directory had some old screenshots that were not being
used, so I removed them.


997d652864

- The legacy radio keyboard behavior does not vary across modes, so I
removed that extra check

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2023-05-03 15:53:35 +00:00
Liam DeBeasi
ce0767bbb0 test(tabs, tab-bar, tab-button): migrate to generators (#27356)
Issue number: N/A

---------

<!-- Please refer to our contributing documentation for any questions on
submitting a pull request, or let us know here if you need any help:
https://ionicframework.com/docs/building/contributing -->

<!-- Some docs updates need to be made in the `ionic-docs` repo, in a
separate PR. See
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#modifying-documentation
for details. -->

<!-- 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?
<!-- Please describe the current behavior that you are modifying. -->

Tabs, tab bar, and tab button are using legacy syntax

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

- Tabs, tab bar, and tab button are using modern syntax


962754d094

- A translucent screenshot test was written in `tab-bar/test/basic` but
it is already being tested in `tab-bar/test/translucent`, so I deleted
the duplicate test/screenshots.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2023-05-03 15:51:48 +00:00
Liam DeBeasi
b1369a94ae test(picker-internal, picker-column-internal): migrate to generators (#27354)
Issue number: N/A

---------

<!-- Please refer to our contributing documentation for any questions on
submitting a pull request, or let us know here if you need any help:
https://ionicframework.com/docs/building/contributing -->

<!-- Some docs updates need to be made in the `ionic-docs` repo, in a
separate PR. See
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#modifying-documentation
for details. -->

<!-- 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?
<!-- Please describe the current behavior that you are modifying. -->

Picker internal and picker column internal tests are using legacy
syntax.

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

- Picker internal and picker column internal tests use generator syntax.


38187b744c

I updated these tests to disable multi-mode and direction testing since
the behaviors this file is testing do not vary across modes/directions.


89436784b0

- I removed the RTL screenshots here because the disabled state does not
vary across directions.


8d31eba5f2

- I removed the RTL screenshots here because the overlay rendering
behavior does not vary across directions. There is RTL behavior that we
need to test, but that is already captured in the screenshots in
`picker-internal/basic`.


d2a1531e6a

- Removed the mode and direction tests because this behavior does not
vary across modes/directions.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2023-05-03 15:22:39 +00:00
Liam DeBeasi
c98ad6f16a fix(modal, popover): wait for contents to mount (#27344)
Issue number: resolves #27343

---------

<!-- Please refer to our contributing documentation for any questions on
submitting a pull request, or let us know here if you need any help:
https://ionicframework.com/docs/building/contributing -->

<!-- Some docs updates need to be made in the `ionic-docs` repo, in a
separate PR. See
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#modifying-documentation
for details. -->

<!-- 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?
<!-- Please describe the current behavior that you are modifying. -->

In
30e3a1485d
I removed the `deepWait` call from popover/modal in custom element
bundle environments (React and Vue as of writing). This had an
unintended side effect where WebKit/iOS would not play the modal enter
animation correctly because the inner contents are mounted
mid-animation. This does not impact other mobile platforms.

This only impacted the modal because popover had a patch in
be9a399eee
which causes it to wait for the JS Framework to finish mounting before
proceeding with the transition.

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

- Modal now emits `ionMount` event and waits 2 frames before proceeding
with the animation.

Note 1: The JS Framework overlay components were already updated to
support this `ionMount` event in
be9a399eee.

I also updated the modal Angular component to listen for `ionMount`. It
is not needed right now because Angular does not use the custom elements
bundle and therefore does not call `ionMount` (it runs the `deepReady`
function though). However, if we move Angular to support the custom
elements bundle in the future this may become an issue. This behavior
currently exists in the popover component for Angular too.

Note 2: This does appear to be a WebKit bug since it does not happen on
Android. However, this patch seems fairly safe which is why I've opted
to try and fix it internally instead of waiting for a patch from Apple.


| before | after |
| - | - |
| <video
src="https://user-images.githubusercontent.com/2721089/235495325-2f258526-0c43-422b-84c3-ac4f5e228bbd.MP4"></video>
| <video
src="https://user-images.githubusercontent.com/2721089/235495362-9b3bb35d-782c-4a8f-ac13-8aaa8f17729b.MP4"></video>
|


## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2023-05-03 14:32:25 +00:00
Brandy Carney
f27c899d13 chore: remove unnecessary comments in PR template (#27358)
Removes the comments about reading the contributing guide from our pull
request template. GitHub recommends the contributing guide in a popup to
new contributors, and it also recommends it if anything has changed
recently.
2023-05-03 13:56:50 +00:00
Liam DeBeasi
16ee234258 merge release-7.0.5
Release 7.0.5
2023-05-03 09:17:41 -04:00
ionitron
4804b67785 chore(): update package lock files 2023-05-03 12:49:38 +00:00
501 changed files with 2989 additions and 1852 deletions

View File

@@ -2,13 +2,9 @@ Issue number: #
---------
<!-- Please refer to our contributing documentation for any questions on submitting a pull request, or let us know here if you need any help: https://ionicframework.com/docs/building/contributing -->
<!-- Please do not submit updates to dependencies unless it fixes an issue. -->
<!-- Some docs updates need to be made in the `ionic-docs` repo, in a separate PR. See https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#modifying-documentation for details. -->
<!-- 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. -->
<!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. -->
## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

View File

@@ -54,7 +54,7 @@ runs:
mkdir updated-screenshots
cd ../ && rsync -R --progress $(git diff --name-only --cached) core/updated-screenshots
if [ -d core/updated-screenshots/core ]; then
echo "::set-output name=hasUpdatedScreenshots::$(echo 'true')"
echo "hasUpdatedScreenshots=$(echo 'true')" >> $GITHUB_OUTPUT
cd core/updated-screenshots
zip -q -r ../../UpdatedScreenshots${{ inputs.commandModifier }}-${{ inputs.shard }}-${{ inputs.totalShards }}.zip core
fi

View File

@@ -1227,19 +1227,19 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-7.0.4.tgz",
"integrity": "sha512-tS1RfWn+L3jVpU833d7QQs1FQO1MlbDQL6sMaYBOYcE1nNZuDTd++zcOS264eklNnZu6z7PJmd04dj3DnQjsmA==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-7.0.5.tgz",
"integrity": "sha512-dcuE/PEF+GHsPEsLppUASSwWnzVcxFZE7uPMDzTwUPMOFTTaRgWbPxIly/4/VRbV6GSL6MEcuVVxhEdWjSODTg==",
"dependencies": {
"@stencil/core": "^3.2.1",
"@stencil/core": "^3.2.2",
"ionicons": "^7.1.0",
"tslib": "^2.1.0"
}
},
"node_modules/@ionic/core/node_modules/@stencil/core": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-3.2.1.tgz",
"integrity": "sha512-Ybm4NteQBScLq3H0JML/uqo4nWjNpZw1HAAURtR5LlRm7ptzNKO5S8EnHp3m05/uyTzeh9yLpUFHY7bxGNdYLg==",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-3.2.2.tgz",
"integrity": "sha512-wXb9cVWL0T3cTwYLveekdTFCRGx6+9hpVDEXna+N8K8OPoW6xtFAHRLv+LjOM7k59PkA8MG3IinAfV7Y+xa0Hw==",
"bin": {
"stencil": "bin/stencil"
},
@@ -8104,19 +8104,19 @@
"dev": true
},
"@ionic/core": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-7.0.4.tgz",
"integrity": "sha512-tS1RfWn+L3jVpU833d7QQs1FQO1MlbDQL6sMaYBOYcE1nNZuDTd++zcOS264eklNnZu6z7PJmd04dj3DnQjsmA==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-7.0.5.tgz",
"integrity": "sha512-dcuE/PEF+GHsPEsLppUASSwWnzVcxFZE7uPMDzTwUPMOFTTaRgWbPxIly/4/VRbV6GSL6MEcuVVxhEdWjSODTg==",
"requires": {
"@stencil/core": "^3.2.1",
"@stencil/core": "^3.2.2",
"ionicons": "^7.1.0",
"tslib": "^2.1.0"
},
"dependencies": {
"@stencil/core": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-3.2.1.tgz",
"integrity": "sha512-Ybm4NteQBScLq3H0JML/uqo4nWjNpZw1HAAURtR5LlRm7ptzNKO5S8EnHp3m05/uyTzeh9yLpUFHY7bxGNdYLg=="
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-3.2.2.tgz",
"integrity": "sha512-wXb9cVWL0T3cTwYLveekdTFCRGx6+9hpVDEXna+N8K8OPoW6xtFAHRLv+LjOM7k59PkA8MG3IinAfV7Y+xa0Hw=="
}
}
},

View File

@@ -116,7 +116,7 @@ export class IonModal {
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
this.el = r.nativeElement;
this.el.addEventListener('willPresent', () => {
this.el.addEventListener('ionMount', () => {
this.isCmpOpen = true;
c.detectChanges();
});
@@ -124,7 +124,6 @@ export class IonModal {
this.isCmpOpen = false;
c.detectChanges();
});
proxyOutputs(this, this.el, [
'ionModalDidPresent',
'ionModalWillPresent',

View File

@@ -977,7 +977,7 @@ export declare interface IonInfiniteScrollContent extends Components.IonInfinite
@ProxyCmp({
inputs: ['accept', 'autocapitalize', 'autocomplete', 'autocorrect', 'autofocus', 'clearInput', 'clearOnEdit', 'color', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'legacy', 'max', 'maxlength', 'min', 'minlength', 'mode', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'shape', 'size', 'spellcheck', 'step', 'type', 'value'],
inputs: ['accept', 'autocapitalize', 'autocomplete', 'autocorrect', 'autofocus', 'clearInput', 'clearOnEdit', 'color', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'legacy', 'mask', 'maskPlaceholder', 'maskVisibility', 'max', 'maxlength', 'min', 'minlength', 'mode', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'shape', 'size', 'spellcheck', 'step', 'type', 'value'],
methods: ['setFocus', 'getInputElement']
})
@Component({
@@ -985,7 +985,7 @@ export declare interface IonInfiniteScrollContent extends Components.IonInfinite
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['accept', 'autocapitalize', 'autocomplete', 'autocorrect', 'autofocus', 'clearInput', 'clearOnEdit', 'color', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'legacy', 'max', 'maxlength', 'min', 'minlength', 'mode', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'shape', 'size', 'spellcheck', 'step', 'type', 'value'],
inputs: ['accept', 'autocapitalize', 'autocomplete', 'autocorrect', 'autofocus', 'clearInput', 'clearOnEdit', 'color', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'legacy', 'mask', 'maskPlaceholder', 'maskVisibility', 'max', 'maxlength', 'min', 'minlength', 'mode', 'multiple', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'shape', 'size', 'spellcheck', 'step', 'type', 'value'],
})
export class IonInput {
protected el: HTMLElement;

View File

@@ -121,4 +121,4 @@ describe('when in a modal', () => {
cy.get('#set-to-null').click();
cy.get('#inputWithFloatingLabel').should('not.have.class', 'item-has-value');
});
});
});

View File

@@ -17,4 +17,4 @@
</ion-list>
</ion-content>
</ng-template>
</ion-modal>
</ion-modal>

View File

@@ -24,4 +24,5 @@ export class ModalInlineComponent implements AfterViewInit {
onBreakpointDidChange() {
this.breakpointDidChangeCounter++;
}
}

View File

@@ -553,6 +553,9 @@ ion-input,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "
ion-input,prop,label,string | undefined,undefined,false,false
ion-input,prop,labelPlacement,"end" | "fixed" | "floating" | "stacked" | "start",'start',false,false
ion-input,prop,legacy,boolean | undefined,undefined,false,false
ion-input,prop,mask,(string | RegExp)[] | RegExp | undefined,undefined,false,false
ion-input,prop,maskPlaceholder,null | string | undefined,'_',false,false
ion-input,prop,maskVisibility,"always" | "focus" | "never" | undefined,'always',false,false
ion-input,prop,max,number | string | undefined,undefined,false,false
ion-input,prop,maxlength,number | undefined,undefined,false,false
ion-input,prop,min,number | string | undefined,undefined,false,false

View File

@@ -17,6 +17,7 @@ import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interf
import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
import { SpinnerTypes } from "./components/spinner/spinner-configs";
import { MaskExpression, MaskPlaceholder, MaskVisibility } from "./utils/input-masking/public-api";
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
import { CounterFormatter } from "./components/item/item-interface";
import { MenuChangeEventDetail, Side } from "./components/menu/menu-interface";
@@ -53,6 +54,7 @@ export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interf
export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
export { SpinnerTypes } from "./components/spinner/spinner-configs";
export { MaskExpression, MaskPlaceholder, MaskVisibility } from "./utils/input-masking/public-api";
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
export { CounterFormatter } from "./components/item/item-interface";
export { MenuChangeEventDetail, Side } from "./components/menu/menu-interface";
@@ -1225,6 +1227,18 @@ export namespace Components {
* Set the `legacy` property to `true` to forcibly use the legacy form control markup. Ionic will only opt components in to the modern form markup when they are using either the `aria-label` attribute or the `label` property. As a result, the `legacy` property should only be used as an escape hatch when you want to avoid this automatic opt-in behavior. Note that this property will be removed in an upcoming major release of Ionic, and all form components will be opted-in to using the modern form markup.
*/
"legacy"?: boolean;
/**
* The predefined format of the user's input. For example, you can set a mask that only accepts digits, or you can configure a more complex pattern like a phone number or credit card number. The mask supports two formats: 1. A valid [regular expression pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) 2. An array containing regular expression and fixed character patterns The fixed characters in the mask cannot be erased or replaced by the user. For example in a phone number mask, the `(`, `)` and `-` are examples of fixed characters.
*/
"mask"?: MaskExpression;
/**
* Character or string to cover unfilled parts of the mask. The default character is `_`. If set to `null`, `undefined` or an empty string, unfilled parts will be empty as in a regular input.
*/
"maskPlaceholder"?: MaskPlaceholder;
/**
* The visibility of the mask placeholder. With `always`, the placeholder will be visible even when the control does not have focus. With `focus`, the placeholder will only be visible when the control has focus. With `never`, the placeholder will never be visibly displayed.
*/
"maskVisibility"?: MaskVisibility;
/**
* The maximum value, which must not be less than its minimum (min attribute) value.
*/
@@ -5255,6 +5269,18 @@ declare namespace LocalJSX {
* Set the `legacy` property to `true` to forcibly use the legacy form control markup. Ionic will only opt components in to the modern form markup when they are using either the `aria-label` attribute or the `label` property. As a result, the `legacy` property should only be used as an escape hatch when you want to avoid this automatic opt-in behavior. Note that this property will be removed in an upcoming major release of Ionic, and all form components will be opted-in to using the modern form markup.
*/
"legacy"?: boolean;
/**
* The predefined format of the user's input. For example, you can set a mask that only accepts digits, or you can configure a more complex pattern like a phone number or credit card number. The mask supports two formats: 1. A valid [regular expression pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) 2. An array containing regular expression and fixed character patterns The fixed characters in the mask cannot be erased or replaced by the user. For example in a phone number mask, the `(`, `)` and `-` are examples of fixed characters.
*/
"mask"?: MaskExpression;
/**
* Character or string to cover unfilled parts of the mask. The default character is `_`. If set to `null`, `undefined` or an empty string, unfilled parts will be empty as in a regular input.
*/
"maskPlaceholder"?: MaskPlaceholder;
/**
* The visibility of the mask placeholder. With `always`, the placeholder will be visible even when the control does not have focus. With `focus`, the placeholder will only be visible when the control has focus. With `never`, the placeholder will never be visibly displayed.
*/
"maskVisibility"?: MaskVisibility;
/**
* The maximum value, which must not be less than its minimum (min attribute) value.
*/
@@ -5830,6 +5856,10 @@ declare namespace LocalJSX {
* Emitted before the modal has presented.
*/
"onIonModalWillPresent"?: (event: IonModalCustomEvent<void>) => void;
/**
* Emitted before the modal has presented, but after the component has been mounted in the DOM. This event exists so iOS can run the entering transition properly
*/
"onIonMount"?: (event: IonModalCustomEvent<void>) => void;
/**
* Emitted before the modal has dismissed. Shorthand for ionModalWillDismiss.
*/

View File

@@ -15,6 +15,7 @@ import {
prepareOverlay,
present,
safeCall,
setOverlayId,
} from '../../utils/overlays';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import { getClassMap } from '../../utils/theme';
@@ -311,6 +312,10 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
this.triggerController.removeClickListener();
}
componentWillLoad() {
setOverlayId(this.el);
}
componentDidLoad() {
/**
* Do not create gesture if:

View File

@@ -0,0 +1,41 @@
import { newSpecPage } from '@stencil/core/testing';
import { ActionSheet } from '../action-sheet';
it('action sheet should be assigned an incrementing id', async () => {
const page = await newSpecPage({
components: [ActionSheet],
html: `<ion-action-sheet is-open="true"></ion-action-sheet>`,
});
let actionSheet: HTMLIonActionSheetElement;
actionSheet = page.body.querySelector('ion-action-sheet')!;
expect(actionSheet).not.toBe(null);
expect(actionSheet.getAttribute('id')).toBe('ion-overlay-1');
// Remove the action sheet from the DOM
actionSheet.remove();
await page.waitForChanges();
// Create a new action sheet to verify the id is incremented
actionSheet = document.createElement('ion-action-sheet');
actionSheet.isOpen = true;
page.body.appendChild(actionSheet);
await page.waitForChanges();
actionSheet = page.body.querySelector('ion-action-sheet')!;
expect(actionSheet.getAttribute('id')).toBe('ion-overlay-2');
// Presenting the same action sheet again should reuse the existing id
actionSheet.isOpen = false;
await page.waitForChanges();
actionSheet.isOpen = true;
await page.waitForChanges();
actionSheet = page.body.querySelector('ion-action-sheet')!;
expect(actionSheet.getAttribute('id')).toBe('ion-overlay-2');
});

View File

@@ -17,6 +17,7 @@ import {
prepareOverlay,
present,
safeCall,
setOverlayId,
} from '../../utils/overlays';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import type { IonicSafeString } from '../../utils/sanitization';
@@ -329,6 +330,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
}
componentWillLoad() {
setOverlayId(this.el);
this.inputsChanged();
this.buttonsChanged();
}

View File

@@ -0,0 +1,41 @@
import { newSpecPage } from '@stencil/core/testing';
import { Alert } from '../alert';
it('alert should be assigned an incrementing id', async () => {
const page = await newSpecPage({
components: [Alert],
html: `<ion-alert is-open="true"></ion-alert>`,
});
let alert: HTMLIonAlertElement;
alert = page.body.querySelector('ion-alert')!;
expect(alert).not.toBe(null);
expect(alert.getAttribute('id')).toBe('ion-overlay-1');
// Remove the alert from the DOM
alert.remove();
await page.waitForChanges();
// Create a new alert to verify the id is incremented
alert = document.createElement('ion-alert');
alert.isOpen = true;
page.body.appendChild(alert);
await page.waitForChanges();
alert = page.body.querySelector('ion-alert')!;
expect(alert.getAttribute('id')).toBe('ion-overlay-2');
// Presenting the same alert again should reuse the existing id
alert.isOpen = false;
await page.waitForChanges();
alert.isOpen = true;
await page.waitForChanges();
alert = page.body.querySelector('ion-alert')!;
expect(alert.getAttribute('id')).toBe('ion-overlay-2');
});

View File

@@ -8,6 +8,8 @@ import type { LegacyFormController } from '../../utils/forms';
import { createLegacyFormController } from '../../utils/forms';
import type { Attributes } from '../../utils/helpers';
import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '../../utils/helpers';
import { MaskController } from '../../utils/input-masking';
import type { MaskExpression, MaskPlaceholder, MaskVisibility } from '../../utils/input-masking/public-api';
import { printIonWarning } from '../../utils/logging';
import { createColorClasses, hostContext } from '../../utils/theme';
@@ -31,6 +33,7 @@ export class Input implements ComponentInterface {
private inheritedAttributes: Attributes = {};
private isComposing = false;
private legacyFormController!: LegacyFormController;
private maskController?: MaskController;
// This flag ensures we log the deprecation warning at most once.
private hasLoggedDeprecationWarning = false;
@@ -272,6 +275,35 @@ export class Input implements ComponentInterface {
*/
@Prop({ mutable: true }) value?: string | number | null = '';
/**
* The predefined format of the user's input. For example, you can set a mask
* that only accepts digits, or you can configure a more complex pattern like
* a phone number or credit card number.
*
* The mask supports two formats:
* 1. A valid [regular expression pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions)
* 2. An array containing regular expression and fixed character patterns
*
* The fixed characters in the mask cannot be erased or replaced by the user. For example
* in a phone number mask, the `(`, `)` and `-` are examples of fixed characters.
*
*/
@Prop() mask?: MaskExpression;
/**
* The visibility of the mask placeholder. With `always`, the placeholder will be
* visible even when the control does not have focus. With `focus`, the placeholder
* will only be visible when the control has focus. With `never`, the placeholder will
* never be visibly displayed.
*/
@Prop() maskVisibility?: MaskVisibility = 'always';
/**
* Character or string to cover unfilled parts of the mask. The default character is `_`.
* If set to `null`, `undefined` or an empty string, unfilled parts will be empty as in a regular input.
*/
@Prop() maskPlaceholder?: MaskPlaceholder = '_';
/**
* The `ionInput` event fires when the `value` of an `<ion-input>` element
* has been changed.
@@ -343,9 +375,11 @@ export class Input implements ComponentInterface {
}
componentWillLoad() {
const { el } = this;
this.inheritedAttributes = {
...inheritAriaAttributes(this.el),
...inheritAttributes(this.el, ['tabindex', 'title', 'data-form-type']),
...inheritAriaAttributes(el),
...inheritAttributes(el, ['tabindex', 'title', 'data-form-type']),
};
}
@@ -366,7 +400,15 @@ export class Input implements ComponentInterface {
}
componentDidLoad() {
const { mask, nativeInput } = this;
this.originalIonInput = this.ionInput;
if (mask !== undefined && nativeInput) {
this.maskController = new MaskController(nativeInput, {
mask,
});
}
}
disconnectedCallback() {
@@ -377,6 +419,9 @@ export class Input implements ComponentInterface {
})
);
}
// Todo - need to evaluate if I need to recreate this in connectedCallback after first load
this.maskController?.destroy();
}
/**

View File

@@ -1,4 +1,5 @@
import { newSpecPage } from '@stencil/core/testing';
import { Input } from '../input';
it('should inherit attributes', async () => {
@@ -7,7 +8,7 @@ it('should inherit attributes', async () => {
html: '<ion-input title="my title" tabindex="-1" data-form-type="password"></ion-input>',
});
const nativeEl = page.body.querySelector('ion-input input');
const nativeEl = page.body.querySelector('ion-input input')!;
expect(nativeEl.getAttribute('title')).toBe('my title');
expect(nativeEl.getAttribute('tabindex')).toBe('-1');
expect(nativeEl.getAttribute('data-form-type')).toBe('password');

View File

@@ -1,4 +1,5 @@
import { newSpecPage } from '@stencil/core/testing';
import { Input } from '../input';
it('should render bottom content when helper text is defined', async () => {

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Input - Mask</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>
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(250px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
}
@media screen and (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
padding: 0;
}
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Input - Item</ion-title>
</ion-toolbar>
</ion-header>
<ion-content id="content" class="ion-padding" color="light">
<div class="grid">
<div class="grid-item">
<h2>US Phone Number</h2>
<ion-input id="input-phone-us" label="Phone"></ion-input>
</div>
</div>
</ion-content>
</ion-app>
<script>
const inputPhoneUS = document.querySelector('#input-phone-us');
inputPhoneUS.mask = [
'+',
'1',
' ',
'(',
/\d/,
/\d/,
/\d/,
')',
' ',
/\d/,
/\d/,
/\d/,
'-',
/\d/,
/\d/,
/\d/,
/\d/,
];
</script>
</body>
</html>

View File

@@ -14,6 +14,7 @@ import {
present,
createDelegateController,
createTriggerController,
setOverlayId,
} from '../../utils/overlays';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import type { IonicSafeString } from '../../utils/sanitization';
@@ -212,6 +213,7 @@ export class Loading implements ComponentInterface, OverlayInterface {
const mode = getIonMode(this);
this.spinner = config.get('loadingSpinner', config.get('spinner', mode === 'ios' ? 'lines' : 'crescent'));
}
setOverlayId(this.el);
}
componentDidLoad() {

View File

@@ -0,0 +1,41 @@
import { newSpecPage } from '@stencil/core/testing';
import { Loading } from '../loading';
it('loading should be assigned an incrementing id', async () => {
const page = await newSpecPage({
components: [Loading],
html: `<ion-loading is-open="true"></ion-loading>`,
});
let loading: HTMLIonLoadingElement;
loading = page.body.querySelector('ion-loading')!;
expect(loading).not.toBe(null);
expect(loading.getAttribute('id')).toBe('ion-overlay-1');
// Remove the loading from the DOM
loading.remove();
await page.waitForChanges();
// Create a new loading to verify the id is incremented
loading = document.createElement('ion-loading');
loading.isOpen = true;
page.body.appendChild(loading);
await page.waitForChanges();
loading = page.body.querySelector('ion-loading')!;
expect(loading.getAttribute('id')).toBe('ion-overlay-2');
// Presenting the same loading again should reuse the existing id
loading.isOpen = false;
await page.waitForChanges();
loading.isOpen = true;
await page.waitForChanges();
loading = page.body.querySelector('ion-loading')!;
expect(loading.getAttribute('id')).toBe('ion-overlay-2');
});

View File

@@ -1,15 +1,16 @@
import { newSpecPage } from '@stencil/core/testing';
import { Loading } from '../loading';
import { config } from '../../../global/config';
describe('alert: custom html', () => {
import { config } from '../../../global/config';
import { Loading } from '../loading';
describe('loading: custom html', () => {
it('should not allow for custom html by default', async () => {
const page = await newSpecPage({
components: [Loading],
html: `<ion-loading message="<button class='custom-html'>Custom Text</button>"></ion-loading>`,
});
const content = page.body.querySelector('.loading-content');
const content = page.body.querySelector('.loading-content')!;
expect(content.textContent).toContain('Custom Text');
expect(content.querySelector('button.custom-html')).toBe(null);
});
@@ -21,7 +22,7 @@ describe('alert: custom html', () => {
html: `<ion-loading message="<button class='custom-html'>Custom Text</button>"></ion-loading>`,
});
const content = page.body.querySelector('.loading-content');
const content = page.body.querySelector('.loading-content')!;
expect(content.textContent).toContain('Custom Text');
expect(content.querySelector('button.custom-html')).not.toBe(null);
});
@@ -33,7 +34,7 @@ describe('alert: custom html', () => {
html: `<ion-loading message="<button class='custom-html'>Custom Text</button>"></ion-loading>`,
});
const content = page.body.querySelector('.loading-content');
const content = page.body.querySelector('.loading-content')!;
expect(content.textContent).toContain('Custom Text');
expect(content.querySelector('button.custom-html')).toBe(null);
});

View File

@@ -28,10 +28,11 @@ import {
prepareOverlay,
present,
createTriggerController,
setOverlayId,
} from '../../utils/overlays';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import { getClassMap } from '../../utils/theme';
import { deepReady } from '../../utils/transition';
import { deepReady, waitForMount } from '../../utils/transition';
import { iosEnterAnimation } from './animations/ios.enter';
import { iosLeaveAnimation } from './animations/ios.leave';
@@ -65,8 +66,6 @@ import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
export class Modal implements ComponentInterface, OverlayInterface {
private readonly triggerController = createTriggerController();
private gesture?: Gesture;
private modalIndex = modalIds++;
private modalId?: string;
private coreDelegate: FrameworkDelegate = CoreDelegate();
private currentTransition?: Promise<any>;
private sheetTransition?: Promise<any>;
@@ -316,6 +315,16 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/
@Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter<OverlayEventDetail>;
/**
* Emitted before the modal has presented, but after the component
* has been mounted in the DOM.
* This event exists so iOS can run the entering
* transition properly
*
* @internal
*/
@Event() ionMount!: EventEmitter<void>;
breakpointsChanged(breakpoints: number[] | undefined) {
if (breakpoints !== undefined) {
this.sortedBreakpoints = breakpoints.sort((a, b) => a - b);
@@ -334,16 +343,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
componentWillLoad() {
const { breakpoints, initialBreakpoint, el } = this;
const isSheetModal = (this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined);
this.inheritedAttributes = inheritAttributes(el, ['aria-label', 'role']);
/**
* If user has custom ID set then we should
* not assign the default incrementing ID.
*/
this.modalId = this.el.hasAttribute('id') ? this.el.getAttribute('id')! : `ion-modal-${this.modalIndex}`;
const isSheetModal = (this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined);
if (isSheetModal) {
this.currentBreakpoint = this.initialBreakpoint;
}
@@ -351,6 +354,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (breakpoints !== undefined && initialBreakpoint !== undefined && !breakpoints.includes(initialBreakpoint)) {
printIonWarning('Your breakpoints array must include the initialBreakpoint value.');
}
setOverlayId(el);
}
componentDidLoad() {
@@ -443,7 +448,30 @@ export class Modal implements ComponentInterface, OverlayInterface {
const { inline, delegate } = this.getDelegate(true);
this.usersElement = await attachComponent(delegate, el, this.component, ['ion-page'], this.componentProps, inline);
hasLazyBuild(el) && (await deepReady(this.usersElement));
this.ionMount.emit();
/**
* When using the lazy loaded build of Stencil, we need to wait
* for every Stencil component instance to be ready before presenting
* otherwise there can be a flash of unstyled content. With the
* custom elements bundle we need to wait for the JS framework
* mount the inner contents of the overlay otherwise WebKit may
* get the transition incorrect.
*/
if (hasLazyBuild(el)) {
await deepReady(this.usersElement);
/**
* If keepContentsMounted="true" then the
* JS Framework has already mounted the inner
* contents so there is no need to wait.
* Otherwise, we need to wait for the JS
* Framework to mount the inner contents
* of this component.
*/
} else if (!this.keepContentsMounted) {
await waitForMount();
}
writeTask(() => this.el.classList.add('show-modal'));
@@ -828,7 +856,6 @@ export class Modal implements ComponentInterface, OverlayInterface {
const showHandle = handle !== false && isSheetModal;
const mode = getIonMode(this);
const { modalId } = this;
const isCardModal = presentingElement !== undefined && mode === 'ios';
const isHandleCycle = handleBehavior === 'cycle';
@@ -848,7 +875,6 @@ export class Modal implements ComponentInterface, OverlayInterface {
'overlay-hidden': true,
...getClassMap(this.cssClass),
}}
id={modalId}
onIonBackdropTap={this.onBackdropTap}
onIonModalDidPresent={this.onLifecycle}
onIonModalWillPresent={this.onLifecycle}
@@ -902,8 +928,6 @@ const LIFECYCLE_MAP: any = {
ionModalDidDismiss: 'ionViewDidLeave',
};
let modalIds = 0;
interface ModalOverlayOptions {
/**
* The element that presented the modal.

View File

@@ -0,0 +1,41 @@
import { newSpecPage } from '@stencil/core/testing';
import { Modal } from '../modal';
it('modal should be assigned an incrementing id', async () => {
const page = await newSpecPage({
components: [Modal],
html: `<ion-modal is-open="true"></ion-modal>`,
});
let modal: HTMLIonModalElement;
modal = page.body.querySelector('ion-modal')!;
expect(modal).not.toBe(null);
expect(modal.getAttribute('id')).toBe('ion-overlay-1');
// Remove the modal from the DOM
modal.remove();
await page.waitForChanges();
// Create a new modal to verify the id is incremented
modal = document.createElement('ion-modal');
modal.isOpen = true;
page.body.appendChild(modal);
await page.waitForChanges();
modal = page.body.querySelector('ion-modal')!;
expect(modal.getAttribute('id')).toBe('ion-overlay-2');
// Presenting the same modal again should reuse the existing id
modal.isOpen = false;
await page.waitForChanges();
modal.isOpen = true;
await page.waitForChanges();
modal = page.body.querySelector('ion-modal')!;
expect(modal.getAttribute('id')).toBe('ion-overlay-2');
});

View File

@@ -1,76 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.describe('picker-column-internal', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/picker-column-internal/test/basic');
});
test('should render a picker item for each item', async ({ page }) => {
const columns = page.locator('ion-picker-column-internal .picker-item:not(.picker-item-empty)');
await expect(columns).toHaveCount(24);
});
test('should render 6 empty picker items', async ({ page }) => {
const columns = page.locator('ion-picker-column-internal .picker-item-empty');
await expect(columns).toHaveCount(6);
});
test('should not have an active item when value is not set', async ({ page }) => {
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
await expect(activeColumn).toHaveCount(0);
});
test('should have an active item when value is set', async ({ page }) => {
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
el.value = '12';
});
await page.waitForChanges();
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
expect(activeColumn).not.toBeNull();
});
// TODO FW-3616
test.skip('scrolling should change the active item', async ({ page, skip }) => {
skip.browser('firefox', 'https://bugzilla.mozilla.org/show_bug.cgi?id=1766890');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
el.scrollTop = 801;
});
await page.waitForChanges();
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
expect(await activeColumn?.innerText()).toEqual('23');
});
test('should not emit ionChange when the value is modified externally', async ({ page, skip }) => {
skip.browser('firefox', 'https://bugzilla.mozilla.org/show_bug.cgi?id=1766890');
const ionChangeSpy = await page.spyOnEvent('ionChange');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
el.value = '12';
});
expect(ionChangeSpy).not.toHaveReceivedEvent();
});
// TODO FW-3616
test.skip('should emit ionChange when the picker is scrolled', async ({ page, skip }) => {
skip.browser('firefox', 'https://bugzilla.mozilla.org/show_bug.cgi?id=1766890');
const ionChangeSpy = await page.spyOnEvent('ionChange');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
el.scrollTo(0, el.scrollHeight);
});
await page.waitForChanges();
await ionChangeSpy.next();
expect(ionChangeSpy).toHaveReceivedEvent();
});
});

View File

@@ -0,0 +1,81 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-column-internal'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/picker-column-internal/test/basic', config);
});
test('should render a picker item for each item', async ({ page }) => {
const columns = page.locator('ion-picker-column-internal .picker-item:not(.picker-item-empty)');
await expect(columns).toHaveCount(24);
});
test('should render 6 empty picker items', async ({ page }) => {
const columns = page.locator('ion-picker-column-internal .picker-item-empty');
await expect(columns).toHaveCount(6);
});
test('should not have an active item when value is not set', async ({ page }) => {
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
await expect(activeColumn).toHaveCount(0);
});
test('should have an active item when value is set', async ({ page }) => {
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
el.value = '12';
});
await page.waitForChanges();
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
expect(activeColumn).not.toBeNull();
});
// TODO FW-3616
test.skip('scrolling should change the active item', async ({ page, skip }) => {
skip.browser('firefox', 'https://bugzilla.mozilla.org/show_bug.cgi?id=1766890');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
el.scrollTop = 801;
});
await page.waitForChanges();
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
expect(await activeColumn?.innerText()).toEqual('23');
});
test('should not emit ionChange when the value is modified externally', async ({ page, skip }) => {
skip.browser('firefox', 'https://bugzilla.mozilla.org/show_bug.cgi?id=1766890');
const ionChangeSpy = await page.spyOnEvent('ionChange');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
el.value = '12';
});
expect(ionChangeSpy).not.toHaveReceivedEvent();
});
// TODO FW-3616
test.skip('should emit ionChange when the picker is scrolled', async ({ page, skip }) => {
skip.browser('firefox', 'https://bugzilla.mozilla.org/show_bug.cgi?id=1766890');
const ionChangeSpy = await page.spyOnEvent('ionChange');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
el.scrollTo(0, el.scrollHeight);
});
await page.waitForChanges();
await ionChangeSpy.next();
expect(ionChangeSpy).toHaveReceivedEvent();
});
});
});

View File

@@ -1,130 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.describe('picker-column-internal: disabled', () => {
test('should not have visual regressions', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal value="b"></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a', disabled: true },
{ text: 'B', value: 'b' },
{ text: 'C', value: 'c', disabled: true }
]
</script>
`);
const picker = page.locator('ion-picker-internal');
await expect(picker).toHaveScreenshot(`picker-internal-disabled-${page.getSnapshotSettings()}.png`);
});
test('all picker items should be enabled by default', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b' },
{ text: 'C', value: 'c' }
]
</script>
`);
const pickerItems = page.locator(
'ion-picker-column-internal .picker-item:not(.picker-item-empty, .picker-item-disabled)'
);
expect(await pickerItems.count()).toBe(3);
});
test('disabled picker item should not be interactive', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
{ text: 'C', value: 'c' }
]
</script>
`);
const disabledItem = page.locator('ion-picker-column-internal .picker-item.picker-item-disabled');
await expect(disabledItem).not.toBeEnabled();
});
test('disabled picker item should not be considered active', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal value="b"></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
{ text: 'C', value: 'c' }
]
</script>
`);
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
test('setting the value to a disabled item should not cause that item to be active', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
{ text: 'C', value: 'c' }
]
</script>
`);
const pickerColumn = page.locator('ion-picker-column-internal');
await pickerColumn.evaluate((el: HTMLIonPickerColumnInternalElement) => (el.value = 'b'));
await page.waitForChanges();
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
await expect(disabledItem).toHaveClass(/picker-item-disabled/);
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
test('defaulting the value to a disabled item should not cause that item to be active', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
{ text: 'C', value: 'c' }
]
column.value = 'b'
</script>
`);
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
await expect(disabledItem).toHaveClass(/picker-item-disabled/);
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
});

View File

@@ -0,0 +1,161 @@
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('picker-column-internal: disabled rendering'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal value="b"></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a', disabled: true },
{ text: 'B', value: 'b' },
{ text: 'C', value: 'c', disabled: true }
]
</script>
`,
config
);
const picker = page.locator('ion-picker-internal');
await expect(picker).toHaveScreenshot(screenshot(`picker-internal-disabled`));
});
});
});
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-column-internal: disabled'), () => {
test('all picker items should be enabled by default', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b' },
{ text: 'C', value: 'c' }
]
</script>
`,
config
);
const pickerItems = page.locator(
'ion-picker-column-internal .picker-item:not(.picker-item-empty, .picker-item-disabled)'
);
expect(await pickerItems.count()).toBe(3);
});
test('disabled picker item should not be interactive', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
{ text: 'C', value: 'c' }
]
</script>
`,
config
);
const disabledItem = page.locator('ion-picker-column-internal .picker-item.picker-item-disabled');
await expect(disabledItem).not.toBeEnabled();
});
test('disabled picker item should not be considered active', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal value="b"></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
{ text: 'C', value: 'c' }
]
</script>
`,
config
);
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
test('setting the value to a disabled item should not cause that item to be active', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
{ text: 'C', value: 'c' }
]
</script>
`,
config
);
const pickerColumn = page.locator('ion-picker-column-internal');
await pickerColumn.evaluate((el: HTMLIonPickerColumnInternalElement) => (el.value = 'b'));
await page.waitForChanges();
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
await expect(disabledItem).toHaveClass(/picker-item-disabled/);
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
test('defaulting the value to a disabled item should not cause that item to be active', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
{ text: 'C', value: 'c' }
]
column.value = 'b'
</script>
`,
config
);
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
await expect(disabledItem).toHaveClass(/picker-item-disabled/);
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
});
});

View File

@@ -1,13 +0,0 @@
import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.describe('picker-internal: a11y', () => {
test('should not have accessibility violations', async ({ page }) => {
await page.goto(`/src/components/picker-internal/test/a11y`);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
});

View File

@@ -0,0 +1,15 @@
import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs().forEach(({ title, config }) => {
test.describe(title('picker-internal: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.goto(`/src/components/picker-internal/test/a11y`, config);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
});
});

View File

@@ -1,21 +1,88 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
import { configs, test } from '@utils/test/playwright';
test.describe('picker-internal', () => {
// TODO: FW-3020
test.skip('inline pickers should not have visual regression', async ({ page }) => {
await page.goto(`/src/components/picker-internal/test/basic`);
// TODO: FW-3020
configs().forEach(({ title, screenshot, config }) => {
test.describe(title('picker-internal: rendering'), () => {
test.skip('inline pickers should not have visual regression', async ({ page }) => {
await page.goto(`/src/components/picker-internal/test/basic`, config);
await page.setIonViewport();
await page.setIonViewport();
await expect(page).toHaveScreenshot(`picker-internal-inline-diff-${page.getSnapshotSettings()}.png`, {
fullPage: true,
await expect(page).toHaveScreenshot(screenshot(`picker-internal-inline-diff`), {
fullPage: true,
});
});
});
});
test.describe('picker-internal: focus', () => {
/**
* This behavior does not vary across modes.
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('picker-internal: overlay rendering'), () => {
test('popover: should not have visual regression', async ({ page }) => {
await page.goto(`/src/components/picker-internal/test/basic`, config);
const button = page.locator('#popover');
const didPresent = await page.spyOnEvent('ionPopoverDidPresent');
const pickerInternal = page.locator('ion-popover ion-picker-internal');
await button.click();
await didPresent.next();
await expect(pickerInternal).toBeVisible();
const popoverContent = page.locator('ion-popover .ion-delegate-host');
await expect(popoverContent).toHaveScreenshot(screenshot(`picker-internal-popover-diff`), {
/**
* Animations must be enabled to capture the screenshot.
* By default, animations are disabled with toHaveScreenshot,
* and when capturing the screenshot will call animation.finish().
* This will cause the popover to close and the screenshot capture
* to be invalid.
*/
animations: 'allow',
});
});
test('modal: should not have visual regression', async ({ page }) => {
await page.goto('/src/components/picker-internal/test/basic', config);
const button = page.locator('#modal');
const didPresent = await page.spyOnEvent('ionModalDidPresent');
const pickerInternal = page.locator('ion-modal ion-picker-internal');
await button.click();
await didPresent.next();
await expect(pickerInternal).toBeVisible();
const modalContent = page.locator('ion-modal .ion-delegate-host');
await expect(modalContent).toHaveScreenshot(screenshot(`picker-internal-modal-diff`), {
/**
* Animations must be enabled to capture the screenshot.
* By default, animations are disabled with toHaveScreenshot,
* and when capturing the screenshot will call animation.finish().
* This will cause the modal to close and the screenshot capture
* to be invalid.
*/
animations: 'allow',
});
});
});
});
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-internal: focus'), () => {
test.beforeEach(async ({ page }) => {
await page.setContent(`
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal value="full-stack" id="first"></ion-picker-column-internal>
<ion-picker-column-internal value="onion" id="second"></ion-picker-column-internal>
@@ -39,7 +106,9 @@ test.describe('picker-internal', () => {
{ text: 'Artichoke', value: 'artichoke' },
];
</script>
`);
`,
config
);
});
test('tabbing should correctly move focus between columns', async ({ page }) => {
@@ -71,58 +140,4 @@ test.describe('picker-internal', () => {
await expect(firstColumn).toBeFocused();
});
});
test.describe('within overlay:', () => {
test('popover: should not have visual regression', async ({ page }) => {
await page.goto(`/src/components/picker-internal/test/basic`);
const button = page.locator('#popover');
const didPresent = await page.spyOnEvent('ionPopoverDidPresent');
const pickerInternal = page.locator('ion-popover ion-picker-internal');
await button.click();
await didPresent.next();
await expect(pickerInternal).toBeVisible();
const popoverContent = page.locator('ion-popover .ion-delegate-host');
await expect(popoverContent).toHaveScreenshot(`picker-internal-popover-diff-${page.getSnapshotSettings()}.png`, {
/**
* Animations must be enabled to capture the screenshot.
* By default, animations are disabled with toHaveScreenshot,
* and when capturing the screenshot will call animation.finish().
* This will cause the popover to close and the screenshot capture
* to be invalid.
*/
animations: 'allow',
});
});
test('modal: should not have visual regression', async ({ page }) => {
await page.goto('/src/components/picker-internal/test/basic');
const button = page.locator('#modal');
const didPresent = await page.spyOnEvent('ionModalDidPresent');
const pickerInternal = page.locator('ion-modal ion-picker-internal');
await button.click();
await didPresent.next();
await expect(pickerInternal).toBeVisible();
const modalContent = page.locator('ion-modal .ion-delegate-host');
await expect(modalContent).toHaveScreenshot(`picker-internal-modal-diff-${page.getSnapshotSettings()}.png`, {
/**
* Animations must be enabled to capture the screenshot.
* By default, animations are disabled with toHaveScreenshot,
* and when capturing the screenshot will call animation.finish().
* This will cause the modal to close and the screenshot capture
* to be invalid.
*/
animations: 'allow',
});
});
});
});

View File

@@ -1,123 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
import type { E2ELocator } from '@utils/test/playwright/page/utils/locator';
test.describe('picker-internal: keyboard entry', () => {
test('should scroll to and update the value prop for a single column', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: '01', value: 1 },
{ text: '02', value: 2 },
{ text: '03', value: 3 },
{ text: '04', value: 4 },
{ text: '05', value: 5 }
];
column.value = 5;
column.numericInput = true;
</script>
`);
const column = page.locator('ion-picker-column-internal');
const ionChange = await page.spyOnEvent('ionChange');
await column.focus();
await page.keyboard.press('Digit2');
await expect(ionChange).toHaveReceivedEventDetail({ text: '02', value: 2 });
await expect(column).toHaveJSProperty('value', 2);
});
test('should scroll to and update the value prop for multiple columns', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal id="first"></ion-picker-column-internal>
<ion-picker-column-internal id="second"></ion-picker-column-internal>
</ion-picker-internal>
<script>
const firstColumn = document.querySelector('ion-picker-column-internal#first');
firstColumn.items = [
{ text: '01', value: 1 },
{ text: '02', value: 2 },
{ text: '03', value: 3 },
{ text: '04', value: 4 },
{ text: '05', value: 5 }
];
firstColumn.value = 5;
firstColumn.numericInput = true;
const secondColumn = document.querySelector('ion-picker-column-internal#second');
secondColumn.items = [
{ text: '20', value: 20 },
{ text: '21', value: 21 },
{ text: '22', value: 22 },
{ text: '23', value: 23 },
{ text: '24', value: 24 }
];
secondColumn.value = 22;
secondColumn.numericInput = true;
</script>
`);
const firstColumn = page.locator('ion-picker-column-internal#first');
const secondColumn = page.locator('ion-picker-column-internal#second');
const highlight = page.locator('ion-picker-internal .picker-highlight');
const firstIonChange = await (firstColumn as E2ELocator).spyOnEvent('ionChange');
const secondIonChange = await (secondColumn as E2ELocator).spyOnEvent('ionChange');
const box = await highlight.boundingBox();
if (box !== null) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
await expect(firstColumn).toHaveClass(/picker-column-active/);
await expect(secondColumn).toHaveClass(/picker-column-active/);
await page.keyboard.press('Digit2');
await expect(firstIonChange).toHaveReceivedEventDetail({ text: '02', value: 2 });
await expect(firstColumn).toHaveJSProperty('value', 2);
await page.keyboard.press('Digit2+Digit4');
await expect(secondIonChange).toHaveReceivedEventDetail({ text: '24', value: 24 });
await expect(secondColumn).toHaveJSProperty('value', 24);
});
test('should select 00', async ({ page }) => {
await page.setContent(`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: '00', value: 12 },
{ text: '01', value: 1 },
{ text: '02', value: 2 },
{ text: '03', value: 3 },
{ text: '04', value: 4 },
{ text: '05', value: 5 }
];
column.value = 5;
column.numericInput = true;
</script>
`);
const column = page.locator('ion-picker-column-internal');
const ionChange = await page.spyOnEvent('ionChange');
await column.focus();
await page.keyboard.press('Digit0');
await expect(ionChange).toHaveReceivedEventDetail({ text: '00', value: 12 });
await expect(column).toHaveJSProperty('value', 12);
});
});

View File

@@ -0,0 +1,137 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { E2ELocator } from '@utils/test/playwright/page/utils/locator';
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-internal: keyboard entry'), () => {
test('should scroll to and update the value prop for a single column', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: '01', value: 1 },
{ text: '02', value: 2 },
{ text: '03', value: 3 },
{ text: '04', value: 4 },
{ text: '05', value: 5 }
];
column.value = 5;
column.numericInput = true;
</script>
`,
config
);
const column = page.locator('ion-picker-column-internal');
const ionChange = await page.spyOnEvent('ionChange');
await column.focus();
await page.keyboard.press('Digit2');
await expect(ionChange).toHaveReceivedEventDetail({ text: '02', value: 2 });
await expect(column).toHaveJSProperty('value', 2);
});
test('should scroll to and update the value prop for multiple columns', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal id="first"></ion-picker-column-internal>
<ion-picker-column-internal id="second"></ion-picker-column-internal>
</ion-picker-internal>
<script>
const firstColumn = document.querySelector('ion-picker-column-internal#first');
firstColumn.items = [
{ text: '01', value: 1 },
{ text: '02', value: 2 },
{ text: '03', value: 3 },
{ text: '04', value: 4 },
{ text: '05', value: 5 }
];
firstColumn.value = 5;
firstColumn.numericInput = true;
const secondColumn = document.querySelector('ion-picker-column-internal#second');
secondColumn.items = [
{ text: '20', value: 20 },
{ text: '21', value: 21 },
{ text: '22', value: 22 },
{ text: '23', value: 23 },
{ text: '24', value: 24 }
];
secondColumn.value = 22;
secondColumn.numericInput = true;
</script>
`,
config
);
const firstColumn = page.locator('ion-picker-column-internal#first');
const secondColumn = page.locator('ion-picker-column-internal#second');
const highlight = page.locator('ion-picker-internal .picker-highlight');
const firstIonChange = await (firstColumn as E2ELocator).spyOnEvent('ionChange');
const secondIonChange = await (secondColumn as E2ELocator).spyOnEvent('ionChange');
const box = await highlight.boundingBox();
if (box !== null) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
await expect(firstColumn).toHaveClass(/picker-column-active/);
await expect(secondColumn).toHaveClass(/picker-column-active/);
await page.keyboard.press('Digit2');
await expect(firstIonChange).toHaveReceivedEventDetail({ text: '02', value: 2 });
await expect(firstColumn).toHaveJSProperty('value', 2);
await page.keyboard.press('Digit2+Digit4');
await expect(secondIonChange).toHaveReceivedEventDetail({ text: '24', value: 24 });
await expect(secondColumn).toHaveJSProperty('value', 24);
});
test('should select 00', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: '00', value: 12 },
{ text: '01', value: 1 },
{ text: '02', value: 2 },
{ text: '03', value: 3 },
{ text: '04', value: 4 },
{ text: '05', value: 5 }
];
column.value = 5;
column.numericInput = true;
</script>
`,
config
);
const column = page.locator('ion-picker-column-internal');
const ionChange = await page.spyOnEvent('ionChange');
await column.focus();
await page.keyboard.press('Digit0');
await expect(ionChange).toHaveReceivedEventDetail({ text: '00', value: 12 });
await expect(column).toHaveJSProperty('value', 12);
});
});
});

View File

@@ -13,6 +13,7 @@ import {
prepareOverlay,
present,
safeCall,
setOverlayId,
} from '../../utils/overlays';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import { getClassMap } from '../../utils/theme';
@@ -194,6 +195,10 @@ export class Picker implements ComponentInterface, OverlayInterface {
this.triggerController.removeClickListener();
}
componentWillLoad() {
setOverlayId(this.el);
}
/**
* Present the picker overlay after it has been created.
*/

View File

@@ -0,0 +1,41 @@
import { newSpecPage } from '@stencil/core/testing';
import { Picker } from '../picker';
it('picker should be assigned an incrementing id', async () => {
const page = await newSpecPage({
components: [Picker],
html: `<ion-picker is-open="true"></ion-picker>`,
});
let picker: HTMLIonPickerElement;
picker = page.body.querySelector('ion-picker')!;
expect(picker).not.toBe(null);
expect(picker.getAttribute('id')).toBe('ion-overlay-1');
// Remove the picker from the DOM
picker.remove();
await page.waitForChanges();
// Create a new picker to verify the id is incremented
picker = document.createElement('ion-picker');
picker.isOpen = true;
page.body.appendChild(picker);
await page.waitForChanges();
picker = page.body.querySelector('ion-picker')!;
expect(picker.getAttribute('id')).toBe('ion-overlay-2');
// Presenting the same picker again should reuse the existing id
picker.isOpen = false;
await page.waitForChanges();
picker.isOpen = true;
await page.waitForChanges();
picker = page.body.querySelector('ion-picker')!;
expect(picker.getAttribute('id')).toBe('ion-overlay-2');
});

View File

@@ -6,11 +6,19 @@ import type { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate
import { CoreDelegate, attachComponent, detachComponent } from '../../utils/framework-delegate';
import { addEventListener, raf, hasLazyBuild } from '../../utils/helpers';
import { printIonWarning } from '../../utils/logging';
import { BACKDROP, dismiss, eventMethod, focusFirstDescendant, prepareOverlay, present } from '../../utils/overlays';
import {
BACKDROP,
dismiss,
eventMethod,
focusFirstDescendant,
prepareOverlay,
present,
setOverlayId,
} from '../../utils/overlays';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import { isPlatform } from '../../utils/platform';
import { getClassMap } from '../../utils/theme';
import { deepReady } from '../../utils/transition';
import { deepReady, waitForMount } from '../../utils/transition';
import { iosEnterAnimation } from './animations/ios.enter';
import { iosLeaveAnimation } from './animations/ios.leave';
@@ -49,8 +57,6 @@ export class Popover implements ComponentInterface, PopoverInterface {
private usersElement?: HTMLElement;
private triggerEl?: HTMLElement | null;
private parentPopover: HTMLIonPopoverElement | null = null;
private popoverIndex = popoverIds++;
private popoverId?: string;
private coreDelegate: FrameworkDelegate = CoreDelegate();
private currentTransition?: Promise<any>;
private destroyTriggerInteraction?: () => void;
@@ -338,13 +344,10 @@ export class Popover implements ComponentInterface, PopoverInterface {
}
componentWillLoad() {
/**
* If user has custom ID set then we should
* not assign the default incrementing ID.
*/
this.popoverId = this.el.hasAttribute('id') ? this.el.getAttribute('id')! : `ion-popover-${this.popoverIndex}`;
const { el } = this;
const popoverId = setOverlayId(el);
this.parentPopover = this.el.closest(`ion-popover:not(#${this.popoverId})`) as HTMLIonPopoverElement | null;
this.parentPopover = el.closest(`ion-popover:not(#${popoverId})`) as HTMLIonPopoverElement | null;
if (this.alignment === undefined) {
this.alignment = getIonMode(this) === 'ios' ? 'center' : 'start';
@@ -455,7 +458,6 @@ export class Popover implements ComponentInterface, PopoverInterface {
this.componentProps,
inline
);
hasLazyBuild(el) && (await deepReady(this.usersElement));
if (!this.keyboardEvents) {
this.configureKeyboardInteraction();
@@ -464,52 +466,50 @@ export class Popover implements ComponentInterface, PopoverInterface {
this.ionMount.emit();
return new Promise((resolve) => {
/**
* When using the lazy loaded build of Stencil, we need to wait
* for every Stencil component instance to be ready before presenting
* otherwise there can be a flash of unstyled content. With the
* custom elements bundle we need to wait for the JS framework
* mount the inner contents of the overlay otherwise WebKit may
* get the transition incorrect.
*/
if (hasLazyBuild(el)) {
await deepReady(this.usersElement);
/**
* Wait two request animation frame loops before presenting the popover.
* This allows the framework implementations enough time to mount
* the popover contents, so the bounding box is set when the popover
* transition starts.
*
* On Angular and React, a single raf is enough time, but for Vue
* we need to wait two rafs. As a result we are using two rafs for
* all frameworks to ensure the popover is presented correctly.
* If keepContentsMounted="true" then the
* JS Framework has already mounted the inner
* contents so there is no need to wait.
* Otherwise, we need to wait for the JS
* Framework to mount the inner contents
* of this component.
*/
raf(() => {
raf(async () => {
this.currentTransition = present<PopoverPresentOptions>(
this,
'popoverEnter',
iosEnterAnimation,
mdEnterAnimation,
{
event: event || this.event,
size: this.size,
trigger: this.triggerEl,
reference: this.reference,
side: this.side,
align: this.alignment,
}
);
} else if (!this.keepContentsMounted) {
await waitForMount();
}
await this.currentTransition;
this.currentTransition = undefined;
/**
* If popover is nested and was
* presented using the "Right" arrow key,
* we need to move focus to the first
* descendant inside of the popover.
*/
if (this.focusDescendantOnPresent) {
focusFirstDescendant(this.el, this.el);
}
resolve();
});
});
this.currentTransition = present<PopoverPresentOptions>(this, 'popoverEnter', iosEnterAnimation, mdEnterAnimation, {
event: event || this.event,
size: this.size,
trigger: this.triggerEl,
reference: this.reference,
side: this.side,
align: this.alignment,
});
await this.currentTransition;
this.currentTransition = undefined;
/**
* If popover is nested and was
* presented using the "Right" arrow key,
* we need to move focus to the first
* descendant inside of the popover.
*/
if (this.focusDescendantOnPresent) {
focusFirstDescendant(this.el, this.el);
}
}
/**
@@ -663,7 +663,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
render() {
const mode = getIonMode(this);
const { onLifecycle, popoverId, parentPopover, dismissOnSelect, side, arrow, htmlAttributes } = this;
const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes } = this;
const desktop = isPlatform('desktop');
const enableArrow = arrow && !parentPopover;
@@ -676,7 +676,6 @@ export class Popover implements ComponentInterface, PopoverInterface {
style={{
zIndex: `${20000 + this.overlayIndex}`,
}}
id={popoverId}
class={{
...getClassMap(this.cssClass),
[mode]: true,
@@ -712,8 +711,6 @@ const LIFECYCLE_MAP: any = {
ionPopoverDidDismiss: 'ionViewDidLeave',
};
let popoverIds = 0;
interface PopoverPresentOptions {
/**
* The original target event that presented the popover.

View File

@@ -0,0 +1,41 @@
import { newSpecPage } from '@stencil/core/testing';
import { Popover } from '../popover';
it('popover should be assigned an incrementing id', async () => {
const page = await newSpecPage({
components: [Popover],
html: `<ion-popover is-open="true"></ion-popover>`,
});
let popover: HTMLIonPopoverElement;
popover = page.body.querySelector('ion-popover')!;
expect(popover).not.toBe(null);
expect(popover.getAttribute('id')).toBe('ion-overlay-1');
// Remove the popover from the DOM
popover.remove();
await page.waitForChanges();
// Create a new popover to verify the id is incremented
popover = document.createElement('ion-popover');
popover.isOpen = true;
page.body.appendChild(popover);
await page.waitForChanges();
popover = page.body.querySelector('ion-popover')!;
expect(popover.getAttribute('id')).toBe('ion-overlay-2');
// Presenting the same popover again should reuse the existing id
popover.isOpen = false;
await page.waitForChanges();
popover.isOpen = true;
await page.waitForChanges();
popover = page.body.querySelector('ion-popover')!;
expect(popover.getAttribute('id')).toBe('ion-overlay-2');
});

View File

@@ -1,142 +0,0 @@
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { test } from '@utils/test/playwright';
import type { E2EPage } from '@utils/test/playwright';
test.describe('radio-group: basic', () => {
test('should not have visual regressions', async ({ page }) => {
await page.goto(`/src/components/radio-group/test/basic`);
const list = page.locator('ion-list');
await expect(list).toHaveScreenshot(`radio-group-diff-${page.getSnapshotSettings()}.png`);
});
});
test.describe('radio-group: interaction', () => {
let radioFixture: RadioFixture;
test.beforeEach(({ page, skip }) => {
skip.rtl();
radioFixture = new RadioFixture(page);
});
test('spacebar should not deselect without allowEmptySelection', async ({ page }) => {
await page.setContent(`
<ion-radio-group value="one" allow-empty-selection="false">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`);
await radioFixture.checkRadio('keyboard');
await radioFixture.expectChecked(true);
});
test('spacebar should deselect with allowEmptySelection', async ({ page }) => {
await page.setContent(`
<ion-radio-group value="one" allow-empty-selection="true">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`);
await radioFixture.checkRadio('keyboard');
await radioFixture.expectChecked(false);
});
test('click should not deselect without allowEmptySelection', async ({ page }) => {
await page.setContent(`
<ion-radio-group value="one" allow-empty-selection="false">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`);
await radioFixture.checkRadio('mouse');
await radioFixture.expectChecked(true);
});
test('click should deselect with allowEmptySelection', async ({ page }) => {
await page.setContent(`
<ion-radio-group value="one" allow-empty-selection="true">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`);
await radioFixture.checkRadio('mouse');
await radioFixture.expectChecked(false);
});
test('programmatically assigning a value should update the checked radio', async ({ page }) => {
await page.setContent(`
<ion-radio-group value="1">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-radio value="1" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-radio value="2" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-radio value="3" slot="start"></ion-radio>
</ion-item>
</ion-radio-group>
`);
const radioGroup = page.locator('ion-radio-group');
const radioOne = page.locator('ion-radio[value="1"]');
const radioTwo = page.locator('ion-radio[value="2"]');
await radioGroup.evaluate((el: HTMLIonRadioGroupElement) => (el.value = '2'));
await page.waitForChanges();
await expect(radioOne).not.toHaveClass(/radio-checked/);
await expect(radioTwo).toHaveClass(/radio-checked/);
});
});
class RadioFixture {
readonly page: E2EPage;
private radio!: Locator;
constructor(page: E2EPage) {
this.page = page;
}
async checkRadio(method: 'keyboard' | 'mouse', selector = 'ion-radio') {
const { page } = this;
const radio = (this.radio = page.locator(selector));
if (method === 'keyboard') {
await radio.focus();
await page.keyboard.press('Space');
} else {
await radio.click();
}
await page.waitForChanges();
return radio;
}
async expectChecked(state: boolean) {
const { radio } = this;
await expect(radio.locator('input')).toHaveJSProperty('checked', state);
}
}

View File

@@ -0,0 +1,163 @@
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { E2EPage } from '@utils/test/playwright';
configs().forEach(({ title, screenshot, config }) => {
test.describe(title('radio-group: basic'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.goto(`/src/components/radio-group/test/basic`, config);
const list = page.locator('ion-list');
await expect(list).toHaveScreenshot(screenshot(`radio-group-diff`));
});
});
});
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('radio-group: interaction'), () => {
let radioFixture: RadioFixture;
test.beforeEach(({ page }) => {
radioFixture = new RadioFixture(page);
});
test('spacebar should not deselect without allowEmptySelection', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="one" allow-empty-selection="false">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
await radioFixture.checkRadio('keyboard');
await radioFixture.expectChecked(true);
});
test('spacebar should deselect with allowEmptySelection', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="one" allow-empty-selection="true">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
await radioFixture.checkRadio('keyboard');
await radioFixture.expectChecked(false);
});
test('click should not deselect without allowEmptySelection', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="one" allow-empty-selection="false">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
await radioFixture.checkRadio('mouse');
await radioFixture.expectChecked(true);
});
test('click should deselect with allowEmptySelection', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="one" allow-empty-selection="true">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
await radioFixture.checkRadio('mouse');
await radioFixture.expectChecked(false);
});
test('programmatically assigning a value should update the checked radio', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="1">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-radio value="1" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-radio value="2" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-radio value="3" slot="start"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
const radioGroup = page.locator('ion-radio-group');
const radioOne = page.locator('ion-radio[value="1"]');
const radioTwo = page.locator('ion-radio[value="2"]');
await radioGroup.evaluate((el: HTMLIonRadioGroupElement) => (el.value = '2'));
await page.waitForChanges();
await expect(radioOne).not.toHaveClass(/radio-checked/);
await expect(radioTwo).toHaveClass(/radio-checked/);
});
});
});
class RadioFixture {
readonly page: E2EPage;
private radio!: Locator;
constructor(page: E2EPage) {
this.page = page;
}
async checkRadio(method: 'keyboard' | 'mouse', selector = 'ion-radio') {
const { page } = this;
const radio = (this.radio = page.locator(selector));
if (method === 'keyboard') {
await radio.focus();
await page.keyboard.press('Space');
} else {
await radio.click();
}
await page.waitForChanges();
return radio;
}
async expectChecked(state: boolean) {
const { radio } = this;
await expect(radio.locator('input')).toHaveJSProperty('checked', state);
}
}

View File

@@ -1,67 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.describe('radio-group: form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/radio-group/test/form');
});
test('selecting an option should update the value', async ({ page }) => {
const radioGroup = page.locator('ion-radio-group');
const ionChange = await page.spyOnEvent('ionChange');
const griffRadio = page.locator('ion-radio[value="griff"]');
await expect(radioGroup).toHaveAttribute('value', 'biff');
await griffRadio.click();
await page.waitForChanges();
await expect(ionChange).toHaveReceivedEventDetail({ value: 'griff', event: { isTrusted: true } });
});
test('selecting a disabled option should not update the value', async ({ page }) => {
const value = page.locator('#value');
const disabledRadio = page.locator('ion-radio[value="george"]');
await expect(value).toHaveText('');
await expect(disabledRadio).toHaveAttribute('disabled', '');
await disabledRadio.click({ force: true });
await page.waitForChanges();
await expect(value).toHaveText('');
});
});
test.describe('radio-group: form submission', () => {
test('should submit radio data in a form', async ({ page, skip }) => {
skip.rtl();
skip.mode('md');
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/27016',
});
await page.setContent(`
<form>
<ion-radio-group value="a" name="my-group">
<ion-radio value="a"></ion-radio>
<ion-radio value="b"></ion-radio>
<ion-radio value="c"></ion-radio>
</ion-radio-group>
</form>
`);
const radioGroupData = await page.evaluate(() => {
const form = document.querySelector('form');
if (!form) {
return;
}
const formData = new FormData(form);
return formData.get('my-group');
});
await expect(radioGroupData).toBe('a');
});
});

View File

@@ -0,0 +1,69 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('radio-group: form'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/radio-group/test/form', config);
});
test('selecting an option should update the value', async ({ page }) => {
const radioGroup = page.locator('ion-radio-group');
const ionChange = await page.spyOnEvent('ionChange');
const griffRadio = page.locator('ion-radio[value="griff"]');
await expect(radioGroup).toHaveAttribute('value', 'biff');
await griffRadio.click();
await page.waitForChanges();
await expect(ionChange).toHaveReceivedEventDetail({ value: 'griff', event: { isTrusted: true } });
});
test('selecting a disabled option should not update the value', async ({ page }) => {
const value = page.locator('#value');
const disabledRadio = page.locator('ion-radio[value="george"]');
await expect(value).toHaveText('');
await expect(disabledRadio).toHaveAttribute('disabled', '');
await disabledRadio.click({ force: true });
await page.waitForChanges();
await expect(value).toHaveText('');
});
});
test.describe(title('radio-group: form submission'), () => {
test('should submit radio data in a form', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/27016',
});
await page.setContent(
`
<form>
<ion-radio-group value="a" name="my-group">
<ion-radio value="a"></ion-radio>
<ion-radio value="b"></ion-radio>
<ion-radio value="c"></ion-radio>
</ion-radio-group>
</form>
`,
config
);
const radioGroupData = await page.evaluate(() => {
const form = document.querySelector('form');
if (!form) {
return;
}
const formData = new FormData(form);
return formData.get('my-group');
});
await expect(radioGroupData).toBe('a');
});
});
});

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