Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
- uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: javascript
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
- uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [8.8.19](https://github.com/ionic-team/ionic-framework/compare/v8.8.18...v8.8.19) (2026-08-19)

### Bug Fixes

* **gesture:** remove leaked optsTest listener from passive support check ([#31363](https://github.com/ionic-team/ionic-framework/issues/31363)) ([c689000](https://github.com/ionic-team/ionic-framework/commit/c6890002df0a1ec5ac030626b1694e87022365c0)), closes [#30539](https://github.com/ionic-team/ionic-framework/issues/30539)
* **menu:** respect ion-app dir attribute for menu animation side ([#31246](https://github.com/ionic-team/ionic-framework/issues/31246)) ([2618a6f](https://github.com/ionic-team/ionic-framework/commit/2618a6f7bd793ad09e38e4868d753189610eff9d)), closes [#30226](https://github.com/ionic-team/ionic-framework/issues/30226)
* **range:** update knob positions when dualKnobs changes ([#31365](https://github.com/ionic-team/ionic-framework/issues/31365)) ([7d64d62](https://github.com/ionic-team/ionic-framework/commit/7d64d62a31d59078560575ad9c4fc67a8e49e524)), closes [#31026](https://github.com/ionic-team/ionic-framework/issues/31026)


## [8.8.18](https://github.com/ionic-team/ionic-framework/compare/v8.8.17...v8.8.18) (2026-08-12)


Expand Down
9 changes: 9 additions & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [8.8.19](https://github.com/ionic-team/ionic-framework/compare/v8.8.18...v8.8.19) (2026-08-19)

### Bug Fixes

* **gesture:** remove leaked optsTest listener from passive support check ([#31363](https://github.com/ionic-team/ionic-framework/issues/31363)) ([c689000](https://github.com/ionic-team/ionic-framework/commit/c6890002df0a1ec5ac030626b1694e87022365c0)), closes [#30539](https://github.com/ionic-team/ionic-framework/issues/30539)
* **menu:** respect ion-app dir attribute for menu animation side ([#31246](https://github.com/ionic-team/ionic-framework/issues/31246)) ([2618a6f](https://github.com/ionic-team/ionic-framework/commit/2618a6f7bd793ad09e38e4868d753189610eff9d)), closes [#30226](https://github.com/ionic-team/ionic-framework/issues/30226)
* **range:** update knob positions when dualKnobs changes ([#31365](https://github.com/ionic-team/ionic-framework/issues/31365)) ([7d64d62](https://github.com/ionic-team/ionic-framework/commit/7d64d62a31d59078560575ad9c4fc67a8e49e524)), closes [#31026](https://github.com/ionic-team/ionic-framework/issues/31026)


## [8.8.18](https://github.com/ionic-team/ionic-framework/compare/v8.8.17...v8.8.18) (2026-08-12)


Expand Down
4 changes: 2 additions & 2 deletions core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
"version": "8.8.18",
"version": "8.8.19",
"description": "Base components for Ionic",
"engines": {
"node": ">= 16"
Expand Down
4 changes: 2 additions & 2 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2545,7 +2545,7 @@ export namespace Components {
*/
"disabled": boolean;
/**
* Show two knobs.
* If `true`, the range shows two knobs and `value` is an object with `lower` and `upper` properties. If `false`, the range shows one knob and `value` is a number.
* @default false
*/
"dualKnobs": boolean;
Expand Down Expand Up @@ -7679,7 +7679,7 @@ declare namespace LocalJSX {
*/
"disabled"?: boolean;
/**
* Show two knobs.
* If `true`, the range shows two knobs and `value` is an object with `lower` and `upper` properties. If `false`, the range shows one knob and `value` is a number.
* @default false
*/
"dualKnobs"?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions core/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export class Menu implements ComponentInterface, MenuI {

@Watch('side')
protected sideChanged() {
this.isEndSide = isEnd(this.side);
this.isEndSide = isEnd(this.side, this.el);
/**
* Menu direction animation is calculated based on the document direction.
* If the document direction changes, we need to create a new animation.
Expand Down Expand Up @@ -499,7 +499,7 @@ export class Menu implements ComponentInterface, MenuI {
* Menu direction animation is calculated based on the document direction.
* If the document direction changes, we need to create a new animation.
*/
const isEndSide = isEnd(this.side);
const isEndSide = isEnd(this.side, this.el);
if (width === this.width && this.animation !== undefined && isEndSide === this.isEndSide) {
return;
}
Expand Down
11 changes: 10 additions & 1 deletion core/src/components/range/range.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,19 @@ export class Range implements ComponentInterface {
@Prop() label?: string;

/**
* Show two knobs.
* If `true`, the range shows two knobs and `value` is an object with `lower`
* and `upper` properties. If `false`, the range shows one knob and `value` is
* a number.
*/
@Prop() dualKnobs = false;

@Watch('dualKnobs')
protected dualKnobsChanged() {
if (!this.noUpdate) {
this.updateRatio();
}
}

/**
* Minimum integer value of the range.
*/
Expand Down
122 changes: 122 additions & 0 deletions core/src/components/range/test/dual-knobs/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Range - Dual Knobs</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>
:root {
--color-purple: #8b5cf6;
--color-blue: var(--ion-color-primary, #0054e9);
}

ion-range::part(bar-active) {
background: linear-gradient(to right, var(--color-purple) 0%, var(--color-blue) 100%);
}

ion-range::part(knob-lower) {
background: var(--color-purple);
}

ion-range::part(knob-upper) {
background: var(--color-blue);
}
</style>
</head>

<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Range - Dual Knobs</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
<p>
Every range below is assigned <code>{ lower: 20, upper: 80 }</code>. The lower knob is purple while the upper
is blue.
</p>

<h2>dualKnobs set on the component</h2>
<ion-range id="markup" dual-knobs="true">
<span slot="label">20 - 80</span>
</ion-range>

<h2>dualKnobs assigned before value</h2>
<ion-range id="dual-knobs-first">
<span slot="label">20 - 80</span>
</ion-range>

<h2>dualKnobs assigned after value</h2>
<ion-range id="value-first">
<span slot="label">20 - 80</span>
</ion-range>

<h2>dualKnobs toggled</h2>
<ion-range id="toggled">
<span slot="label" id="toggled-label"></span>
</ion-range>
<ion-button id="toggle-dual-knobs" onclick="toggleDualKnobs()">Toggle Dual Knobs</ion-button>
</ion-content>
</ion-app>

<script>
const dualValue = { lower: 20, upper: 80 };

document.querySelector('#markup').value = dualValue;

(async () => {
await customElements.whenDefined('ion-range');

const dualKnobsFirst = document.querySelector('#dual-knobs-first');
await dualKnobsFirst.componentOnReady();
dualKnobsFirst.dualKnobs = true;
dualKnobsFirst.value = dualValue;

/**
* Frameworks apply bindings in template order, so a template that lists
* `value` before `dualKnobs` assigns them in this order. `dualKnobs` is
* still false when the value lands, so the object collapses to its
* `upper` value and only one ratio is ever computed.
*/
const valueFirst = document.querySelector('#value-first');
await valueFirst.componentOnReady();
valueFirst.value = dualValue;
valueFirst.dualKnobs = true;

const toggled = document.querySelector('#toggled');
await toggled.componentOnReady();
toggled.value = dualValue;
updateToggledLabel();
})();

/**
* A single knob range ignores the `lower` half of an object value, so the
* label only reads as a range once `dualKnobs` is on.
*/
const updateToggledLabel = () => {
const { dualKnobs, value } = document.querySelector('#toggled');
document.querySelector('#toggled-label').textContent = dualKnobs
? `${value.lower} - ${value.upper}`
: `${value.upper}`;
};

// Toggling dualKnobs at runtime should reposition the knobs.
const toggleDualKnobs = () => {
const toggled = document.querySelector('#toggled');
toggled.dualKnobs = !toggled.dualKnobs;
updateToggledLabel();
};
</script>
</body>
</html>
82 changes: 82 additions & 0 deletions core/src/components/range/test/dual-knobs/range.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';

const DUAL_VALUE = { lower: 20, upper: 80 };

/**
* Returns the value each knob is positioned at. Sorted low to high because knob
* A is not guaranteed to be the knob holding the lower value.
*/
const knobValues = async (range: Locator) => {
const values: string[] = await range
.locator('.range-knob-handle')
.evaluateAll((els: HTMLElement[]) => els.map((el) => el.getAttribute('aria-valuenow')!));

return values.sort((a, b) => parseFloat(a) - parseFloat(b));
};

/**
* This behavior does not vary across modes/directions
*/
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
test.describe(title('range: dual knobs'), () => {
test('should position knobs when dualKnobs is assigned before value', async ({ page }) => {
await page.setContent(`<ion-range aria-label="range"></ion-range>`, config);

const range = page.locator('ion-range');
await range.evaluate((el: HTMLIonRangeElement, value) => {
el.dualKnobs = true;
el.value = value;
}, DUAL_VALUE);
await page.waitForChanges();

expect(await knobValues(range)).toEqual(['20', '80']);

// A single knob range ignores the lower half of an object value.
await range.evaluate((el: HTMLIonRangeElement) => (el.dualKnobs = false));
await page.waitForChanges();

expect(await knobValues(range)).toEqual(['80']);
});

test('should position knobs when dualKnobs is assigned after value', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/31026',
});

await page.setContent(`<ion-range aria-label="range"></ion-range>`, config);

const range = page.locator('ion-range');
await range.evaluate((el: HTMLIonRangeElement, value) => {
el.value = value;
el.dualKnobs = true;
}, DUAL_VALUE);
await page.waitForChanges();

expect(await knobValues(range)).toEqual(['20', '80']);
});

test('should reposition knobs when dualKnobs is toggled at runtime', async ({ page }) => {
await page.setContent(`<ion-range aria-label="range"></ion-range>`, config);

const range = page.locator('ion-range');
await range.evaluate((el: HTMLIonRangeElement, value) => (el.value = value), DUAL_VALUE);
await page.waitForChanges();

// A single knob range ignores the lower half of an object value.
expect(await knobValues(range)).toEqual(['80']);

await range.evaluate((el: HTMLIonRangeElement) => (el.dualKnobs = true));
await page.waitForChanges();

expect(await knobValues(range)).toEqual(['20', '80']);

await range.evaluate((el: HTMLIonRangeElement) => (el.dualKnobs = false));
await page.waitForChanges();

expect(await knobValues(range)).toEqual(['80']);
});
});
});
36 changes: 4 additions & 32 deletions core/src/utils/gesture/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,10 @@ export const addEventListener = (
capture?: boolean;
}
): (() => void) => {
// use event listener options when supported
// otherwise it's just a boolean for the "capture" arg
const listenerOpts = supportsPassive(el)
? {
capture: !!opts.capture,
passive: !!opts.passive,
}
: !!opts.capture;
const listenerOpts = {
capture: !!opts.capture,
passive: !!opts.passive,
};

let add: string;
let remove: string;
Expand All @@ -31,27 +27,3 @@ export const addEventListener = (
el[remove](eventName, callback, listenerOpts);
};
};

const supportsPassive = (node: Node) => {
if (_sPassive === undefined) {
try {
const opts = Object.defineProperty({}, 'passive', {
get: () => {
_sPassive = true;
},
});
node.addEventListener(
'optsTest',
() => {
return;
},
opts
);
} catch {
_sPassive = false;
}
}
return !!_sPassive;
};

let _sPassive: boolean | undefined;
Loading
Loading