Skip to content

Commit 727292e

Browse files
authored
Add ability to customize input ids (#31)
* Add ability to customize input ids * Simplify formOptions.inputIds logic * add types test * Refactor to prevent overriding id prop when inputIds is set to false * make generating ids disabled by default to prevent introducing any breaking changes as the generated id is likely to override any exiting ids before calling the input props initializers * Move id tests to a different file * Remove input.id helper * update README.md * rename createIds → withIds
1 parent 2cea040 commit 727292e

10 files changed

+188
-99
lines changed

README.md

+35-12
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
- [`formOptions.onBlur`](#formoptionsonblur)
4040
- [`formOptions.onChange`](#formoptionsonchange)
4141
- [`formOptions.onTouched`](#formoptionsontouched)
42+
- [`formOptions.withIds`](#formoptionswithids)
4243
- [`[formState, inputs]`](#formstate-inputs)
4344
- [Form State](#form-state)
4445
- [Input Types](#input-types)
@@ -208,13 +209,17 @@ function LoginForm({ onSubmit }) {
208209
);
209210
}
210211
```
212+
211213
### Labels
212214

213-
A label can be paired to a specific input by passing the same parameters to
214-
`input.label()`. This will populate the label's `htmlFor` attribute.
215+
As a convenience, `useFormState` provides an optional API that helps with pairing a label to a specific input.
216+
217+
When [`formOptions.withIds`](#formoptionswithids) is enabled, a label can be paired to an [input](#input-types) by using `input.label()`. This will populate the label's `htmlFor` attribute for an input with the same parameters.
215218

216219
```js
217-
const [formState, { label, text, radio }] = useFormState();
220+
const [formState, { label, text, radio }] = useFormState(initialState, {
221+
withIds: true, // enable automatic creation of id and htmlFor props
222+
});
218223

219224
return (
220225
<form>
@@ -230,17 +235,10 @@ return (
230235
);
231236
```
232237

233-
An input's generated ID can also be queried with the `id` getter.
238+
Note that this will override any existing `id` prop if specified before calling the input functions. If you want the `id` to take precedence, it must be passed _after_ calling the input types like this:
234239

235240
```jsx
236-
const [formState, { id, text }] = useFormState();
237-
238-
return (
239-
<>
240-
<input {...text('name')} />
241-
<p>The input's ID is {id('name')}</p>
242-
</>
243-
);
241+
<input {...text('username')} id="signup-username" />
244242
```
245243

246244
## Working with TypeScript
@@ -335,6 +333,31 @@ const [formState, inputs] = useFormState(null, {
335333
});
336334
```
337335

336+
#### `formOptions.withIds`
337+
338+
Indicates whether `useFormState` should generate and pass an `id` attribute to its fields. This is helpful when [working with labels](#labels-and-ids).
339+
340+
It can be one of the following:
341+
342+
A `boolean` indicating whether [input types](#input-types) should pass an `id` attribute to the inputs (set to `false` by default).
343+
344+
```js
345+
const [formState, inputs] = useFormState(null, {
346+
withIds: true,
347+
});
348+
```
349+
350+
Or a custom id formatter: a function that gets called with the input's name and own value, and expected to return a unique string (using these parameters) that will be as the input id.
351+
352+
```js
353+
const [formState, inputs] = useFormState(null, {
354+
withIds: (name, ownValue) =>
355+
ownValue ? `MyForm-${name}-${ownValue}` : `MyForm-${name}`,
356+
});
357+
```
358+
359+
Note that when `withIds` is set to `false`, applying `input.label()` will be a no-op.
360+
338361
### `[formState, inputs]`
339362

340363
The return value of `useFormState`. An array of two items, the first is the [form state](#form-state), and the second an [input types](#input-types) object.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@babel/core": "^7.1.2",
5858
"@babel/preset-env": "^7.1.0",
5959
"@babel/preset-react": "^7.0.0",
60+
"@types/jest": "^24.0.11",
6061
"@types/react": "^16.8.4",
6162
"babel-core": "^7.0.0-bridge.0",
6263
"babel-jest": "^23.6.0",

src/constants.js

+1-5
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,13 @@ export const TEXTAREA = 'textarea';
1616
export const TIME = 'time';
1717
export const URL = 'url';
1818
export const WEEK = 'week';
19+
export const LABEL = 'label';
1920

2021
/**
2122
* @todo add support for datetime-local
2223
*/
2324
export const DATETIME_LOCAL = 'datetime-local';
2425

25-
export const LABEL = 'label';
26-
export const ID = 'id';
27-
2826
export const TYPES = [
2927
CHECKBOX,
3028
COLOR,
@@ -45,5 +43,3 @@ export const TYPES = [
4543
URL,
4644
WEEK,
4745
];
48-
49-
export const ID_PREFIX = '__ufs';

src/index.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface FormOptions<T> {
3232
): void;
3333
onBlur(e: React.FocusEvent<InputElement>): void;
3434
onTouched(e: React.FocusEvent<InputElement>): void;
35+
withIds: boolean | ((name: string, value?: string) => string);
3536
}
3637

3738
// prettier-ignore

src/useFormState.js

+14-18
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@ import { useReducer } from 'react';
22
import { stateReducer } from './stateReducer';
33
import { toString } from './toString';
44
import { parseInputArgs } from './parseInputArgs';
5+
import { useInputId } from './useInputId';
56
import { useMarkAsDirty } from './useMarkAsDirty';
67
import {
7-
ID_PREFIX,
88
TYPES,
99
SELECT,
1010
CHECKBOX,
1111
RADIO,
1212
TEXTAREA,
1313
SELECT_MULTIPLE,
1414
LABEL,
15-
ID,
1615
} from './constants';
1716

1817
function noop() {}
@@ -21,21 +20,17 @@ const defaultFromOptions = {
2120
onChange: noop,
2221
onBlur: noop,
2322
onTouched: noop,
23+
withIds: false,
2424
};
2525

26-
const idGetter = (name, value) =>
27-
[ID_PREFIX, name, value].filter(part => !!part).join('__');
28-
29-
const labelPropsGetter = (...args) => ({
30-
htmlFor: idGetter(...args),
31-
});
32-
3326
export default function useFormState(initialState, options) {
3427
const formOptions = { ...defaultFromOptions, ...options };
3528

3629
const [state, setState] = useReducer(stateReducer, initialState || {});
3730
const [touched, setTouchedState] = useReducer(stateReducer, {});
3831
const [validity, setValidityState] = useReducer(stateReducer, {});
32+
33+
const { getIdProp } = useInputId(formOptions.withIds);
3934
const { setDirty, isDirty } = useMarkAsDirty();
4035

4136
const createPropsGetter = type => (...args) => {
@@ -93,13 +88,15 @@ export default function useFormState(initialState, options) {
9388

9489
const inputProps = {
9590
name,
96-
id: idGetter(name, toString(ownValue)),
9791
get type() {
98-
if (type !== SELECT && type !== SELECT_MULTIPLE && type !== TEXTAREA)
92+
if (type !== SELECT && type !== SELECT_MULTIPLE && type !== TEXTAREA) {
9993
return type;
94+
}
10095
},
10196
get multiple() {
102-
if (type === SELECT_MULTIPLE) return true;
97+
if (type === SELECT_MULTIPLE) {
98+
return true;
99+
}
103100
},
104101
get checked() {
105102
if (isRadio) {
@@ -176,6 +173,7 @@ export default function useFormState(initialState, options) {
176173
setDirty(name, false);
177174
}
178175
},
176+
...getIdProp('id', name, ownValue),
179177
};
180178

181179
return inputProps;
@@ -186,13 +184,11 @@ export default function useFormState(initialState, options) {
186184
{},
187185
);
188186

189-
const otherCreators = {
190-
[LABEL]: labelPropsGetter,
191-
[ID]: idGetter,
192-
};
193-
194187
return [
195188
{ values: state, validity, touched },
196-
{ ...inputPropsCreators, ...otherCreators },
189+
{
190+
...inputPropsCreators,
191+
[LABEL]: (name, ownValue) => getIdProp('htmlFor', name, ownValue),
192+
},
197193
];
198194
}

src/useInputId.js

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useCallback } from 'react';
2+
import { toString } from './toString';
3+
4+
const defaultCreateId = (name, value) =>
5+
['__ufs', name, value].filter(Boolean).join('__');
6+
7+
export function useInputId(implementation) {
8+
const getId = useCallback(
9+
(name, ownValue) => {
10+
let createId;
11+
if (!implementation) {
12+
createId = () => {}; // noop
13+
} else if (typeof implementation === 'function') {
14+
createId = implementation;
15+
} else {
16+
createId = defaultCreateId;
17+
}
18+
const value = toString(ownValue);
19+
return value ? createId(name, value) : createId(name);
20+
},
21+
[implementation],
22+
);
23+
24+
const getIdProp = useCallback(
25+
(prop, name, value) => {
26+
const id = getId(name, value);
27+
return id === undefined ? {} : { [prop]: id };
28+
},
29+
[getId],
30+
);
31+
32+
return { getIdProp };
33+
}

test/types.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const [formState, input] = useFormState<FormFields>(initialState, {
3434
onTouched(e) {
3535
const { name, value } = e.target;
3636
},
37+
withIds: (name, value) => (value ? `${name}.${value.toLowerCase()}` : name),
3738
});
3839

3940
let name: string = formState.values.name;

test/useFormState-ids.test.js

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useFormState } from '../src';
2+
import * as TestUtils from './test-utils';
3+
4+
TestUtils.mockReactUseReducer();
5+
TestUtils.mockReactUseCallback();
6+
TestUtils.mockReactUseRef();
7+
8+
describe('Input IDs', () => {
9+
/**
10+
* Label only needs a htmlFor
11+
*/
12+
it('input method correct props from type "label"', () => {
13+
const [, input] = useFormState(null, { withIds: true });
14+
expect(input.label('name')).toEqual({
15+
htmlFor: expect.any(String),
16+
});
17+
});
18+
19+
it('input method has an "id" prop', () => {
20+
const [, input] = useFormState(null, { withIds: true });
21+
expect(input.text('name')).toHaveProperty('id', expect.any(String));
22+
});
23+
24+
it('generates unique IDs for inputs with different names', () => {
25+
const [, input] = useFormState(null, { withIds: true });
26+
const { id: firstId } = input.text('firstName');
27+
const { id: lastId } = input.text('lastName');
28+
expect(firstId).not.toBe(lastId);
29+
});
30+
31+
it('generates unique IDs for inputs with the same name and different values', () => {
32+
const [, input] = useFormState(null, { withIds: true });
33+
const { id: freeId } = input.radio('plan', 'free');
34+
const { id: premiumId } = input.radio('plan', 'premium');
35+
expect(freeId).not.toBe(premiumId);
36+
});
37+
38+
it('sets matching IDs for inputs and labels', () => {
39+
const [, input] = useFormState(null, { withIds: true });
40+
const { id: inputId } = input.text('name');
41+
const { htmlFor: labelId } = input.label('name');
42+
expect(labelId).toBe(inputId);
43+
});
44+
45+
it('sets matching IDs for inputs and labels with non string values', () => {
46+
const [, input] = useFormState(null, { withIds: true });
47+
const { id: inputId } = input.checkbox('name', 0);
48+
const { htmlFor: labelId } = input.label('name', 0);
49+
expect(labelId).toBe(inputId);
50+
});
51+
52+
it('sets a custom id when formOptions.withIds is set to a function', () => {
53+
const customInputFormat = jest.fn((name, value) =>
54+
value ? `form-${name}-${value}` : `form-${name}`,
55+
);
56+
const [, input] = useFormState(null, { withIds: customInputFormat });
57+
58+
// inputs with own values (e.g. radio button)
59+
60+
const radioProps = input.radio('option', 0);
61+
expect(radioProps.id).toEqual('form-option-0');
62+
expect(customInputFormat).toHaveBeenCalledWith('option', '0');
63+
64+
const radioLabelProps = input.label('option', 0);
65+
expect(radioLabelProps.htmlFor).toEqual('form-option-0');
66+
expect(customInputFormat).toHaveBeenNthCalledWith(2, 'option', '0');
67+
68+
// inputs with no own values (e.g. text input)
69+
70+
const textProps = input.text('name');
71+
expect(textProps.id).toEqual('form-name');
72+
expect(customInputFormat).toHaveBeenLastCalledWith('name');
73+
74+
const textLabelProps = input.label('name');
75+
expect(textLabelProps.htmlFor).toEqual('form-name');
76+
expect(customInputFormat).toHaveBeenNthCalledWith(3, 'name');
77+
});
78+
79+
it('does not return IDs when formOptions.withIds is set to false', () => {
80+
const [, input] = useFormState();
81+
const nameInputProps = input.checkbox('name', 0);
82+
const nameLabelProps = input.label('name', 0);
83+
expect(nameInputProps).not.toHaveProperty('id');
84+
expect(nameLabelProps).not.toHaveProperty('htmlFor');
85+
});
86+
});

0 commit comments

Comments
 (0)