diff --git a/ui/package.json b/ui/package.json index 9a568b7..f69e1dd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -71,6 +71,7 @@ "ag-grid-community": "^33.2.1", "ag-grid-react": "^33.2.1", "axios": "^1.8.4", + "date-fns": "^4.1.0", "jspdf": "^3.0.1", "jwt-decode": "^4.0.0", "react-router-dom": "^7.3.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 5ff21f4..ffed32e 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: axios: specifier: ^1.8.4 version: 1.8.4 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 jspdf: specifier: ^3.0.1 version: 3.0.1 @@ -2662,6 +2665,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -8266,6 +8272,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.1.0: {} + dayjs@1.11.13: {} debug@4.4.0: diff --git a/ui/src/components/ChannelPreview.tsx b/ui/src/components/ChannelPreview.tsx index c94ac8c..bdb0ad8 100644 --- a/ui/src/components/ChannelPreview.tsx +++ b/ui/src/components/ChannelPreview.tsx @@ -124,7 +124,7 @@ export function ChannelPreview({ {unreadCount > 0 && (
{unreadCount > 99 ? "99+" : unreadCount} diff --git a/ui/src/components/CustomModal.tsx b/ui/src/components/CustomModal.tsx new file mode 100644 index 0000000..5202907 --- /dev/null +++ b/ui/src/components/CustomModal.tsx @@ -0,0 +1,57 @@ +// Custom Modal Component +interface CustomModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +export function CustomModal({ + isOpen, + onClose, + title, + children, +}: CustomModalProps) { + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

+ {title} +

