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 core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2662,7 +2662,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 @@ -7939,7 +7939,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
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']);
});
});
});
Loading