diff --git a/api/package-lock.json b/api/package-lock.json index b20dcf9..b1cf52f 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -15,6 +15,7 @@ "graphile-worker": "^0.16.6", "hono": "^4.7.7", "hono-sessions": "^0.7.2", + "luxon": "^3.7.2", "multer": "^2.0.2", "nodemailer": "^7.0.4", "stream-chat": "^9.8.0", @@ -3905,6 +3906,14 @@ "get-func-name": "^2.0.1" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "engines": { + "node": ">=12" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", diff --git a/api/package.json b/api/package.json index 5cd3dea..254a1cf 100644 --- a/api/package.json +++ b/api/package.json @@ -21,6 +21,7 @@ "graphile-worker": "^0.16.6", "hono": "^4.7.7", "hono-sessions": "^0.7.2", + "luxon": "^3.7.2", "multer": "^2.0.2", "nodemailer": "^7.0.4", "stream-chat": "^9.8.0", @@ -41,5 +42,5 @@ }, "overrides": { "linkifyjs": "^4.3.2" - } + } } diff --git a/api/src/__tests__/slots.test.ts b/api/src/__tests__/slots.test.ts index 136ccd9..8e3cce5 100644 --- a/api/src/__tests__/slots.test.ts +++ b/api/src/__tests__/slots.test.ts @@ -5,7 +5,7 @@ import { type EventTypeConfig, type Exception, generateTimeSlots, - getDateString, + getDateStringCET, getDayOfWeek, type WeeklyAvailability, } from "../slots.js"; @@ -734,8 +734,10 @@ describe("generateTimeSlots", () => { expect(slot09_00?.available, "09:00 should be available").to.be.true; expect(slot09_30?.available, "09:30 should not be available").to.be.false; - expect(slot10_00?.available, "10:00 should not be unavailable").to.be.false; // Within buffered time - expect(slot10_30?.available, "10:30 should not be unavailable").to.be.false; // Within buffered time + expect(slot10_00?.available, "10:00 should not be unavailable").to.be + .false; // Within buffered time + expect(slot10_30?.available, "10:30 should not be unavailable").to.be + .false; // Within buffered time expect(slot11_00?.available, "11:00 should be available").to.be.true; // After buffered time }); @@ -1143,8 +1145,12 @@ describe("generateTimeSlots", () => { }); it("should format date strings correctly", () => { - expect(getDateString(new Date("2024-01-15T10:30:00Z"))).to.equal("2024-01-15"); - expect(getDateString(new Date("2024-12-31T23:59:59Z"))).to.equal("2024-12-31"); + expect(getDateStringCET(new Date("2024-01-15T10:30:00Z"))).to.equal( + "2024-01-15" + ); + expect(getDateStringCET(new Date("2024-12-31T23:59:59Z"))).to.equal( + "2025-01-01" + ); }); }); }); diff --git a/api/src/public.ts b/api/src/public.ts index 5843249..3ad5668 100644 --- a/api/src/public.ts +++ b/api/src/public.ts @@ -6,40 +6,12 @@ import { type EventTypeConfig, type Exception, generateTimeSlots, - getDateString, + getDateStringCET, getDayOfWeek, type TimeSlot, type WeeklyAvailability, } from "./slots.js"; -// Helper function to get current time in CET -function getCETTime(): Date { - const utcNow = new Date(); - - // Use Intl.DateTimeFormat to get the correct CET/CEST time - const formatter = new Intl.DateTimeFormat("en", { - timeZone: "Europe/Paris", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); - - const parts = formatter.formatToParts(utcNow); - const year = parseInt(parts.find((p) => p.type === "year")?.value || "0"); - const month = - parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed - const day = parseInt(parts.find((p) => p.type === "day")?.value || "0"); - const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0"); - const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0"); - const second = parseInt(parts.find((p) => p.type === "second")?.value || "0"); - - return new Date(year, month, day, hour, minute, second); -} - export const publicRouter = new Hono<{ Variables: { supabase: SupabaseClient; @@ -102,7 +74,7 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => { // Get existing events for the next month // Use CET time for availability calculations - const now = getCETTime(); + const now = new Date(); const nextMonth = new Date(now); nextMonth.setMonth(now.getMonth() + 2); @@ -110,8 +82,8 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => { .from("events") .select("*") .eq("created_by", user.id) - .gte("start_date", getDateString(now)) - .lte("start_date", getDateString(nextMonth)) + .gte("start_date", getDateStringCET(now)) + .lte("start_date", getDateStringCET(nextMonth)) .is("deleted_at", null); if (eventsError) { diff --git a/api/src/slots.ts b/api/src/slots.ts index b509129..7e5d5e4 100644 --- a/api/src/slots.ts +++ b/api/src/slots.ts @@ -1,30 +1,6 @@ import type { Tables } from "./database.types.js"; -// Helper function to convert UTC date to CET -function convertToCET(utcDate: Date): Date { - // Use Intl.DateTimeFormat to get the correct CET/CEST offset - const formatter = new Intl.DateTimeFormat("en", { - timeZone: "Europe/Paris", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); - - const parts = formatter.formatToParts(utcDate); - const year = parseInt(parts.find((p) => p.type === "year")?.value || "0"); - const month = - parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed - const day = parseInt(parts.find((p) => p.type === "day")?.value || "0"); - const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0"); - const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0"); - const second = parseInt(parts.find((p) => p.type === "second")?.value || "0"); - - return new Date(year, month, day, hour, minute, second); -} +import { DateTime } from "luxon"; // Types for availability calculation type TimeRange = { @@ -139,39 +115,38 @@ function getMinAdvanceBookingDate( config: EventTypeConfig, currentDate: Date ): { date: string; time: string } { - // Convert current UTC date to CET - const cetCurrentDate = convertToCET(currentDate); - if (!config.minAdvanceBooking) { return { - date: getDateString(cetCurrentDate), - time: formatTime(cetCurrentDate.getHours(), cetCurrentDate.getMinutes()), + date: getDateStringCET(currentDate), + time: formatTime(currentDate.getHours(), currentDate.getMinutes()), }; } const { value, unit } = config.minAdvanceBooking; - const advanceDate = new Date(cetCurrentDate); + const advanceDate = new Date(currentDate); switch (unit) { case "minutes": - advanceDate.setMinutes(cetCurrentDate.getMinutes() + value); + advanceDate.setMinutes(currentDate.getMinutes() + value); break; case "hours": - advanceDate.setHours(cetCurrentDate.getHours() + value); + advanceDate.setHours(currentDate.getHours() + value); break; case "days": - advanceDate.setDate(cetCurrentDate.getDate() + value); + advanceDate.setDate(currentDate.getDate() + value); break; } return { - date: getDateString(advanceDate), + date: getDateStringCET(advanceDate), time: formatTime(advanceDate.getHours(), advanceDate.getMinutes()), }; } -export function getDateString(date: Date): string { - return date.toISOString().split("T")[0]; +export function getDateStringCET(date: Date): string { + return DateTime.fromJSDate(date) + .setZone("Europe/Paris") + .toFormat("yyyy-MM-dd"); } export function generateTimeSlots( @@ -182,14 +157,13 @@ export function generateTimeSlots( exceptions: Exception[], existingEvents: Tables<"events">[] ): TimeSlot[] { - const dateStr = getDateString(date); + const dateStr = getDateStringCET(date); const slots: TimeSlot[] = []; // Check if this date has an exception const exception = exceptions.find((e) => { const exceptionDate = new Date(e.date); - const exceptionDateCET = convertToCET(exceptionDate); - const exceptionDateStr = getDateString(exceptionDateCET); + const exceptionDateStr = getDateStringCET(exceptionDate); return exceptionDateStr === dateStr; }); diff --git a/ui/src/components/EmbedConfigModal.tsx b/ui/src/components/EmbedConfigModal.tsx new file mode 100644 index 0000000..a6f9621 --- /dev/null +++ b/ui/src/components/EmbedConfigModal.tsx @@ -0,0 +1,181 @@ +import { Button } from "@ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@ui/components/ui/dialog"; +import { Label } from "@ui/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/ui/select"; +import { CopyButton } from "@ui/components/ui/clipboard"; +import { useState } from "react"; +import { TypographyMuted } from "@ui/components/ui/typography"; + +type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange"; + +interface EmbedConfig { + backgroundVariant: ColorVariant; + buttonVariant: ColorVariant; +} + +interface EmbedConfigModalProps { + isOpen: boolean; + onClose: () => void; + baseEmbedUrl: string; +} + +export function EmbedConfigModal({ isOpen, onClose, baseEmbedUrl }: EmbedConfigModalProps) { + const [embedConfig, setEmbedConfig] = useState({ + backgroundVariant: "purple", + buttonVariant: "purple", + }); + + const getEmbedUrl = () => { + const params = new URLSearchParams({ + backgroundVariant: embedConfig.backgroundVariant, + buttonVariant: embedConfig.buttonVariant, + }); + return `${baseEmbedUrl}?${params.toString()}`; + }; + + const generateEmbedCode = () => { + const embedUrl = getEmbedUrl(); + + return ``; + }; + + const colorOptions: { value: ColorVariant; label: string; color: string }[] = [ + { value: "black", label: "Noir", color: "bg-gray-900" }, + { value: "white", label: "Blanc", color: "bg-white" }, + { value: "blue", label: "Bleu", color: "bg-blue-600" }, + { value: "purple", label: "Violet", color: "bg-purple-600" }, + { value: "green", label: "Vert", color: "bg-green-600" }, + { value: "orange", label: "Orange", color: "bg-orange-600" }, + ]; + + return ( + + + + Configurer l'intégration + + +
+ {/* Configuration Section */} +
+
+ + +
+ +
+ + +
+
+ + {/* Preview Link */} +
+ +
+ + +
+
+ + {/* Embed Code */} +
+ + + Copiez ce code pour intégrer le formulaire de réservation sur votre site web + +
+
+
+                  {generateEmbedCode()}
+                
+
+
+ +
+
+
+
+ + + + +
+
+ ); +} diff --git a/ui/src/components/EventTypeCard.tsx b/ui/src/components/EventTypeCard.tsx index 373a42e..401f756 100644 --- a/ui/src/components/EventTypeCard.tsx +++ b/ui/src/components/EventTypeCard.tsx @@ -7,9 +7,17 @@ import { CardHeader, CardTitle, } from "@ui/components/ui/card"; -import { CopyButton } from "@ui/components/ui/clipboard"; +import { EmbedConfigModal } from "@ui/components/EmbedConfigModal"; import { EventType, EventTypeConfig, useEventTypes } from "@ui/hooks/event-types"; -import { CheckIcon, EditIcon, ExternalLinkIcon, TrashIcon, XIcon } from "lucide-react"; +import { + CheckIcon, + EditIcon, + ExternalLinkIcon, + SettingsIcon, + TrashIcon, + XIcon, +} from "lucide-react"; +import { useState } from "react"; import { useUser } from "src/providers/UserStoreProvider"; export function EventTypeCard({ @@ -21,7 +29,9 @@ export function EventTypeCard({ }) { const { toggleEventType, deleteEventType } = useEventTypes(); const user = useUser(); - const getPublicLink = (standardName: string | null) => { + const [isEmbedModalOpen, setIsEmbedModalOpen] = useState(false); + + const getPublicLink = (standardName: string | null, isEmbed: boolean = false) => { // Sanitize user name for URL (replace spaces with hyphens, lowercase, remove special chars) const sanitizedUserName = user.name ?.toLowerCase() @@ -31,10 +41,12 @@ export function EventTypeCard({ const shortUserId = user.id.substring(0, 6); // Construct the public booking URL const baseUrl = window.location.origin; - const publicUrl = `${baseUrl}/book/${sanitizedUserName}-${shortUserId}/${standardName}`; - - return publicUrl; + if (isEmbed) { + return `${baseUrl}/embed/book/${sanitizedUserName}-${shortUserId}/${standardName}`; + } + return `${baseUrl}/book/${sanitizedUserName}-${shortUserId}/${standardName}`; }; + return ( {eventType.name}
+ -
); } 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/ui-library/hooks/use-clipboard.ts b/ui/src/components/ui/hooks/use-clipboard.ts similarity index 80% rename from ui/src/ui-library/hooks/use-clipboard.ts rename to ui/src/components/ui/hooks/use-clipboard.ts index 447deae..ab0136c 100644 --- a/ui/src/ui-library/hooks/use-clipboard.ts +++ b/ui/src/components/ui/hooks/use-clipboard.ts @@ -1,6 +1,3 @@ -/** - * From https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/hooks/src/use-clipboard/use-clipboard.ts - */ import React from "react"; export function useCopyToClipboard({ timeout = 2000 } = {}) { @@ -21,7 +18,9 @@ export function useCopyToClipboard({ timeout = 2000 } = {}) { .then(() => handleCopyResult(true)) .catch((err) => setError(err)); } else { - setError(new Error("useCopyToClipboard: navigator.clipboard is not supported")); + setError( + new Error("useCopyToClipboard: navigator.clipboard is not supported") + ); } }; diff --git a/ui/src/hooks/availabilities.ts b/ui/src/hooks/availabilities.ts index 73c8359..8430660 100644 --- a/ui/src/hooks/availabilities.ts +++ b/ui/src/hooks/availabilities.ts @@ -4,6 +4,7 @@ import { supabase } from "@ui/hooks/auth"; import { queryClient } from "@ui/lib/api"; import { Database } from "@ui/types/database.types"; import { useEffect, useState } from "react"; +import { toast } from "src/lib/toast"; export type TimeRange = { start: string; @@ -33,20 +34,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 +87,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); } @@ -101,17 +106,37 @@ export function useAvailabilities() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["availabilities"] }); + toast.add({ + title: "Succès", + description: "Disponibilités mises à jour avec succès", + type: "success", + }); + }, + onError: () => { + toast.add({ + title: "Erreur", + description: "Erreur lors de la mise à jour des disponibilités", + type: "error", + }); }, }); - 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, }, @@ -123,14 +148,22 @@ export function useAvailabilities() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["availabilities"] }); + toast.add({ + title: "Succès", + description: "Exception supprimée avec succès", + type: "success", + }); }, }); - 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 +174,6 @@ export function useAvailabilities() { setDraftAvailabilities, exceptions: (availabilities?.exceptions as Exception[] | null) || [], deleteException, + isModified: draftAvailabilities !== availabilities?.availability_data, }; } diff --git a/ui/src/pages/EmbeddedBookingPage.tsx b/ui/src/pages/EmbeddedBookingPage.tsx index 12827d3..a86c3ba 100644 --- a/ui/src/pages/EmbeddedBookingPage.tsx +++ b/ui/src/pages/EmbeddedBookingPage.tsx @@ -20,19 +20,173 @@ import { UserIcon, } from "lucide-react"; import { useState } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; import { twMerge } from "tailwind-merge"; +type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange"; + +// Color scheme configurations +const backgroundColors = { + black: { + gradient: "from-gray-900 via-gray-800 to-gray-900", + overlay: "from-gray-600/5 via-transparent to-gray-600/10", + iconBg: "bg-gray-700/50", + iconBorder: "border-gray-500/20", + iconText: "text-gray-400", + borderColor: "border-gray-700/50", + linkColor: "text-gray-400/60", + avatarBorder: "border-gray-500/30", + }, + white: { + gradient: "from-gray-50 via-white to-gray-50", + overlay: "from-gray-200/5 via-transparent to-gray-200/10", + iconBg: "bg-gray-100/50", + iconBorder: "border-gray-300/20", + iconText: "text-gray-600", + borderColor: "border-gray-200/50", + linkColor: "text-gray-500/60", + avatarBorder: "border-gray-300/30", + }, + blue: { + gradient: "from-blue-900 via-blue-800 to-blue-900", + overlay: "from-blue-600/5 via-transparent to-blue-600/10", + iconBg: "bg-blue-700/50", + iconBorder: "border-blue-500/20", + iconText: "text-blue-400", + borderColor: "border-blue-700/50", + linkColor: "text-blue-400/60", + avatarBorder: "border-blue-500/30", + }, + purple: { + gradient: "from-gray-900 via-gray-800 to-gray-900", + overlay: "from-purple-600/5 via-transparent to-purple-600/10", + iconBg: "bg-gray-700/50", + iconBorder: "border-purple-500/20", + iconText: "text-purple-400", + borderColor: "border-gray-700/50", + linkColor: "text-purple-400/60", + avatarBorder: "border-purple-500/30", + }, + green: { + gradient: "from-green-900 via-green-800 to-green-900", + overlay: "from-green-600/5 via-transparent to-green-600/10", + iconBg: "bg-green-700/50", + iconBorder: "border-green-500/20", + iconText: "text-green-400", + borderColor: "border-green-700/50", + linkColor: "text-green-400/60", + avatarBorder: "border-green-500/30", + }, + orange: { + gradient: "from-orange-900 via-orange-800 to-orange-900", + overlay: "from-orange-600/5 via-transparent to-orange-600/10", + iconBg: "bg-orange-700/50", + iconBorder: "border-orange-500/20", + iconText: "text-orange-400", + borderColor: "border-orange-700/50", + linkColor: "text-orange-400/60", + avatarBorder: "border-orange-500/30", + }, +}; + +const buttonColors = { + black: { + selected: "bg-gray-900 dark:bg-white text-white dark:text-gray-900", + ring: "ring-gray-500/50", + todayBorder: "border-gray-500/30", + hoverBorder: "hover:border-gray-500/50", + slotHover: + "hover:bg-gray-900 dark:hover:bg-white hover:text-white dark:hover:text-gray-900 hover:border-gray-500/50", + modalBorder: "border-gray-500/20", + modalIcon: "text-gray-600 dark:text-gray-400", + }, + white: { + selected: "bg-white dark:bg-gray-100 text-gray-900 dark:text-gray-900", + ring: "ring-gray-300/50", + todayBorder: "border-gray-300/30", + hoverBorder: "hover:border-gray-300/50", + slotHover: + "hover:bg-white dark:hover:bg-gray-100 hover:text-gray-900 dark:hover:text-gray-900 hover:border-gray-300/50", + modalBorder: "border-gray-300/20", + modalIcon: "text-gray-600 dark:text-gray-500", + }, + blue: { + selected: "bg-blue-600 dark:bg-blue-500 text-white dark:text-white", + ring: "ring-blue-500/50", + todayBorder: "border-blue-500/30", + hoverBorder: "hover:border-blue-500/50", + slotHover: + "hover:bg-blue-600 dark:hover:bg-blue-500 hover:text-white dark:hover:text-white hover:border-blue-500/50", + modalBorder: "border-blue-500/20", + modalIcon: "text-blue-600 dark:text-blue-400", + }, + purple: { + selected: "bg-purple-600 dark:bg-purple-500 text-white dark:text-white", + ring: "ring-purple-500/50", + todayBorder: "border-purple-500/30", + hoverBorder: "hover:border-purple-500/50", + slotHover: + "hover:bg-purple-600 dark:hover:bg-purple-500 hover:text-white dark:hover:text-white hover:border-purple-500/50", + modalBorder: "border-purple-500/20", + modalIcon: "text-purple-600 dark:text-purple-400", + }, + green: { + selected: "bg-green-600 dark:bg-green-500 text-white dark:text-white", + ring: "ring-green-500/50", + todayBorder: "border-green-500/30", + hoverBorder: "hover:border-green-500/50", + slotHover: + "hover:bg-green-600 dark:hover:bg-green-500 hover:text-white dark:hover:text-white hover:border-green-500/50", + modalBorder: "border-green-500/20", + modalIcon: "text-green-600 dark:text-green-400", + }, + orange: { + selected: "bg-orange-600 dark:bg-orange-500 text-white dark:text-white", + ring: "ring-orange-500/50", + todayBorder: "border-orange-500/30", + hoverBorder: "hover:border-orange-500/50", + slotHover: + "hover:bg-orange-600 dark:hover:bg-orange-500 hover:text-white dark:hover:text-white hover:border-orange-500/50", + modalBorder: "border-orange-500/20", + modalIcon: "text-orange-600 dark:text-orange-400", + }, +}; + +// Automatically infer text color based on background luminance +const getTextColorFromBackground = (variant: ColorVariant): string => { + // Dark backgrounds need light text, light backgrounds need dark text + const darkBackgrounds = ["black", "blue", "purple", "green", "orange"]; + return darkBackgrounds.includes(variant) ? "text-white" : "text-gray-900"; +}; + +// Automatically infer muted text color based on background luminance +const getMutedTextColorFromBackground = (variant: ColorVariant): string => { + // Dark backgrounds need lighter muted text, light backgrounds need darker muted text + const darkBackgrounds = ["black", "blue", "purple", "green", "orange"]; + return darkBackgrounds.includes(variant) ? "text-gray-400" : "text-gray-600"; +}; + export function EmbeddedBookingPage() { const { user_info, event_type_standard_name } = useParams<{ user_info: string; event_type_standard_name: string; }>(); + const [searchParams] = useSearchParams(); const { mutateAsync: signUpWithoutPassword } = useSignUpWithoutPassword(); const { session } = useSession(); const user = useMaybeUser(); const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1); + // Get variants from URL params or props, with fallback to purple + const backgroundVariant = (searchParams.get("backgroundVariant") as ColorVariant) || "black"; + const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple"; + + // Get color schemes based on variants + const bgColors = backgroundColors[backgroundVariant]; + const btnColors = buttonColors[buttonVariant]; + const txtColor = getTextColorFromBackground(backgroundVariant); + const mutedTxtColor = getMutedTextColorFromBackground(backgroundVariant); + const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots( shortUserId || "", event_type_standard_name || "" @@ -41,7 +195,6 @@ export function EmbeddedBookingPage() { const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(); const userProfile = publicSlots?.user; - console.log(userProfile); const eventType = publicSlots?.eventType; const slotsData = publicSlots?.slots || {}; @@ -282,28 +435,42 @@ export function EmbeddedBookingPage() { } return ( -
-
+
+
{/* Left Side - Event Details */} -
- {/* Subtle purple accent overlay */} -
+
+ {/* Subtle accent overlay */} +
- {/* Logo */} -
- Xtablo -
- {/* User Profile */}
{(userProfile as { name: string; avatar_url?: string })?.avatar_url ? ( {userProfile?.name ) : ( -
+
)} @@ -315,46 +482,62 @@ export function EmbeddedBookingPage() {

{eventType?.name || "Type d'événement"}

{eventType?.description && ( -

+ {eventType.description} -

+ )}
{eventType?.duration && (
-
- +
+

Durée

-

- {formatDuration(eventType.duration)} -

+

{formatDuration(eventType.duration)}

)} {eventType?.price && (
-
- +
+

Prix

-

{eventType.price}€

+

{eventType.price}€

)} {eventType?.location && (
-
- +
+

Lieu

-

{eventType.location}

+

{eventType.location}

)} @@ -362,14 +545,18 @@ export function EmbeddedBookingPage() {
{/* Footer */} -
+
+ {/* Logo */} +
+ Xtablo +
Powered by{" "} XTablo @@ -434,11 +621,11 @@ export function EmbeddedBookingPage() { isPastDate(date) ? "text-gray-300 dark:text-gray-600 cursor-not-allowed" : selectedDate?.toDateString() === date.toDateString() - ? "bg-gray-900 dark:bg-white text-white dark:text-gray-900 font-semibold shadow-md ring-2 ring-purple-500/50" + ? `${btnColors.selected} font-semibold shadow-md ring-2 ${btnColors.ring}` : isToday(date) - ? "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold border border-purple-500/30" + ? `bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold border ${btnColors.todayBorder}` : hasAvailableSlots(date) - ? "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-purple-500/50 border border-gray-200 dark:border-gray-600" + ? `text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 ${btnColors.hoverBorder} border border-gray-200 dark:border-gray-600` : "text-gray-400 dark:text-gray-500 cursor-not-allowed" )} > @@ -480,7 +667,10 @@ export function EmbeddedBookingPage() { key={index} variant="outline" size="sm" - className="w-full justify-center text-sm py-2 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:bg-gray-900 dark:hover:bg-white hover:text-white dark:hover:text-gray-900 hover:border-purple-500/50 transition-all" + className={twMerge( + "w-full justify-center text-sm py-2 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600 transition-all", + btnColors.slotHover + )} onClick={() => handleSlotClick(selectedDate, slot)} > {slot.time} @@ -516,9 +706,14 @@ export function EmbeddedBookingPage() { width="md" > {selectedSlot && ( -
+
- + {selectedSlot.date.toLocaleDateString("fr-FR", { weekday: "long", @@ -528,7 +723,7 @@ export function EmbeddedBookingPage() {
- + {selectedSlot.slot.time}
diff --git a/ui/src/pages/availabilities.tsx b/ui/src/pages/availabilities.tsx index 624f789..93a1a0c 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,21 @@ export function AvailabilitiesPage() {
- + }); + }} + > + Enregistrer + + )} + + + +
); } diff --git a/ui/src/ui-library/avatar.tsx b/ui/src/ui-library/avatar.tsx deleted file mode 100644 index 82e10b8..0000000 --- a/ui/src/ui-library/avatar.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from "react"; -import { twMerge } from "tailwind-merge"; -import { useImageLoadingStatus } from "./hooks/use-image-loading-status"; -import { FallbackAvatarProps, getFallbackAvatarDataUrl } from "./initials"; - -const AvatarContext = React.createContext<{ - badgeId: string; -} | null>(null); - -const sizes = { - 16: "[--size:--spacing(4)]", - 20: "[--size:--spacing(5)]", - 24: "[--size:--spacing(6)]", - 28: "[--size:--spacing(7)]", - 32: "[--size:--spacing(8)] [--badge-size:10px]", - 36: "[--size:--spacing(9)] [--badge-size:10px]", - 40: "[--size:--spacing(10)] [--badge-size:10px]", - 48: "[--size:--spacing(12)] [--badge-size:10px]", - 56: "[--size:--spacing(14)] [--badge-size:10px]", - 64: "[--size:--spacing(16)] [--badge-size:16px]", - 72: "[--size:--spacing(18)] [--badge-size:16px]", - 96: "[--size:--spacing(24)] [--badge-size:20px]", - 120: "[--size:--spacing(30)] [--badge-size:24px] [--badge-gap:3px]", - 128: "[--size:--spacing(34)] [--badge-size:26px] [--badge-gap:3px]", -}; - -export type AvatarProps = { - src?: string; - alt: string; - size?: keyof typeof sizes; -} & FallbackAvatarProps & - React.JSX.IntrinsicElements["div"]; - -export function Avatar({ - className, - children, - src, - alt, - size = 40, - fallback = "initials", - colorful, - background, - ...props -}: AvatarProps) { - const badgeId = React.useId(); - const avatarId = React.useId(); - const ariaLabelledby = [avatarId, children ? badgeId : ""].join(" "); - const status = useImageLoadingStatus(src); - - return ( - -
- {alt} - {children} -
-
- ); -} - -type AvatarBadgeProps = { - className?: string; - badge: React.ReactNode; -}; - -export const AvatarBadge = ({ badge, ...props }: AvatarBadgeProps) => { - const context = React.useContext(AvatarContext); - - if (!context) { - throw new Error(" is required"); - } - - return ( - [data-ui=icon]:not([class*=size-])]:size-full", - props.className, - ])} - > - {badge} - - ); -}; - -type AvatarGroupProps = { - reverse?: boolean; -} & React.JSX.IntrinsicElements["div"]; - -export function AvatarGroup({ - reverse = false, - className, - ...props -}: AvatarGroupProps & { - "aria-label": string; -}) { - return ( -
[role=img]:not([class*=ring-4])]:ring-2", - "[&>[role=img]:not([class*=ring-])]:ring-background", - reverse && "flex-row-reverse justify-end [&>[role=img]:last-of-type]:-me-2", - className - )} - /> - ); -} diff --git a/ui/src/ui-library/badge/badge.styles.ts b/ui/src/ui-library/badge/badge.styles.ts deleted file mode 100644 index 903a3e7..0000000 --- a/ui/src/ui-library/badge/badge.styles.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 9c8df8f..0000000 --- a/ui/src/ui-library/badge/badge.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index a1119b0..0000000 --- a/ui/src/ui-library/badge/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { Badge } from "./badge"; -export type { BadgeColor } from "./badge.styles"; -export { getBadgeStyles } from "./badge.styles"; diff --git a/ui/src/ui-library/breadcrumbs.tsx b/ui/src/ui-library/breadcrumbs.tsx deleted file mode 100644 index 3003d66..0000000 --- a/ui/src/ui-library/breadcrumbs.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - composeRenderProps, - LinkProps, - Breadcrumb as RACBreadcrumb, - BreadcrumbProps as RACBreadcrumbProps, - Breadcrumbs as RACBreadcrumbs, - BreadcrumbsProps as RACBreadcrumbsProps, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { ChevronRightIcon } from "./icons"; -import { Link } from "./link"; - -export function Breadcrumbs({ className, ...props }: RACBreadcrumbsProps) { - return ; -} - -type BreadcrumbProps = RACBreadcrumbProps & LinkProps; - -export function Breadcrumb(props: BreadcrumbProps) { - return ( - { - return twMerge("flex items-center gap-1", className); - } - )} - > - { - return twMerge( - "underline underline-offset-2", - isDisabled && "opacity-100", - !isHovered && "decoration-muted" - ); - }} - /> - {props.href && } - - ); -} diff --git a/ui/src/ui-library/button.tsx b/ui/src/ui-library/button.tsx deleted file mode 100644 index 1d1105b..0000000 --- a/ui/src/ui-library/button.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import React from "react"; -import { - composeRenderProps, - Button as RACButton, - ButtonProps as RACButtonProps, - ToggleButton as RACToggleButton, - ToggleButtonGroup as RACToggleButtonGroup, - ToggleButtonProps as RACToggleButtonProps, - ToggleButtonGroupProps, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { SpinnerIcon } from "./icons"; -import { AsChildProps, Slot } from "./slot"; -import { NonFousableTooltipTarget, Tooltip, TooltipTrigger } from "./tooltip"; - -type Color = "accent" | "success" | "destructive"; - -type Size = "sm" | "lg"; - -type Variant = "solid" | "outline" | "plain" | "unstyle"; - -export type ButtonStyleProps = { - color?: Color; - size?: Size; - isCustomPending?: boolean; - isIconOnly?: boolean; - pendingLabel?: string; - variant?: Variant; -}; - -export type ButtonWithAsChildProps = AsChildProps< - RACButtonProps & { - tooltip?: React.ReactNode; - allowTooltipOnDisabled?: boolean; - } -> & - ButtonStyleProps; - -export type ButtonProps = RACButtonProps & - ButtonStyleProps & { - tooltip?: string; - }; - -const buttonStyle = ({ - size, - color, - isIconOnly, - variant = "solid", - isPending, - isDisabled, - isFocusVisible, - isCustomPending, -}: ButtonStyleProps & { - isPending?: boolean; - isDisabled?: boolean; - isFocusVisible?: boolean; -}) => { - const base = [ - "relative rounded-md", - isFocusVisible ? "outline outline-2 outline-ring outline-offset-2" : "outline-hidden", - isDisabled && "opacity-50", - ]; - - if (variant === "unstyle") { - return base; - } - - const style = { - base, - variant: { - base: "group inline-flex gap-x-2 justify-center items-center font-semibold text-base/6 sm:text-sm/6", - solid: [ - "border border-transparent bg-[var(--btn-bg)]", - "[--btn-color:lch(from_var(--btn-bg)_calc((49.44_-_l)_*_infinity)_0_0)]", - "text-[var(--btn-color)]", - !isDisabled && "hover:opacity-90", - ], - outline: [ - "border text-[var(--btn-color)] shadow-xs", - !isDisabled && "hover:bg-zinc-50 dark:hover:bg-zinc-800", - ], - plain: ["text-[var(--btn-color)]", !isDisabled && "hover:bg-zinc-100 dark:hover:bg-zinc-800"], - }, - size: { - base: "[&_svg[data-ui=icon]:not([class*=size-])]:size-[var(--icon-size)]", - sm: [ - isIconOnly - ? "size-8 sm:size-7 [--icon-size:theme(size.5)] sm:[--icon-size:theme(size.4)]" - : "h-8 sm:h-7 [--icon-size:theme(size.3)] text-sm/6 sm:text-xs/6 px-3 sm:px-2", - ], - md: [ - // lg: 44px, sm:36px - "[--icon-size:theme(size.5)] sm:[--icon-size:theme(size.4)]", - isIconOnly - ? "p-[calc(--spacing(2.5)-1px)] sm:p-[calc(--spacing(1.5)-1px)] [&_svg[data-ui=icon]]:m-0.5 sm:[&_svg[data-ui=icon]]:m-1" - : "px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)] py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]", - ], - - lg: [ - "[--icon-size:theme(size.5)]", - isIconOnly - ? "p-[calc(--spacing(2.5)-1px)] [&_svg[data-ui=icon]]:m-0.5" - : "px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)]", - ], - }, - color: { - foreground: "[--btn-color:var(--color-foreground)]", - accent: "[--btn-color:var(--color-accent)]", - destructive: "[--btn-color:var(--color-destructive)]", - success: "[--btn-color:var(--color-success)]", - }, - iconColor: { - base: "[&:not(:hover)_svg[data-ui=icon]:not([class*=text-])]:text-[var(--icon-color)]", - solid: !isIconOnly && "[--icon-color:lch(from_var(--btn-color)_calc(0.85*l)_c_h)]", - outline: !isIconOnly && "[--icon-color:var(--color-muted)]", - plain: !isIconOnly && "[--icon-color:var(--color-muted)]", - }, - backgroundColor: { - accent: "[--btn-bg:var(--color-accent)]", - destructive: "[--btn-bg:var(--color-destructive)]", - success: "[--btn-bg:var(--color-success)]", - }, - }; - - return [ - style.base, - style.color[color ?? "foreground"], - style.variant.base, - style.variant[variant], - style.size.base, - style.size[size ?? "md"], - style.iconColor.base, - style.iconColor[variant], - style.backgroundColor[color ?? "accent"], - !isCustomPending && isPending && "text-transparent", - ]; -}; - -export const Button = React.forwardRef( - function Button(props, ref) { - if (props.asChild) { - return {props.children}; - } - - const { - tooltip, - allowTooltipOnDisabled, - children, - isCustomPending, - pendingLabel, - size, - color, - variant = "solid", - isIconOnly, - ...buttonProps - } = props; - - const button = ( - - twMerge([ - buttonStyle({ - size, - color, - isIconOnly, - variant, - isCustomPending, - ...renderProps, - }), - className, - ]) - )} - > - {(renderProps) => { - return ( - <> - {renderProps.isPending ? ( - <> - - - {typeof children === "function" ? children(renderProps) : children} - - - ) : typeof children === "function" ? ( - children(renderProps) - ) : ( - children - )} - - ); - }} - - ); - - if (tooltip) { - if (allowTooltipOnDisabled && buttonProps.isDisabled) { - return ( - - -
{button}
-
- {tooltip} -
- ); - } - - return ( - - {button} - {tooltip} - - ); - } - - return button; - } -); - -export function ToggleButton( - props: RACToggleButtonProps & - ButtonStyleProps & { - tooltip?: React.ReactNode; - allowTooltipOnDisabled?: boolean; - } -) { - const { variant, tooltip, allowTooltipOnDisabled, size, isIconOnly, color, ...buttonProps } = - props; - - const toggleButton = ( - { - return twMerge( - buttonStyle({ variant, size, isIconOnly, color, ...renderProps }), - className - ); - })} - /> - ); - - if (tooltip) { - if (allowTooltipOnDisabled && buttonProps.isDisabled) { - return ( - - -
{toggleButton}
-
- {tooltip} -
- ); - } - - return ( - - {toggleButton} - {tooltip} - - ); - } - - return toggleButton; -} - -const buttonGroupStyle = ({ - inline, - orientation = "horizontal", -}: { - inline?: boolean; - orientation?: "horizontal" | "vertical"; -}) => { - const style = { - base: [ - "group inline-flex w-max items-center", - "[&>*:not(:first-child):not(:last-child)]:rounded-none", - "[&>*[data-variant=solid]:not(:first-child)]:border-s-[lch(from_var(--btn-bg)_calc(l*0.85)_c_h)]", - ], - horizontal: [ - "[&>*:first-child]:rounded-e-none", - "[&>*:last-child]:rounded-s-none", - "[&>*:not(:last-child)]:border-e-0", - inline && "shadow-xs [&>*:not(:first-child)]:border-s-0 *:shadow-none", - ], - vertical: [ - "flex-col", - "[&>*:first-child]:rounded-b-none", - "[&>*:last-child]:rounded-t-none", - "[&>*:not(:last-child)]:border-b-0", - - inline && "shadow-xs [&>*:not(:first-child)]:border-t-0 *:shadow-none", - ], - }; - - return [style.base, style[orientation]]; -}; - -export function ToggleButtonGroup({ - inline, - orientation = "horizontal", - ...props -}: ToggleButtonGroupProps & { - inline?: boolean; - orientation?: "horizontal" | "vertical"; -}) { - return ( - - twMerge(buttonGroupStyle({ inline, orientation }), className) - )} - /> - ); -} - -export function ButtonGroup({ - className, - inline, - orientation = "horizontal", - ...props -}: React.JSX.IntrinsicElements["div"] & { - inline?: boolean; - orientation?: "horizontal" | "vertical"; -}) { - return ( -
- ); -} diff --git a/ui/src/ui-library/calendar.tsx b/ui/src/ui-library/calendar.tsx deleted file mode 100644 index 87f44f8..0000000 --- a/ui/src/ui-library/calendar.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { CalendarDate, getLocalTimeZone, isToday } from "@internationalized/date"; -import { useDateFormatter } from "@react-aria/i18n"; -import { CalendarState } from "@react-stately/calendar"; -import React from "react"; -import { - CalendarCell, - CalendarGrid, - CalendarGridBody, - CalendarHeaderCell, - CalendarStateContext, - composeRenderProps, - DateValue, - Heading, - Calendar as RACCalendar, - CalendarGridHeader as RACCalendarGridHeader, - CalendarProps as RACCalendarProps, - Text, - useLocale, -} from "react-aria-components"; -import { twMerge } from "tailwind-merge"; -import { Button, ButtonGroup } from "./button"; -import { Label } from "./field"; -import { ChevronLeftIcon, ChevronRightIcon } from "./icons"; -import { NativeSelect, NativeSelectField } from "./native-select"; - -export type YearRange = number | [yearsBefore: number, yearsAfter: number]; - -export interface CalendarProps - extends Omit, "visibleDuration"> { - yearRange?: YearRange; - errorMessage?: string; -} - -export function Calendar({ - errorMessage, - yearRange, - ...props -}: CalendarProps) { - return ( - { - return twMerge("px-1 py-2.5", className); - })} - > - - - - - {(date) => { - return ( - { - return twMerge( - "relative flex size-10 cursor-default items-center justify-center rounded-lg text-sm outline-hidden", - isToday(date, getLocalTimeZone()) && "bg-zinc-100 dark:bg-zinc-800", - isHovered && "bg-zinc-100 dark:bg-zinc-800", - isPressed && "bg-accent/90 text-white", - isDisabled && "opacity-50", - isSelected && [ - "bg-accent text-sm text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]", - isHovered && "bg-accent dark:bg-accent", - isInvalid && "border-destructive bg-destructive text-white", - ], - isUnavailable && "text-destructive decoration-destructive line-through", - isFocusVisible && ["outline-ring outline", isSelected && "outline-offset-1"], - className - ); - } - )} - /> - ); - }} - - - {errorMessage && ( - - {errorMessage} - - )} - - ); -} - -// https://github.com/adobe/react-spectrum/discussions/3950#discussioncomment-4851719 -export function CalendarHeader({ yearRange }: { yearRange?: YearRange }) { - const { direction } = useLocale(); - const state = React.use(CalendarStateContext)!; - - return ( -
- {yearRange ? ( -
- - -
- ) : ( - - )} - - - - - - -
- ); -} - -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-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 ( -