Build Module Studio application composition

This commit is contained in:
DCCONSTRUCTIONS
2026-07-11 21:37:20 +03:00
parent d3e2859ca1
commit 50718a33a1
22 changed files with 2243 additions and 35 deletions
+41 -5
View File
@@ -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}
+160
View File
@@ -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>
);
}
+1
View File
@@ -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";