install packages

This commit is contained in:
2025-12-24 11:20:50 +03:30
parent 87b5f80f1f
commit c5872fb762
91 changed files with 24445 additions and 4271 deletions

View File

@@ -0,0 +1,82 @@
"use client";
import { useBottomSheetStore } from "@/stores/useBottomSheetStore";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect } from "react";
import { createPortal } from "react-dom";
const BottomSheet = () => {
const { isOpen, content, options, closeBottomSheet } = useBottomSheetStore();
const interactive = options?.interactiveBackground ?? false;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
// Escape key (only active for modal mode)
useEffect(() => {
if (!isOpen || interactive) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") closeBottomSheet();
};
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "unset";
};
}, [isOpen, interactive, closeBottomSheet]);
return createPortal(
<AnimatePresence>
{isOpen && (
<>
{/* Non-interactive background mode (modal) */}
{!interactive && (
<motion.div
key="modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-50 bg-black/50"
onClick={closeBottomSheet}
/>
)}
{/* The sheet (works in both modes) */}
<motion.div
key="sheet"
initial={{ y: viewportHeight }}
animate={{ y: 0 }}
exit={{ y: viewportHeight }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.25 }}
onDragEnd={(e, info) => {
if (info.offset.y > 100 || info.velocity.y > 500) {
closeBottomSheet();
}
}}
className="bg-pwa-primary fixed inset-x-0 bottom-0 z-[60] mx-auto w-full max-w-md rounded-t-xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-center py-2">
<div className="h-1 w-12 rounded-full bg-gray-400" />
</div>
<div className="overflow-y-auto p-4" style={{ maxHeight: "90vh" }} dir="rtl">
{content}
</div>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
};
export default BottomSheet;

View File

@@ -0,0 +1,161 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { Chevron, DayButton, getDefaultClassNames } from "react-day-picker";
import { DayPicker } from "react-day-picker/persian";
interface CalendarHijriProps {
selected?: Date;
onSelect?: (date: Date | undefined) => void;
defaultMonth?: Date;
className?: string;
}
export function CalendarHijri({ selected, onSelect, defaultMonth, className }: CalendarHijriProps) {
return (
<Calendar
mode="single"
defaultMonth={defaultMonth ?? selected ?? new Date()}
selected={selected}
onSelect={onSelect}
className={cn("rounded-lg shadow-sm", className)}
/>
);
}
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker>) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("bg-text text-desktop-primary p-3 [--cell-size:--spacing(10)]", className)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
month: cn("flex flex-col w-full gap-3", defaultClassNames.month),
nav: cn("flex items-center w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
button_previous: cn(
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
"size-(--cell-size) aria-disabled:opacity-50 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-text-desktop-primary [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex items-center justify-center", defaultClassNames.weekdays),
weekday: cn(
"text-neo-aqua rounded-md flex-1 font-normal text-sm select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
week_number: cn("text-sm select-none text-desktop-primary", defaultClassNames.week_number),
day: cn(
"relative w-full h-full text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-neo-aqua/10 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-text-desktop-primary aria-selected:text-text-desktop-primary",
defaultClassNames.outside
),
disabled: cn("text-text-desktop-primary opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => (
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
),
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <Chevron className={cn("size-4 rotate-180", className)} {...props} />;
}
if (orientation === "right") {
return <Chevron className={cn("size-4 justify-self-end", className)} {...props} />;
}
return <Chevron className={cn("size-4 rotate-90", className)} {...props} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
),
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<button
ref={ref}
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-neo-aqua data-[selected-single=true]:text-text data-[range-middle=true]:bg-neo-aqua/20 data-[range-middle=true]:text-text data-[range-start=true]:bg-neo-aqua/20 data-[range-start=true]:text-text data-[range-end=true]:bg-neo-aqua/20 data-[range-end=true]:text-text flex aspect-square size-auto w-full min-w-(--cell-size) flex-col items-center justify-center gap-1 rounded leading-none group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:scale-90 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useModalStore } from "@/stores/useModalStore";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
const Modal = () => {
const isOpen = useModalStore((s) => s.isOpen);
const content = useModalStore((s) => s.content);
const closeModal = useModalStore((s) => s.closeModal);
const modalRef = useRef<HTMLDivElement>(null);
// Handle escape key to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") closeModal();
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden"; // Prevent scrolling
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "unset";
};
}, [isOpen, closeModal]);
const handleClickOutside = (e: React.MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
closeModal();
}
};
if (!isOpen) return null;
return createPortal(
<AnimatePresence>
<motion.div
role="dialog"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={handleClickOutside}
>
<motion.div
ref={modalRef}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.3 }}
className="relative rounded-lg shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{content}
</motion.div>
</motion.div>
</AnimatePresence>,
document.body
);
};
export default Modal;

View File

@@ -0,0 +1,42 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,147 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronIcon } from "@/assets";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronIcon className="text-text size-4 rotate-270" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon size={4} />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronIcon className="text-desktop-primary size-4 rotate-90" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronIcon className="text-desktop-primary size-4 rotate-270" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
import { HTMLAttributes } from "react";
type SeparatorProps = {
className?: HTMLAttributes<HTMLDivElement> | string | undefined;
vertical?: boolean;
};
export default function Separator({ className, vertical }: SeparatorProps) {
if (vertical) {
return <div className={cn("border-text/15 h-full border-r", className)} />;
}
return <div className={cn("border-text/15 w-full border-b", className)} />;
}

View File

@@ -0,0 +1,492 @@
"use client";
import {
type Announcements,
closestCenter,
closestCorners,
DndContext,
type DndContextProps,
type DragEndEvent,
type DraggableAttributes,
type DraggableSyntheticListeners,
DragOverlay,
type DragStartEvent,
type DropAnimation,
defaultDropAnimationSideEffects,
KeyboardSensor,
MouseSensor,
type ScreenReaderInstructions,
TouchSensor,
type UniqueIdentifier,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
type SortableContextProps,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
const orientationConfig = {
vertical: {
modifiers: [restrictToVerticalAxis, restrictToParentElement],
strategy: verticalListSortingStrategy,
collisionDetection: closestCenter,
},
horizontal: {
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
strategy: horizontalListSortingStrategy,
collisionDetection: closestCenter,
},
mixed: {
modifiers: [restrictToParentElement],
strategy: undefined,
collisionDetection: closestCorners,
},
};
const ROOT_NAME = "Sortable";
const CONTENT_NAME = "SortableContent";
const ITEM_NAME = "SortableItem";
const ITEM_HANDLE_NAME = "SortableItemHandle";
const OVERLAY_NAME = "SortableOverlay";
interface SortableRootContextValue<T> {
id: string;
items: UniqueIdentifier[];
modifiers: DndContextProps["modifiers"];
strategy: SortableContextProps["strategy"];
activeId: UniqueIdentifier | null;
setActiveId: (id: UniqueIdentifier | null) => void;
getItemValue: (item: T) => UniqueIdentifier;
flatCursor: boolean;
}
const SortableRootContext = React.createContext<SortableRootContextValue<unknown> | null>(null);
function useSortableContext(consumerName: string) {
const context = React.useContext(SortableRootContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface GetItemValue<T> {
/**
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
* @example getItemValue={(item) => item.id}
*/
getItemValue: (item: T) => UniqueIdentifier;
}
type SortableRootProps<T> = DndContextProps &
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
value: T[];
onValueChange?: (items: T[]) => void;
onMove?: (event: DragEndEvent & { activeIndex: number; overIndex: number }) => void;
strategy?: SortableContextProps["strategy"];
orientation?: "vertical" | "horizontal" | "mixed";
flatCursor?: boolean;
};
function SortableRoot<T>(props: SortableRootProps<T>) {
const {
value,
onValueChange,
collisionDetection,
modifiers,
strategy,
onMove,
orientation = "vertical",
flatCursor = false,
getItemValue: getItemValueProp,
accessibility,
...sortableProps
} = props;
const id = React.useId();
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const config = React.useMemo(() => orientationConfig[orientation], [orientation]);
const getItemValue = React.useCallback(
(item: T): UniqueIdentifier => {
if (typeof item === "object" && !getItemValueProp) {
throw new Error("getItemValue is required when using array of objects");
}
return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier);
},
[getItemValueProp]
);
const items = React.useMemo(() => {
return value.map((item) => getItemValue(item));
}, [value, getItemValue]);
const onDragStart = (event: DragStartEvent) => {
sortableProps.onDragStart?.(event);
if (event.activatorEvent.defaultPrevented) return;
setActiveId(event.active.id);
};
const onDragEnd = (event: DragEndEvent) => {
sortableProps.onDragEnd?.(event);
if (event.activatorEvent.defaultPrevented) return;
const { active, over } = event;
if (over && active.id !== over?.id) {
const activeIndex = value.findIndex((item) => getItemValue(item) === active.id);
const overIndex = value.findIndex((item) => getItemValue(item) === over.id);
if (onMove) {
onMove({ ...event, activeIndex, overIndex });
} else {
onValueChange?.(arrayMove(value, activeIndex, overIndex));
}
}
setActiveId(null);
};
const onDragCancel = (event: DragEndEvent) => {
sortableProps.onDragCancel?.(event);
if (event.activatorEvent.defaultPrevented) return;
setActiveId(null);
};
const announcements: Announcements = React.useMemo(
() => ({
onDragStart({ active }) {
const activeValue = active.id.toString();
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
},
onDragOver({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
const activeIndex = active.data.current?.sortable.index ?? 0;
const moveDirection = overIndex > activeIndex ? "down" : "up";
const activeValue = active.id.toString();
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
}
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
},
onDragEnd({ active, over }) {
const activeValue = active.id.toString();
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
}
return `Sortable item "${activeValue}" dropped. No changes were made.`;
},
onDragCancel({ active }) {
const activeIndex = active.data.current?.sortable.index ?? 0;
const activeValue = active.id.toString();
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
},
onDragMove({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
const activeIndex = active.data.current?.sortable.index ?? 0;
const moveDirection = overIndex > activeIndex ? "down" : "up";
const activeValue = active.id.toString();
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
}
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
},
}),
[value]
);
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
() => ({
draggable: `
To pick up a sortable item, press space or enter.
While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
Press space or enter again to drop the item in its new position, or press escape to cancel.
`,
}),
[orientation]
);
const contextValue = React.useMemo(
() => ({
id,
items,
modifiers: modifiers ?? config.modifiers,
strategy: strategy ?? config.strategy,
activeId,
setActiveId,
getItemValue,
flatCursor,
}),
[id, items, modifiers, strategy, config.modifiers, config.strategy, activeId, getItemValue, flatCursor]
);
return (
<SortableRootContext.Provider value={contextValue as SortableRootContextValue<unknown>}>
<DndContext
collisionDetection={collisionDetection ?? config.collisionDetection}
modifiers={modifiers ?? config.modifiers}
sensors={sensors}
{...sortableProps}
id={id}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
accessibility={{
announcements,
screenReaderInstructions,
...accessibility,
}}
/>
</SortableRootContext.Provider>
);
}
const SortableContentContext = React.createContext<boolean>(false);
interface SortableContentProps extends React.ComponentProps<"div"> {
strategy?: SortableContextProps["strategy"];
children: React.ReactNode;
asChild?: boolean;
withoutSlot?: boolean;
}
function SortableContent(props: SortableContentProps) {
const { strategy: strategyProp, asChild, withoutSlot, children, ref, ...contentProps } = props;
const context = useSortableContext(CONTENT_NAME);
const ContentPrimitive = asChild ? Slot : "div";
return (
<SortableContentContext.Provider value={true}>
<SortableContext items={context.items} strategy={strategyProp ?? context.strategy}>
{withoutSlot ? (
children
) : (
<ContentPrimitive data-slot="sortable-content" {...contentProps} ref={ref}>
{children}
</ContentPrimitive>
)}
</SortableContext>
</SortableContentContext.Provider>
);
}
interface SortableItemContextValue {
id: string;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners | undefined;
setActivatorNodeRef: (node: HTMLElement | null) => void;
isDragging?: boolean;
disabled?: boolean;
}
const SortableItemContext = React.createContext<SortableItemContextValue | null>(null);
function useSortableItemContext(consumerName: string) {
const context = React.useContext(SortableItemContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
}
return context;
}
interface SortableItemProps extends React.ComponentProps<"div"> {
value: UniqueIdentifier;
asHandle?: boolean;
asChild?: boolean;
disabled?: boolean;
}
function SortableItem(props: SortableItemProps) {
const { value, style, asHandle, asChild, disabled, className, ref, ...itemProps } = props;
const inSortableContent = React.useContext(SortableContentContext);
const inSortableOverlay = React.useContext(SortableOverlayContext);
if (!inSortableContent && !inSortableOverlay) {
throw new Error(`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``);
}
if (value === "") {
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
}
const context = useSortableContext(ITEM_NAME);
const id = React.useId();
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
id: value,
disabled,
});
const composedRef = useComposedRefs(ref, (node) => {
if (disabled) return;
setNodeRef(node);
if (asHandle) setActivatorNodeRef(node);
});
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
transform: CSS.Translate.toString(transform),
transition,
...style,
};
}, [transform, transition, style]);
const itemContext = React.useMemo<SortableItemContextValue>(
() => ({
id,
attributes,
listeners,
setActivatorNodeRef,
isDragging,
disabled,
}),
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled]
);
const ItemPrimitive = asChild ? Slot : "div";
return (
<SortableItemContext.Provider value={itemContext}>
<ItemPrimitive
id={id}
data-disabled={disabled}
data-dragging={isDragging ? "" : undefined}
data-slot="sortable-item"
{...itemProps}
{...(asHandle && !disabled ? attributes : {})}
{...(asHandle && !disabled ? listeners : {})}
ref={composedRef}
style={composedStyle}
className={cn(
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
{
"touch-none select-none": asHandle,
"cursor-default": context.flatCursor,
"data-dragging:cursor-grabbing": !context.flatCursor,
"cursor-grab": !isDragging && asHandle && !context.flatCursor,
"opacity-50": isDragging,
"pointer-events-none opacity-50": disabled,
},
className
)}
/>
</SortableItemContext.Provider>
);
}
interface SortableItemHandleProps extends React.ComponentProps<"button"> {
asChild?: boolean;
}
function SortableItemHandle(props: SortableItemHandleProps) {
const { asChild, disabled, className, ref, ...itemHandleProps } = props;
const context = useSortableContext(ITEM_HANDLE_NAME);
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
const isDisabled = disabled ?? itemContext.disabled;
const composedRef = useComposedRefs(ref, (node) => {
if (!isDisabled) return;
itemContext.setActivatorNodeRef(node);
});
const HandlePrimitive = asChild ? Slot : "button";
return (
<HandlePrimitive
type="button"
aria-controls={itemContext.id}
data-disabled={isDisabled}
data-dragging={itemContext.isDragging ? "" : undefined}
data-slot="sortable-item-handle"
{...itemHandleProps}
{...(isDisabled ? {} : itemContext.attributes)}
{...(isDisabled ? {} : itemContext.listeners)}
ref={composedRef}
className={cn(
"select-none disabled:pointer-events-none disabled:opacity-50",
context.flatCursor ? "cursor-default" : "cursor-grab data-dragging:cursor-grabbing",
className
)}
disabled={isDisabled}
/>
);
}
const SortableOverlayContext = React.createContext(false);
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
};
interface SortableOverlayProps extends Omit<React.ComponentProps<typeof DragOverlay>, "children"> {
container?: Element | DocumentFragment | null;
children?: ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode;
}
function SortableOverlay(props: SortableOverlayProps) {
const { container: containerProp, children, ...overlayProps } = props;
const context = useSortableContext(OVERLAY_NAME);
const [mounted, setMounted] = React.useState(false);
React.useLayoutEffect(() => setMounted(true), []);
const container = containerProp ?? (mounted ? globalThis.document?.body : null);
if (!container) return null;
return ReactDOM.createPortal(
<DragOverlay
dropAnimation={dropAnimation}
modifiers={context.modifiers}
className={cn(!context.flatCursor && "cursor-grabbing")}
{...overlayProps}
>
<SortableOverlayContext.Provider value={true}>
{context.activeId
? typeof children === "function"
? children({ value: context.activeId })
: children
: null}
</SortableOverlayContext.Provider>
</DragOverlay>,
container
);
}
export { SortableRoot as Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay };

View File

@@ -0,0 +1,48 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-text text-desktop-primary z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-text fill-text z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-xs" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };