Skip to content

Commit 76982f0

Browse files
Added Calendar component and used for filtering (TryGhost#27657)
Ref https://linear.app/ghost/issue/BER-3598/ Adding a Shade calendar component and replacing the existing native datepicker for filtering with it. --------- Co-authored-by: Kevin Ansfield <kevin@ghost.org>
1 parent e88ae30 commit 76982f0

9 files changed

Lines changed: 672 additions & 28 deletions

File tree

apps/posts/src/views/comments/comment-fields.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ export const commentFields = defineFields({
6969
ui: {
7070
label: 'Date',
7171
defaultOperator: DEFAULT_DATE_OPERATOR,
72-
type: 'date',
73-
className: 'w-full max-w-32'
72+
type: 'date'
7473
},
7574
codec: dateCodec()
7675
},

apps/posts/src/views/members/member-fields.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,7 @@ export const memberFields = defineFields({
150150
ui: {
151151
label: 'Last seen',
152152
type: 'date',
153-
defaultOperator: DEFAULT_DATE_OPERATOR,
154-
className: 'w-40'
153+
defaultOperator: DEFAULT_DATE_OPERATOR
155154
},
156155
codec: dateCodec()
157156
},
@@ -160,8 +159,7 @@ export const memberFields = defineFields({
160159
ui: {
161160
label: 'Created',
162161
type: 'date',
163-
defaultOperator: DEFAULT_DATE_OPERATOR,
164-
className: 'w-40'
162+
defaultOperator: DEFAULT_DATE_OPERATOR
165163
},
166164
codec: dateCodec()
167165
},
@@ -264,8 +262,7 @@ export const memberFields = defineFields({
264262
ui: {
265263
label: 'Paid start date',
266264
type: 'date',
267-
defaultOperator: DEFAULT_DATE_OPERATOR,
268-
className: 'w-40'
265+
defaultOperator: DEFAULT_DATE_OPERATOR
269266
},
270267
metadata: {
271268
activeColumn: {
@@ -281,8 +278,7 @@ export const memberFields = defineFields({
281278
ui: {
282279
label: 'Next billing date',
283280
type: 'date',
284-
defaultOperator: DEFAULT_DATE_OPERATOR,
285-
className: 'w-40'
281+
defaultOperator: DEFAULT_DATE_OPERATOR
286282
},
287283
metadata: {
288284
activeColumn: {

apps/shade/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,11 @@
127127
"clsx": "2.1.1",
128128
"cmdk": "1.1.1",
129129
"color": "^5.0.3",
130+
"date-fns": "4.1.0",
130131
"lucide-react": "0.577.0",
131132
"moment-timezone": "^0.5.48",
132133
"react": "18.3.1",
134+
"react-day-picker": "9.14.0",
133135
"react-dom": "18.3.1",
134136
"react-dropzone": "14.2.3",
135137
"react-hook-form": "7.72.1",

apps/shade/src/components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './components/ui/badge';
66
export * from './components/ui/banner';
77
export * from './components/ui/breadcrumb';
88
export * from './components/ui/button';
9+
export * from './components/ui/calendar';
910
export * from './components/ui/card';
1011
export * from './components/ui/chart';
1112
export * from './components/ui/checkbox';

apps/shade/src/components/features/filters/filters.tsx

Lines changed: 220 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client';
22

33
import type React from 'react';
4-
import {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react';
4+
import {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
5+
import {Calendar} from '@/components/ui/calendar';
56
import {
67
Command,
78
CommandEmpty,
@@ -21,7 +22,7 @@ import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover';
2122
import {Switch} from '@/components/ui/switch';
2223
import {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip';
2324
import {cva, type VariantProps} from 'class-variance-authority';
24-
import {AlertCircle, Check, Loader2, Plus, X} from 'lucide-react';
25+
import {AlertCircle, Calendar as CalendarIcon, Check, Loader2, Plus, X} from 'lucide-react';
2526
import {cn} from '@/lib/utils';
2627

2728
// i18n Configuration Interface
@@ -642,6 +643,214 @@ function FilterInput<T = unknown>({
642643
);
643644
}
644645

646+
// Parses an HTML-date-input value (YYYY-MM-DD) into a local-time Date so it
647+
// matches what the native control would have produced. Returns undefined for
648+
// empty / unparseable input rather than throwing.
649+
const parseFilterDateValue = (value: string): Date | undefined => {
650+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
651+
if (!match) {
652+
return undefined;
653+
}
654+
const [, yearPart, monthPart, dayPart] = match;
655+
const year = Number(yearPart);
656+
const month = Number(monthPart);
657+
const day = Number(dayPart);
658+
const date = new Date(year, month - 1, day);
659+
660+
if (
661+
date.getFullYear() !== year ||
662+
date.getMonth() !== month - 1 ||
663+
date.getDate() !== day
664+
) {
665+
return undefined;
666+
}
667+
668+
return date;
669+
};
670+
671+
const formatFilterDateValue = (date: Date | undefined): string => {
672+
if (!date) {
673+
return '';
674+
}
675+
const year = date.getFullYear();
676+
const month = String(date.getMonth() + 1).padStart(2, '0');
677+
const day = String(date.getDate()).padStart(2, '0');
678+
return `${year}-${month}-${day}`;
679+
};
680+
681+
interface FilterDatePickerProps<T = unknown> {
682+
field?: FilterFieldConfig<T>;
683+
value: string;
684+
onChange: (value: string) => void;
685+
className?: string;
686+
}
687+
688+
// Composes a text input for YYYY-MM-DD values with a Shade Calendar popover.
689+
// Avoid using <input type="date"> here: Safari opens its native date picker
690+
// from clicks inside the text area even when the calendar indicator is hidden.
691+
function FilterDatePicker<T = unknown>({
692+
field,
693+
value,
694+
onChange,
695+
className
696+
}: FilterDatePickerProps<T>) {
697+
const context = useFilterContext();
698+
const [open, setOpen] = useState(false);
699+
const parsed = useMemo(() => parseFilterDateValue(value), [value]);
700+
const [month, setMonth] = useState<Date | undefined>(parsed);
701+
const inputRef = useRef<HTMLInputElement>(null);
702+
const lastLocalCommitRef = useRef(value);
703+
// Local buffer for the input's value so the controlled element follows the
704+
// user's segment-edit state instead of the filter state. This insulates the
705+
// input from upstream re-renders triggered by URL roundtrips on Comments —
706+
// each keystroke updates `localValue` (which matches what the browser put in
707+
// the DOM), so React never has to force the DOM back to the committed
708+
// value mid-edit and the segment-edit cursor stays intact.
709+
const [localValue, setLocalValue] = useState(value);
710+
711+
useEffect(() => {
712+
if (parsed) {
713+
setMonth(parsed);
714+
}
715+
}, [parsed]);
716+
717+
// Sync the buffer from the committed filter value only when the user
718+
// isn't editing — calendar picks, "Clear filters", URL deep-links, etc.
719+
useEffect(() => {
720+
if (value === lastLocalCommitRef.current) {
721+
return;
722+
}
723+
724+
if (document.activeElement !== inputRef.current) {
725+
setLocalValue(value);
726+
lastLocalCommitRef.current = value;
727+
}
728+
}, [value]);
729+
730+
const notifyInputChange = (nextValue: string, input: HTMLInputElement | null = inputRef.current) => {
731+
if (!field?.onInputChange || !input) {
732+
return;
733+
}
734+
735+
field.onInputChange({
736+
target: {...input, value: nextValue},
737+
currentTarget: {...input, value: nextValue}
738+
} as React.ChangeEvent<HTMLInputElement>);
739+
};
740+
741+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
742+
setLocalValue(e.target.value);
743+
};
744+
745+
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
746+
const inputValue = e.target.value;
747+
const parsedInputValue = parseFilterDateValue(inputValue);
748+
const dateValue = inputValue && !parsedInputValue ? formatFilterDateValue(new Date()) : inputValue;
749+
750+
if (parsedInputValue) {
751+
setMonth(parsedInputValue);
752+
} else if (dateValue) {
753+
setMonth(parseFilterDateValue(dateValue));
754+
}
755+
756+
if (dateValue !== inputValue) {
757+
if (inputRef.current) {
758+
inputRef.current.value = dateValue;
759+
}
760+
setLocalValue(dateValue);
761+
}
762+
763+
if (dateValue !== value) {
764+
lastLocalCommitRef.current = dateValue;
765+
onChange(dateValue);
766+
}
767+
notifyInputChange(dateValue, e.target);
768+
};
769+
770+
const handleSelect = (date: Date | undefined) => {
771+
if (!date) {
772+
lastLocalCommitRef.current = '';
773+
if (inputRef.current) {
774+
inputRef.current.value = '';
775+
}
776+
setLocalValue('');
777+
onChange('');
778+
notifyInputChange('');
779+
return;
780+
}
781+
const formatted = formatFilterDateValue(date);
782+
lastLocalCommitRef.current = formatted;
783+
if (inputRef.current) {
784+
inputRef.current.value = formatted;
785+
}
786+
setMonth(date);
787+
setLocalValue(formatted);
788+
onChange(formatted);
789+
notifyInputChange(formatted);
790+
setOpen(false);
791+
};
792+
793+
return (
794+
<div
795+
className={cn(
796+
'w-32',
797+
filterInputVariants({variant: context.variant, size: context.size, cursorPointer: false}),
798+
className
799+
)}
800+
data-slot="filters-input-wrapper"
801+
>
802+
{field?.prefix && (
803+
<div
804+
className={filterFieldAddonVariants({variant: context.variant, size: context.size})}
805+
data-slot="filters-prefix"
806+
>
807+
{field.prefix}
808+
</div>
809+
)}
810+
<div className="flex w-full min-w-0 items-stretch">
811+
<input
812+
ref={inputRef}
813+
autoComplete="off"
814+
className="w-full min-w-0 bg-transparent outline-hidden dark:!bg-transparent"
815+
data-slot="filters-input"
816+
inputMode="numeric"
817+
pattern="\d{4}-\d{2}-\d{2}"
818+
placeholder="YYYY-MM-DD"
819+
type="text"
820+
value={localValue}
821+
onBlur={handleInputBlur}
822+
onChange={handleInputChange}
823+
/>
824+
</div>
825+
<Popover open={open} onOpenChange={setOpen}>
826+
<PopoverTrigger asChild>
827+
<button
828+
aria-label="Open calendar"
829+
className={cn(
830+
filterFieldAddonVariants({variant: context.variant, size: context.size}),
831+
'cursor-pointer text-muted-foreground transition-colors hover:text-foreground'
832+
)}
833+
data-slot="filters-suffix"
834+
type="button"
835+
>
836+
<CalendarIcon className="size-3.5" />
837+
</button>
838+
</PopoverTrigger>
839+
<PopoverContent align="center" className="w-auto overflow-hidden p-0" sideOffset={4}>
840+
<Calendar
841+
captionLayout="dropdown"
842+
mode="single"
843+
month={month}
844+
selected={parsed}
845+
onMonthChange={setMonth}
846+
onSelect={handleSelect}
847+
/>
848+
</PopoverContent>
849+
</Popover>
850+
</div>
851+
);
852+
}
853+
645854
interface FilterRemoveButtonProps
646855
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
647856
VariantProps<typeof filterRemoveButtonVariants> {
@@ -1733,27 +1942,23 @@ function FilterValueSelector<T = unknown>({field, values, onChange, operator}: F
17331942
cursorPointer: context.cursorPointer
17341943
})}
17351944
>
1736-
<FilterInput
1737-
className={cn('w-24 max-w-full', field.className)}
1945+
<FilterDatePicker
1946+
className={cn('max-w-full', field.className)}
17381947
field={field}
1739-
type="date"
17401948
value={startDate}
1741-
onChange={e => onChange([e.target.value, endDate] as T[])}
1742-
onInputChange={field.onInputChange}
1949+
onChange={v => onChange([v, endDate] as T[])}
17431950
/>
17441951
<div
17451952
className={filterFieldBetweenVariants({variant: context.variant, size: context.size})}
17461953
data-slot="filters-between"
17471954
>
17481955
{context.i18n.to}
17491956
</div>
1750-
<FilterInput
1751-
className={cn('w-24 max-w-full', field.className)}
1957+
<FilterDatePicker
1958+
className={cn('max-w-full', field.className)}
17521959
field={field}
1753-
type="date"
17541960
value={endDate}
1755-
onChange={e => onChange([startDate, e.target.value] as T[])}
1756-
onInputChange={field.onInputChange}
1961+
onChange={v => onChange([startDate, v] as T[])}
17571962
/>
17581963
</div>
17591964
);
@@ -1824,13 +2029,11 @@ function FilterValueSelector<T = unknown>({field, values, onChange, operator}: F
18242029

18252030
if (field.type === 'date') {
18262031
return (
1827-
<FilterInput
1828-
className={cn('w-16', field.className)}
2032+
<FilterDatePicker
2033+
className={field.className}
18292034
field={field}
1830-
type="date"
18312035
value={(values[0] as string) || ''}
1832-
onChange={e => onChange([e.target.value] as T[])}
1833-
onInputChange={field.onInputChange}
2036+
onChange={v => onChange([v] as T[])}
18342037
/>
18352038
);
18362039
}

0 commit comments

Comments
 (0)