UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

812 lines 44.2 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ThemeProviderService } from '../../services/theme-provider-service.js'; import { Slider } from './slider.js'; describe('Slider', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks(); }); const renderSlider = async (props = {}) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: createComponent(Slider, { ...props }), }); await flushUpdates(); return { injector, slider: document.querySelector('shade-slider'), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('rendering', () => { it('should render as custom element', async () => { await usingAsync(await renderSlider(), async ({ slider }) => { expect(slider).not.toBeNull(); }); }); it('should render the rail, track, and thumb', async () => { await usingAsync(await renderSlider(), async ({ slider }) => { expect(slider.querySelector('.slider-rail')).not.toBeNull(); expect(slider.querySelector('.slider-track')).not.toBeNull(); expect(slider.querySelector('.slider-thumb')).not.toBeNull(); }); }); it('should render a single thumb for non-range slider', async () => { await usingAsync(await renderSlider({ value: 50 }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); expect(thumbs.length).toBe(1); }); }); it('should render two thumbs for range slider', async () => { await usingAsync(await renderSlider({ value: [20, 80] }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); expect(thumbs.length).toBe(2); }); }); }); describe('value positioning', () => { it('should position thumb at 0% when value equals min', async () => { await usingAsync(await renderSlider({ value: 0, min: 0, max: 100 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.style.left).toBe('0%'); }); }); it('should position thumb at 50% when value is midpoint', async () => { await usingAsync(await renderSlider({ value: 50, min: 0, max: 100 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.style.left).toBe('50%'); }); }); it('should position thumb at 100% when value equals max', async () => { await usingAsync(await renderSlider({ value: 100, min: 0, max: 100 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.style.left).toBe('100%'); }); }); it('should set correct track width for single slider', async () => { await usingAsync(await renderSlider({ value: 60, min: 0, max: 100 }), async ({ slider }) => { const track = slider.querySelector('.slider-track'); expect(track.style.left).toBe('0%'); expect(track.style.width).toBe('60%'); }); }); it('should set correct track position for range slider', async () => { await usingAsync(await renderSlider({ value: [20, 80], min: 0, max: 100 }), async ({ slider }) => { const track = slider.querySelector('.slider-track'); expect(track.style.left).toBe('20%'); expect(track.style.width).toBe('60%'); }); }); it('should position range thumbs correctly', async () => { await usingAsync(await renderSlider({ value: [25, 75], min: 0, max: 100 }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); expect(thumbs[0].style.left).toBe('25%'); expect(thumbs[1].style.left).toBe('75%'); }); }); }); describe('vertical mode', () => { it('should set data-vertical attribute', async () => { await usingAsync(await renderSlider({ vertical: true }), async ({ slider }) => { expect(slider.hasAttribute('data-vertical')).toBe(true); }); }); it('should not set data-vertical when horizontal', async () => { await usingAsync(await renderSlider({ vertical: false }), async ({ slider }) => { expect(slider.hasAttribute('data-vertical')).toBe(false); }); }); it('should use bottom for positioning in vertical mode', async () => { await usingAsync(await renderSlider({ value: 50, vertical: true }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.style.bottom).toBe('50%'); }); }); it('should set vertical track via bottom/height', async () => { await usingAsync(await renderSlider({ value: 60, vertical: true }), async ({ slider }) => { const track = slider.querySelector('.slider-track'); expect(track.style.bottom).toBe('0%'); expect(track.style.height).toBe('60%'); }); }); }); describe('disabled state', () => { it('should set data-disabled attribute when disabled', async () => { await usingAsync(await renderSlider({ disabled: true }), async ({ slider }) => { expect(slider.hasAttribute('data-disabled')).toBe(true); }); }); it('should not set data-disabled when not disabled', async () => { await usingAsync(await renderSlider({ disabled: false }), async ({ slider }) => { expect(slider.hasAttribute('data-disabled')).toBe(false); }); }); it('should set tabIndex to -1 on thumb when disabled', async () => { await usingAsync(await renderSlider({ disabled: true }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.tabIndex).toBe(-1); }); }); it('should set tabIndex to 0 on thumb when not disabled', async () => { await usingAsync(await renderSlider({ disabled: false }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.tabIndex).toBe(0); }); }); }); describe('ARIA attributes', () => { it('should set role="slider" on thumb', async () => { await usingAsync(await renderSlider({ value: 50 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.getAttribute('role')).toBe('slider'); }); }); it('should set aria-valuemin and aria-valuemax', async () => { await usingAsync(await renderSlider({ value: 50, min: 10, max: 90 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.getAttribute('aria-valuemin')).toBe('10'); expect(thumb.getAttribute('aria-valuemax')).toBe('90'); }); }); it('should set aria-valuenow to current value', async () => { await usingAsync(await renderSlider({ value: 42 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.getAttribute('aria-valuenow')).toBe('42'); }); }); it('should set aria-orientation to horizontal by default', async () => { await usingAsync(await renderSlider(), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.getAttribute('aria-orientation')).toBe('horizontal'); }); }); it('should set aria-orientation to vertical when vertical', async () => { await usingAsync(await renderSlider({ vertical: true }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.getAttribute('aria-orientation')).toBe('vertical'); }); }); it('should set aria-disabled when disabled', async () => { await usingAsync(await renderSlider({ disabled: true }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.getAttribute('aria-disabled')).toBe('true'); }); }); it('should set separate aria-valuenow for range thumbs', async () => { await usingAsync(await renderSlider({ value: [20, 80] }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); expect(thumbs[0].getAttribute('aria-valuenow')).toBe('20'); expect(thumbs[1].getAttribute('aria-valuenow')).toBe('80'); }); }); }); describe('marks', () => { it('should render mark dots when marks is true', async () => { await usingAsync(await renderSlider({ min: 0, max: 10, step: 5, marks: true }), async ({ slider }) => { const dots = slider.querySelectorAll('.slider-mark-dot'); expect(dots.length).toBe(3); // 0, 5, 10 }); }); it('should render custom marks from an array', async () => { const marks = [ { value: 0, label: 'Min' }, { value: 50, label: 'Mid' }, { value: 100, label: 'Max' }, ]; await usingAsync(await renderSlider({ marks }), async ({ slider }) => { const dots = slider.querySelectorAll('.slider-mark-dot'); const labels = slider.querySelectorAll('.slider-mark-label'); expect(dots.length).toBe(3); expect(labels.length).toBe(3); expect(labels[0].textContent).toBe('Min'); expect(labels[1].textContent).toBe('Mid'); expect(labels[2].textContent).toBe('Max'); }); }); it('should mark active dots for single slider', async () => { await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, step: 50, marks: true }), async ({ slider }) => { const dots = slider.querySelectorAll('.slider-mark-dot'); expect(dots[0].hasAttribute('data-active')).toBe(true); // 0 <= 50 expect(dots[1].hasAttribute('data-active')).toBe(true); // 50 <= 50 expect(dots[2].hasAttribute('data-active')).toBe(false); // 100 > 50 }); }); it('should mark active dots for range slider', async () => { await usingAsync(await renderSlider({ value: [25, 75], min: 0, max: 100, step: 25, marks: true }), async ({ slider }) => { const dots = slider.querySelectorAll('.slider-mark-dot'); expect(dots[0].hasAttribute('data-active')).toBe(false); // 0 < 25 expect(dots[1].hasAttribute('data-active')).toBe(true); // 25 >= 25 && 25 <= 75 expect(dots[2].hasAttribute('data-active')).toBe(true); // 50 >= 25 && 50 <= 75 expect(dots[3].hasAttribute('data-active')).toBe(true); // 75 >= 25 && 75 <= 75 expect(dots[4].hasAttribute('data-active')).toBe(false); // 100 > 75 }); }); it('should set data-has-labels when marks have labels', async () => { const marks = [{ value: 50, label: 'Half' }]; await usingAsync(await renderSlider({ marks }), async ({ slider }) => { expect(slider.hasAttribute('data-has-labels')).toBe(true); }); }); it('should not set data-has-labels when marks have no labels', async () => { await usingAsync(await renderSlider({ min: 0, max: 10, step: 5, marks: true }), async ({ slider }) => { expect(slider.hasAttribute('data-has-labels')).toBe(false); }); }); it('should not render marks when marks is false or undefined', async () => { await usingAsync(await renderSlider({ marks: false }), async ({ slider }) => { expect(slider.querySelectorAll('.slider-mark-dot').length).toBe(0); }); }); }); describe('theme integration', () => { it('should set CSS color variable from theme', async () => { await usingAsync(await renderSlider(), async ({ slider, injector }) => { const themeService = injector.get(ThemeProviderService); expect(slider.style.getPropertyValue('--slider-color')).toBe(themeService.theme.palette.primary.main); }); }); it('should use custom color from color prop', async () => { await usingAsync(await renderSlider({ color: 'secondary' }), async ({ slider, injector }) => { const themeService = injector.get(ThemeProviderService); expect(slider.style.getPropertyValue('--slider-color')).toBe(themeService.theme.palette.secondary.main); }); }); }); describe('keyboard navigation', () => { it('should increment value on ArrowRight', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, step: 10, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(60); }); }); it('should decrement value on ArrowLeft', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, step: 10, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(40); }); }); it('should increment value on ArrowUp', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, step: 5, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(55); }); }); it('should decrement value on ArrowDown', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, step: 5, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(45); }); }); it('should jump to min on Home', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(0); }); }); it('should jump to max on End', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(100); }); }); it('should large-step on PageUp', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, step: 1, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(60); }); }); it('should not exceed max on ArrowRight', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 100, step: 10, min: 0, max: 100, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(100); }); }); it('should not go below min on ArrowLeft', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 0, step: 10, min: 0, max: 100, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(0); }); }); it('should not fire change when disabled', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, disabled: true, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onValueChange).not.toHaveBeenCalled(); }); }); }); describe('defaults', () => { it('should use min=0, max=100, step=1 by default', async () => { await usingAsync(await renderSlider(), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.getAttribute('aria-valuemin')).toBe('0'); expect(thumb.getAttribute('aria-valuemax')).toBe('100'); expect(thumb.getAttribute('aria-valuenow')).toBe('0'); }); }); it('should default value to min', async () => { await usingAsync(await renderSlider({ min: 20, max: 80 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.style.left).toBe('0%'); }); }); }); describe('hidden input for form integration', () => { it('should render a hidden input when name is provided', async () => { await usingAsync(await renderSlider({ name: 'volume', value: 75 }), async ({ slider }) => { const input = slider.querySelector('input[type="hidden"]'); expect(input).not.toBeNull(); expect(input.name).toBe('volume'); expect(input.value).toBe('75'); }); }); it('should not render a hidden input when name is not provided', async () => { await usingAsync(await renderSlider({ value: 75 }), async ({ slider }) => { const input = slider.querySelector('input[type="hidden"]'); expect(input).toBeNull(); }); }); }); describe('custom min/max/step', () => { it('should respect custom min and max', async () => { await usingAsync(await renderSlider({ value: 15, min: 10, max: 20 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.style.left).toBe('50%'); }); }); it('should handle step with decimals', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 0.5, min: 0, max: 1, step: 0.1, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.style.left).toBe('50%'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(0.6); }); }); it('should handle step=0 (continuous mode) with keyboard using effective step of 1', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, step: 0, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(51); }); }); }); describe('range keyboard navigation', () => { it('should increment first thumb of range with ArrowRight', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [20, 80], step: 5, onValueChange }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith([25, 80]); }); }); it('should decrement second thumb of range with ArrowLeft', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [20, 80], step: 5, onValueChange }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith([20, 75]); }); }); it('should clamp range thumb 0 to not exceed thumb 1', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [50, 50], step: 10, onValueChange }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith([50, 50]); }); }); it('should clamp range thumb 1 to not go below thumb 0', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [50, 50], step: 10, onValueChange }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith([50, 50]); }); }); it('should jump range thumb to min on Home', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [30, 70], min: 0, max: 100, onValueChange }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith([0, 70]); }); }); it('should jump range thumb to max on End', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [30, 70], min: 0, max: 100, onValueChange }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith([30, 100]); }); }); it('should use ArrowUp to increment value', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [20, 80], step: 5, onValueChange }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith([25, 80]); }); }); it('should use ArrowDown to decrement value', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [20, 80], step: 5, onValueChange }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith([20, 75]); }); }); }); describe('PageDown', () => { it('should large-step down on PageDown', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, step: 1, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true })); expect(onValueChange).toHaveBeenCalledWith(40); }); }); }); describe('vertical range positioning', () => { it('should use bottom for positioning range thumbs vertically', async () => { await usingAsync(await renderSlider({ value: [20, 80], vertical: true }), async ({ slider }) => { const thumbs = slider.querySelectorAll('.slider-thumb'); expect(thumbs[0].style.bottom).toBe('20%'); expect(thumbs[1].style.bottom).toBe('80%'); }); }); it('should use bottom/height for range track in vertical mode', async () => { await usingAsync(await renderSlider({ value: [20, 80], vertical: true }), async ({ slider }) => { const track = slider.querySelector('.slider-track'); expect(track.style.bottom).toBe('20%'); expect(track.style.height).toBe('60%'); }); }); }); describe('marks with step=0', () => { it('should not generate auto marks when step is 0', async () => { await usingAsync(await renderSlider({ min: 0, max: 100, step: 0, marks: true }), async ({ slider }) => { const dots = slider.querySelectorAll('.slider-mark-dot'); expect(dots.length).toBe(0); }); }); }); describe('non-keyboard events are ignored', () => { it('should not fire change when non-slider-thumb receives keyboard event', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, onValueChange }), async ({ slider }) => { const rail = slider.querySelector('.slider-rail'); rail.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(onValueChange).not.toHaveBeenCalled(); }); }); it('should ignore unrecognized keys', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, onValueChange }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); expect(onValueChange).not.toHaveBeenCalled(); }); }); }); describe('marks positioning in vertical mode', () => { it('should use bottom for marks in vertical mode', async () => { const marks = [{ value: 50, label: 'Half' }]; await usingAsync(await renderSlider({ marks, vertical: true }), async ({ slider }) => { const dot = slider.querySelector('.slider-mark-dot'); expect(dot.style.bottom).toBe('50%'); }); }); }); describe('mouse interaction', () => { const mockSliderRoot = (slider, rect) => { const root = slider.querySelector('.slider-root'); if (root) { vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ top: 0, left: 0, width: 200, height: 4, right: 200, bottom: 4, x: 0, y: 0, toJSON: () => ({}), ...rect, }); } }; it('should update slider on mousedown on track (single mode)', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, step: 1, onValueChange }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const root = slider.querySelector('.slider-root'); root.dispatchEvent(new MouseEvent('mousedown', { clientX: 150, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const calledWith = onValueChange.mock.calls[0][0]; expect(calledWith).toBe(75); }); }); it('should not respond to mousedown when disabled', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, disabled: true, onValueChange }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); slider.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).not.toHaveBeenCalled(); }); }); it('should set data-dragging during drag and remove after', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, step: 1, onValueChange }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const root = slider.querySelector('.slider-root'); root.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, clientY: 2, bubbles: true })); expect(root.hasAttribute('data-dragging')).toBe(true); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(root.hasAttribute('data-dragging')).toBe(false); }); }); it('should update value on mousemove during drag', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 0, min: 0, max: 100, step: 1, onValueChange }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const thumb = slider.querySelector('.slider-thumb'); thumb.dispatchEvent(new MouseEvent('mousedown', { clientX: 0, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const calledWith = onValueChange.mock.calls[0][0]; expect(calledWith).toBe(50); }); }); it('should handle range slider mousedown - picks nearest thumb', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [20, 80], min: 0, max: 100, step: 1, onValueChange, }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const root = slider.querySelector('.slider-root'); // Click at 90% (180px) → closer to thumb at 80% root.dispatchEvent(new MouseEvent('mousedown', { clientX: 180, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const val = onValueChange.mock.calls[0][0]; expect(val[0]).toBe(20); expect(val[1]).toBe(90); }); }); it('should handle range slider mousedown - swaps if crossover', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [40, 60], min: 0, max: 100, step: 1, onValueChange, }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const root = slider.querySelector('.slider-root'); // Click at 20% (40px), closer to start thumb at 40%. // New val for thumb[0]=20. 20 < 60, no swap. root.dispatchEvent(new MouseEvent('mousedown', { clientX: 40, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const val = onValueChange.mock.calls[0][0]; expect(val[0]).toBe(20); expect(val[1]).toBe(60); }); }); it('should handle mousemove with range values', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [20, 80], min: 0, max: 100, step: 1, onValueChange, }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[0].dispatchEvent(new MouseEvent('mousedown', { clientX: 40, clientY: 2, bubbles: true })); // Move to 60px (30%) document.dispatchEvent(new MouseEvent('mousemove', { clientX: 60, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const val = onValueChange.mock.calls[0][0]; expect(val[0]).toBe(30); expect(val[1]).toBe(80); }); }); it('should handle vertical mode mousedown', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, step: 1, vertical: true, onValueChange }), async ({ slider }) => { mockSliderRoot(slider, { top: 0, height: 200, bottom: 200, left: 0, width: 4 }); const root = slider.querySelector('.slider-root'); // Click at bottom-50px (bottom=200, clientY=150 → (200-150)/200=25%) root.dispatchEvent(new MouseEvent('mousedown', { clientX: 2, clientY: 150, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const calledWith = onValueChange.mock.calls[0][0]; expect(calledWith).toBe(25); }); }); it('should clamp pointer values to 0-100 range', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, step: 1, onValueChange }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const root = slider.querySelector('.slider-root'); // Click far to the right beyond the track root.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const calledWith = onValueChange.mock.calls[0][0]; expect(calledWith).toBe(100); }); }); }); describe('touch interaction', () => { const mockSliderRoot = (slider, rect) => { const root = slider.querySelector('.slider-root'); if (root) { vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ top: 0, left: 0, width: 200, height: 4, right: 200, bottom: 4, x: 0, y: 0, toJSON: () => ({}), ...rect, }); } }; it('should handle touchstart on track', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, step: 1, onValueChange }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const root = slider.querySelector('.slider-root'); const touch = { clientX: 100, clientY: 2, identifier: 0, target: root }; root.dispatchEvent(new TouchEvent('touchstart', { touches: [touch] })); document.dispatchEvent(new TouchEvent('touchend', { changedTouches: [touch] })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const calledWith = onValueChange.mock.calls[0][0]; expect(calledWith).toBe(50); }); }); it('should handle touchmove during drag', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 0, min: 0, max: 100, step: 1, onValueChange }), async ({ slider }) => { mockSliderRoot(slider, { left: 0, width: 200 }); const root = slider.querySelector('.slider-root'); const startTouch = { clientX: 0, clientY: 2, identifier: 0, target: root }; root.dispatchEvent(new TouchEvent('touchstart', { touches: [startTouch] })); const moveTouch = { clientX: 150, clientY: 2, identifier: 0, target: slider }; document.dispatchEvent(new TouchEvent('touchmove', { touches: [moveTouch], cancelable: true })); const endTouch = { clientX: 150, clientY: 2, identifier: 0, target: slider }; document.dispatchEvent(new TouchEvent('touchend', { changedTouches: [endTouch] })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); const calledWith = onValueChange.mock.calls[0][0]; expect(calledWith).toBe(75); }); }); }); describe('syncVisuals for vertical range', () => { it('should position range thumbs and track in vertical mode during drag', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: [20, 80], min: 0, max: 100, step: 1, vertical: true, onValueChange, }), async ({ slider }) => { const root = slider.querySelector('.slider-root'); if (root) { vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ top: 0, left: 0, width: 4, height: 200, right: 4, bottom: 200, x: 0, y: 0, toJSON: () => ({}), }); } const thumbs = slider.querySelectorAll('.slider-thumb'); thumbs[0].dispatchEvent(new MouseEvent('mousedown', { clientX: 2, clientY: 160, bubbles: true })); // Move to 60% from bottom: clientY = 200 - 120 = 80 document.dispatchEvent(new MouseEvent('mousemove', { clientX: 2, clientY: 80, bubbles: true })); // During drag, syncVisuals updates positions directly expect(slider.querySelector('.slider-track')).not.toBeNull(); expect(root.hasAttribute('data-dragging')).toBe(true); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); }); }); }); describe('edge cases', () => { it('should return null from getValueFromPointer when root has zero dimensions', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSlider({ value: 50, min: 0, max: 100, onValueChange }), async ({ slider }) => { const root = slider.querySelector('.slider-root'); if (root) { vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}), }); } slider.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, clientY: 2, bubbles: true })); document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); await flushUpdates(); // Should not have fired because getValueFromPointer returned null expect(onValueChange).not.toHaveBeenCalled(); }); }); it('should handle valueToPercent when max === min', async () => { await usingAsync(await renderSlider({ value: 5, min: 5, max: 5 }), async ({ slider }) => { const thumb = slider.querySelector('.slider-thumb'); expect(thumb.style.left).toBe('0%'); }); }); }); }); //# sourceMappingURL=slider.spec.js.map