UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

501 lines 23.2 kB
import { Shade, createComponent } from '@furystack/shades'; import { buildTransition, cssVariableTheme } from '../../services/css-variable-theme.js'; import { ThemeProviderService } from '../../services/theme-provider-service.js'; const valueToPercent = (value, min, max) => { if (max === min) return 0; return ((value - min) / (max - min)) * 100; }; const percentToValue = (percent, min, max) => { return min + (percent / 100) * (max - min); }; const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const snapToStep = (value, step, min, max) => { if (step <= 0) return clamp(value, min, max); const snapped = Math.round((value - min) / step) * step + min; const decimals = String(step).split('.')[1]?.length ?? 0; return clamp(Number(snapped.toFixed(decimals)), min, max); }; const resolveMarks = (marks, min, max, step) => { if (!marks) return []; if (Array.isArray(marks)) return marks; if (step <= 0) return []; const result = []; const decimals = String(step).split('.')[1]?.length ?? 0; for (let v = min; v <= max; v = Number((v + step).toFixed(decimals))) { result.push({ value: v }); } return result; }; const isRangeValue = (value) => Array.isArray(value) && value.length === 2 && typeof value[0] === 'number' && typeof value[1] === 'number'; /** Stores props for each Slider ref so the constructed handler can access them without re-renders */ const sliderPropsMap = new WeakMap(); /** * Directly updates DOM positions and aria-valuenow on thumb/track elements. * Used during drag for smooth updates without triggering a full re-render. */ const syncVisuals = (track, thumb0, thumb1, value, min, max, vertical) => { const thumbs = [thumb0, thumb1].filter(Boolean); if (isRangeValue(value)) { const startPct = valueToPercent(value[0], min, max); const endPct = valueToPercent(value[1], min, max); if (track) { if (vertical) { track.style.bottom = `${startPct}%`; track.style.height = `${endPct - startPct}%`; track.style.left = ''; track.style.width = ''; } else { track.style.left = `${startPct}%`; track.style.width = `${endPct - startPct}%`; track.style.bottom = ''; track.style.height = ''; } } thumbs.forEach((thumb, i) => { const pct = i === 0 ? startPct : endPct; if (vertical) { thumb.style.bottom = `${pct}%`; thumb.style.left = ''; } else { thumb.style.left = `${pct}%`; thumb.style.bottom = ''; } thumb.setAttribute('aria-valuenow', String(value[i])); }); } else { const pct = valueToPercent(value, min, max); if (track) { if (vertical) { track.style.bottom = '0%'; track.style.height = `${pct}%`; track.style.left = ''; track.style.width = ''; } else { track.style.left = '0%'; track.style.width = `${pct}%`; track.style.bottom = ''; track.style.height = ''; } } if (thumbs[0]) { if (vertical) { thumbs[0].style.bottom = `${pct}%`; thumbs[0].style.left = ''; } else { thumbs[0].style.left = `${pct}%`; thumbs[0].style.bottom = ''; } thumbs[0].setAttribute('aria-valuenow', String(value)); } } }; export const Slider = Shade({ customElementName: 'shade-slider', css: { display: 'block', fontFamily: cssVariableTheme.typography.fontFamily, position: 'relative', width: '100%', padding: '10px', cursor: 'pointer', userSelect: 'none', webkitUserSelect: 'none', touchAction: 'none', '&[data-vertical]': { display: 'inline-flex', width: 'auto', height: '200px', }, '&[data-disabled]': { cursor: 'default', opacity: cssVariableTheme.action.disabledOpacity, pointerEvents: 'none', }, '&[data-has-labels]': { paddingBottom: '28px', }, '&[data-vertical][data-has-labels]': { paddingBottom: '10px', paddingRight: '40px', }, '& .slider-root': { position: 'relative', width: '100%', height: '4px', }, '&[data-vertical] .slider-root': { width: '4px', height: '100%', }, '& .slider-rail': { position: 'absolute', inset: '0', borderRadius: cssVariableTheme.shape.borderRadius.xs, backgroundColor: 'color-mix(in srgb, var(--slider-color) 30%, transparent)', }, '& .slider-track': { position: 'absolute', height: '100%', borderRadius: cssVariableTheme.shape.borderRadius.xs, backgroundColor: 'var(--slider-color)', transition: buildTransition(['left', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['width', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default]), }, '&[data-vertical] .slider-track': { width: '100%', height: 'auto', transition: buildTransition(['bottom', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['height', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default]), }, '&[data-dragging] .slider-track': { transition: 'none', }, '& .slider-thumb': { position: 'absolute', width: '20px', height: '20px', borderRadius: cssVariableTheme.shape.borderRadius.full, backgroundColor: 'var(--slider-color)', boxShadow: cssVariableTheme.shadows.sm, top: '50%', transform: 'translate(-50%, -50%)', outline: 'none', cursor: 'grab', zIndex: '1', transition: buildTransition(['left', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['box-shadow', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default]), }, '&[data-vertical] .slider-thumb': { top: 'auto', left: '50%', transform: 'translate(-50%, 50%)', transition: buildTransition(['bottom', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['box-shadow', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default]), }, '&[data-dragging] .slider-thumb': { cursor: 'grabbing', transition: 'none', }, '& .slider-thumb:hover': { boxShadow: '0 0 0 8px color-mix(in srgb, var(--slider-color) 16%, transparent)', }, '& .slider-thumb:focus-visible': { outline: 'none', boxShadow: '0 0 0 4px color-mix(in srgb, var(--slider-color) 30%, transparent)', }, '& .slider-mark-dot': { position: 'absolute', width: '4px', height: '4px', borderRadius: cssVariableTheme.shape.borderRadius.full, top: '50%', transform: 'translate(-50%, -50%)', backgroundColor: 'color-mix(in srgb, var(--slider-color) 50%, white)', }, '& .slider-mark-dot[data-active]': { backgroundColor: 'var(--slider-color)', }, '&[data-vertical] .slider-mark-dot': { top: 'auto', left: '50%', transform: 'translate(-50%, 50%)', }, '& .slider-mark-label': { position: 'absolute', top: '14px', transform: 'translateX(-50%)', fontSize: cssVariableTheme.typography.fontSize.xs, color: cssVariableTheme.text.secondary, whiteSpace: 'nowrap', }, '&[data-vertical] .slider-mark-label': { top: 'auto', left: cssVariableTheme.spacing.md, transform: 'translateY(50%)', }, }, render: ({ props, injector, useDisposable, useHostProps, useRef }) => { const sliderRootRef = useRef('sliderRoot'); const trackRef = useRef('sliderTrack'); const thumb0Ref = useRef('sliderThumb0'); const thumb1Ref = useRef('sliderThumb1'); useDisposable('interaction-handler', () => { let isDragging = false; let activeThumbIdx = 0; let cleanupDrag = null; // Pending value tracked during drag to avoid triggering re-renders mid-interaction. // Shades recreates custom elements on parent re-render, which would orphan our // document-level drag listeners and cause stale getBoundingClientRect calculations. let pendingValue = null; const getProps = () => { const p = sliderPropsMap.get(sliderRootRef); return { ...p, min: p?.min ?? 0, max: p?.max ?? 100, step: p?.step ?? 1, }; }; const getValueFromPointer = (clientX, clientY) => { if (!sliderRootRef.current?.isConnected) return null; const root = sliderRootRef.current; if (!root) return null; const rect = root.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return null; const { min, max, step, vertical } = getProps(); let pct; if (vertical) { pct = rect.height > 0 ? ((rect.bottom - clientY) / rect.height) * 100 : 0; } else { pct = rect.width > 0 ? ((clientX - rect.left) / rect.width) * 100 : 0; } pct = clamp(pct, 0, 100); return snapToStep(percentToValue(pct, min, max), step, min, max); }; const applyVisual = (newValue) => { const { min, max, vertical } = getProps(); syncVisuals(trackRef.current, thumb0Ref.current, thumb1Ref.current, newValue, min, max, vertical ?? false); }; const emitToParent = (newValue) => { getProps().onValueChange?.(newValue); }; const getCurrentValue = () => { if (pendingValue !== null) return pendingValue; const currentProps = getProps(); return currentProps.value ?? currentProps.min; }; const handlePointerDown = (e) => { const currentProps = getProps(); if (currentProps.disabled) return; const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; const target = e.target; const isThumb = target.classList.contains('slider-thumb'); if (isThumb) { activeThumbIdx = Number(target.dataset.index ?? 0); pendingValue = getCurrentValue(); } else { const newVal = getValueFromPointer(clientX, clientY); if (newVal === null) return; const currentValue = currentProps.value ?? currentProps.min; if (isRangeValue(currentValue)) { const distStart = Math.abs(newVal - currentValue[0]); const distEnd = Math.abs(newVal - currentValue[1]); activeThumbIdx = distStart <= distEnd ? 0 : 1; const updated = [currentValue[0], currentValue[1]]; updated[activeThumbIdx] = newVal; if (updated[0] > updated[1]) { activeThumbIdx = activeThumbIdx === 0 ? 1 : 0; [updated[0], updated[1]] = [updated[1], updated[0]]; } pendingValue = updated; } else { activeThumbIdx = 0; pendingValue = newVal; } applyVisual(pendingValue); } isDragging = true; sliderRootRef.current?.setAttribute('data-dragging', ''); const handlePointerMove = (moveEvt) => { if (!isDragging || !sliderRootRef.current?.isConnected) { endDrag(); return; } moveEvt.preventDefault(); const mx = 'touches' in moveEvt ? moveEvt.touches[0].clientX : moveEvt.clientX; const my = 'touches' in moveEvt ? moveEvt.touches[0].clientY : moveEvt.clientY; const newVal = getValueFromPointer(mx, my); if (newVal === null) return; const currentValue = getCurrentValue(); if (isRangeValue(currentValue)) { const updated = [currentValue[0], currentValue[1]]; updated[activeThumbIdx] = newVal; if (updated[0] > updated[1]) { activeThumbIdx = activeThumbIdx === 0 ? 1 : 0; [updated[0], updated[1]] = [updated[1], updated[0]]; } pendingValue = updated; } else { pendingValue = newVal; } applyVisual(pendingValue); }; const endDrag = () => { isDragging = false; sliderRootRef.current?.removeAttribute('data-dragging'); document.removeEventListener('mousemove', handlePointerMove); document.removeEventListener('mouseup', endDrag); document.removeEventListener('touchmove', handlePointerMove); document.removeEventListener('touchend', endDrag); cleanupDrag = null; if (pendingValue !== null) { const value = pendingValue; pendingValue = null; emitToParent(value); } }; document.addEventListener('mousemove', handlePointerMove); document.addEventListener('mouseup', endDrag); document.addEventListener('touchmove', handlePointerMove, { passive: false }); document.addEventListener('touchend', endDrag); cleanupDrag = endDrag; e.preventDefault(); }; const handleKeyDown = (e) => { const currentProps = getProps(); if (currentProps.disabled) return; const target = e.target; if (!target.classList.contains('slider-thumb')) return; const thumbIdx = Number(target.dataset.index ?? 0); const { step, min, max } = currentProps; const currentValue = getCurrentValue(); let val; if (isRangeValue(currentValue)) { val = currentValue[thumbIdx]; } else { val = currentValue; } const effectiveStep = step <= 0 ? 1 : step; const bigStep = effectiveStep * 10; let newVal; switch (e.key) { case 'ArrowRight': case 'ArrowUp': newVal = snapToStep(val + effectiveStep, step, min, max); break; case 'ArrowLeft': case 'ArrowDown': newVal = snapToStep(val - effectiveStep, step, min, max); break; case 'PageUp': newVal = snapToStep(val + bigStep, step, min, max); break; case 'PageDown': newVal = snapToStep(val - bigStep, step, min, max); break; case 'Home': newVal = min; break; case 'End': newVal = max; break; default: return; } e.preventDefault(); let updated; if (isRangeValue(currentValue)) { const pair = [currentValue[0], currentValue[1]]; pair[thumbIdx] = newVal; if (thumbIdx === 0 && pair[0] > pair[1]) pair[0] = pair[1]; if (thumbIdx === 1 && pair[1] < pair[0]) pair[1] = pair[0]; updated = pair; } else { updated = newVal; } applyVisual(updated); emitToParent(updated); }; let root = null; queueMicrotask(() => { root = sliderRootRef.current; root?.addEventListener('mousedown', handlePointerDown); root?.addEventListener('touchstart', handlePointerDown, { passive: false }); root?.addEventListener('keydown', handleKeyDown); }); return { [Symbol.dispose]: () => { root?.removeEventListener('mousedown', handlePointerDown); root?.removeEventListener('touchstart', handlePointerDown); root?.removeEventListener('keydown', handleKeyDown); cleanupDrag?.(); }, }; }); const themeProvider = injector.get(ThemeProviderService); const min = props.min ?? 0; const max = props.max ?? 100; const step = props.step ?? 1; const value = props.value ?? min; const vertical = props.vertical ?? false; const disabled = props.disabled ?? false; const rangeMode = isRangeValue(value); // Store props for interaction event handlers sliderPropsMap.set(sliderRootRef, props); // Theme color const color = themeProvider.theme.palette[props.color || 'primary'].main; // Resolve marks const marks = resolveMarks(props.marks, min, max, step); const hasLabels = marks.some((m) => m.label); useHostProps({ style: { '--slider-color': color }, ...(vertical ? { 'data-vertical': '' } : {}), ...(disabled ? { 'data-disabled': '' } : {}), ...(hasLabels ? { 'data-has-labels': '' } : {}), }); const orientation = vertical ? 'vertical' : 'horizontal'; // Calculate positions const renderMarks = (activeCheck) => marks.map((mark) => { const pct = valueToPercent(mark.value, min, max); const isActive = activeCheck(mark.value); const pos = vertical ? { bottom: `${pct}%` } : { left: `${pct}%` }; return (createComponent(createComponent, null, createComponent("span", { className: "slider-mark-dot", ...(isActive ? { 'data-active': '' } : {}), style: pos }), mark.label ? (createComponent("span", { className: "slider-mark-label", style: pos }, mark.label)) : null)); }); if (rangeMode) { const startPct = valueToPercent(value[0], min, max); const endPct = valueToPercent(value[1], min, max); const trackStyle = vertical ? { bottom: `${startPct}%`, height: `${endPct - startPct}%` } : { left: `${startPct}%`, width: `${endPct - startPct}%` }; const thumbStartStyle = vertical ? { bottom: `${startPct}%` } : { left: `${startPct}%` }; const thumbEndStyle = vertical ? { bottom: `${endPct}%` } : { left: `${endPct}%` }; return (createComponent("div", { ref: sliderRootRef, className: "slider-root" }, createComponent("div", { className: "slider-rail" }), createComponent("div", { ref: trackRef, className: "slider-track", style: trackStyle }), createComponent("div", { ref: thumb0Ref, className: "slider-thumb", "data-index": "0", tabIndex: disabled ? -1 : 0, style: thumbStartStyle, role: "slider", "aria-valuemin": String(min), "aria-valuemax": String(max), "aria-valuenow": String(value[0]), "aria-orientation": orientation, "aria-disabled": disabled ? 'true' : undefined }), createComponent("div", { ref: thumb1Ref, className: "slider-thumb", "data-index": "1", tabIndex: disabled ? -1 : 0, style: thumbEndStyle, role: "slider", "aria-valuemin": String(min), "aria-valuemax": String(max), "aria-valuenow": String(value[1]), "aria-orientation": orientation, "aria-disabled": disabled ? 'true' : undefined }), renderMarks((v) => v >= value[0] && v <= value[1]))); } // Single slider const pct = valueToPercent(value, min, max); const trackStyle = vertical ? { bottom: '0%', height: `${pct}%` } : { left: '0%', width: `${pct}%` }; const thumbStyle = vertical ? { bottom: `${pct}%` } : { left: `${pct}%` }; return (createComponent("div", { ref: sliderRootRef, className: "slider-root" }, createComponent("div", { className: "slider-rail" }), createComponent("div", { ref: trackRef, className: "slider-track", style: trackStyle }), createComponent("div", { ref: thumb0Ref, className: "slider-thumb", "data-index": "0", tabIndex: disabled ? -1 : 0, style: thumbStyle, role: "slider", "aria-valuemin": String(min), "aria-valuemax": String(max), "aria-valuenow": String(value), "aria-orientation": orientation, "aria-disabled": disabled ? 'true' : undefined }), renderMarks((v) => v <= value), props.name ? createComponent("input", { type: "hidden", name: props.name, value: String(value) }) : null)); }, }); //# sourceMappingURL=slider.js.map