Merge pull request #16 from artslidd/develop

develop
This commit is contained in:
Arthur Belleville 2025-10-19 18:45:48 +02:00 committed by GitHub
commit bb3f508776
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 656 additions and 6660 deletions

9
api/package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}
}

View file

@ -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"
);
});
});
});

View file

@ -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) {

View file

@ -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;
});

View file

@ -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<EmbedConfig>({
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 `<iframe
src="${embedUrl}"
width="1130"
height="700"
frameborder="0"
style="border: none; border-radius: 8px;"
></iframe>`;
};
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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Configurer l'intégration</DialogTitle>
</DialogHeader>
<div className="space-y-6 overflow-hidden">
{/* Configuration Section */}
<div className="space-y-4">
<div className="space-y-2">
<Label>Couleur de fond</Label>
<Select
value={embedConfig.backgroundVariant}
onValueChange={(value) =>
setEmbedConfig({ ...embedConfig, backgroundVariant: value as ColorVariant })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${option.color}`}></div>
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Couleur des boutons</Label>
<Select
value={embedConfig.buttonVariant}
onValueChange={(value) =>
setEmbedConfig({ ...embedConfig, buttonVariant: value as ColorVariant })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${option.color}`}></div>
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Preview Link */}
<div className="space-y-2 pt-4 border-t">
<Label>Lien d'aperçu</Label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={getEmbedUrl()}
className="flex-1 min-w-0 px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden text-ellipsis"
/>
<Button
variant="outline"
className="flex-shrink-0"
onClick={() => window.open(getEmbedUrl(), "_blank")}
>
Aperçu
</Button>
</div>
</div>
{/* Embed Code */}
<div className="space-y-2 min-w-0">
<Label>Code d'intégration</Label>
<TypographyMuted className="text-xs">
Copiez ce code pour intégrer le formulaire de réservation sur votre site web
</TypographyMuted>
<div className="relative min-w-0">
<div className="overflow-auto max-w-full">
<pre className="p-4 pr-16 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-xs whitespace-pre-wrap break-words w-full">
<code className="break-all">{generateEmbedCode()}</code>
</pre>
</div>
<div className="absolute top-2 right-2">
<CopyButton
copyValue={generateEmbedCode()}
label="Copier"
labelAfterCopied="Copié"
variant="outline"
size="sm"
/>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Fermer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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 (
<Card
key={eventType.id}
@ -44,6 +56,14 @@ export function EventTypeCard({
<CardTitle className="text-lg">{eventType.name}</CardTitle>
<CardAction>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => setIsEmbedModalOpen(true)}
aria-label="Configurer l'intégration"
>
<SettingsIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
@ -52,11 +72,6 @@ export function EventTypeCard({
>
<ExternalLinkIcon className="w-4 h-4" />
</Button>
<CopyButton
copyValue={getPublicLink(eventType.standardName ?? null)}
label="Copier le lien"
labelAfterCopied="Lien copié"
></CopyButton>
<Button
variant="ghost"
size="icon"
@ -129,6 +144,12 @@ export function EventTypeCard({
{eventType.isActive ? "Actif" : "Inactif"}
</Button>
</CardFooter>
<EmbedConfigModal
isOpen={isEmbedModalOpen}
onClose={() => setIsEmbedModalOpen(false)}
baseEmbedUrl={getPublicLink(eventType.standardName ?? null, true)}
/>
</Card>
);
}

View file

@ -9,15 +9,11 @@ import {
DropdownMenuTrigger,
} from "@ui/components/ui/dropdown-menu";
import { useUser } from "@ui/providers/UserStoreProvider";
// react-aria components (still used)
import { Disclosure, DisclosureControl, DisclosurePanel } from "@ui/ui-library/disclosure";
import { Link } from "@ui/ui-library/link";
import { isProd, isStaging } from "@ui/utils/helpers";
import { getXtabloIcon } from "@ui/utils/iconHelpers";
import {
CalendarCheckIcon,
CalendarIcon,
ChevronRightIcon,
Circle,
ConstructionIcon,
Kanban,
@ -32,7 +28,7 @@ import {
SquareKanban,
} from "lucide-react";
import { useState } from "react";
import { LinkProps, Separator } from "react-aria-components";
import { Separator } from "react-aria-components";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { ThemeSwitcher } from "./ThemeSwitcher";
@ -43,35 +39,14 @@ import { useLogout } from "src/hooks/auth";
type NavLinkItem = {
isActive?: boolean;
} & LinkProps;
children: React.ReactNode;
};
type NavLinkProps = NavLinkItem | { title: string; items: NavLinkItem[] };
type NavLinkProps = NavLinkItem;
function NavLink(props: NavLinkProps) {
if ("items" in props) {
return (
<Disclosure defaultExpanded>
<DisclosureControl className="group/control [&:not(:hover)]:text-white/50 mt-3 w-full ps-2.5 text-xs /6 font-semibold">
{props.title}{" "}
<ChevronRightIcon className="ms-auto hidden size-4 transition-all group-hover/control:flex group-aria-expanded:rotate-90" />
</DisclosureControl>
<DisclosurePanel>
<ul className="grid gap-y-1">
{props.items.map((item) => (
<li key={item.href}>
<NavLink {...item} />
</li>
))}
</ul>
</DisclosurePanel>
</Disclosure>
);
}
const { isActive, ...rest } = props;
function NavLink({ isActive, children }: NavLinkProps) {
return (
<Link
{...rest}
<div
className={twMerge(
"group w-full gap-x-3 overflow-hidden px-2.5 py-1.5 text-nowrap hover:bg-navbar-darker hover:no-underline focus-visible:outline-offset-0 [&>[data-ui=icon]:not([class*=size-])]:size-4.5",
"[&>[data-ui=notification-badge]]:bg-navbar-darker",
@ -88,8 +63,8 @@ function NavLink(props: NavLinkProps) {
: ["font-medium", "text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"]
)}
>
{props.children}
</Link>
{children}
</div>
);
}

View file

@ -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";

View file

@ -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<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
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<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View file

@ -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";

View file

@ -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")
);
}
};

View file

@ -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<void, Error, { exceptionIndex: number }>({
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<WeeklyAvailability | null>(null);
const [draftAvailabilities, setDraftAvailabilities] =
useState<WeeklyAvailability | null>(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,
};
}

View file

@ -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 (
<div className="w-[1130px] h-[700px] p-6 bg-gray-50 dark:bg-gray-900 overflow-hidden">
<div className="h-full bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex overflow-hidden">
<div className="w-[1130px] h-[700px] bg-transparent overflow-hidden">
<div className="h-full bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 flex overflow-hidden">
{/* Left Side - Event Details */}
<div className="w-[400px] bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 p-8 flex flex-col text-white relative overflow-hidden">
{/* Subtle purple accent overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-600/5 via-transparent to-purple-600/10 pointer-events-none"></div>
<div
className={twMerge(
"w-[400px] bg-gradient-to-br p-8 flex flex-col relative overflow-hidden",
bgColors.gradient,
txtColor
)}
>
{/* Subtle accent overlay */}
<div
className={twMerge(
"absolute inset-0 bg-gradient-to-br pointer-events-none",
bgColors.overlay
)}
></div>
<div className="relative z-10 flex flex-col h-full">
{/* Logo */}
<div className="mb-8">
<img src="/logo_white.png" alt="Xtablo" className="h-10 w-auto" />
</div>
{/* User Profile */}
<div className="mb-8">
{(userProfile as { name: string; avatar_url?: string })?.avatar_url ? (
<img
src={(userProfile as { name: string; avatar_url?: string }).avatar_url}
alt={userProfile?.name || "Profile"}
className="w-20 h-20 rounded-full object-cover border-4 border-purple-500/30 mb-4"
className={twMerge(
"w-20 h-20 rounded-full object-cover border-4 mb-4",
bgColors.avatarBorder
)}
/>
) : (
<div className="w-20 h-20 rounded-full bg-gray-700 flex items-center justify-center border-4 border-purple-500/30 mb-4">
<div
className={twMerge(
"w-20 h-20 rounded-full bg-gray-700 flex items-center justify-center border-4 mb-4",
bgColors.avatarBorder
)}
>
<UserIcon className="w-10 h-10 text-gray-300" />
</div>
)}
@ -315,46 +482,62 @@ export function EmbeddedBookingPage() {
<h3 className="text-xl font-bold mb-3">{eventType?.name || "Type d'événement"}</h3>
{eventType?.description && (
<p className="text-white/90 mb-6 text-sm leading-relaxed">
<TypographyMuted className={twMerge("mb-6 text-sm leading-relaxed", mutedTxtColor)}>
{eventType.description}
</p>
</TypographyMuted>
)}
<div className="space-y-4">
{eventType?.duration && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-700/50 border border-purple-500/20 flex items-center justify-center flex-shrink-0">
<ClockIcon className="w-5 h-5 text-purple-400" />
<div
className={twMerge(
"w-10 h-10 rounded-lg border flex items-center justify-center flex-shrink-0",
bgColors.iconBg,
bgColors.iconBorder
)}
>
<ClockIcon className={twMerge("w-5 h-5", bgColors.iconText)} />
</div>
<div>
<p className="text-xs text-gray-400">Durée</p>
<p className="font-semibold text-white">
{formatDuration(eventType.duration)}
</p>
<p className="font-semibold">{formatDuration(eventType.duration)}</p>
</div>
</div>
)}
{eventType?.price && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-700/50 border border-purple-500/20 flex items-center justify-center flex-shrink-0">
<span className="text-xl font-bold text-purple-400"></span>
<div
className={twMerge(
"w-10 h-10 rounded-lg border flex items-center justify-center flex-shrink-0",
bgColors.iconBg,
bgColors.iconBorder
)}
>
<span className={twMerge("text-xl font-bold", bgColors.iconText)}></span>
</div>
<div>
<p className="text-xs text-gray-400">Prix</p>
<p className="font-semibold text-white">{eventType.price}</p>
<p className="font-semibold">{eventType.price}</p>
</div>
</div>
)}
{eventType?.location && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-700/50 border border-purple-500/20 flex items-center justify-center flex-shrink-0">
<MapPinIcon className="w-5 h-5 text-purple-400" />
<div
className={twMerge(
"w-10 h-10 rounded-lg border flex items-center justify-center flex-shrink-0",
bgColors.iconBg,
bgColors.iconBorder
)}
>
<MapPinIcon className={twMerge("w-5 h-5", bgColors.iconText)} />
</div>
<div>
<p className="text-xs text-gray-400">Lieu</p>
<p className="font-semibold text-white text-sm">{eventType.location}</p>
<p className="font-semibold text-sm">{eventType.location}</p>
</div>
</div>
)}
@ -362,14 +545,18 @@ export function EmbeddedBookingPage() {
</div>
{/* Footer */}
<div className="mt-auto pt-6 border-t border-gray-700/50">
<div className={twMerge("mt-auto pt-6 border-t", bgColors.borderColor)}>
{/* Logo */}
<div className="mb-4">
<img src="/logo_white.png" alt="Xtablo" className="h-8 w-auto" />
</div>
<TypographyMuted className="text-xs text-gray-500">
Powered by{" "}
<a
href="https://www.xtablo.com"
target="_blank"
rel="noopener noreferrer"
className="text-purple-400/60 hover:underline"
className={twMerge("hover:underline", bgColors.linkColor)}
>
XTablo
</a>
@ -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 && (
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border border-purple-500/20">
<div
className={twMerge(
"mb-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border",
btnColors.modalBorder
)}
>
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
<CalendarIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<CalendarIcon className={twMerge("w-4 h-4", btnColors.modalIcon)} />
<Text className="font-medium">
{selectedSlot.date.toLocaleDateString("fr-FR", {
weekday: "long",
@ -528,7 +723,7 @@ export function EmbeddedBookingPage() {
</Text>
</div>
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100 mt-1">
<ClockIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<ClockIcon className={twMerge("w-4 h-4", btnColors.modalIcon)} />
<Text className="font-medium">{selectedSlot.slot.time}</Text>
</div>
</div>

View file

@ -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() {
<TabsContent value="availabilities" className="space-y-4">
<div className="flex gap-2 mb-4">
<Button
size="sm"
variant="default"
className="[--btn-bg:var(--color-green-800)]"
onClick={() => {
updateAvailabilities(
{
{isModified && (
<Button
size="sm"
variant="default"
className="[--btn-bg:var(--color-green-800)]"
onClick={() => {
updateAvailabilities({
updatedAvailabilities: draftAvailabilities,
newException: null,
},
{
onSuccess: () => {
toast.add({
title: "Succès",
description: "Disponibilités enregistrées avec succès",
type: "success",
});
},
onError: (err) => {
console.error(err);
toast.add({
title: "Erreur",
description: "Erreur lors de l'enregistrement des disponibilités",
type: "error",
});
},
}
);
}}
>
<SaveIcon /> Enregistrer
</Button>
});
}}
>
<SaveIcon /> Enregistrer
</Button>
)}
<Button
size="sm"
onClick={() => {
@ -391,19 +376,20 @@ export function AvailabilitiesPage() {
<div className="space-y-3 max-h-60 overflow-y-auto">
{DAYS_OF_WEEK.filter((day) => day !== sourceDayData?.day).map((day) => (
<Checkbox
key={day}
isSelected={selectedDays.includes(day)}
onChange={(isSelected) => {
if (isSelected) {
setSelectedDays([...selectedDays, day]);
} else {
setSelectedDays(selectedDays.filter((d) => d !== day));
}
}}
>
<Text className="font-medium">{DAYS_OF_WEEK_DISPLAY[day]}</Text>
</Checkbox>
<div key={day} className="flex items-center gap-2">
<Checkbox
checked={selectedDays.includes(day)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedDays([...selectedDays, day]);
} else {
setSelectedDays(selectedDays.filter((d) => d !== day));
}
}}
id={`day-${day}`}
/>
<Label htmlFor={`day-${day}`}>{DAYS_OF_WEEK_DISPLAY[day]}</Label>
</div>
))}
</div>

View file

@ -4,12 +4,20 @@ import { Input } from "@ui/components/ui/input";
import { Label } from "@ui/components/ui/label";
import { Textarea } from "@ui/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
import { useUser } from "@ui/providers/UserStoreProvider";
import { useState, useRef } from "react";
import { TypographyH3, TypographyMuted, TypographySmall } from "@ui/components/ui/typography";
import { useIntroduction } from "src/hooks/intros";
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "@ui/hooks/profile";
import { CameraIcon, Trash2Icon, UploadIcon } from "lucide-react";
import { CameraIcon, Loader2Icon, Trash2Icon, UploadIcon } from "lucide-react";
import { toast } from "@ui/lib/toast";
import { ImageCropDialog } from "@ui/components/ImageCropDialog";
@ -23,7 +31,7 @@ export default function SettingsPage() {
} = useIntroduction();
const { mutate: updateProfile, isPending: updateProfilePending } = useUpdateProfile();
const { mutate: uploadAvatar } = useUploadAvatar();
const { mutateAsync: removeAvatar } = useRemoveAvatar();
const { mutateAsync: removeAvatar, isPending: removeAvatarPending } = useRemoveAvatar();
const [firstName, setFirstName] = useState(user?.first_name || "");
const [lastName, setLastName] = useState(user?.last_name || "");
@ -31,6 +39,7 @@ export default function SettingsPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [imageToCrop, setImageToCrop] = useState<string | null>(null);
const [isCropDialogOpen, setIsCropDialogOpen] = useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -68,13 +77,18 @@ export default function SettingsPage() {
}
};
const handleRemoveAvatar = async () => {
const handleRemoveAvatarClick = () => {
setIsDeleteConfirmOpen(true);
};
const handleConfirmRemoveAvatar = async () => {
await removeAvatar();
setAvatarPreview(null);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setIsDeleteConfirmOpen(false);
};
const handleUploadAvatar = () => {
@ -187,7 +201,7 @@ export default function SettingsPage() {
<Button
variant="outline"
size="sm"
onClick={handleRemoveAvatar}
onClick={handleRemoveAvatarClick}
className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
>
<Trash2Icon className="w-4 h-4" />
@ -306,6 +320,36 @@ export default function SettingsPage() {
onCropComplete={handleCropComplete}
/>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteConfirmOpen} onOpenChange={setIsDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Supprimer la photo de profil</DialogTitle>
<DialogDescription>
Êtes-vous sûr de vouloir supprimer votre photo de profil ? Cette action est
irréversible.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDeleteConfirmOpen(false)}>
Annuler
</Button>
<Button
variant="outline"
onClick={handleConfirmRemoveAvatar}
className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
>
{removeAvatarPending ? (
<Loader2Icon className="w-4 h-4 animate-spin" />
) : (
<Trash2Icon className="w-4 h-4" />
)}
{removeAvatarPending ? "Suppression..." : "Supprimer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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 (
<AvatarContext.Provider value={{ badgeId }}>
<div
{...props}
aria-labelledby={ariaLabelledby}
role="img"
className={twMerge([
"relative isolate flex size-(--size) shrink-0 rounded-lg [--badge-gap:2px] [--badge-size:8px]",
status === "loaded" && "dark:outline dark:-outline-offset-1 dark:outline-white/10",
status === "loading" && "skeleton",
sizes[size],
className,
])}
>
<img
aria-hidden
id={avatarId}
src={
status === "error"
? getFallbackAvatarDataUrl({
fallback,
alt,
...(colorful === undefined ? { background } : { colorful }),
})
: src
}
alt={alt}
className={twMerge(
"size-full rounded-lg object-cover in-[.rounded-full]:rounded-full",
"[&:has(+[data-ui=avatar-badge])]:[mask:radial-gradient(circle_at_bottom_calc(var(--badge-size)/2)_right_calc(var(--badge-size)/2),_transparent_calc(var(--badge-size)/2_+_var(--badge-gap)_-_0.25px),_white_calc(var(--badge-size)/2_+_var(--badge-gap)_+_0.25px))]"
)}
/>
{children}
</div>
</AvatarContext.Provider>
);
}
type AvatarBadgeProps = {
className?: string;
badge: React.ReactNode;
};
export const AvatarBadge = ({ badge, ...props }: AvatarBadgeProps) => {
const context = React.useContext(AvatarContext);
if (!context) {
throw new Error("<AvatarContext.Provider> is required");
}
return (
<span
aria-hidden
data-ui="avatar-badge"
id={context.badgeId}
className={twMerge([
"bg-background absolute end-0 bottom-0 grid size-(--badge-size) place-items-center rounded-full bg-clip-content [&>[data-ui=icon]:not([class*=size-])]:size-full",
props.className,
])}
>
{badge}
</span>
);
};
type AvatarGroupProps = {
reverse?: boolean;
} & React.JSX.IntrinsicElements["div"];
export function AvatarGroup({
reverse = false,
className,
...props
}: AvatarGroupProps & {
"aria-label": string;
}) {
return (
<div
{...props}
role="group"
className={twMerge(
"isolate flex items-center -space-x-2 rtl:space-x-reverse",
"[&>[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
)}
/>
);
}

View file

@ -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,
];
}

View file

@ -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 <div {...props} className={twMerge(getBadgeStyles({ color, variant }), className)} />;
}

View file

@ -1,3 +0,0 @@
export { Badge } from "./badge";
export type { BadgeColor } from "./badge.styles";
export { getBadgeStyles } from "./badge.styles";

View file

@ -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<T extends object>({ className, ...props }: RACBreadcrumbsProps<T>) {
return <RACBreadcrumbs {...props} className={twMerge("flex gap-1", className)} />;
}
type BreadcrumbProps = RACBreadcrumbProps & LinkProps;
export function Breadcrumb(props: BreadcrumbProps) {
return (
<RACBreadcrumb
{...props}
className={composeRenderProps(
props.className as RACBreadcrumbProps["className"],
(className) => {
return twMerge("flex items-center gap-1", className);
}
)}
>
<Link
{...props}
className={({ isDisabled, isHovered }) => {
return twMerge(
"underline underline-offset-2",
isDisabled && "opacity-100",
!isHovered && "decoration-muted"
);
}}
/>
{props.href && <ChevronRightIcon className="size-4 text-muted" />}
</RACBreadcrumb>
);
}

View file

@ -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<HTMLButtonElement, ButtonWithAsChildProps>(
function Button(props, ref) {
if (props.asChild) {
return <Slot className={twMerge(buttonStyle(props))}>{props.children}</Slot>;
}
const {
tooltip,
allowTooltipOnDisabled,
children,
isCustomPending,
pendingLabel,
size,
color,
variant = "solid",
isIconOnly,
...buttonProps
} = props;
const button = (
<RACButton
{...buttonProps}
ref={ref}
data-variant={variant}
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge([
buttonStyle({
size,
color,
isIconOnly,
variant,
isCustomPending,
...renderProps,
}),
className,
])
)}
>
{(renderProps) => {
return (
<>
{renderProps.isPending ? (
<>
<SpinnerIcon
aria-label={pendingLabel}
className={twMerge("absolute", isCustomPending ? "sr-only" : "flex")}
/>
<span
className="contents"
{...(renderProps.isPending && { "aria-hidden": true })}
>
{typeof children === "function" ? children(renderProps) : children}
</span>
</>
) : typeof children === "function" ? (
children(renderProps)
) : (
children
)}
</>
);
}}
</RACButton>
);
if (tooltip) {
if (allowTooltipOnDisabled && buttonProps.isDisabled) {
return (
<TooltipTrigger>
<NonFousableTooltipTarget>
<div className="content">{button}</div>
</NonFousableTooltipTarget>
{tooltip}
</TooltipTrigger>
);
}
return (
<TooltipTrigger>
{button}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
);
}
return button;
}
);
export function ToggleButton(
props: RACToggleButtonProps &
ButtonStyleProps & {
tooltip?: React.ReactNode;
allowTooltipOnDisabled?: boolean;
}
) {
const { variant, tooltip, allowTooltipOnDisabled, size, isIconOnly, color, ...buttonProps } =
props;
const toggleButton = (
<RACToggleButton
{...buttonProps}
data-variant={variant}
className={composeRenderProps(props.className, (className, renderProps) => {
return twMerge(
buttonStyle({ variant, size, isIconOnly, color, ...renderProps }),
className
);
})}
/>
);
if (tooltip) {
if (allowTooltipOnDisabled && buttonProps.isDisabled) {
return (
<TooltipTrigger>
<NonFousableTooltipTarget>
<div className="content">{toggleButton}</div>
</NonFousableTooltipTarget>
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
);
}
return (
<TooltipTrigger>
{toggleButton}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
);
}
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 (
<RACToggleButtonGroup
{...props}
data-ui="button-group"
className={composeRenderProps(props.className, (className) =>
twMerge(buttonGroupStyle({ inline, orientation }), className)
)}
/>
);
}
export function ButtonGroup({
className,
inline,
orientation = "horizontal",
...props
}: React.JSX.IntrinsicElements["div"] & {
inline?: boolean;
orientation?: "horizontal" | "vertical";
}) {
return (
<div
{...props}
data-ui="button-group"
className={twMerge(buttonGroupStyle({ inline, orientation }), className)}
/>
);
}

View file

@ -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<T extends DateValue>
extends Omit<RACCalendarProps<T>, "visibleDuration"> {
yearRange?: YearRange;
errorMessage?: string;
}
export function Calendar<T extends DateValue>({
errorMessage,
yearRange,
...props
}: CalendarProps<T>) {
return (
<RACCalendar
{...props}
className={composeRenderProps(props.className, (className) => {
return twMerge("px-1 py-2.5", className);
})}
>
<CalendarHeader yearRange={yearRange} />
<CalendarGrid weekdayStyle="short" className="w-full border-separate border-spacing-y-1 px-2">
<CalendarGridHeader />
<CalendarGridBody>
{(date) => {
return (
<CalendarCell
date={date}
className={composeRenderProps(
"",
(
className,
{
isHovered,
isPressed,
isDisabled,
isSelected,
isInvalid,
isUnavailable,
isFocusVisible,
}
) => {
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
);
}
)}
/>
);
}}
</CalendarGridBody>
</CalendarGrid>
{errorMessage && (
<Text slot="errorMessage" className="text-destructive text-sm">
{errorMessage}
</Text>
)}
</RACCalendar>
);
}
// 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 (
<header
className={twMerge("flex w-full items-center py-1 ps-4 pe-2", yearRange ? "ps-2" : "ps-4")}
>
{yearRange ? (
<div className="flex flex-1 gap-x-2 text-left text-base/6 sm:text-sm/6 rtl:text-right">
<MonthDropdown state={state} />
<YearDropdown state={state} yearRange={yearRange} />
</div>
) : (
<Heading
level={2}
className="flex flex-1 text-left text-base/6 font-medium sm:text-sm/6 rtl:text-right"
aria-hidden
></Heading>
)}
<ButtonGroup>
<Button
slot="previous"
variant="plain"
size="sm"
isIconOnly
aria-label="Previous"
className="[&:not(:hover)]:text-muted/75 focus-visible:-outline-offset-2"
>
{direction === "rtl" ? (
<ChevronRightIcon className="sm:size-5" />
) : (
<ChevronLeftIcon className="sm:size-5" />
)}
</Button>
<Button
size="sm"
slot="next"
variant="plain"
isIconOnly
aria-label="Next"
className="[&:not(:hover)]:text-muted/75 focus-visible:-outline-offset-2"
>
{direction === "rtl" ? (
<ChevronLeftIcon className="sm:size-5" />
) : (
<ChevronRightIcon className="sm:size-5" />
)}
</Button>
</ButtonGroup>
</header>
);
}
export function CalendarGridHeader() {
return (
<RACCalendarGridHeader>
{(day) => (
<CalendarHeaderCell className="text-muted size-10 text-sm/6 font-normal">
{day}
</CalendarHeaderCell>
)}
</RACCalendarGridHeader>
);
}
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<HTMLSelectElement>) => {
const index = Number(e.target.value);
const date = years[index].value;
state.setFocusedDate(date);
};
return (
<NativeSelectField>
<Label className="sr-only">Year</Label>
<NativeSelect onChange={onChange} value={yearsBefore}>
{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.
<option key={i} value={i}>
{year.formatted}
</option>
))}
</NativeSelect>
</NativeSelectField>
);
}
function MonthDropdown({ state }: { state: CalendarState }) {
const months: Array<string> = [];
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<HTMLSelectElement>) => {
const value = Number(e.target.value);
const date = state.focusedDate.set({ month: value });
state.setFocusedDate(date);
};
return (
<NativeSelectField>
<Label className="sr-only">Month</Label>
<NativeSelect onChange={onChange} value={state.focusedDate.month}>
{months.map((month, i) => (
<option key={i} value={i + 1}>
{month}
</option>
))}
</NativeSelect>
</NativeSelectField>
);
}

