Skip to content

Commit

Permalink
feat(components): time range form
Browse files Browse the repository at this point in the history
  • Loading branch information
mikeldking committed Jan 27, 2025
1 parent 4ab0b79 commit ced93d9
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 16 deletions.
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@internationalized/date": "^3.7.0",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@react-aria/utils": "^3.26.0",
Expand Down
25 changes: 14 additions & 11 deletions app/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions app/src/@types/time.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,11 @@ declare type TimeRange = {
start: Date;
end: Date;
};

/**
* A time range that is open-ended on either the start or end.
*/
declare type OpenTimeRange = {
start?: Date | null;
end?: Date | null;
};
21 changes: 19 additions & 2 deletions app/src/components/datetime/DateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import {
import { css } from "@emotion/react";

import { fieldBaseCSS } from "../field/styles";
import { StylableProps } from "../types";

export type DateFieldProps<T extends DateValue> = AriaDateFieldProps<T>;
export interface DateFieldProps<T extends DateValue>
extends AriaDateFieldProps<T>,
StylableProps {}

const dateFieldCSS = css`
--date-field-vertical-padding: 6px;
Expand All @@ -24,6 +27,7 @@ const dateFieldCSS = css`
border-radius: var(--ac-global-rounding-small);
background-color: var(--ac-global-input-field-background-color);
width: fit-content;
box-sizing: border-box;
min-width: 150px;
white-space: nowrap;
forced-color-adjust: none;
Expand All @@ -32,6 +36,10 @@ const dateFieldCSS = css`
outline: 1px solid var(--ac-global-color-primary);
outline-offset: -1px;
}
&[data-invalid] {
border-color: var(--ac-global-color-danger);
}
}
.react-aria-DateSegment {
Expand All @@ -58,12 +66,21 @@ const dateFieldCSS = css`
}
}
`;

/**
* A date field, can be used to input just a date as well as a date and time.
*/
function DateField<T extends DateValue>(
props: DateFieldProps<T>,
ref: Ref<HTMLDivElement>
) {
const { css: propsCSS, ...restProps } = props;
return (
<AriaDateField css={css(fieldBaseCSS, dateFieldCSS)} {...props} ref={ref} />
<AriaDateField
css={css(fieldBaseCSS, dateFieldCSS, propsCSS)}
{...restProps}
ref={ref}
/>
);
}

Expand Down
200 changes: 200 additions & 0 deletions app/src/components/datetime/TimeRangeForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import React, { useCallback } from "react";
import {
DateInput,
DateSegment,
DateValue,
FieldError,
Form,
Label,
} from "react-aria-components";
import { Controller, useForm } from "react-hook-form";
import {
getLocalTimeZone,
parseAbsoluteToLocal,
} from "@internationalized/date";
import { css } from "@emotion/react";

import { Button, Icon, Icons } from "@phoenix/components";

import { DateField } from "./DateField";

const containerCSS = css`
display: flex;
flex-direction: column;
gap: var(--ac-global-dimension-size-200);
`;

const formRowCSS = css`
display: flex;
gap: var(--ac-global-dimension-size-100);
align-items: start;
justify-content: end;
/* Move the button down to align */
button {
margin-top: 23.5px;
}
`;

const controlsRowCSS = css`
width: 100%;
display: flex;
justify-content: flex-end;
gap: var(--ac-global-dimension-size-100);
`;

const dateFieldCSS = css`
width: 100%;
.react-aria-DateInput {
width: 100%;
// Eliminate the re-sizing of the DateField as you type
min-width: 200px;
}
`;

type TimeRangeFormParams = {
startDate: DateValue | null;
endDate: DateValue | null;
};

export type TimeRangeFormProps = {
initialValue?: OpenTimeRange;
/**
* Called when the form is submitted.
*/
onSubmit: (data: OpenTimeRange) => void;
};

function timeRangeToFormParams(timeRange: OpenTimeRange): TimeRangeFormParams {
return {
startDate: timeRange.start
? parseAbsoluteToLocal(timeRange.start.toISOString())
: null,
endDate: timeRange.end
? parseAbsoluteToLocal(timeRange.end.toISOString())
: null,
};
}

/**
* A form that displays a date and time picker.
*/
export function TimeRangeForm(props: TimeRangeFormProps) {
const { initialValue, onSubmit: propsOnSubmit } = props;
const {
control,
handleSubmit,
formState: { isValid },
resetField,
setError,
clearErrors,
} = useForm<TimeRangeFormParams>({
defaultValues: timeRangeToFormParams(initialValue || {}),
});

const onStartClear = useCallback(() => {
resetField("startDate", { defaultValue: null });
}, [resetField]);

const onEndClear = useCallback(() => {
alert("onEndClear");
resetField("endDate", { defaultValue: null });
}, [resetField]);

const onSubmit = useCallback(
(data: TimeRangeFormParams) => {
clearErrors();
const { startDate, endDate } = data;
const start = startDate ? startDate.toDate(getLocalTimeZone()) : null;
const end = endDate ? endDate.toDate(getLocalTimeZone()) : null;
if (start && end && start > end) {
setError("endDate", {
message: "End must be after the start date",
});
return;
}
propsOnSubmit({ start, end });
},
[propsOnSubmit, setError, clearErrors]
);
return (
<Form
css={containerCSS}
data-testid="time-range-form"
onSubmit={handleSubmit(onSubmit)}
>
<div data-testid="start-time" css={formRowCSS}>
<Controller
name="startDate"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { invalid },
}) => (
<DateField
isInvalid={invalid}
onChange={onChange}
onBlur={onBlur}
value={value}
granularity="second"
hideTimeZone
css={dateFieldCSS}
>
<Label>Start Date</Label>
<DateInput>
{(segment) => <DateSegment segment={segment} />}
</DateInput>
</DateField>
)}
/>
<Button
size="S"
excludeFromTabOrder
onPress={onStartClear}
aria-label="Clear start date and time"
icon={<Icon svg={<Icons.Refresh />} />}
/>
</div>
<div data-testid="end-time" css={formRowCSS}>
<Controller
name="endDate"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { invalid, error },
}) => {
return (
<DateField
isInvalid={invalid}
onChange={onChange}
onBlur={onBlur}
value={value}
granularity="second"
hideTimeZone
css={dateFieldCSS}
>
<Label>End Date</Label>
<DateInput>
{(segment) => <DateSegment segment={segment} />}
</DateInput>
{error ? <FieldError>{error.message}</FieldError> : null}
</DateField>
);
}}
/>
<Button
size="S"
excludeFromTabOrder
onPress={onEndClear}
aria-label="Clear start date and time"
icon={<Icon svg={<Icons.Refresh />} />}
/>
</div>
<div data-testid="controls" css={controlsRowCSS}>
<Button size="S">Cancel</Button>
<Button isDisabled={!isValid} size="S" type="submit" variant="primary">
Apply
</Button>
</div>
</Form>
);
}
1 change: 1 addition & 0 deletions app/src/components/datetime/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./LastNTimeRangePicker";
export * from "./ConnectedLastNTimeRangePicker";
export * from "./DateField";
export * from "./TimeField";
export * from "./TimeRangeForm";
1 change: 0 additions & 1 deletion app/src/components/overlay/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ const popoverCSS = css`
background: var(--background-color);
color: var(--ac-global-text-color-900);
outline: none;
max-width: 250px;
.react-aria-OverlayArrow svg {
display: block;
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/textfield/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const textFieldCSS = css`
var(--textfield-horizontal-padding);
box-sizing: border-box;
outline-offset: -1px;
outline: 1px solid transparent;
outline: var(--ac-global-border-size-thin) solid transparent;
&[data-focused] {
outline: 1px solid var(--ac-global-input-field-border-color-active);
}
Expand Down
Loading

0 comments on commit ced93d9

Please sign in to comment.