+ +
+ + {/* Content */} +
{children}
+
+
+ ); +} diff --git a/ui/src/components/EventDetailsModal.tsx b/ui/src/components/EventDetailsModal.tsx new file mode 100644 index 0000000..decb6c0 --- /dev/null +++ b/ui/src/components/EventDetailsModal.tsx @@ -0,0 +1,181 @@ +import { EventAndTablo } from "@ui/types/events.types"; +import { + Dialog, + DialogHeader, + DialogBody, + DialogCloseButton, +} from "@ui/ui-library/dialog"; +import { Text, Strong } from "@ui/ui-library/text"; +import { Button } from "@ui/ui-library/button"; +import { CalendarIcon, User } from "lucide-react"; +import { CustomModal } from "./CustomModal"; + +interface EventDetailsModalProps { + event: EventAndTablo | null; + isOpen: boolean; + onClose: () => void; + onEdit?: () => void; +} + +export const EventDetailsModal = ({ + event, + isOpen, + onClose, + onEdit, +}: EventDetailsModalProps) => { + if (!event) return null; + + const formatEventDateTime = (event: EventAndTablo) => { + if (!event.start_date) return "Date non définie"; + + try { + const date = new Date(event.start_date); + const options: Intl.DateTimeFormatOptions = { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }; + let formatted = date.toLocaleDateString("fr-FR", options); + + if (event.start_time) { + // Remove seconds from time (HH:MM:SS -> HH:MM) + const startTime = event.start_time.substring(0, 5); + formatted += ` à ${startTime}`; + if (event.end_time) { + const endTime = event.end_time.substring(0, 5); + formatted += ` - ${endTime}`; + } + } + + return formatted; + } catch { + return "Date invalide"; + } + }; + + const getEventStatusBadge = (event: EventAndTablo) => { + if (!event.start_date) return null; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const eventDate = new Date(event.start_date); + eventDate.setHours(0, 0, 0, 0); + + if (eventDate.getTime() === today.getTime()) { + return ( + + Aujourd'hui + + ); + } else if (eventDate > today) { + return ( + + À venir + + ); + } else { + return ( + + Passé + + ); + } + }; + + return ( + + + +
+
+

+ {event.title || "Événement sans titre"} +

+
{getEventStatusBadge(event)}
+
+
+ +
+ + + {/* Date and Time */} +
+ +
+ + Date et heure + + + {formatEventDateTime(event)} + +
+
+ + {/* Tablo */} + {event.tablo_name && ( +
+
+
+ + Tableau + + + {event.tablo_name} + +
+
+ )} + + {/* Description */} + {event.description && ( +
+ +
+ + Description + + + {event.description} + +
+
+ )} + + {/* Event ID (for debugging/reference) */} +
+
+ ID: {event.event_id} + {event.tablo_id && Tableau ID: {event.tablo_id}} +
+
+ + + {/* Footer */} +
+ + {onEdit && ( + + )} +
+
+
+ ); +}; diff --git a/ui/src/lib/routes.tsx b/ui/src/lib/routes.tsx index e7d7f99..df00401 100644 --- a/ui/src/lib/routes.tsx +++ b/ui/src/lib/routes.tsx @@ -20,6 +20,7 @@ import { ChatPage } from "@ui/pages/chat"; import { FeedbackPage } from "@ui/pages/feedback"; import { SupportPage } from "@ui/pages/support"; import { AvailabilitiesPage } from "@ui/pages/availabilities"; +import { BookingsPage } from "@ui/pages/bookings"; export const routes: RouteObject[] = [ // Protected routes @@ -78,6 +79,11 @@ export const routes: RouteObject[] = [ path: "availabilities", element: , }, + { + path: "bookings", + element: , + children: [{ index: true }, { path: ":tablo_id" }], + }, { path: "feedback", element: , diff --git a/ui/src/pages/availabilities.tsx b/ui/src/pages/availabilities.tsx index 623b801..d2a57a5 100644 --- a/ui/src/pages/availabilities.tsx +++ b/ui/src/pages/availabilities.tsx @@ -17,59 +17,7 @@ import { DatePicker, DatePickerInput } from "@ui/ui-library/date-picker"; import { Label } from "@ui/ui-library/field"; import { DateValue, getLocalTimeZone, today } from "@internationalized/date"; import { RadioGroup, Radios, Radio } from "@ui/ui-library/radio-group"; - -// Custom Modal Component -interface CustomModalProps { - isOpen: boolean; - onClose: () => void; - title: string; - children: React.ReactNode; -} - -function CustomModal({ isOpen, onClose, title, children }: CustomModalProps) { - if (!isOpen) return null; - - return ( -
- {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
-

- {title} -

- -
- - {/* Content */} -
{children}
-
-
- ); -} +import { CustomModal } from "@ui/components/CustomModal"; const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; const DAYS_OF_WEEK_DISPLAY = [ diff --git a/ui/src/pages/bookings.tsx b/ui/src/pages/bookings.tsx new file mode 100644 index 0000000..d566127 --- /dev/null +++ b/ui/src/pages/bookings.tsx @@ -0,0 +1,543 @@ +import { useState, useMemo, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { Text, Strong } from "@ui/ui-library/text"; +import { Button } from "@ui/ui-library/button"; +import { + Select, + SelectButton, + SelectListBox, + SelectListItem, + SelectListItemLabel, + SelectPopover, + StatusIcon, +} from "@ui/ui-library/select"; +import { Input } from "@ui/ui-library/field"; +import { SearchIcon } from "lucide-react"; +import { CalendarIcon } from "@ui/ui-library/icons/outline/calendar"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useEventsByTablo } from "@ui/hooks/events"; +import { useTablosList } from "@ui/hooks/tablos"; +import { EventAndTablo } from "@ui/types/events.types"; +import { LoadingSpinner } from "@ui/components/LoadingSpinner"; +import { EventDetailsModal } from "@ui/components/EventDetailsModal"; +import { RadioGroup, Radios, Radio } from "@ui/ui-library/radio-group"; +import { Badge } from "@ui/ui-library/badge"; + +type BookingStatus = "all" | "upcoming" | "past"; + +interface BookingStatusOption { + id: BookingStatus; + name: string; +} + +const statusOptions: BookingStatusOption[] = [ + { id: "upcoming", name: "À venir" }, + { id: "past", name: "Passés" }, +]; + +export const BookingsPage = () => { + const { tablo_id } = useParams(); + const navigate = useNavigate(); + const [selectedTabloId, setSelectedTabloId] = useState( + tablo_id || "all" + ); + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("upcoming"); + const [selectedEvent, setSelectedEvent] = useState( + null + ); + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + + // Fetch tablos and events + const { data: tablos, isLoading: tablosLoading } = useTablosList(); + const { data: events = [], isLoading: eventsLoading } = useEventsByTablo( + selectedTabloId !== "all" ? selectedTabloId : null + ); + + // Filter and search events + const filteredEvents = useMemo(() => { + if (!events) return []; + + let filtered = events; + + // Search filter + if (searchTerm) { + filtered = filtered.filter( + (event) => + event.title?.toLowerCase().includes(searchTerm.toLowerCase()) || + event.description?.toLowerCase().includes(searchTerm.toLowerCase()) || + event.tablo_name?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Status filter + if (statusFilter !== "all") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + filtered = filtered.filter((event) => { + if (!event.start_date) return false; + + const eventDate = new Date(event.start_date); + eventDate.setHours(0, 0, 0, 0); + + switch (statusFilter) { + case "upcoming": + return eventDate >= today; + case "past": + return eventDate < today; + default: + return true; + } + }); + } + + return filtered.sort((a, b) => { + if (!a.start_date || !b.start_date) return 0; + return ( + new Date(b.start_date).getTime() - new Date(a.start_date).getTime() + ); + }); + }, [events, searchTerm, statusFilter]); + + // Pagination logic + const totalItems = filteredEvents.length; + const totalPages = Math.ceil(totalItems / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedEvents = filteredEvents.slice(startIndex, endIndex); + + // Reset to first page when filters or page size change + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, statusFilter, selectedTabloId, itemsPerPage]); + + const formatEventDateTime = (event: EventAndTablo) => { + if (!event.start_date) return "Date non définie"; + + try { + const date = new Date(event.start_date); + const options: Intl.DateTimeFormatOptions = { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }; + let formatted = date.toLocaleDateString("fr-FR", options); + + if (event.start_time) { + // Remove seconds from time (HH:MM:SS -> HH:MM) + const startTime = event.start_time.substring(0, 5); + formatted += ` à ${startTime}`; + if (event.end_time) { + const endTime = event.end_time.substring(0, 5); + formatted += ` - ${endTime}`; + } + } + + return formatted; + } catch { + return "Date invalide"; + } + }; + + const getEventStatusBadge = (event: EventAndTablo) => { + if (!event.start_date) return null; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const eventDate = new Date(event.start_date); + eventDate.setHours(0, 0, 0, 0); + + if (eventDate.getTime() === today.getTime()) { + return ( + + Aujourd'hui + + ); + } else if (eventDate > today) { + return ( + + À venir + + ); + } else { + return ( + + Passé + + ); + } + }; + + const handleCreateEvent = () => { + const today = new Date(); + const dateParam = today.toISOString(); + const tabloParam = + selectedTabloId !== "all" ? `&tablo_id=${selectedTabloId}` : ""; + navigate(`/planning/create?date=${dateParam}${tabloParam}`); + }; + + const handleEditEvent = (event: EventAndTablo) => { + if (event.event_id && event.tablo_id) { + navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`); + } + }; + + const handleViewEvent = (event: EventAndTablo) => { + setSelectedEvent(event); + setIsDetailsModalOpen(true); + }; + + return ( +
+ {/* Header */} +
+
+
+
+

+ Réservations +

+ + Gérez vos événements et réservations + +
+
+ +
+
+
+
+ + {/* Main Content */} +
+ {/* Filters */} +
+
+ {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10 h-10" + /> +
+
+ + {/* Tablo Filter */} +
+ +
+ + {/* Status Filter */} +
+ setStatusFilter(value as BookingStatus)} + > + + {statusOptions.map((option) => { + return ( + + {({ isSelected }: { isSelected: boolean }) => { + return ( + + {option.name} + + ); + }} + + ); + })} + + +
+
+
+ + {/* Events List */} +
+ {tablosLoading || eventsLoading ? ( +
+ +
+ ) : paginatedEvents.length === 0 ? ( +
+ +

+ Aucun événement trouvé +

+

+ {searchTerm || statusFilter !== "all" + ? "Essayez de modifier vos filtres de recherche." + : "Commencez par créer votre premier événement."} +

+
+ ) : ( +
+ {paginatedEvents.map((event) => ( +
+
+
+
+ + {event.title || "Événement sans titre"} + + {getEventStatusBadge(event)} +
+ +
+ + + {formatEventDateTime(event)} + + {event.tablo_name && ( + + {event.tablo_name} + + )} +
+ + {event.description && ( + + {event.description} + + )} +
+ +
+ + +
+
+
+ ))} +
+ )} +
+ + {/* Pagination Controls */} + {totalItems > 0 && ( +
+
+
+ + Affichage de {startIndex + 1} à{" "} + {Math.min(endIndex, totalItems)} sur {totalItems} événements + +
+ Éléments par page: + +
+
+ + {totalPages > 1 && ( +
+ + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((page) => { + // Show first page, last page, current page, and pages around current + return ( + page === 1 || + page === totalPages || + Math.abs(page - currentPage) <= 1 + ); + }) + .map((page, index, array) => { + // Add ellipsis if there's a gap + const prevPage = array[index - 1]; + const showEllipsis = prevPage && page - prevPage > 1; + + return ( +
+ {showEllipsis && ( + ... + )} + +
+ ); + })} +
+ + +
+ )} +
+
+ )} + + {/* Stats Summary */} + {filteredEvents.length > 0 && ( +
+
+
+
+ {filteredEvents.length} +
+
+ Événements trouvés +
+
+
+
+ { + filteredEvents.filter((e) => { + if (!e.start_date) return false; + const eventDate = new Date(e.start_date); + return eventDate >= new Date(); + }).length + } +
+
+ À venir +
+
+
+
+ { + filteredEvents.filter((e) => { + if (!e.start_date) return false; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const eventDate = new Date(e.start_date); + eventDate.setHours(0, 0, 0, 0); + return eventDate.getTime() === today.getTime(); + }).length + } +
+
+ Aujourd'hui +
+
+
+
+ )} + + {/* Event Details Modal */} + { + setIsDetailsModalOpen(false); + setSelectedEvent(null); + }} + onEdit={() => selectedEvent && handleEditEvent(selectedEvent)} + /> +
+
+ ); +}; diff --git a/ui/src/pages/devis.tsx b/ui/src/pages/devis.tsx index e3af693..ec587b5 100644 --- a/ui/src/pages/devis.tsx +++ b/ui/src/pages/devis.tsx @@ -32,7 +32,7 @@ import { statusToText, } from "@ui/utils/helpers"; import { ViewDevisModal } from "@ui/components/devis/ViewDevisModal"; -import { Badge, BadgeVariant } from "@ui/ui-library/badge"; +import { Badge, BadgeColor } from "@ui/ui-library/badge"; import { Select, SelectButton } from "@ui/ui-library/select"; import { SelectListBox } from "@ui/ui-library/select"; import { SelectPopover } from "@ui/ui-library/select"; @@ -42,12 +42,12 @@ ModuleRegistry.registerModules([AllCommunityModule]); type Devis = Database["public"]["Tables"]["devis"]["Row"]; -const statusToVariant: Record = { - draft: "neutral", - sent: "in-review", - accepted: "done", - rejected: "danger", - expired: "notice", +const statusToVariant: Record = { + draft: "zinc", + sent: "indigo", + accepted: "green", + rejected: "red", + expired: "yellow", }; export const DevisPage = () => { @@ -177,7 +177,7 @@ export const DevisPage = () => { {Object.entries(statusToVariant).map(([status]) => ( - + {statusToText[status as Devis["status"]]} diff --git a/ui/src/ui-library/badge.tsx b/ui/src/ui-library/badge.tsx deleted file mode 100644 index e1a44ec..0000000 --- a/ui/src/ui-library/badge.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react"; - -const baseStyles = - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"; - -const variantStyles = { - neutral: - "border-transparent bg-gray-100 text-gray-800 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80", - done: "border-transparent bg-green-100 text-green-800 hover:bg-green-100/80 dark:bg-green-900 dark:text-green-50 dark:hover:bg-green-900/80", - danger: - "border-transparent bg-red-100 text-red-800 hover:bg-red-100/80 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/80", - notice: - "border-transparent bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80 dark:bg-yellow-900 dark:text-yellow-50 dark:hover:bg-yellow-900/80", - "in-review": - "border-transparent bg-blue-100 text-blue-800 hover:bg-blue-100/80 dark:bg-blue-900 dark:text-blue-50 dark:hover:bg-blue-900/80", -}; - -export type BadgeVariant = keyof typeof variantStyles; - -export interface BadgeProps extends React.HTMLAttributes { - variant?: BadgeVariant; - children: React.ReactNode; -} - -function Badge({ - className, - variant = "neutral", - children, - ...props -}: BadgeProps) { - const combinedClassName = [baseStyles, variantStyles[variant], className] - .filter(Boolean) - .join(" "); - - return ( -
- {children} -
- ); -} - -export { Badge }; diff --git a/ui/src/ui-library/badge/badge.styles.ts b/ui/src/ui-library/badge/badge.styles.ts new file mode 100644 index 0000000..903a3e7 --- /dev/null +++ b/ui/src/ui-library/badge/badge.styles.ts @@ -0,0 +1,95 @@ +import { ClassNameValue } from "tailwind-merge"; + +const colors = { + zinc: "[--badge:var(--color-zinc-500)]", + red: "[--badge:var(--color-red-500)]", + orange: "[--badge:var(--color-orange-500)]", + amber: "[--badge:var(--color-amber-500)]", + yellow: "[--badge:var(--color-yellow-500)]", + lime: "[--badge:var(--color-lime-500)]", + green: "[--badge:var(--color-green-500)]", + emerald: "[--badge:var(--color-emerald-500)]", + teal: "[--badge:var(--color-teal-500)]", + cyan: "[--badge:var(--color-cyan-500)]", + sky: "[--badge:var(--color-sky-500)]", + blue: "[--badge:var(--color-blue-500)]", + indigo: "[--badge:var(--color-indigo-500)]", + violet: "[--badge:var(--color-violet-500)]", + purple: "[--badge:var(--color-purple-500)]", + fuchsia: "[--badge:var(--color-fuchsia-500)]", + pink: "[--badge:var(--color-pink-500)]", + rose: "[--badge:var(--color-rose-500)]", +}; + +export type BadgeColor = keyof typeof colors | "white" | "black"; + +export type BadgeVariant = "solid"; + +export function getBadgeStyles( + { + color = "zinc", + variant, + }: { + color?: BadgeColor; + variant?: BadgeVariant; + }, + className?: ClassNameValue +) { + const base = [ + "inline-flex max-w-fit cursor-default items-center gap-x-1 rounded-md px-2 py-0.5 text-xs/5 font-medium outline-0 transition [&>[data-ui=icon]:not([class*=size-])]:size-3.5", + ]; + + if (color === "white") { + return [ + base, + variant === "solid" + ? "border border-accent bg-accent text-[--btn-color:lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)] data-selection-mode:hover:opacity-85" + : "border data-selection-mode:hover:bg-zinc-100 data-selection-mode:dark:hover:bg-zinc-700", + className, + ]; + } + + if (color === "black") { + return [ + base, + variant === "solid" + ? "bg-zinc-950 text-white dark:bg-white dark:text-zinc-950 data-selection-mode:hover:opacity-85" + : [ + "bg-zinc-200", + "text-zinc-900", + "data-selection-mode:hover:bg-zinc-950", + "data-selection-mode:hover:text-white ", + "dark:bg-zinc-600", + "dark:text-white", + "data-selection-mode:dark:hover:bg-zinc-700", + ], + className, + ]; + } + + return [ + base, + "text-(--color)", + "bg-(--bg)", + colors[color] ?? colors.zinc, + variant === "solid" + ? [ + "[--bg:color-mix(in_oklab,_var(--badge)_90%,_black)]", + "[--color:color-mix(in_oklab,_var(--badge)_5%,_white)]", + "data-selection-mode:hover:[--bg:color-mix(in_oklab,_var(--badge)_80%,_black)]", + ] + : [ + // light + "[--bg:color-mix(in_oklab,_var(--badge)_10%,_white)]", + "[--color:color-mix(in_oklab,_var(--badge)_80%,_black)]", + "data-selection-mode:hover:[--bg:color-mix(in_oklab,_var(--badge)_30%,_white)]", + + // dark + "dark:[--bg:color-mix(in_oklab,_var(--badge)_40%,_black)]", + "dark:[--color:color-mix(in_oklab,_var(--badge)_98%,_black)]", + + "data-selection-mode:hover:dark:[--bg:color-mix(in_oklab,_var(--badge)_30%,_black)]", + ], + className, + ]; +} diff --git a/ui/src/ui-library/badge/badge.tsx b/ui/src/ui-library/badge/badge.tsx new file mode 100644 index 0000000..f282a9a --- /dev/null +++ b/ui/src/ui-library/badge/badge.tsx @@ -0,0 +1,19 @@ +import { twMerge } from "tailwind-merge"; +import { BadgeColor, getBadgeStyles } from "./badge.styles"; + +export function Badge({ + className, + color = "zinc", + variant, + ...props +}: React.JSX.IntrinsicElements["div"] & { + color?: BadgeColor; + variant?: "solid"; +}) { + return ( +
+ ); +} diff --git a/ui/src/ui-library/badge/index.ts b/ui/src/ui-library/badge/index.ts new file mode 100644 index 0000000..e81d39f --- /dev/null +++ b/ui/src/ui-library/badge/index.ts @@ -0,0 +1,5 @@ +export type { BadgeColor } from "./badge.styles"; + +export { getBadgeStyles } from "./badge.styles"; + +export { Badge } from "./badge"; diff --git a/ui/src/ui-library/radio-group.tsx b/ui/src/ui-library/radio-group.tsx index d7f22fa..b1cb835 100644 --- a/ui/src/ui-library/radio-group.tsx +++ b/ui/src/ui-library/radio-group.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; import { composeRenderProps, Radio as RACRadio, @@ -6,35 +6,120 @@ import { RadioGroupProps as RACRadioGroupProps, RadioProps as RACRadioProps, RadioRenderProps, -} from 'react-aria-components'; -import { twMerge } from 'tailwind-merge'; -import { DescriptionContext, DescriptionProvider } from './field'; -import { composeTailwindRenderProps, groupBox } from './utils'; +} from "react-aria-components"; +import { twMerge } from "tailwind-merge"; +import { DescriptionContext, DescriptionProvider } from "./field"; +import { composeTailwindRenderProps, groupBox } from "./utils"; + +type RadioGroupVariant = { + orientation?: "vertical" | "horizontal"; + labelPlacement?: "start" | "end"; +} & ( + | { + variant?: "card"; + compact?: true; + } + | { + variant?: "radio" | "segment"; + compact?: never; + } +); + +const RadioGroupVariantContext = React.createContext( + null +); + +const useRadioGroupVariantContext = () => { + const { + labelPlacement = "end", + orientation = "vertical", + variant = "radio", + compact = false, + } = React.useContext(RadioGroupVariantContext) ?? {}; + + return { + labelPlacement, + orientation, + variant, + compact, + } as RadioGroupVariant; +}; + +export function RadioGroup({ + children, + variant = "radio", + orientation, + labelPlacement = "end", + compact, + ...props +}: RACRadioGroupProps & Exclude) { + if (variant === "segment") { + orientation = orientation ?? "horizontal"; + } -export function RadioGroup(props: RACRadioGroupProps) { return ( - + + + {children} + + ); } export function Radios({ className, ...props -}: React.JSX.IntrinsicElements['div']) { +}: React.JSX.IntrinsicElements["div"]) { + const { variant, orientation, compact } = useRadioGroupVariantContext(); + return (
@@ -44,19 +129,23 @@ export function Radios({ export function RadioField({ className, ...props -}: React.JSX.IntrinsicElements['div']) { +}: React.JSX.IntrinsicElements["div"]) { + const { labelPlacement } = useRadioGroupVariantContext(); + return (
@@ -64,13 +153,15 @@ export function RadioField({ } export interface RadioProps extends RACRadioProps { - labelPlacement?: 'start' | 'end'; - radio?: React.ReactElement | ((props: RadioRenderProps) => React.ReactNode); + radio?: + | React.ReactElement + | null + | ((props: Partial) => React.ReactNode); render?: never; } export interface CustomRenderRadioProps - extends Omit { + extends Omit { render: | string | React.ReactElement @@ -79,8 +170,85 @@ export interface CustomRenderRadioProps children?: never; } +function getRadioStyle({ + isSelected, + isHovered, + variant, + orientation = "vertical", + compact, +}: Partial & { + variant: RadioGroupVariant["variant"]; + orientation?: RadioGroupVariant["orientation"]; + compact?: boolean; +}) { + const style = { + radio: [], + card: [ + "flex-1 rounded-lg px-4 py-3 items-start [&>[data-slot=radio]:not([class*=mt-])]:mt-1.5", + "[&_[data-ui=icon]:not([class*=size-])]:w-4", + "[&_[data-ui=icon]:not([class*=size-])]:h-[1lh]", + isSelected + ? "[&_[data-ui=icon]:not([class*=text-])]:text-foreground" + : "[&_[data-ui=icon]:not([class*=text-])]:text-muted", + compact + ? [ + "[&:not(:first-child):not(:last-child)]:rounded-none", + orientation === "horizontal" && [ + "first:rounded-e-none", + "last:rounded-s-none", + "border-t border-b", + "not-first:border-e", + "not-last:border-s", + "[&:has(+label[data-selected]:last-child)]:border-e-accent/50", + isSelected && "[&:first-child+label]:border-s-accent/50", + ], + + orientation === "vertical" && [ + "border-s border-e", + "first:rounded-b-none", + "last:rounded-t-none", + "not-first:border-b", + "not-last:border-t", + "[&:has(+label[data-selected]:last-child)]:border-b-accent/50", + isSelected && "[&:first-child+label]:border-t-accent/50", + ], + + isSelected && [ + "bg-[color-mix(in_oklab,_var(--accent)_5%,_white)]", + "dark:bg-[color-mix(in_oklab,_var(--accent)_25%,_black)] border-accent/50", + ], + ] + : [ + "ring ring-border", + isSelected && ["ring-ring", "ring-inset", "ring-1"], + ], + ], + segment: [ + "flex", + "justify-center", + "items-center", + "flex-1 text-center font-medium rounded-[calc(var(--segment-radius)-var(--segment-padding))]", + "transition-all ease-in-out", + "[&_[data-ui=icon]:not([class*=size-])]:size-4", + isSelected && [ + "bg-white dark:bg-zinc-600", + "shadow-sm dark:shadow-none", + "ring ring-zinc-950/10", + ], + !isSelected && !isHovered && "text-muted", + + orientation === "horizontal" && ["px-4 py-1"], + orientation === "vertical" && ["p-2"], + ], + }; + + return style[variant ?? "radio"]; +} + export function Radio(props: RadioProps | CustomRenderRadioProps) { const descriptionContext = React.useContext(DescriptionContext); + const { variant, orientation, labelPlacement, compact } = + useRadioGroupVariantContext(); if (props.render !== undefined) { const { render, ...restProps } = props; @@ -88,17 +256,23 @@ export function Radio(props: RadioProps | CustomRenderRadioProps) { return ( + (className, renderProps) => twMerge( - 'group text-base/6 sm:text-sm/6', - isDisabled && 'opacity-50', - isFocusVisible && - 'outline-ring outline outline-2 outline-offset-2', - className, - ), + "group text-base/6 sm:text-sm/6", + renderProps.isDisabled && "opacity-50", + renderProps.isFocusVisible && + "outline-ring outline-2 outline-offset-3", + getRadioStyle({ + variant, + orientation, + compact, + ...renderProps, + }), + className + ) )} > {render} @@ -106,60 +280,50 @@ export function Radio(props: RadioProps | CustomRenderRadioProps) { ); } - const { labelPlacement = 'end', radio, ...restProps } = props; + const { radio, ...restProps } = props; + + const noRadioToggle = radio === null; return ( - twMerge( - 'group flex items-center text-base/6 sm:text-sm/6', - 'group-aria-[orientation=horizontal]:text-nowrap', - labelPlacement === 'start' && 'flex-row-reverse justify-between', - isDisabled && 'opacity-50', - className, - ), + aria-describedby={descriptionContext?.["aria-describedby"]} + className={composeRenderProps(props.className, (className, renderProps) => + twMerge( + "group flex items-center text-base/6 sm:text-sm/6", + orientation === "horizontal" && "text-nowrap", + labelPlacement === "start" && "flex-row-reverse justify-between", + getRadioStyle({ + variant, + orientation, + compact, + ...renderProps, + }), + renderProps.isDisabled && "opacity-50", + noRadioToggle && [ + renderProps.isFocusVisible && + "outline-ring outline-2 outline-offset-2", + ], + className + ) )} > {(renderProps) => { return ( <> -
- {radio ? ( - typeof radio === 'function' ? ( - radio(renderProps) - ) : ( - radio - ) - ) : ( -
- )} -
+ {!noRadioToggle && ( + + )} - {typeof props.children === 'function' + {typeof props.children === "function" ? props.children(renderProps) : props.children} @@ -168,3 +332,68 @@ export function Radio(props: RadioProps | CustomRenderRadioProps) {
); } + +type RadioBoxProps = Partial & { + radio?: Exclude; + renderProps?: Partial; +} & Omit; + +export function RadioToggle({ + radio, + renderProps, + className, + ...props +}: RadioBoxProps) { + return ( +
+ {radio && renderProps ? ( + typeof radio === "function" ? ( + radio(renderProps) + ) : ( + radio + ) + ) : ( +
+ )} +
+ ); +} diff --git a/ui/src/ui-library/select.tsx b/ui/src/ui-library/select.tsx index 26f8a3b..b9df08e 100644 --- a/ui/src/ui-library/select.tsx +++ b/ui/src/ui-library/select.tsx @@ -33,6 +33,15 @@ export function Select(props: RACSelectProps) { ); } +export function StatusIcon({ className }: { className: string }) { + return ( + + ); +} + export function SelectButton(props: { className?: string; children?: React.ReactNode;