Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components): time range form #6156

Merged
merged 3 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
199 changes: 199 additions & 0 deletions app/src/components/datetime/TimeRangeForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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(() => {
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 end 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
Loading