From b7becbc30d6838ed4f4860f59ed2e1f761e4bb3d Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 19 Oct 2025 10:46:43 +0200 Subject: [PATCH 1/8] Add confirmation modal before deletion --- ui/src/pages/settings.tsx | 52 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 52ace62..4f2c404 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -4,12 +4,20 @@ import { Input } from "@ui/components/ui/input"; import { Label } from "@ui/components/ui/label"; import { Textarea } from "@ui/components/ui/textarea"; import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/ui/avatar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@ui/components/ui/dialog"; import { useUser } from "@ui/providers/UserStoreProvider"; import { useState, useRef } from "react"; import { TypographyH3, TypographyMuted, TypographySmall } from "@ui/components/ui/typography"; import { useIntroduction } from "src/hooks/intros"; import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "@ui/hooks/profile"; -import { CameraIcon, Trash2Icon, UploadIcon } from "lucide-react"; +import { CameraIcon, Loader2Icon, Trash2Icon, UploadIcon } from "lucide-react"; import { toast } from "@ui/lib/toast"; import { ImageCropDialog } from "@ui/components/ImageCropDialog"; @@ -23,7 +31,7 @@ export default function SettingsPage() { } = useIntroduction(); const { mutate: updateProfile, isPending: updateProfilePending } = useUpdateProfile(); const { mutate: uploadAvatar } = useUploadAvatar(); - const { mutateAsync: removeAvatar } = useRemoveAvatar(); + const { mutateAsync: removeAvatar, isPending: removeAvatarPending } = useRemoveAvatar(); const [firstName, setFirstName] = useState(user?.first_name || ""); const [lastName, setLastName] = useState(user?.last_name || ""); @@ -31,6 +39,7 @@ export default function SettingsPage() { const [selectedFile, setSelectedFile] = useState(null); const [imageToCrop, setImageToCrop] = useState(null); const [isCropDialogOpen, setIsCropDialogOpen] = useState(false); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); const fileInputRef = useRef(null); const handleAvatarChange = (e: React.ChangeEvent) => { @@ -68,13 +77,18 @@ export default function SettingsPage() { } }; - const handleRemoveAvatar = async () => { + const handleRemoveAvatarClick = () => { + setIsDeleteConfirmOpen(true); + }; + + const handleConfirmRemoveAvatar = async () => { await removeAvatar(); setAvatarPreview(null); setSelectedFile(null); if (fileInputRef.current) { fileInputRef.current.value = ""; } + setIsDeleteConfirmOpen(false); }; const handleUploadAvatar = () => { @@ -187,7 +201,7 @@ export default function SettingsPage() { + + + + ); } From 59e196fcb24ff0015cf77534f83b8af5bcc8dbfa Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 19 Oct 2025 11:34:51 +0200 Subject: [PATCH 2/8] Improve flow for event types --- ui/src/components/NavigationBar.tsx | 41 ++------- ui/src/components/TabloModal.tsx | 2 +- ui/src/components/ui/checkbox.tsx | 48 ++++++----- ui/src/components/ui/clipboard.tsx | 2 +- ui/src/components/ui/hooks/use-clipboard.ts | 34 ++++++++ ui/src/hooks/availabilities.ts | 58 ++++++++----- ui/src/pages/availabilities.tsx | 95 +++++++++++---------- ui/src/ui-library/hooks/use-clipboard.ts | 32 ------- 8 files changed, 157 insertions(+), 155 deletions(-) create mode 100644 ui/src/components/ui/hooks/use-clipboard.ts diff --git a/ui/src/components/NavigationBar.tsx b/ui/src/components/NavigationBar.tsx index 8dc876a..3f93dac 100644 --- a/ui/src/components/NavigationBar.tsx +++ b/ui/src/components/NavigationBar.tsx @@ -9,15 +9,11 @@ import { DropdownMenuTrigger, } from "@ui/components/ui/dropdown-menu"; import { useUser } from "@ui/providers/UserStoreProvider"; -// react-aria components (still used) -import { Disclosure, DisclosureControl, DisclosurePanel } from "@ui/ui-library/disclosure"; -import { Link } from "@ui/ui-library/link"; import { isProd, isStaging } from "@ui/utils/helpers"; import { getXtabloIcon } from "@ui/utils/iconHelpers"; import { CalendarCheckIcon, CalendarIcon, - ChevronRightIcon, Circle, ConstructionIcon, Kanban, @@ -32,7 +28,7 @@ import { SquareKanban, } from "lucide-react"; import { useState } from "react"; -import { LinkProps, Separator } from "react-aria-components"; +import { Separator } from "react-aria-components"; import { Link as RouterLink, useLocation } from "react-router-dom"; import { twMerge } from "tailwind-merge"; import { ThemeSwitcher } from "./ThemeSwitcher"; @@ -43,35 +39,14 @@ import { useLogout } from "src/hooks/auth"; type NavLinkItem = { isActive?: boolean; -} & LinkProps; + children: React.ReactNode; +}; -type NavLinkProps = NavLinkItem | { title: string; items: NavLinkItem[] }; +type NavLinkProps = NavLinkItem; -function NavLink(props: NavLinkProps) { - if ("items" in props) { - return ( - - - {props.title}{" "} - - - -
    - {props.items.map((item) => ( -
  • - -
  • - ))} -
