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
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@nodedc/tokens",
"version": "0.1.0",
"type": "module",
"files": ["tokens.json", "tokens.css", "themes.css"],
"exports": {
"./tokens.json": "./tokens.json",
"./tokens.css": "./tokens.css",
"./themes.css": "./themes.css"
}
}
+67
View File
@@ -0,0 +1,67 @@
:root,
[data-nodedc-theme="dark"] {
color-scheme: dark;
--nodedc-accent-rgb: 195 255 102;
--nodedc-on-accent-rgb: 11 17 23;
--nodedc-danger-rgb: 255 116 116;
--nodedc-warning-rgb: 255 209 102;
--nodedc-success-rgb: 181 255 90;
--nodedc-canvas: #050506;
--nodedc-canvas-soft: #0b0b0d;
--nodedc-text-primary: rgba(247, 248, 244, 0.96);
--nodedc-text-secondary: rgba(247, 248, 244, 0.72);
--nodedc-text-muted: rgba(247, 248, 244, 0.48);
--nodedc-text-disabled: rgba(247, 248, 244, 0.34);
--nodedc-glass-panel-bg: rgba(28, 28, 30, 0.72);
--nodedc-glass-panel-bg-strong: rgba(18, 18, 21, 0.88);
--nodedc-glass-panel-bg-soft: rgba(42, 42, 46, 0.5);
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.014)), var(--nodedc-glass-panel-bg);
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.018)), var(--nodedc-glass-panel-bg-strong);
--nodedc-glass-control-bg: rgba(110, 110, 110, 0.07);
--nodedc-glass-control-hover: rgba(129, 129, 129, 0.13);
--nodedc-glass-control-active: rgba(255, 255, 255, 0.92);
--nodedc-glass-control-active-text: rgba(8, 8, 10, 0.96);
--nodedc-field-bg: rgba(20, 20, 27, 0.96);
--nodedc-overlay-bg: rgba(0, 0, 0, 0.46);
--nodedc-glass-rim: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.026));
--nodedc-glass-control-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
--nodedc-glass-panel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.075), 0 22px 72px rgba(0, 0, 0, 0.42);
--nodedc-glass-dropdown-shadow: 0 22px 60px rgba(0, 0, 0, 0.42), 0 6px 18px rgba(0, 0, 0, 0.2);
--nodedc-focus-surface: rgba(var(--nodedc-accent-rgb), 0.14);
}
[data-nodedc-theme="light"] {
color-scheme: light;
--nodedc-accent-rgb: 32 32 34;
--nodedc-on-accent-rgb: 247 248 244;
--nodedc-danger-rgb: 205 47 67;
--nodedc-warning-rgb: 173 111 0;
--nodedc-success-rgb: 46 125 76;
--nodedc-canvas: #ececec;
--nodedc-canvas-soft: #f4f4f4;
--nodedc-text-primary: rgba(18, 20, 22, 0.96);
--nodedc-text-secondary: rgba(18, 20, 22, 0.7);
--nodedc-text-muted: rgba(18, 20, 22, 0.48);
--nodedc-text-disabled: rgba(18, 20, 22, 0.32);
--nodedc-glass-panel-bg: rgba(255, 255, 255, 0.48);
--nodedc-glass-panel-bg-strong: rgba(255, 255, 255, 0.72);
--nodedc-glass-panel-bg-soft: rgba(255, 255, 255, 0.34);
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.22)), var(--nodedc-glass-panel-bg);
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0.34)), var(--nodedc-glass-panel-bg-strong);
--nodedc-glass-control-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(255, 255, 255, 0.22)), rgba(255, 255, 255, 0.38);
--nodedc-glass-control-hover: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.34)), rgba(255, 255, 255, 0.54);
--nodedc-glass-control-active: rgba(32, 32, 34, 0.96);
--nodedc-glass-control-active-text: rgba(247, 248, 244, 0.96);
--nodedc-field-bg: rgba(255, 255, 255, 0.58);
--nodedc-overlay-bg: rgba(24, 32, 29, 0.22);
--nodedc-glass-rim: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(24, 32, 29, 0.1));
--nodedc-glass-control-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.68);
--nodedc-glass-panel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 22px 76px rgba(24, 32, 29, 0.16);
--nodedc-glass-dropdown-shadow: 0 22px 60px rgba(24, 32, 29, 0.2), 0 6px 18px rgba(24, 32, 29, 0.1);
--nodedc-focus-surface: rgba(var(--nodedc-accent-rgb), 0.1);
}
+56
View File
@@ -0,0 +1,56 @@
:root,
[data-nodedc-ui] {
--nodedc-radius-modal: 1.75rem;
--nodedc-radius-panel: 1.375rem;
--nodedc-radius-card: 1.35rem;
--nodedc-radius-control: 1.25rem;
--nodedc-radius-control-compact: 0.875rem;
--nodedc-radius-option: 0.9rem;
--nodedc-radius-circle: 999px;
--nodedc-control-height: 2.875rem;
--nodedc-control-height-compact: 2.375rem;
--nodedc-icon-button-size: 2.875rem;
--nodedc-header-row-height: 3rem;
--nodedc-header-pill-height: 3.45rem;
--nodedc-header-logo-width: 7.25rem;
--nodedc-header-logo-height: 1.79rem;
--nodedc-inspector-width: 24.375rem;
--nodedc-space-1: 0.25rem;
--nodedc-space-2: 0.5rem;
--nodedc-space-3: 0.75rem;
--nodedc-space-4: 1rem;
--nodedc-space-5: 1.25rem;
--nodedc-space-6: 1.5rem;
--nodedc-space-8: 2rem;
--nodedc-page-padding: 1.25rem;
--nodedc-blur-control: 24px;
--nodedc-blur-panel: 44px;
--nodedc-blur-modal: 64px;
--nodedc-blur-dropdown: 44px;
--nodedc-duration-fast: 120ms;
--nodedc-duration-normal: 180ms;
--nodedc-duration-slow: 260ms;
--nodedc-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
--nodedc-layer-base: 0;
--nodedc-layer-panel: 100;
--nodedc-layer-header: 400;
--nodedc-layer-overlay: 800;
--nodedc-layer-popover: 900;
--nodedc-layer-toast: 950;
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--nodedc-font-size-xs: 0.6875rem;
--nodedc-font-size-sm: 0.75rem;
--nodedc-font-size-md: 0.8125rem;
--nodedc-font-size-lg: 1rem;
--nodedc-font-size-title: 1.125rem;
--nodedc-font-weight-regular: 450;
--nodedc-font-weight-medium: 620;
--nodedc-font-weight-strong: 760;
}
+61
View File
@@ -0,0 +1,61 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"schemaVersion": "1.0.0",
"name": "NODE.DC Design Tokens",
"color": {
"accentRgb": { "$type": "color", "$value": "rgb(195 255 102)" },
"onAccentRgb": { "$type": "color", "$value": "rgb(11 17 23)" },
"dangerRgb": { "$type": "color", "$value": "rgb(255 116 116)" },
"warningRgb": { "$type": "color", "$value": "rgb(255 209 102)" },
"successRgb": { "$type": "color", "$value": "rgb(181 255 90)" }
},
"radius": {
"modal": { "$type": "dimension", "$value": "28px" },
"panel": { "$type": "dimension", "$value": "22px" },
"card": { "$type": "dimension", "$value": "21.6px" },
"control": { "$type": "dimension", "$value": "20px" },
"controlCompact": { "$type": "dimension", "$value": "14px" },
"option": { "$type": "dimension", "$value": "14.4px" },
"circle": { "$type": "dimension", "$value": "999px" }
},
"size": {
"control": { "$type": "dimension", "$value": "46px" },
"controlCompact": { "$type": "dimension", "$value": "38px" },
"iconButton": { "$type": "dimension", "$value": "46px" },
"headerRow": { "$type": "dimension", "$value": "48px" },
"headerPill": { "$type": "dimension", "$value": "55.2px" },
"logoWidth": { "$type": "dimension", "$value": "116px" },
"logoHeight": { "$type": "dimension", "$value": "28.64px" },
"inspectorWidth": { "$type": "dimension", "$value": "390px" }
},
"space": {
"1": { "$type": "dimension", "$value": "4px" },
"2": { "$type": "dimension", "$value": "8px" },
"3": { "$type": "dimension", "$value": "12px" },
"4": { "$type": "dimension", "$value": "16px" },
"5": { "$type": "dimension", "$value": "20px" },
"6": { "$type": "dimension", "$value": "24px" },
"8": { "$type": "dimension", "$value": "32px" },
"page": { "$type": "dimension", "$value": "20px" }
},
"blur": {
"control": { "$type": "dimension", "$value": "24px" },
"panel": { "$type": "dimension", "$value": "44px" },
"modal": { "$type": "dimension", "$value": "64px" },
"dropdown": { "$type": "dimension", "$value": "44px" }
},
"duration": {
"fast": { "$type": "duration", "$value": "120ms" },
"normal": { "$type": "duration", "$value": "180ms" },
"slow": { "$type": "duration", "$value": "260ms" }
},
"layer": {
"base": { "$type": "number", "$value": 0 },
"panel": { "$type": "number", "$value": 100 },
"header": { "$type": "number", "$value": 400 },
"overlay": { "$type": "number", "$value": 800 },
"popover": { "$type": "number", "$value": 900 },
"toast": { "$type": "number", "$value": 950 }
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@nodedc/ui-core",
"version": "0.1.0",
"type": "module",
"files": ["dist", "styles.css"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./styles.css": "./styles.css"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nodedc/tokens": "0.1.0"
}
}
+38
View File
@@ -0,0 +1,38 @@
export type RgbTuple = readonly [number, number, number];
export interface AccentVariables {
"--nodedc-accent-rgb": string;
"--nodedc-on-accent-rgb": string;
}
function clampChannel(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
}
function linearChannel(channel: number): number {
const normalized = clampChannel(channel) / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
}
export function relativeLuminance(rgb: RgbTuple): number {
const [red, green, blue] = rgb.map(linearChannel) as [number, number, number];
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
}
export function readableTextRgb(rgb: RgbTuple): RgbTuple {
return relativeLuminance(rgb) > 0.46 ? [11, 17, 23] : [247, 248, 244];
}
export function rgbTuple(value: RgbTuple): string {
return value.map(clampChannel).join(" ");
}
export function createAccentVariables(accent: RgbTuple): AccentVariables {
return {
"--nodedc-accent-rgb": rgbTuple(accent),
"--nodedc-on-accent-rgb": rgbTuple(readableTextRgb(accent)),
};
}
+67
View File
@@ -0,0 +1,67 @@
export type FloatingPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
export interface RectLike {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
}
export interface FloatingPositionOptions {
anchor: RectLike;
surfaceWidth: number;
surfaceHeight: number;
placement?: FloatingPlacement;
offset?: number;
viewportWidth?: number;
viewportHeight?: number;
viewportPadding?: number;
}
export interface FloatingPosition {
top: number;
left: number;
maxHeight: number;
resolvedPlacement: FloatingPlacement;
}
export function computeFloatingPosition({
anchor,
surfaceWidth,
surfaceHeight,
placement = "bottom-start",
offset = 8,
viewportWidth = typeof window === "undefined" ? 1920 : window.innerWidth,
viewportHeight = typeof window === "undefined" ? 1080 : window.innerHeight,
viewportPadding = 8,
}: FloatingPositionOptions): FloatingPosition {
const roomBelow = viewportHeight - anchor.bottom - offset - viewportPadding;
const roomAbove = anchor.top - offset - viewportPadding;
const requestedTop = placement.startsWith("top");
const shouldFlip = requestedTop
? surfaceHeight > roomAbove && roomBelow > roomAbove
: surfaceHeight > roomBelow && roomAbove > roomBelow;
const resolvedPlacement = shouldFlip
? (`${requestedTop ? "bottom" : "top"}-${placement.endsWith("end") ? "end" : "start"}` as FloatingPlacement)
: placement;
const alignedLeft = resolvedPlacement.endsWith("end")
? anchor.right - surfaceWidth
: anchor.left;
const left = Math.min(
Math.max(viewportPadding, alignedLeft),
Math.max(viewportPadding, viewportWidth - surfaceWidth - viewportPadding),
);
const availableHeight = resolvedPlacement.startsWith("top") ? roomAbove : roomBelow;
const maxHeight = Math.max(96, availableHeight);
const visibleHeight = Math.min(surfaceHeight, maxHeight);
const top = resolvedPlacement.startsWith("top")
? Math.max(viewportPadding, anchor.top - offset - visibleHeight)
: Math.min(anchor.bottom + offset, viewportHeight - visibleHeight - viewportPadding);
return { top, left, maxHeight, resolvedPlacement };
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./accent";
export * from "./floating";
export * from "./theme";
+28
View File
@@ -0,0 +1,28 @@
import { createAccentVariables, type RgbTuple } from "./accent";
export type NodedcTheme = "dark" | "light";
export interface ApplyThemeOptions {
theme?: NodedcTheme;
accent?: RgbTuple;
}
export function applyNodedcTheme(
target: HTMLElement,
{ theme = "dark", accent }: ApplyThemeOptions = {},
): void {
target.dataset.nodedcTheme = theme;
target.dataset.nodedcUi = "true";
if (!accent) return;
const variables = createAccentVariables(accent);
Object.entries(variables).forEach(([property, value]) => {
target.style.setProperty(property, value);
});
}
export function clearNodedcAccent(target: HTMLElement): void {
target.style.removeProperty("--nodedc-accent-rgb");
target.style.removeProperty("--nodedc-on-accent-rgb");
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@nodedc/ui-dom",
"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"
}
}
+131
View File
@@ -0,0 +1,131 @@
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
export interface FloatingLayerOptions {
trigger: HTMLElement;
surface: HTMLElement;
placement?: FloatingPlacement;
offset?: number;
minWidth?: number;
matchTriggerWidth?: boolean;
closeOnSelect?: boolean;
onOpenChange?: (open: boolean) => void;
}
export interface FloatingLayerController {
open: () => void;
close: () => void;
toggle: () => void;
reposition: () => void;
isOpen: () => boolean;
destroy: () => void;
}
export function createFloatingLayer({
trigger,
surface,
placement = "bottom-start",
offset = 8,
minWidth = 180,
matchTriggerWidth = true,
closeOnSelect = false,
onOpenChange,
}: FloatingLayerOptions): FloatingLayerController {
let openState = false;
const originalParent = surface.parentNode;
const originalNextSibling = surface.nextSibling;
surface.hidden = true;
surface.classList.add("nodedc-dropdown-surface");
document.body.append(surface);
const reposition = () => {
if (!openState) return;
const anchor = trigger.getBoundingClientRect();
const measured = surface.getBoundingClientRect();
const surfaceWidth = Math.max(minWidth, matchTriggerWidth ? anchor.width : 0, measured.width);
const surfaceHeight = Math.max(1, surface.scrollHeight);
const position = computeFloatingPosition({ anchor, surfaceWidth, surfaceHeight, placement, offset });
Object.assign(surface.style, {
position: "fixed",
top: `${position.top}px`,
left: `${position.left}px`,
width: `${surfaceWidth}px`,
maxHeight: `${position.maxHeight}px`,
visibility: "visible",
});
};
const open = () => {
if (openState || trigger.hasAttribute("disabled")) return;
openState = true;
surface.hidden = false;
trigger.setAttribute("aria-expanded", "true");
window.dispatchEvent(new CustomEvent("nodedc-floating-open", { detail: { surface } }));
requestAnimationFrame(reposition);
onOpenChange?.(true);
};
const close = () => {
if (!openState) return;
openState = false;
surface.hidden = true;
trigger.setAttribute("aria-expanded", "false");
onOpenChange?.(false);
};
const toggle = () => {
if (openState) close();
else open();
};
const handleTriggerClick = (event: MouseEvent) => {
event.preventDefault();
toggle();
};
const handleDocumentPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node)) return;
if (!trigger.contains(event.target) && !surface.contains(event.target)) close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && openState) {
close();
trigger.focus();
}
};
const handleOtherOpen = (event: Event) => {
const otherSurface = (event as CustomEvent<{ surface?: HTMLElement }>).detail.surface;
if (otherSurface && otherSurface !== surface) close();
};
const handleSurfaceClick = (event: MouseEvent) => {
if (closeOnSelect && event.target instanceof HTMLElement && event.target.closest("[data-nodedc-option]")) close();
};
trigger.addEventListener("click", handleTriggerClick);
surface.addEventListener("click", handleSurfaceClick);
document.addEventListener("pointerdown", handleDocumentPointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("nodedc-floating-open", handleOtherOpen as EventListener);
window.addEventListener("resize", reposition);
window.addEventListener("scroll", reposition, true);
return {
open,
close,
toggle,
reposition,
isOpen: () => openState,
destroy: () => {
close();
trigger.removeEventListener("click", handleTriggerClick);
surface.removeEventListener("click", handleSurfaceClick);
document.removeEventListener("pointerdown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("nodedc-floating-open", handleOtherOpen as EventListener);
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
if (originalParent) originalParent.insertBefore(surface, originalNextSibling);
surface.removeAttribute("style");
},
};
}
+5
View File
@@ -0,0 +1,5 @@
export { applyNodedcTheme, clearNodedcAccent, createAccentVariables, readableTextRgb } from "@nodedc/ui-core";
export * from "./floating";
export * from "./modal";
export * from "./select";
+117
View File
@@ -0,0 +1,117 @@
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export interface ModalControllerOptions {
overlay: HTMLElement;
dialog: HTMLElement;
closeControls?: Iterable<HTMLElement>;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
lockBodyScroll?: boolean;
onOpenChange?: (open: boolean) => void;
}
export interface ModalController {
open: () => void;
close: () => void;
isOpen: () => boolean;
destroy: () => void;
}
export function createModalController({
overlay,
dialog,
closeControls = [],
closeOnBackdrop = true,
closeOnEscape = true,
lockBodyScroll = true,
onOpenChange,
}: ModalControllerOptions): ModalController {
let openState = false;
let previousFocus: HTMLElement | null = null;
let previousOverflow = "";
const controls = Array.from(closeControls);
overlay.classList.add("nodedc-overlay", "nodedc-ui-root");
dialog.classList.add("nodedc-window");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.tabIndex = -1;
overlay.hidden = true;
const focusFirst = () => {
const first = dialog.querySelector<HTMLElement>(focusableSelector);
(first ?? dialog).focus();
};
const open = () => {
if (openState) return;
openState = true;
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
previousOverflow = document.body.style.overflow;
if (lockBodyScroll) document.body.style.overflow = "hidden";
overlay.hidden = false;
requestAnimationFrame(focusFirst);
onOpenChange?.(true);
};
const close = () => {
if (!openState) return;
openState = false;
overlay.hidden = true;
if (lockBodyScroll) document.body.style.overflow = previousOverflow;
previousFocus?.focus();
onOpenChange?.(false);
};
const handleOverlayPointerDown = (event: PointerEvent) => {
if (closeOnBackdrop && event.target === overlay) close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!openState) return;
if (event.key === "Escape" && closeOnEscape) {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector));
if (focusable.length === 0) {
event.preventDefault();
dialog.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();
}
};
overlay.addEventListener("pointerdown", handleOverlayPointerDown);
document.addEventListener("keydown", handleKeyDown);
controls.forEach((control) => control.addEventListener("click", close));
return {
open,
close,
isOpen: () => openState,
destroy: () => {
close();
overlay.removeEventListener("pointerdown", handleOverlayPointerDown);
document.removeEventListener("keydown", handleKeyDown);
controls.forEach((control) => control.removeEventListener("click", close));
},
};
}
+67
View File
@@ -0,0 +1,67 @@
import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating";
export interface SelectControllerOptions<T extends string> extends Omit<FloatingLayerOptions, "closeOnSelect"> {
value: T;
onChange: (value: T) => void;
valueTarget?: HTMLElement;
}
export interface SelectController<T extends string> extends FloatingLayerController {
getValue: () => T;
setValue: (value: T, emit?: boolean) => void;
}
export function createSelectController<T extends string>({
value,
onChange,
valueTarget,
surface,
trigger,
...floatingOptions
}: SelectControllerOptions<T>): SelectController<T> {
let currentValue = value;
const floating = createFloatingLayer({
trigger,
surface,
closeOnSelect: true,
...floatingOptions,
});
const options = () => Array.from(surface.querySelectorAll<HTMLElement>("[data-nodedc-option][data-nodedc-value]"));
const sync = () => {
options().forEach((option) => {
const selected = option.dataset.nodedcValue === currentValue;
option.dataset.selected = selected ? "true" : "false";
option.setAttribute("aria-selected", String(selected));
if (selected && valueTarget) valueTarget.textContent = option.dataset.nodedcLabel ?? option.textContent ?? currentValue;
});
};
const setValue = (nextValue: T, emit = true) => {
currentValue = nextValue;
sync();
if (emit) onChange(nextValue);
};
const handleOptionClick = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) return;
const option = event.target.closest<HTMLElement>("[data-nodedc-option][data-nodedc-value]");
if (!option || option.hasAttribute("disabled")) return;
setValue(option.dataset.nodedcValue as T);
};
surface.addEventListener("click", handleOptionClick);
sync();
return {
...floating,
getValue: () => currentValue,
setValue,
destroy: () => {
surface.removeEventListener("click", handleOptionClick);
floating.destroy();
},
};
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+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"]
}