diff --git a/labs/gb/components/slider/_slider-tokens.scss b/labs/gb/components/slider/_slider-tokens.scss new file mode 100644 index 0000000000..89a4681ab5 --- /dev/null +++ b/labs/gb/components/slider/_slider-tokens.scss @@ -0,0 +1,239 @@ +// +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// Auto-generated by cascade_tokens +// Add `// EDIT:` comments for manual adjustments. +// +// --prefix=md.comp.slider +// --states="hover|focus|pressed,xsmall|small|medium|large|xlarge,disabled" + +@mixin root { + // LINT.IfChange + // md.comp.slider.active.handle.color + --active-handle-color: var(--md-sys-color-primary); + // ┌ md.comp.slider.medium.active.handle.height + // ├ md.comp.slider.small.active.handle.height + // ├ md.comp.slider.xsmall.active.handle.height + // md.comp.slider.active.handle.height + --active-handle-height: 44px; + // md.comp.slider.active.handle.leading-space + --active-handle-leading-space: var(--md-sys-space-75); + // md.comp.slider.active.handle.padding + --active-handle-padding: var(--md-sys-space-75); + // md.comp.slider.active.handle.shape + --active-handle-shape: var(--md-sys-shape-corner-full); + // md.comp.slider.active.handle.trailing-space + --active-handle-trailing-space: var(--md-sys-space-75); + // md.comp.slider.active.handle.width + --active-handle-width: 4px; + // ┌ md.comp.slider.active.stop-indicator.container.opacity + // md.comp.slider.active.stop-indicator.container.color + --active-stop-indicator-container-color: hsl( + from var(--md-sys-color-on-primary) h s l / 1 + ); + // ┌ md.comp.slider.focus.active.track.color + // ├ md.comp.slider.pressed.active.track.color + // md.comp.slider.active.track.color + --active-track-color: var(--md-sys-color-primary); + // ┌ md.comp.slider.xsmall.active.track.height + // md.comp.slider.active.track.height + --active-track-height: 16px; + // md.comp.slider.active.track.inner-corner.corner-size + --active-track-inner-corner-corner-size: 0 2px 2px 0; + // --active-track-opacity: unset; // EDIT: unused + // md.comp.slider.active.track.outer-corner.corner-size + --active-track-outer-corner-corner-size: var(--md-sys-shape-corner-full); + // md.comp.slider.active.track.shape + --active-track-shape: var(--md-sys-shape-corner-full); + // ┌ md.comp.slider.small.active.track.shape.leading + // ├ md.comp.slider.xsmall.active.track.shape.leading + --active-track-shape-leading: 8px; + // md.comp.slider.handle.color + --handle-color: var(--md-sys-color-primary); + // md.comp.slider.handle.height + --handle-height: 44px; + // --handle-opacity: unset; // EDIT: unused + // md.comp.slider.handle.shape + --handle-shape: var(--md-sys-shape-corner-full); + // ┌ md.comp.slider.hover.handle.width + // md.comp.slider.handle.width + --handle-width: 4px; + // ┌ md.comp.slider.large.icon.padding + // ├ md.comp.slider.medium.icon.padding + --icon-padding: 6px; + // ┌ md.comp.slider.large.icon.size + // ├ md.comp.slider.medium.icon.size + --icon-size: 24px; + // ┌ md.comp.slider.inactive.stop-indicator.container.opacity + // md.comp.slider.inactive.stop-indicator.container.color + --inactive-stop-indicator-container-color: hsl( + from var(--md-sys-color-on-secondary-container) h s l / 1 + ); + // ┌ md.comp.slider.focus.inactive.track.color + // ├ md.comp.slider.pressed.inactive.track.color + // md.comp.slider.inactive.track.color + --inactive-track-color: var(--md-sys-color-secondary-container); + // ┌ md.comp.slider.xsmall.inactive.track.height + // md.comp.slider.inactive.track.height + --inactive-track-height: 16px; + // --inactive-track-opacity: unset; // EDIT: unused + // md.comp.slider.inactive.track.shape + --inactive-track-shape: var(--md-sys-shape-corner-full); + // ┌ md.comp.slider.small.inactive.track.shape.trailing + // ├ md.comp.slider.xsmall.inactive.track.shape.trailing + --inactive-track-shape-trailing: 8px; + // md.comp.slider.stop-indicator.color + --stop-indicator-color: var(--md-sys-color-on-secondary-container); + // md.comp.slider.stop-indicator.color-selected + --stop-indicator-color-selected: var(--md-sys-color-on-primary); + // md.comp.slider.stop-indicator.shape + --stop-indicator-shape: var(--md-sys-shape-corner-full); + // md.comp.slider.stop-indicator.size + --stop-indicator-size: 4px; + // md.comp.slider.stop-indicator.trailing-space + --stop-indicator-trailing-space: var(--md-sys-space-50); + // md.comp.slider.value-indicator.active.bottom-space + --value-indicator-active-bottom-space: var(--md-sys-space-150); + // md.comp.slider.value-indicator.container.color + --value-indicator-container-color: var(--md-sys-color-inverse-surface); + // ┌ md.comp.slider.value-indicator.label.label-text.font + // ├ md.comp.slider.value-indicator.label.label-text.line-height + // ├ md.comp.slider.value-indicator.label.label-text.size + // ├ md.comp.slider.value-indicator.label.label-text.weight + --value-indicator-label-label-text: var(--md-sys-typescale-label-lg); + // ┌┄ md.comp.slider.value-indicator.label.label-text + --value-indicator-label-label-text-axes: var( + --md-sys-typescale-label-lg-axes + ); + // md.comp.slider.value-indicator.label.label-text.color + --value-indicator-label-label-text-color: var( + --md-sys-color-inverse-on-surface + ); + // md.comp.slider.value-indicator.label.label-text.tracking + --value-indicator-label-label-text-tracking: var( + --md-sys-typescale-body-lg-tracking + ); + --with-overlap-handle-outline-color: var(--md-sys-color-on-primary); + --with-overlap-handle-outline-width: 1px; + --focus-ring-shape: var(--handle-shape); + --md-focus-ring-shape: var(--handle-shape); +} + +@mixin focus { + // md.comp.slider.focus.handle.width + --handle-width: 2px; +} + +@mixin pressed { + // md.comp.slider.pressed.handle.color + --handle-color: var(--md-sys-color-primary); + // md.comp.slider.pressed.handle.width + --handle-width: 2px; +} + +@mixin xsmall { + // md.comp.slider.xsmall.active.handle.height + --active-handle-height: 44px; + // md.comp.slider.xsmall.active.track.height + --active-track-height: 16px; + // md.comp.slider.xsmall.active.track.shape.leading + --active-track-shape-leading: 8px; + // md.comp.slider.xsmall.inactive.track.height + --inactive-track-height: 16px; + // md.comp.slider.xsmall.inactive.track.shape.trailing + --inactive-track-shape-trailing: 8px; +} + +@mixin small { + // md.comp.slider.small.active.handle.height + --active-handle-height: 44px; + // md.comp.slider.small.active.track.height + --active-track-height: 24px; + // md.comp.slider.small.active.track.shape.leading + --active-track-shape-leading: 8px; + // md.comp.slider.small.inactive.track.height + --inactive-track-height: 24px; + // md.comp.slider.small.inactive.track.shape.trailing + --inactive-track-shape-trailing: 8px; +} + +@mixin medium { + // md.comp.slider.medium.active.handle.height + --active-handle-height: 44px; + // md.comp.slider.medium.active.track.height + --active-track-height: 40px; + // md.comp.slider.medium.active.track.shape.leading + --active-track-shape-leading: 12px; + // md.comp.slider.medium.icon.padding + --icon-padding: 6px; + // md.comp.slider.medium.icon.size + --icon-size: 24px; + // md.comp.slider.medium.inactive.track.height + --inactive-track-height: 40px; + // md.comp.slider.medium.inactive.track.shape.trailing + --inactive-track-shape-trailing: 12px; +} + +@mixin large { + // md.comp.slider.large.active.handle.height + --active-handle-height: 68px; + // md.comp.slider.large.active.track.height + --active-track-height: 56px; + // md.comp.slider.large.active.track.shape.leading + --active-track-shape-leading: 16px; + // md.comp.slider.large.icon.padding + --icon-padding: 6px; + // md.comp.slider.large.icon.size + --icon-size: 24px; + // md.comp.slider.large.inactive.track.height + --inactive-track-height: 56px; + // md.comp.slider.large.inactive.track.shape.trailing + --inactive-track-shape-trailing: 16px; +} + +@mixin xlarge { + // md.comp.slider.xlarge.active.handle.height + --active-handle-height: 108px; + // md.comp.slider.xlarge.active.track.height + --active-track-height: 96px; + // md.comp.slider.xlarge.active.track.shape.leading + --active-track-shape-leading: 28px; + // md.comp.slider.xlarge.icon.padding + --icon-padding: 8px; + // md.comp.slider.xlarge.icon.size + --icon-size: 32px; + // md.comp.slider.xlarge.inactive.track.height + --inactive-track-height: 96px; + // md.comp.slider.xlarge.inactive.track.shape.trailing + --inactive-track-shape-trailing: 28px; +} + +@mixin disabled { + // ┌ md.comp.slider.active.stop-indicator.container.opacity + // md.comp.slider.disabled.active.stop-indicator.container.color + --active-stop-indicator-container-color: hsl( + from var(--md-sys-color-inverse-on-surface) h s l / 1 + ); + // ┌ md.comp.slider.disabled.active.track.opacity + // md.comp.slider.disabled.active.track.color + --active-track-color: hsl(from var(--md-sys-color-on-surface) h s l / 0.38); + // ┌ md.comp.slider.disabled.handle.opacity + // md.comp.slider.disabled.handle.color + --handle-color: color-mix( + in srgb, + var(--md-sys-color-on-surface) 38%, + var(--md-sys-color-surface) + ); + // md.comp.slider.disabled.handle.width + --handle-width: 4px; + // ┌ md.comp.slider.inactive.stop-indicator.container.opacity + // md.comp.slider.disabled.inactive.stop-indicator.container.color + --inactive-stop-indicator-container-color: hsl( + from var(--md-sys-color-on-surface) h s l / 1 + ); + // ┌ md.comp.slider.disabled.inactive.track.opacity + // md.comp.slider.disabled.inactive.track.color + --inactive-track-color: hsl(from var(--md-sys-color-on-surface) h s l / 0.12); +} diff --git a/labs/gb/components/slider/demo/demo.ts b/labs/gb/components/slider/demo/demo.ts new file mode 100644 index 0000000000..8d4649e74a --- /dev/null +++ b/labs/gb/components/slider/demo/demo.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './material-collection.js'; +import './index.js'; + +import { + KnobTypesToKnobs, + MaterialCollection, + materialInitsToStoryInits, + setUpDemo, +} from './material-collection.js'; +import {boolInput, Knob, selectDropdown} from './index.js'; + +import {SliderSize} from '../slider.js'; +import {stories, StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'Slider', + [ + new Knob('disabled', {ui: boolInput()}), + new Knob('size', { + ui: selectDropdown({ + options: [ + {value: 'xsmall', label: 'X-Small'}, + {value: 'small', label: 'Small'}, + {value: 'medium', label: 'Medium'}, + {value: 'large', label: 'Large'}, + {value: 'xlarge', label: 'X-Large'}, + ], + }), + }), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/slider/demo/stories.ts b/labs/gb/components/slider/demo/stories.ts new file mode 100644 index 0000000000..f97c3cdcc9 --- /dev/null +++ b/labs/gb/components/slider/demo/stories.ts @@ -0,0 +1,352 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/gb/components/slider/md-gb-slider.js'; + +import { + labelStyles, + MaterialStoryInit, +} from './material-collection.js'; +import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js'; +import {css, html} from 'lit'; + +import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.css' with {type: 'css'}; // github-only +import {SliderSize} from '@material/web/labs/gb/components/slider/slider.js'; +import {SliderElement} from '@material/web/labs/gb/components/slider/slider-element.js'; +// import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; // google3-only + +/** Knob types for slider stories. */ +export interface StoryKnobs { + disabled: boolean; + size?: SliderSize; +} + +adoptStyles(document, [ + m3Styles, + css` + :root { + --md-icon-font: 'Material Symbols Outlined'; + } + .story-wrapper { + display: flex; + flex-direction: column; + gap: 24px; + padding: 48px 24px; + min-width: 320px; + max-width: 600px; + } + label { + display: flex; + flex-direction: column; + gap: 8px; + font-family: Roboto, sans-serif; + font-size: 14px; + color: var(--md-sys-color-on-surface); + } + md-gb-slider { + width: 100%; + } + `, +]); + +const single: MaterialStoryInit = { + name: 'Single point sliders', + styles: [labelStyles], + render({disabled, size}) { + return html` +
+ + + +
+ `; + }, +}; + +const range: MaterialStoryInit = { + name: 'Range sliders', + styles: [labelStyles], + render({disabled, size}) { + return html` +
+ + + +
+ `; + }, +}; + +const centered: MaterialStoryInit = { + name: 'Centered sliders', + styles: [labelStyles], + render({disabled, size}) { + return html` +
+ +
+ `; + }, +}; + +const disabledStory: MaterialStoryInit = { + name: 'Disabled sliders', + styles: [labelStyles], + render({size}) { + return html` +
+ + +
+ `; + }, +}; + +const customText: MaterialStoryInit = { + name: 'Slider with custom value indicator text and aria-valuetext', + styles: [labelStyles], + render({disabled, size}) { + const labels = ['Extra Small', 'Small', 'Medium', 'Large', 'Extra Large']; + function updateText(event: Event) { + const target = event.target as SliderElement; + const index = Math.round(target.value ?? 0); + target.valueLabel = labels[index]; + target.ariaValueText = labels[index]; + } + return html` +
+ +
+ `; + }, +}; + +const customTheme: MaterialStoryInit = { + name: 'Sliders with custom theme', + styles: [ + labelStyles, + css` + .custom-theme-slider { + --active-track-color: #6750a4; + --inactive-track-color: #eaddff; + --handle-color: #6750a4; + --active-handle-color: #381e72; + } + `, + ], + render({disabled, size}) { + return html` +
+ +
+ `; + }, +}; + +const idomDemo: MaterialStoryInit = { + name: 'IDOM demo', + styles: [labelStyles], + render({disabled, size}) { + return html` +
+ + +
+ + + +
+
+ `; + }, +}; + +const disabled: MaterialStoryInit = { + name: 'Disabled', + styles: [labelStyles], + render({size}) { + return html` +
+ +
+ `; + }, +}; + +/** Slider stories. */ +export const stories = [ + single, + range, + centered, + disabledStory, + customText, + customTheme, + idomDemo, + disabled, +]; diff --git a/labs/gb/components/slider/md-gb-slider.ts b/labs/gb/components/slider/md-gb-slider.ts new file mode 100644 index 0000000000..a61b818e5c --- /dev/null +++ b/labs/gb/components/slider/md-gb-slider.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {SliderElement} from './slider-element.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A gBreeze Slider component. */ + 'md-gb-slider': SliderElement; + } +} + +customElements.define('md-gb-slider', SliderElement); diff --git a/labs/gb/components/slider/slider-element.ts b/labs/gb/components/slider/slider-element.ts new file mode 100644 index 0000000000..3b55d09023 --- /dev/null +++ b/labs/gb/components/slider/slider-element.ts @@ -0,0 +1,562 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + css, + CSSResultOrNative, + html, + LitElement, + nothing, + PropertyValues, +} from 'lit'; +import {property, query, state} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import {styleMap} from 'lit/directives/style-map.js'; +import {ARIAMixinStrict} from '../../../../internal/aria/aria.js'; +import {mixinDelegatesAria} from '../../../../internal/aria/delegate.js'; +import {redispatchEvent} from '../../../../internal/events/redispatch-event.js'; +import {mixinElementInternals} from '../../../behaviors/element-internals.js'; +import { + getFormState, + getFormValue, + mixinFormAssociated, +} from '../../../behaviors/form-associated.js'; + +// import sliderStyles from './slider.cssresult.js'; // google3-only + +import { + calculateFraction, + clampRangeAction, + flipRangeAction, + handleClasses, + isOverlapping, + isRangeActionFlipped, + type RangeAction, + sliderClasses, + type SliderSize, + startRangeAction, +} from './slider.js'; + +// Separate variable needed for closure. +const baseClass = mixinDelegatesAria( + mixinFormAssociated(mixinElementInternals(LitElement)), +); + +/** + * A gBreeze Slider component. + * + * Supports both single-thumb (`range = false`) and multi-thumb range (`range = true`) modes. + * + * @fires {InputEvent} input - Fired when a slider thumb value changes. --bubbles --composed + * @fires {Event} change - Fired when a slider thumb value commit occurs. --bubbles + * @cssprop --active-handle-height - Height of the active handle. + * @cssprop --active-handle-padding - Padding around the active handle for the state layer. + * @cssprop --active-track-color - Color of the active track. + * @cssprop --active-track-height - Height of the active track. + * @cssprop --active-track-shape - Shape of the active track. + * @cssprop --handle-color - Color of the thumb handle. + * @cssprop --handle-elevation - Elevation box shadow of the thumb handle. + * @cssprop --handle-height - Height of the thumb handle. + * @cssprop --handle-shape - Border radius shape of the thumb handle. + * @cssprop --handle-width - Width of the thumb handle. + * @cssprop --inactive-track-color - Color of the inactive track. + * @cssprop --inactive-track-height - Height of the inactive track. + * @cssprop --inactive-track-shape - Shape of the inactive track. + * @cssprop --stop-indicator-color - Color of track stop indicators. + * @cssprop --value-indicator-container-color - Background color of the value indicator tooltip. + * @cssprop --with-overlap-handle-outline-color - Outline color when two handles overlap. + * @cssprop --with-overlap-handle-outline-width - Outline width when two handles overlap. + */ +export class SliderElement extends baseClass { + /** @nocollapse */ + static override shadowRootOptions: ShadowRootInit = { + mode: 'open', + delegatesFocus: true, + }; + + static override styles: CSSResultOrNative[] = [ + sliderStyles, + css` + :host { + display: inline-flex; + vertical-align: middle; + } + .slider { + flex: 1; + } + `, + ]; + + /** The slider minimum value. */ + @property({type: Number}) min = 0; + + /** The slider maximum value. */ + @property({type: Number}) max = 100; + + /** The slider value displayed when range is false. */ + @property({type: Number}) value?: number; + + /** The slider start value displayed when range is true. */ + @property({type: Number, attribute: 'value-start'}) valueStart?: number; + + /** The slider end value displayed when range is true. */ + @property({type: Number, attribute: 'value-end'}) valueEnd?: number; + + /** + * An optional label for the slider's value displayed when range is false; + * if not set, the label is the value itself. + */ + @property({attribute: 'value-label'}) valueLabel = ''; + + /** + * An optional label for the slider's start value displayed when range is + * true; if not set, the label is the valueStart itself. + */ + @property({attribute: 'value-label-start'}) valueLabelStart = ''; + + /** + * An optional label for the slider's end value displayed when range is true; + * if not set, the label is the valueEnd itself. + */ + @property({attribute: 'value-label-end'}) valueLabelEnd = ''; + + /** The step between values. */ + @property({type: Number}) step = 1; + + /** Whether or not to display a multi-thumb range slider. */ + @property({type: Boolean, reflect: true}) range = false; + + /** Whether or not the slider is disabled. */ + @property({type: Boolean, reflect: true}) override disabled = false; + + /** + * The HTML name to use in form submission for a range slider's starting + * value. Use `name` instead if both the start and end values should use the + * same name. + */ + get nameStart() { + return this.getAttribute('name-start') ?? this.name; + } + set nameStart(name: string) { + this.setAttribute('name-start', name); + } + + /** + * The HTML name to use in form submission for a range slider's ending value. + * Use `name` instead if both the start and end values should use the same + * name. + */ + get nameEnd() { + return this.getAttribute('name-end') ?? this.nameStart; + } + set nameEnd(name: string) { + this.setAttribute('name-end', name); + } + + /** Whether or not tick marks are shown. */ + @property({type: Boolean}) ticks = false; + + /** Whether or not value labels are shown. */ + @property({type: Boolean}) labeled = false; + + /** Size variant for the slider. */ + @property() size?: SliderSize; + + /** Aria label for the start handle when range is true. */ + @property({attribute: 'aria-label-start'}) ariaLabelStart = ''; + + /** Aria label for the end handle when range is true. */ + @property({attribute: 'aria-label-end'}) ariaLabelEnd = ''; + + /** Aria value text for the start handle when range is true. */ + @property({attribute: 'aria-valuetext-start'}) ariaValueTextStart = ''; + + /** Aria value text for the end handle when range is true. */ + @property({attribute: 'aria-valuetext-end'}) ariaValueTextEnd = ''; + + /** Aria value text for the single handle when range is false. */ + @property({attribute: 'aria-valuetext'}) override ariaValueText = ''; + + @query('input.start') private readonly inputStart!: HTMLInputElement | null; + @query('input.end, input#input') + private readonly inputEnd!: HTMLInputElement | null; + + @query('.handle.start') + private readonly handleStartEl!: HTMLDivElement | null; + @query('.handle.end') private readonly handleEndEl!: HTMLDivElement | null; + + @state() private startOnTop = false; + @state() private handlesOverlapping = false; + @state() private renderValueStart?: number; + @state() private renderValueEnd?: number; + + private action?: RangeAction; + private isRedispatchingEvent = false; + + private get renderAriaLabelStart() { + const {ariaLabel} = this as ARIAMixinStrict; + return ( + this.ariaLabelStart || + (ariaLabel && `${ariaLabel} start`) || + this.valueLabelStart || + String(this.renderValueStart ?? this.valueStart ?? '') + ); + } + + private get renderAriaValueTextStart() { + return ( + this.ariaValueTextStart || + this.valueLabelStart || + String(this.renderValueStart ?? this.valueStart ?? '') + ); + } + + private get renderAriaLabelEnd() { + const {ariaLabel} = this as ARIAMixinStrict; + if (this.range) { + return ( + this.ariaLabelEnd || + (ariaLabel && `${ariaLabel} end`) || + this.valueLabelEnd || + String(this.renderValueEnd ?? this.valueEnd ?? '') + ); + } + return ariaLabel || this.valueLabel || String(this.value ?? ''); + } + + private get renderAriaValueTextEnd() { + if (this.range) { + return ( + this.ariaValueTextEnd || + this.valueLabelEnd || + String(this.renderValueEnd ?? this.valueEnd ?? '') + ); + } + const {ariaValueText} = this as ARIAMixinStrict; + return ariaValueText || this.valueLabel || String(this.value ?? ''); + } + + override [getFormValue](): FormData | string { + if (this.range) { + const data = new FormData(); + data.append(this.nameStart, String(this.valueStart ?? this.min)); + data.append(this.nameEnd, String(this.valueEnd ?? this.max)); + return data; + } + return String(this.value ?? this.min); + } + + override [getFormState](): FormData | string { + return this[getFormValue](); + } + + override formResetCallback() { + if (this.range) { + const valueStart = this.getAttribute('value-start'); + this.valueStart = valueStart !== null ? Number(valueStart) : undefined; + const valueEnd = this.getAttribute('value-end'); + this.valueEnd = valueEnd !== null ? Number(valueEnd) : undefined; + return; + } + const value = this.getAttribute('value'); + this.value = value !== null ? Number(value) : undefined; + } + + override formStateRestoreCallback( + state: string | Array<[string, string]> | null, + ) { + if (Array.isArray(state)) { + const [[, valueStart], [, valueEnd]] = state; + this.valueStart = Number(valueStart); + this.valueEnd = Number(valueEnd); + this.range = true; + return; + } + if (state !== null) { + this.value = Number(state); + this.range = false; + } + } + + override focus(options?: FocusOptions) { + this.inputEnd?.focus(options); + } + + protected override willUpdate(changed: PropertyValues) { + super.willUpdate(changed); + this.renderValueStart = changed.has('valueStart') + ? this.valueStart + : (this.inputStart?.valueAsNumber ?? this.renderValueStart); + const endValueChanged = + (changed.has('valueEnd') && this.range) || changed.has('value'); + this.renderValueEnd = endValueChanged + ? this.range + ? this.valueEnd + : this.value + : (this.inputEnd?.valueAsNumber ?? this.renderValueEnd); + } + + protected override updated(changed: PropertyValues) { + super.updated(changed); + if (this.range && this.inputStart) { + this.renderValueStart = this.inputStart.valueAsNumber; + } + if (this.inputEnd) { + this.renderValueEnd = this.inputEnd.valueAsNumber; + } + + if (this.range) { + const segment = (this.max - this.min) / 3; + if (this.valueStart === undefined) { + if (this.inputStart) { + this.inputStart.valueAsNumber = this.min + segment; + const v = this.inputStart.valueAsNumber; + this.valueStart = this.renderValueStart = v; + } else { + this.valueStart = this.renderValueStart = this.min + segment; + } + } + if (this.valueEnd === undefined) { + if (this.inputEnd) { + this.inputEnd.valueAsNumber = this.min + 2 * segment; + const v = this.inputEnd.valueAsNumber; + this.valueEnd = this.renderValueEnd = v; + } else { + this.valueEnd = this.renderValueEnd = this.min + 2 * segment; + } + } + } else { + this.value ??= + this.renderValueEnd ?? this.min + (this.max - this.min) / 2; + this.renderValueEnd = this.value; + } + + if ( + changed.has('range') || + changed.has('renderValueStart') || + changed.has('renderValueEnd') || + this.isUpdatePending + ) { + const startNub = this.handleStartEl?.querySelector('.handle-nub'); + const endNub = this.handleEndEl?.querySelector('.handle-nub'); + this.handlesOverlapping = isOverlapping(startNub, endNub); + } + this.performUpdate(); + } + + override render() { + const startFraction = this.range + ? calculateFraction( + this.valueStart ?? this.renderValueStart ?? this.min, + this.min, + this.max, + this.step, + ) + : 0; + const endFraction = this.range + ? calculateFraction( + this.valueEnd ?? this.renderValueEnd ?? this.min, + this.min, + this.max, + this.step, + ) + : calculateFraction( + this.value ?? this.renderValueEnd ?? this.min, + this.min, + this.max, + this.step, + ); + const midFraction = (startFraction + endFraction) / 2; + + const classes = sliderClasses({ + range: this.range, + disabled: this.disabled, + size: this.size, + }); + + const style = { + '--_start-fraction': String(startFraction), + '--_end-fraction': String(endFraction), + '--_mid-fraction': String(midFraction), + }; + + return html` +
+ + + ${this.range ? this.renderMultiThumb() : this.renderSingleThumb()} +
+ `; + } + + private renderThumb(type: 'single' | 'start' | 'end') { + const ariaLabelledBy = this.getAttribute('aria-labelledby'); + const isSingle = type === 'single'; + const isStart = type === 'start'; + const id = isSingle ? 'input' : type; + const inputClass = isSingle ? 'input' : `input ${type}`; + + const onTop = + !this.disabled && + (isSingle ? false : isStart ? this.startOnTop : !this.startOnTop); + const isOverlapping = + !this.disabled && !isSingle && this.handlesOverlapping; + + const handleClassesInfo = handleClasses({ + start: isStart, + end: !isStart, + onTop, + isOverlapping, + }); + + const value = isStart + ? (this.valueStart ?? this.renderValueStart ?? this.min) + : isSingle + ? (this.value ?? this.renderValueEnd ?? this.min) + : (this.valueEnd ?? this.renderValueEnd ?? this.max); + + const ariaMin = isStart + ? this.min + : isSingle + ? this.min + : (this.valueStart ?? this.renderValueStart ?? this.min); + const ariaMax = isStart + ? (this.valueEnd ?? this.renderValueEnd ?? this.max) + : this.max; + + const ariaLabel = isStart + ? this.renderAriaLabelStart + : this.renderAriaLabelEnd; + const ariaValueText = isStart + ? this.renderAriaValueTextStart + : this.renderAriaValueTextEnd; + + return html` + +
+
+
+ `; + } + + private renderSingleThumb() { + return this.renderThumb('single'); + } + + private renderMultiThumb() { + return html` ${this.renderThumb('start')} ${this.renderThumb('end')} `; + } + + private startAction(event: Event) { + if (this.disabled) return; + this.action = startRangeAction(event, this.inputStart, this.inputEnd); + } + + private finishAction() { + this.action = undefined; + } + + private handleKeydown(event: KeyboardEvent) { + if (this.disabled) return; + this.startAction(event); + } + + private handleKeyup() { + this.finishAction(); + } + + private handleFocus(event: Event) { + if (this.disabled) return; + this.updateOnTop(event.target as HTMLInputElement); + } + + private handleDown(event: PointerEvent) { + if (this.disabled) return; + this.startAction(event); + } + + private async handleUp(event: PointerEvent) { + if (this.disabled || !this.action) return; + const {target, values, flipped} = this.action; + await new Promise(requestAnimationFrame); + if (target !== undefined) { + target.focus(); + if (flipped && target.valueAsNumber !== values.get(target)!) { + target.dispatchEvent(new Event('change', {bubbles: true})); + } + } + this.finishAction(); + } + + private updateOnTop(input: HTMLInputElement) { + if (this.disabled) return; + this.startOnTop = input.classList.contains('start'); + } + + private handleInput(event: InputEvent) { + if (this.disabled || this.isRedispatchingEvent) return; + let stopPropagation = false; + let redispatch = false; + if (this.range) { + if (isRangeActionFlipped(this.action, this.inputStart)) { + stopPropagation = true; + redispatch = flipRangeAction(this.action); + } + if (clampRangeAction(this.action, this.inputStart)) { + stopPropagation = true; + redispatch = false; + } + } + const target = event.target as HTMLInputElement; + this.updateOnTop(target); + if (this.range) { + this.valueStart = this.inputStart!.valueAsNumber; + this.valueEnd = this.inputEnd!.valueAsNumber; + } else { + this.value = this.inputEnd!.valueAsNumber; + } + if (stopPropagation) event.stopPropagation(); + if (redispatch) { + this.isRedispatchingEvent = true; + redispatchEvent(target, event); + this.isRedispatchingEvent = false; + } + } + + private handleChange(event: Event) { + if (this.disabled) return; + const changeTarget = event.target as HTMLInputElement; + const {target, values} = this.action ?? {}; + const squelch = + target && target.valueAsNumber === values!.get(changeTarget)!; + if (!squelch) redispatchEvent(this, event); + this.finishAction(); + } +} diff --git a/labs/gb/components/slider/slider.scss b/labs/gb/components/slider/slider.scss new file mode 100644 index 0000000000..3db44e8ace --- /dev/null +++ b/labs/gb/components/slider/slider.scss @@ -0,0 +1,225 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'slider-tokens'; +// go/keep-sorted end + +@layer md.sys; +@layer md.comp.slider { + .slider { + & { + @include slider-tokens.root; + --_handle-container-width: calc( + var(--active-handle-width) + var(--active-handle-padding) * 2 + ); + --_handle-container-height: calc( + var(--handle-height) + var(--active-handle-padding) * 2 + ); + } + + &:is(:disabled, .disabled) { + @include slider-tokens.disabled; + } + + &:not(:disabled, .disabled, [aria-disabled='true']) { + cursor: pointer; + } + + &.xsmall { + @include slider-tokens.xsmall; + } + + &.small { + @include slider-tokens.small; + } + + &.medium { + @include slider-tokens.medium; + } + + &.large { + @include slider-tokens.large; + } + + &.xlarge { + @include slider-tokens.xlarge; + } + + & { + position: relative; + display: inline-grid; + align-items: center; + min-width: 200px; + height: 48px; + touch-action: none; + user-select: none; + box-sizing: border-box; + --_start-fraction: 0; + --_end-fraction: 0.5; + } + + .track-wrapper { + position: absolute; + inset: 0; + display: flex; + align-items: center; + pointer-events: none; + } + + .inactive-track { + position: absolute; + left: calc(var(--_handle-container-width) / 2); + width: calc(100% - var(--_handle-container-width)); + height: var(--inactive-track-height); + background-color: var(--inactive-track-color); + border-radius: var(--inactive-track-shape); + } + + .active-track { + position: absolute; + left: calc( + var(--_start-fraction) * (100% - var(--_handle-container-width)) + + calc(var(--_handle-container-width) / 2) + ); + width: calc( + (var(--_end-fraction) - var(--_start-fraction)) * + (100% - var(--_handle-container-width)) + ); + height: var(--active-track-height); + background-color: var(--active-track-color); + border-radius: var(--active-track-shape); + } + + .handle { + position: absolute; + top: 50%; + left: calc( + var(--_end-fraction) * (100% - var(--_handle-container-width)) + + calc(var(--_handle-container-width) / 2) + ); + transform: translate(-50%, -50%); + width: var(--_handle-container-width); + height: var(--_handle-container-height); + border-radius: var(--handle-shape); + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + } + + .handle.start { + left: calc( + var(--_start-fraction) * (100% - var(--_handle-container-width)) + + calc(var(--_handle-container-width) / 2) + ); + } + + .handle.on-top { + z-index: 1; + } + + .handle.on-top.is-overlapping .handle-nub { + border: var(--with-overlap-handle-outline-color) solid + var(--with-overlap-handle-outline-width); + } + + .handle-nub { + width: var(--handle-width); + height: var(--handle-height); + background-color: var(--handle-color); + border-radius: var(--handle-shape); + box-shadow: var(--handle-elevation); + } + + input[type='range'] { + appearance: none; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + background: transparent; + border: none; + outline: none; + cursor: pointer; + opacity: 0; + pointer-events: auto; + z-index: 1; + } + + &:is(:disabled, .disabled, [aria-disabled='true']) input[type='range'], + input[type='range']:disabled { + cursor: default; + pointer-events: none; + } + + input[type='range']:focus, + input[type='range']:focus-visible { + outline: none; + } + + input[type='range']::-webkit-slider-thumb { + appearance: none; + width: var(--_handle-container-width); + height: var(--_handle-container-height); + opacity: 0; + } + + input[type='range']::-moz-range-thumb { + appearance: none; + border: none; + width: var(--_handle-container-width); + height: var(--_handle-container-height); + opacity: 0; + } + + &:not(:disabled, .disabled, [aria-disabled='true']) { + input.start:hover ~ .handle.start .handle-nub, + input.end:hover ~ .handle.end .handle-nub, + input#input:hover ~ .handle.end .handle-nub { + background-color: color-mix(in srgb, var(--handle-color), white 12%); + } + + .handle:active .handle-nub { + background-color: color-mix(in srgb, var(--handle-color), black 12%); + } + + input.start:focus-visible ~ .handle.start, + input.end:focus-visible ~ .handle.end, + input#input:focus-visible ~ .handle.end { + @include slider-tokens.focus; + + .handle-nub { + outline: 2px solid var(--md-sys-color-primary); + outline-offset: 2px; + } + } + + input.start:active ~ .handle.start, + input.end:active ~ .handle.end, + input#input:active ~ .handle.end { + @include slider-tokens.pressed; + } + } + + &.range { + --_clip-to-start: calc( + var(--_mid-fraction) * (100% - var(--_handle-container-width)) + + calc(var(--_handle-container-width) / 2) + ); + --_clip-to-end: calc(100% - var(--_clip-to-start)); + + input.start { + clip-path: inset(0 var(--_clip-to-end) 0 0); + } + + input.end { + clip-path: inset(0 0 0 var(--_clip-to-start)); + } + } + } +} diff --git a/labs/gb/components/slider/slider.ts b/labs/gb/components/slider/slider.ts new file mode 100644 index 0000000000..0e69f791d4 --- /dev/null +++ b/labs/gb/components/slider/slider.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {type ClassInfo} from 'lit/directives/class-map.js'; +import {createClassMapDirective} from '../shared/directives.js'; +import {PSEUDO_CLASSES} from '../shared/pseudo-classes.js'; + +/** Size variants for Slider. */ +export type SliderSize = 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'; + +/** Slider classes. */ +export const SLIDER_CLASSES = { + slider: 'slider', + range: 'range', + disabled: PSEUDO_CLASSES.disabled, + xsmall: 'xsmall', + small: 'small', + medium: 'medium', + large: 'large', + xlarge: 'xlarge', +} as const; + +/** The state provided to the `sliderClasses()` function. */ +export interface SliderClassesState { + /** Whether the slider is a multi-thumb range slider. */ + range?: boolean; + /** Emulates `:disabled`. */ + disabled?: boolean; + /** Optional size variant. */ + size?: SliderSize; +} + +/** + * Returns the slider classes to apply to an element based on the given state. + * + * @param state The state of the slider. + * @return An object of class names and truthy values if they apply. + */ +export function sliderClasses({ + range = false, + disabled = false, + size, +}: SliderClassesState = {}): ClassInfo { + return { + [SLIDER_CLASSES.slider]: true, + [SLIDER_CLASSES.range]: range, + [SLIDER_CLASSES.disabled]: disabled, + [SLIDER_CLASSES.xsmall]: size === 'xsmall', + [SLIDER_CLASSES.small]: size === 'small', + [SLIDER_CLASSES.medium]: size === 'medium', + [SLIDER_CLASSES.large]: size === 'large', + [SLIDER_CLASSES.xlarge]: size === 'xlarge', + }; +} + +/** The state provided to the `handleClasses()` function. */ +export interface HandleClassesState { + start?: boolean; + end?: boolean; + onTop?: boolean; + isOverlapping?: boolean; +} + +/** Handle container classes. */ +export const HANDLE_CLASSES = { + handle: 'handle', + start: 'start', + end: 'end', + onTop: 'on-top', + isOverlapping: 'is-overlapping', +} as const; + +/** + * Returns the handle classes to apply to a slider thumb handle container. + */ +export function handleClasses({ + start = false, + end = false, + onTop = false, + isOverlapping = false, +}: HandleClassesState = {}): ClassInfo { + return { + [HANDLE_CLASSES.handle]: true, + [HANDLE_CLASSES.start]: start, + [HANDLE_CLASSES.end]: end, + [HANDLE_CLASSES.onTop]: onTop, + [HANDLE_CLASSES.isOverlapping]: isOverlapping, + }; +} + +/** + * Sets up slider container functionality for light DOM or framework usage. + */ +export function setupSlider( + sliderEl: HTMLElement, + opts?: {signal?: AbortSignal}, +): void { + sliderEl.addEventListener( + 'input', + (event) => { + if (sliderEl.classList.contains(SLIDER_CLASSES.disabled)) return; + const target = event.target as HTMLInputElement; + if (!target || target.tagName !== 'INPUT' || target.type !== 'range') { + return; + } + const isRange = sliderEl.classList.contains(SLIDER_CLASSES.range); + if (!isRange) return; + const inputStart = + sliderEl.querySelector('input.start'); + const inputEnd = sliderEl.querySelector('input.end'); + if (!inputStart || !inputEnd) return; + const action = startRangeAction(event, inputStart, inputEnd); + if (isRangeActionFlipped(action, inputStart)) { + flipRangeAction(action); + } + clampRangeAction(action, inputStart); + }, + opts, + ); +} + +/** + * A Lit directive that adds slider container styling and functionality. + */ +export const slider = createClassMapDirective({ + getClasses: sliderClasses, + setupElement: setupSlider, +}); + +/** + * Normalizes a slider value into a fraction between 0 and 1 clamped to [min, max]. + */ +export function calculateFraction( + value: number, + min: number, + max: number, + step = 1, +): number { + const stepVal = step === 0 ? 1 : step; + const range = Math.max(max - min, stepVal); + const clamped = Math.max(min, Math.min(max, value)); + return (clamped - min) / range; +} + +/** + * Checks whether pointer event coordinates are within the bounding rect of an element. + */ +export function inBounds( + {x, y}: PointerEvent, + element?: HTMLElement | null, +): boolean { + if (!element) { + return false; + } + const {top, left, bottom, right} = element.getBoundingClientRect(); + return x >= left && x <= right && y >= top && y <= bottom; +} + +/** + * Checks whether two elements visually overlap. + */ +export function isOverlapping( + elA: Element | null | undefined, + elB: Element | null | undefined, +): boolean { + if (!(elA && elB)) { + return false; + } + const a = elA.getBoundingClientRect(); + const b = elB.getBoundingClientRect(); + return !( + a.top > b.bottom || + a.right < b.left || + a.bottom < b.top || + a.left > b.right + ); +} + +/** + * Represents an ongoing drag or keyboard interaction on a range slider. + */ +export interface RangeAction { + canFlip: boolean; + flipped: boolean; + target: HTMLInputElement; + fixed: HTMLInputElement; + values: Map; +} + +/** + * Starts a range slider interaction action. + */ +export function startRangeAction( + event: Event, + inputStart: HTMLInputElement | null, + inputEnd: HTMLInputElement | null, +): RangeAction | undefined { + const target = event.target as HTMLInputElement; + const fixed = target === inputStart ? inputEnd! : inputStart!; + if (!target || !fixed) return undefined; + return { + canFlip: event.type === 'pointerdown', + flipped: false, + target, + fixed, + values: new Map([ + [target, target.valueAsNumber], + [fixed, fixed.valueAsNumber], + ]), + }; +} + +/** + * Checks if a range slider action needs clamping. + */ +export function needsRangeClamping( + action: RangeAction | undefined, + inputStart: HTMLInputElement | null, +): boolean { + if (!action) return false; + const {target, fixed} = action; + const isStart = target === inputStart; + return isStart + ? target.valueAsNumber > fixed.valueAsNumber + : target.valueAsNumber < fixed.valueAsNumber; +} + +/** + * Checks if a range slider action should be flipped when dragging past coincident thumbs. + */ +export function isRangeActionFlipped( + action: RangeAction | undefined, + inputStart: HTMLInputElement | null, +): boolean { + if (!action) return false; + const {target, fixed, values} = action; + if (action.canFlip) { + const coincident = values.get(target) === values.get(fixed); + if (coincident && needsRangeClamping(action, inputStart)) { + action.canFlip = false; + action.flipped = true; + action.target = fixed; + action.fixed = target; + } + } + return action.flipped; +} + +/** + * Flips the action target and fixed values. + */ +export function flipRangeAction(action: RangeAction | undefined): boolean { + if (!action) return false; + const {target, fixed, values} = action; + const changed = target.valueAsNumber !== fixed.valueAsNumber; + target.valueAsNumber = fixed.valueAsNumber; + fixed.valueAsNumber = values.get(fixed)!; + return changed; +} + +/** + * Clamps the range slider action values so start never crosses end. + */ +export function clampRangeAction( + action: RangeAction | undefined, + inputStart: HTMLInputElement | null, +): boolean { + if (!needsRangeClamping(action, inputStart) || !action) return false; + const {target, fixed} = action; + target.valueAsNumber = fixed.valueAsNumber; + return true; +}