View file

@ -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<RACCheckboxGroupProps, "children"> {
children?: ReactNode;
orientation?: "vertical" | "horizontal";
}
export function CheckboxGroup({ orientation = "vertical", ...props }: CheckboxGroupProps) {
return (
<RACCheckboxGroup
{...props}
data-orientation={orientation}
className={composeRenderProps(props.className, (className) => {
return twMerge(groupBox, className);
})}
/>
);
}
export function Checkboxes({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
return (
<div
data-ui="box"
className={twMerge(
"flex flex-col",
"group-data-[orientation=horizontal]:flex-row",
"group-data-[orientation=horizontal]:flex-wrap",
"has-data-[ui=description]:[&_label]:font-medium",
className
)}
{...props}
/>
);
}
export function CheckboxField({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
return (
<DescriptionProvider>
<div
{...props}
data-ui="field"
className={twMerge(
"group flex flex-col gap-y-1",
"has-[label[data-label-placement=start]]:[&_[data-ui=description]:not([class*=pe-])]:pe-16",
"has-[label[data-label-placement=end]]:[&_[data-ui=description]:not([class*=ps-])]:ps-7",
"has-data-[ui=description]:[&_label]:font-medium",
"has-[label[data-disabled]]:**:data-[ui=description]:opacity-50",
className
)}
/>
</DescriptionProvider>
);
}
interface CheckboxProps extends RACCheckboxProps {
labelPlacement?: "start" | "end";
render?: never;
}
export interface CustomRenderCheckboxProps extends Omit<RACCheckboxProps, "children"> {
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 (
<RACCheckbox
{...restProps}
aria-describedby={descriptionContext?.["aria-describedby"]}
className={composeRenderProps(props.className, (className, renderProps) => {
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}
</RACCheckbox>
);
}
const { labelPlacement = "end", ...restProps } = props;
return (
<RACCheckbox
{...restProps}
aria-describedby={descriptionContext?.["aria-describedby"]}
data-label-placement={labelPlacement}
className={composeRenderProps(props.className, (className, renderProps) => {
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 (
<>
<div
data-ui="checkbox"
className={twMerge([
"flex size-4.5 shrink-0 items-center justify-center rounded-sm border border-input sm:size-4",
labelPlacement === "end" ? "me-3" : "ms-3",
renderProps.isReadOnly && "opacity-50",
renderProps.isInvalid && "border-destructive dark:border-destructive",
(renderProps.isSelected || renderProps.isIndeterminate) &&
"border-accent bg-accent",
renderProps.isFocusVisible && "outline-ring outline outline-2 outline-offset-2",
])}
>
{renderProps.isIndeterminate ? (
<MinusIcon className="size-4 text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] sm:size-3.5" />
) : renderProps.isSelected ? (
<CheckIcon className="size-4 text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] sm:size-3.5" />
) : null}
</div>
{typeof props.children === "function" ? props.children(renderProps) : props.children}
</>
);
}}
</RACCheckbox>
);
}

View file

@ -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<object>) {
return (
<RACComboBox
{...props}
data-ui="comboBox"
className={composeRenderProps(props.className, (className) =>
twMerge(["w-full min-w-56", inputField, className])
)}
/>
);
}
export function ComboBoxGroup(props: GroupProps) {
return (
<Group
data-ui="control"
{...props}
className={composeRenderProps(props.className, (className) =>
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 = <ChevronDownIcon />,
}: {
triggerIcon?: React.ReactNode;
}) {
return (
<Button
isIconOnly
size="sm"
data-ui="trigger"
variant="plain"
className="text-muted group-hover/combobox:text-foreground"
>
{triggerIcon}
</Button>
);
}
export function ComboBoxClearButton({ onPress }: { onPress?: ButtonProps["onPress"] }) {
const state = React.useContext(ComboBoxStateContext);
return (
<Button
className={twMerge(
"[&:not(:hover)]:text-muted",
"not-last:-me-1",
state?.inputValue ? "visible focus-visible:-outline-offset-2" : "invisible"
)}
slot={null}
data-ui="clear"
size="sm"
isIconOnly
variant="plain"
onPress={(e) => {
state?.setSelectedKey(null);
onPress?.(e);
}}
>
<XIcon aria-label="Clear" className="size-4 sm:size-[calc(--spacing(4)-1px)]" />
</Button>
);
}
export const ComboBoxPopover = SelectPopover;
export const ComboBoxSection = SelectSection;
export const ComboBoxListBox = SelectListBox;
export const ComboBoxListItemLabel = SelectListItemLabel;
export const ComboBoxListItemDescription = SelectListItemDescription;

View file

@ -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<T extends DateValue> extends RACDateFieldProps<T> {}
export function DateField<T extends DateValue>(props: DateFieldProps<T>) {
return (
<RACDateField
{...props}
className={composeRenderProps(props.className, (className, { isDisabled }) => {
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<RACDateInputProps, "children">;
export function DateInput(props: DateInputProps) {
return (
<RACDateInput
{...props}
data-ui="control"
className={composeRenderProps(
props.className,
(className, { isInvalid, isFocusWithin, isHovered, isDisabled }) =>
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) => (
<DateSegment
data-ui="date-segment"
segment={segment}
className={twMerge(
"inline rounded-sm px-0.5 caret-transparent outline-0 data-[type=literal]:px-0",
"data-placeholder:text-muted data-placeholder:italic",
"focus:bg-accent focus:text-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)] focus:data-placeholder:text-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)]"
)}
/>
)}
</RACDateInput>
);
}

View file

@ -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<T extends DateValue> extends RACDatePickerProps<T> {}
export function DatePicker<T extends DateValue>(props: DatePickerProps<T>) {
return (
<RACDatePicker
{...props}
className={composeRenderProps(props.className, (className) => {
return twMerge(inputField, className);
})}
/>
);
}
export function DatePickerInput({
yearRange,
...props
}: DateInputProps & { yearRange?: YearRange }) {
return (
<>
<Group
data-ui="control"
{...props}
className={["group", "grid w-auto min-w-52", "grid-cols-[1fr_auto]"].join(" ")}
>
<DateInput
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge("col-span-full", "row-start-1", "sm:pe-8", "pe-9", className)
)}
/>
<Button
variant="plain"
size="sm"
isIconOnly
data-ui="trigger"
className={[
"me-1",
"focus-visible:-outline-offset-1",
"row-start-1",
"-col-end-1",
"place-self-center",
"not-disabled:hover:bg-transparent",
"not-disabled:not-hover:text-muted",
].join(" ")}
>
<CalendarIcon />
</Button>
</Group>
<Popover placement="bottom" className="rounded-lg">
<Dialog>
<Calendar yearRange={yearRange} />
</Dialog>
</Popover>
</>
);
}
export function DatePickerButton({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const { locale } = useLocale();
const state = React.useContext(DatePickerStateContext);
const formattedDate = state?.formatValue(locale, {});
return (
<>
<Group data-ui="control">
<Button
className={twMerge(
"w-full min-w-52 flex-1 justify-between px-3 leading-6 font-normal",
className
)}
variant="outline"
>
{formattedDate === "" ? (
<span className="text-muted">{children}</span>
) : (
<span>{formattedDate}</span>
)}
<CalendarIcon className="text-muted group-hover:text-foreground" />
</Button>
<DateInput className="hidden" aria-hidden />
</Group>
<Popover placement="bottom" className="rounded-lg">
<Dialog>
<Calendar />
</Dialog>
</Popover>
</>
);
}

View file

@ -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<T extends DateValue> extends AriaDateRangePickerProps<T> {}
export function DateRangePicker<T extends DateValue>({ ...props }: DateRangePickerProps<T>) {
return (
<AriaDateRangePicker
{...props}
className={composeTailwindRenderProps(props.className, inputField)}
/>
);
}
export function DateRangePickerInput() {
const { locale } = useLocale();
const state = React.useContext(DateRangePickerStateContext);
const formattedValue = state?.formatValue(locale, {});
return (
<>
<Group
data-ui="control"
className={({ isFocusWithin }) =>
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"
)
}
>
<DateInput
slot="start"
className={[
"flex min-w-fit border-none focus-within:ring-0",
"[&:has([data-ui=date-segment][aria-readonly])]:bg-transparent",
"dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-transparent",
].join(" ")}
/>
<span
aria-hidden="true"
className="text-muted place-self-center group-data-disabled:opacity-50"
>
</span>
<DateInput
slot="end"
className={[
"flex min-w-fit flex-1 border-none opacity-100 focus-within:ring-0",
"[&:has([data-ui=date-segment][aria-readonly])]:bg-transparent",
"dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-transparent",
].join(" ")}
/>
<Button
variant="plain"
isIconOnly
size="sm"
className="text-muted group-hover:text-foreground me-1 justify-self-end focus-visible:-outline-offset-1"
>
<CalendarIcon />
</Button>
</Group>
<Popover placement="bottom" className="rounded-xl">
<Dialog>
<RangeCalendar />
</Dialog>
</Popover>
</>
);
}
export function DateRangePickerButton({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const { locale } = useLocale();
const state = React.useContext(DateRangePickerStateContext);
const formattedValue = state?.formatValue(locale, {});
return (
<>
<Group data-ui="control">
<Button
variant="outline"
className={twMerge("border-input w-full min-w-64 px-0 font-normal sm:px-0", className)}
>
<div
className={twMerge(
"grid w-full items-center",
formattedValue ? "grid grid-cols-[1fr_16px_1fr_36px]" : "grid-cols-[1fr_36px]"
)}
>
{formattedValue ? (
<>
<span className="min-w-fit px-3 text-base/6 sm:text-sm/6">
{formattedValue.start}
</span>
<span
aria-hidden="true"
className="text-muted place-self-center group-data-disabled:opacity-50"
>
</span>
<span className="min-w-fit px-3 text-base/6 sm:text-sm/6">
{formattedValue.end}
</span>
</>
) : (
<span className="text-muted justify-self-start px-3">{children}</span>
)}
<CalendarIcon className="text-muted group-hover:text-foreground place-self-center" />
</div>
</Button>
<DateInput slot="start" aria-hidden className="hidden" />
<DateInput slot="end" aria-hidden className="hidden" />
</Group>
<Popover placement="bottom" className="rounded-xl">
<Dialog>
<RangeCalendar />
</Dialog>
</Popover>
</>
);
}

View file

@ -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 (
<RACDialog
{...props}
role={(role ?? alert) ? "alertdialog" : "dialog"}
className={twMerge(
"relative flex max-h-[inherit] flex-col overflow-auto outline-hidden [&:has([data-ui=dialog-body])]:overflow-hidden",
"[&:not(:has([data-ui=dialog-header]))>[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<HTMLHeadingElement, DialogHeaderProps>(
function DialogTitle({ level = 2, ...props }, ref) {
return <Heading {...props} ref={ref} slot="title" level={level} />;
}
);
export function DialogHeader({ className, ...props }: DialogHeaderProps) {
const headerRef = React.useRef<HTMLHeadingElement>(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") ? (
<DialogTitle
{...props}
data-ui="dialog-header"
ref={headerRef}
className={twMerge("ps-6 pe-10 pt-6 pb-2", className)}
/>
) : (
<div
ref={headerRef}
data-ui="dialog-header"
className={twMerge("relative flex w-full flex-col ps-6 pe-10 pt-6 pb-2", className)}
{...props}
>
{props.children}
</div>
);
}
export function DialogBody({ className, children, ...props }: React.JSX.IntrinsicElements["div"]) {
return (
<div
{...props}
data-ui="dialog-body"
className={twMerge(
"flex flex-1 flex-col overflow-auto px-6",
"max-h-[calc(var(--visual-viewport-height)-var(--visual-viewport-vertical-padding)-var(--dialog-header-height,0px)-var(--dialog-footer-height,0px))]",
className
)}
>
{React.Children.toArray(children).every((child) => typeof child === "string") ? (
<Text>{children}</Text>
) : (
children
)}
</div>
);
}
export function DialogFooter({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
const footerRef = React.useRef<HTMLDivElement>(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 (
<div
{...props}
data-ui="dialog-footer"
ref={footerRef}
className={twMerge(
"mt-auto flex flex-col flex-col-reverse justify-end gap-3 p-6 sm:flex-row",
className
)}
/>
);
}
export function DialogCloseButton({ variant = "plain", ...props }: ButtonProps) {
if (props.children) {
return <Button {...props} slot="close" variant={variant} />;
}
const { size = "lg", "aria-label": ariaLabel, isIconOnly = true, ...restProps } = props;
return (
<Button
{...restProps}
slot="close"
isIconOnly={isIconOnly}
variant={variant}
size={size}
className={composeRenderProps(props.className, (className) =>
twMerge("text-muted/75 hover:text-foreground absolute end-2 top-3 p-1.5", className)
)}
>
<XIcon aria-label={ariaLabel ?? "Close"} />
</Button>
);
}

View file

@ -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 (
<RACDisclosureGroup
{...props}
className={composeRenderProps(props.className, (className) => {
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 (
<RACDisclosurePanel {...props}>
{React.Children.toArray(children).every((child) => typeof child === "string") ? (
<Text>{children}</Text>
) : (
children
)}
</RACDisclosurePanel>
);
}
export function DisclosureControl(props: ButtonProps) {
return (
<Button
{...props}
slot="trigger"
className={composeRenderProps(props.className, (className, { isFocusVisible }) => {
return twMerge([
"group [&_svg[data-ui=icon]:not(:hover)]:text-muted mb-2 flex items-center gap-x-2 rounded-sm outline-hidden [&_svg[data-ui=icon]:not([class*=size-])]:size-5",
isFocusVisible && ["outline", "outline-2", "outline-ring", "outline-offset-2"],
className,
]);
})}
/>
);
}

View file

@ -1,23 +0,0 @@
import { composeRenderProps, DropZoneProps, DropZone as RACDropZone } from "react-aria-components";
import { twMerge } from "tailwind-merge";
export function DropZone(props: DropZoneProps) {
return (
<RACDropZone
{...props}
className={composeRenderProps(
props.className,
(className, { isDropTarget, isDisabled, isFocusVisible }) =>
twMerge(
"sm:min-w-96",
"flex shrink-0 flex-col items-center justify-center rounded-md",
"border-input border border-dashed p-2",
isDisabled && "opacity-50",
isDropTarget && "bg-accent/15 dark:bg-accent/75",
(isDropTarget || isFocusVisible) && "border-ring ring-ring border-solid ring-1",
className
)
)}
/>
);
}

View file

@ -1,55 +0,0 @@
import { TextProps } from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { Heading, HeadingProps } from "./heading";
import { Text } from "./text";
export function EmptyState({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
return (
<div
{...props}
className={twMerge(
"flex h-full w-full flex-col items-center justify-center gap-1 p-4 text-center @container",
className
)}
/>
);
}
export function EmptyStateIcon({
className,
children,
...props
}: React.JSX.IntrinsicElements["div"]) {
return (
<div
{...props}
className={twMerge(
"mb-2 flex max-w-32 items-center justify-center @md:max-w-40",
"[&>svg:not([class*=text-])]:text-muted [&>svg]:h-auto [&>svg]:min-w-12 [&>svg]:max-w-full",
className
)}
>
{children}
</div>
);
}
export function EmptyStateHeading({ className, level = 2, ...props }: HeadingProps) {
return (
// @ts-ignore
<Heading {...props} level={level} className={twMerge("text-balance", className)} />
);
}
export function EmptyStateDescription({ className, ...props }: TextProps) {
return <Text {...props} className={twMerge("max-w-prose text-balance", className)} />;
}
export function EmptyStateActions({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
return (
<div
{...props}
className={twMerge("mt-3 flex flex-col items-center justify-center gap-4 p-2", className)}
/>
);
}

View file

@ -1,188 +0,0 @@
import React from "react";
import {
composeRenderProps,
FieldErrorProps,
GroupContext,
InputProps,
LabelContext,
LabelProps,
FieldError as RACFieldError,
Input as RACInput,
Label as RACLabel,
Text as RACText,
TextArea as RACTextArea,
TextAreaProps as RACTextAreaProps,
TextField as RACTextField,
TextFieldProps as RACTextFieldProps,
TextProps,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { Text } from "./text";
import { DisplayLevel, displayLevels, inputField } from "./utils";
// https://react-spectrum.adobe.com/react-aria/Group.html#advanced-customization
export function LabeledGroup({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
const labelId = React.useId();
return (
<LabelContext.Provider value={{ id: labelId, elementType: "span" }}>
<GroupContext.Provider value={{ "aria-labelledby": labelId }}>
<div
className={twMerge(
["[&>[data-ui=label]:first-of-type:not([class*=mb])]:mb-2"],
className
)}
>
{children}
</div>
</GroupContext.Provider>
</LabelContext.Provider>
);
}
export function Label({
requiredHint,
displayLevel = 3,
...props
}: LabelProps & {
requiredHint?: boolean;
displayLevel?: DisplayLevel;
}) {
return (
<RACLabel
{...props}
data-ui="label"
className={twMerge(
"inline-block min-w-max text-pretty",
"group-disabled:opacity-50",
displayLevels[displayLevel],
requiredHint && "after:text-destructive after:ms-0.5 after:content-['*']",
props.className
)}
/>
);
}
export const DescriptionContext = React.createContext<{
"aria-describedby"?: string;
} | null>(null);
export function DescriptionProvider({ children }: { children: React.ReactNode }) {
const descriptionId: string | null = React.useId();
const [descriptionRendered, setDescriptionRendered] = React.useState(true);
React.useLayoutEffect(() => {
if (!document.getElementById(descriptionId)) {
setDescriptionRendered(false);
}
}, [descriptionId]);
return (
<DescriptionContext.Provider
value={{
"aria-describedby": descriptionRendered ? descriptionId : undefined,
}}
>
{children}
</DescriptionContext.Provider>
);
}
/**
* RAC will auto associate <RACText slot="description"/> with TextField/NumberField/RadioGroup/CheckboxGroup/DatePicker etc,
* but not for Switch/Checkbox/Radio and our custom components. We use follow pattern to associate description for
* Switch/Checkbox/Radio https://react-spectrum.adobe.com/react-aria/Switch.html#advanced-customization
*/
export function Description({ className, ...props }: TextProps) {
const describedby = React.useContext(DescriptionContext)?.["aria-describedby"];
return describedby ? (
<Text
{...props}
id={describedby}
data-ui="description"
className={twMerge("block group-disabled:opacity-50", className)}
/>
) : (
<RACText
{...props}
data-ui="description"
slot="description"
className={twMerge(
"text-muted block text-base/6 text-pretty sm:text-sm/6",
"group-disabled:opacity-50",
className
)}
/>
);
}
export function TextField(props: RACTextFieldProps) {
return (
<RACTextField
{...props}
data-ui="text-field"
className={composeRenderProps(props.className, (className) => twMerge(inputField, className))}
/>
);
}
export function FieldError(props: FieldErrorProps) {
return (
<RACFieldError
{...props}
data-ui="errorMessage"
className={composeRenderProps(props.className, (className) =>
twMerge("text-destructive block text-base/6 sm:text-sm/6", className)
)}
/>
);
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
return (
<RACInput
{...props}
ref={ref}
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge(
"border-input w-full rounded-md border outline-hidden",
"px-3 py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]",
"placeholder:text-muted text-base/6 sm:text-sm/6",
"[&[readonly]]:bg-zinc-50",
"dark:[&[readonly]]:bg-white/10",
renderProps.isDisabled && "opacity-50",
renderProps.isInvalid && "border-destructive",
renderProps.isFocused && "border-ring ring-ring ring-1",
className
)
)}
/>
);
});
export function TextArea(props: RACTextAreaProps) {
return (
<RACTextArea
{...props}
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge(
"border-input w-full rounded-md border px-3 py-1 outline-hidden",
"placeholder:text-muted text-base/6 sm:text-sm/6",
"[&[readonly]]:bg-zinc-50",
"dark:[&[readonly]]:bg-white/10",
renderProps.isDisabled && "opacity-50",
renderProps.isInvalid && "border-destructive",
renderProps.isFocused && "border-ring ring-ring ring-1",
className
)
)}
/>
);
}

View file

@ -1 +0,0 @@
export { FileTrigger } from "react-aria-components";

View file

@ -1,6 +0,0 @@
import { FormProps, Form as RACForm } from "react-aria-components";
import { twMerge } from "tailwind-merge";
export function Form(props: FormProps) {
return <RACForm {...props} className={twMerge("max-w-4xl space-y-6", props.className)} />;
}

View file

@ -1,64 +0,0 @@
import {
GridList as AriaGridList,
GridListItem as AriaGridListItem,
Button,
composeRenderProps,
GridListItemProps,
GridListProps,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { Checkbox } from "./checkbox";
import { composeTailwindRenderProps } from "./utils";
export function GridList<T extends object>({ children, ...props }: GridListProps<T>) {
return (
<AriaGridList
{...props}
className={composeTailwindRenderProps(
props.className,
"relative overflow-auto rounded-md border p-1"
)}
>
{children}
</AriaGridList>
);
}
export function GridListItem({ children, ...props }: GridListItemProps) {
const textValue = typeof children === "string" ? children : undefined;
return (
<AriaGridListItem
{...props}
textValue={textValue}
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isSelected, isDisabled, isHovered }) =>
twMerge(
"relative -mb-px flex cursor-default select-none gap-3 rounded-md px-2 py-1.5 text-sm outline-hidden",
"not-last:mb-0.5",
isHovered && ["bg-zinc100 dark:bg-zinc-800"],
isSelected && ["z-20"],
isDisabled && ["opacity-50"],
isFocusVisible && ["outline", "outline-2", "-outline-offset-2", "outline-ring"],
className
)
)}
>
{(renderProps) =>
typeof children === "function" ? (
children(renderProps)
) : (
<>
{/* Add elements for drag and drop and selection. */}
{renderProps.allowsDragging && <Button slot="drag"></Button>}
{renderProps.selectionMode === "multiple" &&
renderProps.selectionBehavior === "toggle" && <Checkbox slot="selection" />}
{children}
</>
)
}
</AriaGridListItem>
);
}

View file

@ -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<HTMLHeadingElement | HTMLDivElement, HeadingProps>(
function Heading({ elementType, ...props }, ref) {
if (elementType) {
const { displayLevel = 1, className, ...restProps } = props;
return (
<div {...restProps} ref={ref} className={twMerge(displayLevels[displayLevel], className)} />
);
}
const { level = 1, displayLevel, className, ...restProps } = props;
return (
<RACHeading
{...restProps}
ref={ref}
level={level}
className={twMerge(displayLevels[displayLevel ?? level], className)}
/>
);
}
);
export const SubHeading = React.forwardRef<HTMLDivElement, React.JSX.IntrinsicElements["div"]>(
function SubHeading({ className, ...props }, ref) {
return (
<div
{...props}
ref={ref}
className={twMerge("text-muted mt-2 text-base sm:text-sm/6", className)}
/>
);
}
);

View file

@ -1,33 +0,0 @@
import React from "react";
type ImageLoadingStatus = "idle" | "loading" | "loaded" | "error";
export function useImageLoadingStatus(src?: string) {
const [loadingStatus, setLoadingStatus] = React.useState<ImageLoadingStatus>("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;
}

View file

@ -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<typeof useHoverCard> | null;
const HoverCardContext = React.createContext<ContextType>(null);
const useHoverCardContext = () => {
const context = React.useContext(HoverCardContext);
if (context == null) {
throw new Error("HoverCard components must be wrapped in <HoverCard />");
}
return context;
};
export function HoverCard({
children,
modal = false,
...restOptions
}: {
children: React.ReactNode;
} & PopoverOptions) {
const popover = useHoverCard({ modal, ...restOptions });
return <HoverCardContext.Provider value={popover}>{children}</HoverCardContext.Provider>;
}
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 && (
<FloatingFocusManager context={floatingContext} modal={modal}>
<div
className={twMerge(
"bg-background max-w-72 rounded-lg p-1 ring-1 shadow-lg ring-zinc-950/10 outline-hidden dark:bg-zinc-800 dark:ring-white/15",
className
)}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
{...aria}
>
{typeof children === "function" ? children({ close: () => setIsOpen(false) }) : children}
</div>
</FloatingFocusManager>
)
);
}
export function HoverCardHeader(props: HeadingProps) {
const { labelId } = useHoverCardContext();
return <Heading {...props} id={labelId}></Heading>;
}

View file

@ -1,30 +0,0 @@
import React from "react";
interface IconProps extends Omit<React.JSX.IntrinsicElements["svg"], "aria-hidden"> {
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 ? <span className="sr-only">{ariaLabel}</span> : null}
</>
);
}

View file

@ -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 (
<Icon aria-label={arialLabel}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path
fillRule="evenodd"
d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 0 1 0-1.113ZM17.25 12a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0Z"
clipRule="evenodd"
/>
</svg>
</Icon>
);
}
export function EyeOffIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" {...props}>
<path
fillRule="evenodd"
d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l14.5 14.5a.75.75 0 1 0 1.06-1.06l-1.745-1.745a10.029 10.029 0 0 0 3.3-4.38 1.651 1.651 0 0 0 0-1.185A10.004 10.004 0 0 0 9.999 3a9.956 9.956 0 0 0-4.744 1.194L3.28 2.22ZM7.752 6.69l1.092 1.092a2.5 2.5 0 0 1 3.374 3.373l1.091 1.092a4 4 0 0 0-5.557-5.557Z"
clipRule="evenodd"
/>
<path d="m10.748 13.93 2.523 2.523a9.987 9.987 0 0 1-3.27.547c-4.258 0-7.894-2.66-9.337-6.41a1.651 1.651 0 0 1 0-1.186A10.007 10.007 0 0 1 2.839 6.02L6.07 9.252a4 4 0 0 0 4.678 4.678Z" />
</svg>
</Icon>
);
}
export function CheckIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
strokeWidth="2"
{...props}
>
<path
fillRule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clipRule="evenodd"
/>
</svg>
</Icon>
);
}
export function CircleInfoIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</Icon>
);
}
export function CircleCheckIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</Icon>
);
}
export function OctagonAlertIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M12 16h.01" />
<path d="M12 8v4" />
<path d="M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z" />
</svg>
</Icon>
);
}
export function CircleXIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
</Icon>
);
}
export function PlusIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
</Icon>
);
}
export function MinusIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M5 12h14" />
</svg>
</Icon>
);
}
export function XIcon({ "aria-label": arialLabel, ...props }: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</Icon>
);
}
export function CalendarIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
</Icon>
);
}
export function ChevronUpIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m18 15-6-6-6 6" />
</svg>
</Icon>
);
}
export function ChevronDownIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m6 9 6 6 6-6" />
</svg>
</Icon>
);
}
export function ChevronRightIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m9 18 6-6-6-6" />
</svg>
</Icon>
);
}
export function ChevronLeftIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m15 18-6-6 6-6" />
</svg>
</Icon>
);
}
export function SearchIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" {...props}>
<path
fillRule="evenodd"
d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"
clipRule="evenodd"
></path>
</svg>
</Icon>
);
}
export function SpinnerIcon({
className,
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
className={twMerge("animate-spin", className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</Icon>
);
}
export function CopyIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</Icon>
);
}
export function AvailableIcon({
className,
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
className={twMerge("text-emerald-600", className)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
{...props}
>
<path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512z" />
</svg>
</Icon>
);
}
export function BusyIcon({
className,
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
className={twMerge("text-red-600", className)}
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
{...props}
>
<path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z" />
</svg>
</Icon>
);
}
export function AwayIcon({
className,
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
className={twMerge("text-slate-400", className)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="none"
stroke="currentColor"
strokeWidth="90"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="256" cy="256" r="213" />
</svg>
</Icon>
);
}
export function DoNotDisturbIcon({
className,
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
fill="currentColor"
className={twMerge("text-red-600", className)}
aria-hidden="true"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 10A5 5 0 1 0 5 0a5 5 0 0 0 0 10ZM3.5 4.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1Z"
fill="currentColor"
></path>
</svg>
</Icon>
);
}

View file

@ -1,28 +0,0 @@
import { Icon } from "../../icon";
export function CalendarIcon({
"aria-label": arialLabel,
...props
}: React.JSX.IntrinsicElements["svg"]) {
return (
<Icon aria-label={arialLabel}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
</Icon>
);
}

View file

@ -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(
`<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 80 80" style="background:${bg};color:oklch(0.985 0 0);"><g><path d="M 8 80 a 28 24 0 0 1 64 0"/><circle cx="40" cy="32" r="16"/></g></svg>`
)
);
}
function getFallbackInitialsDataUrl(bg: string, initials: string) {
return (
"data:image/svg+xml;base64," +
btoa(
`<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" style="background:${bg};color:oklch(0.985 0 0);font-family:system-ui;"><text x="50%" y="50%" alignment-baseline="middle" dominant-baseline="middle" text-anchor="middle" dy=".125em" font-size="65%">${initials}</text></svg>`
)
);
}

