diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e305a482a2..09ee08f76f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -20,12 +20,10 @@ body: id: affected-versions attributes: label: Ionic Framework Version - description: Which version(s) of Ionic Framework does this issue impact? For Ionic Framework 1.x issues, please use https://github.com/ionic-team/ionic-v1. For Ionic Framework 2.x and 3.x issues, please use https://github.com/ionic-team/ionic-v3. + description: Which version(s) of Ionic Framework does this issue impact? [Ionic Framework 1.x to 6.x are no longer supported](https://ionicframework.com/docs/reference/support#framework-maintenance-and-support-status). For extended support, considering visiting [Ionic's Enterprise offering](https://ionic.io/enterprise). options: - - v4.x - - v5.x - - v6.x - v7.x + - v8.x (Beta) - Nightly multiple: true validations: @@ -51,11 +49,11 @@ body: id: steps-to-reproduce attributes: label: Steps to Reproduce - description: Please explain the steps required to duplicate this issue. + description: Explain the steps required to reproduce this issue. placeholder: | - 1. - 2. - 3. + 1. Go to '...' + 2. Click on '...' + 3. Observe: '...' validations: required: true @@ -63,8 +61,15 @@ body: id: reproduction-url attributes: label: Code Reproduction URL - description: Please reproduce this issue in a blank Ionic Framework starter application and provide a link to the repo. Try out our [Getting Started Wizard](https://ionicframework.com/start#basics) to quickly spin up an Ionic Framework starter app. This is the best way to ensure this issue is triaged quickly. Issues without a code reproduction may be closed if the Ionic Team cannot reproduce the issue you are reporting. + description: | + Reproduce this issue in a blank [Ionic Framework starter application](https://ionicframework.com/start#basics) or a Stackblitz example. + + You can use the Stackblitz button available on any of the [component playgrounds](https://ionicframework.com/docs/components) to open an editable example. Remember to save your changes to obtain a link to copy. + + Reproductions cases must be minimal and focused around the specific problem you are experiencing. This is the best way to ensure this issue is triaged quickly. Issues without a code reproduction may be closed if the Ionic Team cannot reproduce the issue you are reporting. placeholder: https://github.com/... + validations: + required: true - type: textarea id: ionic-info diff --git a/core/package-lock.json b/core/package-lock.json index daec0994ef..f5a0ab3965 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -633,9 +633,9 @@ "dev": true }, "node_modules/@capacitor/core": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.7.1.tgz", - "integrity": "sha512-bwmka6FdvyXOpc5U6bOyx58S/Yl6r5lO2TK561f//KnjyXjxav25HWwhV4hthq3ZxJBMiAEucl9RK5vzgkP4Lw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.7.2.tgz", + "integrity": "sha512-/OUtfINmk7ke32VtKIHRAy8NlunbeK+aCqCHOS+fvtr7nUsOJXPkYgbgqZp/CWXET/gSK1xxMecaVBzpE98UKA==", "dev": true, "dependencies": { "tslib": "^2.1.0" @@ -1759,9 +1759,9 @@ } }, "node_modules/@stencil/core": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.12.4.tgz", - "integrity": "sha512-KrwoXu9J1loWSvQQReilGPkt6/dCH/x5eTBDecCBPclz7vxUM13Iw9almBIffEpurk/kaMAglH0G7sAF/A2y1A==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.12.5.tgz", + "integrity": "sha512-vSyFjY7XSEx0ufa9SebOd437CvnneaTXlCpuGDhjUDxAjGBlu6ie5qHyubobVGBth//aErc6wZPHc6W75Vp3iQ==", "bin": { "stencil": "bin/stencil" }, @@ -10416,9 +10416,9 @@ "dev": true }, "@capacitor/core": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.7.1.tgz", - "integrity": "sha512-bwmka6FdvyXOpc5U6bOyx58S/Yl6r5lO2TK561f//KnjyXjxav25HWwhV4hthq3ZxJBMiAEucl9RK5vzgkP4Lw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.7.2.tgz", + "integrity": "sha512-/OUtfINmk7ke32VtKIHRAy8NlunbeK+aCqCHOS+fvtr7nUsOJXPkYgbgqZp/CWXET/gSK1xxMecaVBzpE98UKA==", "dev": true, "requires": { "tslib": "^2.1.0" @@ -11229,9 +11229,9 @@ "requires": {} }, "@stencil/core": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.12.4.tgz", - "integrity": "sha512-KrwoXu9J1loWSvQQReilGPkt6/dCH/x5eTBDecCBPclz7vxUM13Iw9almBIffEpurk/kaMAglH0G7sAF/A2y1A==" + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.12.5.tgz", + "integrity": "sha512-vSyFjY7XSEx0ufa9SebOd437CvnneaTXlCpuGDhjUDxAjGBlu6ie5qHyubobVGBth//aErc6wZPHc6W75Vp3iQ==" }, "@stencil/react-output-target": { "version": "0.5.3", diff --git a/core/src/components/checkbox/checkbox.tsx b/core/src/components/checkbox/checkbox.tsx index d1cddf9036..57b0c5b77e 100644 --- a/core/src/components/checkbox/checkbox.tsx +++ b/core/src/components/checkbox/checkbox.tsx @@ -187,6 +187,7 @@ export class Checkbox implements ComponentInterface { return ( { expect(checkbox.checked).toBe(false); }); }); + +describe('ion-checkbox: indeterminate', () => { + it('should have a mixed value for aria-checked', async () => { + const page = await newSpecPage({ + components: [Checkbox], + html: ` + Checkbox + `, + }); + + const checkbox = page.body.querySelector('ion-checkbox')!; + + expect(checkbox.getAttribute('aria-checked')).toBe('mixed'); + }); +}); diff --git a/core/src/components/range/range.tsx b/core/src/components/range/range.tsx index 2360ac56f9..d3339a1317 100644 --- a/core/src/components/range/range.tsx +++ b/core/src/components/range/range.tsx @@ -275,8 +275,14 @@ export class Range implements ComponentInterface { el: rangeSlider, gestureName: 'range', gesturePriority: 100, - threshold: 0, - onStart: (ev) => this.onStart(ev), + /** + * Provide a threshold since the drag movement + * might be a user scrolling the view. + * If this is true, then the range + * should not move. + */ + threshold: 10, + onStart: () => this.onStart(), onMove: (ev) => this.onMove(ev), onEnd: (ev) => this.onEnd(ev), }); @@ -378,42 +384,101 @@ export class Range implements ComponentInterface { this.ionChange.emit({ value: this.value }); } - private onStart(detail: GestureDetail) { - const { contentEl } = this; - if (contentEl) { - this.initialContentScrollY = disableContentScrollY(contentEl); - } - - const rect = (this.rect = this.rangeSlider!.getBoundingClientRect() as any); - const currentX = detail.currentX; - - // figure out which knob they started closer to - let ratio = clamp(0, (currentX - rect.left) / rect.width, 1); - if (isRTL(this.el)) { - ratio = 1 - ratio; - } - - this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B'; - - this.setFocus(this.pressedKnob); - - // update the active knob's position - this.update(currentX); - + /** + * The value should be updated on touch end or + * when the component is being dragged. + * This follows the native behavior of mobile devices. + * + * For example: When the user lifts their finger from the + * screen after tapping the bar or dragging the bar or knob. + */ + private onStart() { this.ionKnobMoveStart.emit({ value: this.ensureValueInBounds(this.value) }); } + /** + * The value should be updated while dragging the + * bar or knob. + * + * While the user is dragging, the view + * should not scroll. This is to prevent the user from + * feeling disoriented while dragging. + * + * The user can scroll on the view if the knob or + * bar is not being dragged. + * + * @param detail The details of the gesture event. + */ private onMove(detail: GestureDetail) { - this.update(detail.currentX); + const { contentEl, pressedKnob } = this; + const currentX = detail.currentX; + + /** + * Since the user is dragging on the bar or knob, the view should not scroll. + * + * This only needs to be done once. + */ + if (contentEl && this.initialContentScrollY === undefined) { + this.initialContentScrollY = disableContentScrollY(contentEl); + } + + /** + * The `pressedKnob` can be undefined if the user just + * started dragging the knob. + * + * This is necessary to determine which knob the user is dragging, + * especially when it's a dual knob. + * Plus, it determines when to apply certain styles. + * + * This only needs to be done once since the knob won't change + * while the user is dragging. + */ + if (pressedKnob === undefined) { + this.setPressedKnob(currentX); + } + + this.update(currentX); } - private onEnd(detail: GestureDetail) { + /** + * The value should be updated on touch end: + * - When the user lifts their finger from the screen after + * tapping the bar. + * + * @param detail The details of the gesture or mouse event. + */ + private onEnd(detail: GestureDetail | MouseEvent) { const { contentEl, initialContentScrollY } = this; - if (contentEl) { + const currentX = (detail as GestureDetail).currentX || (detail as MouseEvent).clientX; + + /** + * The `pressedKnob` can be undefined if the user never + * dragged the knob. They just tapped on the bar. + * + * This is necessary to determine which knob the user is changing, + * especially when it's a dual knob. + * Plus, it determines when to apply certain styles. + */ + if (this.pressedKnob === undefined) { + this.setPressedKnob(currentX); + } + + /** + * The user is no longer dragging the bar or + * knob (if they were dragging it). + * + * The user can now scroll on the view in the next gesture event. + */ + if (contentEl && initialContentScrollY !== undefined) { resetContentScrollY(contentEl, initialContentScrollY); } - this.update(detail.currentX); + // update the active knob's position + this.update(currentX); + /** + * Reset the pressed knob to undefined since the user + * may start dragging a different knob in the next gesture event. + */ this.pressedKnob = undefined; this.emitValueChange(); @@ -445,6 +510,19 @@ export class Range implements ComponentInterface { this.updateValue(); } + private setPressedKnob(currentX: number) { + const rect = (this.rect = this.rangeSlider!.getBoundingClientRect() as any); + + // figure out which knob they started closer to + let ratio = clamp(0, (currentX - rect.left) / rect.width, 1); + if (isRTL(this.el)) { + ratio = 1 - ratio; + } + this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B'; + + this.setFocus(this.pressedKnob); + } + private get valA() { return ratioToValue(this.ratioA, this.min, this.max, this.step); } @@ -643,7 +721,39 @@ export class Range implements ComponentInterface { } return ( -
(this.rangeSlider = rangeEl)}> +
(this.rangeSlider = rangeEl)} + /** + * Since the gesture has a threshold, the value + * won't change until the user has dragged past + * the threshold. This is to prevent the range + * from moving when the user is scrolling. + * + * This results in the value not being updated + * and the event emitters not being triggered + * if the user taps on the range. This is why + * we need to listen for the "pointerUp" event. + */ + onPointerUp={(ev: PointerEvent) => { + /** + * If the user drags the knob on the web + * version (does not occur on mobile), + * the "pointerUp" event will be triggered + * along with the gesture's events. + * This leads to duplicate events. + * + * By checking if the pressedKnob is undefined, + * we can determine if the "pointerUp" event was + * triggered by a tap or a drag. If it was + * dragged, the pressedKnob will be defined. + */ + if (this.pressedKnob === undefined) { + this.onStart(); + this.onEnd(ev); + } + }} + > {ticks.map((tick) => (
expect(rangeEnd).toHaveReceivedEventDetail({ value: 21 }); }); + test('should emit end event on tap', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/28487', + }); + + await page.setContent(``, config); + + const range = page.locator('ion-range'); + const rangeEndSpy = await page.spyOnEvent('ionKnobMoveEnd'); + const rangeBoundingBox = await range.boundingBox(); + /** + * Coordinates for the click event. + * These need to be near the end of the range + * (or anything that isn't the current value). + * + * The number 50 is arbitrary, but it should be + * less than the width of the range. + */ + const x = rangeBoundingBox!.width - 50; + // The y coordinate is the middle of the range. + const y = rangeBoundingBox!.height / 2; + + // Click near the end of the range. + await range.click({ + position: { x, y }, + }); + + await rangeEndSpy.next(); + + expect(rangeEndSpy.length).toBe(1); + }); + // TODO FW-2873 test.skip('should not scroll when the knob is swiped', async ({ page, skip }) => { skip.browser('webkit', 'mouse.wheel is not available in WebKit'); diff --git a/packages/angular/common/src/directives/navigation/nav.ts b/packages/angular/common/src/directives/navigation/nav.ts index 585bffc040..78cfaa240f 100644 --- a/packages/angular/common/src/directives/navigation/nav.ts +++ b/packages/angular/common/src/directives/navigation/nav.ts @@ -1,4 +1,12 @@ -import { ElementRef, Injector, EnvironmentInjector, NgZone, ChangeDetectorRef, Directive } from '@angular/core'; +import { + ElementRef, + Injector, + EnvironmentInjector, + NgZone, + ChangeDetectorRef, + Directive, + EventEmitter, +} from '@angular/core'; import type { Components } from '@ionic/core'; import { AngularDelegate } from '../../providers/angular-delegate'; @@ -22,8 +30,16 @@ const NAV_METHODS = [ 'getPrevious', ]; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export declare interface IonNav extends Components.IonNav {} +export declare interface IonNav extends Components.IonNav { + /** + * Event fired when the nav will change components + */ + ionNavWillChange: EventEmitter>; + /** + * Event fired when the nav has changed components + */ + ionNavDidChange: EventEmitter>; +} @ProxyCmp({ inputs: NAV_INPUTS,