Skip to content

Commit

Permalink
Merge pull request #3 from kajetansw/support-textarea-element
Browse files Browse the repository at this point in the history
Support `textarea` element
  • Loading branch information
kajetansw committed Jun 19, 2022
2 parents b8dbe3c + 512c1c4 commit 26dd793
Show file tree
Hide file tree
Showing 17 changed files with 1,669 additions and 13 deletions.
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ yarn add solar-forms
+ [Type of "checkbox"](#type-of-checkbox)
+ [Type of "radio"](#type-of-radio)
* [Binding form controls to `<select>` element](#binding-form-controls-to-select-element)
* [Binding form controls to `<textarea>` element](#binding-form-controls-to-textarea-element)
* [Form control errors](#form-control-errors)
+ [Form control name does not match any key from form group](#form-control-name-does-not-match-any-key-from-form-group)
+ [Form control type does not match the type of an input element](#form-control-type-does-not-match-the-type-of-an-input-element)
Expand Down Expand Up @@ -1553,7 +1554,7 @@ type CountryOption = '' | 'Poland' | 'Spain' | 'Germany';

// Component definition

const fg = createFormGroup<CustomFormGroup>({
const fg = createFormGroup({
// 1️⃣ Default value is set here
country: '' as CountryOption,
});
Expand All @@ -1575,6 +1576,28 @@ return (
);
```

### Binding form controls to `<textarea>` element

`textarea` element is a string-based form control. You can
define your form control's default value as `string` or `null`:

```tsx
// Component definition

const fg = createFormGroup({
// 1️⃣ Default value is set here
bio: '',
});

return (
<form use:formGroup={fg}>
<label htmlFor="bio">Bio</label>
{/* 2️⃣ Here we bind form control to form group */}
<textarea name="bio" id="bio" formControlName="bio" />
</form>
);
```


### Form control errors

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "solar-forms",
"version": "0.0.4",
"version": "0.0.5",
"description": "Form library for SolidJS inspired by Angular's reactive forms",
"info": "Solar Forms allows you to create reactive and type-safe state for your form controls. It lets you take over form controls and access key information like control's current value, whether it's disabled, valid, etc. as SolidJS signals. Form controls can also be pre-configured with validator functions, ensuring your form won't be marked as valid unless all data is correct.",
"type": "module",
Expand Down
64 changes: 64 additions & 0 deletions src/core/form-group-directive/form-group-directive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,68 @@ const formGroupForSelect = <I extends CreateFormGroupInput>(
}
};

const formGroupForTextArea = <I extends CreateFormGroupInput>(
$formControl: HTMLTextAreaElement,
formGroupSignal: () => FormGroup<I>
) => {
const [value, setValue] = formGroupSignal().value;
const [getDisabled] = formGroupSignal().disabled;
const [dirty, setDirty] = formGroupSignal().dirty;
const [touched, setTouched] = formGroupSignal().touched;
const setToDirtyIfPristine = (formControlName: string | undefined) => {
if (formControlName && !dirty()[formControlName]) {
setDirty((s) => ({ ...s, [formControlName]: true }));
}
};
const setToTouchedIfUntouched = (formControlName: string | undefined) => {
if (formControlName && !touched()[formControlName]) {
setTouched((s) => ({ ...s, [formControlName]: true }));
}
};

const formGroupKeys = Object.keys(value());
const formControlName = getFormControlName($formControl);

if (formControlName) {
if (!formGroupKeys.includes(formControlName)) {
throw new FormControlInvalidKeyError(formControlName);
}

// Set <textarea> as disabled or enabled
createEffect(() => {
const disabledValue = getDisabled()[formControlName];
if (isBoolean(disabledValue)) {
$formControl.disabled = disabledValue;
}
});

// Set value of <textarea> element
createRenderEffect(() => {
const formValue = value()[formControlName];
if (isString(formValue) || isNull(formValue)) {
$formControl.value = formValue as string;
} else {
throw new FormControlInvalidTypeError(formControlName, 'string', formValue);
}
});

// Update form control values and mark as dirty on user input
const onInput = () => {
setValue((s) => ({ ...s, [formControlName]: $formControl.value }));
setToDirtyIfPristine(formControlName);
};
$formControl.addEventListener('input', onInput);

// Mark <textarea> as touched on blur event
const onBlur = () => setToTouchedIfUntouched(formControlName);
$formControl.addEventListener('blur', onBlur);

// Clean up
onCleanup(() => $formControl.removeEventListener('input', onInput));
onCleanup(() => $formControl.removeEventListener('blur', onBlur));
}
};

export function formGroup<I extends CreateFormGroupInput>(el: Element, formGroupSignal: () => FormGroup<I>) {
for (const $child of el.children) {
const formGroupName = getFormGroupName($child);
Expand All @@ -240,6 +302,8 @@ export function formGroup<I extends CreateFormGroupInput>(el: Element, formGroup
formGroupForInput($formControl, formGroupSignal);
} else if ($formControl instanceof HTMLSelectElement) {
formGroupForSelect($formControl, formGroupSignal);
} else if ($formControl instanceof HTMLTextAreaElement) {
formGroupForTextArea($formControl, formGroupSignal);
}
}
}
Expand Down
22 changes: 11 additions & 11 deletions src/core/form-group-directive/utils/get-form-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,28 @@ function isSelect(child: JSX.Element): child is HTMLSelectElement {
return child instanceof HTMLSelectElement;
}

function isTextArea(child: JSX.Element): child is HTMLTextAreaElement {
return child instanceof HTMLTextAreaElement;
}

function getAssociatedControlForLabel(label: HTMLLabelElement) {
return Array.from(label.children).find((c) => c.id === label.htmlFor);
}

/**
* Extracts form control from the HTML element. Identifies:
* - standalone `input` elements
* - `label`s with `input`s as children bound by `htmlFor` attribute.
* - standalone `input`, `select` and `textarea` elements
* - `label`s with form elements as children bound by `htmlFor`/`for` attribute.
*/
export function getFormControl(child: JSX.Element): HTMLInputElement | HTMLSelectElement | null {
if (isInput(child)) {
return child;
}
if (isSelect(child)) {
export function getFormControl(
child: JSX.Element
): HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null {
if (isInput(child) || isSelect(child) || isTextArea(child)) {
return child;
}
if (isLabel(child)) {
const control = getAssociatedControlForLabel(child);
if (isInput(control)) {
return control;
}
if (isSelect(control)) {
if (isInput(control) || isSelect(control) || isTextArea(control)) {
return control;
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ declare module 'solid-js' {
formControlName?: string;
}

interface TextareaHTMLAttributes<T> {
formControlName?: string;
}

interface HTMLAttributes<T> {
formGroupName?: string;
}
Expand Down
103 changes: 103 additions & 0 deletions test/textarea-element/form-control-dirty-all.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { createFormGroup, formGroup } from '../../src';
import { screen, render } from 'solid-testing-library';
import userEvent from '@testing-library/user-event';
import getRandomString from '../utils/get-random-string';

const INIT_VALUE = getRandomString();
const TEST_VALUE = getRandomString();

const TestApp = () => {
const fg = createFormGroup({
value1: INIT_VALUE,
value2: {
value21: INIT_VALUE,
},
});
const [dirty, setDirty] = fg.dirty;
const [dirtyAll, setDirtyAll] = fg.dirtyAll;

return (
<>
<p data-testid="value-dirtyAll">{JSON.stringify(dirtyAll())}</p>

<form use:formGroup={fg}>
<label for="value1">value1</label>
<textarea data-testid="input-value1" name="value1" id="value1" formControlName="value1" />

<div formGroupName="value2">
<label for="value21">value21</label>
<textarea data-testid="input-value21" name="value21" id="value21" formControlName="value21" />
</div>
</form>

<button data-testid="btn-mark-all-dirty" onClick={() => setDirtyAll(true)}>
Mark all as dirty
</button>
<button data-testid="btn-mark-all-pristine" onClick={() => setDirtyAll(false)}>
Mark all as pristine
</button>
<button
data-testid="btn-mark-each-dirty"
onClick={() => setDirty({ ...dirty(), value1: true, value2: { value21: true } })}
>
Mark each as dirty
</button>
</>
);
};

describe('Marking all form controls and groups as dirty or pristine for `textarea` element', () => {
let $valueDirtyAll: HTMLElement;
let $inputValue1: HTMLTextAreaElement;
let $inputValue21: HTMLTextAreaElement;
let $btnMarkAllDirty: HTMLElement;
let $btnMarkAllPristine: HTMLElement;
let $btnMarkEachDirty: HTMLElement;

beforeEach(async () => {
render(() => <TestApp />);

$valueDirtyAll = await screen.findByTestId('value-dirtyAll');
$inputValue1 = (await screen.findByTestId('input-value1')) as HTMLTextAreaElement;
$inputValue21 = (await screen.findByTestId('input-value21')) as HTMLTextAreaElement;
$btnMarkAllDirty = await screen.findByTestId('btn-mark-all-dirty');
$btnMarkAllPristine = await screen.findByTestId('btn-mark-all-pristine');
$btnMarkEachDirty = await screen.findByTestId('btn-mark-each-dirty');
});

it('should read dirtyAll value as "false" initially', () => {
expect($valueDirtyAll.innerHTML).toBe(String(false));
});

it('should read dirtyAll value as "false" when not all form controls are dirty', () => {
userEvent.type($inputValue1, TEST_VALUE);

expect($valueDirtyAll.innerHTML).toBe(String(false));
});

it('should read dirtyAll value as "true" when all controls were changed from UI', () => {
userEvent.type($inputValue1, TEST_VALUE);
userEvent.type($inputValue21, TEST_VALUE);

expect($valueDirtyAll.innerHTML).toBe(String(true));
});

it('should read dirtyAll value as "true" when all controls were marked as dirty programmatically from outside the form', () => {
userEvent.click($btnMarkEachDirty);

expect($valueDirtyAll.innerHTML).toBe(String(true));
});

it('should disable all form controls when setting the property programmatically from outside the form', () => {
userEvent.click($btnMarkAllDirty);

expect($valueDirtyAll.innerHTML).toBe(String(true));
});

it('should read dirtyAll value as "false" when setting the touched property to "false" programmatically from outside the form', () => {
userEvent.click($btnMarkAllDirty);
userEvent.click($btnMarkAllPristine);

expect($valueDirtyAll.innerHTML).toBe(String(false));
});
});
Loading

0 comments on commit 26dd793

Please sign in to comment.