View file

@ -1,23 +0,0 @@
import { Keyboard as RACKeyboard } from "react-aria-components";
import { twMerge } from "tailwind-merge";
export type KeyboardProps = Omit<React.JSX.IntrinsicElements["div"], "children"> & {
children: string;
outline?: boolean;
};
export function Kbd({ className, children, outline, ...props }: KeyboardProps) {
return (
<RACKeyboard
{...props}
data-ui="kbd"
className={twMerge(
"font-sans text-base/6 tracking-widest sm:text-sm/6",
outline && "rounded-sm bg-zinc-200 px-1 py-0.5 font-medium dark:bg-white/10",
className
)}
>
{children}
</RACKeyboard>
);
}

View file

@ -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<HTMLAnchorElement, LinkWithAsChild>(function Link(props, ref) {
if (props.asChild) {
return <Slot className={linkStyle}>{props.children}</Slot>;
}
const { tooltip, ...rest } = props;
const link = (
<RACLink
{...rest}
ref={ref}
className={composeRenderProps(props.className, (className, { isFocusVisible }) =>
twMerge(
linkStyle,
isFocusVisible && "outline outline-2 outline-offset-2 outline-ring",
className
)
)}
/>
);
if (tooltip) {
return (
<TooltipTrigger>
{link}
{tooltip}
</TooltipTrigger>
);
}
return link;
});

