Skip to content

Commit

Permalink
feat: implement disabled form tree (#71)
Browse files Browse the repository at this point in the history
* feat: add cascading disable logic to option groups and options

* refactor: add a barrel file for easy importing

* feat: implement cascading disabled context for all inputs

* chore: include all components in playground

* feat: implement form-level disabled

* chore: add changeset
  • Loading branch information
logaretm authored Nov 12, 2024
1 parent d3601ba commit bbba9bf
Show file tree
Hide file tree
Showing 25 changed files with 237 additions and 93 deletions.
5 changes: 5 additions & 0 deletions .changeset/green-starfishes-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@formwerk/core': minor
---

feat: implement disabled form tree
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { InjectionKey, MaybeRefOrGetter, Ref, computed, inject, provide, toValue } from 'vue';

interface DisabledContext {
isDisabled: Ref<boolean>;
}

const DisabledContextKey: InjectionKey<DisabledContext> = Symbol('disabledContextKey');

/**
* Create a disabled context.
* @param isDisabled - The disabled state.
* @param terminate - Whether to terminate the context and not provide it further.
* @returns The disabled state.
*/
export function createDisabledContext(isDisabled?: MaybeRefOrGetter<boolean | undefined>) {
const parentContext = inject(DisabledContextKey, null);
const context: DisabledContext = {
isDisabled: computed(() => parentContext?.isDisabled.value || toValue(isDisabled) || false),
};

provide(DisabledContextKey, context);

return context.isDisabled;
}
1 change: 1 addition & 0 deletions packages/core/src/helpers/createDisabledContext/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './createDisabledContext';
12 changes: 5 additions & 7 deletions packages/core/src/useCheckbox/useCheckbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function useCheckbox<TValue = string>(
disableHtmlValidation: props.disableHtmlValidation,
});
}
const { fieldValue, setTouched, setValue, errorMessage, setErrors } = field;
const { fieldValue, setTouched, setValue, errorMessage, setErrors, isDisabled } = field;

