Establish NODE.DC design system baseline
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./accent";
|
||||
export * from "./floating";
|
||||
export * from "./theme";
|
||||
|
||||
@@ -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
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { applyNodedcTheme, clearNodedcAccent, createAccentVariables, readableTextRgb } from "@nodedc/ui-core";
|
||||
export * from "./floating";
|
||||
export * from "./modal";
|
||||
export * from "./select";
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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