View file

@ -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<T> extends Omit<RACListBoxProps<T>, "layout" | "orientation"> {}
export const ListBox = React.forwardRef(
<T extends object>(props: ListBoxProps<T>, ref: React.Ref<HTMLDivElement>) => {
return (
<RACListBox
{...props}
ref={ref}
className={composeTailwindRenderProps(props.className, ["outline-hidden"])}
/>
);
}
) as <T extends object>(
props: ListBoxProps<T> & { ref?: React.Ref<HTMLDivElement> }
) => React.JSX.Element;
export const ListBoxItem = React.forwardRef(
(props: ListBoxItemProps, ref: React.Ref<HTMLLIElement>) => {
const textValue =
props.textValue || (typeof props.children === "string" ? props.children : undefined);
return (
<RACListBoxItem
{...props}
ref={ref}
textValue={textValue}
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isDisabled }) =>
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<HTMLLIElement> }) => React.JSX.Element;

View file

@ -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 = <ChevronDownIcon className="ms-auto" />,
variant = "outline",
children,
...props
}: MenuButtonProps) {
return (
<Button {...props} variant={variant}>
{(renderProps) => {
return (
<>
{typeof children === "function" ? children(renderProps) : children}
{buttonArrow}
</>
);
}}
</Button>
);
}
// eslint-disable-next-line react/display-name
export const MenuPopover = React.forwardRef(
({ className, ...props }: PopoverProps, ref: React.Ref<HTMLDivElement>) => {
return (
<Popover
{...props}
// @ts-expect-error "ref is not defined"
ref={ref}
className={composeTailwindRenderProps(
className,
twMerge(
"max-w-72",
"min-w-[max(--spacing(36),var(--trigger-width))]",
"has-[[data-ui=content]_[data-ui=icon]]:min-w-[max(--spacing(48),var(--trigger-width))]",
"has-[[data-ui=content]_kbd]:min-w-[max(--spacing(11),var(--trigger-width))]"
)
)}
/>
);
}
);
type MenuProps<T> = RACMenuProps<T> & {
checkIconPlacement?: "start" | "end";
};
export function Menu<T extends object>({ checkIconPlacement = "end", ...props }: MenuProps<T>) {
return (
<RACMenu
{...props}
data-check-icon-placement={checkIconPlacement}
className={composeTailwindRenderProps(
props.className,
twMerge(
"max-h-[inherit] overflow-auto outline-hidden",
"flex flex-col",
"p-1 has-[header]:pt-0",
// Header, Menu item style when has selectable items
"[&_header]:px-2",
checkIconPlacement === "start" &&
"[&:has(:is([role=menuitemradio],[role=menuitemcheckbox]))_:is(header,[role=menuitem])]:ps-7",
// Menu item content
"**:data-[ui=content]:flex-1",
"**:data-[ui=content]:grid",
"[&_[data-ui=content]:has([data-ui=label])]:grid-cols-[--spacing(4)_1fr_minmax(--spacing(12),max-content)]",
"**:data-[ui=content]:items-center",
"**:data-[ui=content]:gap-x-2",
"**:data-[ui=content]:rtl:text-right",
// Icon
"[&_[data-ui=content]:not(:hover)>[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<T extends object>(props: MenuProps<T> & { "aria-label": string }) {
return <Menu {...props} />;
}
export function MenuSeparator({ className }: { className?: string }) {
return (
<Separator
className={twMerge(
"border-t-border/50 my-1 w-[calc(100%-(--spacing(4)))] self-center border-t",
className
)}
/>
);
}
type MenuItemProps = RACMenuItemProps & {
destructive?: true;
};
export function MenuItem({ destructive, ...props }: MenuItemProps) {
const textValue =
props.textValue || (typeof props.children === "string" ? props.children : undefined);
return (
<RACMenuItem
{...props}
textValue={textValue}
className={composeRenderProps(props.className, (className, { isFocused, isDisabled }) => {
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 }) => (
<>
<CheckIcon
className={twMerge(
"flex h-[1lh] w-4 items-center self-start",
selectionMode == "none" ? "hidden" : "in-data-[check-icon-placement=end]:hidden",
isSelected ? "visible" : "invisible"
)}
/>
<div data-ui="content" data-destructive={destructive ? destructive : undefined}>
{children}
</div>
<CheckIcon
className={twMerge(
"flex h-[1lh] w-4 items-center self-start",
selectionMode == "none" ? "hidden" : "in-data-[check-icon-placement=start]:hidden",
isSelected ? "visible" : "invisible"
)}
/>
{/* Submenu indicator */}
<ChevronRightIcon className="text-muted hidden size-4 group-data-has-submenu:inline-block" />
</>
))}
</RACMenuItem>
);
}
export function MenuItemLabel({ className, ...props }: React.JSX.IntrinsicElements["span"]) {
return (
<span slot="label" data-ui="label" className={twMerge("truncate", className)} {...props} />
);
}
export function MenuItemDescription({ className, ...props }: React.JSX.IntrinsicElements["span"]) {
return <Small slot="description" data-ui="description" className={className} {...props} />;
}
export interface MenuSectionProps<T> extends RACMenuSectionProps<T> {
title?: string | React.ReactNode;
}
export function MenuSection<T extends object>({ className, ...props }: MenuSectionProps<T>) {
return (
<RACMenuSection
{...props}
className={twMerge(
"not-first:mt-1.5",
"not-first:border-t",
"not-first:border-t-border/75",
className
)}
>
<Header className="text-muted bg-background sticky inset-0 z-10 truncate pt-2 text-xs/6 rtl:text-right">
{props.title}
</Header>
<Collection items={props.items}>{props.children}</Collection>
</RACMenuSection>
);
}

View file

@ -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 (
<AriaMeter
{...props}
className={composeTailwindRenderProps(props.className, "flex flex-col gap-1")}
>
{({ percentage, valueText }) => (
<>
<div className="flex justify-between gap-2">
<Label>{label}</Label>
<span
className={`text-sm ${percentage >= 80 && !positive && !informative && "text-destructive"}`}
>
{percentage >= 80 && !positive && (
<svg
aria-label="Alert"
className="inline-block size-5 align-text-bottom"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
)}
{` ${valueText}`}
</span>
</div>
<div className="relative h-2 w-64 rounded-full bg-gray-300 outline outline-1 -outline-offset-1 outline-transparent dark:bg-zinc-800">
<div
className={`absolute left-0 top-0 h-full rounded-full ${getColor(percentage, { positive, informative })}`}
style={{ width: `${percentage}%` }}
/>
</div>
</>
)}
</AriaMeter>
);
}
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";
}

View file

@ -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<RACModalOverlayProps, "className"> & {
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<HTMLElement>(":root")
?.style.setProperty(
"--scrollbar-width",
`${window.innerWidth - document.documentElement.clientWidth}px`
);
}, []);
return (
<RACModalOverlay
{...props}
data-ui="modal-overlay"
className={composeTailwindRenderProps(classNames?.modalOverlay, [
"fixed top-0 left-0 isolate z-20",
"h-(--visual-viewport-height) w-full",
"bg-zinc-950/25 dark:bg-zinc-950/50",
"text-center",
"data-entering:animate-in",
"data-entering:fade-in",
"data-entering:duration-300",
"data-entering:ease-out",
"data-exiting:animate-out",
"data-exiting:fade-out",
"data-exiting:duration-200",
"data-exiting:ease-in",
drawer
? "flex items-start p-2 [--visual-viewport-vertical-padding:16px] [&:has([data-placement=right])]:justify-end"
: [
"grid justify-items-center",
placement === "center"
? "grid-rows-[1fr_auto_1fr] p-4 [--visual-viewport-vertical-padding:32px]"
: [
// Default alert dialog style
"[&:has([role=alertdialog])]:grid-rows-[1fr_auto_1fr] sm:[&:has([role=alertdialog])]:grid-rows-[1fr_auto_3fr]",
"[&:has([role=alertdialog])]:p-4 [&:has([role=alertdialog])]:[--visual-viewport-vertical-padding:32px]",
// Default dialog style
placement === "top"
? "grid-rows-[1fr_auto_3fr] [&:has([role=dialog])]:p-4 sm:[&:has([role=dialog])]:[--visual-viewport-vertical-padding:32px]"
: [
"grid-rows-[1fr_auto] sm:grid-rows-[1fr_auto_3fr]",
"[&:has([role=dialog])]:pt-4 sm:[&:has([role=dialog])]:p-4",
"[&:has([role=dialog])]:[--visual-viewport-vertical-padding:16px]",
"sm:[&:has([role=dialog])]:[--visual-viewport-vertical-padding:32px]",
],
],
/**
* Style for stack dialogs
*/
// First dialog
"[&:has(~[data-ui=modal-overlay]:not([data-exiting]))>[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",
],
])}
>
<RACModal
{...props}
data-ui="modal"
data-placement={placement}
className={composeTailwindRenderProps(classNames?.modal, [
"relative max-h-full w-full overflow-hidden",
"text-left align-middle",
"shadow-lg",
"bg-background",
"ring-1 ring-zinc-950/5 dark:ring-zinc-800",
props.size
? sizes[props.size]
: "sm:has-[[role=alertdialog]]:max-w-md sm:has-[[role=dialog]]:max-w-lg",
"data-entering:animate-in",
"data-entering:ease-out",
"data-entering:duration-200",
"data-exiting:animate-out",
"data-exiting:ease-in",
"data-exiting:duration-200",
drawer
? [
"h-full",
"rounded-xl",
"data-[placement=left]:data-entering:slide-in-from-left",
"data-[placement=right]:data-entering:slide-in-from-right",
"data-[placement=left]:data-exiting:slide-out-to-left",
"data-[placement=right]:data-exiting:slide-out-to-right",
]
: [
"row-start-2",
"rounded-xl",
"data-entering:zoom-in-95",
"data-exiting:zoom-out-95",
// Handle layout shift when toggling scroll lock
props.size !== "fullWidth" && "sm:data-exiting:-me-(--scrollbar-width)",
"sm:data-exiting:duration-0",
!placement && [
"has-[[role=dialog]]:rounded-t-xl",
"has-[[role=dialog]]:rounded-b-none",
"sm:has-[[role=dialog]]:rounded-xl",
"has-[[role=dialog]]:data-entering:zoom-in-100",
"has-[[role=dialog]]:data-entering:slide-in-from-bottom",
"sm:has-[[role=dialog]]:data-entering:zoom-in-95",
"sm:has-[[role=dialog]]:data-entering:slide-in-from-bottom-0",
"has-[[role=dialog]]:data-exiting:zoom-out-100",
"has-[[role=dialog]]:data-exiting:slide-out-to-bottom",
"sm:has-[[role=dialog]]:data-exiting:zoom-out-95",
"sm:has-[[role=dialog]]:data-exiting:slide-out-to-bottom-0",
],
],
])}
/>
</RACModalOverlay>
);
}

View file

