Establish NODE.DC design system baseline
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function cn(...values: Array<string | false | null | undefined>): string {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user