Created
July 26, 2023 22:15
-
-
Save mattstobbs/1d7d2396a8c1a7ee6811d0be6e6e49dd to your computer and use it in GitHub Desktop.
A simple month picker that matches the style of shadcn/ui
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
add, | |
eachMonthOfInterval, | |
endOfYear, | |
format, | |
isEqual, | |
isFuture, | |
parse, | |
startOfMonth, | |
startOfToday, | |
} from 'date-fns'; | |
import { ChevronLeft, ChevronRight } from 'lucide-react'; | |
import * as React from 'react'; | |
import { buttonVariants } from '~/components/ui/button'; | |
import { cn } from '~/utils/shadcn'; | |
function getStartOfCurrentMonth() { | |
return startOfMonth(startOfToday()); | |
} | |
interface MonthPickerProps { | |
currentMonth: Date; | |
onMonthChange: (newMonth: Date) => void; | |
} | |
export default function MonthPicker({ | |
currentMonth, | |
onMonthChange, | |
}: MonthPickerProps) { | |
const [currentYear, setCurrentYear] = React.useState( | |
format(currentMonth, 'yyyy') | |
); | |
const firstDayCurrentYear = parse(currentYear, 'yyyy', new Date()); | |
const months = eachMonthOfInterval({ | |
start: firstDayCurrentYear, | |
end: endOfYear(firstDayCurrentYear), | |
}); | |
function previousYear() { | |
let firstDayNextYear = add(firstDayCurrentYear, { years: -1 }); | |
setCurrentYear(format(firstDayNextYear, 'yyyy')); | |
} | |
function nextYear() { | |
let firstDayNextYear = add(firstDayCurrentYear, { years: 1 }); | |
setCurrentYear(format(firstDayNextYear, 'yyyy')); | |
} | |
return ( | |
<div className="p-3"> | |
<div className="flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0"> | |
<div className="space-y-4"> | |
<div className="relative flex items-center justify-center pt-1"> | |
<div | |
className="text-sm font-medium" | |
aria-live="polite" | |
role="presentation" | |
id="month-picker" | |
> | |
{format(firstDayCurrentYear, 'yyyy')} | |
</div> | |
<div className="flex items-center space-x-1"> | |
<button | |
name="previous-year" | |
aria-label="Go to previous year" | |
className={cn( | |
buttonVariants({ variant: 'outline' }), | |
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100', | |
'absolute left-1' | |
)} | |
type="button" | |
onClick={previousYear} | |
> | |
<ChevronLeft className="h-4 w-4" /> | |
</button> | |
<button | |
name="next-year" | |
aria-label="Go to next year" | |
className={cn( | |
buttonVariants({ variant: 'outline' }), | |
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100', | |
'absolute right-1 disabled:bg-slate-100' | |
)} | |
type="button" | |
disabled={isFuture(add(firstDayCurrentYear, { years: 1 }))} | |
onClick={nextYear} | |
> | |
<ChevronRight className="h-4 w-4" /> | |
</button> | |
</div> | |
</div> | |
<div | |
className="grid w-full grid-cols-3 gap-2" | |
role="grid" | |
aria-labelledby="month-picker" | |
> | |
{months.map((month) => ( | |
<div | |
key={month.toString()} | |
className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-slate-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md dark:[&:has([aria-selected])]:bg-slate-800" | |
role="presentation" | |
> | |
<button | |
name="day" | |
className={cn( | |
'inline-flex h-9 w-16 items-center justify-center rounded-md p-0 text-sm font-normal ring-offset-white transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-selected:opacity-100 dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:focus-visible:ring-slate-800', | |
isEqual(month, currentMonth) && | |
'bg-slate-900 text-slate-50 hover:bg-slate-900 hover:text-slate-50 focus:bg-slate-900 focus:text-slate-50 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50 dark:hover:text-slate-900 dark:focus:bg-slate-50 dark:focus:text-slate-900', | |
!isEqual(month, currentMonth) && | |
isEqual(month, getStartOfCurrentMonth()) && | |
'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-50' | |
)} | |
disabled={isFuture(month)} | |
role="gridcell" | |
tabIndex={-1} | |
type="button" | |
onClick={() => onMonthChange(month)} | |
> | |
<time dateTime={format(month, 'yyyy-MM-dd')}> | |
{format(month, 'MMM')} | |
</time> | |
</button> | |
</div> | |
))} | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
} |
import {
add,
eachMonthOfInterval,
endOfYear,
format,
isEqual,
isFuture,
parse,
startOfMonth,
startOfToday,
} from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import * as React from 'react';
import { cn } from '../../lib/utils';
import { buttonVariants } from '../button';
function getStartOfCurrentMonth() {
return startOfMonth(startOfToday());
}
interface MonthPickerProps {
currentMonth: Date | null;
onMonthChange: (newMonth: Date) => void;
}
export function MonthPicker({ currentMonth, onMonthChange }: MonthPickerProps) {
const [currentYear, setCurrentYear] = React.useState(
currentMonth ? format(currentMonth, 'yyyy') : format(new Date(), 'yyyy'),
);
const firstDayCurrentYear = parse(currentYear, 'yyyy', new Date());
const months = eachMonthOfInterval({
start: firstDayCurrentYear,
end: endOfYear(firstDayCurrentYear),
});
function previousYear() {
let firstDayNextYear = add(firstDayCurrentYear, { years: -1 });
setCurrentYear(format(firstDayNextYear, 'yyyy'));
}
function nextYear() {
let firstDayNextYear = add(firstDayCurrentYear, { years: 1 });
setCurrentYear(format(firstDayNextYear, 'yyyy'));
}
return (
<div className="p-3">
<div className="flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
<div className="space-y-4">
<div className="relative flex items-center justify-center pt-1">
<div
className="text-sm font-medium"
aria-live="polite"
role="presentation"
id="month-picker"
>
{format(firstDayCurrentYear, 'yyyy')}
</div>
<div className="flex items-center space-x-1">
<button
name="previous-year"
aria-label="Go to previous year"
className={cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
'absolute left-1',
)}
type="button"
onClick={previousYear}
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
name="next-year"
aria-label="Go to next year"
className={cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
'absolute right-1 disabled:bg-slate-100',
)}
type="button"
disabled={isFuture(add(firstDayCurrentYear, { years: 1 }))}
onClick={nextYear}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
<div
className="grid w-full grid-cols-3 gap-2"
role="grid"
aria-labelledby="month-picker"
>
{months.map((month) => (
<div
key={month.toString()}
className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-slate-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md dark:[&:has([aria-selected])]:bg-slate-800"
role="presentation"
>
<button
name="day"
className={cn(
'inline-flex h-9 w-16 items-center justify-center rounded-md p-0 text-sm font-normal ring-offset-white transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-selected:opacity-100 dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:focus-visible:ring-slate-800',
isEqual(month, currentMonth ?? new Date()) &&
'bg-slate-900 text-slate-50 hover:bg-slate-900 hover:text-slate-50 focus:bg-slate-900 focus:text-slate-50 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50 dark:hover:text-slate-900 dark:focus:bg-slate-50 dark:focus:text-slate-900',
!isEqual(month, currentMonth ?? new Date()) &&
isEqual(month, getStartOfCurrentMonth()) &&
'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-50',
)}
disabled={isFuture(month)}
role="gridcell"
tabIndex={-1}
type="button"
onClick={() => onMonthChange(month)}
>
<time dateTime={format(month, 'yyyy-MM-dd')}>
{format(month, 'MMM')}
</time>
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
test-component.tsx
const [month, setMonth] = React.useState<Date | null>(null);
<MonthPicker
currentMonth={month}
onMonthChange={(value) => setMonth(value)}
/>
tysm for this!
thanks!
This is excellent.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thank you so much