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