Skip to content
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
3 changes: 1 addition & 2 deletions apps/posts/src/views/comments/comment-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ export const commentFields = defineFields({
ui: {
label: 'Date',
defaultOperator: DEFAULT_DATE_OPERATOR,
type: 'date',
className: 'w-full max-w-32'
type: 'date'
},
codec: dateCodec()
},
Expand Down
12 changes: 4 additions & 8 deletions apps/posts/src/views/members/member-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,7 @@ export const memberFields = defineFields({
ui: {
label: 'Last seen',
type: 'date',
defaultOperator: DEFAULT_DATE_OPERATOR,
className: 'w-40'
defaultOperator: DEFAULT_DATE_OPERATOR
},
codec: dateCodec()
},
Expand All @@ -160,8 +159,7 @@ export const memberFields = defineFields({
ui: {
label: 'Created',
type: 'date',
defaultOperator: DEFAULT_DATE_OPERATOR,
className: 'w-40'
defaultOperator: DEFAULT_DATE_OPERATOR
},
codec: dateCodec()
},
Expand Down Expand Up @@ -264,8 +262,7 @@ export const memberFields = defineFields({
ui: {
label: 'Paid start date',
type: 'date',
defaultOperator: DEFAULT_DATE_OPERATOR,
className: 'w-40'
defaultOperator: DEFAULT_DATE_OPERATOR
},
metadata: {
activeColumn: {
Expand All @@ -281,8 +278,7 @@ export const memberFields = defineFields({
ui: {
label: 'Next billing date',
type: 'date',
defaultOperator: DEFAULT_DATE_OPERATOR,
className: 'w-40'
defaultOperator: DEFAULT_DATE_OPERATOR
},
metadata: {
activeColumn: {
Expand Down
2 changes: 2 additions & 0 deletions apps/shade/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,11 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"color": "^5.0.3",
"date-fns": "4.1.0",
"lucide-react": "0.577.0",
"moment-timezone": "^0.5.48",
"react": "18.3.1",
"react-day-picker": "9.14.0",
"react-dom": "18.3.1",
"react-dropzone": "14.2.3",
"react-hook-form": "7.72.1",
Expand Down
1 change: 1 addition & 0 deletions apps/shade/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './components/ui/badge';
export * from './components/ui/banner';
export * from './components/ui/breadcrumb';
export * from './components/ui/button';
export * from './components/ui/calendar';
export * from './components/ui/card';
export * from './components/ui/chart';
export * from './components/ui/checkbox';
Expand Down
237 changes: 220 additions & 17 deletions apps/shade/src/components/features/filters/filters.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client';

import type React from 'react';
import {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react';
import {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {Calendar} from '@/components/ui/calendar';
import {
Command,
CommandEmpty,
Expand All @@ -21,7 +22,7 @@ import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover';
import {Switch} from '@/components/ui/switch';
import {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip';
import {cva, type VariantProps} from 'class-variance-authority';
import {AlertCircle, Check, Loader2, Plus, X} from 'lucide-react';
import {AlertCircle, Calendar as CalendarIcon, Check, Loader2, Plus, X} from 'lucide-react';
import {cn} from '@/lib/utils';

// i18n Configuration Interface
Expand Down Expand Up @@ -642,6 +643,214 @@ function FilterInput<T = unknown>({
);
}

// Parses an HTML-date-input value (YYYY-MM-DD) into a local-time Date so it
// matches what the native control would have produced. Returns undefined for
// empty / unparseable input rather than throwing.
const parseFilterDateValue = (value: string): Date | undefined => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return undefined;
}
const [, yearPart, monthPart, dayPart] = match;
const year = Number(yearPart);
const month = Number(monthPart);
const day = Number(dayPart);
const date = new Date(year, month - 1, day);

if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return undefined;
}

return date;
};

const formatFilterDateValue = (date: Date | undefined): string => {
if (!date) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};

interface FilterDatePickerProps<T = unknown> {
field?: FilterFieldConfig<T>;
value: string;
onChange: (value: string) => void;
className?: string;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Composes a text input for YYYY-MM-DD values with a Shade Calendar popover.
// Avoid using <input type="date"> here: Safari opens its native date picker
// from clicks inside the text area even when the calendar indicator is hidden.
function FilterDatePicker<T = unknown>({
field,
value,
onChange,
className
}: FilterDatePickerProps<T>) {
const context = useFilterContext();
const [open, setOpen] = useState(false);
const parsed = useMemo(() => parseFilterDateValue(value), [value]);
const [month, setMonth] = useState<Date | undefined>(parsed);
const inputRef = useRef<HTMLInputElement>(null);
const lastLocalCommitRef = useRef(value);
// Local buffer for the input's value so the controlled element follows the
// user's segment-edit state instead of the filter state. This insulates the
// input from upstream re-renders triggered by URL roundtrips on Comments —
// each keystroke updates `localValue` (which matches what the browser put in
// the DOM), so React never has to force the DOM back to the committed
// value mid-edit and the segment-edit cursor stays intact.
const [localValue, setLocalValue] = useState(value);

useEffect(() => {
if (parsed) {
setMonth(parsed);
}
}, [parsed]);

// Sync the buffer from the committed filter value only when the user
// isn't editing — calendar picks, "Clear filters", URL deep-links, etc.
useEffect(() => {
if (value === lastLocalCommitRef.current) {
return;
}

if (document.activeElement !== inputRef.current) {
setLocalValue(value);
lastLocalCommitRef.current = value;
}
}, [value]);

const notifyInputChange = (nextValue: string, input: HTMLInputElement | null = inputRef.current) => {
if (!field?.onInputChange || !input) {
return;
}

field.onInputChange({
target: {...input, value: nextValue},
currentTarget: {...input, value: nextValue}
} as React.ChangeEvent<HTMLInputElement>);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};

const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
const parsedInputValue = parseFilterDateValue(inputValue);
const dateValue = inputValue && !parsedInputValue ? formatFilterDateValue(new Date()) : inputValue;

if (parsedInputValue) {
setMonth(parsedInputValue);
} else if (dateValue) {
setMonth(parseFilterDateValue(dateValue));
}

if (dateValue !== inputValue) {
if (inputRef.current) {
inputRef.current.value = dateValue;
}
setLocalValue(dateValue);
}

if (dateValue !== value) {
lastLocalCommitRef.current = dateValue;
onChange(dateValue);
}
notifyInputChange(dateValue, e.target);
};

const handleSelect = (date: Date | undefined) => {
if (!date) {
lastLocalCommitRef.current = '';
if (inputRef.current) {
inputRef.current.value = '';
}
setLocalValue('');
onChange('');
notifyInputChange('');
return;
}
const formatted = formatFilterDateValue(date);
lastLocalCommitRef.current = formatted;
if (inputRef.current) {
inputRef.current.value = formatted;
}
setMonth(date);
setLocalValue(formatted);
onChange(formatted);
notifyInputChange(formatted);
setOpen(false);
};

return (
<div
className={cn(
'w-32',
filterInputVariants({variant: context.variant, size: context.size, cursorPointer: false}),
className
)}
data-slot="filters-input-wrapper"
>
{field?.prefix && (
<div
className={filterFieldAddonVariants({variant: context.variant, size: context.size})}
data-slot="filters-prefix"
>
{field.prefix}
</div>
)}
<div className="flex w-full min-w-0 items-stretch">
<input
ref={inputRef}
autoComplete="off"
className="w-full min-w-0 bg-transparent outline-hidden dark:!bg-transparent"
data-slot="filters-input"
inputMode="numeric"
pattern="\d{4}-\d{2}-\d{2}"
placeholder="YYYY-MM-DD"
type="text"
value={localValue}
onBlur={handleInputBlur}
onChange={handleInputChange}
/>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
aria-label="Open calendar"
className={cn(
filterFieldAddonVariants({variant: context.variant, size: context.size}),
'cursor-pointer text-muted-foreground transition-colors hover:text-foreground'
)}
data-slot="filters-suffix"
type="button"
>
<CalendarIcon className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="center" className="w-auto overflow-hidden p-0" sideOffset={4}>
<Calendar
captionLayout="dropdown"
mode="single"
month={month}
selected={parsed}
onMonthChange={setMonth}
onSelect={handleSelect}
/>
</PopoverContent>
</Popover>
</div>
);
}

interface FilterRemoveButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof filterRemoveButtonVariants> {
Expand Down Expand Up @@ -1733,27 +1942,23 @@ function FilterValueSelector<T = unknown>({field, values, onChange, operator}: F
cursorPointer: context.cursorPointer
})}
>
<FilterInput
className={cn('w-24 max-w-full', field.className)}
<FilterDatePicker
className={cn('max-w-full', field.className)}
field={field}
type="date"
value={startDate}
onChange={e => onChange([e.target.value, endDate] as T[])}
onInputChange={field.onInputChange}
onChange={v => onChange([v, endDate] as T[])}
/>
<div
className={filterFieldBetweenVariants({variant: context.variant, size: context.size})}
data-slot="filters-between"
>
{context.i18n.to}
</div>
<FilterInput
className={cn('w-24 max-w-full', field.className)}
<FilterDatePicker
className={cn('max-w-full', field.className)}
field={field}
type="date"
value={endDate}
onChange={e => onChange([startDate, e.target.value] as T[])}
onInputChange={field.onInputChange}
onChange={v => onChange([startDate, v] as T[])}
/>
</div>
);
Expand Down Expand Up @@ -1824,13 +2029,11 @@ function FilterValueSelector<T = unknown>({field, values, onChange, operator}: F

if (field.type === 'date') {
return (
<FilterInput
className={cn('w-16', field.className)}
<FilterDatePicker
className={field.className}
field={field}
type="date"
value={(values[0] as string) || ''}
onChange={e => onChange([e.target.value] as T[])}
onInputChange={field.onInputChange}
onChange={v => onChange([v] as T[])}
/>
);
}
Expand Down
Loading
Loading