@ -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<T extends object>
extends Omit<
RACComboBoxProps<T>,
| "children"
| "validate"
| "allowsEmptyCollection"
| "inputValue"
| "selectedKey"
| "inputValue"
| "className"
| "value"
| "onSelectionChange"
| "onInputChange"
> {
items: Array<T>;
selectedList: ListData<T>;
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 (
<LabeledGroup {...props}>
<Group className={composeTailwindRenderProps(className, inputField)}>
<DescriptionProvider>{children}</DescriptionProvider>
</Group>
</LabeledGroup>
);
}
export function MultiSelect<
T extends {
id: Key;
textValue: string;
},
>({
children,
items,
selectedList,
onItemRemove,
onItemAdd,
className,
name,
renderEmptyState,
...props
}: MultiSelectProps<T>) {
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<Key>) => {
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<HTMLInputElement>) => {
if (e.key === "Backspace" && fieldState.inputValue === "") {
deleteLast();
}
},
[deleteLast, fieldState.inputValue]
);
const tagGroupId = React.useId();
const triggerRef = React.useRef<HTMLDivElement | null>(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<HTMLButtonElement | null>(null);
const labelContext = (React.useContext(LabelContext) ?? {}) as {
id?: string;
};
const descriptionContext = React.useContext(DescriptionContext);
return (
<>
<div
data-ui="control"
ref={triggerRef}
className={twMerge(
"relative",
"pe-4",
"flex min-h-9 w-[350px] flex-row flex-wrap items-center rounded-md",
"border has-[input[data-focused=true]]:border-ring",
"has-[input[data-invalid=true][data-focused=true]]:border-ring has-[input[data-invalid=true]]:border-destructive",
"has-[input[data-focused=true]]:ring-1 has-[input[data-focused=true]]:ring-ring",
className
)}
>
{selectedList.items.length > 0 && (
<TagGroup
id={tagGroupId}
aria-labelledby={labelContext.id}
className="contents"
onRemove={onRemove}
>
<TagList
items={selectedList.items}
className={twMerge(selectedList.items.length !== 0 && "p-1", "outline-hidden")}
>
{props.tag}
</TagList>
</TagGroup>
)}
<ComboBox
{...props}
allowsEmptyCollection
className={twMerge("group flex flex-1", className)}
items={availableList.items}
selectedKey={fieldState.selectedKey}
inputValue={fieldState.inputValue}
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
aria-labelledby={labelContext.id}
>
<div
className={[
"inline-flex flex-1 flex-wrap items-center gap-1 px-2",
selectedList.items.length > 0 && "ps-0",
].join(" ")}
>
<Input
className="me-4 flex-1 border-0 px-0.5 py-0 outline-0 focus:ring-0"
onBlur={() => {
setFieldState({
inputValue: "",
selectedKey: null,
});
availableList.setFilterText("");
}}
aria-describedby={[tagGroupId, descriptionContext?.["aria-describedby"] ?? ""].join(
" "
)}
onKeyDownCapture={onKeyDownCapture}
/>
<div className="sr-only" aria-hidden>
<Button variant="plain" ref={triggerButtonRef}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<path d="m6 9 6 6 6-6" />
</svg>
</Button>
</div>
</div>
<Popover
style={{ width: `${width}px` }}
triggerRef={triggerRef}
className="max-w-none duration-0"
>
<ListBox<T>
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}
</ListBox>
</Popover>
</ComboBox>
<Button variant="plain" asChild>
<div className="top-50 absolute end-0 me-1 size-6 rounded-sm p-0.5" aria-hidden>
{/* React Aria Button does not allow tabIndex */}
<button type="button" onClick={() => triggerButtonRef.current?.click()} tabIndex={-1}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4 text-muted group-hover:text-foreground"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
</Button>
</div>
{name && <input hidden name={name} value={selectedKeys.join(",")} readOnly />}
</>
);
}
export function MultiSelectItem(props: ListBoxItemProps) {
return (
<ListBoxItem
{...props}
className={composeRenderProps(props.className, (className, { isFocused }) => {
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}
</ListBoxItem>
);
}

View file

@ -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 (
<LabelContext.Provider value={{ id: labelId, elementType: "span" }}>
<DescriptionProvider>
<div
{...props}
data-ui="native-select-field"
className={twMerge("has-[select:disabled]:opacity-50", inputField, className)}
/>
</DescriptionProvider>
</LabelContext.Provider>
);
}
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 (
<div
data-ui="control"
className={twMerge(
"group relative isolate flex transition",
"after:pointer-events-none",
"after:absolute",
"after:border-muted",
"hover:after:border-foreground",
"after:content-['']",
"after:size-2 sm:after:size-1.5",
"after:border-r-[1.5px] after:border-b-[1.5px]",
"after:end-3 after:bottom-[55%] after:-translate-x-1/2 after:translate-y-1/2 after:rotate-45 rtl:after:translate-x-1.5"
)}
>
<select
{...focusProps}
aria-labelledby={labelContext.id}
aria-describedby={descriptionContext?.["aria-describedby"]}
className={twMerge(
"w-full",
"appearance-none bg-transparent",
"ps-2.5 pe-8 sm:pe-7.5",
"py-[calc(--spacing(2.5)-1px)]",
"sm:py-[calc(--spacing(1.5)-1px)]",
"rounded-md border border-input outline-hidden",
"text-base/6 sm:text-sm/6",
"hover:bg-zinc-100 hover:dark:bg-zinc-800",
"hover:bg-zinc-100 dark:hover:bg-zinc-800",
isFocusVisible && "border-ring ring-ring ring-1",
className
)}
{...props}
/>
</div>
);
}

View file

@ -1,68 +0,0 @@
import { twMerge } from "tailwind-merge";
type DotVariantProps = {
variant: "dot";
inline?: boolean;
};
type NumericVariantProps = {
variant: "numeric";
value: number;
inline?: boolean;
};
export type NotificationBadgeProps = (DotVariantProps | NumericVariantProps) &
React.JSX.IntrinsicElements["span"];
export function NotificationBadge({
className,
"aria-label": ariaLabel,
...props
}: NotificationBadgeProps) {
if (props.variant === "dot") {
const { inline, ...rest } = props;
return (
<>
<span
aria-hidden
className={twMerge(
inline ? "" : "absolute top-1 right-1",
"flex size-2 rounded-full bg-red-600",
className
)}
/>
{ariaLabel && (
<span role="status" className="sr-only" {...rest}>
{ariaLabel}
</span>
)}
</>
);
}
const { inline, ...rest } = props;
return (
<>
<span
aria-hidden
className={twMerge([
inline ? "" : "absolute -top-1.5 -right-1",
"flex h-4 items-center justify-center rounded-full bg-red-600 text-[0.65rem] text-white",
props.value > 0 ? (props.value > 9 ? "w-5" : "w-4") : "hidden",
className,
])}
>
{Math.min(props.value, 9)}
{props.value > 9 ? <span className="pb-0.5">+</span> : null}
</span>
{ariaLabel && (
<span role="status" className="sr-only" {...rest}>
{ariaLabel}
</span>
)}
</>
);
}

View file

@ -1,72 +0,0 @@
import {
Group,
InputProps,
NumberField as RACNumberField,
NumberFieldProps as RACNumberFieldProps,
} from "react-aria-components";
import { Button } from "./button";
import { Input } from "./field";
import { MinusIcon, PlusIcon } from "./icons";
import { Separator } from "./separator";
import { composeTailwindRenderProps, inputField } from "./utils";
export interface NumberFieldProps extends RACNumberFieldProps {}
export function NumberField(props: NumberFieldProps) {
return (
<RACNumberField
{...props}
className={composeTailwindRenderProps(props.className, inputField)}
/>
);
}
export function NumberInput(props: InputProps) {
return (
<Group
data-ui="control"
className={[
"group isolate grid grid-cols-[auto_auto_1fr_auto_auto]",
"[&>div:has([role=separator])]:h-full",
"[&>div:has([role=separator])]:z-10",
"[&>div:has([role=separator])]:py-[1px]",
"[&:focus-within>div:has([role=separator])]:py-[2px]",
].join(" ")}
>
<Button
slot="decrement"
isIconOnly
variant="plain"
className="z-10 col-start-1 row-start-1 rounded-none hover:bg-transparent pressed:bg-transparent text-muted hover:text-foreground"
>
<MinusIcon />
</Button>
<div className="col-start-2 row-start-1">
<Separator orientation="vertical" className="h-full" />
</div>
<Input
{...props}
className={composeTailwindRenderProps(props.className, [
"z-0",
"col-span-full",
"row-start-1",
"px-[calc(theme(size.11)+10px)] sm:px-[calc(theme(size.9)+10px)]",
])}
/>
<div className="-col-end-2 row-start-1">
<Separator orientation="vertical" className="h-full" />
</div>
<Button
slot="increment"
className="-col-end-1 row-start-1 rounded-none text-muted hover:text-foreground hover:bg-transparent pressed:bg-transparent"
isIconOnly
variant="plain"
>
<PlusIcon />
</Button>
</Group>
);
}

View file

