UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

649 lines 32.8 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 { Form, FormContextToken } from '../form.js'; import { Input } from './input.js'; describe('Input', () => { 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(Input, null), }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input).not.toBeNull(); }); }); it('should render the inner input element', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "testField" }), }); await flushUpdates(); const input = document.querySelector('shade-input input'); expect(input).not.toBeNull(); expect(input.name).toBe('testField'); }); }); it('should render the label title', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { labelTitle: "Test Label" }), }); await flushUpdates(); const label = document.querySelector('shade-input label'); expect(label).not.toBeNull(); expect(label.textContent).toContain('Test Label'); }); }); describe('variants', () => { it('should set data-variant attribute for outlined variant', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { variant: "outlined" }), }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input).not.toBeNull(); expect(input.getAttribute('data-variant')).toBe('outlined'); }); }); it('should set data-variant attribute for contained variant', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { variant: "contained" }), }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input).not.toBeNull(); expect(input.getAttribute('data-variant')).toBe('contained'); }); }); it('should not have data-variant when variant is not specified', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, null), }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input).not.toBeNull(); expect(input.hasAttribute('data-variant')).toBe(false); }); }); }); 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(Input, { disabled: true }), }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input).not.toBeNull(); expect(input.hasAttribute('data-disabled')).toBe(true); }); }); it('should not have data-disabled attribute when not disabled', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { disabled: false }), }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input).not.toBeNull(); expect(input.hasAttribute('data-disabled')).toBe(false); }); }); }); describe('validation', () => { it('should call custom validation callback', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const getValidationResult = vi.fn().mockReturnValue({ isValid: true }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "email", getValidationResult: getValidationResult }), }); await flushUpdates(); const input = document.querySelector('shade-input input'); input.value = 'test@example.com'; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); expect(getValidationResult).toHaveBeenCalled(); }); }); it('should set data-invalid when validation fails', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Input, { name: "email", getValidationResult: () => ({ isValid: false, message: 'Invalid email' }), value: "invalid" })), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const input = inputWrapper.querySelector('input'); input.value = 'invalid'; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); expect(inputWrapper.hasAttribute('data-invalid')).toBe(true); }); }); it('should not have data-invalid when validation passes', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "email", getValidationResult: () => ({ isValid: true }), value: "valid@email.com" }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const input = inputWrapper.querySelector('input'); input.value = 'valid@email.com'; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); expect(inputWrapper.hasAttribute('data-invalid')).toBe(false); }); }); it('should display validation message in helper text when validation fails', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Input, { name: "email", getValidationResult: () => ({ isValid: false, message: 'Email is required' }) })), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const input = inputWrapper.querySelector('input'); input.value = ''; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); const helperText = inputWrapper.querySelector('.helperText'); expect(helperText.textContent).toBe('Email is required'); }); }); it('should show default validation message for required field', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", required: true }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const input = inputWrapper.querySelector('input'); const invalidEvent = new Event('invalid', { bubbles: true, cancelable: true }); input.dispatchEvent(invalidEvent); await flushUpdates(); const helperText = inputWrapper.querySelector('.helperText'); expect(helperText.textContent).toBe('Value is required'); }); }); }); describe('helper text', () => { it('should render custom helper text', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "email", getHelperText: () => 'Enter your email address' }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const helperText = inputWrapper.querySelector('.helperText'); expect(helperText.textContent).toBe('Enter your email address'); }); }); it('should call getHelperText with state and validation result when validation passes', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const getHelperText = vi.fn().mockReturnValue('Helper text'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Input, { name: "email", getHelperText: getHelperText, getValidationResult: () => ({ isValid: true }) })), }); await flushUpdates(); const input = document.querySelector('shade-input input'); input.value = 'test'; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); expect(getHelperText).toHaveBeenCalled(); const { calls } = getHelperText.mock; const callWithValidation = calls.find((call) => call[0].validationResult !== undefined); expect(callWithValidation).toBeDefined(); expect(callWithValidation[0].validationResult).toEqual({ isValid: true, }); expect(callWithValidation[0].state).toBeDefined(); }); }); it('should use validation message instead of getHelperText when validation fails with message', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const getHelperText = vi.fn().mockReturnValue('Fallback helper'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Input, { name: "email", getHelperText: getHelperText, getValidationResult: () => ({ isValid: false, message: 'Validation error message' }) })), }); await flushUpdates(); const input = document.querySelector('shade-input input'); input.value = 'test'; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const helperText = inputWrapper.querySelector('.helperText'); expect(helperText.textContent).toBe('Validation error message'); }); }); }); describe('icons', () => { it('should render start icon when getStartIcon is provided', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "search", getStartIcon: () => '🔍' }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const startIcon = inputWrapper.querySelector('.startIcon'); expect(startIcon).not.toBeNull(); expect(startIcon.textContent).toBe('🔍'); }); }); it('should render end icon when getEndIcon is provided', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "password", getEndIcon: () => '👁️' }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const endIcon = inputWrapper.querySelector('.endIcon'); expect(endIcon).not.toBeNull(); expect(endIcon.textContent).toBe('👁️'); }); }); it('should not render icon container when getStartIcon is not provided', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field" }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const startIcon = inputWrapper.querySelector('.startIcon'); expect(startIcon).toBeNull(); }); }); it('should update icons on state change', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", getEndIcon: ({ state }) => (state.focused ? '✓' : '○') }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const input = inputWrapper.querySelector('input'); const endIcon = inputWrapper.querySelector('.endIcon'); expect(endIcon.textContent).toBe('○'); input.dispatchEvent(new FocusEvent('focus')); await flushUpdates(); expect(endIcon.textContent).toBe('✓'); }); }); }); describe('theme integration', () => { it('should set CSS color variables from theme', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field" }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const themeService = injector.get(ThemeProviderService); expect(inputWrapper.style.getPropertyValue('--input-primary-color')).toBe(themeService.theme.palette.primary.main); expect(inputWrapper.style.getPropertyValue('--input-error-color')).toBe(themeService.theme.palette.error.main); }); }); it('should use custom color from defaultColor prop', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", defaultColor: "secondary" }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const themeService = injector.get(ThemeProviderService); expect(inputWrapper.style.getPropertyValue('--input-primary-color')).toBe(themeService.theme.palette.secondary.main); }); }); }); describe('callbacks', () => { it('should call onTextChange when input value changes', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const onTextChange = vi.fn(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", onTextChange: onTextChange }), }); await flushUpdates(); const input = document.querySelector('shade-input input'); input.value = 'new value'; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); expect(onTextChange).toHaveBeenCalledWith('new value'); }); }); it('should call onchange when input value changes', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const onchange = vi.fn(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", onchange: onchange }), }); await flushUpdates(); const input = document.querySelector('shade-input input'); input.value = 'test'; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); expect(onchange).toHaveBeenCalled(); }); }); }); describe('focus and blur', () => { it('should update state on focus', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const getEndIcon = vi.fn().mockReturnValue('icon'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", getEndIcon: getEndIcon }), }); await flushUpdates(); const input = document.querySelector('shade-input input'); input.dispatchEvent(new FocusEvent('focus')); await flushUpdates(); expect(getEndIcon).toHaveBeenLastCalledWith(expect.objectContaining({ state: expect.objectContaining({ focused: true, }), })); }); }); it('should update state on blur', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const getEndIcon = vi.fn().mockReturnValue('icon'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", getEndIcon: getEndIcon }), }); await flushUpdates(); const input = document.querySelector('shade-input input'); input.dispatchEvent(new FocusEvent('focus')); await flushUpdates(); input.dispatchEvent(new FocusEvent('blur')); await flushUpdates(); expect(getEndIcon).toHaveBeenLastCalledWith(expect.objectContaining({ state: expect.objectContaining({ focused: false, }), })); }); }); }); describe('autofocus', () => { it('should set initial focused state based on autofocus prop', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const getEndIcon = vi.fn().mockReturnValue('icon'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", autofocus: true, getEndIcon: getEndIcon }), }); await flushUpdates(); expect(getEndIcon).toHaveBeenCalledWith(expect.objectContaining({ state: expect.objectContaining({ focused: true, }), })); }); }); }); describe('FormService integration', () => { it('should register input with FormService when inside a Form', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (_data) => true }, createComponent(Input, { name: "email", labelTitle: "Email" }))), }); await flushUpdates(); const form = document.querySelector('form[is="shade-form"]'); const formInjector = form.injector; const formService = formInjector.get(FormContextToken); expect(formService.inputs.size).toBe(1); }); }); it('should update FormService field state on validation', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (_data) => true }, createComponent(Input, { name: "email", labelTitle: "Email", getValidationResult: ({ state }) => { if (state.value.includes('@')) { return { isValid: true }; } return { isValid: false, message: 'Invalid email format' }; } }))), }); await flushUpdates(); const form = document.querySelector('form[is="shade-form"]'); const inputWrapper = form.querySelector('shade-input'); const input = inputWrapper.querySelector('input'); input.value = 'invalid'; input.dispatchEvent(new Event('change', { bubbles: true })); await flushUpdates(); const formInjector = form.injector; const formService = formInjector.get(FormContextToken); const fieldErrors = formService.fieldErrors.getValue(); expect(fieldErrors.email).toBeDefined(); expect(fieldErrors.email?.validationResult).toEqual({ isValid: false, message: 'Invalid email format', }); }); }); it('should unregister input from FormService on cleanup', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (_data) => true }, createComponent(Input, { name: "email", labelTitle: "Email" }))), }); await flushUpdates(); const form = document.querySelector('form[is="shade-form"]'); const formInjector = form.injector; const formService = formInjector.get(FormContextToken); expect(formService.inputs.size).toBe(1); rootElement.innerHTML = ''; await flushUpdates(); }); }); }); describe('default validation messages', () => { it('should show message for typeMismatch', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "email", type: "email" }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const input = inputWrapper.querySelector('input'); input.value = 'not-an-email'; const invalidEvent = new Event('invalid', { bubbles: true, cancelable: true }); input.dispatchEvent(invalidEvent); await flushUpdates(); const helperText = inputWrapper.querySelector('.helperText'); expect(helperText.textContent).toBe('Value is not valid'); }); }); it('should handle pattern mismatch validation', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "code", pattern: "[A-Z]{3}" }), }); await flushUpdates(); const inputWrapper = document.querySelector('shade-input'); const input = inputWrapper.querySelector('input'); input.value = '123'; const invalidEvent = new Event('invalid', { bubbles: true, cancelable: true }); input.dispatchEvent(invalidEvent); await flushUpdates(); const helperText = inputWrapper.querySelector('.helperText'); expect(helperText.textContent).toBe('Value does not match the pattern'); }); }); }); describe('value handling', () => { it('should use initial value prop', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", value: "initial value" }), }); await flushUpdates(); const input = document.querySelector('shade-input input'); expect(input.value).toBe('initial value'); }); }); }); describe('labelProps', () => { it('should pass labelProps to the label element', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Input, { name: "field", labelProps: { className: 'custom-label' } }), }); await flushUpdates(); const label = document.querySelector('shade-input label'); expect(label.className).toContain('custom-label'); }); }); }); 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(Input, null) }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input.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(Input, { size: "medium" }) }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input.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(Input, { size: "small" }) }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input.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(Input, { size: "large" }) }); await flushUpdates(); const input = document.querySelector('shade-input'); expect(input.getAttribute('data-size')).toBe('large'); }); }); }); }); //# sourceMappingURL=input.spec.js.map