Skip to content

Commit d11ec29

Browse files
Merge pull request #6 from jeffreylauwers/feature/date-input
feat(DateInput): add DateInput component with native date picker button
2 parents 8e4869b + fab779a commit d11ec29

15 files changed

Lines changed: 666 additions & 42 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* DateInput Component Styles
3+
* Fixed-width wrapper positions the Button (subtle, small, icon-only) at inline-end inside the input.
4+
* The input uses dsn-text-input base styles with extra padding-inline-end
5+
* to prevent typed text from overlapping the button.
6+
* Button hover/focus/color states come from the Button component itself.
7+
*/
8+
9+
/* Wrapper — fixed md width (20ch), positions calendar button absolutely inside the input */
10+
.dsn-date-input-wrapper {
11+
position: relative;
12+
display: block;
13+
inline-size: 100%;
14+
max-inline-size: var(--dsn-form-control-width-md);
15+
}
16+
17+
/* Calendar button — centered vertically, positioned at inline-end with reduced margin */
18+
.dsn-date-input__button {
19+
position: absolute;
20+
inset-block-start: 50%;
21+
inset-inline-end: var(--dsn-date-input-button-inset-inline-end);
22+
transform: translateY(-50%);
23+
}
24+
25+
/* Visually hidden button label — accessible to screen readers, invisible to sighted users */
26+
.dsn-date-input__button-label {
27+
position: absolute;
28+
inline-size: 1px;
29+
block-size: 1px;
30+
padding: 0;
31+
margin: -1px;
32+
overflow: hidden;
33+
clip: rect(0, 0, 0, 0);
34+
white-space: nowrap;
35+
border-width: 0;
36+
}
37+
38+
/* Input — extra padding-inline-end to make room for the calendar button */
39+
/* Hide the native browser calendar/date picker indicator */
40+
.dsn-date-input {
41+
padding-inline-end: var(--dsn-date-input-padding-inline-end-with-icon);
42+
}
43+
44+
.dsn-date-input::-webkit-calendar-picker-indicator {
45+
display: none;
46+
}
47+
48+
.dsn-date-input::-webkit-inner-spin-button {
49+
display: none;
50+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { DateInput } from './DateInput';
5+
6+
describe('DateInput', () => {
7+
it('renders a wrapper div', () => {
8+
const { container } = render(<DateInput data-testid="input" />);
9+
expect(
10+
container.querySelector('.dsn-date-input-wrapper')
11+
).toBeInTheDocument();
12+
});
13+
14+
it('renders an input element inside wrapper', () => {
15+
render(<DateInput data-testid="input" />);
16+
expect(screen.getByTestId('input').tagName).toBe('INPUT');
17+
});
18+
19+
it('has date type', () => {
20+
render(<DateInput data-testid="input" />);
21+
expect(screen.getByTestId('input')).toHaveAttribute('type', 'date');
22+
});
23+
24+
it('always has base dsn-text-input class on input', () => {
25+
render(<DateInput data-testid="input" />);
26+
expect(screen.getByTestId('input')).toHaveClass('dsn-text-input');
27+
});
28+
29+
it('always has dsn-date-input class on input', () => {
30+
render(<DateInput data-testid="input" />);
31+
expect(screen.getByTestId('input')).toHaveClass('dsn-date-input');
32+
});
33+
34+
it('applies custom className to input', () => {
35+
render(<DateInput className="custom" data-testid="input" />);
36+
const el = screen.getByTestId('input');
37+
expect(el).toHaveClass('dsn-text-input');
38+
expect(el).toHaveClass('dsn-date-input');
39+
expect(el).toHaveClass('custom');
40+
});
41+
42+
it('forwards ref to input element', () => {
43+
const ref = { current: null as HTMLInputElement | null };
44+
render(<DateInput ref={ref} />);
45+
expect(ref.current).toBeInstanceOf(HTMLInputElement);
46+
});
47+
48+
it('spreads additional HTML attributes to input', () => {
49+
render(<DateInput id="date" data-testid="input" />);
50+
const el = screen.getByTestId('input');
51+
expect(el).toHaveAttribute('id', 'date');
52+
});
53+
54+
it('accepts value prop', () => {
55+
render(
56+
<DateInput value="2025-03-15" onChange={() => {}} data-testid="input" />
57+
);
58+
expect(screen.getByTestId('input')).toHaveValue('2025-03-15');
59+
});
60+
61+
it('wrapper always has fixed width (no width prop)', () => {
62+
const { container } = render(<DateInput />);
63+
const wrapper = container.querySelector('.dsn-date-input-wrapper');
64+
expect(wrapper?.className).toBe('dsn-date-input-wrapper');
65+
});
66+
67+
describe('calendar button', () => {
68+
it('renders a calendar button', () => {
69+
const { container } = render(<DateInput />);
70+
expect(
71+
container.querySelector('.dsn-date-input__button')
72+
).toBeInTheDocument();
73+
});
74+
75+
it('calendar button is a Button component (has dsn-button class)', () => {
76+
const { container } = render(<DateInput />);
77+
const button = container.querySelector('.dsn-date-input__button');
78+
expect(button).toHaveClass('dsn-button');
79+
});
80+
81+
it('calendar button uses subtle variant', () => {
82+
const { container } = render(<DateInput />);
83+
const button = container.querySelector('.dsn-date-input__button');
84+
expect(button).toHaveClass('dsn-button--subtle');
85+
});
86+
87+
it('calendar button uses small size', () => {
88+
const { container } = render(<DateInput />);
89+
const button = container.querySelector('.dsn-date-input__button');
90+
expect(button).toHaveClass('dsn-button--size-small');
91+
});
92+
93+
it('calendar button uses icon-only', () => {
94+
const { container } = render(<DateInput />);
95+
const button = container.querySelector('.dsn-date-input__button');
96+
expect(button).toHaveClass('dsn-button--icon-only');
97+
});
98+
99+
it('calendar button has tabIndex -1', () => {
100+
const { container } = render(<DateInput />);
101+
const button = container.querySelector('.dsn-date-input__button');
102+
expect(button).toHaveAttribute('tabindex', '-1');
103+
});
104+
105+
it('calendar button has type button', () => {
106+
const { container } = render(<DateInput />);
107+
const button = container.querySelector('.dsn-date-input__button');
108+
expect(button).toHaveAttribute('type', 'button');
109+
});
110+
111+
it('calendar button has aria-hidden', () => {
112+
const { container } = render(<DateInput />);
113+
const button = container.querySelector('.dsn-date-input__button');
114+
expect(button).toHaveAttribute('aria-hidden', 'true');
115+
});
116+
117+
it('calendar button has visually hidden label text', () => {
118+
const { container } = render(<DateInput />);
119+
const hiddenLabel = container.querySelector(
120+
'.dsn-date-input__button-label'
121+
);
122+
expect(hiddenLabel).toBeInTheDocument();
123+
expect(hiddenLabel).toHaveTextContent('Datumkiezer openen');
124+
});
125+
126+
it('does not render calendar button when disabled', () => {
127+
const { container } = render(<DateInput disabled data-testid="input" />);
128+
expect(
129+
container.querySelector('.dsn-date-input__button')
130+
).not.toBeInTheDocument();
131+
});
132+
133+
it('does not render calendar button when readOnly', () => {
134+
const { container } = render(<DateInput readOnly data-testid="input" />);
135+
expect(
136+
container.querySelector('.dsn-date-input__button')
137+
).not.toBeInTheDocument();
138+
});
139+
140+
it('calls showPicker on button click when available', async () => {
141+
const user = userEvent.setup();
142+
const showPicker = vi.fn();
143+
const { container } = render(<DateInput data-testid="input" />);
144+
const input = screen.getByTestId('input') as HTMLInputElement;
145+
Object.defineProperty(input, 'showPicker', {
146+
value: showPicker,
147+
writable: true,
148+
});
149+
const button = container.querySelector(
150+
'.dsn-date-input__button'
151+
) as HTMLButtonElement;
152+
await user.click(button);
153+
expect(showPicker).toHaveBeenCalledOnce();
154+
});
155+
});
156+
157+
it('can be disabled', () => {
158+
render(<DateInput disabled data-testid="input" />);
159+
expect(screen.getByTestId('input')).toBeDisabled();
160+
});
161+
162+
it('can be read-only', () => {
163+
render(<DateInput readOnly data-testid="input" />);
164+
expect(screen.getByTestId('input')).toHaveAttribute('readOnly');
165+
});
166+
167+
it('can be required', () => {
168+
render(<DateInput required data-testid="input" />);
169+
expect(screen.getByTestId('input')).toBeRequired();
170+
});
171+
172+
describe('invalid state', () => {
173+
it('sets aria-invalid when invalid prop is true', () => {
174+
render(<DateInput invalid data-testid="input" />);
175+
expect(screen.getByTestId('input')).toHaveAttribute(
176+
'aria-invalid',
177+
'true'
178+
);
179+
});
180+
181+
it('does not set aria-invalid when invalid prop is false', () => {
182+
render(<DateInput invalid={false} data-testid="input" />);
183+
expect(screen.getByTestId('input')).not.toHaveAttribute('aria-invalid');
184+
});
185+
186+
it('does not set aria-invalid by default', () => {
187+
render(<DateInput data-testid="input" />);
188+
expect(screen.getByTestId('input')).not.toHaveAttribute('aria-invalid');
189+
});
190+
});
191+
192+
describe('accessibility', () => {
193+
it('can have aria-describedby', () => {
194+
render(<DateInput aria-describedby="help-text" data-testid="input" />);
195+
expect(screen.getByTestId('input')).toHaveAttribute(
196+
'aria-describedby',
197+
'help-text'
198+
);
199+
});
200+
201+
it('can have aria-labelledby', () => {
202+
render(<DateInput aria-labelledby="label-id" data-testid="input" />);
203+
expect(screen.getByTestId('input')).toHaveAttribute(
204+
'aria-labelledby',
205+
'label-id'
206+
);
207+
});
208+
});
209+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import React from 'react';
2+
import { classNames } from '@dsn/core';
3+
import { Button } from '../Button';
4+
import { Icon } from '../Icon';
5+
import '../TextInput/TextInput.css';
6+
import './DateInput.css';
7+
8+
export interface DateInputProps extends Omit<
9+
React.InputHTMLAttributes<HTMLInputElement>,
10+
'type'
11+
> {
12+
/**
13+
* Whether the input is in an invalid state
14+
* @default false
15+
*/
16+
invalid?: boolean;
17+
18+
/**
19+
* Additional CSS class names
20+
*/
21+
className?: string;
22+
}
23+
24+
/**
25+
* Date Input component
26+
* Date input with an interactive calendar button at inline-end that opens the native date picker.
27+
* Fixed width (sm) — not configurable, as date inputs have a predictable content width.
28+
*
29+
* @example
30+
* ```tsx
31+
* // Basic usage
32+
* <DateInput />
33+
*
34+
* // With label
35+
* <FormFieldLabel htmlFor="date">Datum</FormFieldLabel>
36+
* <DateInput id="date" />
37+
*
38+
* // With default value
39+
* <DateInput defaultValue="2025-03-15" />
40+
*
41+
* // Invalid state
42+
* <DateInput invalid aria-invalid="true" aria-describedby="error" />
43+
* ```
44+
*/
45+
export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
46+
({ className, invalid, disabled, readOnly, ...props }, ref) => {
47+
const inputRef = React.useRef<HTMLInputElement>(null);
48+
49+
// Merge external ref with internal ref
50+
const handleRef = React.useCallback(
51+
(node: HTMLInputElement | null) => {
52+
(inputRef as React.MutableRefObject<HTMLInputElement | null>).current =
53+
node;
54+
if (typeof ref === 'function') {
55+
ref(node);
56+
} else if (ref) {
57+
(ref as React.MutableRefObject<HTMLInputElement | null>).current =
58+
node;
59+
}
60+
},
61+
[ref]
62+
);
63+
64+
const handleButtonClick = () => {
65+
if (inputRef.current && 'showPicker' in inputRef.current) {
66+
inputRef.current.showPicker();
67+
}
68+
};
69+
70+
const inputClasses = classNames(
71+
'dsn-text-input',
72+
'dsn-date-input',
73+
className
74+
);
75+
76+
return (
77+
<div className="dsn-date-input-wrapper">
78+
<input
79+
ref={handleRef}
80+
type="date"
81+
className={inputClasses}
82+
aria-invalid={invalid || undefined}
83+
disabled={disabled}
84+
readOnly={readOnly}
85+
{...props}
86+
/>
87+
{!disabled && !readOnly && (
88+
<Button
89+
variant="subtle"
90+
size="small"
91+
iconOnly
92+
className="dsn-date-input__button"
93+
onClick={handleButtonClick}
94+
tabIndex={-1}
95+
aria-hidden="true"
96+
>
97+
<Icon name="calendar-event" aria-hidden />
98+
<span className="dsn-date-input__button-label">
99+
Datumkiezer openen
100+
</span>
101+
</Button>
102+
)}
103+
</div>
104+
);
105+
}
106+
);
107+
108+
DateInput.displayName = 'DateInput';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { DateInput } from './DateInput';
2+
export type { DateInputProps } from './DateInput';

packages/components-react/src/TimeInput/TimeInput.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
max-inline-size: var(--dsn-form-control-width-sm);
1515
}
1616

17-
/* Clock button — centered vertically, positioned at inline-end */
17+
/* Clock button — centered vertically, positioned at inline-end with reduced margin */
1818
.dsn-time-input__button {
1919
position: absolute;
2020
inset-block-start: 50%;
21-
inset-inline-end: var(--dsn-form-control-padding-inline-end);
21+
inset-inline-end: var(--dsn-time-input-button-inset-inline-end);
2222
transform: translateY(-50%);
2323
}
2424

packages/components-react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export * from './NumberInput';
2424
export * from './TelephoneInput';
2525
export * from './SearchInput';
2626
export * from './TimeInput';
27+
export * from './DateInput';
2728
export * from './TextArea';
2829

2930
// Form Options

0 commit comments

Comments
 (0)