@ -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 (
<nav
role="navigation"
aria-label={arialLabel}
className={twMerge("mx-auto flex w-full justify-center gap-x-2", className)}
{...props}
/>
);
}
export function PaginationList({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
return <div {...props} className={twMerge("flex hidden gap-x-1 sm:flex", className)} />;
}
export function PaginationPrevious({
className,
label = "Previous",
...props
}: LinkProps & { className?: string; label?: string }) {
return (
<Button asChild variant="plain">
<Link {...props} className={twMerge("px-3.5 outline-offset-0 hover:no-underline", className)}>
<ChevronLeftIcon />
{label}
</Link>
</Button>
);
}
export function PaginationNext({
className,
label = "Next",
...props
}: LinkProps & { className?: string; label?: string }) {
return (
<Button asChild variant="plain">
<Link {...props} className={twMerge("px-3.5 outline-offset-1 hover:no-underline", className)}>
{label}
<ChevronRightIcon />
</Link>
</Button>
);
}
export function PaginationPage({
className,
current,
"aria-label": arialLabel,
...props
}: LinkProps & { className?: string; current?: boolean; children: string }) {
return (
<Button asChild {...(!current && { variant: "plain" })}>
<Link
{...props}
aria-label={arialLabel ?? `Page ${props.children}`}
className={twMerge("min-w-9 outline-offset-1 hover:no-underline", className)}
/>
</Button>
);
}
export function PaginationGap({ className, ...props }: React.JSX.IntrinsicElements["span"]) {
return (
<span {...props} aria-hidden className={twMerge("h-9 px-3.5", className)}>
&hellip;
</span>
);
}

View file

@ -1,53 +0,0 @@
import React from "react";
import { Group, InputProps } from "react-aria-components";
import { ToggleButton } from "./button";
import { Input } from "./field";
import { EyeIcon, EyeOffIcon } from "./icons";
import { composeTailwindRenderProps } from "./utils";
export function PasswordInput({ className, ...props }: InputProps) {
const [isPasswordVisible, setIsPasswordVisible] = React.useState(false);
return (
<Group
data-ui="control"
className={[
"grid",
"grid-cols-[1fr_calc(theme(size.5)+20px)]",
"sm:grid-cols-[1fr_calc(theme(size.4)+20px)]",
].join(" ")}
>
<Input
{...props}
className={composeTailwindRenderProps(className, [
"peer",
"col-span-full",
"row-start-1",
"pe-10 sm:pe-9",
])}
type={isPasswordVisible ? "text" : "password"}
/>
<ToggleButton
isIconOnly
size="sm"
variant="plain"
aria-label="Show password"
isSelected={isPasswordVisible}
onChange={setIsPasswordVisible}
className={[
"group/toggle-password",
"focus-visible:-outline-offset-1",
"row-start-1",
"-col-end-1",
"place-self-center",
].join(" ")}
>
{isPasswordVisible ? (
<EyeOffIcon className="text-muted/75 group-hover/toggle-password:text-foreground" />
) : (
<EyeIcon className="text-muted/75 group-hover/toggle-password:text-foreground" />
)}
</ToggleButton>
</Group>
);
}

View file

@ -1,42 +0,0 @@
import React from "react";
import {
PopoverContext,
Popover as RACPopover,
PopoverProps as RACPopoverProps,
useSlottedContext,
} from "react-aria-components";
import { composeTailwindRenderProps } from "./utils";
export interface PopoverProps extends Omit<RACPopoverProps, "children"> {
children: React.ReactNode;
}
export function Popover(props: PopoverProps) {
const popoverContext = useSlottedContext(PopoverContext)!;
const isSubmenu = popoverContext?.trigger === "SubmenuTrigger";
let offset = 8;
offset = props.offset !== undefined ? props.offset : isSubmenu ? offset - 14 : offset;
return (
<RACPopover
{...props}
offset={offset}
className={composeTailwindRenderProps(props.className, [
"bg-background",
"shadow-lg",
"rounded-md",
"ring-1",
"ring-zinc-950/10",
"dark:ring-zinc-800",
"data-entering:animate-in",
"data-entering:ease-out",
"data-entering:fade-in",
"data-exiting:animate-out",
"data-exiting:ease-in",
"data-exiting:fade-out",
"data-exiting:duration-50",
])}
/>
);
}

View file

@ -1,34 +0,0 @@
import {
ProgressBar as AriaProgressBar,
ProgressBarProps as AriaProgressBarProps,
} from "react-aria-components";
import { Label } from "./field";
import { composeTailwindRenderProps } from "./utils";
export interface ProgressBarProps extends AriaProgressBarProps {
label?: string;
}
export function ProgressBar({ label, ...props }: ProgressBarProps) {
return (
<AriaProgressBar
{...props}
className={composeTailwindRenderProps(props.className, "flex flex-col gap-1")}
>
{({ percentage, valueText, isIndeterminate }) => (
<>
<div className="flex justify-between gap-2">
<Label>{label}</Label>
<span className="text-sm text-muted">{valueText}</span>
</div>
<div className="relative h-2 w-64 overflow-hidden rounded-full bg-gray-300 outline outline-1 -outline-offset-1 outline-transparent dark:bg-zinc-700">
<div
className={`absolute top-0 h-full rounded-full bg-accent ${isIndeterminate ? "left-full duration-1000 ease-out animate-in slide-in-from-left-[20rem] repeat-infinite" : "left-0"}`}
style={{ width: `${isIndeterminate ? 40 : percentage}%` }}
/>
</div>
</>
)}
</AriaProgressBar>
);
}

View file

@ -1,366 +0,0 @@
import React from "react";
import {
composeRenderProps,
Radio as RACRadio,
RadioGroup as RACRadioGroup,
RadioGroupProps as RACRadioGroupProps,
RadioProps as RACRadioProps,
RadioRenderProps,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { DescriptionContext, DescriptionProvider } from "./field";
import { composeTailwindRenderProps, groupBox } from "./utils";
type RadioGroupVariant = {
orientation?: "vertical" | "horizontal";
labelPlacement?: "start" | "end";
} & (
| {
variant?: "card";
compact?: true;
}
| {
variant?: "radio" | "segment";
compact?: never;
}
);
const RadioGroupVariantContext = React.createContext<RadioGroupVariant | null>(null);
const useRadioGroupVariantContext = () => {
const {
labelPlacement = "end",
orientation = "vertical",
variant = "radio",
compact = false,
} = React.useContext(RadioGroupVariantContext) ?? {};
return {
labelPlacement,
orientation,
variant,
compact,
} as RadioGroupVariant;
};
export function RadioGroup({
children,
variant = "radio",
orientation,
labelPlacement = "end",
compact,
...props
}: RACRadioGroupProps & Exclude<RadioGroupVariant, "orientation">) {
if (variant === "segment") {
orientation = orientation ?? "horizontal";
}
return (
<RadioGroupVariantContext.Provider
value={
variant === "card"
? {
variant,
orientation,
labelPlacement,
compact,
}
: {
variant,
orientation,
labelPlacement,
}
}
>
<RACRadioGroup
{...props}
orientation={orientation}
className={composeTailwindRenderProps(props.className, [
groupBox,
variant === "segment" && [
orientation === "vertical" && ["items-start"],
"[--segment-padding:--spacing(0.5)]",
"[--segment-radius:var(--radius-lg)]",
],
])}
>
{children}
</RACRadioGroup>
</RadioGroupVariantContext.Provider>
);
}
export function Radios({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
const { variant, orientation, compact } = useRadioGroupVariantContext();
return (
<div
data-ui="box"
className={twMerge(
"flex",
"flex-col",
"gap-y-3",
"has-data-[ui=description]:gap-y-4",
"has-data-[ui=description]:[&_label]:font-medium",
orientation === "horizontal" && ["flex-row flex-wrap gap-x-4 gap-y-2"],
variant === "card" && [
orientation === "horizontal" && [
compact ? "flex-row flex-nowrap" : "flex-col sm:flex-row",
],
compact ? "gap-x-0 gap-y-0" : "gap-x-6, gap-y-6",
],
variant === "segment" && [
"bg-zinc-100 p-0.5 dark:bg-zinc-800",
"rounded-(--segment-radius)",
orientation === "horizontal" && ["min-w-sm"],
],
className
)}
{...props}
/>
);
}
export function RadioField({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
const { labelPlacement } = useRadioGroupVariantContext();
return (
<DescriptionProvider>
<div
{...props}
data-ui="field"
className={twMerge(
"group flex flex-col gap-y-1 has-[label[data-disabled]]:**:data-[ui=description]:opacity-50",
labelPlacement === "start" && [
"[&_[data-ui=description]:not([class*=pe-])]:pe-16 [&_label]:justify-between",
],
labelPlacement === "end" && ["[&_[data-ui=description]:not([class*=ps-])]:ps-7"],
className
)}
/>
</DescriptionProvider>
);
}
export interface RadioProps extends RACRadioProps {
radio?: React.ReactElement | null | ((props: Partial<RadioRenderProps>) => React.ReactNode);
render?: never;
}
export interface CustomRenderRadioProps extends Omit<RACRadioProps, "children"> {
render: string | React.ReactElement | ((props: RadioRenderProps) => React.ReactNode);
radio?: never;
children?: never;
}
function getRadioStyle({
isSelected,
isHovered,
variant,
orientation = "vertical",
compact,
}: Partial<RadioRenderProps> & {
variant: RadioGroupVariant["variant"];
orientation?: RadioGroupVariant["orientation"];
compact?: boolean;
}) {
const style = {
radio: [],
card: [
"flex-1 rounded-lg px-4 py-3 items-start [&>[data-slot=radio]:not([class*=mt-])]:mt-1.5",
"[&_[data-ui=icon]:not([class*=size-])]:w-4",
"[&_[data-ui=icon]:not([class*=size-])]:h-[1lh]",
isSelected
? "[&_[data-ui=icon]:not([class*=text-])]:text-foreground"
: "[&_[data-ui=icon]:not([class*=text-])]:text-muted",
compact
? [
"[&:not(:first-child):not(:last-child)]:rounded-none",
orientation === "horizontal" && [
"first:rounded-e-none",
"last:rounded-s-none",
"border-t border-b",
"not-first:border-e",
"not-last:border-s",
"[&:has(+label[data-selected]:last-child)]:border-e-accent/50",
isSelected && "[&:first-child+label]:border-s-accent/50",
],
orientation === "vertical" && [
"border-s border-e",
"first:rounded-b-none",
"last:rounded-t-none",
"not-first:border-b",
"not-last:border-t",
"[&:has(+label[data-selected]:last-child)]:border-b-accent/50",
isSelected && "[&:first-child+label]:border-t-accent/50",
],
isSelected && [
"bg-[color-mix(in_oklab,_var(--accent)_5%,_white)]",
"dark:bg-[color-mix(in_oklab,_var(--accent)_25%,_black)] border-accent/50",
],
]
: ["ring ring-border", isSelected && ["ring-ring", "ring-inset", "ring-1"]],
],
segment: [
"flex",
"justify-center",
"items-center",
"flex-1 text-center font-medium rounded-[calc(var(--segment-radius)-var(--segment-padding))]",
"transition-all ease-in-out",
"[&_[data-ui=icon]:not([class*=size-])]:size-4",
isSelected && [
"bg-white dark:bg-zinc-600",
"shadow-sm dark:shadow-none",
"ring ring-zinc-950/10",
],
!isSelected && !isHovered && "text-muted",
orientation === "horizontal" && ["px-4 py-1"],
orientation === "vertical" && ["p-2"],
],
};
return style[variant ?? "radio"];
}
export function Radio(props: RadioProps | CustomRenderRadioProps) {
const descriptionContext = React.useContext(DescriptionContext);
const { variant, orientation, labelPlacement, compact } = useRadioGroupVariantContext();
if (props.render !== undefined) {
const { render, ...restProps } = props;
return (
<RACRadio
{...restProps}
aria-describedby={descriptionContext?.["aria-describedby"]}
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge(
"group text-base/6 sm:text-sm/6",
renderProps.isDisabled && "opacity-50",
renderProps.isFocusVisible && "outline-ring outline-2 outline-offset-3",
getRadioStyle({
variant,
orientation,
compact,
...renderProps,
}),
className
)
)}
>
{render}
</RACRadio>
);
}
const { radio, ...restProps } = props;
const noRadioToggle = radio === null;
return (
<RACRadio
{...restProps}
aria-describedby={descriptionContext?.["aria-describedby"]}
className={composeRenderProps(props.className, (className, renderProps) =>
twMerge(
"group flex items-center text-base/6 sm:text-sm/6",
orientation === "horizontal" && "text-nowrap",
labelPlacement === "start" && "flex-row-reverse justify-between",
getRadioStyle({
variant,
orientation,
compact,
...renderProps,
}),
renderProps.isDisabled && "opacity-50",
noRadioToggle && [
renderProps.isFocusVisible && "outline-ring outline-2 outline-offset-2",
],
className
)
)}
>
{(renderProps) => {
return (
<>
{!noRadioToggle && (
<RadioToggle
data-slot="radio"
radio={radio}
renderProps={renderProps}
className={twMerge(
labelPlacement === "end" ? "me-3" : "ms-3",
!radio && "size-4.5 sm:size-4"
)}
/>
)}
{typeof props.children === "function" ? props.children(renderProps) : props.children}
</>
);
}}
</RACRadio>
);
}
type RadioBoxProps = Partial<RadioRenderProps> & {
radio?: Exclude<RadioProps["radio"], null>;
renderProps?: Partial<RadioRenderProps>;
} & Omit<React.JSX.IntrinsicElements["div"], "children">;
export function RadioToggle({ radio, renderProps, className, ...props }: RadioBoxProps) {
return (
<div
{...props}
data-check-indicator
className={twMerge(
"grid shrink-0 place-content-center rounded-full shadow-sm ring ring-zinc-950/15 dark:shadow-none dark:ring-white/20",
radio ? "" : "size-4",
renderProps?.isReadOnly
? "opacity-50"
: renderProps?.isHovered && "ring-zinc-950/25 dark:ring-white/25",
renderProps?.isSelected
? "bg-accent ring-accent dark:ring-accent"
: "dark:bg-white/5 dark:[--contract:1.1]",
// When it is inside menu item and the item is selected
"in-[&[data-ui=content][data-hovered=true]]:ring-zinc-950/25",
"in-[&[data-ui=content][data-hovered=true]]:dark:ring-white/25",
"in-[&[data-ui=content][data-selected=true]]:bg-accent",
"in-[&[data-ui=content][data-selected=true]]:dark:bg-accent",
"in-[&[data-ui=content][data-selected=true]]:ring-accent",
"in-[&[data-ui=content][data-selected=true]]:dark:ring-accent",
renderProps?.isInvalid && "ring-red-600 dark:ring-red-600",
renderProps?.isFocusVisible && "outline-ring outline-2 outline-offset-3",
className
)}
>
{radio && renderProps ? (
typeof radio === "function" ? (
radio(renderProps)
) : (
radio
)
) : (
<div
className={twMerge(
"rounded-full",
renderProps?.isSelected &&
"size-2 bg-white shadow-[0_1px_1px_rgba(0,0,0,0.25)] dark:bg-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)]",
// when it is inside menu item and the item is selected
"in-[&[data-ui=content][data-selected=true]]:size-2",
"in-[&[data-ui=content][data-selected=true]]:bg-white",
"in-[&[data-ui=content][data-selected=true]]:shadow-[0_1px_1px_rgba(0,0,0,0.25)] dark:bg-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)]"
)}
></div>
)}
</div>
);
}

View file

@ -1,109 +0,0 @@
import { getLocalTimeZone, isToday } from "@internationalized/date";
import {
CalendarCell,
CalendarGrid,
CalendarGridBody,
composeRenderProps,
DateValue,
RangeCalendar as RACRangeCalendar,
RangeCalendarProps as RACRangeCalendarProps,
Text,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { CalendarGridHeader, CalendarHeader } from "./calendar";
export interface RangeCalendarProps<T extends DateValue>
extends Omit<RACRangeCalendarProps<T>, "visibleDuration"> {
errorMessage?: string;
}
export function RangeCalendar<T extends DateValue>({
errorMessage,
...props
}: RangeCalendarProps<T>) {
return (
<RACRangeCalendar
{...props}
className={composeRenderProps(props.className, (className) => {
return twMerge("px-1.5 py-2.5", className);
})}
>
<CalendarHeader />
<CalendarGrid
className="border-separate border-spacing-y-1 px-3 sm:px-2"
weekdayStyle="short"
>
<CalendarGridHeader />
<CalendarGridBody>
{(date) => (
<CalendarCell
date={date}
className={composeRenderProps(
"",
(className, { isSelected, isSelectionStart, isSelectionEnd, isInvalid }) => {
return twMerge(
"group grid size-10 cursor-default place-items-center text-sm outline-hidden [td:first-child_&]:rounded-s-lg [td:last-child_&]:rounded-e-lg",
isToday(date, getLocalTimeZone()) && [
isSelected ? "rounded-none" : "rounded-lg bg-zinc-100 dark:bg-zinc-800",
],
isSelected && "bg-accent/[0.07] dark:bg-accent/35 dark:text-white",
isSelected &&
isInvalid &&
"bg-destructive/15 text-destructive dark:bg-destructive/30",
isSelectionStart && "rounded-s-lg",
isSelectionEnd && "rounded-e-lg",
className
);
}
)}
>
{({
formattedDate,
isSelected,
isInvalid,
isHovered,
isPressed,
isSelectionStart,
isSelectionEnd,
isFocusVisible,
isUnavailable,
isDisabled,
}) => (
<span
className={twMerge(
"relative flex size-[calc(--spacing(10)-1px)] items-center justify-center",
isHovered && [
"rounded-lg bg-zinc-100 dark:bg-zinc-700",
isPressed && "bg-accent/90",
isSelected &&
"bg-accent dark:bg-accent text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]",
],
isDisabled && "opacity-50",
isUnavailable && "text-destructive decoration-destructive line-through",
(isSelectionStart || isSelectionEnd) && [
"bg-accent rounded-lg text-sm text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]",
isHovered && "bg-accent/90 dark:bg-accent/90",
isInvalid && "border-destructive bg-destructive text-white",
],
isFocusVisible && [
"outline-ring outline outline-2",
(isSelectionStart || isSelectionEnd) && "outline-offset-1",
"rounded-lg",
]
)}
>
{formattedDate}
</span>
)}
</CalendarCell>
)}
</CalendarGridBody>
</CalendarGrid>
{errorMessage && (
<Text slot="errorMessage" className="text-destructive text-sm">
{errorMessage}
</Text>
)}
</RACRangeCalendar>
);
}

View file

@ -1,57 +0,0 @@
import {
Group,
InputProps,
SearchField as RACSearchField,
SearchFieldProps as RACSearchFieldProps,
} from "react-aria-components";
import { Button } from "./button";
import { Input } from "./field";
import { SearchIcon, SpinnerIcon, XIcon } from "./icons";
import { composeTailwindRenderProps, inputField } from "./utils";
export interface SearchFieldProps extends RACSearchFieldProps {}
export function SearchField(props: SearchFieldProps) {
return (
<RACSearchField
{...props}
className={composeTailwindRenderProps(props.className, inputField)}
></RACSearchField>
);
}
export function SearchInput({ isPending, ...props }: InputProps & { isPending?: boolean }) {
return (
<Group
data-ui="control"
className={[
"isolate",
"grid",
"grid-cols-[calc(theme(size.5)+20px)_1fr_calc(theme(size.5)+20px)]",
"sm:grid-cols-[calc(theme(size.4)+20px)_1fr_calc(theme(size.4)+20px)]",
].join(" ")}
>
{isPending ? (
<SpinnerIcon className="z-10 col-start-1 row-start-1 size-5 place-self-center text-muted sm:size-4" />
) : (
<SearchIcon className="z-10 col-start-1 row-start-1 size-5 place-self-center text-muted sm:size-4" />
)}
<Input
{...props}
className={composeTailwindRenderProps(props.className, [
"[&::-webkit-search-cancel-button]:hidden",
"col-span-full row-start-1 pe-10 ps-10 sm:pe-9 sm:ps-8",
])}
/>
<Button
isIconOnly
variant="plain"
size="sm"
className="-col-end-1 row-start-1 place-self-center group-data-empty:invisible"
>
<XIcon aria-label="Clear" />
</Button>
</Group>
);
}

View file

@ -1,268 +0,0 @@
import React from "react";
import {
Button,
Collection,
composeRenderProps,
Header,
ListBoxItemProps,
ListBoxItem as RACListBoxItem,
ListBoxSection as RACListBoxSection,
ListBoxSectionProps as RACListBoxSectionProps,
Select as RACSelect,
SelectProps as RACSelectProps,
SelectValue,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { Icon } from "./icon";
import { CheckIcon, ChevronDownIcon } from "./icons";
import { ListBox, ListBoxProps } from "./list-box";
import { Popover, PopoverProps } from "./popover";
import { Small } from "./text";
import { composeTailwindRenderProps, inputField } from "./utils";
export function Select<T extends object>(props: RACSelectProps<T>) {
return (
<RACSelect
{...props}
data-ui="select"
className={composeTailwindRenderProps(props.className, ["w-full min-w-16", inputField])}
/>
);
}
export function StatusIcon({ className }: { className: string }) {
return (
<span
data-ui="icon"
className={`size-3 rounded-full border border-solid border-white ${className}`}
/>
);
}
export function SelectButton(props: { className?: string; children?: React.ReactNode }) {
return (
<Button
data-ui="control"
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isPressed, isHovered, isDisabled }) =>
twMerge(
"group border-input relative flex w-full cursor-default items-center gap-x-1 rounded-md border text-start outline-hidden transition",
"ps-3 pe-2.5",
"py-[calc(--spacing(2.5)-1px)]",
"sm:py-[calc(--spacing(1.5)-1px)]",
"text-base/6 sm:text-sm/6",
"group-data-invalid:border-destructive",
isDisabled && "cursor-not-allowed opacity-50",
isHovered && ["bg-zinc-50 dark:bg-zinc-800"],
isPressed && ["bg-zinc-50 dark:bg-zinc-800"],
isHovered && isPressed && ["dark:bg-zinc-800"],
isFocusVisible && "border-ring ring-ring group-data-invalid:border-ring ring-1",
className
)
)}
>
{!!props.children && <span className="flex items-center gap-x-2">{props.children}</span>}
<SelectValue
data-ui="select-value"
className={twMerge([
"data-placeholder:text-muted flex-1 truncate dark:data-placeholder:text-white",
// Selected Item style
"*:data-[ui=content]:flex",
"*:data-[ui=content]:items-center",
"*:data-[ui=content]:gap-x-2",
"[&>[data-ui=content]_[data-ui=description]]:sr-only",
"[&>[data-ui=content]:not(:hover)_[data-ui=icon]:not([class*=text-])]:text-muted",
"[&>[data-ui=content]_[data-ui=icon]:not([class*=size-])]:size-5",
"[&>[data-ui=content]_[role=img]]:size-6",
"sm:[&>[data-ui=content]_[data-ui=icon]:not([class*=size-])]:size-4",
"sm:[&>[data-ui=content]_[role=img]]:size-5",
])}
/>
<Icon className="group-[&:not(:hover)]:text-muted size-5 sm:size-4">
<ChevronDownIcon />
</Icon>
</Button>
);
}
export function SelectPopover({ className, placement = "bottom", ...props }: PopoverProps) {
return (
<Popover
{...props}
className={composeTailwindRenderProps(className, ["w-(--trigger-width)"])}
placement={placement}
/>
);
}
export interface SelectListBoxProps<T>
extends ListBoxProps<T>,
React.RefAttributes<HTMLDivElement> {
checkIconPlacement?: "start" | "end";
}
export const SelectListBox = React.forwardRef(
<T extends object>(
{ checkIconPlacement = "end", ...props }: SelectListBoxProps<T>,
ref: React.Ref<HTMLDivElement> // Adjust ref type if ListBox renders something else
) => {
return (
<ListBox
{...props}
ref={ref} // Forward the ref to ListBox
data-check-icon-placement={checkIconPlacement}
className={composeTailwindRenderProps(props.className, [
"max-h-[inherit] overflow-auto",
"flex flex-col",
"p-1 has-[header]:pt-0",
// Listbox item
"**:data-[ui=content]:grid",
"**:data-[ui=content]:grid-cols-[minmax(--spacing(4),max-content)_1fr]",
"[&:has([data-ui=content]>[role=img])_[data-ui=content]]:grid-cols-[minmax(--spacing(5),max-content)_1fr]",
"[&:has([data-ui=content]>[role=img]+[data-ui=label]+[data-ui=description])_[data-ui=content]]:grid-cols-[minmax(--spacing(7),max-content)_1fr]",
"**:data-[ui=content]:items-center",
"**:data-[ui=content]:gap-x-2",
// Icon
"[&_[data-ui=content]>[data-ui=icon]:not([class*=size-])]:size-4",
"[&_[data-ui=content]:not(:hover)>[data-ui=icon]:not([class*=text-])]:text-muted",
// Label
"**:data-[ui=label]:col-span-full",
"[&:has(:is([data-ui=icon],[role=img])+[data-ui=label])_[data-ui=label]]:col-start-2",
"[&:has([data-ui=icon]+[data-ui=label])_[data-ui=content]:not(:has([data-ui=label]))]:ps-6",
"[&_[data-ui=label]:has(+[data-ui=description])]:leading-5",
// Description
"[&:has(:is([data-ui=icon],[role=img])+[data-ui=label])_[data-ui=description]]:col-start-2",
// Image
"[&_[role=img]]:size-5",
"[&:has([data-ui=description])_[role=img]]:size-7",
"[&_[role=img]]:self-start",
"[&_[role=img]]:mt-0.5",
"[&_[role=img]]:row-start-1",
"[&:has([data-ui=description])_[role=img]]:row-end-3",
])}
/>
);
}
) as <T extends object>(
props: SelectListBoxProps<T> & { ref?: React.Ref<HTMLDivElement> }
) => React.JSX.Element;
export interface SectionProps<T> extends RACListBoxSectionProps<T> {
title?: string | React.ReactNode;
}
export function SelectSection<T extends object>(props: SectionProps<T>) {
return (
<RACListBoxSection
className={twMerge(
"not-first:mt-1.5",
"border-border/75 not-first:border-t",
props.className
)}
>
<Header
className={twMerge(
"text-muted bg-background sticky z-10 truncate ps-8 pt-2 text-xs/6",
"inset-0 rounded-sm",
"in-data-[check-icon-placement=end]:px-2"
)}
>
{props.title}
</Header>
<Collection items={props.items}>{props.children}</Collection>
</RACListBoxSection>
);
}
interface SelectListItemProps extends ListBoxItemProps {
destructive?: true;
checkIcon?: React.ReactNode;
}
export const SelectListItem = React.forwardRef(
({ destructive, checkIcon, ...props }: SelectListItemProps, ref: React.Ref<HTMLLIElement>) => {
const textValue =
props.textValue || (typeof props.children === "string" ? props.children : undefined);
return (
<RACListBoxItem
{...props}
ref={ref}
textValue={textValue}
className={composeRenderProps(
props.className,
(className, { isFocused, isDisabled, isHovered }) =>
twMerge(
"group flex cursor-default items-center gap-x-1.5 rounded-sm outline-hidden select-none",
"px-2 py-2.5 text-base/6 sm:py-1.5 sm:text-sm/6",
isDisabled && "opacity-50",
(isFocused || isHovered) && "bg-zinc-100 dark:bg-zinc-800",
destructive && "text-destructive",
className
)
)}
>
{composeRenderProps(props.children, (children, { isSelected }) => {
return (
<>
{checkIcon !== null && (
<span
className={twMerge(
"flex h-[1lh] items-center self-start",
"in-data-[check-icon-placement=end]:hidden",
"in-data-[ui=select-value]:hidden",
isSelected ? "visible" : "invisible"
)}
>
{checkIcon ?? <CheckIcon className="size-4" />}
</span>
)}
<div data-ui="content" className="w-full">
{children}
</div>
{checkIcon !== null && (
<span
className={twMerge(
"flex h-[1lh] items-center self-start",
"in-data-[ui=select-value]:hidden",
"in-data-[check-icon-placement=start]:hidden",
isSelected ? "visible" : "invisible"
)}
>
{checkIcon ?? <CheckIcon className="size-4" />}
</span>
)}
</>
);
})}
</RACListBoxItem>
);
}
) as (props: SelectListItemProps & { ref?: React.Ref<HTMLLIElement> }) => React.JSX.Element;
export function SelectListItemLabel({ className, ...props }: React.JSX.IntrinsicElements["span"]) {
return (
<span
{...props}
slot="label"
data-ui="label"
className={twMerge("mb-0 w-full truncate", className)}
/>
);
}
export function SelectListItemDescription({
className,
...props
}: React.JSX.IntrinsicElements["span"]) {
return <Small {...props} slot="description" data-ui="description" className={className} />;
}

