diff --git a/.changeset/green-starfishes-join.md b/.changeset/green-starfishes-join.md new file mode 100644 index 0000000..1b2297c --- /dev/null +++ b/.changeset/green-starfishes-join.md @@ -0,0 +1,5 @@ +--- +'@formwerk/core': minor +--- + +feat: implement disabled form tree diff --git a/packages/core/src/helpers/createDisabledContext/createDisabledContext.ts b/packages/core/src/helpers/createDisabledContext/createDisabledContext.ts new file mode 100644 index 0000000..224ea28 --- /dev/null +++ b/packages/core/src/helpers/createDisabledContext/createDisabledContext.ts @@ -0,0 +1,24 @@ +import { InjectionKey, MaybeRefOrGetter, Ref, computed, inject, provide, toValue } from 'vue'; + +interface DisabledContext { + isDisabled: Ref; +} + +const DisabledContextKey: InjectionKey = 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) { + const parentContext = inject(DisabledContextKey, null); + const context: DisabledContext = { + isDisabled: computed(() => parentContext?.isDisabled.value || toValue(isDisabled) || false), + }; + + provide(DisabledContextKey, context); + + return context.isDisabled; +} diff --git a/packages/core/src/helpers/createDisabledContext/index.ts b/packages/core/src/helpers/createDisabledContext/index.ts new file mode 100644 index 0000000..2e3deb7 --- /dev/null +++ b/packages/core/src/helpers/createDisabledContext/index.ts @@ -0,0 +1 @@ +export * from './createDisabledContext'; diff --git a/packages/core/src/useCheckbox/useCheckbox.ts b/packages/core/src/useCheckbox/useCheckbox.ts index 716a4d7..68e99fa 100644 --- a/packages/core/src/useCheckbox/useCheckbox.ts +++ b/packages/core/src/useCheckbox/useCheckbox.ts @@ -77,7 +77,7 @@ export function useCheckbox( disableHtmlValidation: props.disableHtmlValidation, }); } - const { fieldValue, setTouched, setValue, errorMessage, setErrors } = field; + const { fieldValue, setTouched, setValue, errorMessage, setErrors, isDisabled } = field; const checked = computed({ get() { @@ -103,10 +103,8 @@ export function useCheckbox( 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 = { @@ -163,7 +161,7 @@ export function useCheckbox( [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 ? {} : { @@ -183,14 +181,14 @@ export function useCheckbox( 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()) { diff --git a/packages/core/src/useCheckbox/useCheckboxGroup.ts b/packages/core/src/useCheckbox/useCheckboxGroup.ts index 47abf46..076b51e 100644 --- a/packages/core/src/useCheckbox/useCheckboxGroup.ts +++ b/packages/core/src/useCheckbox/useCheckboxGroup.ts @@ -41,7 +41,6 @@ export interface CheckboxRegistration { export interface CheckboxGroupContext { name: string; - disabled: boolean; readonly: boolean; required: boolean; field: FormField>; @@ -97,6 +96,7 @@ export function useCheckboxGroup(_props: Reactivify(_props: Reactivify(_props: Reactivify(_props: Reactivify = 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), diff --git a/packages/core/src/useForm/useForm.ts b/packages/core/src/useForm/useForm.ts index 420e7aa..6f03cca 100644 --- a/packages/core/src/useForm/useForm.ts +++ b/packages/core/src/useForm/useForm.ts @@ -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 { @@ -24,6 +24,7 @@ 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> { id: string; @@ -31,6 +32,7 @@ export interface FormOptions; schema: TSchema; disableHtmlValidation: boolean; + disabled: MaybeRefOrGetter; } export interface FormContext @@ -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; const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema; @@ -155,6 +158,7 @@ export function useForm< isTouched, isDirty, isValid, + isDisabled, isFieldDirty: ctx.isFieldDirty, setFieldValue: ctx.setFieldValue, getFieldValue: ctx.getFieldValue, diff --git a/packages/core/src/useFormField/useFormField.ts b/packages/core/src/useFormField/useFormField.ts index 0b9b9d9..fd8124f 100644 --- a/packages/core/src/useFormField/useFormField.ts +++ b/packages/core/src/useFormField/useFormField.ts @@ -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 { path: MaybeRefOrGetter | undefined; @@ -22,6 +23,7 @@ export type FormField = { isTouched: Ref; isDirty: Ref; isValid: Ref; + isDisabled: Ref; errors: Ref; errorMessage: Ref; schema: StandardSchema | undefined; @@ -38,6 +40,7 @@ export function useFormField(opts?: Partial { const path = toValue(opts?.path); @@ -108,6 +111,7 @@ export function useFormField(opts?: Partial(opts?: Partial { setTouched(true); @@ -159,26 +163,21 @@ export function useFormField(opts?: Partial 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; } @@ -292,7 +291,7 @@ function initFormPathIfNecessary( getPath: Getter, initialValue: unknown, initialTouched: boolean, - disabled: boolean, + isDisabled: MaybeRefOrGetter, ) { const path = getPath(); if (!path) { @@ -306,7 +305,7 @@ function initFormPathIfNecessary( path, value: initialValue ?? form.getFieldInitialValue(path), touched: initialTouched, - disabled, + disabled: toValue(isDisabled), errors: [...tf.getFieldErrors(path)], })); }); diff --git a/packages/core/src/useFormGroup/useFormGroup.ts b/packages/core/src/useFormGroup/useFormGroup.ts index d44f00a..4357c9f 100644 --- a/packages/core/src/useFormGroup/useFormGroup.ts +++ b/packages/core/src/useFormGroup/useFormGroup.ts @@ -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 { name: string; label?: string; schema?: StandardSchema; + disabled?: boolean; disableHtmlValidation?: boolean; } @@ -48,6 +50,7 @@ export function useFormGroup toValue(props.name); const groupEl = elementRef || shallowRef(); 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 } = @@ -161,6 +164,7 @@ export function useFormGroup(() => { if (Number.isNaN(fieldValue.value) || isEmpty(fieldValue.value)) { return ''; @@ -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', @@ -187,7 +187,7 @@ export function useNumberField( const inputProps = computed(() => { return withRefCapture( { - ...propsToValues(props, ['name', 'placeholder', 'required', 'readonly', 'disabled']), + ...propsToValues(props, ['name', 'placeholder', 'required', 'readonly']), ...labelledByProps.value, ...describedByProps.value, ...accessibleErrorProps.value, @@ -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', @@ -217,7 +218,7 @@ export function useNumberField( decrement(); }, - { disabled: () => toValue(props.disableWheel), passive: true }, + { disabled: () => isDisabled.value || toValue(props.disableWheel), passive: true }, ); return { diff --git a/packages/core/src/useRadio/useRadio.ts b/packages/core/src/useRadio/useRadio.ts index a07b6c0..ca3a6e2 100644 --- a/packages/core/src/useRadio/useRadio.ts +++ b/packages/core/src/useRadio/useRadio.ts @@ -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 { value: TValue; @@ -30,6 +31,7 @@ export function useRadio( const group: RadioGroupContext | null = inject(RadioGroupKey, null); const inputEl = elementRef || ref(); const checked = computed(() => isEqual(group?.modelValue, toValue(props.value))); + const isDisabled = createDisabledContext(props.disabled); const { labelProps, labelledByProps } = useLabel({ for: inputId, label: props.label, @@ -43,8 +45,7 @@ export function useRadio( } 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(); @@ -66,7 +67,7 @@ export function useRadio( id: inputId, getElem: () => inputEl.value, isChecked: () => checked.value, - isDisabled, + isDisabled: () => isDisabled.value, setChecked, }); @@ -97,7 +98,7 @@ export function useRadio( 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, }; @@ -112,7 +113,7 @@ export function useRadio( return { ...base, role: 'radio', - tabindex: checked.value ? '0' : registration?.canReceiveFocus() ? '0' : '-1', + tabindex: checked.value ? '0' : registration?.canReceiveFocus() && !isDisabled.value ? '0' : '-1', }; } @@ -122,7 +123,7 @@ export function useRadio( inputEl, inputProps, isChecked: checked, - isDisabled: computed(() => isDisabled()), + isDisabled, labelProps, }; } diff --git a/packages/core/src/useRadio/useRadioGroup.ts b/packages/core/src/useRadio/useRadioGroup.ts index 1d9caca..18b16ec 100644 --- a/packages/core/src/useRadio/useRadioGroup.ts +++ b/packages/core/src/useRadio/useRadioGroup.ts @@ -27,7 +27,6 @@ import { exposeField } from '../utils/exposers'; export interface RadioGroupContext { name: string; - disabled: boolean; readonly: boolean; required: boolean; @@ -94,7 +93,6 @@ export function useRadioGroup(_props: Reactivify([]); const { labelProps, labelledByProps } = useLabel({ for: groupId, @@ -116,7 +114,7 @@ export function useRadioGroup(_props: Reactivify(_props: Reactivify(_props: Reactivify(_props: Reactivify = 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), modelValue: fieldValue, diff --git a/packages/core/src/useSearchField/useSearchField.ts b/packages/core/src/useSearchField/useSearchField.ts index b0e6734..a11587c 100644 --- a/packages/core/src/useSearchField/useSearchField.ts +++ b/packages/core/src/useSearchField/useSearchField.ts @@ -23,6 +23,7 @@ import { useLabel } from '../a11y/useLabel'; import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; import { exposeField } from '../utils/exposers'; +import { createDisabledContext } from '../helpers/createDisabledContext'; export interface SearchInputDOMAttributes extends TextInputBaseAttributes { type?: 'search'; @@ -68,14 +69,15 @@ export function useSearchField( const props = normalizeProps(_props, ['onSubmit', 'schema']); const inputId = useUniqId(FieldTypePrefixes.SearchField); const inputEl = elementRef || ref(); + const isDisabled = createDisabledContext(props.disabled); const field = useFormField({ path: props.name, initialValue: toValue(props.modelValue) ?? toValue(props.value), - disabled: props.disabled, + disabled: isDisabled, schema: props.schema, }); - const isMutable = () => !toValue(props.readonly) && !toValue(props.disabled); + const isMutable = () => !toValue(props.readonly) && !isDisabled.value; const { validityDetails, updateValidity } = useInputValidity({ inputEl, @@ -152,12 +154,13 @@ export function useSearchField( const inputProps = computed(() => withRefCapture( { - ...propsToValues(props, ['name', 'pattern', 'placeholder', 'required', 'readonly', 'disabled']), + ...propsToValues(props, ['name', 'pattern', 'placeholder', 'required', 'readonly']), ...labelledByProps.value, ...describedByProps.value, ...accessibleErrorProps.value, id: inputId, value: fieldValue.value, + disabled: isDisabled.value ? true : undefined, type: 'search', maxlength: toValue(props.maxLength), minlength: toValue(props.minLength), diff --git a/packages/core/src/useSelect/useOption.ts b/packages/core/src/useSelect/useOption.ts index a560522..5e9a204 100644 --- a/packages/core/src/useSelect/useOption.ts +++ b/packages/core/src/useSelect/useOption.ts @@ -4,6 +4,7 @@ import { SelectionContextKey } from './useSelect'; import { hasKeyCode, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common'; import { ListManagerKey } from './useListBox'; import { FieldTypePrefixes } from '../constants'; +import { createDisabledContext } from '../helpers/createDisabledContext'; interface OptionDomProps { id: string; @@ -15,6 +16,7 @@ interface OptionDomProps { 'aria-selected'?: boolean; // Used when the listbox allows multiple selections 'aria-checked'?: boolean; + 'aria-disabled'?: boolean; } export interface OptionProps { @@ -28,6 +30,7 @@ export function useOption(_props: Reactivify>, ele const props = normalizeProps(_props); const optionEl = elementRef || ref(); const isFocused = shallowRef(false); + const isDisabled = createDisabledContext(props.disabled); const selectionCtx = inject(SelectionContextKey, null); const listManager = inject(ListManagerKey, null); const isSelected = computed(() => selectionCtx?.isValueSelected(getValue()) ?? false); @@ -48,12 +51,11 @@ export function useOption(_props: Reactivify>, ele } const optionId = useUniqId(FieldTypePrefixes.Option); - const isDisabled = () => !!toValue(props.disabled); listManager?.useOptionRegistration({ id: optionId, toggleSelected, - isDisabled, + isDisabled: () => isDisabled.value, isSelected: () => isSelected.value, isFocused: () => isFocused.value, getLabel: () => toValue(props.label) ?? '', @@ -72,14 +74,14 @@ export function useOption(_props: Reactivify>, ele const handlers = { onClick() { - if (isDisabled()) { + if (isDisabled.value) { return; } selectionCtx?.toggleValue(getValue()); }, onKeydown(e: KeyboardEvent) { - if (isDisabled()) { + if (isDisabled.value) { return; } @@ -104,7 +106,7 @@ export function useOption(_props: Reactivify>, ele tabindex: isFocused.value ? '0' : '-1', 'aria-selected': isMultiple ? undefined : isSelected.value, 'aria-checked': isMultiple ? isSelected.value : undefined, - 'aria-disabled': isDisabled() || undefined, + 'aria-disabled': isDisabled.value || undefined, ...handlers, }, optionEl, @@ -116,5 +118,6 @@ export function useOption(_props: Reactivify>, ele optionProps, isSelected, optionEl, + isDisabled, }; } diff --git a/packages/core/src/useSelect/useOptionGroup.spec.ts b/packages/core/src/useSelect/useOptionGroup.spec.ts index 58f7a5c..92f2d70 100644 --- a/packages/core/src/useSelect/useOptionGroup.spec.ts +++ b/packages/core/src/useSelect/useOptionGroup.spec.ts @@ -2,6 +2,8 @@ import { flush } from '@test-utils/index'; import { render, screen } from '@testing-library/vue'; import { axe } from 'vitest-axe'; import { useOptionGroup } from './useOptionGroup'; +import { useOption } from './useOption'; +import { defineComponent } from 'vue'; test('should not have a11y errors', async () => { await render({ @@ -29,3 +31,53 @@ test('should not have a11y errors', async () => { expect(await axe(screen.getByTestId('fixture'))).toHaveNoViolations(); vi.useFakeTimers(); }); + +test('disabling a group disables all options', async () => { + const Option = defineComponent({ + setup() { + const { optionProps } = useOption({ label: 'Option', value: '' }); + + return { + optionProps, + }; + }, + template: ` +
+
Option
+
+ `, + }); + + await render({ + components: { + Option, + }, + setup() { + const label = 'Field'; + const { groupProps, labelProps } = useOptionGroup({ + label, + disabled: true, + }); + + return { + groupProps, + labelProps, + label, + }; + }, + template: ` +
+
{{ label }}
+
+ `, + }); + + await flush(); + await expect(screen.getAllByRole('option')).toHaveLength(3); + for (const option of screen.getAllByRole('option')) { + expect(option).toHaveAttribute('aria-disabled', 'true'); + } +}); diff --git a/packages/core/src/useSelect/useOptionGroup.ts b/packages/core/src/useSelect/useOptionGroup.ts index 855721d..f0bdb88 100644 --- a/packages/core/src/useSelect/useOptionGroup.ts +++ b/packages/core/src/useSelect/useOptionGroup.ts @@ -3,15 +3,18 @@ import { Maybe, Reactivify } from '../types'; import { useLabel } from '../a11y/useLabel'; import { normalizeProps, useUniqId, withRefCapture } from '../utils/common'; import { FieldTypePrefixes } from '../constants'; +import { createDisabledContext } from '../helpers/createDisabledContext'; export interface OptionGroupProps { label: string; + disabled?: boolean; } export function useOptionGroup(_props: Reactivify, elementRef?: Ref>) { const groupEl = elementRef || ref(); const props = normalizeProps(_props); const groupId = useUniqId(FieldTypePrefixes.OptionGroup); + const isDisabled = createDisabledContext(props.disabled); const { labelProps, labelledByProps } = useLabel({ label: props.label, @@ -35,5 +38,6 @@ export function useOptionGroup(_props: Reactivify, elementRef? labelProps, groupProps, groupEl, + isDisabled, }; } diff --git a/packages/core/src/useSelect/useSelect.ts b/packages/core/src/useSelect/useSelect.ts index a19615e..ccc9cc8 100644 --- a/packages/core/src/useSelect/useSelect.ts +++ b/packages/core/src/useSelect/useSelect.ts @@ -44,7 +44,6 @@ export interface SelectTriggerDomProps extends AriaLabelableProps { export interface SelectionContext { isValueSelected(value: TValue): boolean; isMultiple(): boolean; - isDisabled(): boolean; toggleValue(value: TValue, force?: boolean): void; } @@ -55,16 +54,15 @@ const MENU_OPEN_KEYS = ['Enter', 'Space', 'ArrowDown', 'ArrowUp']; export function useSelect(_props: Reactivify, 'schema'>) { const inputId = useUniqId(FieldTypePrefixes.Select); const props = normalizeProps(_props, ['schema']); - const isDisabled = () => toValue(props.disabled) ?? false; - const isMutable = () => !isDisabled() && !toValue(props.readonly); const field = useFormField>({ path: props.name, initialValue: (toValue(props.modelValue) ?? toValue(props.value)) as Arrayable, disabled: props.disabled, - schema: props.schema, }); + const { fieldValue, setValue, errorMessage, isDisabled } = field; + const isMutable = () => !isDisabled.value && !toValue(props.readonly); const { labelProps, labelledByProps } = useLabel({ label: props.label, for: inputId, @@ -73,6 +71,7 @@ export function useSelect(_props: Reactivify({ labeledBy: () => labelledByProps.value['aria-labelledby'], + disabled: isDisabled, label: props.label, multiple: props.multiple, orientation: props.orientation, @@ -82,7 +81,6 @@ export function useSelect(_props: Reactivify(_props: Reactivify isEqual(item, value)); }, - isDisabled, toggleValue(optionValue, force) { if (!isMutable()) { return; @@ -193,14 +190,14 @@ export function useSelect(_props: Reactivify(_props: Reactivify) { const inputId = useUniqId(FieldTypePrefixes.Slider); const trackEl = ref(); const thumbs = ref([]); - const isDisabled = () => toValue(props.disabled) ?? false; - const isReadonly = () => toValue(props.readonly) ?? false; - const isMutable = () => !isDisabled() && !isReadonly(); const { direction } = useLocale(); const field = useFormField>({ path: props.name, @@ -128,7 +123,9 @@ export function useSlider(_props: Reactivify) { schema: props.schema, }); - const { fieldValue, setValue, setTouched } = field; + const { fieldValue, setValue, setTouched, isDisabled } = field; + const isReadonly = () => toValue(props.readonly) ?? false; + const isMutable = () => !isDisabled.value && !isReadonly(); const { updateValidity } = useInputValidity({ field }); const { labelProps, labelledByProps } = useLabel({ for: inputId, @@ -291,14 +288,12 @@ export function useSlider(_props: Reactivify) { setThumbValue(getThumbIndex(), value); }, setTouched, - isDisabled, getAccessibleErrorProps: () => accessibleErrorProps.value, }; return reg; } - // TODO: IDK what this does const outputProps = { 'aria-live': 'off', }; diff --git a/packages/core/src/useSlider/useSliderThumb.ts b/packages/core/src/useSlider/useSliderThumb.ts index db90582..23b99a6 100644 --- a/packages/core/src/useSlider/useSliderThumb.ts +++ b/packages/core/src/useSlider/useSliderThumb.ts @@ -5,6 +5,7 @@ import { Reactivify } from '../types'; import { useSpinButton } from '../useSpinButton'; import { useLocale } from '../i18n'; import { FieldTypePrefixes, NOOP } from '../constants'; +import { createDisabledContext } from '../helpers/createDisabledContext'; export interface SliderThumbProps { label?: string; @@ -15,6 +16,7 @@ export interface SliderThumbProps { export function useSliderThumb(_props: Reactivify, elementRef?: Ref) { const props = normalizeProps(_props); const thumbEl = elementRef || ref(); + const isDisabled = createDisabledContext(props.disabled); const isDragging = ref(false); const { direction } = useLocale(); const id = useUniqId(FieldTypePrefixes.SliderThumb); @@ -50,7 +52,6 @@ export function useSliderThumb(_props: Reactivify, elementRef? const slider = inject(SliderInjectionKey, mockSlider, true).useSliderThumbRegistration(thumbContext); const thumbValue = computed(() => slider.getThumbValue()); - const isDisabled = () => toValue(props.disabled) || slider.isDisabled(); if ('__isMock' in slider) { warn( @@ -81,7 +82,7 @@ export function useSliderThumb(_props: Reactivify, elementRef? return withRefCapture( { - tabindex: isDisabled() ? '-1' : '0', + tabindex: isDisabled.value ? '-1' : '0', role: 'slider', ...slider.getAccessibleErrorProps(), 'aria-orientation': slider.getOrientation(), @@ -150,6 +151,7 @@ export function useSliderThumb(_props: Reactivify, elementRef? thumbProps, currentValue: thumbValue, isDragging, + isDisabled, thumbEl, }; } diff --git a/packages/core/src/useSwitch/useSwitch.ts b/packages/core/src/useSwitch/useSwitch.ts index 6a6572a..44c3198 100644 --- a/packages/core/src/useSwitch/useSwitch.ts +++ b/packages/core/src/useSwitch/useSwitch.ts @@ -64,8 +64,6 @@ export function useSwitch(_props: Reactivify, elementRef? const props = normalizeProps(_props, ['schema']); const inputId = useUniqId(FieldTypePrefixes.Switch); const inputEl = elementRef || shallowRef(); - const isDisabled = () => toValue(props.disabled); - const isMutable = () => !toValue(props.readonly) && !isDisabled(); const { labelProps, labelledByProps } = useLabel({ for: inputId, label: props.label, @@ -84,7 +82,9 @@ export function useSwitch(_props: Reactivify, elementRef? inputEl, disableHtmlValidation: props.disableHtmlValidation, }); - const { fieldValue, setValue, setTouched, errorMessage } = field; + + const { fieldValue, setValue, setTouched, errorMessage, isDisabled } = field; + const isMutable = () => !toValue(props.readonly) && !isDisabled.value; const { errorMessageProps, accessibleErrorProps } = createAccessibleErrorMessageProps({ inputId, errorMessage, @@ -176,7 +176,7 @@ export function useSwitch(_props: Reactivify, elementRef? [isInput ? 'checked' : 'aria-checked']: isPressed.value || false, [isInput ? 'required' : 'aria-required']: toValue(props.required) || undefined, [isInput ? 'readonly' : 'aria-readonly']: toValue(props.readonly) || undefined, - [isInput ? 'disabled' : 'aria-disabled']: isDisabled() || undefined, + [isInput ? 'disabled' : 'aria-disabled']: isDisabled.value || undefined, role: 'switch' as const, }; @@ -192,7 +192,7 @@ export function useSwitch(_props: Reactivify, elementRef? return { ...base, onClick, - tabindex: isDisabled() ? '-1' : '0', + tabindex: isDisabled.value ? '-1' : '0', onKeydown: handlers.onKeydown, }; } diff --git a/packages/core/src/useTextField/useTextField.ts b/packages/core/src/useTextField/useTextField.ts index eb400c0..f9a8a15 100644 --- a/packages/core/src/useTextField/useTextField.ts +++ b/packages/core/src/useTextField/useTextField.ts @@ -76,7 +76,7 @@ export function useTextField( }); const { validityDetails } = useInputValidity({ inputEl, field, disableHtmlValidation: props.disableHtmlValidation }); - const { fieldValue, setValue, setTouched, errorMessage } = field; + const { fieldValue, setValue, setTouched, errorMessage, isDisabled } = field; const { labelProps, labelledByProps } = useLabel({ for: inputId, label: props.label, @@ -108,7 +108,7 @@ export function useTextField( const inputProps = computed(() => { return withRefCapture( { - ...propsToValues(props, ['name', 'type', 'placeholder', 'required', 'readonly', 'disabled']), + ...propsToValues(props, ['name', 'type', 'placeholder', 'required', 'readonly']), ...labelledByProps.value, ...describedByProps.value, ...accessibleErrorProps.value, @@ -117,6 +117,7 @@ export function useTextField( value: fieldValue.value, maxlength: toValue(props.maxLength), minlength: toValue(props.minLength), + disabled: isDisabled.value ? true : undefined, // Maybe we need to find a better way to serialize RegExp to a pattern string pattern: inputEl.value?.tagName === 'TEXTAREA' ? undefined : toValue(props.pattern)?.toString(), }, diff --git a/packages/core/src/utils/exposers.ts b/packages/core/src/utils/exposers.ts index 307d6ac..0e784ce 100644 --- a/packages/core/src/utils/exposers.ts +++ b/packages/core/src/utils/exposers.ts @@ -9,6 +9,7 @@ export function exposeField(field: FormField) { isDirty: field.isDirty, isTouched: field.isTouched, isValid: field.isValid, + isDisabled: field.isDisabled, setErrors: field.setErrors, setTouched: field.setTouched, setValue: field.setValue, diff --git a/packages/playground/index.html b/packages/playground/index.html index 15434ba..fc1de79 100644 --- a/packages/playground/index.html +++ b/packages/playground/index.html @@ -7,7 +7,7 @@ Vite + Vue + TS -
+
diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 69d1bd4..582a298 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -2,26 +2,74 @@ import { FormSchema, useForm } from '@formwerk/core'; import InputText from './components/InputText.vue'; import Switch from './components/Switch.vue'; -import FormRepeater from './components/Repeater.vue'; +import Radio from './components/RadioItem.vue'; +import RadioGroup from './components/RadioGroup.vue'; +import CheckboxGroup from './components/CheckboxGroup.vue'; +import CheckboxItem from './components/CheckboxItem.vue'; +import InputSearch from './components/InputSearch.vue'; +import InputNumber from './components/InputNumber.vue'; +import InputSelect from './components/InputSelect.vue'; +import OptionGroup from './components/OptionGroup.vue'; +import OptionItem from './components/OptionItem.vue'; +import FormGroup from './components/FormGroup.vue'; -const { handleSubmit, values } = useForm>(); - -values.email; // string | null +const { handleSubmit, values } = useForm< + FormSchema<{ + name: string; + email: string; + subscribe: boolean; + plan: string; + preferences: string[]; + }> +>(); const onSubmit = handleSubmit(data => { - console.log(data.toJSON().email); // string + console.log(data.toJSON()); }); diff --git a/packages/playground/src/components/OptionItem.vue b/packages/playground/src/components/OptionItem.vue index f1b9550..5c989b8 100644 --- a/packages/playground/src/components/OptionItem.vue +++ b/packages/playground/src/components/OptionItem.vue @@ -1,6 +1,8 @@ diff --git a/packages/playground/src/style.css b/packages/playground/src/style.css index af52733..04a8e85 100644 --- a/packages/playground/src/style.css +++ b/packages/playground/src/style.css @@ -10,7 +10,6 @@ html, body { - height: 100%; font-family: 'Monaspace Neon Var'; }