diff --git a/packages/common/core/src/slider/slider.api.ts b/packages/common/core/src/slider/slider.api.ts index 1634b02f..619e7577 100644 --- a/packages/common/core/src/slider/slider.api.ts +++ b/packages/common/core/src/slider/slider.api.ts @@ -21,7 +21,7 @@ import type { } from "@qualcomm-ui/utils/machine" import {getPercentValue, getValuePercent} from "@qualcomm-ui/utils/number" -import {getFirstThumbEl} from "./slider.dom" +import {domIds, getFirstThumbEl} from "./slider.dom" import { getControlStyle, getMarkerGroupStyle, @@ -30,7 +30,24 @@ import { getRootStyle, getThumbStyle, } from "./slider.style" -import type {SliderApi, SliderSchema} from "./slider.types" +import type { + SliderApi, + SliderControlBindings, + SliderErrorTextBindings, + SliderHiddenInputBindings, + SliderHintBindings, + SliderMarkerBindings, + SliderMarkerGroupBindings, + SliderMaxMarkerBindings, + SliderMinMarkerBindings, + SliderRangeBindings, + SliderRootBindings, + SliderSchema, + SliderThumbBindings, + SliderThumbIndicatorBindings, + SliderTrackBindings, + SliderValueTextBindings, +} from "./slider.types" import {getRangeAtIndex} from "./slider.utils" export function createSliderApi( @@ -65,8 +82,8 @@ export function createSliderApi( } function getDefaultMarks(count = 11): number[] { - const max = prop("max")! - const min = prop("min")! + const max = prop("max") + const min = prop("min") if (max <= min) { return [min] @@ -114,8 +131,8 @@ export function createSliderApi( increment(index) { send({index, type: "INCREMENT"}) }, - max: prop("max")!, - min: prop("min")!, + max: prop("max"), + min: prop("min"), setThumbPercent(index, percent) { const value = getPercentValueFn(percent) send({index, type: "SET_VALUE", value}) @@ -130,7 +147,7 @@ export function createSliderApi( // group: bindings - getControlBindings(props) { + getControlBindings(props): SliderControlBindings { scope.ids.register("control", props) return normalize.element({ ...commonProps, @@ -142,8 +159,8 @@ export function createSliderApi( "data-part": "control", "data-readonly": booleanDataAttr(readOnly), id: props.id, - max: prop("max")!, - min: prop("min")!, + max: prop("max"), + min: prop("min"), onPointerDown(event) { if (!interactive) { return @@ -164,19 +181,7 @@ export function createSliderApi( style: getControlStyle(), }) }, - getDraggingIndicatorBindings({index = 0}) { - const isDragging = index === focusedIndex && dragging - return normalize.element({ - ...commonProps, - "data-orientation": prop("orientation"), - "data-part": "dragging-indicator", - "data-state": isDragging ? "open" : "closed", - hidden: !isDragging, - role: "presentation", - style: getThumbStyle(store, index), - }) - }, - getErrorTextBindings(props) { + getErrorTextBindings(props): SliderErrorTextBindings { scope.ids.register("errorText", props) return normalize.label({ ...commonProps, @@ -189,7 +194,12 @@ export function createSliderApi( id: props.id, }) }, - getHiddenInputBindings({id, index = 0, name, onDestroy}) { + getHiddenInputBindings({ + id, + index = 0, + name, + onDestroy, + }): SliderHiddenInputBindings { scope.ids .collection("hiddenInput") .register(index.toString(), id, onDestroy) @@ -222,7 +232,7 @@ export function createSliderApi( type: "text", }) }, - getHintBindings(props) { + getHintBindings(props): SliderHintBindings { scope.ids.register("hint", props) return normalize.label({ ...commonProps, @@ -263,7 +273,7 @@ export function createSliderApi( }, }) }, - getMarkerBindings({id, onDestroy, value}) { + getMarkerBindings({id, onDestroy, value}): SliderMarkerBindings { scope.ids.collection("marker").register(value.toString(), id, onDestroy) const style = getMarkerStyle(store, value) @@ -290,7 +300,7 @@ export function createSliderApi( style, }) }, - getMarkerGroupBindings(props) { + getMarkerGroupBindings(props): SliderMarkerGroupBindings { scope.ids.register("markerGroup", props) return normalize.element({ ...commonProps, @@ -302,7 +312,7 @@ export function createSliderApi( style: getMarkerGroupStyle(), }) }, - getMaxMarkerBindings(props) { + getMaxMarkerBindings(props): SliderMaxMarkerBindings { scope.ids.register("maxMarker", props) return normalize.element({ ...commonProps, @@ -310,12 +320,12 @@ export function createSliderApi( "data-orientation": prop("orientation"), "data-part": "max", "data-readonly": booleanDataAttr(readOnly), - "data-value": prop("max")!, + "data-value": prop("max"), id: props.id, role: "presentation", }) }, - getMinMarkerBindings(props) { + getMinMarkerBindings(props): SliderMinMarkerBindings { scope.ids.register("minMarker", props) return normalize.element({ ...commonProps, @@ -323,12 +333,12 @@ export function createSliderApi( "data-orientation": prop("orientation"), "data-part": "min", "data-readonly": booleanDataAttr(readOnly), - "data-value": prop("min")!, + "data-value": prop("min"), id: props.id, role: "presentation", }) }, - getRangeBindings(props) { + getRangeBindings(props): SliderRangeBindings { scope.ids.register("range", props) return normalize.element({ ...commonProps, @@ -343,7 +353,7 @@ export function createSliderApi( style: getRangeStyle(store), }) }, - getRootBindings(props) { + getRootBindings(props): SliderRootBindings { scope.ids.register("root", props) return normalize.element({ ...commonProps, @@ -358,7 +368,7 @@ export function createSliderApi( style: getRootStyle(store), }) }, - getThumbBindings({id, index = 0, name, onDestroy}) { + getThumbBindings({id, index = 0, name, onDestroy}): SliderThumbBindings { scope.ids.collection("thumb").register(index.toString(), id, onDestroy) const value = sliderValue[index] @@ -369,9 +379,10 @@ export function createSliderApi( return normalize.element({ ...commonProps, + "aria-describedby": domIds.hint(scope), "aria-disabled": booleanAriaAttr(disabled), "aria-label": _ariaLabel, - "aria-labelledby": _ariaLabelledBy ?? scope.ids.get("label"), + "aria-labelledby": _ariaLabelledBy ?? domIds.label(scope), "aria-orientation": prop("orientation"), "aria-valuemax": range.max, "aria-valuemin": range.min, @@ -407,7 +418,7 @@ export function createSliderApi( return } - const step = getEventStep(event) * prop("step")! + const step = getEventStep(event) * prop("step") const keyMap: EventKeyMap = { ArrowDown() { @@ -467,7 +478,19 @@ export function createSliderApi( if (!isLeftClick(event)) { return } - send({index, type: "THUMB_POINTER_DOWN"}) + + const thumbEl = event.currentTarget as HTMLElement + const rect = thumbEl.getBoundingClientRect() + const midpoint = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + } + const offset = { + x: event.clientX - midpoint.x, + y: event.clientY - midpoint.y, + } + + send({index, offset, type: "THUMB_POINTER_DOWN"}) event.stopPropagation() }, role: "slider", @@ -475,7 +498,23 @@ export function createSliderApi( tabIndex: disabled ? undefined : 0, }) }, - getTrackBindings(props) { + getThumbIndicatorBindings({ + id, + index = 0, + onDestroy, + }): SliderThumbIndicatorBindings { + scope.ids + .collection("thumbIndicator") + .register(index.toString(), id, onDestroy) + return normalize.element({ + ...commonProps, + "data-orientation": prop("orientation"), + "data-part": "thumb-indicator", + role: "presentation", + style: getThumbStyle(store, index), + }) + }, + getTrackBindings(props): SliderTrackBindings { scope.ids.register("track", props) return normalize.element({ ...commonProps, @@ -490,7 +529,7 @@ export function createSliderApi( style: {position: "relative"}, }) }, - getValueTextBindings(props) { + getValueTextBindings(props): SliderValueTextBindings { scope.ids.register("valueText", props) return normalize.element({ ...commonProps, diff --git a/packages/common/core/src/slider/slider.dom.ts b/packages/common/core/src/slider/slider.dom.ts index 0bafb766..45f50231 100644 --- a/packages/common/core/src/slider/slider.dom.ts +++ b/packages/common/core/src/slider/slider.dom.ts @@ -35,6 +35,8 @@ export const domIds: ScopeDomIds = { range: (scope) => scope.ids.get("range"), root: (scope) => scope.ids.get("root"), thumb: (scope, key) => scope.ids.collection("thumb").get(key), + thumbIndicator: (scope, key) => + scope.ids.collection("thumbIndicator").get(key), track: (scope) => scope.ids.get("track"), valueText: (scope) => scope.ids.get("valueText"), } @@ -52,6 +54,8 @@ export const domEls: ScopeDomElements = { range: (scope) => scope.getById(domIds.range(scope)), root: (scope) => scope.getById(domIds.root(scope)), thumb: (scope, key) => scope.getById(domIds.thumb(scope, key)!), + thumbIndicator: (scope, key) => + scope.getById(domIds.thumbIndicator(scope, key)!), track: (scope) => scope.getById(domIds.track(scope)), valueText: (scope) => scope.getById(domIds.valueText(scope)), } @@ -77,7 +81,7 @@ export const getPointValue = ( inverted: {y: true}, orientation: prop("orientation"), }) - return getPercentValue(percent, prop("min")!, prop("max")!, prop("step")!) + return getPercentValue(percent, prop("min"), prop("max"), prop("step")) } export const dispatchChangeEvent = (scope: Scope, value: number[]): void => { diff --git a/packages/common/core/src/slider/slider.machine.ts b/packages/common/core/src/slider/slider.machine.ts index 911f036f..62355759 100644 --- a/packages/common/core/src/slider/slider.machine.ts +++ b/packages/common/core/src/slider/slider.machine.ts @@ -41,6 +41,9 @@ export const sliderMachine: MachineConfig = clearFocusedIndex({context}) { context.set("focusedIndex", -1) }, + clearThumbDragOffset({refs}) { + refs.set("thumbDragOffset", null) + }, decrementThumbAtIndex(params) { const {context, event} = params const {index, step} = event as {index?: number; step?: number} @@ -120,6 +123,13 @@ export const sliderMachine: MachineConfig = ) }) }, + setThumbDragOffset(params) { + const {event, refs} = params + if (!("offset" in event)) { + return + } + refs.set("thumbDragOffset", event.offset ?? null) + }, setValue(params) { const {context, event} = params if (!("value" in event)) { @@ -229,22 +239,29 @@ export const sliderMachine: MachineConfig = return } - return trackElementRect(getThumbEls(scope), { - box: "border-box", - measure(el) { - return getOffsetRect(el) - }, - onEntry({rects}) { - if (rects.length === 0) { - return - } - const size = pick(rects[0], ["width", "height"]) - if (isEqualSize(context.get("thumbSize"), size)) { - return - } - context.set("thumbSize", size) - }, + let cleanup: VoidFunction | undefined + const rafId = requestAnimationFrame(() => { + cleanup = trackElementRect(getThumbEls(scope), { + box: "border-box", + measure(el) { + return getOffsetRect(el) + }, + onEntry({rects}) { + if (rects.length === 0) { + return + } + const size = pick(rects[0], ["width", "height"]) + if (isEqualSize(context.get("thumbSize"), size)) { + return + } + context.set("thumbSize", size) + }, + }) }) + return () => { + cancelAnimationFrame(rafId) + cleanup?.() + } }, }, @@ -266,6 +283,7 @@ export const sliderMachine: MachineConfig = range: bindableId(), root: bindableId(), thumb: bindableIdCollection(), + thumbIndicator: bindableIdCollection(), track: bindableId(), valueText: bindableId(), } @@ -324,6 +342,12 @@ export const sliderMachine: MachineConfig = } }, + refs: () => { + return { + thumbDragOffset: null, + } + }, + states: { dragging: { effects: ["trackPointerMove"], @@ -333,7 +357,7 @@ export const sliderMachine: MachineConfig = actions: ["setPointerValue"], }, POINTER_UP: { - actions: ["invokeOnChangeEnd"], + actions: ["invokeOnChangeEnd", "clearThumbDragOffset"], target: "focus", }, }, @@ -367,7 +391,11 @@ export const sliderMachine: MachineConfig = target: "dragging", }, THUMB_POINTER_DOWN: { - actions: ["setFocusedIndex", "focusActiveThumb"], + actions: [ + "setFocusedIndex", + "setThumbDragOffset", + "focusActiveThumb", + ], target: "dragging", }, }, @@ -388,7 +416,11 @@ export const sliderMachine: MachineConfig = target: "dragging", }, THUMB_POINTER_DOWN: { - actions: ["setFocusedIndex", "focusActiveThumb"], + actions: [ + "setFocusedIndex", + "setThumbDragOffset", + "focusActiveThumb", + ], target: "dragging", }, }, diff --git a/packages/common/core/src/slider/slider.props.ts b/packages/common/core/src/slider/slider.props.ts index 663a0bac..593abffa 100644 --- a/packages/common/core/src/slider/slider.props.ts +++ b/packages/common/core/src/slider/slider.props.ts @@ -17,6 +17,7 @@ export const sliderProps: (keyof SliderApiProps)[] = "disabled", "form", "getAriaValueText", + "getRootNode", "invalid", "max", "min", diff --git a/packages/common/core/src/slider/slider.style.ts b/packages/common/core/src/slider/slider.style.ts index 188947ed..b136c888 100644 --- a/packages/common/core/src/slider/slider.style.ts +++ b/packages/common/core/src/slider/slider.style.ts @@ -78,7 +78,7 @@ function getVerticalThumbOffset( const {context, prop} = params const {height = 0} = context.get("thumbSize") ?? {} const getValue = getValueTransformer( - [prop("min")!, prop("max")!], + [prop("min"), prop("max")], [-height / 2, height / 2], ) return parseFloat(getValue(value).toFixed(2)) @@ -95,14 +95,14 @@ function getHorizontalThumbOffset( if (isRtl) { const getValue = getValueTransformer( - [prop("max")!, prop("min")!], + [prop("max"), prop("min")], [-width / 2, width / 2], ) return -1 * parseFloat(getValue(value).toFixed(2)) } const getValue = getValueTransformer( - [prop("min")!, prop("max")!], + [prop("min"), prop("max")], [-width / 2, width / 2], ) return parseFloat(getValue(value).toFixed(2)) @@ -128,7 +128,7 @@ export function getThumbOffset( value: number, ): string { const {prop} = params - const percent = getValuePercent(value, prop("min")!, prop("max")!) * 100 + const percent = getValuePercent(value, prop("min"), prop("max")) * 100 return getOffset(params, percent, value) } @@ -182,15 +182,7 @@ export function getRootStyle( const range = getRangeOffsets(params) const thumbSize = context.get("thumbSize") - const offsetStyles = context - .get("value") - .reduce((styles, value, index) => { - const offset = getThumbOffset(params, value) - return {...styles, [`--slider-thumb-offset-${index}`]: offset} - }, {}) - - return { - ...offsetStyles, + const styles: JSX.CSSProperties = { "--slider-range-end": range.end, "--slider-range-start": range.start, "--slider-thumb-height": toPx(thumbSize?.height), @@ -201,6 +193,13 @@ export function getRootStyle( : "translateX(-50%)", "--slider-thumb-width": toPx(thumbSize?.width), } + + const values = context.get("value") + for (let i = 0; i < values.length; i++) { + styles[`--slider-thumb-offset-${i}`] = getThumbOffset(params, values[i]) + } + + return styles } /** Marker style calculations */ diff --git a/packages/common/core/src/slider/slider.types.ts b/packages/common/core/src/slider/slider.types.ts index 5cfe56db..25332ec3 100644 --- a/packages/common/core/src/slider/slider.types.ts +++ b/packages/common/core/src/slider/slider.types.ts @@ -13,6 +13,7 @@ import type {DirectionProperty} from "@qualcomm-ui/utils/direction" import type {RequiredBy} from "@qualcomm-ui/utils/guard" import type { ActionSchema, + CommonProperties, EffectSchema, GuardSchema, IdRegistrationProps, @@ -32,6 +33,7 @@ export interface SliderElementIds { range: string root: string thumb: string[] + thumbIndicator: string[] track: string valueText: string } @@ -50,7 +52,7 @@ export interface ValueTextDetails { value: number } -export interface SliderApiProps extends DirectionProperty { +export interface SliderApiProps extends DirectionProperty, CommonProperties { /** * The aria-label of each slider thumb. Useful for providing an accessible name to * the slider @@ -171,6 +173,7 @@ export interface Size { type Actions = ActionSchema< | "clearFocusedIndex" + | "clearThumbDragOffset" | "decrementThumbAtIndex" | "dispatchChangeEvent" | "focusActiveThumb" @@ -181,6 +184,7 @@ type Actions = ActionSchema< | "setFocusedThumbToMax" | "setFocusedThumbToMin" | "setPointerValue" + | "setThumbDragOffset" | "setValue" | "setValueAtIndex" | "syncInputElements" @@ -206,7 +210,7 @@ type Events = | {type: "POINTER_UP"} | {point: Point; type: "POINTER_MOVE"} | {index: number; type: "FOCUS"} - | {index: number; type: "THUMB_POINTER_DOWN"} + | {index: number; offset?: {x: number; y: number}; type: "THUMB_POINTER_DOWN"} | {index?: number; src: ArrowKeys; step: number; type: "ARROW_DEC"} | {index?: number; src: ArrowKeys; step: number; type: "ARROW_INC"} | {type: "HOME"} @@ -246,6 +250,10 @@ interface Computed { valuePercent: number[] } +interface Refs { + thumbDragOffset: {x: number; y: number} | null +} + export interface SliderSchema { actions: Actions computed: Computed @@ -266,6 +274,7 @@ export interface SliderSchema { | "step" | "thumbAlignment" > + refs: Refs } export interface ThumbProps { @@ -299,10 +308,9 @@ export interface SliderControlBindings extends CommonBindings { style: JSX.CSSProperties } -export interface SliderDraggingIndicatorBindings extends CommonBindings { - "data-part": "dragging-indicator" - "data-state": "open" | "closed" - hidden: boolean +export interface SliderThumbIndicatorBindings extends CommonBindings { + "data-orientation": Orientation | undefined + "data-part": "thumb-indicator" role: "presentation" style: JSX.CSSProperties } @@ -391,6 +399,7 @@ export interface SliderRootBindings extends CommonBindings { } export interface SliderThumbBindings extends CommonBindings { + "aria-describedby": string | undefined "aria-disabled": BooleanAriaAttr "aria-label": string | undefined "aria-labelledby": string | undefined @@ -457,19 +466,27 @@ export interface SliderMaxMarkerBindings extends CommonBindings { } export interface SliderApi { - decrement(index: number): void decrement(index: number): void dragging: boolean focus(): void focused: boolean focusedIndex: number - getControlBindings(props: IdRegistrationProps): SliderControlBindings getDefaultMarks: (count?: number) => number[] - getDraggingIndicatorBindings({ - index, - }: { - index: number - }): SliderDraggingIndicatorBindings + getPercentValue: (percent: number) => number + getThumbMax(index: number): number + getThumbMin(index: number): number + getThumbPercent(index: number): number + getThumbValue(index: number): number + getValuePercent: (value: number) => number + increment(index: number): void + max: number + min: number + setThumbPercent(index: number, percent: number): void + setThumbValue(index: number, value: number): void + setValue(value: number[]): void + value: number[] + // group: bindings + getControlBindings(props: IdRegistrationProps): SliderControlBindings getErrorTextBindings(props: IdRegistrationProps): SliderErrorTextBindings getHiddenInputBindings( props: ThumbProps & IdRegistrationProps, @@ -482,22 +499,12 @@ export interface SliderApi { getMarkerGroupBindings(props: IdRegistrationProps): SliderMarkerGroupBindings getMaxMarkerBindings(props: IdRegistrationProps): SliderMaxMarkerBindings getMinMarkerBindings(props: IdRegistrationProps): SliderMinMarkerBindings - getPercentValue: (percent: number) => number getRangeBindings(props: IdRegistrationProps): SliderRangeBindings getRootBindings(props: IdRegistrationProps): SliderRootBindings getThumbBindings(props: ThumbProps & IdRegistrationProps): SliderThumbBindings - getThumbMax(index: number): number - getThumbMin(index: number): number - getThumbPercent(index: number): number - getThumbValue(index: number): number + getThumbIndicatorBindings( + props: {index: number} & IdRegistrationProps, + ): SliderThumbIndicatorBindings getTrackBindings(props: IdRegistrationProps): SliderTrackBindings - getValuePercent: (value: number) => number getValueTextBindings(props: IdRegistrationProps): SliderValueTextBindings - increment(index: number): void - max: number - min: number - setThumbPercent(index: number, percent: number): void - setThumbValue(index: number, value: number): void - setValue(value: number[]): void - value: number[] } diff --git a/packages/common/core/src/slider/slider.utils.ts b/packages/common/core/src/slider/slider.utils.ts index 55cbf0ba..06aa490e 100644 --- a/packages/common/core/src/slider/slider.utils.ts +++ b/packages/common/core/src/slider/slider.utils.ts @@ -57,8 +57,8 @@ export function getRangeAtIndex( min: number } { const {context, prop} = params - const step = prop("step")! * prop("minStepsBetweenThumbs")! - return getValueRanges(context.get("value"), prop("min")!, prop("max")!, step)[ + const step = prop("step") * prop("minStepsBetweenThumbs") + return getValueRanges(context.get("value"), prop("min"), prop("max"), step)[ index ] } @@ -74,7 +74,7 @@ export function constrainValue( value, prop("min"), prop("max"), - prop("step")!, + prop("step"), ) return clampValue(snapValue, range.min, range.max) } @@ -89,7 +89,7 @@ export function decrement( const range = getRangeAtIndex(params, idx) const nextValues = getPreviousStepValue(idx, { ...range, - step: step ?? prop("step")!, + step: step ?? prop("step"), values: context.get("value"), }) nextValues[idx] = clampValue(nextValues[idx], range.min, range.max) @@ -106,7 +106,7 @@ export function increment( const range = getRangeAtIndex(params, idx) const nextValues = getNextStepValue(idx, { ...range, - step: step ?? prop("step")!, + step: step ?? prop("step"), values: context.get("value"), }) nextValues[idx] = clampValue(nextValues[idx], range.min, range.max) @@ -120,10 +120,3 @@ export function getClosestIndex( const {context} = params return getClosestValueIndex(context.get("value"), pointValue) } - -export function assignArray(current: number[], next: number[]): void { - for (let i = 0; i < next.length; i++) { - const value = next[i] - current[i] = value - } -} diff --git a/packages/common/qds-core/src/slider/qds-slider.css b/packages/common/qds-core/src/slider/qds-slider.css index 396c72b6..23e33a81 100644 --- a/packages/common/qds-core/src/slider/qds-slider.css +++ b/packages/common/qds-core/src/slider/qds-slider.css @@ -1,11 +1,11 @@ .qui-slider__root { - --slider-track-thickness: 4px; + --slider-track-thickness: var(--sizing-30); &[data-size="sm"] { - --slider-thumb-size: 8px; + --slider-thumb-size: var(--sizing-50); } &[data-size="md"] { - --slider-thumb-size: 16px; + --slider-thumb-size: var(--sizing-70); } --slider-label-color: var(--color-text-neutral-secondary); @@ -160,44 +160,43 @@ } } -.qui-slider__control:has(.qui-slider__thumb[data-dragging]) - .qui-slider__thumb:not([data-dragging]) { - background-color: var(--slider-thumb-color); - scale: 1; -} - .qui-slider__thumb { - &:not([data-disabled]):is(:hover, :active) { - scale: 1.15; - } - &:not([data-disabled]):hover { - background-color: var(--slider-thumb-color-hover); - } - &:not([data-disabled]):active { - background-color: var(--slider-thumb-color-active); - } + aspect-ratio: 1; + width: var(--slider-thumb-size); + &[data-focus], &[data-dragging] { z-index: 1; } - aspect-ratio: 1; - background-color: var(--slider-thumb-color); - border-radius: 9999px; - outline: 2px solid var(--color-border-neutral-00); - transition: - scale 130ms ease-in-out, - background-color 130ms ease-in-out; - width: var(--slider-thumb-size); - - /* TODO :focus-visible sometimes shows the ring, sometimes not, switch to just :focus? */ - &:focus-visible { + /* the actual thumb (to avoid scaling children) */ + &::before { + background-color: var(--slider-thumb-color); + border-radius: var(--border-radius-rounded); + content: ""; + inset: 0; + outline: var(--sizing-20) solid var(--color-border-neutral-00); + position: absolute; + transition: + scale 130ms ease-in-out, + background-color 130ms ease-in-out; + } + &:focus-visible::before { box-shadow: 0 0 0 4px var(--color-utility-focus-border); } + &:not([data-disabled]):is(:hover, :active)::before { + scale: 1.15; + } + &:not([data-disabled]):hover::before { + background-color: var(--slider-thumb-color-hover); + } + &:not([data-disabled]):active::before { + background-color: var(--slider-thumb-color-active); + } /* hitbox */ &::after { - border-radius: 9999px; + border-radius: var(--border-radius-rounded); bottom: -8px; content: ""; left: -8px; @@ -209,14 +208,13 @@ .qui-slider__track { background-color: var(--slider-track-color); - border-radius: 9999px; + border-radius: var(--border-radius-rounded); overflow: hidden; &[data-orientation="horizontal"] { height: var(--slider-track-thickness); width: 100%; } - &[data-orientation="vertical"] { height: 100%; width: var(--slider-track-thickness); @@ -230,7 +228,6 @@ &[data-orientation="horizontal"] { height: 100%; } - &[data-orientation="vertical"] { width: 100%; } @@ -273,3 +270,65 @@ grid-row: error; margin-block-start: var(--row-gap); } + +.qui-slider__thumb-indicator { + background-color: var(--color-background-neutral-10); + border-radius: var(--border-radius-xs); + box-shadow: var(--shadow-high); + color: var(--color-text-neutral-inverse); + font: var(--font-static-body-xs-default); + width: max-content; + padding-block: var(--spacing-40); + padding-inline: var(--spacing-70); + pointer-events: none; + bottom: calc(100% + 12px); + left: 50%; + + opacity: 0; + display: none; + transition: + opacity 130ms ease-in-out, + display 130ms ease-in-out allow-discrete; + + .qui-slider__thumb:not([data-disabled]):is( + :hover, + :active, + :focus-visible, + [data-dragging] + ) + & { + opacity: 1; + display: block; + } + + @starting-style { + .qui-slider__thumb:not([data-disabled]):is( + :hover, + :active, + :focus-visible, + [data-dragging] + ) + & { + opacity: 0; + } + } + + /* arrow */ + &::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + width: 8px; + height: 8px; + background-color: var(--color-background-neutral-10); + transform: translateX(-50%) translateY(-50%) rotate(45deg); + } +} + +/* Angular extra markup */ + +q-slider-markers, +q-slider-thumbs { + display: contents; +} diff --git a/packages/common/qds-core/src/slider/slider.api.ts b/packages/common/qds-core/src/slider/slider.api.ts index 51825a47..1e6df6f6 100644 --- a/packages/common/qds-core/src/slider/slider.api.ts +++ b/packages/common/qds-core/src/slider/slider.api.ts @@ -18,6 +18,7 @@ import type { QdsSliderRangeBindings, QdsSliderRootBindings, QdsSliderThumbBindings, + QdsSliderThumbIndicatorBindings, QdsSliderTrackBindings, QdsSliderValueTextBindings, } from "./slider.types" @@ -84,6 +85,11 @@ export function createQdsSliderApi( className: sliderClasses.thumb, }) }, + getThumbIndicatorBindings(): QdsSliderThumbIndicatorBindings { + return normalize.element({ + className: sliderClasses.thumbIndicator, + }) + }, getTrackBindings(): QdsSliderTrackBindings { return normalize.element({ className: sliderClasses.track, diff --git a/packages/common/qds-core/src/slider/slider.classes.ts b/packages/common/qds-core/src/slider/slider.classes.ts index 3fb63787..2a791348 100644 --- a/packages/common/qds-core/src/slider/slider.classes.ts +++ b/packages/common/qds-core/src/slider/slider.classes.ts @@ -13,6 +13,7 @@ export const sliderClasses = { range: "qui-slider__range", root: "qui-slider__root", thumb: "qui-slider__thumb", + thumbIndicator: "qui-slider__thumb-indicator", track: "qui-slider__track", valueText: "qui-slider__value-text", } as const diff --git a/packages/common/qds-core/src/slider/slider.types.ts b/packages/common/qds-core/src/slider/slider.types.ts index 53c3f653..3e083e29 100644 --- a/packages/common/qds-core/src/slider/slider.types.ts +++ b/packages/common/qds-core/src/slider/slider.types.ts @@ -24,6 +24,9 @@ type SliderClasses = typeof sliderClasses export interface QdsSliderControlBindings { className: SliderClasses["control"] } +export interface QdsSliderThumbIndicatorBindings { + className: SliderClasses["thumbIndicator"] +} export interface QdsSliderErrorTextBindings { className: SliderClasses["errorText"] } @@ -75,6 +78,7 @@ export interface QdsSliderApi { getRangeBindings(): QdsSliderRangeBindings getRootBindings(): QdsSliderRootBindings getThumbBindings(): QdsSliderThumbBindings + getThumbIndicatorBindings(): QdsSliderThumbIndicatorBindings getTrackBindings(): QdsSliderTrackBindings getValueTextBindings(): QdsSliderValueTextBindings } diff --git a/packages/debug-apps/angular-ssr/src/app/components/slider.ts b/packages/debug-apps/angular-ssr/src/app/components/slider.ts index 4d59c659..a767aebf 100644 --- a/packages/debug-apps/angular-ssr/src/app/components/slider.ts +++ b/packages/debug-apps/angular-ssr/src/app/components/slider.ts @@ -17,9 +17,123 @@ import {Component} from "@angular/core" } `, template: ` -
-
- Slider component demos are not yet available in the Angular docs. +
+
+

Simple

+
+ +
+
+
+

Variant

+
+ +
+
+
+

Size

+
+ +
+
+
+

Hint

+
+ +
+
+
+

Disabled

+
+ +
+
+
+

Range

+
+ +
+
+
+

Min Max Step

+
+ +
+
+
+

Min Steps

+
+ +
+
+
+

Origin

+
+ +
+
+
+

Markers

+
+ +
+
+
+

Side Markers

+
+ +
+
+
+

Display

+
+ +
+
+
+

Tooltip

+
+ +
+
+
+

Composite

+
+ +
+
+
+

Template Forms

+
+ +
+
+
+

Template Form State

+
+ +
+
+
+

Reactive Form States

+
+ +
+
+
+

Value Callback

+
+ +
+
+
+

Focus Callback

+
+ +
`, diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/_slider.mdx b/packages/docs/angular-docs/src/routes/components+/slider+/_slider.mdx new file mode 100644 index 00000000..baeecc2e --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/_slider.mdx @@ -0,0 +1,373 @@ +--- +title: Slider +group: Form Controls +--- + +import { + TypeDocAngularAttributes, + TypeDocProps, +} from "@qualcomm-ui/react-mdx/typedoc" +import {QdsDemo} from "~components/demo" + +# {frontmatter.title} + +```typescript +import {SliderModule} from "@qualcomm-ui/angular/slider" +``` + +## Usage + +The slider component lets users select a single value or a range of values by moving one or two thumbs along a track. Sliders are ideal for adjusting settings such as volume, brightness, or selecting a numeric range. + +## Examples + +### Simple + +Use the simple API to create a slider using minimal markup. + + + +### Composite + +Build with the composite API for granular control. This API requires you to provide each subcomponent, but gives you full control over the structure and layout. + + + +### Min/Max/Step + +Use [min](./#min), [max](./#max), and [step](./#step) to control the slider's value range and increments: + +- `min`: The minimum value of the slider (default is 0). +- `max`: The maximum value of the slider (default is 100). +- `step`: The increment/decrement step value (default is 1). + + + +### Origin + +Use [origin](./#origin) to control where the track is filled from for single-value sliders: + +- `start`: Fills from the start of the track to the thumb. Useful when the value represents an absolute value (default). +- `center`: Fills from the center of the track to the thumb. Useful when the value represents an offset or relative value. +- `end`: Fills from the end of the track to the thumb. Useful when the value represents an offset from the end. + + + +### Tooltip + +Use [tooltip](./#tooltip-1) to display the slider's value in a tooltip rather than above the component. + + + +#### Display + +You can customize the tooltip's content by using the [composite API](./#thumbs-with-tooltips) and passing a function to [display](./#display-5) on the `q-slider-thumb-indicator` component. + + + +### Markers + +#### Track Markers + +By default, the component generates 11 default marks based on the slider's [min](./#min) and [max](./#max) values. +You can provide your own set using [marks](./#marks). + + + +#### Side Markers + +For more compact designs, you can display the `min` and `max` markers at the ends of the track using [sideMarkers](./#sideMarkers). + + + +### Range + +Set [value](./#value) or [defaultValue](./#defaultValue) with two values to create a range slider. + + + +#### Minimum Steps Between Thumbs + +To prevent overlapping thumbs, use [minStepsBetweenThumbs](./#minStepsBetweenThumbs) to set a minimum distance between them. + + + +#### Display + +By default, range values are displayed separated by an em dash (—). You can customize this by passing a separator string or a function to [display](./#display-1). + + + +### Size + +Set [size](./#size-1) to adjust the size of the thumbs. Available sizes are `sm` (small) and `md` (medium, default). + + + +### Variant + +Set [variant](./#variant-1) to adjust the visual style of the slider. Available variants are `neutral` and `primary` (default). + + + +### Hint + +Use [hint](./#hint-1) to add additional guidance or context to the user below the slider. + + + +### Disabled + +Use [disabled](./#disabled-1) to prevent user interaction. + + + +### Focus Callback + +The [focusChanged](./#focusChanged) output allows you to listen for focus events on the slider's thumbs. + + + +### Value Callbacks + +Use [valueChanged](./#valueChanged) or [valueChangedEnd](./#valueChangedEnd) to monitor the value of the slider. + + + +## Forms + +### Template Forms + +#### States + +When using template forms, the [disabled](./#disabled), [readOnly](./#readOnly), and [invalid](./#invalid) properties govern the interactive state of the control. + + + +#### Validation + +This example shows a range slider where values must be at least 30 units apart. + + + +### Reactive Forms + +#### State Guidelines + +The [disabled](./#disabled) and [invalid](./#invalid) properties have no effect when using Reactive Forms. Use the equivalent Reactive Form bindings instead: + + + +## Composite API and Shortcuts + +The Slider component provides a composite API should you need more control over the slider's behavior and appearance. + +### Anatomy + +The composite API is based on the following components and directives: + +- `q-slider-root`: The main component that wraps the slider subcomponents. +- `q-slider-label`: The slider main label. +- `q-slider-valueText`: The slider current value as text. +- `q-slider-control`: The container for the thumb(s) and its track and range. +- `q-slider-track`: The track along which the thumb(s) move. +- `q-slider-range`: The filled portion of the track. +- `q-slider-thumbs` component or `q-slider-thumb + q-slider-hiddenInput`: The draggable handle(s). +- `q-slider-thumb-indicator`: The thumb tooltip. +- `q-slider-hint`: The hint text below the slider. +- `q-slider-errorText`: The error message below the slider. +- `q-slider-markers` component or `q-slider.MarkerGroup + q-slider.Marker`: The markers along the track. + +### Thumbs + +If you need more control over the thumbs, you can use the `q-slider-thumb` directive instead of the `q-slider-thumbs` shortcut component. +Note that `q-slider-thumbs` automatically creates a range slider when the slider's `value` or `defaultValue` is set accordingly. + +```angular-ts +import {Component} from "@angular/core" +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "range-slider", + template: ` +
+
+
+
+
+
+
+ +
+
+ +
+
+
+ `, +}) +export class RangeSlider {} +``` + +### Thumbs with tooltips + +Setting [tooltip](./#tooltip-1) on the `q-slider-thumbs` or `q-slider-thumb` component is the equivalent of the following composition: + +```angular-ts +import {Component} from "@angular/core" +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-with-tooltip", + template: ` +
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ `, +}) +export class SliderWithTooltip {} +``` + +### Markers + +The `q-slider-markers` component is a convenient shortcut to display custom track markers. But you can also create your own markers using the `q-slider-marker-group` and `q-slider-marker` directives. + +```angular-ts +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-with-custom-markers", + template: ` +
+
+
+
+
+
+ +
+
+ 0 + 25 + 50 + 75 + 100 +
+
+ `, +}) +export class SliderWithCustomMarkers {} +``` + +## API + +### q-slider + +The `q-slider` component extends the [q-slider-root](./#q-slider-root) directive with the following props: + + + +## Composite API + +### q-slider-root + + + + + +### q-slider-label + + + + + +### q-slider-value-text + + + + + +### q-slider-control + + + + + +### q-slider-track + + + + + +### q-slider-range + + + + + +### q-slider-thumbs shortcut + + + +### q-slider-thumb + + + + + +### q-slider-thumb-indicator + + + + + +### q-slider-hidden-input + + + + + +### q-slider-markers shortcut + + + +### q-slider-marker-group + + + + + +### q-slider-marker + + + + + +### q-slider-hint + + + + + +### q-slider-error-text + + + + diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-composite-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-composite-demo.ts new file mode 100644 index 00000000..6caf4752 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-composite-demo.ts @@ -0,0 +1,33 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-composite-demo", + template: ` + +
+ +
+
+
+
+
+
+ +
+
+
+ @for (value of markers; track value) { + {{ value }} + } +
+ Some contextual help here +
+ + `, +}) +export class SliderCompositeDemo { + markers = Array.from({length: 11}, (_, i) => i * 10) +} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-disabled-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-disabled-demo.ts new file mode 100644 index 00000000..6411c016 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-disabled-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-disabled-demo", + template: ` + + + + `, +}) +export class SliderDisabledDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-display-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-display-demo.ts new file mode 100644 index 00000000..289632bc --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-display-demo.ts @@ -0,0 +1,18 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-display-demo", + template: ` + + + + `, +}) +export class SliderDisplayDemo { + display(values: number[]): string { + return `from ${values[0]} to ${values[1]}` + } +} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-focus-callback-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-focus-callback-demo.ts new file mode 100644 index 00000000..765886b6 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-focus-callback-demo.ts @@ -0,0 +1,31 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" +import type {FocusChangeDetails} from "@qualcomm-ui/core/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-focus-callback-demo", + template: ` + +
+ + + currently focused: + {{ currentOutput }} + +
+ + `, +}) +export class SliderFocusCallbackDemo { + currentOutput = "none" + + onFocusChange(e: FocusChangeDetails) { + this.currentOutput = + e.focusedIndex === -1 ? "none" : `thumb ${e.focusedIndex}` + } +} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-hint-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-hint-demo.ts new file mode 100644 index 00000000..15c95bbc --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-hint-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-hint-demo", + template: ` + + + + `, +}) +export class SliderHintDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-markers-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-markers-demo.ts new file mode 100644 index 00000000..c55325b3 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-markers-demo.ts @@ -0,0 +1,20 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-markers-demo", + template: ` + + + + `, +}) +export class SliderMarkersDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-min-max-step-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-min-max-step-demo.ts new file mode 100644 index 00000000..18c8f1df --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-min-max-step-demo.ts @@ -0,0 +1,20 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-min-max-step-demo", + template: ` + + + + `, +}) +export class SliderMinMaxStepDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-min-steps-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-min-steps-demo.ts new file mode 100644 index 00000000..42656826 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-min-steps-demo.ts @@ -0,0 +1,18 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-min-steps-demo", + template: ` + + + + `, +}) +export class SliderMinStepsDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-origin-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-origin-demo.ts new file mode 100644 index 00000000..8464d29e --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-origin-demo.ts @@ -0,0 +1,33 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-origin-demo", + template: ` +
+ + + + + +
+ `, +}) +export class SliderOriginDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-range-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-range-demo.ts new file mode 100644 index 00000000..37d6649d --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-range-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-range-demo", + template: ` + + + + `, +}) +export class SliderRangeDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-reactive-form-states-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-reactive-form-states-demo.ts new file mode 100644 index 00000000..af6b4666 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-reactive-form-states-demo.ts @@ -0,0 +1,44 @@ +import {Component, type OnInit} from "@angular/core" +import { + type AbstractControl, + FormControl, + ReactiveFormsModule, +} from "@angular/forms" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +function minRangeValidator(control: AbstractControl) { + const [min, max] = control.value + return max - min >= 30 + ? null + : {minRange: {actualRange: max - min, requiredRange: 30}} +} + +@Component({ + imports: [SliderModule, ReactiveFormsModule], + selector: "slider-reactive-form-states-demo", + template: ` +
+ + +
+ `, +}) +export class SliderReactiveFormStatesDemo implements OnInit { + // preview + disabledField = new FormControl([30, 70]) + invalidField = new FormControl([31, 60], { + validators: [minRangeValidator], + }) + + ngOnInit() { + this.disabledField.disable() + this.invalidField.markAsDirty() + } + // preview +} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-side-markers-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-side-markers-demo.ts new file mode 100644 index 00000000..c6567d73 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-side-markers-demo.ts @@ -0,0 +1,20 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-side-markers-demo", + template: ` + + + + `, +}) +export class SliderSideMarkersDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-simple-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-simple-demo.ts new file mode 100644 index 00000000..89bfa4f7 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-simple-demo.ts @@ -0,0 +1,19 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-simple-demo", + template: ` + + + + `, +}) +export class SliderSimpleDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-size-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-size-demo.ts new file mode 100644 index 00000000..66bc06d2 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-size-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-size-demo", + template: ` + + + + `, +}) +export class SliderSizeDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-template-form-state-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-template-form-state-demo.ts new file mode 100644 index 00000000..97db01f9 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-template-form-state-demo.ts @@ -0,0 +1,19 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-template-form-state-demo", + template: ` +
+ + + + + + +
+ `, +}) +export class SliderTemplateFormStateDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-template-forms-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-template-forms-demo.ts new file mode 100644 index 00000000..01612276 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-template-forms-demo.ts @@ -0,0 +1,53 @@ +import {Component, computed, signal} from "@angular/core" +import {FormsModule, type NgForm} from "@angular/forms" + +import {ButtonModule} from "@qualcomm-ui/angular/button" +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule, FormsModule, ButtonModule], + selector: "slider-template-forms-demo", + template: ` + +
+ + + + + `, +}) +export class SliderTemplateFormsDemo { + readonly value = signal([30, 70]) + + readonly rangeDifference = computed(() => { + const [min, max] = this.value() + return max - min + }) + + readonly isRangeTooSmall = computed(() => { + return this.rangeDifference() < 30 + }) + + onSubmit(form: NgForm) { + console.table(form.value) + } +} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-tooltip-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-tooltip-demo.ts new file mode 100644 index 00000000..8eb04a35 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-tooltip-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-tooltip-demo", + template: ` + + + + `, +}) +export class SliderTooltipDemo {} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-tooltip-display-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-tooltip-display-demo.ts new file mode 100644 index 00000000..2c1f8400 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-tooltip-display-demo.ts @@ -0,0 +1,30 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-tooltip-display-demo", + template: ` +
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+ `, +}) +export class SliderTooltipDisplayDemo { + displayFromTooltip = (value: number) => `From ${value}%` + displayToTooltip = (value: number) => `To ${value}%` +} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-value-callback-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-value-callback-demo.ts new file mode 100644 index 00000000..dc0f2588 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-value-callback-demo.ts @@ -0,0 +1,40 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" +import type {ValueChangeDetails} from "@qualcomm-ui/core/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-value-callback-demo", + template: ` + +
+ + + live value: + {{ value.join(", ") }} + + + final value: + {{ finalValue.join(", ") }} + +
+ + `, +}) +export class SliderValueCallbackDemo { + value = [25, 75] + finalValue = [25, 75] + + onValueChange(details: ValueChangeDetails) { + this.value = details.value + } + + onValueChangeEnd(details: ValueChangeDetails) { + this.finalValue = details.value + } +} diff --git a/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-variant-demo.ts b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-variant-demo.ts new file mode 100644 index 00000000..474d559c --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/slider+/demos/slider-variant-demo.ts @@ -0,0 +1,14 @@ +import {Component} from "@angular/core" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +@Component({ + imports: [SliderModule], + selector: "slider-variant-demo", + template: ` + + + + `, +}) +export class SliderVariantDemo {} diff --git a/packages/docs/react-docs/src/routes/components+/slider+/_slider.mdx b/packages/docs/react-docs/src/routes/components+/slider+/_slider.mdx index 93a0db7b..fa98eeeb 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/_slider.mdx +++ b/packages/docs/react-docs/src/routes/components+/slider+/_slider.mdx @@ -59,6 +59,15 @@ Use the [tooltip](./#tooltip-1) prop to display the slider's value in a tooltip +#### Display + +You can customize the tooltip's content by using the [composite API](./#thumbs-with-tooltips) and passing a function to [display](./#display-3) on the `SliderThumbIndicator` component. + + + ### Markers #### Track Markers @@ -175,6 +184,7 @@ The composite API is based on the following components: - `Slider.Track`: The track along which the thumb(s) move. - `Slider.Range`: The filled portion of the track. - `Slider.Thumbs` or `Slider.Thumb + Slider.HiddenInput`: The draggable handle(s). +- `Slider.ThumbIndicator`: The thumb tooltip. - `Slider.Hint`: The hint text below the slider. - `Slider.ErrorText`: The error message below the slider. - `Slider.Markers` or `Slider.MarkerGroup + Slider.Marker`: The markers along the track. @@ -209,48 +219,26 @@ function RangeSlider() { ### Thumbs with tooltips -Setting the [tooltip](#./tooltip) prop on the `Slider.Thumbs` component is the equivalent of the following composition using the `Tooltip` component: +Setting the [tooltip](#./tooltip) prop on the `Slider.Thumbs` component is the equivalent of the following composition: ```tsx import {Slider} from "@qualcomm-ui/react/slider" -import {Tooltip} from "@qualcomm-ui/react/tooltip" -import {useSliderContext} from "@qualcomm-ui/react-core/slider" - -export function SliderWithTooltip() { - function ThumbValue({index}: {index: number}) { - const context = useSliderContext() - return context.getThumbValue(index) - } + +function SliderWithTooltip() { return ( - Label - - - - - } - > - - - - - - } - > - - + + + + + + + + ) @@ -267,20 +255,20 @@ import {Slider} from "@qualcomm-ui/react/slider" function SliderWithCustomMarkers() { return ( - Label - - 0 - 25 - 50 - 75 - 100 - + + + 0 + 25 + 50 + 75 + 100 + ) } @@ -342,6 +330,12 @@ The `Slider` extends the [Slider.Root](./#slider-root) with the following props: +### \ + + + + + ### \ diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/index.ts b/packages/docs/react-docs/src/routes/components+/slider+/demos/index.ts index 99d43ba3..a10f37b1 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/index.ts +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/index.ts @@ -14,6 +14,7 @@ export * from "./slider-simple-demo" export * from "./slider-size-demo" export * from "./slider-tanstack-form-demo" export * from "./slider-tooltip-demo" +export * from "./slider-tooltip-display-demo" export * from "./slider-value-callback-demo" export * from "./slider-variant-demo" export * from "./slider-vertical-demo" diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-composite-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-composite-demo.tsx index 4d52e844..c27be046 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-composite-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-composite-demo.tsx @@ -5,7 +5,7 @@ import {Slider} from "@qualcomm-ui/react/slider" export function SliderCompositeDemo(): ReactElement { return ( // preview - + Choose a value diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-disabled-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-disabled-demo.tsx index 26a90b81..621bf67e 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-disabled-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-disabled-demo.tsx @@ -5,7 +5,7 @@ import {Slider} from "@qualcomm-ui/react/slider" export function SliderDisabledDemo(): ReactElement { return ( // preview - + // preview ) } diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-display-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-display-demo.tsx index 3e3c7013..2daa531b 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-display-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-display-demo.tsx @@ -6,7 +6,7 @@ export function SliderDisplayDemo(): ReactElement { return ( // preview `from ${values[0]} to ${values[1]}`} /> diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-focus-callback-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-focus-callback-demo.tsx index e2446cb5..b698733f 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-focus-callback-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-focus-callback-demo.tsx @@ -5,7 +5,7 @@ import {Slider} from "@qualcomm-ui/react/slider" export function SliderFocusCallbackDemo(): ReactElement { const [currentOutput, setCurrentOutput] = useState("none") return ( -
+
{/* preview */} + // preview ) } diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-markers-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-markers-demo.tsx index 5bd2e28f..c87e5cbb 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-markers-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-markers-demo.tsx @@ -6,7 +6,7 @@ export function SliderMarkersDemo(): ReactElement { return ( // preview diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-origin-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-origin-demo.tsx index 7f4758c3..21e4a768 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-origin-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-origin-demo.tsx @@ -7,19 +7,19 @@ export function SliderOriginDemo(): ReactElement {
{/* preview */} + // preview ) } diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-react-hook-form-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-react-hook-form-demo.tsx index c1b9bd02..cefe14a0 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-react-hook-form-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-react-hook-form-demo.tsx @@ -39,7 +39,7 @@ export function SliderReactHookFormDemo(): ReactElement { return (
{ void handleSubmit((data) => console.log(data))(e) }} diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-side-markers-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-side-markers-demo.tsx index a0870eb7..2cb268e9 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-side-markers-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-side-markers-demo.tsx @@ -6,7 +6,7 @@ export function SliderSideMarkersDemo(): ReactElement { return ( // preview + return } diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tanstack-form-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tanstack-form-demo.tsx index d327cddc..ec93e9d8 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tanstack-form-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tanstack-form-demo.tsx @@ -30,7 +30,7 @@ export function SliderTanstackFormDemo(): ReactElement { return ( { event.preventDefault() event.stopPropagation() diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tooltip-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tooltip-demo.tsx index f2e82739..5ff9d778 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tooltip-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tooltip-demo.tsx @@ -5,7 +5,7 @@ import {Slider} from "@qualcomm-ui/react/slider" export function SliderTooltipDemo(): ReactElement { return ( // preview - + // preview ) } diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tooltip-display-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tooltip-display-demo.tsx new file mode 100644 index 00000000..c83754c9 --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-tooltip-display-demo.tsx @@ -0,0 +1,24 @@ +import type {ReactElement} from "react" + +import {Slider} from "@qualcomm-ui/react/slider" + +export function SliderTooltipDisplayDemo(): ReactElement { + return ( + + + + + + + + `From ${value}%`} /> + + + + `To ${value}%`} /> + + + + + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-value-callback-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-value-callback-demo.tsx index 0fb3b0e0..a250ba43 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-value-callback-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-value-callback-demo.tsx @@ -6,7 +6,7 @@ export function SliderValueCallbackDemo(): ReactElement { const [value, setValue] = useState([25, 75]) const [finalValue, setFinalValue] = useState(value) return ( -
+
{/* preview */} { diff --git a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-variant-demo.tsx b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-variant-demo.tsx index 8c1aaedd..cbd03b2b 100644 --- a/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-variant-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/slider+/demos/slider-variant-demo.tsx @@ -5,7 +5,7 @@ import {Slider} from "@qualcomm-ui/react/slider" export function SliderVariantDemo(): ReactElement { return ( // preview - + // preview ) } diff --git a/packages/frameworks/angular-core/src/input/abstract-list-collection-form-control.directive.ts b/packages/frameworks/angular-core/src/input/abstract-list-collection-form-control.directive.ts index c9ec9e05..0efc934e 100644 --- a/packages/frameworks/angular-core/src/input/abstract-list-collection-form-control.directive.ts +++ b/packages/frameworks/angular-core/src/input/abstract-list-collection-form-control.directive.ts @@ -40,21 +40,14 @@ import {defined, isDefined} from "@qualcomm-ui/utils/guard" import {initInputFormControl} from "./input-form-control-provider" @Directive() -export abstract class AbstractListCollectionFormControlDirective< +export abstract class AbstractBaseListCollectionFormControlDirective< T extends CollectionItem, > implements ControlValueAccessor, OnInit { /** - * The item collection - * - * @inheritDoc - */ - readonly collection = input.required>() - - /** - * The initial state of the input when rendered. Use when you don't need to - * control the checked state of the input. This property will be ignored if you + * The initial value of the input when rendered. Use when you don't need to + * control the value of the input. This property will be ignored if you * opt into controlled state via form control bindings. * * @inheritDoc @@ -342,3 +335,15 @@ export abstract class AbstractListCollectionFormControlDirective< this.isDisabled.set(isDisabled) } } + +@Directive() +export abstract class AbstractListCollectionFormControlDirective< + T extends CollectionItem, +> extends AbstractBaseListCollectionFormControlDirective { + /** + * The item collection + * + * @inheritDoc + */ + readonly collection = input.required>() +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-control.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-control.directive.ts new file mode 100644 index 00000000..08e1a74d --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-control.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderControlDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getControlBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-error-text.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-error-text.directive.ts new file mode 100644 index 00000000..37d92f84 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-error-text.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderErrorTextDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getErrorTextBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-hidden-input.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-hidden-input.directive.ts new file mode 100644 index 00000000..9e702d27 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-hidden-input.directive.ts @@ -0,0 +1,38 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" +import {useSliderThumbContext} from "./slider-thumb-context.service" + +@Directive() +export class CoreSliderHiddenInputDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + protected readonly sliderThumbContext = useSliderThumbContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getHiddenInputBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + ...this.sliderThumbContext(), + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-hint.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-hint.directive.ts new file mode 100644 index 00000000..d5904d27 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-hint.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderHintDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getHintBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-label.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-label.directive.ts new file mode 100644 index 00000000..30ad8bce --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-label.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderLabelDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getLabelBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-marker-group.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-marker-group.directive.ts new file mode 100644 index 00000000..c641b225 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-marker-group.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderMarkerGroupDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getMarkerGroupBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-marker.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-marker.directive.ts new file mode 100644 index 00000000..357bbcdd --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-marker.directive.ts @@ -0,0 +1,40 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderMarkerDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + /** + * The value the marker should indicate. + */ + readonly value = input.required() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getMarkerBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + value: this.value(), + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-max.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-max.directive.ts new file mode 100644 index 00000000..9b378776 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-max.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderMaxDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => { + return this.sliderContext().getMaxMarkerBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }) + }) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-min.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-min.directive.ts new file mode 100644 index 00000000..80c24a75 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-min.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderMinDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getMinMarkerBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-range.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-range.directive.ts new file mode 100644 index 00000000..67ab0771 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-range.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderRangeDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getRangeBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-root.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-root.directive.ts new file mode 100644 index 00000000..3dde78c8 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-root.directive.ts @@ -0,0 +1,252 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {DOCUMENT} from "@angular/common" +import { + computed, + Directive, + inject, + input, + type OnInit, + output, +} from "@angular/core" +import {type ControlValueAccessor} from "@angular/forms" + +import {useId, useIsMounted} from "@qualcomm-ui/angular-core/common" +import {AbstractBaseListCollectionFormControlDirective} from "@qualcomm-ui/angular-core/input" +import { + normalizeProps, + useMachine, + useTrackBindings, +} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import { + createSliderApi, + type FocusChangeDetails, + type SliderApiProps, + sliderMachine, + type ValueChangeDetails, + type ValueTextDetails, +} from "@qualcomm-ui/core/slider" +import type {Direction} from "@qualcomm-ui/utils/direction" +import type {Explicit} from "@qualcomm-ui/utils/guard" + +import {SliderContextService} from "./slider-context.service" + +@Directive() +export class CoreSliderRootDirective + extends AbstractBaseListCollectionFormControlDirective + implements + SignalifyInput< + Omit< + SliderApiProps, + "form" | "ids" | "value" | "aria-label" | "aria-labelledby" + > + >, + ControlValueAccessor, + OnInit +{ + /** + * The document's text/writing direction. + */ + readonly dir = input() + + /** + * A root node to correctly resolve the Document in custom environments. i.e., + * Iframes, Electron. + */ + readonly getRootNode = input< + (() => ShadowRoot | Document | Node) | undefined + >() + + /** + * HTML {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility.) + */ + readonly id = input() + + /** + * The aria-label of each slider thumb. Useful for providing an accessible name to + * the slider + */ + readonly ariaLabel = input(undefined, { + alias: "aria-label", + }) + + /** + * The `id` of the elements that labels each slider thumb. Useful for providing an + * accessible name to the slider + */ + readonly ariaLabelledby = input(undefined, { + alias: "aria-labelledby", + }) + + /** + * Function that returns a human readable value for the slider thumb + */ + readonly getAriaValueText = input<(details: ValueTextDetails) => string>() + + /** + * The maximum value of the slider + * @default 100 + */ + readonly max = input() + + /** + * The minimum value of the slider + * @default 0 + */ + readonly min = input() + + /** + * The minimum permitted steps between multiple thumbs. + * + * `minStepsBetweenThumbs` * `step` should reflect the gap between the thumbs. + * + * - `step: 1` and `minStepsBetweenThumbs: 10` => gap is `10` + * - `step: 10` and `minStepsBetweenThumbs: 2` => gap is `20` + * + * @default 0 + */ + readonly minStepsBetweenThumbs = input() + + /** + * The orientation of the slider + * @default "horizontal" + */ + readonly orientation = input<"vertical" | "horizontal" | undefined>() + + /** + * The origin of the slider range. The track is filled from the origin + * to the thumb for single values. + * - "start": Useful when the value represents an absolute value + * - "center": Useful when the value represents an offset (relative) + * - "end": Useful when the value represents an offset from the end + * + * @default "start" + */ + readonly origin = input<"start" | "center" | "end" | undefined>() + + /** + * The step value of the slider + * @default 1 + */ + readonly step = input() + + /** + * The alignment of the slider thumb relative to the track + * - `center`: the thumb will extend beyond the bounds of the slider track. + * - `contain`: the thumb will be contained within the bounds of the track. + * + * @default "contain" + */ + readonly thumbAlignment = input<"contain" | "center" | undefined>() + + /** + * The slider thumbs dimensions + */ + readonly thumbSize = input<{height: number; width: number} | undefined>() + + /** + * Value change callback. + */ + readonly valueChanged = output() + + /** + * Function invoked when the slider value change is done + */ + readonly valueChangedEnd = output() + + /** + * Function invoked when the slider focus changes + */ + readonly focusChanged = output() + + protected readonly document = inject(DOCUMENT) + + protected readonly sliderContextService = inject(SliderContextService) + + protected readonly isMounted = useIsMounted() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContextService.context().getRootBindings({id: this.hostId()}), + ) + + override ngOnInit() { + super.ngOnInit() + + const machine = useMachine( + sliderMachine, + computed>(() => ({ + "aria-label": this.ariaLabel(), + "aria-labelledby": this.ariaLabelledby(), + defaultValue: this.defaultValue(), + dir: this.dir(), + disabled: this.isDisabled(), + // angular handles this automatically with ngModel and Reactive Forms + form: undefined, + getAriaValueText: this.getAriaValueText(), + getRootNode: this.getRootNode() ?? (() => this.document), + ids: undefined, + invalid: this.isInvalid(), + max: this.max(), + min: this.min(), + minStepsBetweenThumbs: this.minStepsBetweenThumbs(), + name: this.name(), + onFocusChange: (details) => { + if (this.isMounted()) { + this.focusChanged.emit(details) + } + if (details.focusedIndex === -1) { + // only trigger onTouched on blur. + this.onTouched() + } + }, + onValueChange: (details) => { + if (!this.control) { + if (this.isMounted()) { + this.valueChanged.emit(details) + } + this.value.set(details.value) + return + } + // ngModel is bound to the root, but change events happen from internal + // elements and are passed to the machine. So we need to fire the + // form's value change event to keep it in sync. + this.onChange(details.value) + // angular handles touched/dirty internally when ngModel is bound to an + // element, but we don't have that luxury here. We fire these + // manually. + if (!this.control?.touched) { + this.control.markAsTouched?.() + } + if (!this.control?.dirty) { + this.control.markAsDirty?.() + } + }, + onValueChangeEnd: (details) => { + if (this.isMounted()) { + this.valueChangedEnd.emit(details) + } + }, + orientation: this.orientation(), + origin: this.origin(), + readOnly: this.readOnly(), + required: this.isRequired(), + step: this.step(), + thumbAlignment: this.thumbAlignment(), + thumbSize: this.thumbSize(), + value: this.value(), + })), + this.injector, + ) + + this.sliderContextService.init( + computed(() => createSliderApi(machine, normalizeProps)), + ) + + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-thumb-indicator.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-thumb-indicator.directive.ts new file mode 100644 index 00000000..1638dc7b --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-thumb-indicator.directive.ts @@ -0,0 +1,42 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service.js" +import {useSliderThumbContext} from "./slider-thumb-context.service.js" + +@Directive() +export class CoreSliderThumbIndicatorDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + protected readonly sliderThumbContext = useSliderThumbContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly value = computed(() => + this.sliderContext().getThumbValue(this.sliderThumbContext().index), + ) + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getThumbIndicatorBindings({ + id: this.hostId(), + index: this.sliderThumbContext().index, + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-thumb.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-thumb.directive.ts new file mode 100644 index 00000000..56f16f55 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-thumb.directive.ts @@ -0,0 +1,61 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, inject, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type {ThumbProps} from "@qualcomm-ui/core/slider" + +import {useSliderContext} from "./slider-context.service" +import {SliderThumbContextService} from "./slider-thumb-context.service" + +@Directive() +export class CoreSliderThumbDirective + implements SignalifyInput, OnInit +{ + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + /** + * The slider thumb's index. + */ + readonly index = input.required() + /** + * The name associated with the slider thumb's input (when used in a form). + */ + readonly name = input() + + protected readonly sliderThumbContextService = inject( + SliderThumbContextService, + ) + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getThumbBindings({ + id: this.hostId(), + index: this.index(), + name: this.name(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.sliderThumbContextService.init( + computed(() => ({ + index: this.index(), + name: this.name(), + })), + ) + + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-track.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-track.directive.ts new file mode 100644 index 00000000..402d761a --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-track.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderTrackDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getTrackBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/core-slider-value-text.directive.ts b/packages/frameworks/angular-core/src/slider/core-slider-value-text.directive.ts new file mode 100644 index 00000000..e15f3a29 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/core-slider-value-text.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useId, useOnDestroy} from "@qualcomm-ui/angular-core/common" +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useSliderContext} from "./slider-context.service" + +@Directive() +export class CoreSliderValueTextDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + + protected readonly hostId = computed(() => useId(this, this.id())) + + protected readonly sliderContext = useSliderContext() + + protected readonly onDestroy = useOnDestroy() + + protected readonly trackBindings = useTrackBindings(() => + this.sliderContext().getValueTextBindings({ + id: this.hostId(), + onDestroy: this.onDestroy, + }), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/slider/index.ts b/packages/frameworks/angular-core/src/slider/index.ts new file mode 100644 index 00000000..1e29d661 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/index.ts @@ -0,0 +1,17 @@ +export * from "./slider-context.service" +export * from "./slider-thumb-context.service" +export * from "./core-slider-control.directive" +export * from "./core-slider-error-text.directive" +export * from "./core-slider-hidden-input.directive" +export * from "./core-slider-hint.directive" +export * from "./core-slider-label.directive" +export * from "./core-slider-marker-group.directive" +export * from "./core-slider-marker.directive" +export * from "./core-slider-max.directive" +export * from "./core-slider-min.directive" +export * from "./core-slider-range.directive" +export * from "./core-slider-root.directive" +export * from "./core-slider-thumb-indicator.directive" +export * from "./core-slider-thumb.directive" +export * from "./core-slider-track.directive" +export * from "./core-slider-value-text.directive" diff --git a/packages/frameworks/angular-core/src/slider/ng-package.json b/packages/frameworks/angular-core/src/slider/ng-package.json new file mode 100644 index 00000000..7e82164b --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/frameworks/angular-core/src/slider/slider-context.service.ts b/packages/frameworks/angular-core/src/slider/slider-context.service.ts new file mode 100644 index 00000000..1b6afaab --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/slider-context.service.ts @@ -0,0 +1,16 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Injectable} from "@angular/core" + +import { + BaseApiContextService, + createApiContext, +} from "@qualcomm-ui/angular-core/machine" +import type {SliderApi} from "@qualcomm-ui/core/slider" + +@Injectable() +export class SliderContextService extends BaseApiContextService {} + +export const [SLIDER_CONTEXT, useSliderContext, provideSliderContext] = + createApiContext("SliderContext", SliderContextService) diff --git a/packages/frameworks/angular-core/src/slider/slider-thumb-context.service.ts b/packages/frameworks/angular-core/src/slider/slider-thumb-context.service.ts new file mode 100644 index 00000000..c9b21359 --- /dev/null +++ b/packages/frameworks/angular-core/src/slider/slider-thumb-context.service.ts @@ -0,0 +1,22 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Injectable} from "@angular/core" + +import { + BaseApiContextService, + createApiContext, +} from "@qualcomm-ui/angular-core/machine" +import type {ThumbProps} from "@qualcomm-ui/core/slider" + +@Injectable() +export class SliderThumbContextService extends BaseApiContextService {} + +export const [ + SLIDER_THUMB_CONTEXT, + useSliderThumbContext, + provideSliderThumbContext, +] = createApiContext( + "SliderThumbContext", + SliderThumbContextService, +) diff --git a/packages/frameworks/angular/src/slider/index.ts b/packages/frameworks/angular/src/slider/index.ts new file mode 100644 index 00000000..4f51fe7e --- /dev/null +++ b/packages/frameworks/angular/src/slider/index.ts @@ -0,0 +1,19 @@ +export * from "./slider-control.directive" +export * from "./slider-error-text.directive" +export * from "./slider-hidden-input.directive" +export * from "./slider-hint.directive" +export * from "./slider-label.directive" +export * from "./slider-marker-group.directive" +export * from "./slider-marker.directive" +export * from "./slider-markers.component" +export * from "./slider-max.directive" +export * from "./slider-min.directive" +export * from "./slider-range.directive" +export * from "./slider-root.directive" +export * from "./slider-thumb-indicator.directive" +export * from "./slider-thumb.directive" +export * from "./slider-thumbs.component" +export * from "./slider-track.directive" +export * from "./slider-value-text.directive" +export * from "./slider.component" +export * from "./slider.module" diff --git a/packages/frameworks/angular/src/slider/ng-package.json b/packages/frameworks/angular/src/slider/ng-package.json new file mode 100644 index 00000000..020455f6 --- /dev/null +++ b/packages/frameworks/angular/src/slider/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./index.ts" + } +} diff --git a/packages/frameworks/angular/src/slider/qds-slider-context.service.ts b/packages/frameworks/angular/src/slider/qds-slider-context.service.ts new file mode 100644 index 00000000..f124f985 --- /dev/null +++ b/packages/frameworks/angular/src/slider/qds-slider-context.service.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Injectable} from "@angular/core" + +import { + type ApiContext, + BaseApiContextService, + createApiContext, +} from "@qualcomm-ui/angular-core/machine" +import type {QdsSliderApi} from "@qualcomm-ui/qds-core/slider" + +@Injectable() +export class QdsSliderContextService extends BaseApiContextService {} + +export const [ + QDS_SLIDER_CONTEXT, + useQdsSliderContext, + provideQdsSliderContext, +]: ApiContext = createApiContext( + "QdsSliderContext", + QdsSliderContextService, +) diff --git a/packages/frameworks/angular/src/slider/slider-control.directive.ts b/packages/frameworks/angular/src/slider/slider-control.directive.ts new file mode 100644 index 00000000..31ac6dbc --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-control.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderControlDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-control]", + standalone: false, +}) +export class SliderControlDirective extends CoreSliderControlDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getControlBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-error-text.directive.ts b/packages/frameworks/angular/src/slider/slider-error-text.directive.ts new file mode 100644 index 00000000..49dd156c --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-error-text.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderErrorTextDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-error-text]", + standalone: false, +}) +export class SliderErrorTextDirective extends CoreSliderErrorTextDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getErrorTextBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-hidden-input.directive.ts b/packages/frameworks/angular/src/slider/slider-hidden-input.directive.ts new file mode 100644 index 00000000..6a8743e0 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-hidden-input.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderHiddenInputDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-hidden-input]", + standalone: false, +}) +export class SliderHiddenInputDirective extends CoreSliderHiddenInputDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getHiddenInputBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-hint.directive.ts b/packages/frameworks/angular/src/slider/slider-hint.directive.ts new file mode 100644 index 00000000..397fba85 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-hint.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderHintDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-hint]", + standalone: false, +}) +export class SliderHintDirective extends CoreSliderHintDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getHintBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-label.directive.ts b/packages/frameworks/angular/src/slider/slider-label.directive.ts new file mode 100644 index 00000000..d528b680 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-label.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderLabelDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-label]", + standalone: false, +}) +export class SliderLabelDirective extends CoreSliderLabelDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getLabelBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-marker-group.directive.ts b/packages/frameworks/angular/src/slider/slider-marker-group.directive.ts new file mode 100644 index 00000000..f3a736f4 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-marker-group.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderMarkerGroupDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-marker-group]", + standalone: false, +}) +export class SliderMarkerGroupDirective extends CoreSliderMarkerGroupDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getMarkerGroupBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-marker.directive.ts b/packages/frameworks/angular/src/slider/slider-marker.directive.ts new file mode 100644 index 00000000..2c1e144a --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-marker.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderMarkerDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-marker]", + standalone: false, +}) +export class SliderMarkerDirective extends CoreSliderMarkerDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getMarkerBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-markers.component.ts b/packages/frameworks/angular/src/slider/slider-markers.component.ts new file mode 100644 index 00000000..c0b6e156 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-markers.component.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input} from "@angular/core" + +import {useSliderContext} from "@qualcomm-ui/angular-core/slider" + +@Component({ + selector: "q-slider-markers", + standalone: false, + template: ` +
+ @for (mark of markerValues(); track mark) { + {{ mark }} + } +
+ `, +}) +export class SliderMarkersComponent { + /** + * An array of numbers indicating where to place the markers. If not + * provided, the component will generate 11 evenly spaced markers based on + * the `min` and `max` slider values. + */ + readonly marks = input([]) + + private readonly sliderContext = useSliderContext() + + protected readonly markerValues = computed(() => { + const marks = this.marks() + return Array.isArray(marks) && marks.length > 0 + ? marks + : this.sliderContext().getDefaultMarks() + }) +} diff --git a/packages/frameworks/angular/src/slider/slider-max.directive.ts b/packages/frameworks/angular/src/slider/slider-max.directive.ts new file mode 100644 index 00000000..575be05c --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-max.directive.ts @@ -0,0 +1,24 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" + +import {CoreSliderMaxDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Component({ + selector: "[q-slider-max]", + standalone: false, + template: "{{ this.sliderContext().max }}", +}) +export class SliderMaxDirective extends CoreSliderMaxDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getMinMaxMarkerBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-min.directive.ts b/packages/frameworks/angular/src/slider/slider-min.directive.ts new file mode 100644 index 00000000..0b83d69a --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-min.directive.ts @@ -0,0 +1,24 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed} from "@angular/core" + +import {CoreSliderMinDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Component({ + selector: "[q-slider-min]", + standalone: false, + template: "{{ this.sliderContext().min }}", +}) +export class SliderMinDirective extends CoreSliderMinDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getMinMaxMarkerBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-range.directive.ts b/packages/frameworks/angular/src/slider/slider-range.directive.ts new file mode 100644 index 00000000..862a9663 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-range.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderRangeDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-range]", + standalone: false, +}) +export class SliderRangeDirective extends CoreSliderRangeDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getRangeBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-root.directive.ts b/packages/frameworks/angular/src/slider/slider-root.directive.ts new file mode 100644 index 00000000..2e4bc248 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-root.directive.ts @@ -0,0 +1,56 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, inject, input} from "@angular/core" + +import {normalizeProps} from "@qualcomm-ui/angular-core/machine" +import { + CoreSliderRootDirective, + provideSliderContext, +} from "@qualcomm-ui/angular-core/slider" +import { + createQdsSliderApi, + type QdsSliderSize, + type QdsSliderVariant, +} from "@qualcomm-ui/qds-core/slider" + +import { + provideQdsSliderContext, + QdsSliderContextService, +} from "./qds-slider-context.service" + +@Directive({ + providers: [provideSliderContext(), provideQdsSliderContext()], + selector: "[q-slider-root]", + standalone: false, +}) +export class SliderRootDirective extends CoreSliderRootDirective { + /** + * The size of the slider. + */ + readonly size = input() + + /** + * The variant of the slider. + */ + readonly variant = input() + + readonly qdsSliderService = inject(QdsSliderContextService) + + override ngOnInit() { + super.ngOnInit() + + this.qdsSliderService.init( + computed(() => + createQdsSliderApi( + {size: this.size(), variant: this.variant()}, + normalizeProps, + ), + ), + ) + + this.trackBindings.extendWith( + computed(() => this.qdsSliderService.context().getRootBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-thumb-indicator.directive.ts b/packages/frameworks/angular/src/slider/slider-thumb-indicator.directive.ts new file mode 100644 index 00000000..2973c413 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-thumb-indicator.directive.ts @@ -0,0 +1,37 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input} from "@angular/core" + +import {CoreSliderThumbIndicatorDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service.js" + +@Component({ + selector: "[q-slider-thumb-indicator]", + standalone: false, + template: "{{valueText()}}", +}) +export class SliderThumbIndicatorDirective extends CoreSliderThumbIndicatorDirective { + /** + * Custom value display: a function that receives the value and returns a + * string. + * + * @default ' - ' + */ + readonly display = input<(value: number) => string>() + + readonly valueText = computed(() => { + const display = this.display() + return typeof display === "function" ? display(this.value()) : this.value() + }) + + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getThumbIndicatorBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-thumb.directive.ts b/packages/frameworks/angular/src/slider/slider-thumb.directive.ts new file mode 100644 index 00000000..6c7ee3d5 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-thumb.directive.ts @@ -0,0 +1,27 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import { + CoreSliderThumbDirective, + provideSliderThumbContext, +} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + providers: [provideSliderThumbContext()], + selector: "[q-slider-thumb]", + standalone: false, +}) +export class SliderThumbDirective extends CoreSliderThumbDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getThumbBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-thumbs.component.ts b/packages/frameworks/angular/src/slider/slider-thumbs.component.ts new file mode 100644 index 00000000..8406700e --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-thumbs.component.ts @@ -0,0 +1,36 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {booleanAttribute, Component, computed, input} from "@angular/core" + +import {useSliderContext} from "@qualcomm-ui/angular-core/slider" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" + +@Component({ + selector: "q-slider-thumbs", + standalone: false, + template: ` + @for (idx of thumbs(); track idx) { +
+ + @if (this.tooltip()) { +
+ } +
+ } + `, +}) +export class SliderThumbsComponent { + /** + * Whether to display the thumb value as a tooltip. + */ + readonly tooltip = input(undefined, { + transform: booleanAttribute, + }) + + private readonly sliderContext = useSliderContext() + + readonly thumbs = computed(() => + this.sliderContext().value.map((_, idx) => idx), + ) +} diff --git a/packages/frameworks/angular/src/slider/slider-track.directive.ts b/packages/frameworks/angular/src/slider/slider-track.directive.ts new file mode 100644 index 00000000..69a1d203 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-track.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreSliderTrackDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Directive({ + selector: "[q-slider-track]", + standalone: false, +}) +export class SliderTrackDirective extends CoreSliderTrackDirective { + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getTrackBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider-value-text.directive.ts b/packages/frameworks/angular/src/slider/slider-value-text.directive.ts new file mode 100644 index 00000000..c1ed33ef --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider-value-text.directive.ts @@ -0,0 +1,44 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Component, computed, input} from "@angular/core" + +import {CoreSliderValueTextDirective} from "@qualcomm-ui/angular-core/slider" + +import {useQdsSliderContext} from "./qds-slider-context.service" + +@Component({ + selector: "[q-slider-value-text]", + standalone: false, + template: "{{valueText()}}", +}) +export class SliderValueTextDirective extends CoreSliderValueTextDirective { + /** + * How to display range values: a separator string or a function that receives the + * value array and returns a string. + * + * @default ' - ' + */ + readonly display = input string)>() + + readonly valueText = computed(() => { + const value = this.sliderContext().value + const display = this.display() + if (typeof display === "function") { + return display(value) + } + if (typeof display === "string") { + return value.join(display) + } + return value.join(" - ") + }) + + protected readonly qdsSliderContext = useQdsSliderContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsSliderContext().getValueTextBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/slider/slider.component.ts b/packages/frameworks/angular/src/slider/slider.component.ts new file mode 100644 index 00000000..8c1664c9 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider.component.ts @@ -0,0 +1,109 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {booleanAttribute, Component, input} from "@angular/core" + +import { + provideSliderContext, + useSliderContext, +} from "@qualcomm-ui/angular-core/slider" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" + +import {provideQdsSliderContext} from "./qds-slider-context.service" +import {SliderRootDirective} from "./slider-root.directive" + +@Component({ + providers: [provideSliderContext(), provideQdsSliderContext()], + selector: "q-slider", + standalone: false, + template: ` + + @if (label()) { + + } + + @if (!tooltip()) { + +
+
+ } + @if (sideMarkers()) { + + + + } + + +
+ +
+ +
+
+
+
+ +
+
+ + @if (sideMarkers()) { + + + + } @else { + + } + + + @if (errorText()) { + {{ errorText() }} + } + + + @if (hint()) { + {{ hint() }} + } + + `, +}) +export class SliderComponent extends SliderRootDirective { + /** + * The label text for the slider. + */ + readonly label = input() + /** + * Optional hint text to display below the slider. + */ + readonly hint = input() + /** + * The error message to display when the slider value is invalid. + */ + readonly errorText = input() + /** + * Whether to display markers on the sides of the slider. + */ + readonly sideMarkers = input(undefined, { + transform: booleanAttribute, + }) + /** + * The list of marks to display along the slider track. + */ + readonly marks = input([]) + /** + * Whether to display the thumb value as a tooltip. + */ + readonly tooltip = input(undefined, { + transform: booleanAttribute, + }) + /** + * How to display range values: a separator string or a function that receives the + * value array and returns a string. + * + * @default '—' + */ + readonly display = input string) | undefined>() + + private readonly sliderContext = useSliderContext() +} diff --git a/packages/frameworks/angular/src/slider/slider.module.ts b/packages/frameworks/angular/src/slider/slider.module.ts new file mode 100644 index 00000000..ccbe0050 --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider.module.ts @@ -0,0 +1,69 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {CommonModule} from "@angular/common" +import {NgModule} from "@angular/core" + +import {SliderControlDirective} from "./slider-control.directive" +import {SliderErrorTextDirective} from "./slider-error-text.directive" +import {SliderHiddenInputDirective} from "./slider-hidden-input.directive" +import {SliderHintDirective} from "./slider-hint.directive" +import {SliderLabelDirective} from "./slider-label.directive" +import {SliderMarkerGroupDirective} from "./slider-marker-group.directive" +import {SliderMarkerDirective} from "./slider-marker.directive" +import {SliderMarkersComponent} from "./slider-markers.component" +import {SliderMaxDirective} from "./slider-max.directive" +import {SliderMinDirective} from "./slider-min.directive" +import {SliderRangeDirective} from "./slider-range.directive" +import {SliderRootDirective} from "./slider-root.directive" +import {SliderThumbIndicatorDirective} from "./slider-thumb-indicator.directive" +import {SliderThumbDirective} from "./slider-thumb.directive" +import {SliderThumbsComponent} from "./slider-thumbs.component" +import {SliderTrackDirective} from "./slider-track.directive" +import {SliderValueTextDirective} from "./slider-value-text.directive" +import {SliderComponent} from "./slider.component" + +@NgModule({ + declarations: [ + SliderRootDirective, + SliderLabelDirective, + SliderValueTextDirective, + SliderControlDirective, + SliderTrackDirective, + SliderRangeDirective, + SliderThumbDirective, + SliderHiddenInputDirective, + SliderHintDirective, + SliderMarkerGroupDirective, + SliderMarkerDirective, + SliderErrorTextDirective, + SliderMinDirective, + SliderMaxDirective, + SliderComponent, + SliderThumbIndicatorDirective, + SliderThumbsComponent, + SliderMarkersComponent, + ], + exports: [ + SliderRootDirective, + SliderLabelDirective, + SliderValueTextDirective, + SliderControlDirective, + SliderTrackDirective, + SliderRangeDirective, + SliderThumbDirective, + SliderHiddenInputDirective, + SliderHintDirective, + SliderMarkerGroupDirective, + SliderMarkerDirective, + SliderErrorTextDirective, + SliderMinDirective, + SliderMaxDirective, + SliderComponent, + SliderThumbIndicatorDirective, + SliderThumbsComponent, + SliderMarkersComponent, + ], + imports: [CommonModule], +}) +export class SliderModule {} diff --git a/packages/frameworks/angular/src/slider/slider.spec.ts b/packages/frameworks/angular/src/slider/slider.spec.ts new file mode 100644 index 00000000..1945c7ac --- /dev/null +++ b/packages/frameworks/angular/src/slider/slider.spec.ts @@ -0,0 +1,1412 @@ +import {Component, input, output} from "@angular/core" +import {render} from "@testing-library/angular" +import {page, userEvent} from "@vitest/browser/context" +import {describe, expect, test, vi} from "vitest" + +import {SliderModule} from "@qualcomm-ui/angular/slider" + +import {type MultiComponentTest, runTests} from "~test-utils" + +async function clickFocusTarget() { + return page.getByText("Focus target").click() +} + +const testIds = { + focusTarget: "focus-target", + sliderControl: "slider-control", + sliderErrorText: "slider-error-text", + sliderHint: "slider-hint", + sliderInput0: "slider-input-0", + sliderInput1: "slider-input-1", + sliderLabel: "slider-label", + sliderMarker: "slider-marker", + sliderMarkerGroup: "slider-marker-group", + sliderMax: "slider-max", + sliderMin: "slider-min", + sliderRoot: "slider-root", + sliderThumb0: "slider-thumb-0", + sliderThumb1: "slider-thumb-1", + sliderValueText: "slider-value-text", +} as const + +@Component({ + imports: [SliderModule], + template: ` + + + `, +}) +class SimpleSliderComponent { + readonly testIds = testIds + + readonly defaultValue = input(undefined) + readonly max = input(undefined) + readonly min = input(undefined) + readonly step = input(undefined) + readonly minStepsBetweenThumbs = input(undefined) + readonly orientation = input<"vertical" | "horizontal" | undefined>(undefined) + readonly ariaLabel = input(undefined) + readonly disabled = input(undefined) + readonly invalid = input(undefined) + readonly readOnly = input(undefined) + readonly dir = input(undefined) + readonly label = input(undefined) + readonly hint = input(undefined) + readonly errorText = input(undefined) + readonly markers = input(undefined) + readonly sideMarkers = input(undefined) + readonly name = input(undefined) + readonly getAriaValueText = input< + ((details: {value: number}) => string) | undefined + >(undefined) + readonly origin = input<"start" | "center" | "end" | undefined>(undefined) + + readonly valueChanged = output<{value: number[]}>() + readonly valueChangedEnd = output<{value: number[]}>() +} + +@Component({ + imports: [SliderModule], + template: ` + +
+ @if (label()) { + + } +
+ + @if (sideMarkers()) { + + } + +
+
+
+
+ + @if (markers()) { +
+ @for (markerValue of markers(); track markerValue) { + + {{ markerValue }} + + } +
+ } + + @let thumbCount = (defaultValue() ?? []).length; + @for (idx of [0, 1]; track idx) { + @if (idx < thumbCount) { +
+ +
+ } + } +
+ + @if (sideMarkers()) { + + } + + @if (hint()) { + + {{ hint() }} + + } + @if (errorText()) { + + {{ errorText() }} + + } +
+ `, +}) +class CompositeSliderComponent { + readonly testIds = testIds + + readonly defaultValue = input(undefined) + readonly max = input(undefined) + readonly min = input(undefined) + readonly step = input(undefined) + readonly minStepsBetweenThumbs = input(undefined) + readonly orientation = input<"vertical" | "horizontal" | undefined>(undefined) + readonly ariaLabel = input(undefined) + readonly disabled = input(undefined) + readonly invalid = input(undefined) + readonly readOnly = input(undefined) + readonly dir = input(undefined) + readonly label = input(undefined) + readonly hint = input(undefined) + readonly errorText = input(undefined) + readonly markers = input(undefined) + readonly sideMarkers = input(undefined) + readonly name = input(undefined) + readonly getAriaValueText = input< + ((details: {value: number}) => string) | undefined + >(undefined) + readonly origin = input<"start" | "center" | "end" | undefined>(undefined) + + readonly valueChanged = output<{value: number[]}>() + readonly valueChangedEnd = output<{value: number[]}>() + + getThumbName(index: number): string | undefined { + const nameValue = this.name() + if (!nameValue) { + return undefined + } + if (typeof nameValue !== "string") { + return nameValue[index] + } + const thumbCount = (this.defaultValue() ?? []).length + return thumbCount === 1 ? nameValue : `${nameValue}${index}` + } +} + +const testCases: MultiComponentTest[] = [ + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`default value — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [50]}, + }) + + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + + await expect.element(valueText).toHaveTextContent("50") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`range — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [25, 75]}, + }) + + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + const input0 = + component === SimpleSliderComponent + ? container.querySelectorAll("input")[0] + : page.getByTestId(testIds.sliderInput0) + const input1 = + component === SimpleSliderComponent + ? container.querySelectorAll("input")[1] + : page.getByTestId(testIds.sliderInput1) + + await expect.element(valueText).toHaveTextContent("25 - 75") + await expect.element(input0).toHaveValue("25") + await expect.element(input1).toHaveValue("75") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`aria-value[min|max|now] — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + max: 80, + min: 20, + }, + }) + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(thumb).toHaveAttribute("aria-valuemin", "20") + await expect.element(thumb).toHaveAttribute("aria-valuemax", "80") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "50") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`disabled state — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + disabled: true, + }, + }) + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + const root = + component === SimpleSliderComponent + ? container.querySelector('[data-part="root"]') + : page.getByTestId(testIds.sliderRoot) + + await expect.element(thumb).toHaveAttribute("aria-disabled", "true") + await expect.element(root).toHaveAttribute("data-disabled") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`invalid state — ${component.name}`, async () => { + await render(component, { + inputs: { + defaultValue: [50], + invalid: true, + }, + }) + + await expect + .element(page.getByTestId(testIds.sliderRoot)) + .toHaveAttribute("data-invalid") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`keyboard navigation - right/left arrow keys — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [50]}, + }) + + await clickFocusTarget() + await userEvent.tab() + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + + await expect.element(thumb).toHaveFocus() + + await userEvent.keyboard("{ArrowRight}") + await expect.element(valueText).toHaveTextContent("51") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "51") + + await userEvent.keyboard("{ArrowLeft}") + await expect.element(valueText).toHaveTextContent("50") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "50") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`keyboard navigation - PageUp/PageDown keys — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [50]}, + }) + + await clickFocusTarget() + await userEvent.tab() + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + + await userEvent.keyboard("{PageUp}") + await expect.element(valueText).toHaveTextContent("60") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "60") + + await userEvent.keyboard("{PageDown}") + await expect.element(valueText).toHaveTextContent("50") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "50") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`keyboard navigation - home/end keys — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + max: 90, + min: 10, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + + await userEvent.keyboard("{End}") + await expect.element(valueText).toHaveTextContent("90") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "90") + + await userEvent.keyboard("{Home}") + await expect.element(valueText).toHaveTextContent("10") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "10") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`respects min — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [10]}, + }) + + await clickFocusTarget() + await userEvent.tab() + + for (let i = 0; i < 15; i++) { + await userEvent.keyboard("{ArrowLeft}") + } + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(thumb).toHaveAttribute("aria-valuenow", "0") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`respects max — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [90]}, + }) + + await clickFocusTarget() + await userEvent.tab() + + for (let i = 0; i < 15; i++) { + await userEvent.keyboard("{ArrowRight}") + } + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(thumb).toHaveAttribute("aria-valuenow", "100") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`respects step — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [50], step: 5}, + }) + + await clickFocusTarget() + await userEvent.tab() + + await userEvent.keyboard("{ArrowRight}") + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + + await expect.element(valueText).toHaveTextContent("55") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "55") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`range slider respects minStepsBetweenThumbs — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50, 70], + minStepsBetweenThumbs: 10, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + + for (let i = 0; i < 15; i++) { + await userEvent.keyboard("{ArrowRight}") + } + + const firstThumb = + component === SimpleSliderComponent + ? container.querySelectorAll('[data-part="thumb"]')[0] + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(firstThumb).toHaveAttribute("aria-valuenow", "60") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`aria-orientation — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + orientation: "vertical", + }, + }) + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + const root = + component === SimpleSliderComponent + ? container.querySelector('[data-part="root"]') + : page.getByTestId(testIds.sliderRoot) + + await expect + .element(thumb) + .toHaveAttribute("aria-orientation", "vertical") + await expect + .element(root) + .toHaveAttribute("data-orientation", "vertical") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`aria-label — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + ariaLabel: "Volume control", + defaultValue: [50], + }, + }) + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect + .element(thumb) + .toHaveAttribute("aria-label", "Volume control") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`focus state — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [50]}, + }) + + await clickFocusTarget() + await userEvent.tab() + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + const root = + component === SimpleSliderComponent + ? container.querySelector('[data-part="root"]') + : page.getByTestId(testIds.sliderRoot) + + await expect.element(thumb).toHaveFocus() + await expect.element(thumb).toHaveAttribute("data-focus") + await expect.element(root).toHaveAttribute("data-focus") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`renders label — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + label: "Volume", + }, + }) + + const label = + component === SimpleSliderComponent + ? container.querySelector('[data-part="label"]') + : page.getByTestId(testIds.sliderLabel) + + await expect.element(label).toBeVisible() + await expect.element(label).toHaveTextContent("Volume") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`associates label with thumbs via aria-labelledby — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + label: "Volume", + }, + }) + + if (component === SimpleSliderComponent) { + const label = container.querySelector('[data-part="label"]') + const labelId = label?.getAttribute("id") + const thumb = container.querySelector('[data-part="thumb"]') + expect(thumb).toHaveAttribute("aria-labelledby", labelId!) + } else { + const labelId = page + .getByTestId(testIds.sliderLabel) + .element() + .getAttribute("id") + const thumb = page.getByTestId(testIds.sliderThumb0) + await expect + .element(thumb) + .toHaveAttribute("aria-labelledby", labelId!) + } + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`renders hint text — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + hint: "Adjust volume from 0 to 100", + }, + }) + + const hint = + component === SimpleSliderComponent + ? container.querySelector('[data-part="hint"]') + : page.getByTestId(testIds.sliderHint) + + await expect.element(hint).toBeVisible() + await expect + .element(hint) + .toHaveTextContent("Adjust volume from 0 to 100") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`hint is hidden when invalid — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + hint: "Adjust volume from 0 to 100", + invalid: true, + }, + }) + + const hint = + component === SimpleSliderComponent + ? container.querySelector('[data-part="hint"]') + : page.getByTestId(testIds.sliderHint) + + await expect.element(hint).not.toBeVisible() + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`renders error text when invalid — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + errorText: "Value must be between 20 and 80", + invalid: true, + }, + }) + + const errorText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="error-text"]') + : page.getByTestId(testIds.sliderErrorText) + + await expect.element(errorText).toBeVisible() + await expect + .element(errorText) + .toHaveTextContent("Value must be between 20 and 80") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`error text is hidden when not invalid — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + errorText: "Value must be between 20 and 80", + }, + }) + + const errorText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="error-text"]') + : page.getByTestId(testIds.sliderErrorText) + + await expect.element(errorText).not.toBeVisible() + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`renders markers at specified values — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + markers: [0, 25, 50, 75, 100], + }, + }) + + if (component === SimpleSliderComponent) { + const markers = container.querySelectorAll('[data-part="marker"]') + expect(markers.length).toBe(5) + const markerValues = Array.from(markers).map((m) => m.textContent) + expect(markerValues).toEqual(["0", "25", "50", "75", "100"]) + } else { + await expect + .element(page.getByTestId(testIds.sliderMarkerGroup)) + .toBeVisible() + + for (const value of [0, 25, 50, 75, 100]) { + await expect + .element(page.getByTestId(`${testIds.sliderMarker}-${value}`)) + .toBeVisible() + await expect + .element(page.getByTestId(`${testIds.sliderMarker}-${value}`)) + .toHaveTextContent(String(value)) + } + } + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`markers have correct data-state based on value — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + markers: [25, 50, 75], + }, + }) + + const marker25 = + component === SimpleSliderComponent + ? container.querySelectorAll('[data-part="marker"]')[0] + : page.getByTestId(`${testIds.sliderMarker}-25`) + const marker50 = + component === SimpleSliderComponent + ? container.querySelectorAll('[data-part="marker"]')[1] + : page.getByTestId(`${testIds.sliderMarker}-50`) + const marker75 = + component === SimpleSliderComponent + ? container.querySelectorAll('[data-part="marker"]')[2] + : page.getByTestId(`${testIds.sliderMarker}-75`) + + await expect + .element(marker25) + .toHaveAttribute("data-state", "under-value") + await expect.element(marker50).toHaveAttribute("data-state", "at-value") + await expect + .element(marker75) + .toHaveAttribute("data-state", "over-value") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`renders min and max markers with default range — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + sideMarkers: true, + }, + }) + + const min = + component === SimpleSliderComponent + ? container.querySelector('[data-part="min"]') + : page.getByTestId(testIds.sliderMin) + const max = + component === SimpleSliderComponent + ? container.querySelector('[data-part="max"]') + : page.getByTestId(testIds.sliderMax) + + await expect.element(min).toBeVisible() + await expect.element(max).toBeVisible() + await expect.element(min).toHaveTextContent("0") + await expect.element(max).toHaveTextContent("100") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`renders min and max markers with custom range — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [30], + max: 50, + min: 10, + sideMarkers: true, + }, + }) + + const min = + component === SimpleSliderComponent + ? container.querySelector('[data-part="min"]') + : page.getByTestId(testIds.sliderMin) + const max = + component === SimpleSliderComponent + ? container.querySelector('[data-part="max"]') + : page.getByTestId(testIds.sliderMax) + + await expect.element(min).toHaveTextContent("10") + await expect.element(max).toHaveTextContent("50") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`min and max have data-value attributes — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [30], + max: 50, + min: 10, + sideMarkers: true, + }, + }) + + const min = + component === SimpleSliderComponent + ? container.querySelector('[data-part="min"]') + : page.getByTestId(testIds.sliderMin) + const max = + component === SimpleSliderComponent + ? container.querySelector('[data-part="max"]') + : page.getByTestId(testIds.sliderMax) + + await expect.element(min).toHaveAttribute("data-value", "10") + await expect.element(max).toHaveAttribute("data-value", "50") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`calls valueChanged when value changes via keyboard — ${component.name}`, async () => { + const valueChangedSpy = vi.fn() + await render(component, { + inputs: {defaultValue: [50]}, + on: { + valueChanged: (event) => { + valueChangedSpy(event) + }, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + await userEvent.keyboard("{ArrowRight}") + + await expect + .poll(() => valueChangedSpy) + .toHaveBeenCalledWith({ + value: [51], + }) + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`calls valueChanged with both values in range slider — ${component.name}`, async () => { + const valueChangedSpy = vi.fn() + await render(component, { + inputs: {defaultValue: [25, 75]}, + on: { + valueChanged: (event) => { + valueChangedSpy(event) + }, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + await userEvent.keyboard("{ArrowRight}") + + await expect + .poll(() => valueChangedSpy) + .toHaveBeenCalledWith({ + value: [26, 75], + }) + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`calls valueChanged multiple times for multiple changes — ${component.name}`, async () => { + const valueChangedSpy = vi.fn() + await render(component, { + inputs: {defaultValue: [50]}, + on: { + valueChanged: (event) => { + valueChangedSpy(event) + }, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + await userEvent.keyboard("{ArrowRight}") + await userEvent.keyboard("{ArrowRight}") + + await expect.poll(() => valueChangedSpy).toHaveBeenCalledTimes(2) + await expect + .poll(() => valueChangedSpy) + .toHaveBeenNthCalledWith(1, {value: [51]}) + await expect + .poll(() => valueChangedSpy) + .toHaveBeenNthCalledWith(2, {value: [52]}) + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`calls valueChangedEnd when keyboard interaction completes — ${component.name}`, async () => { + const valueChangedEndSpy = vi.fn() + await render(component, { + inputs: {defaultValue: [50]}, + on: { + valueChangedEnd: (event) => { + valueChangedEndSpy(event) + }, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + + if (component === SimpleSliderComponent) { + await userEvent.keyboard("{ArrowRight}") + await userEvent.keyboard("{ArrowRight}") + + await clickFocusTarget() + + await expect + .poll(() => valueChangedEndSpy) + .toHaveBeenCalledWith({ + value: [52], + }) + } else { + const thumb = page.getByTestId(testIds.sliderThumb0) + await expect.element(thumb).toHaveFocus() + + await userEvent.keyboard("{ArrowRight}") + await userEvent.keyboard("{ArrowRight}") + + await clickFocusTarget() + + await expect + .poll(() => valueChangedEndSpy) + .toHaveBeenCalledWith({ + value: [52], + }) + } + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`applies RTL direction to slider — ${component.name}`, async () => { + await render(component, {inputs: {defaultValue: [50], dir: "rtl"}}) + + await expect + .element(page.getByTestId(testIds.sliderRoot)) + .toHaveAttribute("dir", "rtl") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`arrow key behavior is reversed in RTL mode — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [50], dir: "rtl"}, + }) + + await clickFocusTarget() + await userEvent.tab() + + await userEvent.keyboard("{ArrowLeft}") + + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + + await expect.element(valueText).toHaveTextContent("51") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`step of 0.1 for decimal values — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [5.0], + max: 10, + min: 0, + step: 0.1, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + + await userEvent.keyboard("{ArrowRight}") + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(thumb).toHaveAttribute("aria-valuenow", "5.1") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`large step value — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + step: 25, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + + await userEvent.keyboard("{ArrowRight}") + + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + + await expect.element(valueText).toHaveTextContent("75") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`prevents value changes when readOnly — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + readOnly: true, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + + await userEvent.keyboard("{ArrowRight}") + + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(valueText).toHaveTextContent("50") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "50") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`handles value at minimum boundary — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [0]}, + }) + + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(valueText).toHaveTextContent("0") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "0") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`handles value at maximum boundary — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [100]}, + }) + + const valueText = + component === SimpleSliderComponent + ? container.querySelector('[data-part="value-text"]') + : page.getByTestId(testIds.sliderValueText) + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(valueText).toHaveTextContent("100") + await expect.element(thumb).toHaveAttribute("aria-valuenow", "100") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`prevents thumbs from crossing in range slider — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [40, 60], + minStepsBetweenThumbs: 5, + }, + }) + + await clickFocusTarget() + await userEvent.tab() + + for (let i = 0; i < 20; i++) { + await userEvent.keyboard("{ArrowRight}") + } + + if (component === SimpleSliderComponent) { + const thumbs = container.querySelectorAll('[data-part="thumb"]') + const firstValue = Number(thumbs[0]?.getAttribute("aria-valuenow")) + expect(firstValue).toBeLessThanOrEqual(55) + } else { + const firstThumb = page.getByTestId(testIds.sliderThumb0) + await expect.element(firstThumb).toHaveFocus() + const firstValue = Number( + firstThumb.element().getAttribute("aria-valuenow"), + ) + expect(firstValue).toBeLessThanOrEqual(55) + } + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`clamps out of range defaultValue to min — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [-10]}, + }) + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(thumb).toHaveAttribute("aria-valuenow", "0") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`clamps out of range defaultValue to max — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: {defaultValue: [150]}, + }) + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect.element(thumb).toHaveAttribute("aria-valuenow", "100") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`customizes aria-valuetext for thumb — ${component.name}`, async () => { + const getAriaValueText = (details: {value: number}) => + `${details.value} percent` + + const {container} = await render(component, { + inputs: { + defaultValue: [50], + getAriaValueText, + }, + }) + + const thumb = + component === SimpleSliderComponent + ? container.querySelector('[data-part="thumb"]') + : page.getByTestId(testIds.sliderThumb0) + + await expect + .element(thumb) + .toHaveAttribute("aria-valuetext", "50 percent") + }) + }, + }, +] + +const formTests: MultiComponentTest[] = [ + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`form: single name single thumb — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [50], + name: "volume", + }, + }) + + const input = + component === SimpleSliderComponent + ? container.querySelector("input") + : page.getByTestId(testIds.sliderInput0) + + await expect.element(input).toHaveValue("50") + await expect.element(input).toHaveAttribute("name", "volume") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`form: single name two thumbs — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [25, 75], + name: "range", + }, + }) + + const input0 = + component === SimpleSliderComponent + ? container.querySelectorAll("input")[0] + : page.getByTestId(testIds.sliderInput0) + const input1 = + component === SimpleSliderComponent + ? container.querySelectorAll("input")[1] + : page.getByTestId(testIds.sliderInput1) + + await expect.element(input0).toHaveValue("25") + await expect.element(input0).toHaveAttribute("name", "range0") + await expect.element(input1).toHaveValue("75") + await expect.element(input1).toHaveAttribute("name", "range1") + }) + }, + }, + { + composite: () => CompositeSliderComponent, + simple: () => SimpleSliderComponent, + testCase(component) { + test(`form: two names two thumbs — ${component.name}`, async () => { + const {container} = await render(component, { + inputs: { + defaultValue: [25, 75], + name: ["min", "max"], + }, + }) + + const input0 = + component === SimpleSliderComponent + ? container.querySelectorAll("input")[0] + : page.getByTestId(testIds.sliderInput0) + const input1 = + component === SimpleSliderComponent + ? container.querySelectorAll("input")[1] + : page.getByTestId(testIds.sliderInput1) + + await expect.element(input0).toHaveValue("25") + await expect.element(input0).toHaveAttribute("name", "min") + await expect.element(input1).toHaveValue("75") + await expect.element(input1).toHaveAttribute("name", "max") + }) + }, + }, +] + +describe("Slider", () => { + runTests(testCases) + runTests(formTests) +}) diff --git a/packages/frameworks/react-core/src/slider/use-slider.ts b/packages/frameworks/react-core/src/slider/use-slider.ts index dbd336bc..7826bb1f 100644 --- a/packages/frameworks/react-core/src/slider/use-slider.ts +++ b/packages/frameworks/react-core/src/slider/use-slider.ts @@ -17,6 +17,7 @@ import { type SliderMinMarkerBindings, type SliderRangeBindings, type SliderThumbBindings, + type SliderThumbIndicatorBindings, type SliderTrackBindings, type SliderValueTextBindings, type ThumbProps, @@ -89,19 +90,16 @@ export function useSliderRange({id}: IdProp): SliderRangeBindings { }) } -export function useSliderThumb({id, ...thumbProps}: IdProp & ThumbProps): { - bindings: SliderThumbBindings - value: number -} { +export function useSliderThumb({ + id, + ...thumbProps +}: IdProp & ThumbProps): SliderThumbBindings { const context = useSliderContext() - return { - bindings: context.getThumbBindings({ - id: useControlledId(id), - ...thumbProps, - onDestroy: useOnDestroy(), - }), - value: context.value[thumbProps.index], - } + return context.getThumbBindings({ + id: useControlledId(id), + ...thumbProps, + onDestroy: useOnDestroy(), + }) } export function useSliderHiddenInput({id}: IdProp): SliderHiddenInputBindings { @@ -149,3 +147,19 @@ export function useSliderMaxMarker({id}: IdProp): SliderMaxMarkerBindings { onDestroy: useOnDestroy(), }) } + +export function useSliderThumbIndicator({id}: IdProp): { + bindings: SliderThumbIndicatorBindings + value: number +} { + const context = useSliderContext() + const {index} = useSliderThumbContext() + return { + bindings: context.getThumbIndicatorBindings({ + id: useControlledId(id), + index, + onDestroy: useOnDestroy(), + }), + value: context.getThumbValue(index), + } +} diff --git a/packages/frameworks/react/src/slider/index.ts b/packages/frameworks/react/src/slider/index.ts index 311ec58e..f7398374 100644 --- a/packages/frameworks/react/src/slider/index.ts +++ b/packages/frameworks/react/src/slider/index.ts @@ -18,6 +18,10 @@ import {SliderMin, type SliderMinProps} from "./slider-min" import {SliderRange, type SliderRangeProps} from "./slider-range" import {SliderRoot, type SliderRootProps} from "./slider-root" import {SliderThumb, type SliderThumbProps} from "./slider-thumb" +import { + SliderThumbIndicator, + type SliderThumbIndicatorProps, +} from "./slider-thumb-indicator" import {SliderThumbs} from "./slider-thumbs" import {SliderTrack, type SliderTrackProps} from "./slider-track" import {SliderValueText, type SliderValueTextProps} from "./slider-value-text" @@ -28,6 +32,7 @@ export type { SliderValueTextProps, SliderHiddenInputProps, SliderControlProps, + SliderThumbIndicatorProps, SliderTrackProps, SliderRangeProps, SliderThumbProps, @@ -53,6 +58,7 @@ type SliderComponent = typeof SimpleSlider & { Range: typeof SliderRange Root: typeof SliderRoot Thumb: typeof SliderThumb + ThumbIndicator: typeof SliderThumbIndicator Thumbs: typeof SliderThumbs Track: typeof SliderTrack ValueText: typeof SliderValueText @@ -61,6 +67,7 @@ type SliderComponent = typeof SimpleSlider & { export const Slider = SimpleSlider as SliderComponent Slider.Control = SliderControl +Slider.ThumbIndicator = SliderThumbIndicator Slider.ErrorText = SliderErrorText Slider.HiddenInput = SliderHiddenInput Slider.Hint = SliderHint diff --git a/packages/frameworks/react/src/slider/slider-thumb-indicator.tsx b/packages/frameworks/react/src/slider/slider-thumb-indicator.tsx new file mode 100644 index 00000000..657b0d6c --- /dev/null +++ b/packages/frameworks/react/src/slider/slider-thumb-indicator.tsx @@ -0,0 +1,57 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement, ReactNode} from "react" + +import {useSliderThumbIndicator} from "@qualcomm-ui/react-core/slider" +import { + type ElementRenderProp, + type IdProp, + PolymorphicElement, +} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsSliderContext} from "./qds-slider-context.js" + +export interface SliderThumbIndicatorProps + extends IdProp, + ElementRenderProp<"div"> { + /** + * React {@link https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children children} prop. + */ + children?: ReactNode + /** + * Custom value display: a function that receives the value array and returns a + * React node. + * + * @default ' - ' + */ + display?: (value: number) => ReactNode +} + +/** + * The indicator shown above the slider thumb. Renders a `
` element by + * default. + */ +export function SliderThumbIndicator({ + children, + display, + id, + ...props +}: SliderThumbIndicatorProps): ReactElement { + const {bindings: contextProps, value} = useSliderThumbIndicator({id}) + const qdsContext = useQdsSliderContext() + const mergedProps = mergeProps( + contextProps, + qdsContext.getThumbIndicatorBindings(), + props, + ) + + const valueText = typeof display === "function" ? display(value) : value + + return ( + + {children || valueText} + + ) +} diff --git a/packages/frameworks/react/src/slider/slider-thumb.tsx b/packages/frameworks/react/src/slider/slider-thumb.tsx index fbec21c9..3c0476e3 100644 --- a/packages/frameworks/react/src/slider/slider-thumb.tsx +++ b/packages/frameworks/react/src/slider/slider-thumb.tsx @@ -4,7 +4,6 @@ import type {ReactElement, ReactNode} from "react" import type {ThumbProps} from "@qualcomm-ui/core/slider" -import {Tooltip} from "@qualcomm-ui/react/tooltip" import { SliderThumbContextProvider, useSliderThumb, @@ -26,11 +25,6 @@ export interface SliderThumbProps * React {@link https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children children} prop. */ children?: ReactNode - - /** - * Whether to display the thumb value as a tooltip. - */ - tooltip?: boolean } /** @@ -41,32 +35,21 @@ export function SliderThumb({ id, index, name, - tooltip, ...props }: SliderThumbProps): ReactElement { - const {bindings, value} = useSliderThumb({id, index, name}) + const contextProps = useSliderThumb({id, index, name}) const qdsContext = useQdsSliderContext() - const mergedProps = mergeProps(bindings, qdsContext.getThumbBindings(), props) - - const thumbElement = ( - - {children} - + const mergedProps = mergeProps( + contextProps, + qdsContext.getThumbBindings(), + props, ) return ( - {tooltip ? ( - - {value} - - ) : ( - thumbElement - )} + + {children} + ) } diff --git a/packages/frameworks/react/src/slider/slider-thumbs.tsx b/packages/frameworks/react/src/slider/slider-thumbs.tsx index a91a68c5..71bfa095 100644 --- a/packages/frameworks/react/src/slider/slider-thumbs.tsx +++ b/packages/frameworks/react/src/slider/slider-thumbs.tsx @@ -10,6 +10,7 @@ import { type SliderHiddenInputProps, } from "./slider-hidden-input" import {SliderThumb, type SliderThumbProps} from "./slider-thumb" +import {SliderThumbIndicator} from "./slider-thumb-indicator" export interface SliderThumbsProps { /** @@ -45,8 +46,9 @@ export function SliderThumbs({ return ( <> {context.value.map((_, idx) => ( - + + {tooltip && } ))} diff --git a/packages/frameworks/react/src/slider/slider-value-text.tsx b/packages/frameworks/react/src/slider/slider-value-text.tsx index 7ddcdad6..d0b94627 100644 --- a/packages/frameworks/react/src/slider/slider-value-text.tsx +++ b/packages/frameworks/react/src/slider/slider-value-text.tsx @@ -20,7 +20,7 @@ export interface SliderValueTextProps * How to display range values: a separator string or a function that receives the * value array and returns a React node. * - * @default '-' + * @default ' - ' */ display?: string | ((value: number[]) => ReactNode) }