View file

@ -1,67 +0,0 @@
import { useSeparator } from "react-aria";
import { SeparatorProps as RACSeparatorProps } from "react-aria-components";
import { twMerge } from "tailwind-merge";
export type SeparatorProps = RACSeparatorProps & {
children?: React.ReactNode;
soft?: boolean;
} & React.JSX.IntrinsicElements["div"];
export function Separator({
orientation = "horizontal",
className,
soft = false,
children,
...props
}: SeparatorProps) {
const { separatorProps } = useSeparator({ orientation });
return (
<div
{...separatorProps}
className={twMerge(
"text-sm/6",
"[&>svg:not([class*=size])]:size-5",
children
? [
soft
? "before:border-border/75 after:border-border/75"
: "before:border-border after:border-border",
orientation === "vertical"
? [
"mx-4 flex flex-col items-center",
"before:content-['']",
"before:border-l",
"before:flex-1",
"after:content-['']",
"after:border-r",
"after:flex-1",
typeof children === "string" && ["before:mb-4 after:mt-4"],
]
: [
"self-stretch",
"my-2 flex items-center",
"before:content-['']",
"before:border-t",
"before:flex-1",
"after:content-['']",
"after:border-t",
"after:flex-1",
typeof children === "string" && ["before:me-4 after:ms-4"],
],
]
: [
soft ? "border-border/75" : "border-border",
orientation === "vertical"
? ["h-auto self-stretch border-l", typeof children === "string" && ["mx-1"]]
: ["h-px w-full self-stretch border-b", typeof children === "string" && ["my-1"]],
],
className
)}
{...props}
>
{children}
</div>
);
}

View file

@ -1,10 +0,0 @@
import { twMerge } from "tailwind-merge";
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={twMerge("animate-pulse rounded-md bg-zinc-200 dark:bg-zinc-700", className)}
{...props}
/>
);
}

View file

@ -1,110 +0,0 @@
import {
composeRenderProps,
Slider as RACSlider,
SliderProps as RACSliderProps,
SliderTrack as RACSliderTrack,
SliderRenderProps,
SliderThumb,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { composeTailwindRenderProps } from "./utils";
export { SliderOutput } from "react-aria-components";
export interface SliderProps<T> extends RACSliderProps<T> {
label?: string;
thumbLabels?: string[];
}
export function Slider<T extends number | number[]>(props: SliderProps<T>) {
return (
<RACSlider
{...props}
className={composeTailwindRenderProps(
props.className,
"flex flex-col gap-2 data-[orientation=horizontal]:min-w-64 data-[orientation=vertical]:items-center"
)}
/>
);
}
const trackStyle = [
"absolute rounded-full",
"group-data-[orientation=horizontal]:h-1.5",
"group-data-[orientation=horizontal]:w-full",
"group-data-[orientation=vertical]:top-1/2",
"group-data-[orientation=vertical]:left-1/2",
"group-data-[orientation=vertical]:h-full",
"group-data-[orientation=vertical]:w-[6px]",
"group-data-disabled:opacity-50",
];
export function SliderTack({ thumbLabels }: { thumbLabels?: string[] }) {
return (
<RACSliderTrack className="group relative flex w-full items-center data-[orientation=horizontal]:h-7 data-[orientation=vertical]:h-44 data-[orientation=vertical]:w-7">
{({ state, orientation }) => {
return (
<>
<div
className={twMerge(
"bg-zinc-200 group-data-[orientation=vertical]:-translate-x-1/2 group-data-[orientation=vertical]:-translate-y-1/2 dark:bg-zinc-600",
trackStyle
)}
/>
<div
className={twMerge("bg-accent", trackStyle)}
style={getTrackHighlightStyle(state, orientation)}
/>
{state.values.map((_, i) => (
<SliderThumb
key={i}
index={i}
aria-label={thumbLabels?.[i]}
className={composeRenderProps(
"",
(className, { isFocusVisible, isDragging, isDisabled }) =>
twMerge(
"border-accent size-4 rounded-full border border-2 bg-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] shadow-xl dark:border-3",
"group-data-[orientation=horizontal]:top-1/2 group-data-[orientation=vertical]:left-1/2",
isDragging && ["border-4 dark:border-4"],
isDisabled && "cursor-not-allowed opacity-50",
isFocusVisible && [
"outline",
"outline-2",
"outline-ring",
"outline-offset-2",
],
className
)
)}
/>
))}
</>
);
}}
</RACSliderTrack>
);
}
function getTrackHighlightStyle(
state: SliderRenderProps["state"],
orientation: SliderRenderProps["orientation"]
) {
const hasTwoThumbs = state.values.length == 2;
const highlightPercentage = hasTwoThumbs
? `${(state.getThumbPercent(1) - state.getThumbPercent(0)) * 100}%`
: `${state.getThumbPercent(0) * 100}%`;
const highlightStartPosition = hasTwoThumbs ? `${state.getThumbPercent(0) * 100}%` : "0";
return orientation === "horizontal"
? {
width: highlightPercentage,
left: highlightStartPosition,
}
: {
height: highlightPercentage,
bottom: highlightStartPosition,
top: "auto",
transform: "translate(-50%,0px)",
};
}

View file

@ -1,41 +0,0 @@
// https://www.jacobparis.com/content/react-as-child
import React from "react";
import { twMerge } from "tailwind-merge";
export type AsChildProps<DefaultElementProps> =
| ({ asChild?: false } & DefaultElementProps)
| { asChild: true; children: React.ReactNode };
type cloneElement = React.ReactElement<{
style?: React.CSSProperties;
className?: string;
}>;
export function Slot({
children,
...props
}: React.HTMLAttributes<HTMLElement> & {
children?: React.ReactNode;
}) {
if ("asChild" in props) {
delete props.asChild;
}
if (React.isValidElement(children) && typeof children.props === "object") {
return React.cloneElement(children as cloneElement, {
...props,
...children.props,
style: {
...props.style,
...(children as cloneElement).props?.style,
},
className: twMerge(props.className, (children as cloneElement).props?.className),
});
}
if (React.Children.count(children) > 1) {
React.Children.only(null);
}
return null;
}

View file

@ -1,135 +0,0 @@
import React from "react";
import {
composeRenderProps,
Group,
GroupProps,
Switch as RACSwitch,
SwitchProps as RACSwitchProps,
SwitchRenderProps,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { DescriptionContext, DescriptionProvider, LabeledGroup } from "./field";
import { composeTailwindRenderProps, groupBox } from "./utils";
export function SwitchGroup(props: GroupProps) {
return (
<LabeledGroup>
<Group {...props} className={composeTailwindRenderProps(props.className, groupBox)}></Group>
</LabeledGroup>
);
}
export function Switches({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
return (
<div
{...props}
data-ui="box"
className={twMerge(
"flex flex-col",
// When any switch item has description, apply all `font-medium` to all switch item labels
"has-data-[ui=description]:[&_label]:font-medium",
className
)}
/>
);
}
export function SwitchField({ className, ...props }: React.JSX.IntrinsicElements["div"]) {
return (
<DescriptionProvider>
<div
{...props}
data-ui="field"
className={twMerge(
"group flex flex-col gap-y-1",
"has-[label[data-label-placement=start]]:[&_[data-ui=description]:not([class*=pe-])]:pe-[calc(theme(width.8)+16px)]",
"has-[label[data-label-placement=end]]:[&_[data-ui=description]:not([class*=ps-])]:ps-[calc(theme(width.8)+12px)]",
"has-data-[ui=description]:[&_label]:font-medium",
"has-[label[data-disabled]]:**:data-[ui=description]:opacity-50",
className
)}
/>
</DescriptionProvider>
);
}
interface SwitchProps extends RACSwitchProps {
labelPlacement?: "start" | "end";
size?: "lg";
render?: never;
}
export interface CustomRenderSwitchProps extends Omit<RACSwitchProps, "children"> {
render: React.ReactElement | ((props: SwitchRenderProps) => React.ReactNode);
children?: never;
size?: never;
labelPlacement?: never;
}
export function Switch(props: SwitchProps | CustomRenderSwitchProps) {
const descriptionContext = React.useContext(DescriptionContext);
if (props.render) {
const { render, ...restProps } = props;
return (
<RACSwitch
{...restProps}
aria-describedby={descriptionContext?.["aria-describedby"]}
className={composeRenderProps(props.className, (className, { isDisabled }) =>
twMerge("group text-base/6 sm:text-sm/6", isDisabled && "opacity-50", className)
)}
>
{render}
</RACSwitch>
);
}
const { labelPlacement = "end", size, children, ...restProps } = props;
return (
<RACSwitch
{...restProps}
aria-describedby={descriptionContext?.["aria-describedby"]}
data-label-placement={labelPlacement}
className={composeRenderProps(props.className, (className, { isDisabled }) =>
twMerge(
"group flex items-center text-base/6 sm:text-sm/6",
labelPlacement === "start" && "flex-row-reverse justify-between",
isDisabled && "opacity-50",
className
)
)}
>
{(renderProps) => (
<>
<div
className={twMerge(
"dark:border-input flex h-6 w-11 shrink-0 cursor-default items-center rounded-full border border-zinc-200 bg-zinc-200 p-px dark:bg-transparent",
size !== "lg" && "sm:h-5 sm:w-8",
labelPlacement === "end" ? "me-3" : "ms-3",
renderProps.isReadOnly && "opacity-50",
renderProps.isSelected && "border-accent bg-accent dark:bg-accent dark:border-accent",
renderProps.isDisabled && "bg-gray-200 dark:bg-zinc-700",
renderProps.isFocusVisible && "outline-ring outline outline-offset-2"
)}
>
<span
data-ui="handle"
className={twMerge(
"size-5",
size !== "lg" && "sm:size-4",
"rounded-full bg-white shadow transition-all ease-in-out",
renderProps.isSelected && [
"translate-x-5 bg-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] rtl:-translate-x-5",
size !== "lg" && "sm:translate-x-3 sm:rtl:-translate-x-3",
]
)}
/>
</div>
{typeof children === "function" ? children(renderProps) : children}
</>
)}
</RACSwitch>
);
}

View file

@ -1,169 +0,0 @@
import {
Cell as AriaCell,
Column as AriaColumn,
Row as AriaRow,
Table as AriaTable,
TableHeader as AriaTableHeader,
Button,
CellProps,
Collection,
ColumnProps,
ColumnResizer,
composeRenderProps,
Group,
ResizableTableContainer,
RowProps,
TableHeaderProps,
TableProps,
useTableOptions,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { Checkbox } from "./checkbox";
import { ChevronUpIcon } from "./icons";
import { composeTailwindRenderProps } from "./utils";
export function Table(props: TableProps) {
return (
<ResizableTableContainer className="relative max-h-[280px] w-[550px] scroll-pt-[2.281rem] overflow-auto rounded-md border">
<AriaTable {...props} className="border-separate border-spacing-0" />
</ResizableTableContainer>
);
}
export function Column(props: ColumnProps) {
return (
<AriaColumn
{...props}
className={composeTailwindRenderProps(
props.className,
"cursor-default border-b text-start text-sm font-semibold focus-within:z-20 [&:hover]:z-20"
)}
>
{composeRenderProps(props.children, (children, { allowsSorting, sortDirection }) => (
<div className="flex items-center">
<Group
role="presentation"
tabIndex={-1}
className={composeRenderProps("", (className, { isFocusVisible }) =>
twMerge(
isFocusVisible
? "outline-ring rounded-sm outline outline-2 outline-offset-2"
: "outline-hidden",
"flex h-5 flex-1 items-center gap-1 overflow-hidden px-2",
className
)
)}
>
<span className="truncate">{children}</span>
{allowsSorting && (
<span
className={`flex size-4 items-center justify-center transition ${
sortDirection === "descending" ? "rotate-180" : ""
}`}
>
{sortDirection && <ChevronUpIcon className="size-4 text-muted" />}
</span>
)}
</Group>
{!props.width && (
<ColumnResizer
className={composeRenderProps("", (className, { isFocusVisible, isResizing }) =>
twMerge(
"box-content h-5 w-[1.5px] translate-x-[8px] cursor-col-resize rounded-sm bg-border bg-clip-content px-[8px] py-1",
isResizing && "resizing:w-[2px] resizing:bg-accent resizing:pl-[7px]",
isFocusVisible
? "outline-ring rounded-sm outline outline-2 -outline-offset-2"
: "outline-hidden",
className
)
)}
/>
)}
</div>
))}
</AriaColumn>
);
}
export function TableHeader<T extends object>(props: TableHeaderProps<T>) {
const { selectionBehavior, selectionMode, allowsDragging } = useTableOptions();
return (
<AriaTableHeader
{...props}
className={composeTailwindRenderProps(props.className, [
"sticky top-0 z-10 rounded-t-md backdrop-blur-md",
"after:content-['']",
"after:flex-1",
])}
>
{/* Add extra columns for drag and drop and selection. */}
{allowsDragging && <Column />}
{selectionBehavior === "toggle" && (
<AriaColumn
width={36}
minWidth={36}
className="cursor-default border-b p-2 text-start text-sm font-semibold"
>
{selectionMode === "multiple" && <Checkbox slot="selection" />}
</AriaColumn>
)}
<Collection items={props.columns}>{props.children}</Collection>
</AriaTableHeader>
);
}
export function Row<T extends object>({ id, columns, children, ...props }: RowProps<T>) {
const { selectionBehavior, allowsDragging } = useTableOptions();
return (
<AriaRow
id={id}
{...props}
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isSelected, isHovered, isDisabled }) =>
twMerge(
"group/row relative cursor-default select-none text-sm",
isDisabled && "text-muted",
isHovered && "bg-zinc-100 dark:bg-zinc-700",
isSelected && "bg-accent/5 dark:bg-accent/35",
isHovered && isSelected && "bg-zinc-100 dark:selected:bg-zinc-700",
isFocusVisible
? "outline-ring rounded-sm outline outline-2 -outline-offset-2"
: "outline-hidden",
className
)
)}
>
{allowsDragging && (
<Cell>
<Button slot="drag"></Button>
</Cell>
)}
{selectionBehavior === "toggle" && (
<Cell>
<Checkbox slot="selection" />
</Cell>
)}
<Collection items={columns}>{children}</Collection>
</AriaRow>
);
}
export function Cell(props: CellProps) {
return (
<AriaCell
{...props}
className={composeRenderProps(props.className, (className, { isFocusVisible }) =>
twMerge(
"truncate border-b p-2 group-last/row:border-b-0",
isFocusVisible
? "outline-ring rounded-sm outline outline-2 -outline-offset-2"
: "outline-hidden",
className
)
)}
/>
);
}

View file

