Correct Hub and Engine component geometry

This commit is contained in:
DCCONSTRUCTIONS
2026-07-10 03:14:43 +03:00
parent 73629d68c3
commit 4a9ad01ea6
28 changed files with 922 additions and 167 deletions
@@ -0,0 +1,102 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
export interface AdminNavigationContext {
id: string;
label: string;
description?: string;
groupLabel?: string;
icon?: ReactNode;
active?: boolean;
onSelect?: () => void;
onToggle?: () => void;
}
export interface AdminNavigationItem {
id: string;
label: string;
icon?: ReactNode;
}
export interface AdminNavigationPanelProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
eyebrow?: string;
title: ReactNode;
contexts?: AdminNavigationContext[];
items: AdminNavigationItem[];
activeId?: string;
footer?: ReactNode;
closeLabel?: string;
onClose: () => void;
onItemChange: (id: string) => void;
}
export function AdminNavigationPanel({
eyebrow = "NODE.DC",
title,
contexts = [],
items,
activeId,
footer,
closeLabel = "Закрыть администрирование",
onClose,
onItemChange,
className,
...props
}: AdminNavigationPanelProps) {
return (
<aside className={cn("nodedc-admin-panel", className)} {...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>
</header>
<div className="nodedc-admin-panel__contexts">
{contexts.map((context) => (
<div className="nodedc-admin-panel__context-group" key={context.id}>
{context.groupLabel ? <span className="nodedc-admin-panel__group-label">{context.groupLabel}</span> : null}
<div
className={cn("nodedc-admin-context", context.onToggle && "nodedc-admin-context--split")}
data-active={context.active ? "true" : undefined}
>
<button type="button" className="nodedc-admin-context__main" onClick={context.onSelect}>
<span className="nodedc-admin-context__icon" aria-hidden="true">{context.icon}</span>
<span className="nodedc-admin-context__body">
<strong>{context.label}</strong>
{context.description ? <small>{context.description}</small> : null}
</span>
</button>
{context.onToggle ? (
<button type="button" className="nodedc-admin-context__toggle" aria-label={`Выбрать: ${context.label}`} onClick={context.onToggle}>
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
</button>
) : null}
</div>
</div>
))}
</div>
<nav className="nodedc-admin-panel__nav" aria-label="Разделы администрирования">
{items.map((item) => (
<button
key={item.id}
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>
))}
</nav>
{footer ? <footer className="nodedc-admin-panel__footer">{footer}</footer> : null}
</aside>
);
}
+18 -2
View File
@@ -1,4 +1,4 @@
import type { HTMLAttributes, ReactNode } from "react";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
export interface AppHeaderProps extends HTMLAttributes<HTMLElement> {
@@ -41,6 +41,23 @@ export function HeaderProfile({ children, className, ...props }: HTMLAttributes<
return <div className={cn("nodedc-header__profile", className)} {...props}>{children}</div>;
}
export function HeaderProfileButton({ className, children, type = "button", ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
return <button type={type} className={cn("nodedc-header__profile-button", className)} {...props}>{children}</button>;
}
export interface HeaderAvatarProps extends HTMLAttributes<HTMLSpanElement> {
label: string;
imageUrl?: string;
}
export function HeaderAvatar({ label, imageUrl, className, ...props }: HeaderAvatarProps) {
return (
<span className={cn("nodedc-header__avatar", className)} title={label} {...props}>
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
</span>
);
}
export interface HeaderWorkspaceProps extends HTMLAttributes<HTMLSpanElement> {
label: string;
imageUrl?: string;
@@ -53,4 +70,3 @@ export function HeaderWorkspace({ label, imageUrl, className, ...props }: Header
</span>
);
}
+4
View File
@@ -4,11 +4,13 @@ import { cn } from "./cn";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "default" | "compact";
export type ButtonShape = "default" | "pill" | "rounded";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
width?: "auto" | "full";
shape?: ButtonShape;
accent?: RgbTuple;
icon?: ReactNode;
}
@@ -17,6 +19,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
variant = "secondary",
size = "default",
width = "auto",
shape = "default",
accent,
icon,
className,
@@ -34,6 +37,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
data-variant={variant}
data-size={size === "default" ? undefined : size}
data-width={width === "auto" ? undefined : width}
data-shape={shape === "default" ? undefined : shape}
style={accentStyle ? { ...accentStyle, ...style } : style}
{...props}
>
+2 -1
View File
@@ -8,6 +8,7 @@ export interface InspectorSectionSpec {
group?: string;
content: ReactNode;
disabled?: boolean;
tone?: "default" | "accent";
}
export interface InspectorProps {
@@ -64,6 +65,7 @@ export function Inspector({
className="nodedc-inspector__section-trigger"
data-open={isOpen ? "true" : undefined}
data-active={activeId === section.id ? "true" : undefined}
data-tone={section.tone === "accent" ? "accent" : undefined}
aria-expanded={isOpen}
disabled={section.disabled}
onClick={() => toggle(section.id)}
@@ -98,4 +100,3 @@ export function ControlRow({ label, children, layout = "inline", className }: Co
</div>
);
}
+32 -3
View File
@@ -11,6 +11,8 @@ export interface SelectOption<T extends string> {
disabled?: boolean;
}
export type SelectVariant = "integrated" | "split";
export interface SelectProps<T extends string> {
value: T;
options: Array<SelectOption<T>>;
@@ -23,6 +25,7 @@ export interface SelectProps<T extends string> {
minMenuWidth?: number;
menuWidth?: number | "anchor";
disabled?: boolean;
variant?: SelectVariant;
className?: string;
triggerClassName?: string;
menuClassName?: string;
@@ -40,6 +43,7 @@ export function Select<T extends string>({
minMenuWidth = 180,
menuWidth = "anchor",
disabled = false,
variant = "integrated",
className,
triggerClassName,
menuClassName,
@@ -54,13 +58,13 @@ export function Select<T extends string>({
return (
<Dropdown
className={className}
className={cn("nodedc-select-anchor", className)}
placement={placement}
minWidth={minMenuWidth}
width={menuWidth}
disabled={disabled}
surfaceRole="listbox"
surfaceClassName={menuClassName}
surfaceClassName={cn(variant === "split" && "nodedc-select__menu", menuClassName)}
trigger={({ open, toggle, setTriggerRef, surfaceId }) => {
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
@@ -68,6 +72,32 @@ export function Select<T extends string>({
if (!open) toggle();
}
};
if (variant === "split") {
return (
<div className={cn("nodedc-select", triggerClassName)}>
<div className="nodedc-select__control">
<div ref={setTriggerRef} className="nodedc-select__value">
{selected?.icon ? <span className="nodedc-select__value-icon">{selected.icon}</span> : null}
<span>{selected?.label ?? "—"}</span>
</div>
<button
type="button"
className="nodedc-select__toggle"
aria-label={label}
aria-haspopup="listbox"
aria-controls={surfaceId}
aria-expanded={open}
disabled={disabled}
onClick={toggle}
onKeyDown={handleKeyDown}
>
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
</button>
</div>
</div>
);
}
return (
<button
ref={setTriggerRef}
@@ -126,4 +156,3 @@ export function Select<T extends string>({
</Dropdown>
);
}
+14 -9
View File
@@ -33,6 +33,7 @@ export interface WindowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
lockBodyScroll?: boolean;
trapFocus?: boolean;
onClose: () => void;
}
@@ -45,9 +46,10 @@ export function Window({
size = "md",
placement = "center",
closeLabel = "Закрыть",
closeOnBackdrop = true,
closeOnBackdrop,
closeOnEscape = true,
lockBodyScroll = true,
lockBodyScroll,
trapFocus,
onClose,
className,
...props
@@ -55,12 +57,14 @@ export function Window({
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
const shouldTrapFocus = trapFocus ?? placement === "center";
useEffect(() => {
if (!open || typeof document === "undefined") return;
const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const previousOverflow = document.body.style.overflow;
if (lockBodyScroll) document.body.style.overflow = "hidden";
if (shouldLockBodyScroll) document.body.style.overflow = "hidden";
const frame = window.requestAnimationFrame(() => {
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(focusableSelector);
@@ -73,7 +77,7 @@ export function Window({
onClose();
return;
}
if (event.key !== "Tab" || !dialogRef.current) return;
if (event.key !== "Tab" || !shouldTrapFocus || !dialogRef.current) return;
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(focusableSelector));
if (focusable.length === 0) {
event.preventDefault();
@@ -95,15 +99,17 @@ export function Window({
return () => {
window.cancelAnimationFrame(frame);
document.removeEventListener("keydown", handleKeyDown);
if (lockBodyScroll) document.body.style.overflow = previousOverflow;
if (shouldLockBodyScroll) document.body.style.overflow = previousOverflow;
previousActiveElement?.focus();
};
}, [closeOnEscape, lockBodyScroll, onClose, open]);
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
if (!open || typeof document === "undefined") return null;
const allowBackdropClose = closeOnBackdrop ?? placement === "center";
const handleBackdropPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (closeOnBackdrop && event.target === event.currentTarget) onClose();
if (allowBackdropClose && event.target === event.currentTarget) onClose();
};
return createPortal(
@@ -114,7 +120,7 @@ export function Window({
data-size={size === "md" ? undefined : size}
data-placement={placement}
role="dialog"
aria-modal="true"
aria-modal={placement === "center" ? "true" : undefined}
aria-labelledby={titleId}
aria-describedby={subtitle ? descriptionId : undefined}
tabIndex={-1}
@@ -140,4 +146,3 @@ export function Window({
export function WindowFooterActions({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("nodedc-window__footer-actions", className)} {...props}>{children}</div>;
}
+1 -1
View File
@@ -1,4 +1,5 @@
export * from "./AppHeader";
export * from "./AdminNavigationPanel";
export * from "./Button";
export * from "./Checker";
export * from "./ConfirmationModal";
@@ -11,4 +12,3 @@ export * from "./SegmentedControl";
export * from "./Select";
export * from "./StatusBadge";
export * from "./Window";