commit
bb3f508776
75 changed files with 656 additions and 6660 deletions
9
api/package-lock.json
generated
9
api/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
181
ui/src/components/EmbedConfigModal.tsx
Normal file
181
ui/src/components/EmbedConfigModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { Badge } from "./badge";
|
||||
export type { BadgeColor } from "./badge.styles";
|
||||
export { getBadgeStyles } from "./badge.styles";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { FileTrigger } from "react-aria-components";
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}>
|
||||
…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)",
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue