Build Module Studio application composition
This commit is contained in:
@@ -18,6 +18,9 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { SortableList } from "./DragDrop.js";
|
||||
|
||||
export interface AdminNavigationContext {
|
||||
id: string;
|
||||
@@ -16,12 +17,15 @@ export interface AdminNavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminNavigationPanelProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
contexts?: AdminNavigationContext[];
|
||||
headerActions?: ReactNode;
|
||||
contextSlot?: ReactNode;
|
||||
items: AdminNavigationItem[];
|
||||
activeId?: string;
|
||||
footer?: ReactNode;
|
||||
@@ -29,12 +33,15 @@ export interface AdminNavigationPanelProps extends Omit<HTMLAttributes<HTMLEleme
|
||||
navigationLabel?: string;
|
||||
onClose: () => void;
|
||||
onItemChange: (id: string) => void;
|
||||
onItemsReorder?: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export function AdminNavigationPanel({
|
||||
eyebrow = "NODE.DC",
|
||||
title,
|
||||
contexts = [],
|
||||
headerActions,
|
||||
contextSlot,
|
||||
items,
|
||||
activeId,
|
||||
footer,
|
||||
@@ -42,21 +49,27 @@ export function AdminNavigationPanel({
|
||||
navigationLabel = "Разделы администрирования",
|
||||
onClose,
|
||||
onItemChange,
|
||||
onItemsReorder,
|
||||
className,
|
||||
...props
|
||||
}: AdminNavigationPanelProps) {
|
||||
return (
|
||||
<aside className={cn("nodedc-admin-panel", className)} data-has-contexts={contexts.length > 0 ? "true" : undefined} {...props}>
|
||||
<aside className={cn("nodedc-admin-panel", className)} data-has-contexts={contexts.length > 0 || contextSlot ? "true" : undefined} {...props}>
|
||||
<header className="nodedc-admin-panel__head">
|
||||
<div>
|
||||
<p>{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<button type="button" className="nodedc-admin-panel__close" aria-label={closeLabel} onClick={onClose}>
|
||||
<span className="nodedc-close-mark" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="nodedc-admin-panel__head-actions">
|
||||
{headerActions}
|
||||
<button type="button" className="nodedc-admin-panel__close" aria-label={closeLabel} onClick={onClose}>
|
||||
<span className="nodedc-close-mark" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{contextSlot ? <div className="nodedc-admin-panel__context-slot">{contextSlot}</div> : null}
|
||||
|
||||
{contexts.length > 0 ? <div className="nodedc-admin-panel__contexts">
|
||||
{contexts.map((context) => (
|
||||
<div className="nodedc-admin-panel__context-group" key={context.id}>
|
||||
@@ -83,7 +96,7 @@ export function AdminNavigationPanel({
|
||||
</div> : null}
|
||||
|
||||
<nav className="nodedc-admin-panel__nav" aria-label={navigationLabel}>
|
||||
{items.map((item) => (
|
||||
{items.filter((item) => !item.sortable).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
@@ -95,6 +108,29 @@ export function AdminNavigationPanel({
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{items.some((item) => item.sortable) ? (
|
||||
<SortableList
|
||||
className="nodedc-admin-panel__sortable-nav"
|
||||
items={items.filter((item) => item.sortable)}
|
||||
getId={(item) => item.id}
|
||||
onReorder={(next) => onItemsReorder?.(next.map((item) => item.id))}
|
||||
>
|
||||
{(item, { handle }) => (
|
||||
<div className="nodedc-admin-panel__sortable-row">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-admin-panel__nav-item"
|
||||
data-active={activeId === item.id ? "true" : undefined}
|
||||
onClick={() => onItemChange(item.id)}
|
||||
>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{handle}
|
||||
</div>
|
||||
)}
|
||||
</SortableList>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
{footer ? <footer className="nodedc-admin-panel__footer">{footer}</footer> : null}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type NodedcDragData = Record<string, unknown>;
|
||||
|
||||
export interface DragRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DragDropResult {
|
||||
activeId: string;
|
||||
overId: string | null;
|
||||
activeData?: NodedcDragData;
|
||||
overData?: NodedcDragData;
|
||||
activeRect?: DragRect;
|
||||
overRect?: DragRect;
|
||||
}
|
||||
|
||||
const copyRect = (rect: DragRect | null | undefined): DragRect | undefined => rect ? ({
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}) : undefined;
|
||||
|
||||
export function DragHandle({ label = "Перетащить", listeners, attributes, className }: {
|
||||
label?: string;
|
||||
listeners?: ReturnType<typeof useDraggable>["listeners"];
|
||||
attributes?: ReturnType<typeof useDraggable>["attributes"];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn("nodedc-drag-handle", className)}
|
||||
aria-label={label}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="0.75" /><circle cx="8" cy="4" r="0.75" /><circle cx="12" cy="4" r="0.75" />
|
||||
<circle cx="4" cy="8" r="0.75" /><circle cx="8" cy="8" r="0.75" /><circle cx="12" cy="8" r="0.75" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DragDropRoot({ children, onDragEnd }: { children: ReactNode; onDragEnd: (result: DragDropResult) => void }) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
onDragEnd({
|
||||
activeId: String(event.active.id),
|
||||
overId: event.over ? String(event.over.id) : null,
|
||||
activeData: event.active.data.current as NodedcDragData | undefined,
|
||||
overData: event.over?.data.current as NodedcDragData | undefined,
|
||||
activeRect: copyRect(event.active.rect.current.translated),
|
||||
overRect: copyRect(event.over?.rect),
|
||||
});
|
||||
};
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>{children}</DndContext>;
|
||||
}
|
||||
|
||||
export function DraggableItem({ id, data, className, children }: {
|
||||
id: string;
|
||||
data?: NodedcDragData;
|
||||
className?: string;
|
||||
children: (state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id, data });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(className, isDragging && "is-dragging")}
|
||||
style={{ transform: CSS.Translate.toString(transform), opacity: isDragging ? 0.65 : undefined }}
|
||||
>
|
||||
{children({ handle: <DragHandle listeners={listeners} attributes={attributes} />, isDragging })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropZone({ id, data, className, children }: { id: string; data?: NodedcDragData; className?: string; children: ReactNode }) {
|
||||
const { isOver, setNodeRef } = useDroppable({ id, data });
|
||||
return <div ref={setNodeRef} className={cn(className, isOver && "is-drag-over")}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SortableScope({ ids, children }: { ids: string[]; children: ReactNode }) {
|
||||
return <SortableContext items={ids} strategy={verticalListSortingStrategy}>{children}</SortableContext>;
|
||||
}
|
||||
|
||||
export function SortableItem({ id, data, className, children }: {
|
||||
id: string;
|
||||
data?: NodedcDragData;
|
||||
className?: string;
|
||||
children: (state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, data });
|
||||
const verticalTransform = transform ? { ...transform, x: 0 } : null;
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(className, isDragging && "is-dragging")}
|
||||
style={{ transform: CSS.Transform.toString(verticalTransform), transition, opacity: isDragging ? 0.65 : undefined }}
|
||||
>
|
||||
{children({ handle: <DragHandle listeners={listeners} attributes={attributes} />, isDragging })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableList<T>({ items, getId, className, onReorder, children }: {
|
||||
items: T[];
|
||||
getId: (item: T) => string;
|
||||
className?: string;
|
||||
onReorder: (items: T[]) => void;
|
||||
children: (item: T, state: { handle: ReactNode; isDragging: boolean }) => ReactNode;
|
||||
}) {
|
||||
const ids = items.map(getId);
|
||||
return (
|
||||
<DragDropRoot onDragEnd={({ activeId, overId }) => {
|
||||
if (!overId || activeId === overId) return;
|
||||
const from = ids.indexOf(activeId);
|
||||
const to = ids.indexOf(overId);
|
||||
if (from >= 0 && to >= 0) onReorder(arrayMove(items, from, to));
|
||||
}}>
|
||||
<SortableScope ids={ids}>
|
||||
<div className={className}>
|
||||
{items.map((item) => (
|
||||
<SortableItem key={getId(item)} id={getId(item)}>
|
||||
{(state) => children(item, state)}
|
||||
</SortableItem>
|
||||
))}
|
||||
</div>
|
||||
</SortableScope>
|
||||
</DragDropRoot>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from "./Button.js";
|
||||
export * from "./Checker.js";
|
||||
export * from "./ConfirmationModal.js";
|
||||
export * from "./Dropdown.js";
|
||||
export * from "./DragDrop.js";
|
||||
export * from "./Field.js";
|
||||
export * from "./Glass.js";
|
||||
export * from "./Inspector.js";
|
||||
|
||||
Reference in New Issue
Block a user