UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

624 lines 30.5 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 { ButtonGroup, SegmentedControl, ToggleButton, ToggleButtonGroup, } from './button-group.js'; import { Button } from './button.js'; describe('ButtonGroup', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const renderButtonGroup = async (props = {}, children) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: createComponent(ButtonGroup, { ...props }, children), }); await flushUpdates(); return { injector, group: root.querySelector('shade-button-group'), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('rendering', () => { it('should render a button group element', async () => { await usingAsync(await renderButtonGroup(), async ({ group }) => { expect(group).toBeTruthy(); expect(group.tagName.toLowerCase()).toBe('shade-button-group'); }); }); it('should have role="group"', async () => { await usingAsync(await renderButtonGroup(), async ({ group }) => { expect(group.getAttribute('role')).toBe('group'); }); }); it('should render children', async () => { await usingAsync(await renderButtonGroup({}, [createComponent(Button, null, "First"), createComponent(Button, null, "Second")]), async ({ group }) => { const buttons = group.querySelectorAll('button[is="shade-button"]'); expect(buttons.length).toBe(2); }); }); }); describe('orientation', () => { it('should default to horizontal orientation', async () => { await usingAsync(await renderButtonGroup(), async ({ group }) => { expect(group.getAttribute('data-orientation')).toBe('horizontal'); }); }); it('should support vertical orientation', async () => { await usingAsync(await renderButtonGroup({ orientation: 'vertical' }), async ({ group }) => { expect(group.getAttribute('data-orientation')).toBe('vertical'); }); }); }); describe('variant', () => { it('should not set data-variant when variant is not specified', async () => { await usingAsync(await renderButtonGroup(), async ({ group }) => { expect(group.getAttribute('data-variant')).toBeNull(); }); }); it('should set data-variant for contained', async () => { await usingAsync(await renderButtonGroup({ variant: 'contained' }), async ({ group }) => { expect(group.getAttribute('data-variant')).toBe('contained'); }); }); it('should set data-variant for outlined', async () => { await usingAsync(await renderButtonGroup({ variant: 'outlined' }), async ({ group }) => { expect(group.getAttribute('data-variant')).toBe('outlined'); }); }); }); describe('color', () => { it('should not set data-color when color is not specified', async () => { await usingAsync(await renderButtonGroup(), async ({ group }) => { expect(group.getAttribute('data-color')).toBeNull(); }); }); it('should set data-color for primary', async () => { await usingAsync(await renderButtonGroup({ color: 'primary' }), async ({ group }) => { expect(group.getAttribute('data-color')).toBe('primary'); }); }); }); describe('disabled', () => { it('should not set data-disabled when not disabled', async () => { await usingAsync(await renderButtonGroup(), async ({ group }) => { expect(group.hasAttribute('data-disabled')).toBe(false); }); }); it('should set data-disabled when disabled', async () => { await usingAsync(await renderButtonGroup({ disabled: true }), async ({ group }) => { expect(group.hasAttribute('data-disabled')).toBe(true); }); }); }); describe('custom styles', () => { it('should apply custom styles', async () => { await usingAsync(await renderButtonGroup({ style: { gap: '4px' } }), async ({ group }) => { expect(group.style.gap).toBe('4px'); }); }); }); }); describe('ToggleButton', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const renderToggleButton = async (props) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: createComponent(ToggleButton, { ...props }, "Label"), }); await flushUpdates(); return { injector, button: root.querySelector('button[is="shade-toggle-button"]'), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('pressed', () => { it('should not set data-selected when pressed is not specified', async () => { await usingAsync(await renderToggleButton({ value: 'a' }), async ({ button }) => { expect(button.hasAttribute('data-selected')).toBe(false); }); }); it('should set data-selected when pressed is true', async () => { await usingAsync(await renderToggleButton({ value: 'a', pressed: true }), async ({ button }) => { expect(button.hasAttribute('data-selected')).toBe(true); }); }); it('should not set data-selected when pressed is false', async () => { await usingAsync(await renderToggleButton({ value: 'a', pressed: false }), async ({ button }) => { expect(button.hasAttribute('data-selected')).toBe(false); }); }); }); describe('size', () => { it('should not set data-size by default', async () => { await usingAsync(await renderToggleButton({ value: 'a' }), async ({ button }) => { expect(button.hasAttribute('data-size')).toBe(false); }); }); it('should not set data-size for medium (default)', async () => { await usingAsync(await renderToggleButton({ value: 'a', size: 'medium' }), async ({ button }) => { expect(button.hasAttribute('data-size')).toBe(false); }); }); it('should set data-size="small" for small size', async () => { await usingAsync(await renderToggleButton({ value: 'a', size: 'small' }), async ({ button }) => { expect(button.getAttribute('data-size')).toBe('small'); }); }); it('should set data-size="large" for large size', async () => { await usingAsync(await renderToggleButton({ value: 'a', size: 'large' }), async ({ button }) => { expect(button.getAttribute('data-size')).toBe('large'); }); }); }); }); describe('ToggleButtonGroup', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const renderToggleGroup = async (props = {}, children) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: createComponent(ToggleButtonGroup, { ...props }, children), }); await flushUpdates(); return { injector, group: root.querySelector('shade-toggle-button-group'), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('rendering', () => { it('should render a toggle button group', async () => { await usingAsync(await renderToggleGroup(), async ({ group }) => { expect(group).toBeTruthy(); expect(group.getAttribute('role')).toBe('group'); }); }); it('should render toggle button children', async () => { await usingAsync(await renderToggleGroup({}, [ createComponent(ToggleButton, { value: "a" }, "A"), createComponent(ToggleButton, { value: "b" }, "B"), createComponent(ToggleButton, { value: "c" }, "C"), ]), async ({ group }) => { const buttons = group.querySelectorAll('button[data-value]'); expect(buttons.length).toBe(3); }); }); }); describe('exclusive mode', () => { it('should call onValueChange with clicked value in exclusive mode', async () => { const handleChange = vi.fn(); await usingAsync(await renderToggleGroup({ exclusive: true, value: '', onValueChange: handleChange }, [ createComponent(ToggleButton, { value: "left" }, "Left"), createComponent(ToggleButton, { value: "center" }, "Center"), createComponent(ToggleButton, { value: "right" }, "Right"), ]), async ({ group }) => { const centerBtn = group.querySelector('button[data-value="center"]'); centerBtn.click(); expect(handleChange).toHaveBeenCalledWith('center'); }); }); it('should deselect in exclusive mode when clicking the already selected value', async () => { const handleChange = vi.fn(); await usingAsync(await renderToggleGroup({ exclusive: true, value: 'center', onValueChange: handleChange }, [ createComponent(ToggleButton, { value: "left" }, "Left"), createComponent(ToggleButton, { value: "center" }, "Center"), createComponent(ToggleButton, { value: "right" }, "Right"), ]), async ({ group }) => { const centerBtn = group.querySelector('button[data-value="center"]'); centerBtn.click(); expect(handleChange).toHaveBeenCalledWith(''); }); }); }); describe('multi-select mode', () => { it('should add value to selection in multi-select mode', async () => { const handleChange = vi.fn(); await usingAsync(await renderToggleGroup({ value: ['bold'], onValueChange: handleChange }, [ createComponent(ToggleButton, { value: "bold" }, "B"), createComponent(ToggleButton, { value: "italic" }, "I"), createComponent(ToggleButton, { value: "underline" }, "U"), ]), async ({ group }) => { const italicBtn = group.querySelector('button[data-value="italic"]'); italicBtn.click(); expect(handleChange).toHaveBeenCalledWith(['bold', 'italic']); }); }); it('should remove value from selection in multi-select mode', async () => { const handleChange = vi.fn(); await usingAsync(await renderToggleGroup({ value: ['bold', 'italic'], onValueChange: handleChange }, [ createComponent(ToggleButton, { value: "bold" }, "B"), createComponent(ToggleButton, { value: "italic" }, "I"), createComponent(ToggleButton, { value: "underline" }, "U"), ]), async ({ group }) => { const boldBtn = group.querySelector('button[data-value="bold"]'); boldBtn.click(); expect(handleChange).toHaveBeenCalledWith(['italic']); }); }); }); describe('selected state', () => { it('should mark the selected button with data-selected in exclusive mode', async () => { await usingAsync(await renderToggleGroup({ exclusive: true, value: 'center' }, [ createComponent(ToggleButton, { value: "left" }, "Left"), createComponent(ToggleButton, { value: "center" }, "Center"), createComponent(ToggleButton, { value: "right" }, "Right"), ]), async ({ group }) => { await flushUpdates(); await new Promise((resolve) => requestAnimationFrame(() => resolve())); const centerBtn = group.querySelector('button[data-value="center"]'); const leftBtn = group.querySelector('button[data-value="left"]'); expect(centerBtn.hasAttribute('data-selected')).toBe(true); expect(leftBtn.hasAttribute('data-selected')).toBe(false); }); }); it('should mark multiple selected buttons in multi-select mode', async () => { await usingAsync(await renderToggleGroup({ value: ['bold', 'underline'] }, [ createComponent(ToggleButton, { value: "bold" }, "B"), createComponent(ToggleButton, { value: "italic" }, "I"), createComponent(ToggleButton, { value: "underline" }, "U"), ]), async ({ group }) => { await flushUpdates(); await new Promise((resolve) => requestAnimationFrame(() => resolve())); const boldBtn = group.querySelector('button[data-value="bold"]'); const italicBtn = group.querySelector('button[data-value="italic"]'); const underlineBtn = group.querySelector('button[data-value="underline"]'); expect(boldBtn.hasAttribute('data-selected')).toBe(true); expect(italicBtn.hasAttribute('data-selected')).toBe(false); expect(underlineBtn.hasAttribute('data-selected')).toBe(true); }); }); }); describe('orientation', () => { it('should default to horizontal', async () => { await usingAsync(await renderToggleGroup(), async ({ group }) => { expect(group.getAttribute('data-orientation')).toBe('horizontal'); }); }); it('should support vertical', async () => { await usingAsync(await renderToggleGroup({ orientation: 'vertical' }), async ({ group }) => { expect(group.getAttribute('data-orientation')).toBe('vertical'); }); }); }); describe('color', () => { it('should set default color CSS variable when no color specified', async () => { await usingAsync(await renderToggleGroup(), async ({ group }) => { expect(group.style.getPropertyValue('--toggle-color-main')).toBe('var(--shades-theme-text-secondary)'); }); }); it('should set color CSS variable for a palette color', async () => { await usingAsync(await renderToggleGroup({ color: 'primary' }), async ({ group }) => { expect(group.style.getPropertyValue('--toggle-color-main')).toBe('var(--shades-theme-palette-primary-main)'); }); }); }); describe('size', () => { it('should not set data-size on children by default', async () => { await usingAsync(await renderToggleGroup({}, [ createComponent(ToggleButton, { value: "a" }, "A"), createComponent(ToggleButton, { value: "b" }, "B"), ]), async ({ group }) => { await flushUpdates(); await new Promise((resolve) => requestAnimationFrame(() => resolve())); const buttons = group.querySelectorAll('button[data-value]'); buttons.forEach((btn) => { expect(btn.hasAttribute('data-size')).toBe(false); }); }); }); it('should propagate size="small" to child buttons', async () => { await usingAsync(await renderToggleGroup({ size: 'small' }, [ createComponent(ToggleButton, { value: "a" }, "A"), createComponent(ToggleButton, { value: "b" }, "B"), ]), async ({ group }) => { await flushUpdates(); await new Promise((resolve) => requestAnimationFrame(() => resolve())); const buttons = group.querySelectorAll('button[data-value]'); buttons.forEach((btn) => { expect(btn.getAttribute('data-size')).toBe('small'); }); }); }); it('should propagate size="large" to child buttons', async () => { await usingAsync(await renderToggleGroup({ size: 'large' }, [ createComponent(ToggleButton, { value: "a" }, "A"), createComponent(ToggleButton, { value: "b" }, "B"), ]), async ({ group }) => { await flushUpdates(); await new Promise((resolve) => requestAnimationFrame(() => resolve())); const buttons = group.querySelectorAll('button[data-value]'); buttons.forEach((btn) => { expect(btn.getAttribute('data-size')).toBe('large'); }); }); }); it('should remove data-size when size is medium', async () => { await usingAsync(await renderToggleGroup({ size: 'medium' }, [ createComponent(ToggleButton, { value: "a", size: "small" }, "A"), ]), async ({ group }) => { await flushUpdates(); await new Promise((resolve) => requestAnimationFrame(() => resolve())); const btn = group.querySelector('button[data-value]'); expect(btn?.hasAttribute('data-size')).toBe(false); }); }); }); }); describe('SegmentedControl', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const defaultOptions = [ { value: 'daily', label: 'Daily' }, { value: 'weekly', label: 'Weekly' }, { value: 'monthly', label: 'Monthly' }, ]; const renderSegmented = async (props = {}) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: createComponent(SegmentedControl, { options: defaultOptions, ...props }), }); await flushUpdates(); return { injector, control: root.querySelector('shade-segmented-control'), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('rendering', () => { it('should render a segmented control', async () => { await usingAsync(await renderSegmented(), async ({ control }) => { expect(control).toBeTruthy(); expect(control.getAttribute('role')).toBe('radiogroup'); }); }); it('should render all options as buttons', async () => { await usingAsync(await renderSegmented(), async ({ control }) => { const buttons = control.querySelectorAll('.segmented-option'); expect(buttons.length).toBe(3); }); }); it('should render option labels', async () => { await usingAsync(await renderSegmented(), async ({ control }) => { const buttons = control.querySelectorAll('.segmented-option'); expect(buttons[0].textContent).toContain('Daily'); expect(buttons[1].textContent).toContain('Weekly'); expect(buttons[2].textContent).toContain('Monthly'); }); }); }); describe('selection', () => { it('should mark the selected option with data-selected', async () => { await usingAsync(await renderSegmented({ value: 'weekly' }), async ({ control }) => { const buttons = control.querySelectorAll('.segmented-option'); expect(buttons[0].hasAttribute('data-selected')).toBe(false); expect(buttons[1].hasAttribute('data-selected')).toBe(true); expect(buttons[2].hasAttribute('data-selected')).toBe(false); }); }); it('should set aria-checked on the selected option', async () => { await usingAsync(await renderSegmented({ value: 'daily' }), async ({ control }) => { const buttons = control.querySelectorAll('.segmented-option'); expect(buttons[0].getAttribute('aria-checked')).toBe('true'); expect(buttons[1].getAttribute('aria-checked')).toBe('false'); }); }); it('should call onValueChange when an option is clicked', async () => { const handleChange = vi.fn(); await usingAsync(await renderSegmented({ value: 'daily', onValueChange: handleChange }), async ({ control }) => { const monthlyBtn = control.querySelectorAll('.segmented-option')[2]; monthlyBtn.click(); expect(handleChange).toHaveBeenCalledWith('monthly'); }); }); it('should not call onValueChange when clicking the already selected option', async () => { const handleChange = vi.fn(); await usingAsync(await renderSegmented({ value: 'daily', onValueChange: handleChange }), async ({ control }) => { const dailyBtn = control.querySelectorAll('.segmented-option')[0]; dailyBtn.click(); expect(handleChange).not.toHaveBeenCalled(); }); }); }); describe('disabled', () => { it('should disable all options when disabled', async () => { await usingAsync(await renderSegmented({ disabled: true }), async ({ control }) => { const buttons = control.querySelectorAll('.segmented-option'); expect(buttons.length).toBe(3); buttons.forEach((btn) => { expect(btn.disabled).toBe(true); }); }); }); it('should disable individual options', async () => { const options = [ { value: 'a', label: 'A' }, { value: 'b', label: 'B', disabled: true }, { value: 'c', label: 'C' }, ]; await usingAsync(await renderSegmented({ options }), async ({ control }) => { const buttons = control.querySelectorAll('.segmented-option'); expect(buttons[0].disabled).toBe(false); expect(buttons[1].disabled).toBe(true); expect(buttons[2].disabled).toBe(false); }); }); it('should not call onValueChange when a disabled option is clicked', async () => { const handleChange = vi.fn(); const options = [ { value: 'a', label: 'A' }, { value: 'b', label: 'B', disabled: true }, ]; await usingAsync(await renderSegmented({ options, onValueChange: handleChange }), async ({ control }) => { const disabledBtn = control.querySelectorAll('.segmented-option')[1]; disabledBtn.click(); expect(handleChange).not.toHaveBeenCalled(); }); }); }); describe('size', () => { it('should not set data-size by default', async () => { await usingAsync(await renderSegmented(), async ({ control }) => { expect(control.hasAttribute('data-size')).toBe(false); }); }); it('should set data-size="small" for small size', async () => { await usingAsync(await renderSegmented({ size: 'small' }), async ({ control }) => { expect(control.getAttribute('data-size')).toBe('small'); }); }); it('should set data-size="large" for large size', async () => { await usingAsync(await renderSegmented({ size: 'large' }), async ({ control }) => { expect(control.getAttribute('data-size')).toBe('large'); }); }); }); describe('color', () => { it('should set default primary color CSS variable', async () => { await usingAsync(await renderSegmented(), async ({ control }) => { expect(control.style.getPropertyValue('--seg-color-main')).toBe('var(--shades-theme-palette-primary-main)'); }); }); it('should set color CSS variable for a palette color', async () => { await usingAsync(await renderSegmented({ color: 'secondary' }), async ({ control }) => { expect(control.style.getPropertyValue('--seg-color-main')).toBe('var(--shades-theme-palette-secondary-main)'); }); }); }); describe('custom styles', () => { it('should apply custom styles', async () => { await usingAsync(await renderSegmented({ style: { margin: '10px' } }), async ({ control }) => { expect(control.style.margin).toBe('10px'); }); }); }); }); describe('ToggleButtonGroup edge cases', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const renderToggleGroup = async (props = {}, children) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: createComponent(ToggleButtonGroup, { ...props }, children), }); await flushUpdates(); return { injector, group: root.querySelector('shade-toggle-button-group'), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; it('should handle string value for multi-select (non-array)', async () => { const handleChange = vi.fn(); await usingAsync(await renderToggleGroup({ value: 'bold', onValueChange: handleChange }, [ createComponent(ToggleButton, { value: "bold" }, "B"), createComponent(ToggleButton, { value: "italic" }, "I"), ]), async ({ group }) => { const italicBtn = group.querySelector('button[data-value="italic"]'); italicBtn.click(); expect(handleChange).toHaveBeenCalledWith(['bold', 'italic']); }); }); it('should handle empty value for multi-select', async () => { const handleChange = vi.fn(); await usingAsync(await renderToggleGroup({ value: undefined, onValueChange: handleChange }, [ createComponent(ToggleButton, { value: "bold" }, "B"), ]), async ({ group }) => { const boldBtn = group.querySelector('button[data-value="bold"]'); boldBtn.click(); expect(handleChange).toHaveBeenCalledWith(['bold']); }); }); it('should handle exclusive mode with array value', async () => { const handleChange = vi.fn(); await usingAsync(await renderToggleGroup({ exclusive: true, value: ['center'], onValueChange: handleChange }, [ createComponent(ToggleButton, { value: "left" }, "Left"), createComponent(ToggleButton, { value: "center" }, "Center"), ]), async ({ group }) => { const leftBtn = group.querySelector('button[data-value="left"]'); leftBtn.click(); expect(handleChange).toHaveBeenCalledWith('left'); }); }); it('should not call onValueChange when clicking a disabled button', async () => { const handleChange = vi.fn(); await usingAsync(await renderToggleGroup({ exclusive: true, value: '', onValueChange: handleChange }, [ createComponent(ToggleButton, { value: "a", disabled: true }, "A"), ]), async ({ group }) => { const btn = group.querySelector('button[data-value="a"]'); btn.click(); expect(handleChange).not.toHaveBeenCalled(); }); }); it('should disable all toggle buttons when group disabled is true', async () => { await usingAsync(await renderToggleGroup({ disabled: true }, [ createComponent(ToggleButton, { value: "a" }, "A"), createComponent(ToggleButton, { value: "b" }, "B"), ]), async ({ group }) => { await flushUpdates(); await new Promise((resolve) => requestAnimationFrame(() => resolve())); const buttons = group.querySelectorAll('button[data-value]'); buttons.forEach((btn) => { expect(btn.hasAttribute('disabled')).toBe(true); }); }); }); it('should apply vertical orientation border radius via CSS', async () => { await usingAsync(await renderToggleGroup({ orientation: 'vertical' }, [ createComponent(ToggleButton, { value: "a" }, "A"), createComponent(ToggleButton, { value: "b" }, "B"), ]), async ({ group }) => { await flushUpdates(); // Border-radius is now applied via CSS rules targeting // button:first-of-type / button:last-of-type within the group. // Verify the host has the correct orientation data attribute // that the CSS selectors depend on. expect(group.getAttribute('data-orientation')).toBe('vertical'); const buttons = group.querySelectorAll('button[data-value]'); expect(buttons.length).toBe(2); }); }); it('should apply custom style', async () => { await usingAsync(await renderToggleGroup({ style: { gap: '8px' } }), async ({ group }) => { expect(group.style.gap).toBe('8px'); }); }); }); //# sourceMappingURL=button-group.spec.js.map