Skip to content

Commit

Permalink
Split text input into segments
Browse files Browse the repository at this point in the history
  • Loading branch information
connor-baer committed Oct 12, 2024
1 parent ccbb816 commit e01db7f
Show file tree
Hide file tree
Showing 10 changed files with 589 additions and 51 deletions.
33 changes: 33 additions & 0 deletions packages/circuit-ui/components/DateInput/DateInput.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
.input {
display: flex;
justify-content: space-between;
background-color: var(--cui-bg-normal);
border: none;
border-radius: var(--cui-border-radius-byte);
outline: 0;
box-shadow: 0 0 0 1px var(--cui-border-normal);
transition:
box-shadow var(--cui-transitions-default),
padding var(--cui-transitions-default);
}

.input:hover {
box-shadow: 0 0 0 1px var(--cui-border-normal-hovered);
}

.input:focus-within {
box-shadow: 0 0 0 2px var(--cui-border-accent);
}

.segments {
display: flex;
gap: 2px;
padding: var(--cui-spacings-byte) var(--cui-spacings-mega);
}

.literal {
padding: var(--cui-spacings-bit) 0;
font-size: var(--cui-typography-body-m-font-size);
line-height: var(--cui-typography-body-m-line-height);
}

.calendar-button {
border: none;
border-left: 1px solid var(--cui-border-normal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ export default {

const baseArgs = {
label: 'Date of birth',
validationHint: 'Use the YYYY-MM-DD format',
prevMonthButtonLabel: 'Previous month',
nextMonthButtonLabel: 'Previous month',
openCalendarButtonLabel: 'Change date',
closeCalendarButtonLabel: 'Close',
applyDateButtonLabel: 'Apply',
clearDateButtonLabel: 'Clear',
yearInputLabel: 'Year',
monthInputLabel: 'Month',
dayInputLabel: 'Day',
locale: 'en-US',
// min: '2024-11-14',
// max: '2024-11-24',
};

export const Base = (args: DateInputProps) => {
Expand Down
225 changes: 181 additions & 44 deletions packages/circuit-ui/components/DateInput/DateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,15 @@

'use client';

import {
forwardRef,
useEffect,
useId,
useRef,
useState,
type ChangeEvent,
} from 'react';
import { forwardRef, useEffect, useId, useRef, useState } from 'react';
import type { Temporal } from 'temporal-polyfill';
import { flip, offset, shift, useFloating } from '@floating-ui/react-dom';
import { Calendar as CalendarIcon } from '@sumup-oss/icons';
import { formatDate } from '@sumup-oss/intl';

import { Input, type InputProps } from '../Input/index.js';
import type { InputProps } from '../Input/index.js';
import { IconButton } from '../Button/IconButton.js';
import { Calendar, type CalendarProps } from '../Calendar/Calendar.js';
import { applyMultipleRefs } from '../../util/refs.js';
import { useMedia } from '../../hooks/useMedia/useMedia.js';
import { toPlainDate } from '../../util/date.js';
import {
Expand All @@ -40,11 +32,26 @@ import {
} from '../../util/errors.js';
import { Headline } from '../Headline/Headline.js';
import { CloseButton } from '../CloseButton/CloseButton.js';
import { clsx } from '../../styles/clsx.js';
import { Button } from '../Button/Button.js';
import {
FieldLabelText,
FieldLegend,
FieldSet,
FieldValidationHint,
FieldWrapper,
} from '../Field/Field.js';

Check failure on line 43 in packages/circuit-ui/components/DateInput/DateInput.tsx

View workflow job for this annotation

GitHub Actions / ci (20)

AccessibilityError: [DateInput] The `yearInputLabel` prop is missing or invalid.

at packages/circuit-ui/components/DateInput/DateInput.tsx:43:5 at ThemeProvider node_modules/@emotion/react/dist/emotion-element-ba80abe0.development.esm.js:143:37 at WithProviders packages/circuit-ui/util/test-utils.tsx:22:3

Check failure on line 43 in packages/circuit-ui/components/DateInput/DateInput.tsx

View workflow job for this annotation

GitHub Actions / ci (20)

AccessibilityError: [DateInput] The `yearInputLabel` prop is missing or invalid.

at packages/circuit-ui/components/DateInput/DateInput.tsx:43:5 at ThemeProvider node_modules/@emotion/react/dist/emotion-element-ba80abe0.development.esm.js:143:37 at WithProviders packages/circuit-ui/util/test-utils.tsx:22:3

Check failure on line 43 in packages/circuit-ui/components/DateInput/DateInput.tsx

View workflow job for this annotation

GitHub Actions / ci (20)

AccessibilityError: [DateInput] The `yearInputLabel` prop is missing or invalid.

at packages/circuit-ui/components/DateInput/DateInput.tsx:43:5 at ThemeProvider node_modules/@emotion/react/dist/emotion-element-ba80abe0.development.esm.js:143:37 at WithProviders packages/circuit-ui/util/test-utils.tsx:22:3

Check failure on line 43 in packages/circuit-ui/components/DateInput/DateInput.tsx

View workflow job for this annotation

GitHub Actions / ci (22)

AccessibilityError: [DateInput] The `yearInputLabel` prop is missing or invalid.

at packages/circuit-ui/components/DateInput/DateInput.tsx:43:5 at ThemeProvider node_modules/@emotion/react/dist/emotion-element-ba80abe0.development.esm.js:143:37 at WithProviders packages/circuit-ui/util/test-utils.tsx:22:3

Check failure on line 43 in packages/circuit-ui/components/DateInput/DateInput.tsx

View workflow job for this annotation

GitHub Actions / ci (22)

AccessibilityError: [DateInput] The `yearInputLabel` prop is missing or invalid.

at packages/circuit-ui/components/DateInput/DateInput.tsx:43:5 at ThemeProvider node_modules/@emotion/react/dist/emotion-element-ba80abe0.development.esm.js:143:37 at WithProviders packages/circuit-ui/util/test-utils.tsx:22:3

Check failure on line 43 in packages/circuit-ui/components/DateInput/DateInput.tsx

View workflow job for this annotation

GitHub Actions / ci (22)

AccessibilityError: [DateInput] The `yearInputLabel` prop is missing or invalid.

at packages/circuit-ui/components/DateInput/DateInput.tsx:43:5 at ThemeProvider node_modules/@emotion/react/dist/emotion-element-ba80abe0.development.esm.js:143:37 at WithProviders packages/circuit-ui/util/test-utils.tsx:22:3
import classes from './DateInput.module.css';
import { Dialog } from './components/Dialog.js';
import { getDateSegments } from './DateInputService.js';
import {
usePlainDateState,
useDaySegment,
useMonthSegment,
useYearSegment,
useSegmentFocus,
} from './hooks.js';
import { Segment } from './components/Segment.js';

export interface DateInputProps
extends Omit<
Expand Down Expand Up @@ -103,6 +110,18 @@ export interface DateInputProps
* format (`YYYY-MM-DD`) (inclusive).
*/
max?: string;
/**
* TODO:
*/
yearInputLabel: string;
/**
* TODO:
*/
monthInputLabel: string;
/**
* TODO:
*/
dayInputLabel: string;
}

/**
Expand All @@ -120,26 +139,56 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
locale,
firstDayOfWeek,
modifiers,
hideLabel,
required,
disabled,
readOnly,
invalid,
hasWarning,
showValid,
validationHint,
optionalLabel,
openCalendarButtonLabel,
closeCalendarButtonLabel,
applyDateButtonLabel,
clearDateButtonLabel,
prevMonthButtonLabel,
nextMonthButtonLabel,
...props
yearInputLabel,
monthInputLabel,
dayInputLabel,
className,
style,
// ...props
},
ref,
// ref
) => {
const isMobile = useMedia('(max-width: 479px)');

const referenceRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDialogElement>(null);
const calendarRef = useRef<HTMLDivElement>(null);

const headlineId = useId();
const validationHintId = useId();

const [focusProps, focusNextSegment] = useSegmentFocus();
const state = usePlainDateState({ value, min, max });
const yearProps = useYearSegment(state, focusNextSegment);
const monthProps = useMonthSegment(state, focusNextSegment);
const dayProps = useDaySegment(state, focusNextSegment);

const [open, setOpen] = useState(false);
const [selection, setSelection] = useState<Temporal.PlainDate>();

const { refs, floatingStyles, update } = useFloating({
const { floatingStyles, update } = useFloating({
open,
placement: 'bottom-end',
middleware: [offset(4), flip(), shift()],
elements: {
reference: referenceRef.current,
floating: floatingRef.current,
},
});

useEffect(() => {
Expand Down Expand Up @@ -173,8 +222,6 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
setOpen(false);
};

const placeholder = 'yyyy-mm-dd';

const handleSelect = (date: Temporal.PlainDate) => {
setSelection(date);

Expand All @@ -194,10 +241,6 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
closeCalendar();
};

const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};

const mobileStyles = {
position: 'fixed',
bottom: '0px',
Expand Down Expand Up @@ -232,45 +275,139 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
'The `clearDateButtonLabel` prop is missing or invalid.',
);
}
if (!isSufficientlyLabelled(yearInputLabel)) {
throw new AccessibilityError(
'DateInput',
'The `yearInputLabel` prop is missing or invalid.',
);
}
if (!isSufficientlyLabelled(monthInputLabel)) {
throw new AccessibilityError(
'DateInput',
'The `monthInputLabel` prop is missing or invalid.',
);
}
if (!isSufficientlyLabelled(dayInputLabel)) {
throw new AccessibilityError(
'DateInput',
'The `dayInputLabel` prop is missing or invalid.',
);
}
}

const plainDate = toPlainDate(value);
const calendarButtonLabel = plainDate
? // @ts-expect-error FIXME: Update @sumup-oss/intl
[openCalendarButtonLabel, formatDate(plainDate, locale, 'long')].join(
? [openCalendarButtonLabel, formatDate(plainDate, locale, 'long')].join(
', ',
)
: openCalendarButtonLabel;

const segments = getDateSegments(locale);

// parts are closely related:
// - max days depends on month and year
// - max month depends on year (and max prop)
// - focus management

// dispatch onChange when each part has been filled in
// dispatch with empty string when a part is removed

return (
<div>
<Input
{...props}
ref={applyMultipleRefs(ref, refs.setReference)}
label={label}
<FieldWrapper className={className} style={style} disabled={disabled}>
{/* TODO: Replicate native date input for uncontrolled inputs? */}
{/* <input
ref={ref}
type="hidden"
value={value}
type="text"
min={min}
max={max}
placeholder={placeholder}
renderSuffix={(suffixProps) => (
required={required}
disabled={disabled}
readOnly={readOnly}
{...props}
/> */}
<FieldSet>
<FieldLegend>
<FieldLabelText
label={label}
hideLabel={hideLabel}
required={required}
optionalLabel={optionalLabel}
/>
</FieldLegend>
<div className={classes.input} ref={referenceRef}>
<div className={classes.segments}>
{segments.map((segment, index) => {
switch (segment.type) {
case 'year':
return (
<Segment
key={segment.type}
aria-label={yearInputLabel}
required={required}
disabled={disabled}
readOnly={readOnly}
{...focusProps}
{...yearProps}
/>
);
case 'month':
return (
<Segment
key={segment.type}
aria-label={monthInputLabel}
required={required}
disabled={disabled}
readOnly={readOnly}
{...focusProps}
{...monthProps}
/>
);
case 'day':
return (
<Segment
key={segment.type}
aria-label={dayInputLabel}
required={required}
disabled={disabled}
readOnly={readOnly}
{...focusProps}
{...dayProps}
/>
);
case 'literal':
return (
<div
key={segment.type + index}
className={classes.literal}
aria-hidden="true"
>
{segment.value}
</div>
);
default:
return null;
}
})}
</div>
<IconButton
{...suffixProps}
icon={CalendarIcon}
variant="secondary"
onClick={openCalendar}
className={clsx(
suffixProps.className,
classes['calendar-button'],
)}
className={classes['calendar-button']}
>
{calendarButtonLabel}
</IconButton>
)}
onChange={handleInputChange}
/>
</div>
<FieldValidationHint
id={validationHintId}
disabled={disabled}
invalid={invalid}
hasWarning={hasWarning}
showValid={showValid}
validationHint={validationHint}
/>
</FieldSet>
<Dialog
ref={refs.setFloating}
ref={floatingRef}
open={open}
onClose={closeCalendar}
aria-labelledby={headlineId}
Expand All @@ -296,8 +433,8 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
className={classes.calendar}
onSelect={handleSelect}
selection={selection}
minDate={toPlainDate(min) || undefined}
maxDate={toPlainDate(max) || undefined}
minDate={state.minDate}
maxDate={state.maxDate}
locale={locale}
firstDayOfWeek={firstDayOfWeek}
modifiers={modifiers}
Expand All @@ -320,7 +457,7 @@ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
</div>
)}
</Dialog>
</div>
</FieldWrapper>
);
},
);
Expand Down
Loading

0 comments on commit e01db7f

Please sign in to comment.