@ -1,189 +0,0 @@
import React from "react";
import {
composeRenderProps,
Tab as RACTab,
TabList as RACTabList,
TabPanel as RACTabPanel,
TabsProps as RACTabProps,
Tabs as RACTabs,
TabListProps,
TabPanelProps,
TabProps,
TabRenderProps,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
type Orientation = "vertical" | "horizontal";
type Variant = "underline" | "pills" | "segment";
const TabsContext = React.createContext<{
variant: Variant;
orientation: Orientation;
}>({
variant: "underline",
orientation: "horizontal",
});
export type TabsProps = RACTabProps &
({ variant?: "underline" | "pills" } | { variant: "segment"; orientation?: never });
export function Tabs({
variant = "underline",
orientation = "horizontal",
keyboardActivation = "manual",
...props
}: TabsProps) {
return (
<TabsContext.Provider value={{ variant, orientation }}>
<RACTabs
{...props}
keyboardActivation={keyboardActivation}
orientation={orientation}
className={composeRenderProps(props.className, (className) =>
twMerge([
"group flex",
orientation === "horizontal" ? "flex-col" : "flex-col sm:flex-row",
className,
])
)}
/>
</TabsContext.Provider>
);
}
const tabList = {
base: {
horizontal: "whitespace-nowrap",
vertical: "flex-col flex-1 sm:flex-initial",
},
underline: {
horizontal: "w-full space-x-4 border-b",
vertical: "space-y-3.5 self-start border-l",
},
pills: {
horizontal: "space-x-4",
vertical: "space-y-2",
},
segment: {
horizontal: "p-0.5 rounded-lg bg-zinc-200/75 dark:bg-zinc-800 shadow-xs",
vertical: "",
},
};
export function TabList<T extends object>(props: TabListProps<T>) {
const { variant, orientation } = React.useContext(TabsContext);
return (
<div className="flex overflow-x-auto pb-px pl-px">
<RACTabList
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge([
"flex",
"text-base/6 sm:text-sm/6",
tabList.base[orientation],
tabList[variant][orientation],
className,
])
)}
/>
</div>
);
}
const tabPanel = {
underline: {
horizontal: ["py-4"],
vertical: ["px-4"],
},
pills: {
horizontal: ["px-5 py-4"],
vertical: ["p-4 sm:pl-8 sm:py-0"],
},
segment: {
horizontal: ["px-3 py-4"],
vertical: [],
},
};
export function TabPanel(props: TabPanelProps) {
const { variant, orientation } = React.useContext(TabsContext);
return (
<RACTabPanel
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge(["flex-1 outline-hidden", tabPanel[variant][orientation], className])
)}
/>
);
}
const tab = ({
isSelected,
isDisabled,
isHovered,
isFocusVisible,
variant,
orientation,
}: {
variant: Variant;
orientation: Orientation;
} & TabRenderProps) => {
const style = {
base: [
"outline-hidden relative flex items-center gap-x-3 rounded-md font-medium",
"[&>[data-ui=icon]:not([class*=size-])]:size-5",
isDisabled && "opacity-50",
isSelected || isHovered ? "text-foreground" : "text-muted",
isFocusVisible && "ring-2 ring-inset ring-ring",
],
underline: {
base: "before:absolute before:bg-accent",
horizontal: [
"p-2 before:bottom-[-1.5px] before:w-full before:inset-x-0",
isSelected && "before:h-[2px]",
],
vertical: [
"px-4",
"before:inset-y-0",
isSelected && "before:bg-accent before:left-[-1.5px] before:w-[2px]",
],
},
pills: {
base: ["flex items-center px-3 py-2", isSelected && "bg-zinc-100 dark:bg-zinc-600/45"],
horizontal: "",
vertical: "",
},
segment: {
base: [
"flex-1 justify-center px-6 py-1 [&>[data-ui=icon]:not([class*=size-])]:size-4",
isSelected && "bg-background dark:bg-zinc-500 text-foreground shadow-2xs rounded-md",
],
horizontal: "",
vertical: "",
},
};
return [style.base, style[variant].base, style[variant][orientation]];
};
export function Tab(props: TabProps) {
const { variant, orientation } = React.useContext(TabsContext);
return (
<RACTab
{...props}
className={composeRenderProps(props.className, (className, renderProps) => {
return twMerge(
tab({
variant,
orientation,
...renderProps,
}),
className
);
})}
/>
);
}

View file

@ -1,103 +0,0 @@
import React from "react";
import {
Tag as AriaTag,
TagGroup as AriaTagGroup,
TagGroupProps as AriaTagGroupProps,
TagProps as AriaTagProps,
Button,
composeRenderProps,
TagList as RACTagList,
TagListProps,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { XIcon } from "./icons";
import { composeTailwindRenderProps } from "./utils";
const colors = {
default: "[--tag:var(--color-accent)]",
success: "[--tag:var(--color-success)]",
warning: "[--tag:var(--color-warning)]",
destructive: "[--tag:var(--destructive)]",
} as const;
type Color = keyof typeof colors;
const ColorContext = React.createContext<Color>("default");
export interface TagGroupProps extends AriaTagGroupProps {
color?: Color;
}
export interface TagProps extends AriaTagProps {
color?: Color;
}
export function TagGroup({ children, ...props }: TagGroupProps) {
return (
<AriaTagGroup {...props} className={twMerge("flex flex-col gap-1", props.className)}>
<ColorContext.Provider value={props.color || "default"}>{children}</ColorContext.Provider>
</AriaTagGroup>
);
}
export function TagList<T extends object>(props: TagListProps<T>) {
return (
<RACTagList
{...props}
className={composeTailwindRenderProps(props.className, "flex flex-wrap gap-1")}
/>
);
}
export function Tag({ children, color, ...props }: TagProps) {
const textValue = typeof children === "string" ? children : undefined;
const groupColor = React.useContext(ColorContext);
const tagColor = color ?? groupColor ?? "default";
return (
<AriaTag
textValue={textValue}
{...props}
className={composeRenderProps(
props.className,
(className, { isFocusVisible, isDisabled, isSelected }) =>
twMerge(
"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-selection-mode:cursor-pointer",
colors[tagColor],
isSelected
? "bg-(--tag) text-[lch(from_var(--tag)_calc((49.44_-_l)_*_infinity)_0_0)]"
: "bg-(--tag)/15 text-(--tag)",
isFocusVisible && "outline-ring outline outline-2 outline-offset-1",
isDisabled && "opacity-50",
className
)
)}
>
{(renderProps) => {
return (
<>
{typeof children === "function" ? children(renderProps) : children}
{renderProps.allowsRemoving && (
<Button
slot="remove"
className={composeRenderProps(
"",
(className, { isPressed, isHovered, isFocusVisible }) =>
twMerge(
"flex cursor-default items-center justify-center rounded-full p-0.5 outline-0 transition-[background-color]",
isHovered && "pressed: bg-black/10 dark:bg-white/10",
isPressed && "bg-black/20 dark:bg-white/20",
isFocusVisible && "outline-ring outline outline-2 outline-offset-2",
className
)
)}
>
<XIcon className="size-3"></XIcon>
</Button>
)}
</>
);
}}
</AriaTag>
);
}

View file

@ -1,157 +0,0 @@
import React from "react";
import { type Key, LabelContext, TextFieldProps } from "react-aria-components";
import { ListData } from "react-stately";
import { twMerge } from "tailwind-merge";
import { Input, TextField } from "./field";
import { Tag, TagGroup, TagList } from "./tag-group";
interface TagItem {
id: number;
name: string;
}
interface ContextType {
list: ListData<TagItem>;
onTagAdd?: (tag: TagItem) => void;
onTagRemove?: (tag: TagItem) => void;
}
const TagInputContext = React.createContext<ContextType | null>(null);
function useTagInputContext() {
const context = React.useContext(TagInputContext);
if (!context) {
throw new Error("<TagInputContext.Provider> is required");
}
return context;
}
export interface TagInputProps extends Omit<ContextType, "tagGroupId">, TextFieldProps {
children: React.ReactNode;
className?: string;
}
export function TagsInputField({ list, name, onTagRemove, onTagAdd, ...props }: TagInputProps) {
return (
<TagInputContext.Provider value={{ list, onTagAdd, onTagRemove }}>
<TextField {...props} />
{name && (
<input name={name} hidden readOnly value={list.items.map(({ name }) => name).join(",")} />
)}
</TagInputContext.Provider>
);
}
export function TagsInput({ className }: { className?: string; children?: React.ReactNode }) {
const [inputValue, setInputValue] = React.useState("");
const { list, onTagAdd, onTagRemove } = useTagInputContext();
const deleteLast = React.useCallback(() => {
if (list.items.length == 0) {
return;
}
const lastKey = list.items[list.items.length - 1];
if (lastKey !== null) {
list.remove(lastKey.id);
const item = list.getItem(lastKey.id);
if (item) {
onTagRemove?.(item);
}
}
}, [list, onTagRemove]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" || e.key === "," || e.key === ";") {
e.preventDefault();
addTag();
}
if (e.key === "Backspace" && inputValue === "") {
deleteLast();
}
}
function addTag() {
const tagNames = inputValue.split(/[,;]/);
tagNames.forEach((tagName) => {
const formattedName = tagName
.trim()
.replace(/\s\s+/g, " ")
.replace(/\t|\\t|\r|\\r|\n|\\n/g, "");
if (formattedName === "") {
return;
}
const hasTagExists = list.items.find(
({ name }) => name.toLocaleLowerCase() === formattedName.toLocaleLowerCase()
);
if (!hasTagExists) {
const tag = {
id: (list.items[list.items.length - 1]?.id || 0) + 1,
name: formattedName,
};
list.append(tag);
onTagAdd?.(tag);
}
});
setInputValue("");
}
function handleRemove(keys: Set<Key>) {
list.remove(...keys);
const item = list.getItem([...keys][0]);
if (item) {
onTagRemove?.(item);
}
}
const { id: labelId } = (React.useContext(LabelContext) ?? {}) as {
id?: string;
};
return (
<TagGroup
aria-labelledby={labelId}
onRemove={handleRemove}
className={twMerge(className, "w-full")}
data-ui="control"
>
<div
className={twMerge(
"flex min-h-9 items-center rounded-md",
"border has-[input[data-focused=true]]:border-ring",
"has-[input[data-invalid=true][data-focused=true]]:border-ring has-[input[data-invalid=true]]:border-destructive",
"has-[input[data-focused=true]]:ring-1 has-[input[data-focused=true]]:ring-ring"
)}
>
<div className="inline-flex flex-1 flex-wrap items-center gap-1 px-2 py-[5px]">
<TagList items={list.items} className="contents">
{(item) => <Tag>{item.name}</Tag>}
</TagList>
<div className="flex flex-1">
<Input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
}}
onKeyDown={handleKeyDown}
className="border-0 px-0.5 py-0 focus:ring-0 sm:py-0"
/>
</div>
</div>
</div>
</TagGroup>
);
}

View file

@ -1,44 +0,0 @@
import React from "react";
import { LinkProps, TextProps } from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { Link } from "./link";
import { composeTailwindRenderProps } from "./utils";
export function Text({ className, elementType, children, ...props }: TextProps) {
return React.createElement(
elementType ?? "p",
{
...props,
className: twMerge("text-pretty text-base text-muted sm:text-sm/6", className),
},
children
);
}
export function Strong({ className, ...props }: React.JSX.IntrinsicElements["strong"]) {
return (
<Text
{...props}
elementType="strong"
className={twMerge("font-medium text-foreground", className)}
/>
);
}
export function Small({ className, ...props }: React.JSX.IntrinsicElements["small"]) {
return (
<Text {...props} elementType="small" className={twMerge("text-sm sm:text-xs", className)} />
);
}
export function TextLink(props: LinkProps) {
return (
<Link
{...props}
className={composeTailwindRenderProps(
props.className,
"underline underline-offset-4 decoration-zinc-400 dark:decoration-zinc-500"
)}
/>
);
}

View file

@ -1,28 +0,0 @@
import {
composeRenderProps,
TimeField as RACTimeField,
TimeFieldProps as RACTimeFieldProps,
TimeValue,
} from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { inputField } from "./utils";
export interface TimeFieldProps<T extends TimeValue> extends RACTimeFieldProps<T> {}
export function TimeField<T extends TimeValue>(props: RACTimeFieldProps<T>) {
return (
<RACTimeField
{...props}
className={composeRenderProps(props.className, (className, { isDisabled }) => {
return twMerge(
inputField,
"items-start",
// RAC does not set disable to time field when it is disable
// So we have to style disable state for none input
isDisabled && "[&>:not(input)]:opacity-50",
className
);
})}
/>
);
}

View file

@ -1,77 +0,0 @@
import React from "react";
export type TimeOption = {
hour: number;
minute: number;
value: string;
id: string;
};
export function useTimePicker({
intervalInMinute,
format = "24h",
}: {
intervalInMinute: 15 | 30;
format?: "12h" | "24h";
}): Array<TimeOption> {
return React.useMemo(() => {
const options = [];
if (format === "12h") {
for (let hour = 0; hour < 24; hour++) {
const period = hour >= 12 ? "PM" : "AM";
let hourIn12Format = hour % 12;
if (hourIn12Format === 0) {
hourIn12Format = 12;
}
for (let interval = 0; interval < Math.floor(60 / intervalInMinute); interval++) {
const minutes = interval * intervalInMinute;
options.push({
hour,
minute: minutes,
value: `${hourIn12Format}:${minutes === 0 ? "00" : minutes} ${period}`,
id: `${hourIn12Format}:${minutes === 0 ? "00" : minutes} ${period}`,
});
}
}
} else {
for (let hour = 0; hour < 24; hour++) {
for (let interval = 0; interval < Math.floor(60 / intervalInMinute); interval++) {
const minutes = interval * intervalInMinute;
options.push({
hour,
minute: minutes,
value: `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`,
id: `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`,
});
}
}
}
return options;
}, [intervalInMinute]);
}
export function getRoundMinute({
intervalInMinute,
minute,
}: {
intervalInMinute: number;
minute: number;
}) {
const closeMinute = Array(60 / intervalInMinute + 1)
.fill(0)
.map((_, i) => {
return intervalInMinute * i;
})
.find((i) => {
return i > minute;
});
if (closeMinute) {
return closeMinute - minute;
}
return 0;
}

View file

@ -1,54 +0,0 @@
import React from "react";
import { FocusableOptions, mergeProps, useFocusable } from "react-aria";
import { Tooltip as RACTooltip, TooltipProps as RACTooltipProps } from "react-aria-components";
import { composeTailwindRenderProps } from "./utils";
export { TooltipTrigger } from "react-aria-components";
export interface TooltipProps extends Omit<RACTooltipProps, "children"> {
children: React.ReactNode;
}
export function Tooltip({ children, ...props }: TooltipProps) {
return (
<RACTooltip
{...props}
offset={6}
className={composeTailwindRenderProps(props.className, [
"group max-w-64 rounded-md px-3 py-1.5",
"text-wrap text-pretty",
"shadow-2xs dark:border dark:shadow-none",
React.Children.toArray(children).every((child) => typeof child === "string")
? "bg-zinc-950 text-xs text-white dark:bg-zinc-800"
: "border bg-background",
])}
>
{children}
</RACTooltip>
);
}
// https://argos-ci.com/blog/react-aria-migration
export function NonFousableTooltipTarget(props: { children: React.ReactElement }) {
const triggerRef = React.useRef(null);
const { focusableProps } = useFocusable(props.children.props as FocusableOptions, triggerRef);
return React.cloneElement(
props.children,
mergeProps(
focusableProps,
{ tabIndex: 0 },
props.children.props as React.HTMLProps<HTMLElement>,
{
ref: triggerRef,
}
)
);
}
export function NativeTooltip({
title,
...props
}: React.JSX.IntrinsicElements["div"] & { title: string }) {
return <div title={title} role="presentation" {...props} />;
}

View file

@ -1,57 +0,0 @@
import { composeRenderProps } from "react-aria-components";
import { ClassNameValue, twMerge } from "tailwind-merge";
export function composeTailwindRenderProps<T>(
className: string | ((v: T) => string) | undefined,
tw: string | ClassNameValue
): string | ((v: T) => string) {
return composeRenderProps(className, (className) => twMerge(tw, className));
}
// RAC uses `slot=*`. We use `data-ui=* to avoid potential conflict
export const inputField = [
"group",
// Label style
"[&_[data-ui=label]:not([class*=mb-])]:mb-1",
"[&_[data-ui=label]:not([class*=mb-]):has(+:is(input,textarea,[data-ui=control]))]:mb-2",
// Description style
"[&>:is(input,[data-ui=control])+[data-ui=description]:not([class*=mt-])]:mt-2",
"[&>textarea+[data-ui=description]:not([class*=mt-])]:mt-0.5",
"[&_[data-ui=description]:not([class*=mb-]):has(+:is(input,textarea,[data-ui=control]))]:mb-3",
// Error
"[&>:is(input,textarea,[data-ui=control])+[data-ui=errorMessage]:not([class*=mt-])]:mt-2",
"[&:has([data-ui=description]+[data-ui=errorMessage])_[data-ui=errorMessage]]:mt-1",
].join(" ");
export const groupBox = [
"group flex flex-col",
// Group description style
"[&_[data-ui=description]:not([class*=mt-]):has(+[data-ui=box])]:mt-1",
"[&_[data-ui=description]:not([class*=mt-]):has(+[data-ui=box])]:mb-4",
// Group box style
"[&:not(:has([data-ui=description]+[data-ui=box]))>[data-ui=box]:not([class*=mt-])]:mt-3",
"[&:has(:is([type=checkbox],[type=radio],[role=switch]))_[data-ui=box]:not([class*=gap-])]:gap-y-3",
// Box item description inside
"[&:has(:is([type=checkbox],[type=radio],[role=switch]))_[data-ui=box]:has([data-ui=description]):not([class*=gap-y])]:gap-y-4",
// Horizontal
"[&[data-orientation=horizontal]:has(:is([type=checkbox],[type=radio],[role=switch]))_[data-ui=box]:not([class*=gap-x-])]:gap-x-4",
"[&[data-orientation=horizontal]:has(:is([type=checkbox],[type=radio],[role=switch]))_[data-ui=box]:not([class*=gap-y-])]:gap-y-2",
// Error
"[&:has([data-ui=box]+[data-ui=errorMessage])_[data-ui=errorMessage]]:mt-2",
].join(" ");
export const displayLevels = {
1: "font-semibold text-2xl",
2: "font-semibold text-base",
3: "font-medium text-base sm:text-sm/6",
};
export type DisplayLevel = keyof typeof displayLevels;