feat(range): add classes and expose parts to allow individual styling of dual knobs (#30941)

Issue number: resolves #29862

---------

## What is the current behavior?
Range exposes a single part for both knobs & pins. This makes it impossible to style the knobs/pins differently when dual knobs is enabled.

## What is the new behavior?
- Fixes a bug where the knobs would swap A & B when they cross over each other
- Fixes the focus behavior so that dual knobs act the same as a single knob range when focusing a knob
- Adds the following classes to the host element when `dualKnobs` is enabled:
  - `range-dual-knobs`
  - `range-pressed-a` when the knob with name A is pressed
  - `range-pressed-b` when the knob with name B is pressed
  - `range-pressed-lower` when the lower knob is pressed
  - `range-pressed-upper` when the upper knob is pressed
- Adds parts for the following:
  - `knob-handle-a` — The container for the knob with the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
  - `knob-handle-b` — The container for the knob with the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
  - `knob-handle-lower` — The container for the knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which knob handle they refer to when the knobs cross.
  - `knob-handle-upper` — The container for the knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which knob handle they refer to when the knobs cross.
  - `pin-a` — The value indicator above the knob with the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
  - `pin-b` — The value indicator above the knob with the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
  - `pin-lower` — The value indicator above the knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which pin they refer to when the knobs cross.
  - `pin-upper` — The value indicator above the knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which pin they refer to when the knobs cross.
  - `knob-a` — The visual knob for the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
  - `knob-b` — The visual knob for the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
  - `knob-lower` — The visual knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which knob they refer to when the knobs cross.
  - `knob-upper` — The visual knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which knob they refer to when the knobs cross.
  - `activated` — Added to the knob-handle, knob, and pin when the knob is active. Only one set has this part at a time when `dualKnobs` is `true`.
  - `focused` — Added to the knob-handle, knob, and pin that currently has focus. Only one set has this part at a time when `dualKnobs` is `true`.
  - `hover` — Added to the knob-handle, knob, and pin when the knob has hover. Only one set has this part at a time when `dualKnobs` is `true`.
  - `pressed` — Added to the knob-handle, knob, and pin that is currently being pressed to drag. Only one set has this part at a time when `dualKnobs` is `true`.
- Adds e2e tests for the following:
  - customizing label part
  - customizing bar parts
  - customizing pin parts
  - customizing tick parts
  - customizing knob parts
  - customizing dual knob a & b parts
  - customizing dual knob lower & upper parts
- verifies that a & b parts stay on the original elements but lower & upper parts swap when the values swap
- Adds spec tests for the following:
  - css classes
    - value state classes
    - boolean property classes
    - pressed state classes
  - shadow parts
    - static shadow parts
- verifies the shadow parts exist based on the existence of certain range properties
  - state shadow parts
- verifies the shadow parts exist based on the state of the range knob (pressed, focused, activated, hover)

## Does this introduce a breaking change?
- [ ] Yes
- [x] No

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
This commit is contained in:
Brandy Smith
2026-03-04 10:36:50 -05:00
committed by GitHub
parent ef73476e08
commit 5bcf921841
14 changed files with 1671 additions and 172 deletions

View File

@@ -1488,11 +1488,28 @@ ion-range,css-prop,--pin-background,ios
ion-range,css-prop,--pin-background,md
ion-range,css-prop,--pin-color,ios
ion-range,css-prop,--pin-color,md
ion-range,part,activated
ion-range,part,bar
ion-range,part,bar-active
ion-range,part,focused
ion-range,part,hover
ion-range,part,knob
ion-range,part,knob-a
ion-range,part,knob-b
ion-range,part,knob-handle
ion-range,part,knob-handle-a
ion-range,part,knob-handle-b
ion-range,part,knob-handle-lower
ion-range,part,knob-handle-upper
ion-range,part,knob-lower
ion-range,part,knob-upper
ion-range,part,label
ion-range,part,pin
ion-range,part,pin-a
ion-range,part,pin-b
ion-range,part,pin-lower
ion-range,part,pin-upper
ion-range,part,pressed
ion-range,part,tick
ion-range,part,tick-active

View File

@@ -8,7 +8,9 @@ expect.extend({
throw new Error('expected toHaveShadowPart to be called on an element with a shadow root');
}
const shadowPart = received.shadowRoot.querySelector(`[part="${part}"]`);
// Use attribute selector with ~= to match space-separated part values
// e.g., [part~="knob"] matches elements with part="knob" or part="knob knob-a"
const shadowPart = received.shadowRoot.querySelector(`[part~="${part}"]`);
const pass = shadowPart !== null;
const message = `expected ${received.tagName.toLowerCase()} to have shadow part "${part}"`;

View File

@@ -1,5 +1,7 @@
export type KnobName = 'A' | 'B' | undefined;
export type KnobPosition = 'lower' | 'upper' | undefined;
export type RangeValue = number | { lower: number; upper: number };
export type PinFormatter = (value: number) => number | string;

View File

@@ -1,5 +1,5 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
import { Build, Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
import { findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, clamp, debounceEvent, renderHiddenInput, isSafeNumber } from '@utils/helpers';
@@ -13,6 +13,7 @@ import { roundToMaxDecimalPlaces } from '../../utils/floating-point';
import type {
KnobName,
KnobPosition,
RangeChangeEventDetail,
RangeKnobMoveEndEventDetail,
RangeKnobMoveStartEventDetail,
@@ -29,13 +30,30 @@ import type {
* @slot start - Content is placed to the left of the range slider in LTR, and to the right in RTL.
* @slot end - Content is placed to the right of the range slider in LTR, and to the left in RTL.
*
* @part label - The label text describing the range.
* @part tick - An inactive tick mark.
* @part tick-active - An active tick mark.
* @part pin - The counter that appears above a knob.
* @part knob - The handle that is used to drag the range.
* @part bar - The inactive part of the bar.
* @part bar-active - The active part of the bar.
* @part label - The label text describing the range.
* @part knob-handle - The container that wraps the knob and handles drag interactions.
* @part knob-handle-a - The container for the knob with the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
* @part knob-handle-b - The container for the knob with the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
* @part knob-handle-lower - The container for the knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which knob handle they refer to when the knobs cross.
* @part knob-handle-upper - The container for the knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which knob handle they refer to when the knobs cross.
* @part pin - The value indicator displayed above a knob.
* @part pin-a - The value indicator above the knob with the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
* @part pin-b - The value indicator above the knob with the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
* @part pin-lower - The value indicator above the knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which pin they refer to when the knobs cross.
* @part pin-upper - The value indicator above the knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which pin they refer to when the knobs cross.
* @part knob - The visual knob element on the range track.
* @part knob-a - The visual knob for the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
* @part knob-b - The visual knob for the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value.
* @part knob-lower - The visual knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which knob they refer to when the knobs cross.
* @part knob-upper - The visual knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which knob they refer to when the knobs cross.
* @part activated - Added to the knob-handle, knob, and pin when the knob is active. Only one set has this part at a time when `dualKnobs` is `true`.
* @part focused - Added to the knob-handle, knob, and pin that currently has focus. Only one set has this part at a time when `dualKnobs` is `true`.
* @part hover - Added to the knob-handle, knob, and pin when the knob has hover. Only one set has this part at a time when `dualKnobs` is `true`.
* @part pressed - Added to the knob-handle, knob, and pin that is currently being pressed to drag. Only one set has this part at a time when `dualKnobs` is `true`.
*/
@Component({
tag: 'ion-range',
@@ -57,11 +75,27 @@ export class Range implements ComponentInterface {
private contentEl: HTMLElement | null = null;
private initialContentScrollY = true;
private originalIonInput?: EventEmitter<RangeChangeEventDetail>;
/**
* Used to avoid setting the focused state on click or tap. The focused
* state is only set when the focus comes from the keyboard (e.g. Tab).
* This is set to true on pointer down (mouse/touch).
*/
private focusFromPointer = false;
/**
* Observes class changes on the knob handles to keep the activatedKnob
* state in sync with the ion-activated class. This is necessary to
* determine which knob the user is dragging when using dual knobs and
* apply the activated part correctly.
*/
private activatedObserver?: MutationObserver;
@Element() el!: HTMLIonRangeElement;
@State() private ratioA = 0;
@State() private ratioB = 0;
@State() private activatedKnob: KnobName;
@State() private focusedKnob: KnobName;
@State() private hoveredKnob: KnobName;
@State() private pressedKnob: KnobName;
/**
@@ -324,6 +358,34 @@ export class Range implements ComponentInterface {
}
};
/**
* Observes the knob handles for the ion-activated class and syncs
* activatedKnob so the activated part is correctly set on the handle,
* knob, and pin.
*/
private setupActivatedObserver = () => {
const knobHandleA = this.el.shadowRoot!.querySelector('.range-knob-handle-a');
const knobHandleB = this.el.shadowRoot!.querySelector('.range-knob-handle-b');
const syncActivated = () => {
this.activatedKnob = (knobHandleA as HTMLElement)?.classList.contains('ion-activated')
? 'A'
: (knobHandleB as HTMLElement)?.classList.contains('ion-activated')
? 'B'
: undefined;
};
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.activatedObserver = new MutationObserver(syncActivated);
this.activatedObserver.observe(this.el.shadowRoot!, {
attributes: true,
attributeFilter: ['class'],
subtree: true,
});
}
syncActivated();
};
componentWillLoad() {
/**
* If user has custom ID set then we should
@@ -345,6 +407,7 @@ export class Range implements ComponentInterface {
this.originalIonInput = this.ionInput;
this.setupGesture();
this.updateRatio();
this.setupActivatedObserver();
this.didLoad = true;
}
@@ -362,6 +425,7 @@ export class Range implements ComponentInterface {
*/
if (this.didLoad) {
this.setupGesture();
this.setupActivatedObserver();
}
const ionContent = findClosestIonContent(this.el);
@@ -373,6 +437,10 @@ export class Range implements ComponentInterface {
this.gesture.destroy();
this.gesture = undefined;
}
if (this.activatedObserver) {
this.activatedObserver.disconnect();
this.activatedObserver = undefined;
}
}
private handleKeyboard = (knob: KnobName, isIncrease: boolean) => {
@@ -467,7 +535,7 @@ export class Range implements ComponentInterface {
* started dragging the knob.
*
* This is necessary to determine which knob the user is dragging,
* especially when it's a dual knob.
* especially when using dual knobs.
* Plus, it determines when to apply certain styles.
*
* This only needs to be done once since the knob won't change
@@ -496,7 +564,7 @@ export class Range implements ComponentInterface {
* dragged the knob. They just tapped on the bar.
*
* This is necessary to determine which knob the user is changing,
* especially when it's a dual knob.
* especially when using dual knobs.
* Plus, it determines when to apply certain styles.
*/
if (this.pressedKnob === undefined) {
@@ -515,6 +583,7 @@ export class Range implements ComponentInterface {
// update the active knob's position
this.update(currentX);
/**
* Reset the pressed knob to undefined since the user
* may start dragging a different knob in the next gesture event.
@@ -559,8 +628,6 @@ export class Range implements ComponentInterface {
ratio = 1 - ratio;
}
this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B';
this.setFocus(this.pressedKnob);
}
private get valA() {
@@ -592,9 +659,26 @@ export class Range implements ComponentInterface {
private updateRatio() {
const value = this.getValue() as any;
const { min, max } = this;
/**
* For dual knobs, value gives lower/upper but not which is A vs B.
* Assign (lowerRatio, upperRatio) to (ratioA, ratioB) in the way that
* minimizes change from the current ratios so the knobs don't swap.
*/
if (this.dualKnobs) {
this.ratioA = valueToRatio(value.lower, min, max);
this.ratioB = valueToRatio(value.upper, min, max);
const lowerRatio = valueToRatio(value.lower, min, max);
const upperRatio = valueToRatio(value.upper, min, max);
if (
Math.abs(this.ratioA - lowerRatio) + Math.abs(this.ratioB - upperRatio) <=
Math.abs(this.ratioA - upperRatio) + Math.abs(this.ratioB - lowerRatio)
) {
this.ratioA = lowerRatio;
this.ratioB = upperRatio;
} else {
this.ratioA = upperRatio;
this.ratioB = lowerRatio;
}
} else {
this.ratioA = valueToRatio(value, min, max);
}
@@ -614,20 +698,10 @@ export class Range implements ComponentInterface {
this.noUpdate = false;
}
private setFocus(knob: KnobName) {
if (this.el.shadowRoot) {
const knobEl = this.el.shadowRoot.querySelector(knob === 'A' ? '.range-knob-a' : '.range-knob-b') as
| HTMLElement
| undefined;
if (knobEl) {
knobEl.focus();
}
}
}
private onBlur = () => {
if (this.hasFocus) {
this.hasFocus = false;
this.focusedKnob = undefined;
this.ionBlur.emit();
}
};
@@ -640,24 +714,20 @@ export class Range implements ComponentInterface {
};
private onKnobFocus = (knob: KnobName) => {
// Clicking focuses the range which is needed for the keyboard,
// but we only want to add the ion-focused class when focused via Tab.
if (!this.focusFromPointer) {
this.focusedKnob = knob;
} else {
this.focusFromPointer = false;
this.focusedKnob = undefined;
}
// If the knob was not already focused, emit the focus event
if (!this.hasFocus) {
this.hasFocus = true;
this.ionFocus.emit();
}
// Manually manage ion-focused class for dual knobs
if (this.dualKnobs && this.el.shadowRoot) {
const knobA = this.el.shadowRoot.querySelector('.range-knob-a');
const knobB = this.el.shadowRoot.querySelector('.range-knob-b');
// Remove ion-focused from both knobs first
knobA?.classList.remove('ion-focused');
knobB?.classList.remove('ion-focused');
// Add ion-focused only to the focused knob
const focusedKnobEl = knob === 'A' ? knobA : knobB;
focusedKnobEl?.classList.add('ion-focused');
}
};
private onKnobBlur = () => {
@@ -670,20 +740,21 @@ export class Range implements ComponentInterface {
if (!isStillFocusedOnKnob) {
if (this.hasFocus) {
this.hasFocus = false;
this.focusedKnob = undefined;
this.ionBlur.emit();
}
// Remove ion-focused from both knobs when focus leaves the range
if (this.dualKnobs && this.el.shadowRoot) {
const knobA = this.el.shadowRoot.querySelector('.range-knob-a');
const knobB = this.el.shadowRoot.querySelector('.range-knob-b');
knobA?.classList.remove('ion-focused');
knobB?.classList.remove('ion-focused');
}
}
}, 0);
};
private onKnobMouseEnter = (knob: KnobName) => {
this.hoveredKnob = knob;
};
private onKnobMouseLeave = () => {
this.hoveredKnob = undefined;
};
/**
* Returns true if content was passed to the "start" slot
*/
@@ -708,6 +779,9 @@ export class Range implements ComponentInterface {
max,
step,
handleKeyboard,
activatedKnob,
focusedKnob,
hoveredKnob,
pressedKnob,
disabled,
pin,
@@ -790,6 +864,9 @@ export class Range implements ComponentInterface {
<div
class="range-slider"
ref={(rangeEl) => (this.rangeSlider = rangeEl)}
onPointerDown={() => {
this.focusFromPointer = true;
}}
/**
* Since the gesture has a threshold, the value
* won't change until the user has dragged past
@@ -802,6 +879,8 @@ export class Range implements ComponentInterface {
* we need to listen for the "pointerUp" event.
*/
onPointerUp={(ev: PointerEvent) => {
this.focusFromPointer = false;
/**
* If the user drags the knob on the web
* version (does not occur on mobile),
@@ -848,6 +927,11 @@ export class Range implements ComponentInterface {
{renderKnob(rtl, {
knob: 'A',
position: getKnobPosition('A', this.ratioA, this.ratioB, this.dualKnobs),
dualKnobs: this.dualKnobs,
activated: activatedKnob === 'A',
focused: focusedKnob === 'A',
hovered: hoveredKnob === 'A',
pressed: pressedKnob === 'A',
value: this.valA,
ratio: this.ratioA,
@@ -860,11 +944,18 @@ export class Range implements ComponentInterface {
inheritedAttributes,
onKnobFocus: this.onKnobFocus,
onKnobBlur: this.onKnobBlur,
onKnobMouseEnter: this.onKnobMouseEnter,
onKnobMouseLeave: this.onKnobMouseLeave,
})}
{this.dualKnobs &&
renderKnob(rtl, {
knob: 'B',
position: getKnobPosition('B', this.ratioA, this.ratioB, this.dualKnobs),
dualKnobs: this.dualKnobs,
activated: activatedKnob === 'B',
focused: focusedKnob === 'B',
hovered: hoveredKnob === 'B',
pressed: pressedKnob === 'B',
value: this.valB,
ratio: this.ratioB,
@@ -877,6 +968,8 @@ export class Range implements ComponentInterface {
inheritedAttributes,
onKnobFocus: this.onKnobFocus,
onKnobBlur: this.onKnobBlur,
onKnobMouseEnter: this.onKnobMouseEnter,
onKnobMouseLeave: this.onKnobMouseLeave,
})}
</div>
);
@@ -906,6 +999,14 @@ export class Range implements ComponentInterface {
const mode = getIonMode(this);
/**
* Determine the name and position of the pressed knob to apply
* Host classes for styling.
*/
const pressedKnobName = dualKnobs ? pressedKnob?.toLowerCase() : undefined;
const pressedKnobPosition =
dualKnobs && pressedKnob ? getKnobPosition(pressedKnob, this.ratioA, this.ratioB, dualKnobs) : undefined;
/**
* Determine if any knob is at the min or max value to
* apply Host classes for styling.
@@ -924,7 +1025,10 @@ export class Range implements ComponentInterface {
[mode]: true,
'in-item': inItem,
'range-disabled': disabled,
'range-dual-knobs': dualKnobs,
'range-pressed': pressedKnob !== undefined,
[`range-pressed-${pressedKnobName}`]: pressedKnob !== undefined && pressedKnobName !== undefined,
[`range-pressed-${pressedKnobPosition}`]: pressedKnob !== undefined && pressedKnobPosition !== undefined,
'range-has-pin': pin,
[`range-label-placement-${labelPlacement}`]: true,
'range-item-start-adjustment': needsStartAdjustment,
@@ -956,29 +1060,41 @@ export class Range implements ComponentInterface {
interface RangeKnob {
knob: KnobName;
position: KnobPosition;
dualKnobs: boolean;
value: number;
ratio: number;
min: number;
max: number;
disabled: boolean;
pressed: boolean;
focused: boolean;
hovered: boolean;
activated: boolean;
pin: boolean;
pinFormatter: PinFormatter;
inheritedAttributes: Attributes;
handleKeyboard: (name: KnobName, isIncrease: boolean) => void;
onKnobFocus: (knob: KnobName) => void;
onKnobBlur: () => void;
onKnobMouseEnter: (knob: KnobName) => void;
onKnobMouseLeave: () => void;
}
const renderKnob = (
rtl: boolean,
{
knob,
position,
dualKnobs,
value,
ratio,
min,
max,
disabled,
activated,
focused,
hovered,
pressed,
pin,
handleKeyboard,
@@ -986,6 +1102,8 @@ const renderKnob = (
inheritedAttributes,
onKnobFocus,
onKnobBlur,
onKnobMouseEnter,
onKnobMouseLeave,
}: RangeKnob
) => {
const start = rtl ? 'right' : 'left';
@@ -1017,16 +1135,32 @@ const renderKnob = (
}}
onFocus={() => onKnobFocus(knob)}
onBlur={onKnobBlur}
onMouseEnter={() => onKnobMouseEnter(knob)}
onMouseLeave={onKnobMouseLeave}
class={{
'range-knob-handle': true,
'range-knob-a': knob === 'A',
'range-knob-b': knob === 'B',
'range-knob-handle-a': knob === 'A',
'range-knob-handle-b': knob === 'B',
'range-knob-pressed': pressed,
'range-knob-min': value === min,
'range-knob-max': value === max,
'ion-activatable': true,
'ion-focusable': true,
'ion-focused': focused,
}}
part={[
'knob-handle',
dualKnobs && knob === 'A' && 'knob-handle-a',
dualKnobs && knob === 'B' && 'knob-handle-b',
dualKnobs && position === 'lower' && 'knob-handle-lower',
dualKnobs && position === 'upper' && 'knob-handle-upper',
pressed && 'pressed',
focused && 'focused',
hovered && 'hover',
activated && 'activated',
]
.filter(Boolean)
.join(' ')}
style={knobStyle()}
role="slider"
tabindex={disabled ? -1 : 0}
@@ -1038,15 +1172,72 @@ const renderKnob = (
aria-valuenow={value}
>
{pin && (
<div class="range-pin" role="presentation" part="pin">
<div
class="range-pin"
role="presentation"
part={[
'pin',
dualKnobs && knob === 'A' && 'pin-a',
dualKnobs && knob === 'B' && 'pin-b',
dualKnobs && position === 'lower' && 'pin-lower',
dualKnobs && position === 'upper' && 'pin-upper',
pressed && 'pressed',
focused && 'focused',
hovered && 'hover',
activated && 'activated',
]
.filter(Boolean)
.join(' ')}
>
{pinFormatter(value)}
</div>
)}
<div class="range-knob" role="presentation" part="knob" />
<div
class="range-knob"
role="presentation"
part={[
'knob',
dualKnobs && knob === 'A' && 'knob-a',
dualKnobs && knob === 'B' && 'knob-b',
dualKnobs && position === 'lower' && 'knob-lower',
dualKnobs && position === 'upper' && 'knob-upper',
pressed && 'pressed',
focused && 'focused',
hovered && 'hover',
activated && 'activated',
]
.filter(Boolean)
.join(' ')}
/>
</div>
);
};
/**
* Returns whether the given knob is at the lower or upper position based
* on current ratios for the given knob.
*
* When both knobs have the same ratio, we only want one "lower" and one
* "upper" position so that the `lower` and `upper` parts are not applied to
* the same knob. In that case, we treat knob "A" as the lower position and
* knob "B" as the upper position.
*/
const getKnobPosition = (knob: 'A' | 'B', ratioA: number, ratioB: number, dualKnobs: boolean): 'lower' | 'upper' => {
if (!dualKnobs) {
return 'lower';
}
if (ratioA === ratioB) {
return knob === 'A' ? 'lower' : 'upper';
}
if (knob === 'A') {
return ratioA < ratioB ? 'lower' : 'upper';
}
return ratioB < ratioA ? 'lower' : 'upper';
};
const ratioToValue = (ratio: number, min: number, max: number, step: number): number => {
let value = (max - min) * ratio;

View File

@@ -18,8 +18,8 @@ describe('range: dual knobs focus management', () => {
await page.waitForChanges();
// Get the knob elements
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
expect(knobA).not.toBeNull();
expect(knobB).not.toBeNull();
@@ -41,8 +41,8 @@ describe('range: dual knobs focus management', () => {
const range = page.body.querySelector('ion-range');
await page.waitForChanges();
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
// Focus knob A
knobA.dispatchEvent(new Event('focus'));
@@ -73,8 +73,8 @@ describe('range: dual knobs focus management', () => {
const range = page.body.querySelector('ion-range');
await page.waitForChanges();
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
// Focus knob A
knobA.dispatchEvent(new Event('focus'));
@@ -112,8 +112,8 @@ describe('range: dual knobs focus management', () => {
focusEventFiredCount++;
});
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
// Focus knob A
knobA.dispatchEvent(new Event('focus'));
@@ -140,7 +140,7 @@ describe('range: dual knobs focus management', () => {
blurEventFired = true;
});
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
// Focus and then blur knob A
knobA.dispatchEvent(new Event('focus'));
@@ -173,8 +173,8 @@ describe('range: dual knobs focus management', () => {
const beforeButton = page.body.querySelector('#before') as HTMLElement;
await page.waitForChanges();
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;
// Start with focus on element before the range
beforeButton.focus();

View File

@@ -14,6 +14,20 @@
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<style>
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
margin-left: 5px;
}
/**
* 1st Custom Range: All Parts
* -----------------------------------
*/
.range-part::part(bar) {
background: red;
}
@@ -51,6 +65,271 @@
.md.range-part::part(pin)::before {
background: orange;
}
/**
* Shared Custom Knob Styles
* -----------------------------------
*/
.custom-knobs-range {
--color-blue-light: #3b82f6;
--color-blue-light-rgb: 59, 130, 246;
--color-blue: var(--ion-color-primary, #0054e9);
--color-blue-rgb: var(--ion-color-primary-rgb, 0, 84, 233);
--color-blue-dark: #1e3a8a;
--color-blue-dark-rgb: 30, 58, 138;
--color-purple: #8b5cf6;
--color-green: #10b981;
}
.custom-knobs-range::part(knob-handle) {
--custom-knob-width: 50px;
--custom-knob-height: 25px;
width: var(--custom-knob-width);
height: var(--custom-knob-height);
/* Center vertically */
top: calc((var(--height) - var(--custom-knob-height)) / 2);
/* Center horizontally */
margin-inline-start: calc(0px - var(--custom-knob-width) / 2);
transform-origin: center center;
}
.custom-knobs-range::part(knob) {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
inset: 0;
width: 100%;
height: 100%;
/* MD scales smaller by default, make iOS scale to match */
transform: scale(0.67);
border-radius: 4px;
border: 1px solid rgba(3, 60, 89, 0.5);
}
/* Hover/focus indicator */
.custom-knobs-range::part(knob)::before {
width: 100%;
height: 100%;
border-radius: inherit;
}
/* Override the default hover/focus indicator */
.custom-knobs-range::part(knob activated)::before,
.custom-knobs-range::part(knob hover)::before,
.custom-knobs-range::part(knob pressed)::before,
.custom-knobs-range::part(knob focused)::before {
transform: scale(1);
}
/* Displayed knob values */
.custom-knobs-range .knob-value {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid #000;
border-radius: 4px;
}
/* iOS offset is larger than MD due to not having a bottom arrow */
.ios.custom-knobs-range {
--pin-offset: 8px;
}
.md.custom-knobs-range {
--pin-offset: 4px;
}
.ios.custom-knobs-range {
padding-top: 40px;
}
.ios.custom-knobs-range::part(pin) {
border-radius: 50%;
background-color: var(--color-blue);
color: #fff;
}
/* Move the pin up so it sits above the taller custom knob when pressed/focused */
.custom-knobs-range::part(pin pressed),
.custom-knobs-range::part(pin focused) {
transform: translate3d(0, calc(-100% - (var(--custom-knob-height) / 2) + var(--pin-offset)), 0) scale(1);
}
.custom-knobs-range [slot='start'] {
margin-inline-end: 36px;
}
.custom-knobs-range [slot='end'] {
margin-inline-start: 36px;
}
/**
* 2nd Custom Range: Single Knob
* -----------------------------------
*/
/* Hovered knob */
#single-knob-range::part(knob hover) {
background: rgba(var(--color-blue-rgb), 0.5);
}
/* Pressed knob */
#single-knob-range::part(knob pressed) {
background: var(--color-blue);
}
/* Activated knob */
#single-knob-range::part(knob activated) {
background: var(--color-purple);
}
/**
* 3rd Custom Range: Dual Knobs
* Knobs Lower & Upper
* -----------------------------------
*/
/* Style the start slot knob value when the lower knob is pressed */
#lower-upper-dual-knobs-range.range-pressed-lower [slot='start'] {
background: var(--color-blue-light);
border-color: var(--color-blue-light);
color: #fff;
}
/* Style the end slot knob value when the upper knob is pressed */
#lower-upper-dual-knobs-range.range-pressed-upper [slot='end'] {
background: var(--color-blue-dark);
border-color: var(--color-blue-dark);
color: #fff;
}
/* Knob lower */
#lower-upper-dual-knobs-range::part(knob-lower) {
border-radius: 50% 0 0 50%;
}
/* Knob upper */
#lower-upper-dual-knobs-range::part(knob-upper) {
border-radius: 0 50% 50% 0;
}
/* Hovered knob lower */
#lower-upper-dual-knobs-range::part(knob-lower hover) {
background: rgba(var(--color-blue-light-rgb), 0.5);
}
/* Pressed knob lower */
#lower-upper-dual-knobs-range::part(knob-lower pressed) {
background: var(--color-blue-light);
}
/* Activated knob lower */
#lower-upper-dual-knobs-range::part(knob-lower activated) {
background: var(--color-green);
}
/* Hovered knob upper */
#lower-upper-dual-knobs-range::part(knob-upper hover) {
background: rgba(var(--color-blue-dark-rgb), 0.5);
}
/* Pressed knob upper */
#lower-upper-dual-knobs-range::part(knob-upper pressed) {
background: var(--color-blue-dark);
}
/* Activated knob upper */
#lower-upper-dual-knobs-range::part(knob-upper activated) {
background: var(--color-purple);
}
/* Pin lower */
#lower-upper-dual-knobs-range.range-dual-knobs::part(pin-lower),
#lower-upper-dual-knobs-range.range-dual-knobs::part(pin-lower):before {
background: var(--color-blue-light);
}
/* Pin upper */
#lower-upper-dual-knobs-range.range-dual-knobs::part(pin-upper),
#lower-upper-dual-knobs-range.range-dual-knobs::part(pin-upper):before {
background: var(--color-blue-dark);
}
/**
* 4th Custom Range: Dual Knobs
* Knobs A & B
* -----------------------------------
*/
/* Style the start slot knob value when the A knob is pressed */
#a-b-dual-knobs-range.range-pressed-a [slot='start'] {
background: var(--color-blue-light);
border-color: var(--color-blue-light);
color: #fff;
}
/* Style the end slot knob value when the B knob is pressed */
#a-b-dual-knobs-range.range-pressed-b [slot='end'] {
background: var(--color-blue-dark);
border-color: var(--color-blue-dark);
color: #fff;
}
/* Hovered knob A */
#a-b-dual-knobs-range::part(knob-a hover) {
background: rgba(var(--color-blue-light-rgb), 0.5);
}
/* Pressed knob A */
#a-b-dual-knobs-range::part(knob-a pressed) {
background: var(--color-blue-light);
}
/* Activated knob A */
#a-b-dual-knobs-range::part(knob-a activated) {
background: var(--color-green);
}
/* Hovered knob B */
#a-b-dual-knobs-range::part(knob-b hover) {
background: rgba(var(--color-blue-dark-rgb), 0.5);
}
/* Pressed knob B */
#a-b-dual-knobs-range::part(knob-b pressed) {
background: var(--color-blue-dark);
}
/* Activated knob B */
#a-b-dual-knobs-range::part(knob-b activated) {
background: var(--color-purple);
}
/* Pin A */
#a-b-dual-knobs-range.range-dual-knobs::part(pin-a),
#a-b-dual-knobs-range.range-dual-knobs::part(pin-a):before {
background: var(--color-blue-light);
}
/* Pin B */
#a-b-dual-knobs-range.range-dual-knobs::part(pin-b),
#a-b-dual-knobs-range.range-dual-knobs::part(pin-b):before {
background: var(--color-blue-dark);
}
</style>
<body>
<ion-app>
@@ -61,6 +340,8 @@
</ion-header>
<ion-content class="ion-padding">
<!-- 1st Custom Range -->
<h2>Custom Range</h2>
<ion-range
class="range-part"
min="-200"
@@ -71,6 +352,55 @@
dual-knobs="true"
aria-label="Custom Range"
></ion-range>
<!-- 2nd Custom Range: Single Knob -->
<h2>Custom Range: Single Knob</h2>
<ion-range
id="single-knob-range"
class="custom-knobs-range"
step="5"
snaps="true"
pin="true"
value="50"
aria-label="Custom Range"
>
<span slot="start">
<ion-icon name="volume-off" size="large" color="primary"></ion-icon>
</span>
<span slot="end">
<ion-icon name="volume-high" size="large" color="primary"></ion-icon>
</span>
</ion-range>
<!-- 3rd Custom Range: Dual Knobs with Knob Lower & Upper -->
<h2>Custom Range: Dual Knobs with Knob Lower & Upper</h2>
<ion-range
id="lower-upper-dual-knobs-range"
class="custom-knobs-range range-lower-upper-knobs"
step="5"
snaps="true"
pin="true"
dual-knobs="true"
aria-label="Custom Lower & Upper Knobs Range"
>
<div slot="start" class="knob-value"></div>
<div slot="end" class="knob-value"></div>
</ion-range>
<!-- 4th Custom Range: Dual Knobs with Knob A & B -->
<h2>Custom Range: Dual Knobs with Knob A & B</h2>
<ion-range
id="a-b-dual-knobs-range"
class="custom-knobs-range"
step="5"
snaps="true"
pin="true"
dual-knobs="true"
aria-label="Custom Dual Knobs Range"
>
<div slot="start" class="knob-value"></div>
<div slot="end" class="knob-value"></div>
</ion-range>
</ion-content>
</ion-app>
@@ -80,6 +410,36 @@
lower: '-100',
upper: '100',
};
const lowerUpperKnobs = document.getElementById('lower-upper-dual-knobs-range');
lowerUpperKnobs.value = {
lower: '40',
upper: '60',
};
const abDualKnobs = document.getElementById('a-b-dual-knobs-range');
abDualKnobs.value = {
lower: '25',
upper: '75',
};
function updateDisplayedKnobValues() {
document.querySelector('#lower-upper-dual-knobs-range [slot="start"]').textContent =
lowerUpperKnobs.value.lower;
document.querySelector('#lower-upper-dual-knobs-range [slot="end"]').textContent = lowerUpperKnobs.value.upper;
document.querySelector('#a-b-dual-knobs-range [slot="start"]').textContent = abDualKnobs.value.lower;
document.querySelector('#a-b-dual-knobs-range [slot="end"]').textContent = abDualKnobs.value.upper;
}
updateDisplayedKnobValues();
lowerUpperKnobs.addEventListener('ionChange', () => {
updateDisplayedKnobValues();
});
abDualKnobs.addEventListener('ionChange', () => {
updateDisplayedKnobValues();
});
</script>
</body>
</html>