const checked = computed({
get() {
Expand All @@ -103,10 +103,8 @@ export function useCheckbox<TValue = string>(
errorMessage,
});

const isDisabled = () => (toValue(props.disabled) || group?.disabled) ?? false;
const isReadOnly = () => (toValue(props.readonly) || group?.readonly) ?? false;

const isMutable = () => !isDisabled() && !isReadOnly() && !toValue(props.indeterminate);
const isMutable = () => !isDisabled.value && !isReadOnly() && !toValue(props.indeterminate);

function createHandlers(isInput: boolean) {
const baseHandlers = {
Expand Down Expand Up @@ -163,7 +161,7 @@ export function useCheckbox<TValue = string>(
[isInput ? 'checked' : 'aria-checked']: checked.value,
[isInput ? 'required' : 'aria-required']: (group ? group.required : toValue(props.required)) || undefined,
[isInput ? 'readonly' : 'aria-readonly']: isReadOnly() || undefined,
[isInput ? 'disabled' : 'aria-disabled']: isDisabled() || undefined,
[isInput ? 'disabled' : 'aria-disabled']: isDisabled.value || undefined,
...(group
? {}
: {
Expand All @@ -183,14 +181,14 @@ export function useCheckbox<TValue = string>(
return {
...base,
role: 'checkbox',
tabindex: toValue(props.disabled) ? '-1' : '0',
tabindex: isDisabled.value ? '-1' : '0',
};
}

group?.useCheckboxRegistration({
id: inputId,
getElem: () => inputEl.value,
isDisabled,
isDisabled: () => isDisabled.value,
isChecked: () => checked.value,
setChecked: (force?: boolean) => {
if (!isMutable()) {
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/useCheckbox/useCheckboxGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export interface CheckboxRegistration {

export interface CheckboxGroupContext<TCheckbox> {
name: string;
disabled: boolean;
readonly: boolean;
required: boolean;
field: FormField<CheckboxGroupValue<TCheckbox>>;
Expand Down Expand Up @@ -97,6 +96,7 @@ export function useCheckboxGroup<TCheckbox>(_props: Reactivify<CheckboxGroupProp
path: props.name,
initialValue: toValue(props.modelValue),
schema: props.schema,
disabled: props.disabled,
});

const { validityDetails, updateValidity } = useInputValidity({
Expand All @@ -107,7 +107,7 @@ export function useCheckboxGroup<TCheckbox>(_props: Reactivify<CheckboxGroupProp
disableHtmlValidation: props.disableHtmlValidation,
});

const { fieldValue, setValue, isTouched, setTouched, errorMessage } = field;
const { fieldValue, setValue, isTouched, setTouched, errorMessage, isDisabled } = field;
const { describedByProps, descriptionProps } = createDescribedByProps({
inputId: groupId,
description: props.description,
Expand Down Expand Up @@ -137,6 +137,10 @@ export function useCheckboxGroup<TCheckbox>(_props: Reactivify<CheckboxGroupProp
}

function toggleValue(value: TCheckbox, force?: boolean) {
if (isDisabled.value || toValue(props.readonly)) {
return;
}

const nextValue = toggleValueSelection(fieldValue.value ?? [], value, force);

setValue(nextValue);
Expand Down Expand Up @@ -175,7 +179,6 @@ export function useCheckboxGroup<TCheckbox>(_props: Reactivify<CheckboxGroupProp

const context: CheckboxGroupContext<TCheckbox> = reactive({
name: computed(() => toValue(props.name) ?? groupId),
disabled: computed(() => toValue(props.disabled) ?? false),
readonly: computed(() => toValue(props.readonly) ?? false),
required: computed(() => toValue(props.required) ?? false),
field: markRaw(field),
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/useForm/useForm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed, InjectionKey, onMounted, provide, reactive, readonly, Ref, ref } from 'vue';
import { computed, InjectionKey, MaybeRefOrGetter, onMounted, provide, reactive, readonly, Ref, ref } from 'vue';
import type { v1 } from '@standard-schema/spec';
import { cloneDeep, isEqual, useUniqId } from '../utils/common';
import {
Expand All @@ -24,13 +24,15 @@ import { getConfig } from '../config';
import { FieldTypePrefixes } from '../constants';
import { appendToFormData, clearFormData } from '../utils/formData';
import { PartialDeep } from 'type-fest';
import { createDisabledContext } from '../helpers/createDisabledContext';

export interface FormOptions<TSchema extends GenericFormSchema, TInput extends FormObject = v1.InferInput<TSchema>> {
id: string;
initialValues: MaybeGetter<MaybeAsync<TInput>>;
initialTouched: TouchedSchema<TInput>;
schema: TSchema;
disableHtmlValidation: boolean;
disabled: MaybeRefOrGetter<boolean | undefined>;
}

export interface FormContext<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput>
Expand Down Expand Up @@ -64,6 +66,7 @@ export function useForm<
});

const id = opts?.id || useUniqId(FieldTypePrefixes.Form);
const isDisabled = createDisabledContext(opts?.disabled);
const isHtmlValidationDisabled = () => opts?.disableHtmlValidation ?? getConfig().disableHtmlValidation;
const values = reactive(cloneDeep(valuesSnapshot.originals.value)) as PartialDeep<TInput>;
const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema<TInput>;
Expand Down Expand Up @@ -155,6 +158,7 @@ export function useForm<
isTouched,
isDirty,
isValid,
isDisabled,
isFieldDirty: ctx.isFieldDirty,
setFieldValue: ctx.setFieldValue,
getFieldValue: ctx.getFieldValue,
Expand Down
33 changes: 16 additions & 17 deletions packages/core/src/useFormField/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cloneDeep, isEqual, normalizeArrayable, combineIssues, tryOnScopeDispos
import { FormGroupKey } from '../useFormGroup';
import { useErrorDisplay } from './useErrorDisplay';
import { usePathPrefixer } from '../helpers/usePathPrefixer';
import { createDisabledContext } from '../helpers/createDisabledContext';

interface FormFieldOptions<TValue = unknown> {
path: MaybeRefOrGetter<string | undefined> | undefined;
Expand All @@ -22,6 +23,7 @@ export type FormField<TValue> = {
isTouched: Ref<boolean>;
isDirty: Ref<boolean>;
isValid: Ref<boolean>;
isDisabled: Ref<boolean>;
errors: Ref<string[]>;
errorMessage: Ref<string>;
schema: StandardSchema<TValue> | undefined;
Expand All @@ -38,6 +40,7 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
const form = inject(FormKey, null);
const formGroup = inject(FormGroupKey, null);
const pathPrefixer = usePathPrefixer();
const isDisabled = createDisabledContext(opts?.disabled);
const getPath = () => {
const path = toValue(opts?.path);

Expand Down Expand Up @@ -108,6 +111,7 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
isValid,
errors,
errorMessage,
isDisabled,
schema: opts?.schema,
validate,
getPath,
Expand All @@ -122,7 +126,7 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
return field;
}

initFormPathIfNecessary(form, getPath, initialValue, opts?.initialTouched ?? false, toValue(opts?.disabled) ?? false);
initFormPathIfNecessary(form, getPath, initialValue, opts?.initialTouched ?? false, isDisabled);

form.onSubmitAttempt(() => {
setTouched(true);
Expand Down Expand Up @@ -159,26 +163,21 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
path: newPath,
value: cloneDeep(oldPath ? tf.getFieldValue(oldPath) : pathlessValue.value),
touched: oldPath ? tf.isFieldTouched(oldPath) : pathlessTouched.value,
disabled: toValue(opts?.disabled) ?? false,
disabled: isDisabled.value,
errors: [...(oldPath ? tf.getFieldErrors(oldPath) : pathlessValidity.errors.value)],
};
});
}
});

if (opts?.disabled) {
watch(
() => toValue(opts?.disabled) ?? false,
disabled => {
const path = getPath();
if (!path) {
return;
}

form.setFieldDisabled(path, disabled);
},
);
}
watch(isDisabled, disabled => {
const path = getPath();
if (!path) {
return;
}

form.setFieldDisabled(path, disabled);
});

return field;
}
Expand Down Expand Up @@ -292,7 +291,7 @@ function initFormPathIfNecessary(
getPath: Getter<string | undefined>,
initialValue: unknown,
initialTouched: boolean,
disabled: boolean,
isDisabled: MaybeRefOrGetter<boolean>,
) {
const path = getPath();
if (!path) {
Expand All @@ -306,7 +305,7 @@ function initFormPathIfNecessary(
path,
value: initialValue ?? form.getFieldInitialValue(path),
touched: initialTouched,
disabled,
disabled: toValue(isDisabled),
errors: [...tf.getFieldErrors(path)],
}));
});
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/useFormGroup/useFormGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import { FormValidationMode } from '../useForm/formContext';
import { prefixPath as _prefixPath } from '../utils/path';
import { getConfig } from '../config';
import { createPathPrefixer } from '../helpers/usePathPrefixer';
import { createDisabledContext } from '../helpers/createDisabledContext';

export interface FormGroupProps<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput> {
name: string;
label?: string;
schema?: StandardSchema<TInput, TOutput>;
disabled?: boolean;
disableHtmlValidation?: boolean;
}

Expand Down Expand Up @@ -48,6 +50,7 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
const getPath = () => toValue(props.name);
const groupEl = elementRef || shallowRef<HTMLInputElement>();
const form = inject(FormKey, null);
const isDisabled = createDisabledContext(props.disabled);
const isHtmlValidationDisabled = () =>
toValue(props.disableHtmlValidation) ?? form?.isHtmlValidationDisabled() ?? getConfig().disableHtmlValidation;
const { validate, onValidationDispatch, defineValidationRequest, onValidationDone, dispatchValidateDone } =
Expand Down Expand Up @@ -161,6 +164,7 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
isDirty,
isValid,
isTouched,
isDisabled,
getErrors,
getValues,
getError,
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/useNumberField/useNumberField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function useNumberField(
field,
disableHtmlValidation: props.disableHtmlValidation,
});
const { fieldValue, setValue, setTouched, errorMessage } = field;
const { fieldValue, setValue, setTouched, errorMessage, isDisabled } = field;
const formattedText = computed<string>(() => {
if (Number.isNaN(fieldValue.value) || isEmpty(fieldValue.value)) {
return '';
Expand Down Expand Up @@ -124,7 +124,7 @@ export function useNumberField(
min: props.min,
max: props.max,
readonly: props.readonly,
disabled: () => toValue(props.disabled) || toValue(props.readonly),
disabled: () => isDisabled.value || toValue(props.readonly),
incrementLabel: props.incrementLabel,
decrementLabel: props.decrementLabel,
orientation: 'vertical',
Expand Down Expand Up @@ -187,7 +187,7 @@ export function useNumberField(
const inputProps = computed<NumberInputDOMProps>(() => {
return withRefCapture(
{
...propsToValues(props, ['name', 'placeholder', 'required', 'readonly', 'disabled']),
...propsToValues(props, ['name', 'placeholder', 'required', 'readonly']),
...labelledByProps.value,
...describedByProps.value,
...accessibleErrorProps.value,
Expand All @@ -196,6 +196,7 @@ export function useNumberField(
id: inputId,
inputmode: inputMode.value,
value: formattedText.value,
disabled: isDisabled.value ? true : undefined,
max: toValue(props.max),
min: toValue(props.min),
type: 'text',
Expand All @@ -217,7 +218,7 @@ export function useNumberField(

decrement();
},
{ disabled: () => toValue(props.disableWheel), passive: true },
{ disabled: () => isDisabled.value || toValue(props.disableWheel), passive: true },
);

return {
Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/useRadio/useRadio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AriaInputProps, AriaLabelableProps, InputBaseAttributes, Reactivify, Ro
import { useLabel } from '../a11y/useLabel';
import { RadioGroupContext, RadioGroupKey } from './useRadioGroup';
import { FieldTypePrefixes } from '../constants';
import { createDisabledContext } from '../helpers/createDisabledContext';

export interface RadioProps<TValue = string> {
value: TValue;
Expand All @@ -30,6 +31,7 @@ export function useRadio<TValue = string>(
const group: RadioGroupContext<TValue> | null = inject(RadioGroupKey, null);
const inputEl = elementRef || ref<HTMLInputElement>();
const checked = computed(() => isEqual(group?.modelValue, toValue(props.value)));
const isDisabled = createDisabledContext(props.disabled);
const { labelProps, labelledByProps } = useLabel({
for: inputId,
label: props.label,
Expand All @@ -43,8 +45,7 @@ export function useRadio<TValue = string>(
}

const isReadOnly = () => group?.readonly ?? false;
const isDisabled = () => (toValue(props.disabled) || group?.disabled) ?? false;
const isMutable = () => !isReadOnly() && !isDisabled();
const isMutable = () => !isReadOnly() && !isDisabled.value;

function focus() {
inputEl.value?.focus();
Expand All @@ -66,7 +67,7 @@ export function useRadio<TValue = string>(
id: inputId,
getElem: () => inputEl.value,
isChecked: () => checked.value,
isDisabled,
isDisabled: () => isDisabled.value,
setChecked,
});

Expand Down Expand Up @@ -97,7 +98,7 @@ export function useRadio<TValue = string>(
id: inputId,
[isInput ? 'checked' : 'aria-checked']: checked.value,
[isInput ? 'readonly' : 'aria-readonly']: group?.readonly || undefined,
[isInput ? 'disabled' : 'aria-disabled']: isDisabled() || undefined,
[isInput ? 'disabled' : 'aria-disabled']: isDisabled.value || undefined,
[isInput ? 'required' : 'aria-required']: group?.required,
};

Expand All @@ -112,7 +113,7 @@ export function useRadio<TValue = string>(
return {
...base,
role: 'radio',
tabindex: checked.value ? '0' : registration?.canReceiveFocus() ? '0' : '-1',
tabindex: checked.value ? '0' : registration?.canReceiveFocus() && !isDisabled.value ? '0' : '-1',
};
}

Expand All @@ -122,7 +123,7 @@ export function useRadio<TValue = string>(
inputEl,
inputProps,
isChecked: checked,
isDisabled: computed(() => isDisabled()),
isDisabled,
labelProps,
};
}
Loading

0 comments on commit bbba9bf

Please sign in to comment.