@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
624 lines • 30.5 kB
JavaScript
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