View File

@@ -1,13 +1,425 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import { configs, dragElementBy, test } from '@utils/test/playwright';
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('range: customization'), () => {
test('should be customizable', async ({ page }) => {
await page.goto(`/src/components/range/test/custom`, config);
/**
* This behavior does not vary across modes/directions
*/
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
test.describe(title('range: custom'), () => {
test.describe(title('CSS shadow parts'), () => {
test('should be able to customize label part', async ({ page }) => {
await page.setContent(
`
<style>
ion-range::part(label) {
color: red;
}
</style>
const range = page.locator('ion-range');
await expect(range).toHaveScreenshot(screenshot(`range-custom`));
<ion-range label="Label" value="50"></ion-range>
`,
config
);
const range = page.locator('ion-range');
const labelColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const label = shadowRoot?.querySelector('.label-text-wrapper');
return label ? window.getComputedStyle(label).color : '';
});
expect(labelColor).toBe('rgb(255, 0, 0)');
});
test('should be able to customize bar parts', async ({ page }) => {
await page.setContent(
`
<style>
ion-range::part(bar) {
background-color: red;
}
ion-range::part(bar-active) {
background-color: green;
}
</style>
<ion-range label="Label" value="50"></ion-range>
`,
config
);
const range = page.locator('ion-range');
const barBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const bar = shadowRoot?.querySelector('.range-bar');
return bar ? window.getComputedStyle(bar).backgroundColor : '';
});
expect(barBackgroundColor).toBe('rgb(255, 0, 0)');
const activeBarBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const activeBar = shadowRoot?.querySelector('.range-bar-active');
return activeBar ? window.getComputedStyle(activeBar).backgroundColor : '';
});
expect(activeBarBackgroundColor).toBe('rgb(0, 128, 0)');
});
test('should be able to customize pin parts', async ({ page }) => {
await page.setContent(
`
<style>
ion-range::part(pin) {
background-color: red;
}
ion-range::part(pin)::before {
background-color: green;
}
</style>
<ion-range label="Label" value="50" pin="true"></ion-range>
`,
config
);
const range = page.locator('ion-range');
const pinBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const pin = shadowRoot?.querySelector('.range-pin');
return pin ? window.getComputedStyle(pin).backgroundColor : '';
});
expect(pinBackgroundColor).toBe('rgb(255, 0, 0)');
const pinBeforeBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const pin = shadowRoot?.querySelector('.range-pin');
if (!pin) return '';
return window.getComputedStyle(pin, '::before').backgroundColor;
});
expect(pinBeforeBackgroundColor).toBe('rgb(0, 128, 0)');
});
test('should be able to customize tick parts', async ({ page }) => {
await page.setContent(
`
<style>
ion-range::part(tick) {
background-color: red;
}
ion-range::part(tick-active) {
background-color: green;
}
</style>
<ion-range label="Label" value="50" snaps="true" ticks="true"></ion-range>
`,
config
);
const range = page.locator('ion-range');
const tickBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const tick = shadowRoot?.querySelector('.range-tick:not(.range-tick-active)');
return tick ? window.getComputedStyle(tick).backgroundColor : '';
});
expect(tickBackgroundColor).toBe('rgb(255, 0, 0)');
const activeTickBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const activeTick = shadowRoot?.querySelector('.range-tick-active');
return activeTick ? window.getComputedStyle(activeTick).backgroundColor : '';
});
expect(activeTickBackgroundColor).toBe('rgb(0, 128, 0)');
});
test('should be able to customize knob parts', async ({ page }) => {
await page.setContent(
`
<style>
ion-range::part(knob-handle) {
background-color: red;
}
ion-range::part(knob) {
background-color: green;
}
</style>
<ion-range label="Label" value="50"></ion-range>
`,
config
);
const range = page.locator('ion-range');
const knobHandleBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const knobHandle = shadowRoot?.querySelector('.range-knob-handle');
return knobHandle ? window.getComputedStyle(knobHandle).backgroundColor : '';
});
expect(knobHandleBackgroundColor).toBe('rgb(255, 0, 0)');
const knobBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const knob = shadowRoot?.querySelector('.range-knob');
return knob ? window.getComputedStyle(knob).backgroundColor : '';
});
expect(knobBackgroundColor).toBe('rgb(0, 128, 0)');
});
test('should be able to customize dual knob a & b parts', async ({ page }) => {
await page.setContent(
`
<style>
ion-range::part(knob-handle-a) {
background-color: red;
}
ion-range::part(knob-handle-b) {
background-color: green;
}
ion-range::part(knob-a) {
background-color: blue;
}
ion-range::part(knob-b) {
background-color: yellow;
}
ion-range::part(pin-a) {
background-color: orange;
}
ion-range::part(pin-b) {
background-color: purple;
}
</style>
<ion-range label="Label" dual-knobs="true" pin="true"></ion-range>
`,
config
);
const range = page.locator('ion-range');
await range.evaluate((el) => {
(el as any).value = {
lower: 25,
upper: 75,
};
});
await page.waitForChanges();
const knobHandleABackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const knobHandleA = shadowRoot?.querySelector('.range-knob-handle-a');
return knobHandleA ? window.getComputedStyle(knobHandleA).backgroundColor : '';
});
expect(knobHandleABackgroundColor).toBe('rgb(255, 0, 0)');
const knobHandleBBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const knobHandleB = shadowRoot?.querySelector('.range-knob-handle-b');
return knobHandleB ? window.getComputedStyle(knobHandleB).backgroundColor : '';
});
expect(knobHandleBBackgroundColor).toBe('rgb(0, 128, 0)');
// We query for the knob inside knob-handle-a because the knob
// does not get a class for the knob name, only a part.
const knobABackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const knobA = shadowRoot?.querySelector('.range-knob-handle-a .range-knob');
return knobA ? window.getComputedStyle(knobA).backgroundColor : '';
});
expect(knobABackgroundColor).toBe('rgb(0, 0, 255)');
// We query for the knob inside knob-handle-b because the knob
// does not get a class for the knob name, only a part.
const knobBBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const knobB = shadowRoot?.querySelector('.range-knob-handle-b .range-knob');
return knobB ? window.getComputedStyle(knobB).backgroundColor : '';
});
expect(knobBBackgroundColor).toBe('rgb(255, 255, 0)');
// We query for the pin inside knob-handle-a because the pin
// does not get a class for the knob name, only a part.
const pinABackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const pinA = shadowRoot?.querySelector('.range-knob-handle-a .range-pin');
return pinA ? window.getComputedStyle(pinA).backgroundColor : '';
});
expect(pinABackgroundColor).toBe('rgb(255, 165, 0)');
// We query for the pin inside knob-handle-b because the pin
// does not get a class for the knob name, only a part.
const pinBBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const pinB = shadowRoot?.querySelector('.range-knob-handle-b .range-pin');
return pinB ? window.getComputedStyle(pinB).backgroundColor : '';
});
expect(pinBBackgroundColor).toBe('rgb(128, 0, 128)');
});
test('should be able to customize dual knob lower & upper parts', async ({ page }) => {
await page.setContent(
`
<style>
ion-range::part(knob-handle-lower) {
background-color: red;
}
ion-range::part(knob-handle-upper) {
background-color: green;
}
ion-range::part(knob-lower) {
background-color: blue;
}
ion-range::part(knob-upper) {
background-color: yellow;
}
ion-range::part(pin-lower) {
background-color: orange;
}
ion-range::part(pin-upper) {
background-color: purple;
}
</style>
<ion-range label="Label" dual-knobs="true" pin="true"></ion-range>
`,
config
);
const range = page.locator('ion-range');
await range.evaluate((el) => {
(el as any).value = {
lower: 25,
upper: 75,
};
});
await page.waitForChanges();
// Lower & upper are added as shadow parts but not CSS classes, so we
// have to query by their shadow parts.
const knobHandleLowerBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const knobHandleLower = shadowRoot?.querySelector('[part~="knob-handle-lower"]');
return knobHandleLower ? window.getComputedStyle(knobHandleLower).backgroundColor : '';
});
expect(knobHandleLowerBackgroundColor).toBe('rgb(255, 0, 0)');
const knobHandleUpperBackgroundColor = await range.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const knobHandleUpper = shadowRoot?.querySelector('[part~="knob-handle-upper"]');
return knobHandleUpper ? window.getComputedStyle(knobHandleUpper).backgroundColor : '';
});
expect(knobHandleUpperBackgroundColor).toBe('rgb(0, 128, 0)');
const knobLowerBackgroundColor = await range.evaluate((el) => {
const knobLower = el.shadowRoot?.querySelector('[part~="knob-lower"]');
return knobLower ? window.getComputedStyle(knobLower).backgroundColor : '';
});
expect(knobLowerBackgroundColor).toBe('rgb(0, 0, 255)');
const knobUpperBackgroundColor = await range.evaluate((el) => {
const knobUpper = el.shadowRoot?.querySelector('[part~="knob-upper"]');
return knobUpper ? window.getComputedStyle(knobUpper).backgroundColor : '';
});
expect(knobUpperBackgroundColor).toBe('rgb(255, 255, 0)');
const pinLowerBackgroundColor = await range.evaluate((el) => {
const pinLower = el.shadowRoot?.querySelector('[part~="pin-lower"]');
return pinLower ? window.getComputedStyle(pinLower).backgroundColor : '';
});
expect(pinLowerBackgroundColor).toBe('rgb(255, 165, 0)');
const pinUpperBackgroundColor = await range.evaluate((el) => {
const pinUpper = el.shadowRoot?.querySelector('[part~="pin-upper"]');
return pinUpper ? window.getComputedStyle(pinUpper).backgroundColor : '';
});
expect(pinUpperBackgroundColor).toBe('rgb(128, 0, 128)');
});
test('should keep a & b parts on same elements and swap lower & upper parts when values swap', async ({
page,
}) => {
await page.setContent(
`
<ion-range label="Label" dual-knobs="true" pin="true"></ion-range>
`,
config
);
const range = page.locator('ion-range');
await range.evaluate((el) => {
(el as any).value = { lower: 25, upper: 75 };
});
await page.waitForChanges();
// On load: query each a & b part to check lower & upper
const handleAHasLowerOnLoad = await range.evaluate((el) => {
const handleA = el.shadowRoot?.querySelector('[part~="knob-handle-a"]');
const part = handleA?.getAttribute('part') ?? '';
return part.includes('knob-handle-lower');
});
const handleAHasUpperOnLoad = await range.evaluate((el) => {
const handleA = el.shadowRoot?.querySelector('[part~="knob-handle-a"]');
const part = handleA?.getAttribute('part') ?? '';
return part.includes('knob-handle-upper');
});
const handleBHasLowerOnLoad = await range.evaluate((el) => {
const handleB = el.shadowRoot?.querySelector('[part~="knob-handle-b"]');
const part = handleB?.getAttribute('part') ?? '';
return part.includes('knob-handle-lower');
});
const handleBHasUpperOnLoad = await range.evaluate((el) => {
const handleB = el.shadowRoot?.querySelector('[part~="knob-handle-b"]');
const part = handleB?.getAttribute('part') ?? '';
return part.includes('knob-handle-upper');
});
// The lower knob is assigned to the a part and the
// upper knob is assigned to the b part on load
expect(handleAHasLowerOnLoad).toBe(true);
expect(handleAHasUpperOnLoad).toBe(false);
expect(handleBHasLowerOnLoad).toBe(false);
expect(handleBHasUpperOnLoad).toBe(true);
// Drag the lower knob right so the two knobs swap positions
const box = await range.boundingBox();
expect(box).not.toBeNull();
const startX = box!.x + box!.width * 0.25;
const startY = box!.y + box!.height / 2;
const dragDistance = Math.round(box!.width * 0.55);
await dragElementBy(range, page, dragDistance, 0, startX, startY);
await page.waitForChanges();
// After swap: the same elements have parts A and B
// but lower and upper have swapped positions
const handleAHasLowerAfterSwap = await range.evaluate((el) => {
const handleA = el.shadowRoot?.querySelector('[part~="knob-handle-a"]');
const part = handleA?.getAttribute('part') ?? '';
return part.includes('knob-handle-lower');
});
const handleAHasUpperAfterSwap = await range.evaluate((el) => {
const handleA = el.shadowRoot?.querySelector('[part~="knob-handle-a"]');
const part = handleA?.getAttribute('part') ?? '';
return part.includes('knob-handle-upper');
});
const handleBHasLowerAfterSwap = await range.evaluate((el) => {
const handleB = el.shadowRoot?.querySelector('[part~="knob-handle-b"]');
const part = handleB?.getAttribute('part') ?? '';
return part.includes('knob-handle-lower');
});
const handleBHasUpperAfterSwap = await range.evaluate((el) => {
const handleB = el.shadowRoot?.querySelector('[part~="knob-handle-b"]');
const part = handleB?.getAttribute('part') ?? '';
return part.includes('knob-handle-upper');
});
// After swap: the lower knob is assigned to the b part and the
// upper knob is assigned to the a part
expect(handleAHasLowerAfterSwap).toBe(false);
expect(handleAHasUpperAfterSwap).toBe(true);
expect(handleBHasLowerAfterSwap).toBe(true);
expect(handleBHasUpperAfterSwap).toBe(false);
});
});
});
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 B

View File

@@ -3,9 +3,12 @@ import { newSpecPage } from '@stencil/core/testing';
import { Item } from '../../item/item';
import { Range } from '../range';
const waitForEvent = (el: HTMLElement, eventName: string) =>
new Promise<void>((resolve) => el.addEventListener(eventName, () => resolve(), { once: true }));
let sharedRange: Range;
describe('Range', () => {
describe('range: values', () => {
beforeEach(() => {
sharedRange = new Range();
});
@@ -87,7 +90,7 @@ describe('Range', () => {
});
});
describe('range id', () => {
describe('range: id', () => {
it('should render custom id if passed', async () => {
const page = await newSpecPage({
components: [Range],
@@ -234,120 +237,632 @@ describe('range: item adjustments', () => {
expect(range.classList.contains('range-item-start-adjustment')).toBe(false);
expect(range.classList.contains('range-item-end-adjustment')).toBe(false);
});
});
describe('shadow parts', () => {
it('should have shadow parts', async () => {
describe('range: css classes', () => {
describe('value state classes', () => {
it('should apply range-value-min class when value is at min', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range pin="true" snaps="true" value="50" label="Label"></ion-range>`,
html: `<ion-range min="0" max="100" value="0"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-value-min')).toBe(true);
expect(range.classList.contains('range-value-max')).toBe(false);
});
it('should apply range-value-max class when value is at max', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range min="0" max="100" value="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-value-max')).toBe(true);
expect(range.classList.contains('range-value-min')).toBe(false);
});
it('should not apply range-value-min or range-value-max classes when value is in the middle', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range min="0" max="100" value="50"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-value-min')).toBe(false);
expect(range.classList.contains('range-value-max')).toBe(false);
});
it('should apply range-value-min class when lower knob is at min in dual knobs', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
range.value = { lower: 0, upper: 50 };
await page.waitForChanges();
expect(range.classList.contains('range-value-min')).toBe(true);
expect(range.classList.contains('range-value-max')).toBe(false);
});
it('should apply range-value-max class when upper knob is at max in dual knobs', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
range.value = { lower: 50, upper: 100 };
await page.waitForChanges();
expect(range.classList.contains('range-value-max')).toBe(true);
expect(range.classList.contains('range-value-min')).toBe(false);
});
it('should apply range-value-min and range-value-max classes for dual knobs when both are at boundaries', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
range.value = { lower: 0, upper: 100 };
await page.waitForChanges();
expect(range.classList.contains('range-value-min')).toBe(true);
expect(range.classList.contains('range-value-max')).toBe(true);
});
it('should not apply range-value-min or range-value-max classes for dual knobs when neither is at boundaries', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
range.value = { lower: 25, upper: 75 };
await page.waitForChanges();
expect(range.classList.contains('range-value-min')).toBe(false);
expect(range.classList.contains('range-value-max')).toBe(false);
});
});
describe('boolean property classes', () => {
it('should not have any boolean classes by default', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-disabled')).toBe(false);
expect(range.classList.contains('range-dual-knobs')).toBe(false);
expect(range.classList.contains('range-has-pin')).toBe(false);
});
it('should have range-disabled class when disabled is true', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range disabled="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-disabled')).toBe(true);
});
it('should have range-dual-knobs class when dual-knobs is true true', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-dual-knobs')).toBe(true);
});
it('should have range-has-pin class when pin is true', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-has-pin')).toBe(true);
});
});
describe('pressed state classes', () => {
it('should have range-pressed class when knob is pressed', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
// Simulate a pressed knob A by setting state on component instance
const component = page.rootInstance;
component.pressedKnob = 'A';
await page.waitForChanges();
expect(range.classList.contains('range-pressed')).toBe(true);
expect(range.classList.contains('range-pressed-a')).toBe(false);
expect(range.classList.contains('range-pressed-lower')).toBe(false);
expect(range.classList.contains('range-pressed-b')).toBe(false);
expect(range.classList.contains('range-pressed-upper')).toBe(false);
});
it('should have range-pressed-lower class when lower knob is pressed', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
// Simulate a pressed knob A by setting state on component instance
const component = page.rootInstance;
component.pressedKnob = 'A';
component.ratioA = 0.5;
component.ratioB = 0.8;
await page.waitForChanges();
expect(range.classList.contains('range-pressed-lower')).toBe(true);
});
it('should have range-pressed-upper class when upper knob is pressed', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
// Simulate a pressed knob B by setting state on component instance
const component = page.rootInstance;
component.pressedKnob = 'B';
component.ratioA = 0.5;
component.ratioB = 0.8;
await page.waitForChanges();
expect(range.classList.contains('range-pressed-upper')).toBe(true);
});
it('should have range-pressed-a class when knob A is pressed', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
// Simulate a pressed knob A by setting state on component instance
const component = page.rootInstance;
component.pressedKnob = 'A';
await page.waitForChanges();
expect(range.classList.contains('range-pressed-a')).toBe(true);
});
it('should have range-pressed-b class when knob B is pressed', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
// Simulate a pressed knob B by setting state on component instance
const component = page.rootInstance;
component.pressedKnob = 'B';
await page.waitForChanges();
expect(range.classList.contains('range-pressed-b')).toBe(true);
});
});
});
describe('range: shadow parts', () => {
describe('static shadow parts', () => {
it('should have default shadow parts', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range).toHaveShadowPart('label');
expect(range).toHaveShadowPart('pin');
expect(range).toHaveShadowPart('knob');
// bar and bar-active parts always exist
expect(range).toHaveShadowPart('bar');
expect(range).toHaveShadowPart('bar-active');
// label part always exists
expect(range).toHaveShadowPart('label');
// knob-handle and knob parts always exist
expect(range).toHaveShadowPart('knob-handle');
expect(range).toHaveShadowPart('knob');
// knob-handle a, b, lower, and upper parts only exist when dualKnobs is true
expect(range).not.toHaveShadowPart('knob-handle-a');
expect(range).not.toHaveShadowPart('knob-handle-b');
expect(range).not.toHaveShadowPart('knob-handle-lower');
expect(range).not.toHaveShadowPart('knob-handle-upper');
// knob a, b, lower, and upper parts only exist when dualKnobs is true
expect(range).not.toHaveShadowPart('knob-a');
expect(range).not.toHaveShadowPart('knob-b');
expect(range).not.toHaveShadowPart('knob-lower');
expect(range).not.toHaveShadowPart('knob-upper');
// pin only exists when pin is true
expect(range).not.toHaveShadowPart('pin');
// pin a, b, lower, and upper only exist when both pin and dualKnobs are true
expect(range).not.toHaveShadowPart('pin-a');
expect(range).not.toHaveShadowPart('pin-b');
expect(range).not.toHaveShadowPart('pin-lower');
expect(range).not.toHaveShadowPart('pin-upper');
// ticks only exist when ticks is true
expect(range).not.toHaveShadowPart('tick');
expect(range).not.toHaveShadowPart('tick-active');
});
it('should have tick shadow parts', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range snaps="true" ticks="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
// ticks only exist when both snaps and ticks are true
expect(range).toHaveShadowPart('tick');
expect(range).toHaveShadowPart('tick-active');
});
});
});
describe('range: value state classes', () => {
it('should apply range-value-min class when value is at min', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range min="0" max="100" value="0"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-value-min')).toBe(true);
expect(range.classList.contains('range-value-max')).toBe(false);
});
it('should apply range-value-max class when value is at max', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range min="0" max="100" value="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-value-max')).toBe(true);
expect(range.classList.contains('range-value-min')).toBe(false);
});
it('should not apply range-value-min or range-value-max classes when value is in the middle', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range min="0" max="100" value="50"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
expect(range.classList.contains('range-value-min')).toBe(false);
expect(range.classList.contains('range-value-max')).toBe(false);
});
it('should apply range-value-min class when lower knob is at min in dual knobs', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
range.value = { lower: 0, upper: 50 };
await page.waitForChanges();
expect(range.classList.contains('range-value-min')).toBe(true);
expect(range.classList.contains('range-value-max')).toBe(false);
});
it('should apply range-value-max class when upper knob is at max in dual knobs', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
range.value = { lower: 50, upper: 100 };
await page.waitForChanges();
expect(range.classList.contains('range-value-max')).toBe(true);
expect(range.classList.contains('range-value-min')).toBe(false);
});
it('should apply range-value-min and range-value-max classes for dual knobs when both are at boundaries', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
range.value = { lower: 0, upper: 100 };
await page.waitForChanges();
expect(range.classList.contains('range-value-min')).toBe(true);
expect(range.classList.contains('range-value-max')).toBe(true);
});
it('should not apply range-value-min or range-value-max classes for dual knobs when neither is at boundaries', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" min="0" max="100"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
range.value = { lower: 25, upper: 75 };
await page.waitForChanges();
expect(range.classList.contains('range-value-min')).toBe(false);
expect(range.classList.contains('range-value-max')).toBe(false);
it('should have pin shadow part', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
// pin only exists when pin is true
expect(range).toHaveShadowPart('pin');
// pin a, b, lower, and upper only exist when both pin and dualKnobs are true
expect(range).not.toHaveShadowPart('pin-a');
expect(range).not.toHaveShadowPart('pin-b');
expect(range).not.toHaveShadowPart('pin-lower');
expect(range).not.toHaveShadowPart('pin-upper');
});
it('should have dual knob shadow parts', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
// knob-handle a, b, lower, and upper parts only exist when dualKnobs is true
expect(range).toHaveShadowPart('knob-handle-a');
expect(range).toHaveShadowPart('knob-handle-b');
expect(range).toHaveShadowPart('knob-handle-lower');
expect(range).toHaveShadowPart('knob-handle-upper');
// knob a, b, lower, and upper parts only exist when dualKnobs is true
expect(range).toHaveShadowPart('knob-a');
expect(range).toHaveShadowPart('knob-b');
expect(range).toHaveShadowPart('knob-lower');
expect(range).toHaveShadowPart('knob-upper');
// pin only exists when pin is true
expect(range).toHaveShadowPart('pin');
// pin a, b, lower, and upper only exist when both pin and dualKnobs are true
expect(range).toHaveShadowPart('pin-a');
expect(range).toHaveShadowPart('pin-b');
expect(range).toHaveShadowPart('pin-lower');
expect(range).toHaveShadowPart('pin-upper');
});
});
describe('state shadow parts', () => {
it('should have pressed shadow part when pressed', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
const shadowRoot = range.shadowRoot!;
// The pressed part should not exist on the knob by default
expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).toBeNull();
// Simulate a pressed knob by setting state on component instance
const component = page.rootInstance;
component.pressedKnob = 'A';
await page.waitForChanges();
// The pressed part should exist on the knob when pressed
expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).not.toBeNull();
// Clear the pressed knob
component.pressedKnob = undefined;
await page.waitForChanges();
// The pressed part should not exist after clearing the pressed knob
expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).toBeNull();
});
it('should have focused shadow part when focused', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
const shadowRoot = range.shadowRoot!;
// The focused part should not exist on the knob by default
expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).toBeNull();
// Focus the knob handle
const knobHandle = shadowRoot.querySelector('.range-knob-handle') as HTMLElement;
knobHandle.focus();
await page.waitForChanges();
// The focused part should exist on the knob when focused
expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).not.toBeNull();
// Blur the knob handle
knobHandle.blur();
await waitForEvent(range, 'ionBlur');
await page.waitForChanges();
// The focused part should not exist after blur
expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).toBeNull();
});
it('should have activated shadow part when activated', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
const shadowRoot = range.shadowRoot!;
// The activated part should not exist on the knob by default
expect(shadowRoot.querySelector('[part~="knob"][part~="activated"]')).toBeNull();
// Simulate an activated knob by setting state on component instance
const component = page.rootInstance;
component.activatedKnob = 'A';
await page.waitForChanges();
// The activated part should exist on the knob when activated
expect(shadowRoot.querySelector('[part~="knob"][part~="activated"]')).not.toBeNull();
// Clear the activated knob
component.activatedKnob = undefined;
await page.waitForChanges();
// The activated part should not exist after clearing the activated knob
expect(shadowRoot.querySelector('[part~="knob"][part~="activated"]')).toBeNull();
});
it('should have hover shadow part when hovered', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
const shadowRoot = range.shadowRoot!;
// The hover part should not exist on the knob by default
expect(shadowRoot.querySelector('[part~="knob"][part~="hover"]')).toBeNull();
// Simulate a hovered knob by setting state on component instance
const component = page.rootInstance;
component.hoveredKnob = 'A';
await page.waitForChanges();
// The hover part should exist on the knob when hovered
expect(shadowRoot.querySelector('[part~="knob"][part~="hover"]')).not.toBeNull();
// Clear the hovered knob
component.hoveredKnob = undefined;
await page.waitForChanges();
// The hover part should not exist after clearing the hovered knob
expect(shadowRoot.querySelector('[part~="knob"][part~="hover"]')).toBeNull();
});
it('should have pressed shadow part on only one knob when dual-knobs is true', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
const shadowRoot = range.shadowRoot!;
// The pressed part should not exist on either knob by default
expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
// Simulate a pressed knob A by setting state on component instance
const component = page.rootInstance;
component.pressedKnob = 'A';
await page.waitForChanges();
// The pressed part should exist on knob A only
expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).not.toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
// Simulate a pressed knob B by setting state on component instance
component.pressedKnob = 'B';
await page.waitForChanges();
// The pressed part should now exist on knob B only
expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).not.toBeNull();
// Clear the pressed knob
component.pressedKnob = undefined;
await page.waitForChanges();
// The pressed part should not exist after clearing the pressed knob
expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
});
it('should have focused shadow part on only one knob when dual-knobs is true', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
const shadowRoot = range.shadowRoot!;
// The focused part should not exist on either knob by default
expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
// Focus knob handle A
const knobHandleA = shadowRoot.querySelector('.range-knob-handle-a') as HTMLElement;
knobHandleA.focus();
await page.waitForChanges();
// The focused part should exist on knob A only
expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).not.toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
// Focus knob handle B
const knobHandleB = shadowRoot.querySelector('.range-knob-handle-b') as HTMLElement;
knobHandleB.focus();
await page.waitForChanges();
// The focused part should now exist on knob B only
expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).not.toBeNull();
// Blur knob handle B
knobHandleB.blur();
await waitForEvent(range, 'ionBlur');
await page.waitForChanges();
// The focused part should not exist after blurring the knob handle
expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
});
it('should have activated shadow part on only one knob when dual-knobs is true', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
const shadowRoot = range.shadowRoot!;
// The activated part should not exist on either knob by default
expect(shadowRoot.querySelector('[part~="knob-a"][part~="activated"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="activated"]')).toBeNull();
// Simulate an activated knob A by setting state on component instance
const component = page.rootInstance;
component.activatedKnob = 'A';
await page.waitForChanges();
// The activated part should exist on knob A only
expect(shadowRoot.querySelector('[part~="knob-a"][part~="activated"]')).not.toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="activated"]')).toBeNull();
// Simulate an activated knob B by setting state on component instance
component.activatedKnob = 'B';
await page.waitForChanges();
// The activated part should now exist on knob B only
expect(shadowRoot.querySelector('[part~="knob-a"][part~="activated"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="activated"]')).not.toBeNull();
// Clear the activated knob
component.activatedKnob = undefined;
await page.waitForChanges();
// The activated part should not exist after clearing the activated knob
expect(shadowRoot.querySelector('[part~="knob-a"][part~="activated"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="activated"]')).toBeNull();
});
it('should have hover shadow part on only one knob when dual-knobs is true', async () => {
const page = await newSpecPage({
components: [Range],
html: `<ion-range dual-knobs="true" pin="true" value="50" label="Label"></ion-range>`,
});
const range = page.body.querySelector('ion-range')!;
const shadowRoot = range.shadowRoot!;
// The hover part should not exist on either knob by default
expect(shadowRoot.querySelector('[part~="knob-a"][part~="hover"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="hover"]')).toBeNull();
// Simulate a hovered knob A by setting state on component instance
const component = page.rootInstance;
component.hoveredKnob = 'A';
await page.waitForChanges();
// The hover part should exist on knob A only
expect(shadowRoot.querySelector('[part~="knob-a"][part~="hover"]')).not.toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="hover"]')).toBeNull();
// Simulate a hovered knob B by setting state on component instance
component.hoveredKnob = 'B';
await page.waitForChanges();
// The hover part should now exist on knob B only
expect(shadowRoot.querySelector('[part~="knob-a"][part~="hover"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="hover"]')).not.toBeNull();
// Clear the hovered knob
component.hoveredKnob = undefined;
await page.waitForChanges();
// The hover part should not exist after clearing the hovered knob
expect(shadowRoot.querySelector('[part~="knob-a"][part~="hover"]')).toBeNull();
expect(shadowRoot.querySelector('[part~="knob-b"][part~="hover"]')).toBeNull();
});
});
});