Skip to content

Commit

Permalink
feat: simplify useInputControl
Browse files Browse the repository at this point in the history
  • Loading branch information
edmundhung committed Dec 12, 2023
1 parent 5517368 commit e1523cc
Show file tree
Hide file tree
Showing 7 changed files with 81 additions and 208 deletions.
17 changes: 4 additions & 13 deletions examples/chakra-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,7 @@ export default function Example() {

function ExampleNumberInput(props: FieldProps<number, Error>) {
const { meta } = useField(props);
const control = useInputControl({
key: meta.key,
name: meta.name,
formId: meta.formId,
initialValue: meta.initialValue,
});
const control = useInputControl(meta);

return (
<NumberInput
Expand Down Expand Up @@ -239,16 +234,12 @@ function ExamplePinInput(props: FieldProps<string, Error>) {

function ExampleSlider(props: FieldProps<number, Error>) {
const { meta } = useField(props);
const control = useInputControl(meta, {
initialize(value) {
return typeof value !== 'undefined' ? Number(value) : undefined;
},
});
const control = useInputControl(meta);

return (
<Slider
value={control.value}
onChange={control.change}
value={control.value ? Number(control.value) : 0}
onChange={(number) => control.change(number.toString())}
onBlur={control.blur}
>
<SliderTrack>
Expand Down
21 changes: 8 additions & 13 deletions examples/headless-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,11 @@ function ExampleCombobox(config: FieldProps<string, Error>) {
const [query, setQuery] = useState('');
const { meta } = useField(config);
const control = useInputControl(meta);
const filteredPeople =
control.value === ''
? people
: people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase()),
);
const filteredPeople = !control.value
? people
: people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase()),
);

return (
<Combobox
Expand Down Expand Up @@ -282,17 +281,13 @@ function ExampleCombobox(config: FieldProps<string, Error>) {

function ExampleSwitch(config: FieldProps<boolean, Error>) {
const { meta } = useField(config);
const control = useInputControl(meta, {
initialize(value) {
return typeof value !== 'undefined' ? value === 'on' : false;
},
});
const control = useInputControl(meta);

return (
<Switch
name={meta.name}
checked={control.value}
onChange={control.change}
checked={control.value === 'on'}
onChange={(state) => control.change(state ? 'on' : '')}
className={classNames(
control.value ? 'bg-indigo-600' : 'bg-gray-200',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
Expand Down
20 changes: 6 additions & 14 deletions examples/material-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,19 +243,15 @@ function ExampleRating({ label, name, formId, required }: Field<number>) {
formId,
name,
});
const control = useInputControl(meta, {
initialize(value) {
return value !== '' ? Number(value) : null;
},
});
const control = useInputControl(meta);

return (
<FormControl variant="standard" error={!meta.valid} required={required}>
<FormLabel>{label}</FormLabel>
<Rating
value={control.value}
value={control.value ? Number(control.value) : null}
onChange={(_, value) => {
control.change(value);
control.change(value?.toString() ?? '');
}}
onBlur={control.blur}
/>
Expand All @@ -269,24 +265,20 @@ function ExampleSlider({ label, name, formId, required }: Field<number>) {
formId,
name,
});
const control = useInputControl(meta, {
initialize(value) {
return Number(value);
},
});
const control = useInputControl(meta);

return (
<FormControl variant="standard" error={!meta.valid} required={required}>
<FormLabel>{label}</FormLabel>
<Slider
name={meta.name}
value={control.value}
value={control.value ? Number(control.value) : 0}
onChange={(_, value) => {
if (Array.isArray(value)) {
return;
}

control.change(value);
control.change(value.toString());
}}
/>
<FormHelperText>{meta.errors?.validationMessage}</FormHelperText>
Expand Down
2 changes: 1 addition & 1 deletion packages/conform-react/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export type FieldProps<
};

export type Metadata<Schema, Error> = {
key?: string;
key: string | undefined;
id: string;
errorId: string;
descriptionId: string;
Expand Down
192 changes: 50 additions & 142 deletions packages/conform-react/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import {
type FieldElement,
type FormValue,
isFieldElement,
FieldName,
FormId,
} from '@conform-to/dom';
import { type FieldElement, isFieldElement } from '@conform-to/dom';
import { useRef, useState, useMemo, useEffect } from 'react';
import { type FieldMetadata } from './context';

export type InputControl<Value> = {
value: Value;
change: (value: Value) => void;
export type InputControl = {
value: string | undefined;
change: (value: string) => void;
focus: () => void;
blur: () => void;
};
Expand Down Expand Up @@ -54,106 +47,35 @@ export function getEventTarget(formId: string, name: string): FieldElement {
return input;
}

export function useInputControl<Schema>(
metadata: FieldMetadata<Schema, any, any>,
options?: {
onFocus?: (event: Event) => void;
},
): InputControl<string | undefined>;
export function useInputControl<Schema>(options: {
key?: string;
name: FieldName<Schema>;
formId: FormId<any, any>;
initialValue: FormValue<Schema>;
onFocus?: (event: Event) => void;
}): InputControl<string | undefined>;
export function useInputControl<Schema, Value>(
metadata: FieldMetadata<Schema, any, any>,
options: {
initialize: (value: FormValue<Schema> | undefined) => Value;
serialize?: (value: Value) => string;
onFocus?: (event: Event) => void;
},
): InputControl<Value>;
export function useInputControl<Schema, Value>(options: {
key?: string;
name: FieldName<Schema>;
formId: FormId<any, any>;
initialValue: FormValue<Schema>;
initialize: (value: FormValue<Schema> | undefined) => Value;
serialize?: (value: Value) => string;
onFocus?: (event: Event) => void;
}): InputControl<Value>;
export function useInputControl<Schema, Value>(
metadata:
| FieldMetadata<Schema, any, any>
| {
key?: string;
name: FieldName<Schema>;
formId: FormId<any, any>;
initialValue: FormValue<Schema>;
initialize?: (value: FormValue<Schema> | undefined) => Value;
serialize?: (value: Value | string | undefined) => string;
onFocus?: (event: Event) => void;
},
options?: {
initialize?: (value: FormValue<Schema> | undefined) => Value;
serialize?: (value: Value | string | undefined) => string;
onFocus?: (event: Event) => void;
},
): InputControl<Value | string | undefined> {
export function useInputControl(options: {
key?: string | undefined;
name: string;
formId: string;
initialValue: string | undefined;
}): InputControl {
const eventDispatched = useRef({
change: false,
focus: false,
blur: false,
});
const [key, setKey] = useState(metadata.key);
const initialize =
options?.initialize ??
('initialize' in metadata && metadata.initialize
? metadata.initialize
: (value) => value?.toString());
const serialize =
options?.serialize ??
('serialize' in metadata && metadata.serialize
? metadata.serialize
: undefined);
const onFocus =
options?.onFocus ?? ('onFocus' in metadata ? metadata.onFocus : undefined);
const optionsRef = useRef({
initialize,
serialize,
onFocus,
});
const [value, setValue] = useState(() => initialize(metadata.initialValue));
const [key, setKey] = useState(options.key);
const [value, setValue] = useState(() => options.initialValue);

if (key !== metadata.key) {
setValue(initialize(metadata.initialValue));
setKey(metadata.key);
if (key !== options.key) {
setValue(options.initialValue);
setKey(options.key);
}

useEffect(() => {
optionsRef.current = {
initialize,
serialize,
onFocus,
};
});

useEffect(() => {
const createEventListener = (listener: 'change' | 'focus' | 'blur') => {
return (event: Event) => {
const element = getFieldElement(
metadata.formId,
metadata.name,
options.formId,
options.name,
(element) => element === event.target,
);

if (element) {
if (listener === 'focus') {
optionsRef.current?.onFocus?.(event);
}

eventDispatched.current[listener] = true;
}
};
Expand All @@ -171,67 +93,53 @@ export function useInputControl<Schema, Value>(
document.removeEventListener('focusin', focusHandler, true);
document.removeEventListener('focusout', blurHandler, true);
};
}, [metadata.formId, metadata.name]);
}, [options.formId, options.name]);

const handlers = useMemo<
Omit<InputControl<Value | string | undefined>, 'value'>
>(() => {
const handlers = useMemo<Omit<InputControl, 'value'>>(() => {
return {
change(value) {
if (!eventDispatched.current.change) {
const element = getEventTarget(metadata.formId, metadata.name);
const serializedValue =
optionsRef.current.serialize?.(value) ?? value?.toString() ?? '';
const element = getEventTarget(options.formId, options.name);

eventDispatched.current.change = true;

if (
element instanceof HTMLInputElement &&
(element.type === 'checkbox' || element.type === 'radio')
) {
if (
element.checked
? element.value !== serializedValue
: element.value === serializedValue
) {
element.click();
}
} else {
element.checked = element.value === value;
} else if (element.value !== value) {
// No change event will be triggered on React if `element.value` is already updated
if (element.value !== serializedValue) {
/**
* Triggering react custom change event
* Solution based on dom-testing-library
* @see https://github.com/facebook/react/issues/10135#issuecomment-401496776
* @see https://github.com/testing-library/dom-testing-library/blob/main/src/events.js#L104-L123
*/
const { set: valueSetter } =
Object.getOwnPropertyDescriptor(element, 'value') || {};
const prototype = Object.getPrototypeOf(element);
const { set: prototypeValueSetter } =
Object.getOwnPropertyDescriptor(prototype, 'value') || {};

if (
prototypeValueSetter &&
valueSetter !== prototypeValueSetter
) {
prototypeValueSetter.call(element, value);
/**
* Triggering react custom change event
* Solution based on dom-testing-library
* @see https://github.com/facebook/react/issues/10135#issuecomment-401496776
* @see https://github.com/testing-library/dom-testing-library/blob/main/src/events.js#L104-L123
*/
const { set: valueSetter } =
Object.getOwnPropertyDescriptor(element, 'value') || {};
const prototype = Object.getPrototypeOf(element);
const { set: prototypeValueSetter } =
Object.getOwnPropertyDescriptor(prototype, 'value') || {};

if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
prototypeValueSetter.call(element, value);
} else {
if (valueSetter) {
valueSetter.call(element, value);
} else {
if (valueSetter) {
valueSetter.call(element, value);
} else {
throw new Error(
'The given element does not have a value setter',
);
}
throw new Error(
'The given element does not have a value setter',
);
}
}

// Dispatch input event with the updated input value
element.dispatchEvent(new InputEvent('input', { bubbles: true }));
// Dispatch change event (necessary for select to update the selected option)
element.dispatchEvent(new Event('change', { bubbles: true }));
}

// Dispatch input event with the updated input value
element.dispatchEvent(new InputEvent('input', { bubbles: true }));
// Dispatch change event (necessary for select to update the selected option)
element.dispatchEvent(new Event('change', { bubbles: true }));
}

setValue(value);
Expand All @@ -240,7 +148,7 @@ export function useInputControl<Schema, Value>(
},
focus() {
if (!eventDispatched.current.focus) {
const element = getEventTarget(metadata.formId, metadata.name);
const element = getEventTarget(options.formId, options.name);

eventDispatched.current.focus = true;
element.dispatchEvent(
Expand All @@ -255,7 +163,7 @@ export function useInputControl<Schema, Value>(
},
blur() {
if (!eventDispatched.current.blur) {
const element = getEventTarget(metadata.formId, metadata.name);
const element = getEventTarget(options.formId, options.name);

eventDispatched.current.blur = true;
element.dispatchEvent(
Expand All @@ -269,7 +177,7 @@ export function useInputControl<Schema, Value>(
eventDispatched.current.blur = false;
},
};
}, [metadata.formId, metadata.name]);
}, [options.formId, options.name]);

return {
...handlers,
Expand Down
Loading

0 comments on commit e1523cc

Please sign in to comment.