Establish NODE.DC design system baseline

This commit is contained in:
DCCONSTRUCTIONS
2026-07-10 02:34:36 +03:00
commit 73629d68c3
63 changed files with 6701 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@nodedc/ui-react",
"version": "0.1.0",
"type": "module",
"files": ["dist"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/ui-core": "0.1.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
export interface AppHeaderProps extends HTMLAttributes<HTMLElement> {
brand: ReactNode;
brandHref?: string;
brandLabel?: string;
left?: ReactNode;
center?: ReactNode;
right?: ReactNode;
}
export function AppHeader({
brand,
brandHref,
brandLabel = "NODE.DC",
left,
center,
right,
className,
...props
}: AppHeaderProps) {
const brandNode = brandHref ? (
<a className="nodedc-header__brand" href={brandHref} aria-label={brandLabel}>{brand}</a>
) : (
<span className="nodedc-header__brand" aria-label={brandLabel}>{brand}</span>
);
return (
<header className={cn("nodedc-header-shell", className)} {...props}>
<div className="nodedc-header">
<div className="nodedc-header__left">{brandNode}{left}</div>
<div className="nodedc-header__center">{center}</div>
<div className="nodedc-header__right">{right}</div>
</div>
</header>
);
}
export function HeaderProfile({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("nodedc-header__profile", className)} {...props}>{children}</div>;
}
export interface HeaderWorkspaceProps extends HTMLAttributes<HTMLSpanElement> {
label: string;
imageUrl?: string;
}
export function HeaderWorkspace({ label, imageUrl, className, ...props }: HeaderWorkspaceProps) {
return (
<span className={cn("nodedc-header__workspace", className)} title={label} {...props}>
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
</span>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
import { cn } from "./cn";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "default" | "compact";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
width?: "auto" | "full";
accent?: RgbTuple;
icon?: ReactNode;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
variant = "secondary",
size = "default",
width = "auto",
accent,
icon,
className,
children,
style,
type = "button",
...props
}, ref) {
const accentStyle = accent ? createAccentVariables(accent) : undefined;
return (
<button
ref={ref}
type={type}
className={cn("nodedc-button", className)}
data-variant={variant}
data-size={size === "default" ? undefined : size}
data-width={width === "auto" ? undefined : width}
style={accentStyle ? { ...accentStyle, ...style } : style}
{...props}
>
{icon ? <span className="nodedc-button__icon" aria-hidden="true">{icon}</span> : null}
{children}
</button>
);
});
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
shape?: "circle" | "rounded";
}
export function IconButton({
label,
shape = "circle",
className,
children,
type = "button",
...props
}: IconButtonProps) {
return (
<button
type={type}
className={cn("nodedc-icon-button", className)}
data-shape={shape === "circle" ? undefined : shape}
aria-label={label}
title={label}
{...props}
>
{children}
</button>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { ButtonHTMLAttributes } from "react";
import { cn } from "./cn";
export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean;
label: string;
description?: string;
onChange: (checked: boolean) => void;
}
export function Checker({
checked,
label,
description,
onChange,
disabled,
className,
type = "button",
...props
}: CheckerProps) {
return (
<button
type={type}
className={cn("nodedc-checker", className)}
role="checkbox"
aria-checked={checked}
disabled={disabled}
onClick={() => {
if (!disabled) onChange(!checked);
}}
{...props}
>
<span className="nodedc-checker__copy">
<span className="nodedc-checker__label">{label}</span>
{description ? <span className="nodedc-checker__description">{description}</span> : null}
</span>
<span className="nodedc-checker__indicator" aria-hidden="true" />
</button>
);
}
@@ -0,0 +1,76 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "./Button";
import { Window, WindowFooterActions } from "./Window";
export interface ConfirmationModalProps {
open: boolean;
title: string;
description: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
pendingLabel?: string;
danger?: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
}
export function ConfirmationModal({
open,
title,
description,
confirmLabel = "Подтвердить",
cancelLabel = "Отмена",
pendingLabel = "Выполняется",
danger = false,
onClose,
onConfirm,
}: ConfirmationModalProps) {
const [pending, setPending] = useState(false);
const mounted = useRef(true);
useEffect(() => () => {
mounted.current = false;
}, []);
useEffect(() => {
if (!open) setPending(false);
}, [open]);
const handleConfirm = async () => {
if (pending) return;
setPending(true);
try {
await onConfirm();
} finally {
if (mounted.current) setPending(false);
}
};
return (
<Window
open={open}
title={title}
size="sm"
closeOnBackdrop={!pending}
closeOnEscape={!pending}
onClose={onClose}
footer={
<>
<span />
<WindowFooterActions>
<Button disabled={pending} onClick={onClose}>{cancelLabel}</Button>
<Button variant={danger ? "danger" : "accent"} disabled={pending} onClick={handleConfirm}>
{pending ? pendingLabel : confirmLabel}
</Button>
</WindowFooterActions>
</>
}
>
<div className="nodedc-confirmation">
<span className="nodedc-confirmation__icon" aria-hidden="true">!</span>
<div className="nodedc-confirmation__copy">{description}</div>
</div>
</Window>
);
}
+148
View File
@@ -0,0 +1,148 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
import { cn } from "./cn";
export interface DropdownTriggerApi {
open: boolean;
show: () => void;
close: () => void;
toggle: () => void;
setTriggerRef: (node: HTMLElement | null) => void;
surfaceId: string;
}
export interface DropdownProps {
trigger: (api: DropdownTriggerApi) => ReactNode;
children: ReactNode | ((api: { close: () => void }) => ReactNode);
placement?: FloatingPlacement;
minWidth?: number;
width?: number | "anchor";
offset?: number;
disabled?: boolean;
className?: string;
surfaceClassName?: string;
surfaceRole?: "menu" | "listbox" | "dialog";
}
export function Dropdown({
trigger,
children,
placement = "bottom-start",
minWidth = 180,
width = "anchor",
offset = 8,
disabled = false,
className,
surfaceClassName,
surfaceRole = "menu",
}: DropdownProps) {
const instanceId = useId();
const surfaceId = `${instanceId.replaceAll(":", "")}-surface`;
const [isOpen, setIsOpen] = useState(false);
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
const surfaceRef = useRef<HTMLDivElement>(null);
const [surfaceStyle, setSurfaceStyle] = useState<CSSProperties>({ visibility: "hidden" });
const close = useCallback(() => setIsOpen(false), []);
const show = useCallback(() => {
if (!disabled) setIsOpen(true);
}, [disabled]);
const toggle = useCallback(() => {
if (!disabled) setIsOpen((current) => !current);
}, [disabled]);
const updatePosition = useCallback(() => {
if (!triggerElement || !surfaceRef.current) return;
const anchor = triggerElement.getBoundingClientRect();
const measured = surfaceRef.current.getBoundingClientRect();
const surfaceWidth = typeof width === "number"
? width
: Math.max(minWidth, anchor.width, measured.width);
const surfaceHeight = Math.max(1, surfaceRef.current.scrollHeight);
const position = computeFloatingPosition({
anchor,
surfaceWidth,
surfaceHeight,
placement,
offset,
});
setSurfaceStyle({
top: position.top,
left: position.left,
width: surfaceWidth,
maxHeight: position.maxHeight,
visibility: "visible",
});
}, [minWidth, offset, placement, triggerElement, width]);
useLayoutEffect(() => {
if (!isOpen) return;
updatePosition();
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (triggerElement?.contains(target) || surfaceRef.current?.contains(target)) return;
close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
close();
triggerElement?.focus();
}
};
const handleOtherOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail.id !== instanceId) close();
};
const handleViewportChange = () => updatePosition();
window.dispatchEvent(new CustomEvent("nodedc-dropdown-open", { detail: { id: instanceId } }));
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [close, instanceId, isOpen, triggerElement, updatePosition]);
return (
<span className={cn("nodedc-dropdown-anchor", className)}>
{trigger({ open: isOpen, show, close, toggle, setTriggerRef: setTriggerElement, surfaceId })}
{isOpen && typeof document !== "undefined"
? createPortal(
<div
ref={surfaceRef}
id={surfaceId}
className={cn("nodedc-dropdown-surface", surfaceClassName)}
role={surfaceRole}
style={surfaceStyle}
>
{typeof children === "function" ? children({ close }) : children}
</div>,
document.body,
)
: null}
</span>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useId, type InputHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from "react";
import { cn } from "./cn";
export interface FieldFrameProps {
label: string;
hint?: string;
description?: string;
htmlFor?: string;
children: ReactNode;
className?: string;
}
export function FieldFrame({ label, hint, description, htmlFor, children, className }: FieldFrameProps) {
return (
<label className={cn("nodedc-field", className)} htmlFor={htmlFor}>
<span className="nodedc-field__label-row">
<span className="nodedc-field__label">{label}</span>
{hint ? <span className="nodedc-field__hint">{hint}</span> : null}
</span>
{children}
{description ? <span className="nodedc-field__description">{description}</span> : null}
</label>
);
}
export interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
hint?: string;
description?: string;
fieldClassName?: string;
}
export function TextField({
label,
hint,
description,
fieldClassName,
className,
id,
...props
}: TextFieldProps) {
const generatedId = useId();
const controlId = id ?? generatedId;
return (
<FieldFrame label={label} hint={hint} description={description} htmlFor={controlId} className={fieldClassName}>
<input id={controlId} className={cn("nodedc-field__control", className)} {...props} />
</FieldFrame>
);
}
export interface TextAreaFieldProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string;
hint?: string;
description?: string;
fieldClassName?: string;
}
export function TextAreaField({
label,
hint,
description,
fieldClassName,
className,
id,
...props
}: TextAreaFieldProps) {
const generatedId = useId();
const controlId = id ?? generatedId;
return (
<FieldFrame label={label} hint={hint} description={description} htmlFor={controlId} className={fieldClassName}>
<textarea id={controlId} className={cn("nodedc-field__control", className)} {...props} />
</FieldFrame>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
export type GlassTone = "default" | "strong" | "soft";
export type GlassRadius = "card" | "panel" | "modal";
export type GlassPadding = "none" | "sm" | "md" | "lg";
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
tone?: GlassTone;
radius?: GlassRadius;
padding?: GlassPadding;
materialRim?: boolean;
}
export function GlassSurface({
children,
className,
tone = "default",
radius = "card",
padding = "none",
materialRim = true,
...props
}: GlassSurfaceProps) {
return (
<div
className={cn("nodedc-glass", materialRim && "nodedc-material-rim", className)}
data-tone={tone}
data-radius={radius}
data-padding={padding === "none" ? undefined : padding}
{...props}
>
{children}
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useMemo, useState, type ReactNode } from "react";
import { cn } from "./cn";
export interface InspectorSectionSpec {
id: string;
label: string;
description?: string;
group?: string;
content: ReactNode;
disabled?: boolean;
}
export interface InspectorProps {
sections: InspectorSectionSpec[];
defaultOpen?: string[];
activeId?: string;
singleOpen?: boolean;
className?: string;
onActiveChange?: (id: string) => void;
}
export function Inspector({
sections,
defaultOpen = [],
activeId,
singleOpen = false,
className,
onActiveChange,
}: InspectorProps) {
const [openIds, setOpenIds] = useState(() => new Set(defaultOpen));
const groups = useMemo(() => {
const result: Array<{ label?: string; sections: InspectorSectionSpec[] }> = [];
sections.forEach((section) => {
const previous = result[result.length - 1];
if (previous && previous.label === section.group) {
previous.sections.push(section);
} else {
result.push({ label: section.group, sections: [section] });
}
});
return result;
}, [sections]);
const toggle = (id: string) => {
setOpenIds((current) => {
const next = singleOpen ? new Set<string>() : new Set(current);
if (current.has(id)) next.delete(id);
else next.add(id);
return next;
});
onActiveChange?.(id);
};
return (
<div className={cn("nodedc-inspector", className)}>
{groups.map((group, groupIndex) => (
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
{group.sections.map((section) => {
const isOpen = openIds.has(section.id);
return (
<section className="nodedc-inspector__section" key={section.id}>
<button
type="button"
className="nodedc-inspector__section-trigger"
data-open={isOpen ? "true" : undefined}
data-active={activeId === section.id ? "true" : undefined}
aria-expanded={isOpen}
disabled={section.disabled}
onClick={() => toggle(section.id)}
>
<span className="nodedc-inspector__section-label">{section.label}</span>
{section.description ? (
<span className="nodedc-inspector__section-description">{section.description}</span>
) : null}
</button>
{isOpen ? <div className="nodedc-inspector__section-content">{section.content}</div> : null}
</section>
);
})}
</div>
))}
</div>
);
}
export interface ControlRowProps {
label: ReactNode;
children: ReactNode;
layout?: "inline" | "stack";
className?: string;
}
export function ControlRow({ label, children, layout = "inline", className }: ControlRowProps) {
return (
<div className={cn("nodedc-control-row", className)} data-layout={layout === "inline" ? undefined : layout}>
<div className="nodedc-control-row__label">{label}</div>
<div className="nodedc-control-row__control">{children}</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { CSSProperties, InputHTMLAttributes } from "react";
import { cn } from "./cn";
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
label: string;
value: number;
min: number;
max: number;
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}
export function RangeControl({
label,
value,
min,
max,
step,
formatValue = String,
onChange,
className,
...props
}: RangeControlProps) {
const safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties;
return (
<label className={cn("nodedc-range", className)} style={style}>
<input
type="range"
value={value}
min={min}
max={safeMax}
step={step}
aria-label={label}
onChange={(event) => onChange(Number(event.target.value))}
{...props}
/>
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
<span className="nodedc-range__fill-text" aria-hidden="true">
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
</span>
</label>
);
}
export interface ColorFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
value: string;
onChange: (value: string) => void;
label?: string;
}
export function ColorField({ value, onChange, label = "Цвет", className, ...props }: ColorFieldProps) {
const style = { "--nodedc-color-value": value } as CSSProperties;
return (
<div className={cn("nodedc-color-field", className)} style={style}>
<label className="nodedc-color-field__picker" aria-label={label}>
<input type="color" value={value} onChange={(event) => onChange(event.target.value)} {...props} />
</label>
<input
className="nodedc-color-field__text"
value={value}
aria-label={`${label}: HEX`}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
}
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { cn } from "./cn";
export interface SegmentedItem<T extends string> {
value: T;
label: string;
icon?: ReactNode;
disabled?: boolean;
}
export interface SegmentedControlProps<T extends string> {
value: T;
items: Array<SegmentedItem<T>>;
label: string;
className?: string;
onChange: (value: T) => void;
}
export function SegmentedControl<T extends string>({
value,
items,
label,
className,
onChange,
}: SegmentedControlProps<T>) {
return (
<div className={cn("nodedc-segmented", className)} role="tablist" aria-label={label}>
{items.map((item) => (
<button
key={item.value}
type="button"
className="nodedc-segmented__item"
role="tab"
aria-selected={item.value === value}
data-active={item.value === value ? "true" : undefined}
disabled={item.disabled}
onClick={() => onChange(item.value)}
>
{item.icon}
{item.label}
</button>
))}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
import type { FloatingPlacement } from "@nodedc/ui-core";
import { Dropdown } from "./Dropdown";
import { cn } from "./cn";
export interface SelectOption<T extends string> {
value: T;
label: string;
description?: string;
icon?: ReactNode;
disabled?: boolean;
}
export interface SelectProps<T extends string> {
value: T;
options: Array<SelectOption<T>>;
label: string;
onChange: (value: T, option: SelectOption<T>) => void;
searchable?: boolean;
searchPlaceholder?: string;
emptyLabel?: string;
placement?: FloatingPlacement;
minMenuWidth?: number;
menuWidth?: number | "anchor";
disabled?: boolean;
className?: string;
triggerClassName?: string;
menuClassName?: string;
}
export function Select<T extends string>({
value,
options,
label,
onChange,
searchable = false,
searchPlaceholder = "Поиск",
emptyLabel = "Ничего не найдено",
placement = "bottom-start",
minMenuWidth = 180,
menuWidth = "anchor",
disabled = false,
className,
triggerClassName,
menuClassName,
}: SelectProps<T>) {
const [query, setQuery] = useState("");
const selected = options.find((option) => option.value === value) ?? options[0];
const visibleOptions = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return options;
return options.filter((option) => `${option.label} ${option.description ?? ""}`.toLocaleLowerCase().includes(normalized));
}, [options, query]);
return (
<Dropdown
className={className}
placement={placement}
minWidth={minMenuWidth}
width={menuWidth}
disabled={disabled}
surfaceRole="listbox"
surfaceClassName={menuClassName}
trigger={({ open, toggle, setTriggerRef, surfaceId }) => {
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (!open) toggle();
}
};
return (
<button
ref={setTriggerRef}
type="button"
className={cn("nodedc-select-trigger", triggerClassName)}
aria-label={label}
aria-haspopup="listbox"
aria-controls={surfaceId}
aria-expanded={open}
disabled={disabled}
onClick={toggle}
onKeyDown={handleKeyDown}
>
{selected?.icon ?? null}
<span className="nodedc-select-trigger__label">{selected?.label ?? "—"}</span>
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
</button>
);
}}
>
{({ close }) => (
<>
{searchable ? (
<label className="nodedc-dropdown-search">
<span aria-hidden="true"></span>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={searchPlaceholder} />
</label>
) : null}
{visibleOptions.map((option) => (
<button
key={option.value}
type="button"
className="nodedc-dropdown-option"
role="option"
aria-selected={option.value === value}
data-selected={option.value === value ? "true" : undefined}
disabled={option.disabled}
onClick={() => {
if (option.disabled) return;
onChange(option.value, option);
setQuery("");
close();
}}
>
{option.icon ? <span className="nodedc-dropdown-option__icon">{option.icon}</span> : <span />}
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">{option.label}</span>
{option.description ? <span className="nodedc-dropdown-option__description">{option.description}</span> : null}
</span>
{option.value === value ? <span className="nodedc-dropdown-option__check" aria-hidden="true"></span> : null}
</button>
))}
{visibleOptions.length === 0 ? <div className="nodedc-empty-state">{emptyLabel}</div> : null}
</>
)}
</Dropdown>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn";
export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent";
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: StatusTone;
}
export function StatusBadge({ tone = "neutral", className, children, ...props }: StatusBadgeProps) {
return (
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} {...props}>
{children}
</span>
);
}
+143
View File
@@ -0,0 +1,143 @@
import {
useEffect,
useId,
useRef,
type HTMLAttributes,
type PointerEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn";
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export type WindowSize = "sm" | "md" | "lg";
export type WindowPlacement = "center" | "end";
export interface WindowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
open: boolean;
title: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
footer?: ReactNode;
size?: WindowSize;
placement?: WindowPlacement;
closeLabel?: string;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
lockBodyScroll?: boolean;
onClose: () => void;
}
export function Window({
open,
title,
subtitle,
children,
footer,
size = "md",
placement = "center",
closeLabel = "Закрыть",
closeOnBackdrop = true,
closeOnEscape = true,
lockBodyScroll = true,
onClose,
className,
...props
}: WindowProps) {
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
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";
const frame = window.requestAnimationFrame(() => {
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(focusableSelector);
(firstFocusable ?? dialogRef.current)?.focus();
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && closeOnEscape) {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab" || !dialogRef.current) return;
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(focusableSelector));
if (focusable.length === 0) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
window.cancelAnimationFrame(frame);
document.removeEventListener("keydown", handleKeyDown);
if (lockBodyScroll) document.body.style.overflow = previousOverflow;
previousActiveElement?.focus();
};
}, [closeOnEscape, lockBodyScroll, onClose, open]);
if (!open || typeof document === "undefined") return null;
const handleBackdropPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (closeOnBackdrop && event.target === event.currentTarget) onClose();
};
return createPortal(
<div className="nodedc-overlay nodedc-ui-root" data-placement={placement} onPointerDown={handleBackdropPointerDown}>
<div
ref={dialogRef}
className={cn("nodedc-window nodedc-material-rim", className)}
data-size={size === "md" ? undefined : size}
data-placement={placement}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={subtitle ? descriptionId : undefined}
tabIndex={-1}
{...props}
>
<header className="nodedc-window__head">
<div className="nodedc-window__titles">
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
</div>
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
<span className="nodedc-close-mark" aria-hidden="true" />
</button>
</header>
<div className="nodedc-window__body">{children}</div>
{footer ? <footer className="nodedc-window__footer">{footer}</footer> : null}
</div>
</div>,
document.body,
);
}
export function WindowFooterActions({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("nodedc-window__footer-actions", className)} {...props}>{children}</div>;
}
+4
View File
@@ -0,0 +1,4 @@
export function cn(...values: Array<string | false | null | undefined>): string {
return values.filter(Boolean).join(" ");
}
+14
View File
@@ -0,0 +1,14 @@
export * from "./AppHeader";
export * from "./Button";
export * from "./Checker";
export * from "./ConfirmationModal";
export * from "./Dropdown";
export * from "./Field";
export * from "./Glass";
export * from "./Inspector";
export * from "./RangeControl";
export * from "./SegmentedControl";
export * from "./Select";
export * from "./StatusBadge";
export * from "./Window";
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}