Skip to content

Commit 7e42950

Browse files
authored
feat(frontend): Input Pattern Field (#141)
feat(frontend): Input Pattern Field
1 parent 8ca24a4 commit 7e42950

File tree

5 files changed

+200
-0
lines changed

5 files changed

+200
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { PatternFormat } from 'react-number-format';
2+
3+
import { InputError } from '~/components/input-error';
4+
import { InputHelp } from '~/components/input-help';
5+
import { InputLabel } from '~/components/input-label';
6+
import { cn } from '~/utils/tailwind-utils';
7+
8+
const inputBaseClassName =
9+
'block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500';
10+
const inputDisabledClassName =
11+
'disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70';
12+
const inputReadOnlyClassName =
13+
'read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70';
14+
const inputErrorClassName = 'border-red-500 focus:border-red-500 focus:ring-red-500';
15+
16+
export interface InputPatternFieldProps
17+
extends OmitStrict<
18+
React.ComponentProps<typeof PatternFormat>,
19+
'aria-errormessage' | 'aria-invalid' | 'aria-labelledby' | 'aria-required' | 'value' | 'onChange'
20+
> {
21+
defaultValue: string;
22+
errorMessage?: string;
23+
format: string;
24+
helpMessagePrimary?: React.ReactNode;
25+
helpMessagePrimaryClassName?: string;
26+
helpMessageSecondary?: React.ReactNode;
27+
helpMessageSecondaryClassName?: string;
28+
id: string;
29+
label: string;
30+
name: string;
31+
}
32+
33+
export function InputPatternField(props: InputPatternFieldProps) {
34+
const {
35+
'aria-describedby': ariaDescribedby,
36+
className,
37+
defaultValue,
38+
errorMessage,
39+
helpMessagePrimary,
40+
helpMessagePrimaryClassName,
41+
helpMessageSecondary,
42+
helpMessageSecondaryClassName,
43+
id,
44+
label,
45+
required,
46+
...restProps
47+
} = props;
48+
49+
const inputWrapperId = `input-pattern-field-${id}`;
50+
const inputErrorId = `${inputWrapperId}-error`;
51+
const inputHelpMessagePrimaryId = `${inputWrapperId}-help-primary`;
52+
const inputHelpMessageSecondaryId = `${inputWrapperId}-help-secondary`;
53+
const inputLabelId = `${inputWrapperId}-label`;
54+
55+
function getAriaDescribedby() {
56+
const describedby = [];
57+
if (ariaDescribedby) describedby.push(ariaDescribedby);
58+
if (helpMessagePrimary) describedby.push(inputHelpMessagePrimaryId);
59+
if (helpMessageSecondary) describedby.push(inputHelpMessageSecondaryId);
60+
return describedby.length > 0 ? describedby.join(' ') : undefined;
61+
}
62+
63+
return (
64+
<div id={inputWrapperId} data-testid={inputWrapperId}>
65+
<InputLabel id={inputLabelId} htmlFor={id} className="mb-2">
66+
{label}
67+
</InputLabel>
68+
{errorMessage && (
69+
<p className="mb-2">
70+
<InputError id={inputErrorId}>{errorMessage}</InputError>
71+
</p>
72+
)}
73+
{helpMessagePrimary && (
74+
<InputHelp
75+
id={inputHelpMessagePrimaryId}
76+
className={cn('mb-2', helpMessagePrimaryClassName)}
77+
data-testid="input-pattern-field-help-primary"
78+
>
79+
{helpMessagePrimary}
80+
</InputHelp>
81+
)}
82+
<PatternFormat
83+
aria-describedby={getAriaDescribedby()}
84+
aria-errormessage={errorMessage ? inputErrorId : undefined}
85+
aria-invalid={!!errorMessage}
86+
aria-labelledby={inputLabelId}
87+
aria-required={required}
88+
data-testid="input-pattern-field"
89+
id={id}
90+
className={cn(
91+
inputBaseClassName,
92+
inputDisabledClassName,
93+
inputReadOnlyClassName,
94+
errorMessage && inputErrorClassName,
95+
className,
96+
)}
97+
required={required}
98+
value={defaultValue}
99+
{...restProps}
100+
/>
101+
{helpMessageSecondary && (
102+
<InputHelp
103+
id={inputHelpMessageSecondaryId}
104+
className={cn('mt-2', helpMessageSecondaryClassName)}
105+
data-testid="input-pattern-field-help-secondary"
106+
>
107+
{helpMessageSecondary}
108+
</InputHelp>
109+
)}
110+
</div>
111+
);
112+
}

frontend/package-lock.json

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"react": "^19.0.0",
6161
"react-dom": "^19.0.0",
6262
"react-i18next": "^15.4.0",
63+
"react-number-format": "^5.4.3",
6364
"react-router": "^7.1.3",
6465
"source-map-support": "^0.5.21",
6566
"tailwind-merge": "^2.6.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`InputPatternField > should render 800 000 002 -> 800 000 002 > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><input inputmode="numeric" aria-invalid="false" aria-labelledby="input-pattern-field-test-id-label" data-testid="input-pattern-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" type="text" value="800 000 002" name="test"></div>"`;
4+
5+
exports[`InputPatternField > should render 800 000-002 -> 800 000 002 > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><input inputmode="numeric" aria-invalid="false" aria-labelledby="input-pattern-field-test-id-label" data-testid="input-pattern-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" type="text" value="800 000 002" name="test"></div>"`;
6+
7+
exports[`InputPatternField > should render 800-000-002 -> 800 000 002 > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><input inputmode="numeric" aria-invalid="false" aria-labelledby="input-pattern-field-test-id-label" data-testid="input-pattern-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" type="text" value="800 000 002" name="test"></div>"`;
8+
9+
exports[`InputPatternField > should render 800000 002 -> 800 000 002 > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><input inputmode="numeric" aria-invalid="false" aria-labelledby="input-pattern-field-test-id-label" data-testid="input-pattern-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" type="text" value="800 000 002" name="test"></div>"`;
10+
11+
exports[`InputPatternField > should render 800000-002 -> 800 000 002 > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><input inputmode="numeric" aria-invalid="false" aria-labelledby="input-pattern-field-test-id-label" data-testid="input-pattern-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" type="text" value="800 000 002" name="test"></div>"`;
12+
13+
exports[`InputPatternField > should render 800000002 -> 800 000 002 > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><input inputmode="numeric" aria-invalid="false" aria-labelledby="input-pattern-field-test-id-label" data-testid="input-pattern-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" type="text" value="800 000 002" name="test"></div>"`;
14+
15+
exports[`InputPatternField > should render with error message > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><p class="mb-2"><span class="inline-block max-w-prose border-l-2 border-red-600 bg-red-50 px-3 py-1" data-testid="input-error-test-id" role="alert" id="input-pattern-field-test-id-error">error message</span></p><input inputmode="numeric" aria-errormessage="input-pattern-field-test-id-error" aria-invalid="true" aria-labelledby="input-pattern-field-test-id-label" data-testid="input-pattern-field" id="test-id" class="block rounded-lg focus:outline-none focus:ring disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70 border-red-500 focus:border-red-500 focus:ring-red-500" type="text" value="800 000 002" name="test"></div>"`;
16+
17+
exports[`InputPatternField > should render with help message > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><input inputmode="numeric" aria-describedby="input-pattern-field-test-id-help-secondary" aria-invalid="false" aria-labelledby="input-pattern-field-test-id-label" data-testid="input-pattern-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" type="text" value="800 000 002" name="test"><span class="block max-w-prose text-gray-500 mt-2" data-testid="input-pattern-field-help-secondary" id="input-pattern-field-test-id-help-secondary">help message</span></div>"`;
18+
19+
exports[`InputPatternField > should render with required > expected html 1`] = `"<div id="input-pattern-field-test-id" data-testid="input-pattern-field-test-id"><label class="inline-block font-semibold mb-2" data-testid="input-label" id="input-pattern-field-test-id-label" for="test-id"><span>label test</span></label><input inputmode="numeric" aria-invalid="false" aria-labelledby="input-pattern-field-test-id-label" aria-required="true" data-testid="input-pattern-field" id="test-id" class="block rounded-lg border-gray-500 focus:border-blue-500 focus:outline-none focus:ring focus:ring-blue-500 disabled:bg-gray-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70 read-only:bg-gray-100 read-only:pointer-events-none read-only:cursor-not-allowed read-only:opacity-70" required="" type="text" value="800 000 002" name="test"></div>"`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { InputPatternField } from '~/components/input-pattern-field';
5+
6+
describe('InputPatternField', () => {
7+
const testFormat = '### ### ###';
8+
9+
it.each([
10+
['800000002', '800 000 002'],
11+
['800 000 002', '800 000 002'],
12+
['800-000-002', '800 000 002'],
13+
['800 000-002', '800 000 002'],
14+
['800000 002', '800 000 002'],
15+
['800000-002', '800 000 002'],
16+
])('should render %s -> %s', (defaultValue, expected) => {
17+
const { container } = render(
18+
<InputPatternField id="test-id" name="test" label="label test" defaultValue={defaultValue} format={testFormat} />,
19+
);
20+
expect(container.innerHTML).toMatchSnapshot('expected html');
21+
});
22+
23+
it('should render with help message', () => {
24+
const { container } = render(
25+
<InputPatternField
26+
id="test-id"
27+
name="test"
28+
label="label test"
29+
format={testFormat}
30+
defaultValue="800000002"
31+
helpMessageSecondary="help message"
32+
/>,
33+
);
34+
expect(container.innerHTML).toMatchSnapshot('expected html');
35+
});
36+
37+
it('should render with required', () => {
38+
const { container } = render(
39+
<InputPatternField id="test-id" name="test" label="label test" format={testFormat} defaultValue="800000002" required />,
40+
);
41+
expect(container.innerHTML).toMatchSnapshot('expected html');
42+
});
43+
44+
it('should render with error message', () => {
45+
const { container } = render(
46+
<InputPatternField
47+
id="test-id"
48+
name="test"
49+
label="label test"
50+
format={testFormat}
51+
defaultValue="800000002"
52+
errorMessage="error message"
53+
/>,
54+
);
55+
expect(container.innerHTML).toMatchSnapshot('expected html');
56+
});
57+
});

0 commit comments

Comments
 (0)