diff --git a/labs/gb/components/slider/_slider-tokens.scss b/labs/gb/components/slider/_slider-tokens.scss index f63b563b58..b4295623d4 100644 --- a/labs/gb/components/slider/_slider-tokens.scss +++ b/labs/gb/components/slider/_slider-tokens.scss @@ -32,6 +32,7 @@ --state-layer-size: inherit; --with-overlap-handle-outline-color: var(--md-sys-color-surface); --with-overlap-handle-outline-width: 1px; + --icon-size: 24px; // LINT.ThenChange(slider-element.ts) keep in sync with `@cssprop` jsdoc tags } @@ -52,6 +53,7 @@ @mixin medium { --handle-height: 52px; + --icon-size: 24px; --track-height: 40px; --track-shape: 12px; --inactive-track-shape-trailing: 12px; @@ -59,6 +61,7 @@ @mixin large { --handle-height: 68px; + --icon-size: 24px; --track-height: 56px; --track-shape: 16px; --inactive-track-shape-trailing: 16px; @@ -66,6 +69,7 @@ @mixin xlarge { --handle-height: 108px; + --icon-size: 32px; --track-height: 96px; --track-shape: 28px; --inactive-track-shape-trailing: 28px; diff --git a/labs/gb/components/slider/demo/demo.ts b/labs/gb/components/slider/demo/demo.ts index 305fab2e1b..6bd0f86e6f 100644 --- a/labs/gb/components/slider/demo/demo.ts +++ b/labs/gb/components/slider/demo/demo.ts @@ -64,6 +64,7 @@ const collection = new MaterialCollection>( new Knob('centered', {ui: boolInput(), defaultValue: false}), new Knob('stops', {ui: boolInput(), defaultValue: false}), new Knob('value indicator', {ui: boolInput(), defaultValue: false}), + new Knob('icon', {ui: boolInput(), defaultValue: false}), new Knob('disabled', {ui: boolInput(), defaultValue: false}), new Knob('valueLabel', {ui: textInput(), defaultValue: ''}), ] as unknown as KnobTypesToKnobs, diff --git a/labs/gb/components/slider/demo/stories.ts b/labs/gb/components/slider/demo/stories.ts index 9cd08cc2eb..e5c3ccadb2 100644 --- a/labs/gb/components/slider/demo/stories.ts +++ b/labs/gb/components/slider/demo/stories.ts @@ -13,7 +13,7 @@ import { import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js'; import '@material/web/labs/gb/styles/icon/md-gb-icon.js'; import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; -import {css, html} from 'lit'; +import {css, html, nothing} from 'lit'; export {type SliderOrientation, type SliderSize}; @@ -31,18 +31,19 @@ export interface StoryKnobs { centered: boolean; stops: boolean; 'value indicator': boolean; + icon: boolean; disabled: boolean; valueLabel: string; } const storyStyles = css` .story-column { + box-sizing: content-box; display: flex; flex-direction: column; gap: 24px; padding: 24px; width: 280px; - box-sizing: content-box; } .story-column.vertical, @@ -66,10 +67,14 @@ const playground: MaterialStoryInit = { styles: storyStyles, render(knobs) { const isVertical = knobs.orientation === 'vertical'; + const size = knobs.size ?? 'xs'; + const supportsIcon = + !isVertical && + !knobs.range && + !knobs.centered && + (size === 'md' || size === 'lg' || size === 'xl'); return html` -
+
= { .min=${knobs.min} .max=${knobs.max} .step=${knobs.stops ? knobs.step || 10 : 'any'} - .size=${knobs.size ?? 'xs'} - ?range=${knobs.range} + .size=${size} + ?range=${knobs.range && !isVertical} ?centered=${knobs.centered} ?ticks=${knobs.stops} ?labeled=${knobs['value indicator']} ?disabled=${knobs.disabled} .valueLabel=${knobs.valueLabel} .orientation=${knobs.orientation ?? 'horizontal'}> + ${knobs.icon && supportsIcon + ? html`volume_up` + : nothing}
`; diff --git a/labs/gb/components/slider/slider-element.ts b/labs/gb/components/slider/slider-element.ts index efa0ab26a5..f1b0c93b36 100644 --- a/labs/gb/components/slider/slider-element.ts +++ b/labs/gb/components/slider/slider-element.ts @@ -92,6 +92,7 @@ const baseClass = mixinDelegatesAria( * @cssprop --state-layer-size * @cssprop --with-overlap-handle-outline-color * @cssprop --with-overlap-handle-outline-width + * @cssprop --icon-size * @cssprop --icon-color * @cssprop --icon-color-active */ @@ -128,8 +129,8 @@ export class SliderElement extends baseClass { /** * The orientation of the slider. * - * Vertical orientation applies to all variants (standard, discrete, range, - * and centered). + * Vertical orientation applies to standard, discrete, and centered variants + * (range is not supported in vertical orientation). */ @property({type: String, reflect: true}) orientation: SliderOrientation = 'horizontal'; @@ -146,6 +147,10 @@ export class SliderElement extends baseClass { @property({attribute: 'aria-valuetext-start'}) ariaValueTextStart = ''; @property({attribute: 'aria-valuetext-end'}) ariaValueTextEnd = ''; + private get isRange(): boolean { + return this.range && this.orientation !== 'vertical'; + } + get nameStart() { return this.getAttribute('name-start') ?? this.name; } @@ -194,7 +199,7 @@ export class SliderElement extends baseClass { super(); if (!isServer) { this.addEventListener('click', (event: MouseEvent) => { - if (!isActivationClick(event) || !this.inputEnd) { + if (this.disabled || !isActivationClick(event) || !this.inputEnd) { return; } this.focus(); @@ -223,7 +228,7 @@ export class SliderElement extends baseClass { private get renderAriaLabelEnd() { const {ariaLabel} = this as ARIAMixinStrict; - if (this.range) { + if (this.isRange) { return ( this.ariaLabelEnd || (ariaLabel && `${ariaLabel} end`) || @@ -239,7 +244,7 @@ export class SliderElement extends baseClass { } private get renderAriaValueTextEnd() { - if (this.range) { + if (this.isRange) { return ( this.ariaValueTextEnd || this.valueLabelEnd || @@ -255,6 +260,9 @@ export class SliderElement extends baseClass { } override focus() { + if (this.disabled) { + return; + } this.inputEnd?.focus(); } @@ -299,32 +307,42 @@ export class SliderElement extends baseClass { } protected override willUpdate(changed: PropertyValues) { + if (changed.has('disabled') && this.disabled) { + this.isPointerDragging = false; + this.pointerDownPending = false; + this.handleStartHover = false; + this.handleEndHover = false; + this.startFocusVisible = false; + this.endFocusVisible = false; + this.action = undefined; + } + const rangeModeChanged = changed.has('range') || changed.has('orientation'); this.renderValueStart = - changed.has('valueStart') || changed.has('range') + changed.has('valueStart') || rangeModeChanged ? this.valueStart : this.inputStart?.valueAsNumber; const endValueChanged = - (changed.has('valueEnd') && this.range) || + (changed.has('valueEnd') && (this.isRange || this.value === undefined)) || changed.has('value') || - changed.has('range'); + rangeModeChanged; this.renderValueEnd = endValueChanged - ? this.range + ? this.isRange ? this.valueEnd - : this.value + : (this.value ?? this.valueEnd) : this.inputEnd?.valueAsNumber; } protected override updated(changed: PropertyValues) { - if (this.range) { - this.renderValueStart = this.inputStart!.valueAsNumber; + if (this.isRange && this.inputStart) { + this.renderValueStart = this.inputStart.valueAsNumber; } this.renderValueEnd = this.inputEnd!.valueAsNumber; - if (this.range) { + if (this.isRange && this.inputStart) { const segment = (this.max - this.min) / 3; if (this.valueStart === undefined) { - this.inputStart!.valueAsNumber = this.min + segment; - const v = this.inputStart!.valueAsNumber; + this.inputStart.valueAsNumber = this.min + segment; + const v = this.inputStart.valueAsNumber; this.valueStart = this.renderValueStart = v; } if (this.valueEnd === undefined) { @@ -338,13 +356,14 @@ export class SliderElement extends baseClass { if ( changed.has('range') || + changed.has('orientation') || changed.has('renderValueStart') || changed.has('renderValueEnd') || this.isUpdatePending ) { const startNub = this.handleStart?.querySelector('.slider-handle-nub'); const endNub = this.handleEnd?.querySelector('.slider-handle-nub'); - this.handlesOverlapping = isOverlapping(startNub, endNub); + this.handlesOverlapping = this.isRange && isOverlapping(startNub, endNub); } this.performUpdate(); @@ -377,7 +396,7 @@ export class SliderElement extends baseClass { } private handleKeydown(event: KeyboardEvent) { - if (event.key === 'Tab' || event.defaultPrevented) { + if (this.disabled || event.key === 'Tab' || event.defaultPrevented) { return; } this.startAction(event); @@ -439,6 +458,9 @@ export class SliderElement extends baseClass { } private handleDown(event: PointerEvent) { + if (this.disabled) { + return; + } this.pointerDownPending = true; this.isPointerDragging = event.isPrimary && event.button === 0; try { @@ -563,6 +585,11 @@ export class SliderElement extends baseClass { } private handleFocus(event: FocusEvent) { + if (this.disabled) { + this.startFocusVisible = false; + this.endFocusVisible = false; + return; + } const target = event.target as HTMLInputElement; this.updateOnTop(target); const isStart = target === this.inputStart; @@ -575,14 +602,19 @@ export class SliderElement extends baseClass { } private handleBlur(event: FocusEvent) { - this.pointerDownPending = false; const isStart = (event.target as HTMLInputElement) === this.inputStart; if (isStart) { this.startFocusVisible = false; } else { this.endFocusVisible = false; } - this.finishAction(); + if ( + !this.isPointerDragging && + !this.pointerDownPending && + event.target === this.action?.target + ) { + this.finishAction(); + } } private needsClamping() { @@ -605,9 +637,9 @@ export class SliderElement extends baseClass { const {target, fixed, values} = action; if (action.canFlip) { + action.canFlip = false; const coincident = values.get(target) === values.get(fixed); if (coincident && this.needsClamping()) { - action.canFlip = false; action.flipped = true; action.target = fixed; action.fixed = target; @@ -638,24 +670,38 @@ export class SliderElement extends baseClass { } private handleInput(event: InputEvent) { - if (this.isRedispatchingEvent) { + if (this.disabled || this.isRedispatchingEvent) { return; } + const target = event.target as HTMLInputElement; let stopPropagation = false; let redispatch = false; - if (this.range) { - if (this.isActionFlipped()) { + if (this.isRange && this.inputStart && this.inputEnd) { + if (this.action) { + if (this.isActionFlipped()) { + stopPropagation = true; + redispatch = this.flipAction(); + } + if (this.clampAction()) { + stopPropagation = true; + redispatch = false; + } + } else if ( + target === this.inputStart && + this.inputStart.valueAsNumber > this.inputEnd.valueAsNumber + ) { + this.inputStart.valueAsNumber = this.inputEnd.valueAsNumber; stopPropagation = true; - redispatch = this.flipAction(); - } - if (this.clampAction()) { + } else if ( + target === this.inputEnd && + this.inputEnd.valueAsNumber < this.inputStart.valueAsNumber + ) { + this.inputEnd.valueAsNumber = this.inputStart.valueAsNumber; stopPropagation = true; - redispatch = false; } } - const target = event.target as HTMLInputElement; - this.updateOnTop(target); - if (this.range) { + this.updateOnTop(this.action?.target ?? target); + if (this.isRange) { this.valueStart = this.inputStart!.valueAsNumber; this.valueEnd = this.inputEnd!.valueAsNumber; } else { @@ -683,7 +729,7 @@ export class SliderElement extends baseClass { } override [getFormValue]() { - if (this.range) { + if (this.isRange) { const data = new FormData(); data.append( this.nameStart, @@ -700,7 +746,7 @@ export class SliderElement extends baseClass { } override formResetCallback() { - if (this.range) { + if (this.isRange) { const valueStart = this.getAttribute('value-start'); this.valueStart = valueStart !== null ? Number(valueStart) : undefined; const valueEnd = this.getAttribute('value-end'); @@ -744,12 +790,17 @@ export class SliderElement extends baseClass { } } + const isVertical = this.orientation === 'vertical'; + const isRange = this.isRange; + const styles = computeSliderProperties({ min: this.min, max: this.max, step: this.step, - range: this.range, + size: this.size, + range: isRange, centered: this.centered, + vertical: isVertical, valueStart: this.renderValueStart, valueEnd: this.renderValueEnd, value: this.renderValueEnd, @@ -760,21 +811,23 @@ export class SliderElement extends baseClass { const labelStart = this.valueLabelStart || formatValueIndicatorLabel(this.renderValueStart); const labelEnd = - (this.range ? this.valueLabelEnd : this.valueLabel) || + (isRange ? this.valueLabelEnd : this.valueLabel) || formatValueIndicatorLabel(this.renderValueEnd); - const isVertical = this.orientation === 'vertical'; - const dragTarget = this.dragTarget; const startOnTop = dragTarget ? dragTarget === this.inputStart : this.startOnTop; const handleStartHover = - this.handleStartHover && (!dragTarget || dragTarget === this.inputStart); + !this.disabled && + this.handleStartHover && + (!dragTarget || dragTarget === this.inputStart); const handleEndHover = - this.handleEndHover && (!dragTarget || dragTarget === this.inputEnd); + !this.disabled && + this.handleEndHover && + (!dragTarget || dragTarget === this.inputEnd); const isCoincident = - this.range && + isRange && this.renderValueStart !== undefined && this.renderValueStart === this.renderValueEnd; @@ -790,14 +843,14 @@ export class SliderElement extends baseClass { class="${classMap( sliderClasses({ disabled: this.disabled, - ranged: this.range, + ranged: isRange, centered: this.centered, vertical: isVertical, }), )}" style=${styleMap(cssStyles)}> ${when( - this.range, + isRange, () => html` html` ${when( - this.range || this.centered, + isRange || this.centered, () => html`
${when( - this.range, + isRange, () => html`
${when( @@ -947,21 +998,19 @@ export class SliderElement extends baseClass { end: true, hover: handleEndHover, active: Boolean( - !this.disabled && - this.action && - this.action.target === this.inputEnd, + !this.disabled && this.action?.target === this.inputEnd, ), - focusVisible: this.endFocusVisible, + focusVisible: !this.disabled && this.endFocusVisible, disabled: this.disabled, onTop: !this.disabled && !startOnTop, - isOverlapping: this.range && this.handlesOverlapping, + isOverlapping: isRange && this.handlesOverlapping, }), 'slider-is-coincident': isCoincident, })}">
${when( diff --git a/labs/gb/components/slider/slider.scss b/labs/gb/components/slider/slider.scss index fd6588520c..abfba66291 100644 --- a/labs/gb/components/slider/slider.scss +++ b/labs/gb/components/slider/slider.scss @@ -26,6 +26,7 @@ :host(:is(:disabled, [disabled], .disabled)) { cursor: default; + pointer-events: none; } :host([orientation='vertical']) { @@ -145,23 +146,23 @@ // bicoloured; our single-element version switches colour at the crossover instead. // This is a deliberate simplification. .slider-icon { - position: absolute; + --md-icon-color: var(--_icon-color); + --md-icon-size: var(--icon-size); + align-items: center; + block-size: var(--icon-size); + color: var(--_icon-color); + display: flex; + fill: currentColor; + font-size: var(--icon-size); + inline-size: var(--icon-size); inset-block: 0; - margin-block: auto; // LINT.IfChange(icon_padding) inset-inline-start: 10px; // LINT.ThenChange(slider.ts:icon_padding) - inline-size: 24px; - block-size: 24px; - display: flex; - align-items: center; justify-content: center; + margin-block: auto; pointer-events: none; - --md-icon-color: var(--_icon-color); - --md-icon-size: 24px; - color: var(--_icon-color); - fill: currentColor; - font-size: 24px; + position: absolute; &.slider-over-active { --md-icon-color: var(--_icon-color-active); @@ -173,20 +174,33 @@ z-index: 1; } + :host(:is([size='xs'], [size='sm'], [size='s'])) .slider-icon, + .slider:is( + [size='xs'], + [size='sm'], + [size='s'], + .slider-ranged, + .slider-centered, + .slider-vertical + ) + .slider-icon { + display: none; + } + .slider-icon, ::slotted([slot='icon']) { pointer-events: none; } ::slotted([slot='icon']) { - pointer-events: none; --md-icon-color: inherit; - --md-icon-size: 24px; + --md-icon-size: var(--icon-size); + block-size: var(--icon-size); color: inherit; fill: currentColor; - font-size: 24px; - inline-size: 24px; - block-size: 24px; + font-size: var(--icon-size); + inline-size: var(--icon-size); + pointer-events: none; } .slider-track-box { @@ -352,6 +366,7 @@ input:disabled { cursor: default; + pointer-events: none; } input::-webkit-slider-runnable-track { @@ -400,19 +415,15 @@ .slider.slider-ranged input.slider-start { clip-path: inset(0 #{$_clip-to-end} 0 0); } + .slider.slider-ranged input.slider-end { clip-path: inset(0 0 0 #{$_clip-to-start}); } - .slider.slider-ranged.slider-vertical input.slider-start { - clip-path: inset(#{$_clip-to-end} 0 0 0); - } - .slider.slider-ranged.slider-vertical input.slider-end { - clip-path: inset(0 0 #{$_clip-to-start} 0); - } .slider.slider-ranged:not(.slider-vertical) input.slider-start:dir(rtl) { clip-path: inset(0 0 0 #{$_clip-to-end}); } + .slider.slider-ranged:not(.slider-vertical) input.slider-end:dir(rtl) { clip-path: inset(0 #{$_clip-to-start} 0 0); } @@ -493,35 +504,43 @@ } } - :host(:active) .slider:not(.slider-ranged) .slider-handle-nub, - :host(.active) .slider-handle.slider-end .slider-handle-nub, - .slider-handle:is(.focus-visible, .active) .slider-handle-nub, - input.slider-end:focus-visible + :host(:active:not(:is(:disabled, [disabled], .disabled))) + .slider:not(.slider-ranged):not(.disabled) + .slider-handle:not(.disabled) + .slider-handle-nub, + :host(.active:not(:is(:disabled, [disabled], .disabled))) + .slider:not(.disabled) + .slider-handle.slider-end:not(.disabled) + .slider-handle-nub, + .slider:not(.disabled) + .slider-handle:is(.focus-visible, .active):not(.disabled) + .slider-handle-nub, + input.slider-end:focus-visible:not(:disabled) ~ .slider-handles - .slider-handle.slider-end + .slider-handle.slider-end:not(.disabled) .slider-handle-nub, - input.slider-start:focus-visible + input.slider-start:focus-visible:not(:disabled) ~ .slider-handles - .slider-handle.slider-start + .slider-handle.slider-start:not(.disabled) .slider-handle-nub, - input.slider-end:active + input.slider-end:active:not(:disabled) ~ .slider-handles:not(:has(.slider-handle.active)) - .slider-handle.slider-end + .slider-handle.slider-end:not(.disabled) .slider-handle-nub, - input.slider-start:active + input.slider-start:active:not(:disabled) ~ .slider-handles:not(:has(.slider-handle.active)) - .slider-handle.slider-start + .slider-handle.slider-start:not(.disabled) .slider-handle-nub { --_handle-nub-width: var(--focus-handle-width); } - input.slider-end:focus-visible + input.slider-end:focus-visible:not(:disabled) ~ .slider-handles - .slider-handle.slider-end + .slider-handle.slider-end:not(.disabled) .slider-handle-nub, - input.slider-start:focus-visible + input.slider-start:focus-visible:not(:disabled) ~ .slider-handles - .slider-handle.slider-start + .slider-handle.slider-start:not(.disabled) .slider-handle-nub { --focus-ring-outline: 3px solid var(--md-sys-color-secondary); --focus-ring-offset: 2px; @@ -576,16 +595,24 @@ z-index: 1; } - :host(.hover) .slider-handle.slider-end .slider-label, - :host(.active) .slider-handle.slider-end .slider-label, - .slider-handle:is(.hover, .active, .focus-visible) .slider-label, - input.slider-end:focus-visible + :host(.hover:not(:is(:disabled, [disabled], .disabled))) + .slider:not(.disabled) + .slider-handle.slider-end:not(.disabled) + .slider-label, + :host(.active:not(:is(:disabled, [disabled], .disabled))) + .slider:not(.disabled) + .slider-handle.slider-end:not(.disabled) + .slider-label, + .slider:not(.disabled) + .slider-handle:is(.hover, .active, .focus-visible):not(.disabled) + .slider-label, + input.slider-end:focus-visible:not(:disabled) ~ .slider-handles - .slider-handle.slider-end + .slider-handle.slider-end:not(.disabled) .slider-label, - input.slider-start:focus-visible + input.slider-start:focus-visible:not(:disabled) ~ .slider-handles - .slider-handle.slider-start + .slider-handle.slider-start:not(.disabled) .slider-label { opacity: 1; transform: scale(1); diff --git a/labs/gb/components/slider/slider.ts b/labs/gb/components/slider/slider.ts index 0788aedd18..9d80272f23 100644 --- a/labs/gb/components/slider/slider.ts +++ b/labs/gb/components/slider/slider.ts @@ -32,6 +32,9 @@ export const ICON_PADDING_PX = 10; /** Default inset icon size in pixels, sourced from Android M3E Slider.md. */ export const ICON_SIZE_PX = 24; +/** Inset icon size in pixels for extra-large (`xl`) sliders. */ +export const ICON_SIZE_XL_PX = 32; + /** * Crossover distance from leading track edge in pixels where the icon color * flips between inactive and active track colors. @@ -75,11 +78,12 @@ export function sliderClasses({ centered = false, vertical = false, }: SliderClassesState = {}): ClassInfo { + const isRanged = ranged && !vertical; return { [SLIDER_CLASSES.slider]: true, - [SLIDER_CLASSES.ranged]: ranged, + [SLIDER_CLASSES.ranged]: isRanged, [SLIDER_CLASSES.disabled]: disabled, - [SLIDER_CLASSES.centered]: !ranged && centered, + [SLIDER_CLASSES.centered]: !isRanged && centered, [SLIDER_CLASSES.vertical]: vertical, }; } @@ -153,8 +157,10 @@ export interface SliderStateValues { value?: number; valueStart?: number; valueEnd?: number; + size?: SliderSize; range?: boolean; centered?: boolean; + vertical?: boolean; trackLength?: number; minSpacing?: number; } @@ -235,7 +241,7 @@ export function computeSliderProperties( values.step !== undefined && values.step !== 'any' && values.step > 0 ? values.step : 1; - const range = Boolean(values.range); + const range = Boolean(values.range) && !values.vertical; const centered = Boolean(values.centered); const span = Math.max(max - min, step); @@ -244,8 +250,10 @@ export function computeSliderProperties( ? values.valueEnd : (values.value ?? values.valueEnd); - const startF = range ? ((renderValueStart ?? min) - min) / span : 0; - const endF = ((renderValueEnd ?? min) - min) / span; + const rawStartF = range ? ((renderValueStart ?? min) - min) / span : 0; + const rawEndF = ((renderValueEnd ?? min) - min) / span; + const startF = range ? Math.min(rawStartF, rawEndF) : 0; + const endF = range ? Math.max(rawStartF, rawEndF) : rawEndF; const originF = range ? startF : centered ? 0.5 : 0; const trackLen = values.trackLength ?? 200; @@ -399,8 +407,20 @@ export function computeSliderProperties( // This is a deliberate simplification. const activeEndPx = endF > 0 ? HANDLE_HALF_WIDTH_PX + travel * endF - SLIDER_GAP_PX : 0; - const iconOverActive = - !range && !centered && activeEndPx >= ICON_CROSSOVER_PX; + const size = values.size as string | undefined; + const supportsIcon = + !range && + !centered && + !values.vertical && + (!size || + size === 'md' || + size === 'm' || + size === 'lg' || + size === 'l' || + size === 'xl'); + const iconSizePx = size === 'xl' ? ICON_SIZE_XL_PX : ICON_SIZE_PX; + const iconCrossoverPx = ICON_PADDING_PX + iconSizePx / 2; + const iconOverActive = supportsIcon && activeEndPx >= iconCrossoverPx; return { '--__start-fraction': String(startF), @@ -451,15 +471,39 @@ export function setupSlider( if (!inputEnd) return; let trackLength: number | undefined; + let activeTarget: HTMLInputElement | undefined; + + const startRawAction = (event: Event) => { + activeTarget = event.target as HTMLInputElement; + }; + + const endRawAction = () => { + activeTarget = undefined; + }; const update = () => { + const isVertical = + rootEl.classList.contains('slider-vertical') || + rootEl.getAttribute('orientation') === 'vertical'; const isRange = - rootEl.classList.contains('slider-ranged') || - rootEl.hasAttribute('range') || - Boolean(inputStart); + !isVertical && + (rootEl.classList.contains('slider-ranged') || + rootEl.hasAttribute('range') || + Boolean(inputStart)); const isCentered = rootEl.classList.contains('slider-centered') || rootEl.hasAttribute('centered'); + if ( + isRange && + inputStart && + inputStart.valueAsNumber > inputEnd.valueAsNumber + ) { + if (activeTarget === inputEnd) { + inputEnd.valueAsNumber = inputStart.valueAsNumber; + } else { + inputStart.valueAsNumber = inputEnd.valueAsNumber; + } + } const minSpacingRaw = getComputedStyle(rootEl).getPropertyValue( '--stop-indicator-min-spacing', ); @@ -469,12 +513,15 @@ export function setupSlider( ? parsedMinSpacing : undefined; + const rawSize = rootEl.getAttribute('size') as SliderSize | null; const props = computeSliderProperties({ min: Number(inputEnd.min || 0), max: Number(inputEnd.max || 100), step: inputEnd.step === 'any' ? 'any' : Number(inputEnd.step || 1), + size: rawSize ?? undefined, range: isRange, centered: isCentered, + vertical: isVertical, valueStart: inputStart ? inputStart.valueAsNumber : undefined, valueEnd: inputEnd.valueAsNumber, value: inputEnd.valueAsNumber, @@ -516,8 +563,18 @@ export function setupSlider( } }; + inputEnd.addEventListener('pointerdown', startRawAction, opts); + inputEnd.addEventListener('pointerup', endRawAction, opts); + inputEnd.addEventListener('pointercancel', endRawAction, opts); + inputEnd.addEventListener('keydown', startRawAction, opts); + inputEnd.addEventListener('keyup', endRawAction, opts); inputEnd.addEventListener('input', update, opts); inputEnd.addEventListener('change', update, opts); + inputStart?.addEventListener('pointerdown', startRawAction, opts); + inputStart?.addEventListener('pointerup', endRawAction, opts); + inputStart?.addEventListener('pointercancel', endRawAction, opts); + inputStart?.addEventListener('keydown', startRawAction, opts); + inputStart?.addEventListener('keyup', endRawAction, opts); inputStart?.addEventListener('input', update, opts); inputStart?.addEventListener('change', update, opts); diff --git a/labs/gb/components/slider/slider_test.ts b/labs/gb/components/slider/slider_test.ts index fd87d22d84..2f734f9c7b 100644 --- a/labs/gb/components/slider/slider_test.ts +++ b/labs/gb/components/slider/slider_test.ts @@ -84,14 +84,72 @@ describe('md-gb-slider', () => { const sliderRoot = el.shadowRoot!.querySelector('.slider')!; expect(sliderRoot.classList.contains('slider-vertical')).toBeTrue(); - expect(sliderRoot.classList.contains('slider-ranged')).toBeTrue(); + expect(sliderRoot.classList.contains('slider-ranged')).toBeFalse(); expect(sliderRoot.classList.contains('disabled')).toBeTrue(); + expect(el.shadowRoot!.querySelector('input.slider-start')).toBeNull(); + expect( + el.shadowRoot!.querySelector('.slider-handle.slider-start'), + ).toBeNull(); const inputEnd = el.shadowRoot!.querySelector('input.slider-end')!; expect(inputEnd.getAttribute('orient')).toBe('vertical'); expect((inputEnd as HTMLInputElement).disabled).toBeTrue(); }); + it('falls back to valueEnd when vertical orientation suppresses range', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const el = root.querySelector('md-gb-slider')!; + const inputEnd = + el.shadowRoot!.querySelector('input.slider-end')!; + + expect(inputEnd.valueAsNumber).toBe(80); + expect(el.value).toBe(80); + }); + + it('does not react to pointer, keyboard, or focus interactions when disabled', async () => { + const root = env.render( + html``, + ); + await env.waitForStability(); + const el = root.querySelector('md-gb-slider')!; + const inputEnd = + el.shadowRoot!.querySelector('input.slider-end')!; + const handleEnd = el.shadowRoot!.querySelector( + '.slider-handle.slider-end', + )!; + + inputEnd.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + composed: true, + button: 0, + }), + ); + inputEnd.dispatchEvent( + new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}), + ); + inputEnd.dispatchEvent(new FocusEvent('focus', {bubbles: true})); + await env.waitForStability(); + + expect(handleEnd.classList.contains('active')).toBeFalse(); + expect(handleEnd.classList.contains('hover')).toBeFalse(); + expect(handleEnd.classList.contains('focus-visible')).toBeFalse(); + }); + it('updates value and dispatches input and change events on interaction', async () => { const root = env.render( html``, @@ -200,31 +258,87 @@ describe('md-gb-slider', () => { expect(handleEnd).not.toBeNull(); }); - it('clamps range valueStart so it does not exceed valueEnd', async () => { + it('clamps range handles at each other rather than crossing over', async () => { const root = env.render(html` + value-start="20" + value-end="40"> `); await env.waitForStability(); const el = root.querySelector('md-gb-slider')!; const inputStart = el.shadowRoot!.querySelector('input.slider-start')!; + const inputEnd = + el.shadowRoot!.querySelector('input.slider-end')!; + const handleStart = el.shadowRoot!.querySelector( + '.slider-handle.slider-start', + )!; + const handleEnd = el.shadowRoot!.querySelector( + '.slider-handle.slider-end', + )!; + + inputStart.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + composed: true, + button: 0, + }), + ); + // Simulate blur on the previously focused input when focus moves to inputStart on pointerdown. + inputEnd.dispatchEvent(new FocusEvent('blur', {bubbles: true})); + inputStart.value = '60'; + inputStart.dispatchEvent( + new InputEvent('input', {bubbles: true, composed: true}), + ); + inputStart.value = '80'; inputStart.dispatchEvent( - new PointerEvent('pointerdown', {bubbles: true, composed: true}), + new InputEvent('input', {bubbles: true, composed: true}), ); - inputStart.value = '70'; + await env.waitForStability(); + + expect(el.valueStart).toBe(40); + expect(el.valueEnd).toBe(40); + expect(inputStart.valueAsNumber).toBe(40); + expect(inputEnd.valueAsNumber).toBe(40); + expect(handleStart.classList.contains('active')).toBeTrue(); + expect(handleEnd.classList.contains('active')).toBeFalse(); + inputStart.dispatchEvent( + new PointerEvent('pointerup', { + bubbles: true, + composed: true, + button: 0, + }), + ); + await env.waitForStability(); + + el.valueStart = 20; + el.valueEnd = 40; + await env.waitForStability(); + + inputEnd.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + composed: true, + button: 0, + }), + ); + inputEnd.value = '10'; + inputEnd.dispatchEvent( new InputEvent('input', {bubbles: true, composed: true}), ); await env.waitForStability(); - expect(el.valueStart).toBe(60); - expect(inputStart.valueAsNumber).toBe(60); + expect(el.valueStart).toBe(20); + expect(el.valueEnd).toBe(20); + expect(inputStart.valueAsNumber).toBe(20); + expect(inputEnd.valueAsNumber).toBe(20); + expect(handleStart.classList.contains('active')).toBeFalse(); + expect(handleEnd.classList.contains('active')).toBeTrue(); }); it('marks coincident range handles with slider-is-coincident class', async () => { @@ -386,30 +500,67 @@ describe('md-gb-slider', () => { expect(labelContent.textContent?.trim()).toBe('Custom 45'); }); - it('renders slotted md-gb-icon on md, lg, and xl horizontal sliders', async () => { - const root = env.render(html` - - volume_up - - `); - await env.waitForStability(); - const el = root.querySelector('md-gb-slider')!; - - const iconSlot = el.shadowRoot!.querySelector('.slider-icon slot')!; - expect(iconSlot).not.toBeNull(); - expect(iconSlot.getAttribute('name')).toBe('icon'); + it('renders slotted md-gb-icon with size-specific dimensions on md, lg, and xl horizontal sliders', async () => { + for (const size of ['md', 'lg', 'xl'] as const) { + const root = env.render(html` + + volume_up + + `); + await env.waitForStability(); + const el = root.querySelector('md-gb-slider')!; + + const iconEl = + el.shadowRoot!.querySelector('.slider-icon')!; + expect(iconEl).not.toBeNull(); + const iconSlot = iconEl.querySelector('slot')!; + expect(iconSlot).not.toBeNull(); + expect(iconSlot.getAttribute('name')).toBe('icon'); + + const expectedSize = size === 'xl' ? '32px' : '24px'; + const computed = getComputedStyle(iconEl); + expect(computed.inlineSize).toBe(expectedSize); + expect(computed.blockSize).toBe(expectedSize); + expect(computed.getPropertyValue('--md-icon-size').trim()).toBe( + expectedSize, + ); + } }); - it('does not render slotted icon on xs, vertical, or range sliders', async () => { - const root = env.render(html` - + it('does not render slotted icon on xs, sm, vertical, centered, or range sliders', async () => { + for (const size of ['xs', 'sm'] as const) { + const root = env.render(html` + + volume_up + + `); + await env.waitForStability(); + const el = root.querySelector('md-gb-slider')!; + + expect(el.shadowRoot!.querySelector('.slider-icon')).toBeNull(); + } + + const incompatible = env.render(html` + + volume_up + + + volume_up + + volume_up `); await env.waitForStability(); - const el = root.querySelector('md-gb-slider')!; - - expect(el.shadowRoot!.querySelector('.slider-icon')).toBeNull(); + for (const id of ['#vertical', '#centered', '#range']) { + const el = incompatible.querySelector(id)!; + expect(el.shadowRoot!.querySelector('.slider-icon')).toBeNull(); + } }); it('toggles slider-over-active class on icon when active track crosses icon crossover distance', async () => { @@ -494,7 +645,7 @@ describe('md-gb-slider', () => { }); describe('helper functions', () => { - it('sliderClasses() applies state flags with slider- prefix', () => { + it('sliderClasses() applies state flags with slider- prefix and suppresses ranged when vertical', () => { const classes = sliderClasses({ disabled: true, ranged: true, @@ -503,10 +654,16 @@ describe('md-gb-slider', () => { }); expect(classes[SLIDER_CLASSES.slider]).toBeTrue(); - expect(classes[SLIDER_CLASSES.ranged]).toBeTrue(); + expect(classes[SLIDER_CLASSES.ranged]).toBeFalse(); expect(classes[SLIDER_CLASSES.disabled]).toBeTrue(); expect(classes[SLIDER_CLASSES.centered]).toBeFalse(); expect(classes[SLIDER_CLASSES.vertical]).toBeTrue(); + + const horizontalRanged = sliderClasses({ + ranged: true, + vertical: false, + }); + expect(horizontalRanged[SLIDER_CLASSES.ranged]).toBeTrue(); }); it('handleClasses() applies handle state flags with slider- prefix', () => {