UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

137 lines 6.37 kB
import { defineService } from '@furystack/inject'; import { Shade, createComponent } from '@furystack/shades'; import { ObservableValue } from '@furystack/utils'; import { cssVariableTheme } from '../services/css-variable-theme.js'; /** * Creates a fresh {@link FormService} instance. Called by `<Form>` to populate * the `FormContextToken` on the form's child scope so descendant inputs can * discover it. */ export const createFormService = () => { const validatedFormData = new ObservableValue(null); const rawFormData = new ObservableValue(null); const validationResult = new ObservableValue({ isValid: null }); const fieldErrors = new ObservableValue({}); const inputs = new Set(); const isSubmitting = new ObservableValue(false); const submitError = new ObservableValue(undefined); const setFieldState = (key, fieldValidationResult, validity) => { fieldErrors.setValue({ ...fieldErrors.getValue(), [key]: { validationResult: fieldValidationResult, validity }, }); }; return { validatedFormData, rawFormData, validationResult, fieldErrors, inputs, isSubmitting, submitError, setFieldState, [Symbol.dispose]() { // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <Form> via useDisposable. validatedFormData[Symbol.dispose](); // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <Form> via useDisposable. rawFormData[Symbol.dispose](); // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <Form> via useDisposable. validationResult[Symbol.dispose](); // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <Form> via useDisposable. fieldErrors[Symbol.dispose](); // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <Form> via useDisposable. isSubmitting[Symbol.dispose](); // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is triggered by the owning <Form> via useDisposable. submitError[Symbol.dispose](); }, }; }; /** * Scoped token used by `<Form>` to publish a {@link FormService} instance to * descendant inputs. Defaults to `null` so inputs rendered outside a `<Form>` * can gracefully skip form integration. */ export const FormContextToken = defineService({ name: '@furystack/shades-common-components/FormContextToken', lifetime: 'scoped', factory: () => null, }); export const Form = Shade({ customElementName: 'shade-form', elementBase: HTMLFormElement, elementBaseName: 'form', css: { fontFamily: cssVariableTheme.typography.fontFamily }, render: ({ props, children, useDisposable, injector, useHostProps }) => { const formService = useDisposable('formService', () => createFormService()); const formInjector = useDisposable('formInjector', () => { const scope = injector.createScope({ owner: 'form' }); scope.bind(FormContextToken, () => formService); return scope; }); // Propagate the scoped injector on the host element so child Shade components // can discover it via getInjectorFromParent(). This works because useHostProps // assigns object values as properties on the host element, which sets the // `injector` setter defined on the Shade base class. useHostProps({ injector: formInjector }); const changeHandler = async (ev, shouldSubmit) => { formService.inputs.forEach((i) => { const e = document.createEvent('FocusEvent'); e.initEvent('blur', true, true); i.dispatchEvent(e); }); const formElement = ev.currentTarget; const formData = Object.fromEntries(new FormData(formElement).entries()); formService.rawFormData.setValue(formData); const currentFieldErrors = formService.fieldErrors.getValue(); if (Object.values(currentFieldErrors).some((v) => v?.validationResult.isValid === false) || [...formService.inputs].some((input) => !input.validity.valid)) { formService.validationResult.setValue({ isValid: false, reason: 'input-validation-failed' }); } else if (props.validate(formData)) { formService.validationResult.setValue({ isValid: true }); formService.validatedFormData.setValue(formData); if (shouldSubmit) { formService.isSubmitting.setValue(true); formService.submitError.setValue(undefined); if (props.disableOnSubmit) { formElement.inert = true; } try { await props.onSubmit(formData); } catch (error) { formService.submitError.setValue(error); } finally { formService.isSubmitting.setValue(false); if (props.disableOnSubmit) { formElement.inert = false; } } } } else { formService.validationResult.setValue({ isValid: false, reason: 'validation-failed' }); } }; useHostProps({ oninvalid: (ev) => { void changeHandler(ev); }, onsubmit: (ev) => { ev.preventDefault(); void changeHandler(ev, true); }, onchange: (ev) => { void changeHandler(ev); }, onreset: () => { formService.rawFormData.setValue(null); formService.validationResult.setValue({ isValid: null }); formService.validatedFormData.setValue(null); }, }); return createComponent(createComponent, null, children); }, }); //# sourceMappingURL=form.js.map