feat(events): add event creation form components

This commit is contained in:
micha 2025-06-25 13:10:27 +02:00
parent 1a332a9373
commit 51fe42374d
24 changed files with 1868 additions and 61 deletions

View file

@ -21,8 +21,11 @@ const buttonVariants = cva(
'bg-background border-2 text-text shadow-xs hover:bg-secondary border-secondary hover:border-background-reversed active:bg-active-secondary disabled:bg-disabled-secondary',
outline_muted:
'bg-background border-2 text-text shadow-xs hover:bg-muted border-muted hover:border-background-reversed active:bg-active-muted disabled:bg-disabled-muted',
link: 'text-text underline-offset-4 hover:underline',
calendar:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 w-32 justify-between font-normal',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',

View file

@ -0,0 +1,213 @@
'use client';
import * as React from 'react';
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react';
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months,
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
),
dropdown: cn(
'bg-[var(--color-basecl)] absolute inset-0 opacity-0',
defaultClassNames.dropdown,
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day,
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot='calendar'
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
);
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className='flex size-(--cell-size) items-center justify-center text-center'>
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant='ghost'
size='icon'
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View file

@ -22,7 +22,7 @@ function Card({ className, ...props }: React.ComponentProps<'div'>) {
/* Outline */
'',
/* Shadow */
'shadow-sm',
'shadow-[4px_4px_9px_9px_rgba(0,0,0,0.25)]',
/* Opacity */
'',
/* Scaling */

View file

@ -0,0 +1,184 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot='command'
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className='[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot='command-input-wrapper'
className='flex h-9 items-center gap-2 border-b px-3'
>
<SearchIcon className='size-4 shrink-0 opacity-50' />
<CommandPrimitive.Input
data-slot='command-input'
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot='command-list'
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot='command-empty'
className='py-6 text-center text-sm'
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot='command-group'
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot='command-separator'
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot='command-item'
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='command-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View file

@ -0,0 +1,143 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot='dialog' {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot='dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot='dialog-portal'>
<DialogOverlay />
<DialogPrimitive.Content
data-slot='dialog-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot='dialog-close'
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot='dialog-title'
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot='dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View file

@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
data-slot='input'
className={cn(
/* Text */
'text-text-input selection:text-text md:text-sm file:text-destructive file:text-sm placeholder:text-text-muted-input',
'text-text-input selection:text-text file:text-destructive file:text-sm placeholder:text-text-muted-input',
/* Background */
'bg-transparent selection:bg-muted-input file:bg-transparent',
/* Border */
@ -41,4 +41,45 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
);
}
export { Input };
function Textarea({
className,
rows,
...props
}: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot='input'
rows={rows}
className={cn(
/* Text */
'text-text-input selection:text-text placeholder:text-text-muted-input',
/* Background */
'bg-transparent selection:bg-muted-input',
/* Border */
'rounded-md border border-input focus-visible:border-ring aria-invalid:border-destructive',
/* Font */
'',
/* Cursor */
'disabled:pointer-events-none disabled:cursor-not-allowed',
/* Ring */
'focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
/* Outline */
'outline-none',
/* Shadow */
'shadow-md transition-[color,box-shadow]',
/* Opacity */
'disabled:opacity-50',
/* Scaling */
'h-32 w-full min-w-0', // Bigger height for textarea
/* Spacing */
'px-3 py-2',
/* Alignment */
'',
className,
)}
{...props}
/>
);
}
export { Input, Textarea };

View file

@ -5,16 +5,21 @@ import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
type LabelProps = React.ComponentProps<typeof LabelPrimitive.Root> & {
size?: 'default' | 'small' | 'large';
};
function Label({ className, size = 'default', ...props }: LabelProps) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
/* Text */
'text-sm',
size === 'small'
? 'text-sm'
: size === 'large'
? 'text-xl'
: 'text-base',
/* Background */
'',
/* Border */

View file

@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View file

@ -0,0 +1,54 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, ToasterProps } from 'sonner';
import React from 'react';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
const [shouldExpand, setShouldExpand] = React.useState(false);
React.useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 600px)');
const handleScreenSizeChange = () => {
setShouldExpand(mediaQuery.matches);
};
handleScreenSizeChange(); // set initial value
mediaQuery.addEventListener('change', handleScreenSizeChange);
return () =>
mediaQuery.removeEventListener('change', handleScreenSizeChange);
}, []);
return (
<Sonner
theme={theme as ToasterProps['theme']}
richColors={true}
className='toaster group'
toastOptions={{
style: {
backgroundColor: 'var(--color-neutral-150)',
color: 'var(--color-text-alt)',
borderRadius: 'var(--radius)',
},
cancelButtonStyle: {
backgroundColor: 'var(--color-secondary)',
color: 'var(--color-text-alt)',
},
actionButtonStyle: {
backgroundColor: 'var(--color-secondary)',
color: 'var(--color-text-alt)',
},
}}
swipeDirections={['left', 'right']}
closeButton={true}
expand={shouldExpand}
{...props}
/>
);
};
export { Toaster };