-
-
- ); - } - - const { isActive, ...rest } = props; +function NavLink({ isActive, children }: NavLinkProps) { return ( - [data-ui=icon]:not([class*=size-])]:size-4.5", "[&>[data-ui=notification-badge]]:bg-navbar-darker", @@ -88,8 +63,8 @@ function NavLink(props: NavLinkProps) { : ["font-medium", "text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"] )} > - {props.children} - + {children} + ); } diff --git a/ui/src/components/TabloModal.tsx b/ui/src/components/TabloModal.tsx index 04f6f37..55ebb0a 100644 --- a/ui/src/components/TabloModal.tsx +++ b/ui/src/components/TabloModal.tsx @@ -10,7 +10,7 @@ import { useTabloMembers } from "@ui/hooks/tablos"; import { toast } from "@ui/lib/toast"; import { useUser } from "@ui/providers/UserStoreProvider"; import { TabloUpdate, UserTablo } from "@ui/types/tablos.types"; -import { FileTrigger } from "@ui/ui-library/file-trigger"; +import { FileTrigger } from "react-aria-components"; import { DownloadIcon, Trash2Icon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ClickOutside } from "./ClickOutside"; diff --git a/ui/src/components/ui/checkbox.tsx b/ui/src/components/ui/checkbox.tsx index 1044025..651800b 100644 --- a/ui/src/components/ui/checkbox.tsx +++ b/ui/src/components/ui/checkbox.tsx @@ -1,25 +1,29 @@ -import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; -import { cn } from "@ui/lib/utils"; -import { Check } from "lucide-react"; -import * as React from "react"; +"use client"; -const Checkbox = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - - -)); -Checkbox.displayName = CheckboxPrimitive.Root.displayName; +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; + +import { cn } from "@ui/lib/utils"; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} export { Checkbox }; diff --git a/ui/src/components/ui/clipboard.tsx b/ui/src/components/ui/clipboard.tsx index a3663ed..f6c80d2 100644 --- a/ui/src/components/ui/clipboard.tsx +++ b/ui/src/components/ui/clipboard.tsx @@ -1,5 +1,5 @@ import { cn } from "@ui/lib/utils"; -import { useCopyToClipboard } from "@ui/ui-library/hooks/use-clipboard"; +import { useCopyToClipboard } from "@ui/components/ui/hooks/use-clipboard"; import { Check, Copy } from "lucide-react"; import React from "react"; import { Button, ButtonProps } from "./button"; diff --git a/ui/src/components/ui/hooks/use-clipboard.ts b/ui/src/components/ui/hooks/use-clipboard.ts new file mode 100644 index 0000000..ab0136c --- /dev/null +++ b/ui/src/components/ui/hooks/use-clipboard.ts @@ -0,0 +1,34 @@ +import React from "react"; + +export function useCopyToClipboard({ timeout = 2000 } = {}) { + const [error, setError] = React.useState(null); + const [copied, setCopied] = React.useState(false); + const [copyTimeout, setCopyTimeout] = React.useState(null); + + const handleCopyResult = (value: boolean) => { + window.clearTimeout(copyTimeout!); + setCopyTimeout(window.setTimeout(() => setCopied(false), timeout)); + setCopied(value); + }; + + const copy = (valueToCopy: string) => { + if ("clipboard" in navigator) { + navigator.clipboard + .writeText(valueToCopy) + .then(() => handleCopyResult(true)) + .catch((err) => setError(err)); + } else { + setError( + new Error("useCopyToClipboard: navigator.clipboard is not supported") + ); + } + }; + + const reset = () => { + setCopied(false); + setError(null); + window.clearTimeout(copyTimeout!); + }; + + return { copy, reset, error, copied }; +} diff --git a/ui/src/hooks/availabilities.ts b/ui/src/hooks/availabilities.ts index 73c8359..fef1bac 100644 --- a/ui/src/hooks/availabilities.ts +++ b/ui/src/hooks/availabilities.ts @@ -33,20 +33,23 @@ export type Exception = { const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; -export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce((acc, day) => { - if (day === 5 || day === 6) { - acc[day] = { - enabled: false, - timeRanges: [{ start: "09:00", end: "17:00" }], - }; - } else { - acc[day] = { - enabled: true, - timeRanges: [{ start: "09:00", end: "17:00" }], - }; - } - return acc; -}, {} as WeeklyAvailability); +export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce( + (acc, day) => { + if (day === 5 || day === 6) { + acc[day] = { + enabled: false, + timeRanges: [{ start: "09:00", end: "17:00" }], + }; + } else { + acc[day] = { + enabled: true, + timeRanges: [{ start: "09:00", end: "17:00" }], + }; + } + return acc; + }, + {} as WeeklyAvailability +); export function useAvailabilities() { const { session } = useSession(); @@ -83,7 +86,8 @@ export function useAvailabilities() { newException?: Exception | null; }) => { const newAvailabilities = updatedAvailabilities; - const newExceptions = (availabilities?.exceptions as Exception[] | null) || []; + const newExceptions = + (availabilities?.exceptions as Exception[] | null) || []; if (newException) { newExceptions.push(newException); } @@ -104,14 +108,22 @@ export function useAvailabilities() { }, }); - const { mutate: deleteException } = useMutation({ + const { mutate: deleteException } = useMutation< + void, + Error, + { exceptionIndex: number } + >({ mutationFn: async ({ exceptionIndex }: { exceptionIndex: number }) => { - const currentExceptions = (availabilities?.exceptions as Exception[] | null) || []; - const updatedExceptions = currentExceptions.filter((_, index) => index !== exceptionIndex); + const currentExceptions = + (availabilities?.exceptions as Exception[] | null) || []; + const updatedExceptions = currentExceptions.filter( + (_, index) => index !== exceptionIndex + ); const { error } = await supabase.from("availabilities").upsert( { - availability_data: availabilities?.availability_data || DEFAULT_AVAILABILITIES, + availability_data: + availabilities?.availability_data || DEFAULT_AVAILABILITIES, exceptions: updatedExceptions, user_id: session?.user.id, }, @@ -126,11 +138,14 @@ export function useAvailabilities() { }, }); - const [draftAvailabilities, setDraftAvailabilities] = useState(null); + const [draftAvailabilities, setDraftAvailabilities] = + useState(null); useEffect(() => { if (availabilities?.availability_data) { - setDraftAvailabilities(availabilities.availability_data as WeeklyAvailability); + setDraftAvailabilities( + availabilities.availability_data as WeeklyAvailability + ); } }, [availabilities?.availability_data]); @@ -141,5 +156,6 @@ export function useAvailabilities() { setDraftAvailabilities, exceptions: (availabilities?.exceptions as Exception[] | null) || [], deleteException, + isModified: draftAvailabilities !== availabilities?.availability_data, }; } diff --git a/ui/src/pages/availabilities.tsx b/ui/src/pages/availabilities.tsx index 624f789..b728a44 100644 --- a/ui/src/pages/availabilities.tsx +++ b/ui/src/pages/availabilities.tsx @@ -26,11 +26,12 @@ import { WeeklyAvailability, } from "@ui/hooks/availabilities"; import { toast } from "@ui/lib/toast"; -import { Checkbox } from "@ui/ui-library/checkbox"; +import { Checkbox } from "@ui/components/ui/checkbox"; import { Plus as PlusIcon, SaveIcon } from "lucide-react"; import { useState } from "react"; import { ExceptionModal } from "src/components/ExceptionModal"; import { CardContent } from "src/components/ui/card"; +import { Label } from "src/components/ui/label"; const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; const DAYS_OF_WEEK_DISPLAY = [ @@ -55,6 +56,7 @@ export function AvailabilitiesPage() { setDraftAvailabilities, exceptions, deleteException, + isModified, } = useAvailabilities(); const [copyModalOpen, setCopyModalOpen] = useState(false); @@ -118,38 +120,40 @@ export function AvailabilitiesPage() {
- + { + onSuccess: () => { + toast.add({ + title: "Succès", + description: "Disponibilités enregistrées avec succès", + type: "success", + }); + }, + onError: (err) => { + console.error(err); + toast.add({ + title: "Erreur", + description: "Erreur lors de l'enregistrement des disponibilités", + type: "error", + }); + }, + } + ); + }} + > + Enregistrer + + )} - - - - - ); -} - -export function CalendarGridHeader() { - return ( - - {(day) => ( - - {day} - - )} - - ); -} - -function YearDropdown({ state, yearRange }: { state: CalendarState; yearRange: YearRange }) { - const years: Array<{ - value: CalendarDate; - formatted: string; - }> = []; - const formatter = useDateFormatter({ - year: "numeric", - timeZone: state.timeZone, - }); - - const [yearsBefore, yearsAfter] = Array.isArray(yearRange) ? yearRange : [yearRange, yearRange]; - - if (yearsBefore <= 0 || yearsAfter <= 0) { - throw new Error( - "The yearRange prop must be a positive number or an array of two positive numbers." - ); - } - - for (let i = yearsBefore * -1; i <= yearsAfter; i++) { - const date = state.focusedDate.add({ years: i }); - years.push({ - value: date, - formatted: formatter.format(date.toDate(state.timeZone)), - }); - } - - const onChange = (e: React.ChangeEvent) => { - const index = Number(e.target.value); - const date = years[index].value; - state.setFocusedDate(date); - }; - - return ( - - - - {years.map((year, i) => ( - // use the index as the value so we can retrieve the full - // date object from the list in onChange. We cannot only - // store the year number, because in some calendars, such - // as the Japanese, the era may also change. - - ))} - - - ); -} - -function MonthDropdown({ state }: { state: CalendarState }) { - const months: Array = []; - const formatter = useDateFormatter({ - month: "long", - timeZone: state.timeZone, - }); - - // Format the name of each month in the year according to the - // current locale and calendar system. Note that in some calendar - // systems, such as the Hebrew, the number of months may differ - // between years. - const numMonths = state.focusedDate.calendar.getMonthsInYear(state.focusedDate); - for (let i = 1; i <= numMonths; i++) { - const date = state.focusedDate.set({ month: i }); - months.push(formatter.format(date.toDate(state.timeZone))); - } - - const onChange = (e: React.ChangeEvent) => { - const value = Number(e.target.value); - const date = state.focusedDate.set({ month: value }); - state.setFocusedDate(date); - }; - - return ( - - - - {months.map((month, i) => ( - - ))} - - - ); -} diff --git a/ui/src/ui-library/checkbox.tsx b/ui/src/ui-library/checkbox.tsx deleted file mode 100644 index 216294e..0000000 --- a/ui/src/ui-library/checkbox.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, { ReactNode } from "react"; -import { - CheckboxRenderProps, - composeRenderProps, - Checkbox as RACCheckbox, - CheckboxGroup as RACCheckboxGroup, - CheckboxGroupProps as RACCheckboxGroupProps, - CheckboxProps as RACCheckboxProps, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { DescriptionContext, DescriptionProvider } from "./field"; -import { CheckIcon, MinusIcon } from "./icons"; -import { groupBox } from "./utils"; - -export interface CheckboxGroupProps extends Omit { - children?: ReactNode; - orientation?: "vertical" | "horizontal"; -} - -export function CheckboxGroup({ orientation = "vertical", ...props }: CheckboxGroupProps) { - return ( - { - return twMerge(groupBox, className); - })} - /> - ); -} - -export function Checkboxes({ className, ...props }: React.JSX.IntrinsicElements["div"]) { - return ( -
- ); -} - -export function CheckboxField({ className, ...props }: React.JSX.IntrinsicElements["div"]) { - return ( - -
- - ); -} - -interface CheckboxProps extends RACCheckboxProps { - labelPlacement?: "start" | "end"; - render?: never; -} - -export interface CustomRenderCheckboxProps extends Omit { - render: React.ReactElement | ((props: CheckboxRenderProps) => React.ReactNode); - children?: never; -} - -export function Checkbox(props: CheckboxProps | CustomRenderCheckboxProps) { - const descriptionContext = React.useContext(DescriptionContext); - - if (props.render) { - const { render, ...restProps } = props; - - return ( - { - return twMerge([ - "group", - "text-base/6 sm:text-sm/6", - renderProps.isDisabled && "opacity-50", - renderProps.isFocusVisible && "flex outline-ring outline outline-2 outline-offset-2", - className, - ]); - })} - > - {render} - - ); - } - - const { labelPlacement = "end", ...restProps } = props; - - return ( - { - return twMerge( - "group flex items-center text-base/6 group-data-[orientation=horizontal]:text-nowrap sm:text-sm/6", - labelPlacement === "start" && "flex-row-reverse justify-between", - renderProps.isDisabled && "opacity-50", - className - ); - })} - > - {(renderProps) => { - return ( - <> -
- {renderProps.isIndeterminate ? ( - - ) : renderProps.isSelected ? ( - - ) : null} -
- - {typeof props.children === "function" ? props.children(renderProps) : props.children} - - ); - }} -
- ); -} diff --git a/ui/src/ui-library/combobox.tsx b/ui/src/ui-library/combobox.tsx deleted file mode 100644 index 79a4a1e..0000000 --- a/ui/src/ui-library/combobox.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from "react"; -import { - ComboBoxStateContext, - composeRenderProps, - Group, - GroupProps, - ComboBox as RACComboBox, - ComboBoxProps as RACComboBoxProps, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { Button, ButtonProps } from "./button"; -import { Input } from "./field"; -import { ChevronDownIcon, XIcon } from "./icons"; -import { - SelectListBox, - SelectListItemDescription, - SelectListItemLabel, - SelectPopover, - SelectSection, -} from "./select"; -import { inputField } from "./utils"; - -export function ComboBox(props: RACComboBoxProps) { - return ( - - twMerge(["w-full min-w-56", inputField, className]) - )} - /> - ); -} - -export function ComboBoxGroup(props: GroupProps) { - return ( - - twMerge([ - "group/combobox", - "isolate", - "grid", - "grid-cols-[36px_1fr_minmax(40px,max-content)_minmax(40px,max-content)]", - "sm:grid-cols-[36px_1fr_minmax(36px,max-content)_minmax(36px,max-content)]", - "items-center", - - // Icon - "sm:[&>[data-ui=icon]:has(+input)]:size-4", - "[&>[data-ui=icon]:has(+input)]:size-5", - "[&>[data-ui=icon]:has(+input)]:row-start-1", - "[&>[data-ui=icon]:has(+input)]:col-start-1", - "[&>[data-ui=icon]:has(+input)]:place-self-center", - "[&>[data-ui=icon]:has(+input)]:text-muted", - "[&>[data-ui=icon]:has(+input)]:z-10", - - // Input - "[&>input]:row-start-1", - "[&>input]:col-span-full", - "[&>input:not([class*=pe-])]:pe-10", - "sm:[&>input:not([class*=pe-])]:pe-9", - - "[&>input:has(+[data-ui=clear]:not(:last-of-type))]:pe-20", - "sm:[&>input:has(+[data-ui=clear]:not(:last-of-type))]:pe-16", - - "[&:has([data-ui=icon]+input)>input]:ps-10", - "sm:[&:has([data-ui=icon]+input)>input]:ps-8", - - // Trigger button - "*:data-[ui=trigger]:row-start-1", - "*:data-[ui=trigger]:-col-end-1", - "*:data-[ui=trigger]:place-self-center", - - // Clear button - "*:data-[ui=clear]:row-start-1", - "*:data-[ui=clear]:-col-end-2", - "*:data-[ui=clear]:justify-self-end", - "[&>[data-ui=clear]:last-of-type]:-col-end-1", - "[&>[data-ui=clear]:last-of-type]:place-self-center", - - className, - ]) - )} - /> - ); -} - -export const ComboBoxInput = Input; - -export function ComboBoxButton({ - triggerIcon = , -}: { - triggerIcon?: React.ReactNode; -}) { - return ( - - ); -} - -export function ComboBoxClearButton({ onPress }: { onPress?: ButtonProps["onPress"] }) { - const state = React.useContext(ComboBoxStateContext); - - return ( - - ); -} - -export const ComboBoxPopover = SelectPopover; - -export const ComboBoxSection = SelectSection; - -export const ComboBoxListBox = SelectListBox; - -export const ComboBoxListItemLabel = SelectListItemLabel; - -export const ComboBoxListItemDescription = SelectListItemDescription; diff --git a/ui/src/ui-library/date-field.tsx b/ui/src/ui-library/date-field.tsx deleted file mode 100644 index 9855869..0000000 --- a/ui/src/ui-library/date-field.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { - composeRenderProps, - DateSegment, - DateValue, - DateField as RACDateField, - DateFieldProps as RACDateFieldProps, - DateInput as RACDateInput, - DateInputProps as RACDateInputProps, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { inputField } from "./utils"; - -export interface DateFieldProps extends RACDateFieldProps {} - -export function DateField(props: DateFieldProps) { - return ( - { - return twMerge( - inputField, - // RAC does not set disable to date field when it is disable - // So we have to style disable state for none input - isDisabled && "[&>:not(input)]:opacity-50", - className - ); - })} - /> - ); -} - -export type DateInputProps = Omit; - -export function DateInput(props: DateInputProps) { - return ( - - twMerge( - "group flex min-w-[150px] items-center", - "w-full rounded-md text-base/6 shadow-sm outline-none sm:text-sm/6 dark:shadow-none", - "px-2.5 py-2.5 sm:py-1.5", - "ring ring-zinc-950/10 dark:ring-white/10", - !isFocusWithin && - !isDisabled && - !isInvalid && - isHovered && [ - "[&:not(:has([data-ui=date-segment][aria-readonly]))]:ring-zinc-950/20", - "dark:[&:not(:has([data-ui=date-segment][aria-readonly]))]:ring-white/20", - ], - "[&:has([data-disabled=true])]:opacity-50", - "[&:has([data-ui=date-segment][aria-readonly])]:bg-zinc-50", - "dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-white/5", - isInvalid && "ring-red-600 dark:ring-red-600", - isFocusWithin ? "ring-ring dark:ring-ring ring-2" : "", - className - ) - )} - > - {(segment) => ( - - )} - - ); -} diff --git a/ui/src/ui-library/date-picker.tsx b/ui/src/ui-library/date-picker.tsx deleted file mode 100644 index 0d07e7a..0000000 --- a/ui/src/ui-library/date-picker.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from "react"; -import { - composeRenderProps, - DatePickerStateContext, - DateValue, - Group, - DatePicker as RACDatePicker, - DatePickerProps as RACDatePickerProps, - useLocale, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { Button } from "./button"; -import { Calendar, YearRange } from "./calendar"; -import { DateInput, DateInputProps } from "./date-field"; -import { Dialog } from "./dialog"; -import { CalendarIcon } from "./icons/outline/calendar"; -import { Popover } from "./popover"; -import { inputField } from "./utils"; - -export interface DatePickerProps extends RACDatePickerProps {} - -export function DatePicker(props: DatePickerProps) { - return ( - { - return twMerge(inputField, className); - })} - /> - ); -} - -export function DatePickerInput({ - yearRange, - ...props -}: DateInputProps & { yearRange?: YearRange }) { - return ( - <> - - - twMerge("col-span-full", "row-start-1", "sm:pe-8", "pe-9", className) - )} - /> - - - - - - - - - - ); -} - -export function DatePickerButton({ - className, - children, -}: { - className?: string; - children?: React.ReactNode; -}) { - const { locale } = useLocale(); - const state = React.useContext(DatePickerStateContext); - const formattedDate = state?.formatValue(locale, {}); - - return ( - <> - - - - - - - - - - - - - ); -} diff --git a/ui/src/ui-library/date-range-picker.tsx b/ui/src/ui-library/date-range-picker.tsx deleted file mode 100644 index b541d94..0000000 --- a/ui/src/ui-library/date-range-picker.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from "react"; -import { - DateRangePicker as AriaDateRangePicker, - DateRangePickerProps as AriaDateRangePickerProps, - DateRangePickerStateContext, - DateValue, - Group, - useLocale, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { Button } from "./button"; -import { DateInput } from "./date-field"; -import { Dialog } from "./dialog"; -import { CalendarIcon } from "./icons"; -import { Popover } from "./popover"; -import { RangeCalendar } from "./range-calendar"; -import { composeTailwindRenderProps, inputField } from "./utils"; - -export interface DateRangePickerProps extends AriaDateRangePickerProps {} - -export function DateRangePicker({ ...props }: DateRangePickerProps) { - return ( - - ); -} - -export function DateRangePickerInput() { - const { locale } = useLocale(); - const state = React.useContext(DateRangePickerStateContext); - const formattedValue = state?.formatValue(locale, {}); - - return ( - <> - - twMerge( - "[&:has([aria-valuetext=Empty]:) w-full", - "grid grid-cols-[max-content_16px_max-content_1fr] items-center", - "group border-input relative rounded-md border", - "group-data-invalid:border-destructive", - "[&:has(_input[data-disabled=true])]:border-border/50", - "[&:has([data-ui=date-segment][aria-readonly])]:bg-zinc-50", - "dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-white/10", - formattedValue ? "min-w-60" : "min-w-[278px]", - isFocusWithin && "border-ring ring-ring group-data-invalid:border-ring ring-1" - ) - } - > - - - - - - - - - - - - ); -} - -export function DateRangePickerButton({ - className, - children, -}: { - className?: string; - children?: React.ReactNode; -}) { - const { locale } = useLocale(); - const state = React.useContext(DateRangePickerStateContext); - const formattedValue = state?.formatValue(locale, {}); - - return ( - <> - - - - - - - - - - - - - ); -} diff --git a/ui/src/ui-library/dialog.tsx b/ui/src/ui-library/dialog.tsx deleted file mode 100644 index cf6fbdc..0000000 --- a/ui/src/ui-library/dialog.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import React from "react"; -import { - composeRenderProps, - Dialog as RACDialog, - DialogProps as RACDialogProps, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { Button, ButtonProps } from "./button"; -import { BaseHeadingProps, Heading } from "./heading"; -import { XIcon } from "./icons"; -import { Text } from "./text"; - -export { DialogTrigger } from "react-aria-components"; - -export interface DialogProps extends RACDialogProps { - alert?: boolean; -} - -export function Dialog({ role, alert = false, ...props }: DialogProps) { - return ( - [data-ui=dialog-body]:not([class*=pt-])]:pt-6", - "[&:not(:has([data-ui=dialog-footer]))>[data-ui=dialog-body]:not([class*=pt-])]:pb-6", - props.className - )} - /> - ); -} - -type DialogHeaderProps = BaseHeadingProps; - -export const DialogTitle = React.forwardRef( - function DialogTitle({ level = 2, ...props }, ref) { - return ; - } -); - -export function DialogHeader({ className, ...props }: DialogHeaderProps) { - const headerRef = React.useRef(null); - - React.useEffect(() => { - const header = headerRef.current; - if (!header) { - return; - } - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - header.parentElement?.style.setProperty( - "--dialog-header-height", - `${entry.target.clientHeight}px` - ); - } - }); - - observer.observe(header); - - return () => { - observer.unobserve(header); - }; - }, []); - - return React.Children.toArray(props.children).every((child) => typeof child === "string") ? ( - - ) : ( -
- {props.children} -
- ); -} - -export function DialogBody({ className, children, ...props }: React.JSX.IntrinsicElements["div"]) { - return ( -
- {React.Children.toArray(children).every((child) => typeof child === "string") ? ( - {children} - ) : ( - children - )} -
- ); -} - -export function DialogFooter({ className, ...props }: React.JSX.IntrinsicElements["div"]) { - const footerRef = React.useRef(null); - - React.useEffect(() => { - const footer = footerRef.current; - - if (!footer) { - return; - } - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - footer.parentElement?.style.setProperty( - "--dialog-footer-height", - `${entry.target.clientHeight}px` - ); - } - }); - - observer.observe(footer); - return () => { - observer.unobserve(footer); - }; - }, []); - - return ( -
- ); -} - -export function DialogCloseButton({ variant = "plain", ...props }: ButtonProps) { - if (props.children) { - return - ); -} diff --git a/ui/src/ui-library/disclosure.tsx b/ui/src/ui-library/disclosure.tsx deleted file mode 100644 index 4973ea1..0000000 --- a/ui/src/ui-library/disclosure.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react"; -import { - Button, - ButtonProps, - composeRenderProps, - DisclosureGroupProps, - DisclosurePanelProps, - DisclosureGroup as RACDisclosureGroup, - DisclosurePanel as RACDisclosurePanel, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { Text } from "./text"; - -export { Disclosure } from "react-aria-components"; - -export function DisclosureGroup(props: DisclosureGroupProps) { - return ( - { - return twMerge([ - "flex flex-col [&>div:has(>button[aria-expanded]):not([class*=pb-]):not(:last-child)]:pb-4 [&>div:has(>button[aria-expanded]):not([class*=pt-]):not(:first-of-type)]:pt-4", - className, - ]); - })} - /> - ); -} - -export function DisclosurePanel({ children, ...props }: DisclosurePanelProps) { - return ( - - {React.Children.toArray(children).every((child) => typeof child === "string") ? ( - {children} - ) : ( - children - )} - - ); -} - -export function DisclosureControl(props: ButtonProps) { - return ( - } - {renderProps.selectionMode === "multiple" && - renderProps.selectionBehavior === "toggle" && } - {children} - - ) - } - - ); -} diff --git a/ui/src/ui-library/heading.tsx b/ui/src/ui-library/heading.tsx deleted file mode 100644 index 80348bb..0000000 --- a/ui/src/ui-library/heading.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from "react"; -import { Heading as RACHeading, HeadingProps as RACHeadingProps } from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { DisplayLevel, displayLevels } from "./utils"; - -export type BaseHeadingProps = { - level?: DisplayLevel; - elementType?: never; -} & RACHeadingProps; - -type CustomElement = { - level?: never; - elementType: "div"; -} & React.JSX.IntrinsicElements["div"]; - -export type HeadingProps = { - displayLevel?: DisplayLevel; -} & (BaseHeadingProps | CustomElement); - -export const Heading = React.forwardRef( - function Heading({ elementType, ...props }, ref) { - if (elementType) { - const { displayLevel = 1, className, ...restProps } = props; - return ( -
- ); - } - - const { level = 1, displayLevel, className, ...restProps } = props; - - return ( - - ); - } -); - -export const SubHeading = React.forwardRef( - function SubHeading({ className, ...props }, ref) { - return ( -
- ); - } -); diff --git a/ui/src/ui-library/hooks/use-clipboard.ts b/ui/src/ui-library/hooks/use-clipboard.ts deleted file mode 100644 index f13e488..0000000 --- a/ui/src/ui-library/hooks/use-clipboard.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** - * From https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/hooks/src/use-clipboard/use-clipboard.ts - */ diff --git a/ui/src/ui-library/hooks/use-image-loading-status.ts b/ui/src/ui-library/hooks/use-image-loading-status.ts deleted file mode 100644 index 958bbbe..0000000 --- a/ui/src/ui-library/hooks/use-image-loading-status.ts +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; - -type ImageLoadingStatus = "idle" | "loading" | "loaded" | "error"; - -export function useImageLoadingStatus(src?: string) { - const [loadingStatus, setLoadingStatus] = React.useState("idle"); - - React.useLayoutEffect(() => { - if (!src) { - setLoadingStatus("error"); - return; - } - - let isMounted = true; - const image = new window.Image(); - - const updateStatus = (status: ImageLoadingStatus) => () => { - if (!isMounted) return; - setLoadingStatus(status); - }; - - setLoadingStatus("loading"); - image.onload = updateStatus("loaded"); - image.onerror = updateStatus("error"); - image.src = src; - - return () => { - isMounted = false; - }; - }, [src]); - - return loadingStatus; -} diff --git a/ui/src/ui-library/hover-card.tsx b/ui/src/ui-library/hover-card.tsx deleted file mode 100644 index bb9c007..0000000 --- a/ui/src/ui-library/hover-card.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { - autoUpdate, - FloatingFocusManager, - flip, - offset, - Placement, - ReferenceType, - safePolygon, - shift, - useDismiss, - useFloating, - useHover, - useInteractions, - useRole, -} from "@floating-ui/react"; -import React from "react"; -import { twMerge } from "tailwind-merge"; -import { Heading, HeadingProps } from "./heading"; - -interface PopoverOptions { - placement?: Placement; - modal?: boolean; -} - -function useHoverCard({ placement = "bottom", modal }: PopoverOptions = {}) { - const [isOpen, setIsOpen] = React.useState(false); - const labelId = React.useId(); - - const data = useFloating({ - placement, - open: isOpen, - onOpenChange: setIsOpen, - middleware: [offset(10), flip({ fallbackAxisSideDirection: "end" }), shift()], - whileElementsMounted: autoUpdate, - }); - - const context = data.context; - const dismiss = useDismiss(context); - const role = useRole(context); - const hover = useHover(context, { - handleClose: safePolygon(), - delay: 250, - }); - - const interactions = useInteractions([dismiss, role, hover]); - - return React.useMemo( - () => ({ - isOpen, - setIsOpen, - ...interactions, - ...data, - modal, - labelId, - }), - [isOpen, interactions, data, modal, labelId] - ); -} - -type ContextType = ReturnType | null; - -const HoverCardContext = React.createContext(null); - -const useHoverCardContext = () => { - const context = React.useContext(HoverCardContext); - - if (context == null) { - throw new Error("HoverCard components must be wrapped in "); - } - - return context; -}; - -export function HoverCard({ - children, - modal = false, - ...restOptions -}: { - children: React.ReactNode; -} & PopoverOptions) { - const popover = useHoverCard({ modal, ...restOptions }); - - return {children}; -} - -export function HoverCardTrigger({ children }: { children: React.ReactNode }) { - const context = useHoverCardContext(); - const child = React.Children.only(children); - - return React.cloneElement( - child as React.ReactElement<{ - ref: ((node: ReferenceType | null) => void) & ((node: ReferenceType | null) => void); - }>, - { - ref: context.refs.setReference, - ...context.getReferenceProps(), - } - ); -} - -export function HoverCardContent({ - children, - label, - className, -}: { - children: React.ReactNode | (({ close }: { close: () => void }) => React.ReactNode); -} & { - label?: string; - className?: string; -}) { - const { - labelId, - context: floatingContext, - setIsOpen, - isOpen, - modal, - refs, - floatingStyles, - getFloatingProps, - } = useHoverCardContext(); - - const aria = label ? { "aria-label": label } : { "aria-labelledby": labelId }; - - return ( - isOpen && ( - -
- {typeof children === "function" ? children({ close: () => setIsOpen(false) }) : children} -
-
- ) - ); -} - -export function HoverCardHeader(props: HeadingProps) { - const { labelId } = useHoverCardContext(); - return ; -} diff --git a/ui/src/ui-library/icon.tsx b/ui/src/ui-library/icon.tsx deleted file mode 100644 index 1f4853d..0000000 --- a/ui/src/ui-library/icon.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; - -interface IconProps extends Omit { - children: React.ReactNode; -} - -// See: https://www.radix-ui.com/themes/docs/components/accessible-icon -export function Icon({ children, "aria-label": ariaLabel, ...props }: IconProps) { - const child = React.Children.only(children); - - return ( - <> - {React.cloneElement( - child as React.ReactElement< - React.JSX.IntrinsicElements["svg"] & { - "data-ui"?: string; - } - >, - { - ...props, - "aria-hidden": "true", - "aria-label": undefined, - "data-ui": "icon", - focusable: "false", - } - )} - {ariaLabel ? {ariaLabel} : null} - - ); -} diff --git a/ui/src/ui-library/icons.tsx b/ui/src/ui-library/icons.tsx deleted file mode 100644 index 7715da9..0000000 --- a/ui/src/ui-library/icons.tsx +++ /dev/null @@ -1,520 +0,0 @@ -import { twMerge } from "tailwind-merge"; -import { Icon } from "./icon"; - -export function EyeIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - ); -} - -export function EyeOffIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - ); -} - -export function CheckIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function CircleInfoIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - - ); -} - -export function CircleCheckIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - ); -} - -export function OctagonAlertIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - - ); -} - -export function CircleXIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - - ); -} - -export function PlusIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - ); -} - -export function MinusIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function XIcon({ "aria-label": arialLabel, ...props }: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - ); -} - -export function CalendarIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - - - ); -} - -export function ChevronUpIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function ChevronDownIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function ChevronRightIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function ChevronLeftIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function SearchIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function SpinnerIcon({ - className, - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - ); -} - -export function CopyIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - ); -} - -export function AvailableIcon({ - className, - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function BusyIcon({ - className, - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function AwayIcon({ - className, - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - ); -} - -export function DoNotDisturbIcon({ - className, - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - ); -} diff --git a/ui/src/ui-library/icons/outline/calendar.tsx b/ui/src/ui-library/icons/outline/calendar.tsx deleted file mode 100644 index be310fe..0000000 --- a/ui/src/ui-library/icons/outline/calendar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Icon } from "../../icon"; - -export function CalendarIcon({ - "aria-label": arialLabel, - ...props -}: React.JSX.IntrinsicElements["svg"]) { - return ( - - - - - - - - - ); -} diff --git a/ui/src/ui-library/initials.ts b/ui/src/ui-library/initials.ts deleted file mode 100644 index 014965d..0000000 --- a/ui/src/ui-library/initials.ts +++ /dev/null @@ -1,92 +0,0 @@ -const GRADIENTS: Array<[string, string]> = [ - ["oklch(0.7 0.016 285.938)", "oklch(0.789 0.015 286.067)"], - ["oklch(0.4885 0.1834 3.96)", "oklch(0.7134 0.1638 2.77)"], - ["oklch(0.5348 0.2679 282.44)", "oklch(0.677 0.1533 284.96)"], - ["oklch(0.4309 0.1865 281.4)", "oklch(0.677 0.1533 284.96)"], - ["oklch(0.3034 0.0964 306.25)", "oklch(0.644 0.0971 304.93)"], - ["oklch(0.7376 0.081 170.77)", "oklch(0.8015 0.1603 138.01)"], - ["oklch(0.6273 0.0715 205.19)", "oklch(0.8188 0.0649 173.43)"], - ["oklch(0.52 0.0614 123.17)", "oklch(0.7395 0.1053 118.44)"], - ["oklch(0.3496 0.0988 145.03)", "oklch(0.6515 0.0609 168.71)"], - ["oklch(0.6475 0.1768 249.33)", "oklch(0.813 0.1094 235.78)"], - ["oklch(0.5495 0.1202 251.83)", "oklch(0.7475 0.0724 250.72)"], - ["oklch(0.5126 0.0738 237.27)", "oklch(0.7352 0.0479 227.03)"], - ["oklch(0.6368 0.1388 28.08)", "oklch(0.8143 0.0907 51.75)"], - ["oklch(0.7593 0.164 64.36)", "oklch(0.8769 0.179577 93.1299)"], - ["oklch(0.4815 0.0401 14.22)", "oklch(0.7406 0.0305 77.47)"], -]; - -export type FallbackAvatarProps = { fallback?: "initials" | "icon" } & ( - | { - colorful?: boolean; - background?: never; - } - | { - colorful?: never; - background?: string; - } -); - -export function getInitials(name: string) { - return name - .split(/\s/) - .map((part) => part.substring(0, 1)) - .filter((v) => !!v) - .slice(0, 2) - .join("") - .toUpperCase(); -} - -function sumChars(str: string) { - let sum = 0; - for (let i = 0; i < str.length; i++) { - sum += str.charCodeAt(i); - } - - return sum; -} - -function getInitialsGradient(name: string, colorful?: boolean): [string, string] { - if (colorful) { - const i = sumChars(name) % GRADIENTS.length; - return GRADIENTS[i]; - } - - return GRADIENTS[0]; -} - -export function getFallbackAvatarDataUrl({ - alt, - fallback, - colorful, - background, -}: { - alt: string; -} & FallbackAvatarProps) { - const initials = getInitials(alt); - - background = - background ?? `linear-gradient(135deg, ${getInitialsGradient(alt, colorful).join(", ")})`; - - return fallback === "icon" - ? getFallbackIconDateUrl(background) - : getFallbackInitialsDataUrl(background, initials); -} - -function getFallbackIconDateUrl(bg: string) { - return ( - "data:image/svg+xml;base64," + - btoa( - `` - ) - ); -} - -function getFallbackInitialsDataUrl(bg: string, initials: string) { - return ( - "data:image/svg+xml;base64," + - btoa( - `${initials}` - ) - ); -} diff --git a/ui/src/ui-library/kbd.tsx b/ui/src/ui-library/kbd.tsx deleted file mode 100644 index f5682c8..0000000 --- a/ui/src/ui-library/kbd.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Keyboard as RACKeyboard } from "react-aria-components"; -import { twMerge } from "tailwind-merge"; - -export type KeyboardProps = Omit & { - children: string; - outline?: boolean; -}; - -export function Kbd({ className, children, outline, ...props }: KeyboardProps) { - return ( - - {children} - - ); -} diff --git a/ui/src/ui-library/link.tsx b/ui/src/ui-library/link.tsx deleted file mode 100644 index 6411176..0000000 --- a/ui/src/ui-library/link.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import { - composeRenderProps, - Link as RACLink, - LinkProps as RACLinkProps, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { AsChildProps, Slot } from "./slot"; -import { TooltipTrigger } from "./tooltip"; - -export type LinkProps = RACLinkProps & { - tooltip?: React.ReactNode; -}; - -export type LinkWithAsChild = AsChildProps< - RACLinkProps & { - tooltip?: React.ReactNode; - } ->; - -const linkStyle = [ - "relative inline-flex cursor-pointer items-center gap-1 rounded-sm outline-hidden hover:underline", - "text-base/6 sm:text-sm/6", - "[&.border]:hover:no-underline", - "[&>[data-ui=icon]:not([class*=size-])]:size-4", - "data-disabled:no-underline data-disabled:opacity-50 data-disabled:cursor-default", -].join(" "); - -export const Link = React.forwardRef(function Link(props, ref) { - if (props.asChild) { - return {props.children}; - } - - const { tooltip, ...rest } = props; - - const link = ( - - twMerge( - linkStyle, - isFocusVisible && "outline outline-2 outline-offset-2 outline-ring", - className - ) - )} - /> - ); - - if (tooltip) { - return ( - - {link} - {tooltip} - - ); - } - - return link; -}); diff --git a/ui/src/ui-library/list-box.tsx b/ui/src/ui-library/list-box.tsx deleted file mode 100644 index ffd9cb3..0000000 --- a/ui/src/ui-library/list-box.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from "react"; -import { - composeRenderProps, - ListBoxItemProps, - ListBox as RACListBox, - ListBoxItem as RACListBoxItem, - ListBoxProps as RACListBoxProps, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { composeTailwindRenderProps } from "./utils"; - -export interface ListBoxProps extends Omit, "layout" | "orientation"> {} - -export const ListBox = React.forwardRef( - (props: ListBoxProps, ref: React.Ref) => { - return ( - - ); - } -) as ( - props: ListBoxProps & { ref?: React.Ref } -) => React.JSX.Element; - -export const ListBoxItem = React.forwardRef( - (props: ListBoxItemProps, ref: React.Ref) => { - const textValue = - props.textValue || (typeof props.children === "string" ? props.children : undefined); - - return ( - - twMerge( - "group relative flex outline-0", - isDisabled && "opacity-50", - isFocusVisible && "outline-ring outline outline-2 outline-offset-2", - className - ) - )} - /> - ); - } -) as (props: ListBoxItemProps & { ref?: React.Ref }) => React.JSX.Element; diff --git a/ui/src/ui-library/menu.tsx b/ui/src/ui-library/menu.tsx deleted file mode 100644 index a4f4d6a..0000000 --- a/ui/src/ui-library/menu.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import React from "react"; -import { - Collection, - composeRenderProps, - Header, - Menu as RACMenu, - MenuItem as RACMenuItem, - MenuItemProps as RACMenuItemProps, - MenuProps as RACMenuProps, - MenuSection as RACMenuSection, - MenuSectionProps as RACMenuSectionProps, - Separator, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { Button, ButtonProps } from "./button"; -import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "./icons"; -import { Popover, PopoverProps } from "./popover"; -import { Small } from "./text"; -import { composeTailwindRenderProps } from "./utils"; - -export { MenuTrigger, SubmenuTrigger } from "react-aria-components"; - -type MenuButtonProps = ButtonProps & { - buttonArrow?: React.ReactNode; -}; - -export function MenuButton({ - buttonArrow = , - variant = "outline", - children, - ...props -}: MenuButtonProps) { - return ( - - ); -} - -// eslint-disable-next-line react/display-name -export const MenuPopover = React.forwardRef( - ({ className, ...props }: PopoverProps, ref: React.Ref) => { - return ( - - ); - } -); - -type MenuProps = RACMenuProps & { - checkIconPlacement?: "start" | "end"; -}; - -export function Menu({ checkIconPlacement = "end", ...props }: MenuProps) { - return ( - [data-ui=icon]:not([class*=text-])]:text-muted", - "[&_[data-ui=content][data-destructive]>[data-ui=icon]]:text-destructive", - "[&_[data-ui=content][data-destructive]:not(:hover)>[data-ui=icon]]:text-destructive/75", - "[&_[data-ui=content]>[data-ui=icon]:not([class*=size-])]:size-4", - "[&_[data-ui=content]>[data-ui=icon]:first-child]:col-start-1", - - // Label - "**:data-[ui=label]:col-span-full", - "[&:has([data-ui=icon]+[data-ui=label])_[data-ui=label]]:col-start-2", - "[&:has([data-ui=kbd])_[data-ui=label]]:-col-end-2", - "[&:has([data-ui=icon]+[data-ui=label])_[data-ui=content]:not(:has(>[data-ui=label]))]:ps-6", - - // Kbd - "**:data-[ui=kbd]:col-span-1", - "**:data-[ui=kbd]:row-start-1", - "**:data-[ui=kbd]:col-start-3", - "**:data-[ui=kbd]:justify-self-end", - "**:data-[ui=kbd]:text-xs/6", - "[&_:not([data-destructive])>[data-ui=kbd]:not([class*=bg-])]:text-muted/75", - "[&_[data-destructive]>[data-ui=kbd]]:text-destructive", - - // Description - "**:data-[ui=description]:col-span-full", - "[&:has([data-ui=kbd])_[data-ui=description]]:-col-end-2", - "[&:has([data-ui=icon]+[data-ui=label])_[data-ui=description]]:col-start-2" - ) - )} - /> - ); -} - -export function SubMenu(props: MenuProps & { "aria-label": string }) { - return ; -} - -export function MenuSeparator({ className }: { className?: string }) { - return ( - - ); -} - -type MenuItemProps = RACMenuItemProps & { - destructive?: true; -}; - -export function MenuItem({ destructive, ...props }: MenuItemProps) { - const textValue = - props.textValue || (typeof props.children === "string" ? props.children : undefined); - - return ( - { - return twMerge([ - "group rounded-sm outline-hidden", - "flex items-center gap-x-1.5", - "px-2 py-2.5 sm:py-1.5", - "text-base/6 sm:text-sm/6", - isDisabled && "opacity-50", - isFocused && "bg-zinc-100 dark:bg-zinc-800", - destructive && "text-destructive", - className, - ]); - })} - > - {composeRenderProps(props.children, (children, { selectionMode, isSelected }) => ( - <> - -
- {children} -
- - - {/* Submenu indicator */} - - - ))} -
- ); -} - -export function MenuItemLabel({ className, ...props }: React.JSX.IntrinsicElements["span"]) { - return ( - - ); -} - -export function MenuItemDescription({ className, ...props }: React.JSX.IntrinsicElements["span"]) { - return ; -} - -export interface MenuSectionProps extends RACMenuSectionProps { - title?: string | React.ReactNode; -} - -export function MenuSection({ className, ...props }: MenuSectionProps) { - return ( - -
- {props.title} -
- {props.children} -
- ); -} diff --git a/ui/src/ui-library/meter.tsx b/ui/src/ui-library/meter.tsx deleted file mode 100644 index 098a9d7..0000000 --- a/ui/src/ui-library/meter.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Meter as AriaMeter, MeterProps as AriaMeterProps } from "react-aria-components"; -import { Label } from "./field"; -import { composeTailwindRenderProps } from "./utils"; - -export interface MeterProps extends AriaMeterProps { - label?: string; -} - -export function Meter({ - label, - positive, - informative, - ...props -}: MeterProps & - ( - | { - positive?: true; - informative?: never; - } - | { positive?: never; informative?: true } - )) { - return ( - - {({ percentage, valueText }) => ( - <> -
- - = 80 && !positive && !informative && "text-destructive"}`} - > - {percentage >= 80 && !positive && ( - - - - - - )} - {` ${valueText}`} - -
-
-
-
- - )} - - ); -} - -function getColor( - percentage: number, - { positive, informative }: { positive?: boolean; informative?: boolean } -) { - if (positive) { - return "bg-success"; - } - - if (informative) { - return "bg-blue-500"; - } - - if (percentage < 70) { - return "bg-success"; - } - - if (percentage < 80) { - return "bg-yellow-600"; - } - - return "bg-destructive"; -} diff --git a/ui/src/ui-library/modal.tsx b/ui/src/ui-library/modal.tsx deleted file mode 100644 index e443715..0000000 --- a/ui/src/ui-library/modal.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import React from "react"; -import { - Modal as RACModal, - ModalOverlay as RACModalOverlay, - ModalOverlayProps as RACModalOverlayProps, -} from "react-aria-components"; -import { composeTailwindRenderProps } from "./utils"; - -const sizes = { - xs: "sm:max-w-xs", - sm: "sm:max-w-sm", - md: "sm:max-w-md", - lg: "sm:max-w-lg", - xl: "sm:max-w-lg", - "2xl": "sm:max-w-2xl", - "3xl": "sm:max-w-3xl", - "4xl": "sm:max-w-4xl", - "5xl": "sm:max-w-5xl", - fullWidth: "w-full", -}; - -type ModalType = - | { drawer?: never; placement?: "center" | "top" } - | { drawer: true; placement?: "left" | "right" }; - -type ModalProps = Omit & { - size?: keyof typeof sizes; - classNames?: { - modalOverlay?: RACModalOverlayProps["className"]; - modal?: RACModalOverlayProps["className"]; - }; -} & ModalType; - -export function Modal({ classNames, ...props }: ModalProps) { - const drawer = props.drawer; - const placement = props.drawer ? (props.placement ?? "left") : props.placement; - - React.useEffect(() => { - document - .querySelector(":root") - ?.style.setProperty( - "--scrollbar-width", - `${window.innerWidth - document.documentElement.clientWidth}px` - ); - }, []); - - return ( - [data-ui=modal]>section]:opacity-75", - "[&:has(~[data-ui=modal-overlay]:not([data-exiting]))>[data-ui=modal]]:bg-zinc-100", - "dark:[&:has(~[data-ui=modal-overlay]:not([data-exiting]))>[data-ui=modal]]:bg-zinc-900", - - "[&:has(~[data-ui=modal-overlay])>[data-ui=modal]]:transform-[scale,y]", - "[&:has(~[data-ui=modal-overlay])>[data-ui=modal]]:ease-in-out", - "[&:has(~[data-ui=modal-overlay])>[data-ui=modal]]:duration-200", - - // When the nested dialog is not closing - "[&:has(~[data-ui=modal-overlay]:not([data-exiting]))>[data-ui=modal]]:scale-90", - // Remove nested dialog overlay background and fade in effect - "[&:has(~[data-ui=modal-overlay])~[data-ui=modal-overlay]]:bg-transparent", - "[&:has(~[data-ui=modal-overlay])~[data-ui=modal-overlay]]:fade-in-100", - - // Make both dialogs close immediately - "[&:has(~[data-ui=modal-overlay])~[data-ui=modal-overlay][data-exiting]]:opacity-0", - "[&[data-exiting]:has(~[data-ui=modal-overlay])]:opacity-0", - ], - ])} - > - - - ); -} diff --git a/ui/src/ui-library/multi-select.tsx b/ui/src/ui-library/multi-select.tsx deleted file mode 100644 index 44155fa..0000000 --- a/ui/src/ui-library/multi-select.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import React, { useState } from "react"; -import { useFilter } from "react-aria"; -import { - ComboBox, - composeRenderProps, - Group, - GroupProps, - Key, - LabelContext, - ListBoxItemProps, - ComboBoxProps as RACComboBoxProps, -} from "react-aria-components"; -import { ListData, useListData } from "react-stately"; -import { twMerge } from "tailwind-merge"; -import { Button } from "./button"; -import { DescriptionContext, DescriptionProvider, Input, LabeledGroup } from "./field"; -import { ListBox, ListBoxItem } from "./list-box"; -import { Popover } from "./popover"; -import { TagGroup, TagList } from "./tag-group"; -import { composeTailwindRenderProps, inputField } from "./utils"; - -export interface MultiSelectProps - extends Omit< - RACComboBoxProps, - | "children" - | "validate" - | "allowsEmptyCollection" - | "inputValue" - | "selectedKey" - | "inputValue" - | "className" - | "value" - | "onSelectionChange" - | "onInputChange" - > { - items: Array; - selectedList: ListData; - className?: string; - onItemAdd?: (key: Key) => void; - onItemRemove?: (key: Key) => void; - renderEmptyState: (inputValue: string) => React.ReactNode; - tag: (item: T) => React.ReactNode; - children: React.ReactNode | ((item: T) => React.ReactNode); -} - -export function MultiSelectField({ - children, - className, - ...props -}: GroupProps & { children: React.ReactNode }) { - return ( - - - {children} - - - ); -} - -export function MultiSelect< - T extends { - id: Key; - textValue: string; - }, ->({ - children, - items, - selectedList, - onItemRemove, - onItemAdd, - className, - name, - renderEmptyState, - ...props -}: MultiSelectProps) { - const { contains } = useFilter({ sensitivity: "base" }); - - const selectedKeys = selectedList.items.map((i) => i.id); - - const filter = React.useCallback( - (item: T, filterText: string) => { - return !selectedKeys.includes(item.id) && contains(item.textValue, filterText); - }, - [contains, selectedKeys] - ); - - const availableList = useListData({ - initialItems: items, - filter, - }); - - const [fieldState, setFieldState] = useState<{ - selectedKey: Key | null; - inputValue: string; - }>({ - selectedKey: null, - inputValue: "", - }); - - const onRemove = React.useCallback( - (keys: Set) => { - const key = keys.values().next().value; - if (key) { - selectedList.remove(key); - setFieldState({ - inputValue: "", - selectedKey: null, - }); - onItemRemove?.(key); - } - }, - [selectedList, onItemRemove] - ); - - const onSelectionChange = (id: Key | null) => { - if (!id) { - return; - } - - const item = availableList.getItem(id); - - if (!item) { - return; - } - - if (!selectedKeys.includes(id)) { - selectedList.append(item); - setFieldState({ - inputValue: "", - selectedKey: id, - }); - onItemAdd?.(id); - } - - availableList.setFilterText(""); - }; - - const onInputChange = (value: string) => { - setFieldState((prevState) => ({ - inputValue: value, - selectedKey: value === "" ? null : prevState.selectedKey, - })); - - availableList.setFilterText(value); - }; - - const deleteLast = React.useCallback(() => { - if (selectedList.items.length == 0) { - return; - } - - const lastKey = selectedList.items[selectedList.items.length - 1]; - - if (lastKey !== null) { - selectedList.remove(lastKey.id); - onItemRemove?.(lastKey.id); - } - - setFieldState({ - inputValue: "", - selectedKey: null, - }); - }, [selectedList, onItemRemove]); - - const onKeyDownCapture = React.useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Backspace" && fieldState.inputValue === "") { - deleteLast(); - } - }, - [deleteLast, fieldState.inputValue] - ); - - const tagGroupId = React.useId(); - const triggerRef = React.useRef(null); - - const [width, setWidth] = React.useState(0); - - React.useEffect(() => { - const trigger = triggerRef.current; - if (!trigger) { - return; - } - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setWidth(entry.target.clientWidth); - } - }); - - observer.observe(trigger); - return () => { - observer.unobserve(trigger); - }; - }, [triggerRef]); - - const triggerButtonRef = React.useRef(null); - - const labelContext = (React.useContext(LabelContext) ?? {}) as { - id?: string; - }; - const descriptionContext = React.useContext(DescriptionContext); - - return ( - <> -
- {selectedList.items.length > 0 && ( - - - {props.tag} - - - )} - - -
0 && "ps-0", - ].join(" ")} - > - { - setFieldState({ - inputValue: "", - selectedKey: null, - }); - availableList.setFilterText(""); - }} - aria-describedby={[tagGroupId, descriptionContext?.["aria-describedby"] ?? ""].join( - " " - )} - onKeyDownCapture={onKeyDownCapture} - /> - -
- -
-
- - - renderEmptyState={() => renderEmptyState(fieldState.inputValue)} - selectionMode="multiple" - className="flex max-h-[inherit] flex-col gap-1.5 overflow-auto p-1.5 outline-hidden has-[header]:pt-0 sm:gap-0" - > - {children} - - -
- -
- -
- - {name && } - - ); -} - -export function MultiSelectItem(props: ListBoxItemProps) { - return ( - { - return twMerge([ - "rounded-md p-1.5 text-base/6 outline-0 focus-visible:outline-0 sm:text-sm/6", - isFocused && "bg-zinc-100 dark:bg-zinc-700", - className, - ]); - })} - > - {props.children} - - ); -} diff --git a/ui/src/ui-library/native-select.tsx b/ui/src/ui-library/native-select.tsx deleted file mode 100644 index 31e0645..0000000 --- a/ui/src/ui-library/native-select.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import { useFocusRing } from "react-aria"; -import { LabelContext } from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { DescriptionContext, DescriptionProvider } from "./field"; -import { inputField } from "./utils"; - -export function NativeSelectField({ className, ...props }: React.JSX.IntrinsicElements["div"]) { - const labelId = React.useId(); - - return ( - - -
- - - ); -} - -export function NativeSelect({ className, ...props }: React.JSX.IntrinsicElements["select"]) { - const { focusProps, isFocusVisible } = useFocusRing(); - const labelContext = (React.useContext(LabelContext) ?? {}) as { - id?: string; - }; - const descriptionContext = React.useContext(DescriptionContext); - - return ( -
- - -
- -
- - - - ); -} diff --git a/ui/src/ui-library/pagination.tsx b/ui/src/ui-library/pagination.tsx deleted file mode 100644 index 7574cae..0000000 --- a/ui/src/ui-library/pagination.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { LinkProps } from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { Button } from "./button"; -import { ChevronLeftIcon, ChevronRightIcon } from "./icons"; -import { Link } from "./link"; - -export function Pagination({ - className, - "aria-label": arialLabel = "Page navigation", - ...props -}: React.JSX.IntrinsicElements["nav"]) { - return ( -