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 (
+
+
+
+ );
+};
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;