@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
554 lines • 27.2 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 { InputNumber } from './input-number.js';
describe('InputNumber', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>';
});
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
it('should render as custom element', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, null),
});
await flushUpdates();
const el = document.querySelector('shade-input-number');
expect(el).not.toBeNull();
});
});
it('should render the inner input, decrement and increment buttons', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5 }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
const buttons = wrapper.querySelectorAll('.step-button');
expect(input).not.toBeNull();
expect(input.value).toBe('5');
expect(buttons.length).toBe(2);
});
});
it('should render the label title', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { labelTitle: "Quantity" }),
});
await flushUpdates();
const label = document.querySelector('shade-input-number label');
expect(label).not.toBeNull();
expect(label.textContent).toContain('Quantity');
});
});
describe('increment and decrement', () => {
it('should increment value when + button is clicked', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const incrementBtn = wrapper.querySelectorAll('.step-button')[1];
incrementBtn.click();
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(6);
});
});
it('should decrement value when - button is clicked', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const decrementBtn = wrapper.querySelectorAll('.step-button')[0];
decrementBtn.click();
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(4);
});
});
it('should use custom step value', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 10, step: 5, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const incrementBtn = wrapper.querySelectorAll('.step-button')[1];
incrementBtn.click();
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(15);
});
});
});
describe('min and max', () => {
it('should clamp value to max', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 9, max: 10, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const incrementBtn = wrapper.querySelectorAll('.step-button')[1];
incrementBtn.click();
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(10);
});
});
it('should clamp value to min', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 1, min: 0, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const decrementBtn = wrapper.querySelectorAll('.step-button')[0];
decrementBtn.click();
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(0);
});
});
it('should disable decrement button when value equals min', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 0, min: 0 }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const decrementBtn = wrapper.querySelectorAll('.step-button')[0];
expect(decrementBtn.disabled).toBe(true);
});
});
it('should disable increment button when value equals max', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 100, max: 100 }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const incrementBtn = wrapper.querySelectorAll('.step-button')[1];
expect(incrementBtn.disabled).toBe(true);
});
});
});
describe('precision', () => {
it('should display value with specified precision', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 3.1, precision: 2 }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
expect(input.value).toBe('3.10');
});
});
it('should round to precision when stepping', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 1, step: 0.01, precision: 2, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const incrementBtn = wrapper.querySelectorAll('.step-button')[1];
incrementBtn.click();
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(1.01);
});
});
});
describe('keyboard support', () => {
it('should increment on ArrowUp', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(6);
});
});
it('should decrement on ArrowDown', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(4);
});
});
it('should not respond to keyboard when disabled', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, disabled: true, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flushUpdates();
expect(onValueChange).not.toHaveBeenCalled();
});
});
});
describe('formatter and parser', () => {
it('should display formatted value', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 1000, formatter: (v) => (v !== undefined ? `$${v.toLocaleString()}` : '') }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
expect(input.value).toBe('$1,000');
});
});
it('should use parser to interpret input text', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(InputNumber, { value: 0, parser: (text) => {
const cleaned = text.replace(/[^0-9.-]/g, '');
const num = Number(cleaned);
return isNaN(num) ? undefined : num;
}, onValueChange: onValueChange })),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
input.value = '$500';
input.dispatchEvent(new Event('change', { bubbles: true }));
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(500);
});
});
});
describe('direct text input', () => {
it('should parse typed value on blur', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 0, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
input.value = '42';
input.dispatchEvent(new Event('blur', { bubbles: true }));
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(42);
});
});
it('should handle empty input on blur', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
input.value = '';
input.dispatchEvent(new Event('blur', { bubbles: true }));
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(undefined);
});
});
});
describe('disabled state', () => {
it('should set data-disabled attribute when disabled', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { disabled: true }),
});
await flushUpdates();
const el = document.querySelector('shade-input-number');
expect(el.hasAttribute('data-disabled')).toBe(true);
});
});
it('should disable both step buttons when disabled', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, disabled: true }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const buttons = wrapper.querySelectorAll('.step-button');
expect(buttons[0].disabled).toBe(true);
expect(buttons[1].disabled).toBe(true);
});
});
it('should not change value when clicking buttons while disabled', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, disabled: true, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const incrementBtn = wrapper.querySelectorAll('.step-button')[1];
incrementBtn.click();
await flushUpdates();
expect(onValueChange).not.toHaveBeenCalled();
});
});
});
describe('variants', () => {
it('should set data-variant for outlined', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { variant: "outlined" }),
});
await flushUpdates();
const el = document.querySelector('shade-input-number');
expect(el.getAttribute('data-variant')).toBe('outlined');
});
});
it('should set data-variant for contained', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { variant: "contained" }),
});
await flushUpdates();
const el = document.querySelector('shade-input-number');
expect(el.getAttribute('data-variant')).toBe('contained');
});
});
});
describe('helper text', () => {
it('should render helper text', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { helperText: "Enter a quantity" }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const helperText = wrapper.querySelector('.helperText');
expect(helperText).not.toBeNull();
expect(helperText.textContent).toBe('Enter a quantity');
});
});
it('should not render helper text container when no helper text', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, null),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const helperText = wrapper.querySelector('.helperText');
expect(helperText).toBeNull();
});
});
});
describe('accessibility', () => {
it('should set role=spinbutton on the input', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { value: 5, min: 0, max: 10 }),
});
// Wait for render + requestAnimationFrame
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const input = wrapper.querySelector('input');
expect(input.getAttribute('role')).toBe('spinbutton');
expect(input.getAttribute('aria-valuemin')).toBe('0');
expect(input.getAttribute('aria-valuemax')).toBe('10');
expect(input.getAttribute('aria-valuenow')).toBe('5');
});
});
it('should set aria-label on step buttons', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, null),
});
// Wait for render + requestAnimationFrame
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const buttons = wrapper.querySelectorAll('.step-button');
expect(buttons[0].getAttribute('aria-label')).toBe('Decrease value');
expect(buttons[1].getAttribute('aria-label')).toBe('Increase value');
});
});
});
describe('no initial value', () => {
it('should start from min when incrementing with no value', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { min: 1, onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const incrementBtn = wrapper.querySelectorAll('.step-button')[1];
incrementBtn.click();
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(2);
});
});
it('should start from 0 when incrementing with no value and no min', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onValueChange = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(InputNumber, { onValueChange: onValueChange }),
});
await flushUpdates();
const wrapper = document.querySelector('shade-input-number');
const incrementBtn = wrapper.querySelectorAll('.step-button')[1];
incrementBtn.click();
await flushUpdates();
expect(onValueChange).toHaveBeenCalledWith(1);
});
});
});
describe('size', () => {
it('should not set data-size when size is not specified', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(InputNumber, null) });
await flushUpdates();
const el = document.querySelector('shade-input-number');
expect(el.getAttribute('data-size')).toBeNull();
});
});
it('should not set data-size for medium size (default)', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(InputNumber, { size: "medium" }) });
await flushUpdates();
const el = document.querySelector('shade-input-number');
expect(el.getAttribute('data-size')).toBeNull();
});
});
it('should set data-size="small" for small size', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(InputNumber, { size: "small" }) });
await flushUpdates();
const el = document.querySelector('shade-input-number');
expect(el.getAttribute('data-size')).toBe('small');
});
});
it('should set data-size="large" for large size', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(InputNumber, { size: "large" }) });
await flushUpdates();
const el = document.querySelector('shade-input-number');
expect(el.getAttribute('data-size')).toBe('large');
});
});
});
});
//# sourceMappingURL=input-number.spec.js.map