Make application shell components package-native

This commit is contained in:
DCCONSTRUCTIONS
2026-07-10 12:41:12 +03:00
parent 9261d3af36
commit ebae662b52
52 changed files with 1036 additions and 185 deletions
@@ -1,5 +1,5 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface AdminNavigationContext {
id: string;
+58 -21
View File
@@ -1,11 +1,9 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import type { ButtonHTMLAttributes, ReactNode } from "react";
export interface AppHeaderProps extends HTMLAttributes<HTMLElement> {
export interface AppHeaderProps {
brand: ReactNode;
brandHref?: string;
brandLabel?: string;
fixed?: boolean;
left?: ReactNode;
center?: ReactNode;
right?: ReactNode;
@@ -15,12 +13,9 @@ export function AppHeader({
brand,
brandHref,
brandLabel = "NODE.DC",
fixed = true,
left,
center,
right,
className,
...props
}: AppHeaderProps) {
const brandNode = brandHref ? (
<a className="nodedc-header__brand" href={brandHref} aria-label={brandLabel}>{brand}</a>
@@ -29,45 +24,87 @@ export function AppHeader({
);
return (
<header className={cn("nodedc-header-shell", className)} data-fixed={fixed ? "true" : undefined} {...props}>
<header className="nodedc-header-shell" data-fixed="true" data-preset="launcher">
<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 className="nodedc-header__row">
<div className="nodedc-header__left">{brandNode}{left}</div>
<div className="nodedc-header__center">{center}</div>
<div className="nodedc-header__right">{right}</div>
</div>
</div>
</header>
);
}
export function HeaderProfile({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("nodedc-header__profile", className)} {...props}>{children}</div>;
export interface HeaderNavigationItem<T extends string> {
value: T;
label: ReactNode;
disabled?: boolean;
}
export function HeaderProfileButton({ className, children, type = "button", ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
return <button type={type} className={cn("nodedc-header__profile-button", className)} {...props}>{children}</button>;
export interface HeaderNavigationProps<T extends string> {
label: string;
value?: T;
items: readonly HeaderNavigationItem<T>[];
onChange: (value: T) => void;
}
export interface HeaderAvatarProps extends HTMLAttributes<HTMLSpanElement> {
export function HeaderNavigation<T extends string>({ label, value, items, onChange }: HeaderNavigationProps<T>) {
return (
<nav className="nodedc-header-navigation" aria-label={label}>
{items.map((item) => (
<button
key={item.value}
type="button"
className="nodedc-header__nav-item"
data-active={item.value === value ? "true" : undefined}
aria-current={item.value === value ? "page" : undefined}
disabled={item.disabled}
onClick={() => onChange(item.value)}
>
{item.label}
</button>
))}
</nav>
);
}
export interface HeaderProfileProps {
children: ReactNode;
}
export function HeaderProfile({ children }: HeaderProfileProps) {
return <div className="nodedc-header__profile">{children}</div>;
}
export interface HeaderProfileButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "style"> {}
export function HeaderProfileButton({ children, type = "button", ...props }: HeaderProfileButtonProps) {
return <button type={type} className="nodedc-header__profile-button" {...props}>{children}</button>;
}
export interface HeaderAvatarProps {
label: string;
imageUrl?: string;
}
export function HeaderAvatar({ label, imageUrl, className, ...props }: HeaderAvatarProps) {
export function HeaderAvatar({ label, imageUrl }: HeaderAvatarProps) {
return (
<span className={cn("nodedc-header__avatar", className)} title={label} {...props}>
<span className="nodedc-header__avatar" title={label}>
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
</span>
);
}
export interface HeaderWorkspaceProps extends HTMLAttributes<HTMLSpanElement> {
export interface HeaderWorkspaceProps {
label: string;
imageUrl?: string;
kind?: "mark" | "avatar";
}
export function HeaderWorkspace({ label, imageUrl, className, ...props }: HeaderWorkspaceProps) {
export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) {
return (
<span className={cn("nodedc-header__workspace", className)} title={label} {...props}>
<span className="nodedc-header__workspace" data-kind={kind} title={label}>
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
</span>
);
+2 -2
View File
@@ -1,6 +1,6 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import { Icon } from "./Icon";
import { cn } from "./cn.js";
import { Icon } from "./Icon.js";
export interface ApplicationShellProps extends Omit<HTMLAttributes<HTMLDivElement>, "content"> {
header: ReactNode;
@@ -0,0 +1,67 @@
import { useCallback, useState } from "react";
export interface ApplicationWorkspaceState<ViewId extends string> {
navigationOpen: boolean;
activeView: ViewId | null;
contentOpen: boolean;
contentExpanded: boolean;
}
export interface ApplicationWorkspaceController<ViewId extends string> extends ApplicationWorkspaceState<ViewId> {
openNavigation: () => void;
toggleNavigation: () => void;
closeNavigation: () => void;
openView: (view: ViewId) => void;
closeView: () => void;
setContentExpanded: (expanded: boolean) => void;
}
export interface ApplicationWorkspaceInitialState<ViewId extends string> {
navigationOpen?: boolean;
activeView?: ViewId | null;
contentExpanded?: boolean;
}
/** Owns the Launcher-derived navigation/content-window state machine. */
export function useApplicationWorkspace<ViewId extends string>({
navigationOpen: initialNavigationOpen = false,
activeView: initialActiveView = null,
contentExpanded: initialContentExpanded = true,
}: ApplicationWorkspaceInitialState<ViewId> = {}): ApplicationWorkspaceController<ViewId> {
const [navigationOpen, setNavigationOpen] = useState(initialNavigationOpen || initialActiveView !== null);
const [activeView, setActiveView] = useState<ViewId | null>(initialActiveView);
const [contentExpanded, setContentExpanded] = useState(initialContentExpanded);
const openNavigation = useCallback(() => setNavigationOpen(true), []);
const closeView = useCallback(() => setActiveView(null), []);
const closeNavigation = useCallback(() => {
setNavigationOpen(false);
setActiveView(null);
}, []);
const toggleNavigation = useCallback(() => {
if (navigationOpen) {
setNavigationOpen(false);
setActiveView(null);
} else {
setNavigationOpen(true);
}
}, [navigationOpen]);
const openView = useCallback((view: ViewId) => {
setNavigationOpen(true);
setActiveView(view);
setContentExpanded(true);
}, []);
return {
navigationOpen,
activeView,
contentOpen: activeView !== null,
contentExpanded,
openNavigation,
toggleNavigation,
closeNavigation,
openView,
closeView,
setContentExpanded,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
import { cn } from "./cn";
import { cn } from "./cn.js";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "default" | "compact";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ButtonHTMLAttributes } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean;
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "./Button";
import { Window, WindowFooterActions } from "./Window";
import { Button } from "./Button.js";
import { Window, WindowFooterActions } from "./Window.js";
export interface ConfirmationModalProps {
open: boolean;
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface DropdownTriggerApi {
open: boolean;
+1 -1
View File
@@ -1,5 +1,5 @@
import { useId, type InputHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface FieldFrameProps {
label: string;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export type GlassTone = "default" | "strong" | "soft";
export type GlassRadius = "card" | "panel" | "modal";
+1 -1
View File
@@ -1,5 +1,5 @@
import { useMemo, useState, type ReactNode } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface InspectorSectionSpec {
id: string;
+118
View File
@@ -0,0 +1,118 @@
import { useId, type ChangeEvent, type ReactNode } from "react";
import { Icon } from "./Icon.js";
export type MediaSource = "file" | "url";
export type MediaPreviewKind = "image" | "gif" | "video";
export interface MediaSourceFieldProps {
label: string;
kindLabel?: string;
source: MediaSource;
url: string;
fileName?: string | null;
uploading?: boolean;
previewSrc?: string | null;
previewKind?: MediaPreviewKind | null;
accept?: string;
fileButtonLabel?: string;
emptyFileLabel?: string;
path?: ReactNode;
hint?: ReactNode;
error?: ReactNode;
onSourceChange: (source: MediaSource) => void;
onUrlChange: (url: string) => void;
onFileChange: (file?: File) => void | Promise<void>;
}
function inferredPreviewKind(src: string): MediaPreviewKind {
if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src)) return "video";
if (/\.gif(\?.*)?$/i.test(src)) return "gif";
return "image";
}
function MediaPreview({ src, kind }: { src: string; kind?: MediaPreviewKind | null }) {
const resolvedKind = kind ?? inferredPreviewKind(src);
if (resolvedKind === "video") return <video src={src} autoPlay loop muted playsInline />;
return <img src={src} alt="" />;
}
export function MediaSourceField({
label,
kindLabel = "media",
source,
url,
fileName,
uploading = false,
previewSrc,
previewKind,
accept = "image/*,video/*",
fileButtonLabel = "Выберите файл",
emptyFileLabel = "Файл не выбран",
path,
hint,
error,
onSourceChange,
onUrlChange,
onFileChange,
}: MediaSourceFieldProps) {
const inputId = useId();
const displayFileName = uploading ? "Сохраняем в storage..." : (fileName || emptyFileLabel);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
void onFileChange(event.currentTarget.files?.[0]);
event.currentTarget.value = "";
};
return (
<div className="nodedc-media-field">
<div className="nodedc-media-field__label-row">
<span className="nodedc-media-field__label">{label}</span>
<span className="nodedc-media-field__kind">{kindLabel}</span>
</div>
<div className="nodedc-media-control" data-source={source}>
<div className="nodedc-media-file" hidden={source !== "file"} data-nodedc-media-source-panel="file">
<label className="nodedc-media-file__button" htmlFor={inputId}>{fileButtonLabel}</label>
<span className="nodedc-media-file__name" title={displayFileName}>{displayFileName}</span>
<input id={inputId} type="file" accept={accept} disabled={uploading} onChange={handleFileChange} />
</div>
<input
className="nodedc-media-url"
type="url"
value={url}
hidden={source !== "url"}
data-nodedc-media-source-panel="url"
placeholder="https://..."
autoComplete="off"
aria-label={`${label}: внешняя ссылка`}
onChange={(event) => onUrlChange(event.target.value)}
/>
<div className="nodedc-media-source-switch" aria-label={`${label}: источник`}>
<button
type="button"
className="nodedc-media-source-button"
data-active={source === "file" ? "true" : undefined}
data-nodedc-media-source-option="file"
aria-label="Файл с диска"
aria-pressed={source === "file"}
onClick={() => onSourceChange("file")}
>HD</button>
<button
type="button"
className="nodedc-media-source-button"
data-active={source === "url" ? "true" : undefined}
data-nodedc-media-source-option="url"
aria-label="Внешняя ссылка"
aria-pressed={source === "url"}
onClick={() => onSourceChange("url")}
>URL</button>
</div>
<div className="nodedc-media-preview" aria-hidden="true">
{previewSrc ? <MediaPreview src={previewSrc} kind={previewKind} /> : <Icon name="image" size={16} />}
</div>
</div>
{path ? <span className="nodedc-media-field__path">{path}</span> : null}
{hint ? <span className="nodedc-media-field__hint">{hint}</span> : null}
{error ? <span className="nodedc-media-field__error" role="alert">{error}</span> : null}
</div>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, InputHTMLAttributes } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
label: string;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export interface SegmentedItem<T extends string> {
value: T;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
import type { FloatingPlacement } from "@nodedc/ui-core";
import { Dropdown } from "./Dropdown";
import { cn } from "./cn";
import { Dropdown } from "./Dropdown.js";
import { cn } from "./cn.js";
export interface SelectOption<T extends string> {
value: T;
+48
View File
@@ -0,0 +1,48 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn.js";
export interface SettingsCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
eyebrow?: ReactNode;
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
}
export function SettingsCard({ eyebrow, title, description, actions, children, className, ...props }: SettingsCardProps) {
return (
<section className={cn("nodedc-settings-card", className)} {...props}>
<header className="nodedc-settings-card__head">
<div className="nodedc-settings-card__titles">
{eyebrow ? <span>{eyebrow}</span> : null}
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
</div>
{actions ? <div className="nodedc-settings-card__actions">{actions}</div> : null}
</header>
<div className="nodedc-settings-card__body">{children}</div>
</section>
);
}
export interface SwitchProps {
checked: boolean;
label: string;
disabled?: boolean;
onChange: (checked: boolean) => void;
}
export function Switch({ checked, label, disabled = false, onChange }: SwitchProps) {
return (
<button
type="button"
className="nodedc-switch"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
>
<span className="nodedc-switch__track" aria-hidden="true"><span /></span>
<span>{label}</span>
</button>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn";
import { cn } from "./cn.js";
export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent";
+1 -1
View File
@@ -7,7 +7,7 @@ import {
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn";
import { cn } from "./cn.js";
const focusableSelector = [
"a[href]",
+19 -16
View File
@@ -1,16 +1,19 @@
export * from "./AppHeader";
export * from "./AdminNavigationPanel";
export * from "./ApplicationShell";
export * from "./Button";
export * from "./Checker";
export * from "./ConfirmationModal";
export * from "./Dropdown";
export * from "./Field";
export * from "./Glass";
export * from "./Inspector";
export * from "./Icon";
export * from "./RangeControl";
export * from "./SegmentedControl";
export * from "./Select";
export * from "./StatusBadge";
export * from "./Window";
export * from "./AppHeader.js";
export * from "./AdminNavigationPanel.js";
export * from "./ApplicationShell.js";
export * from "./ApplicationWorkspace.js";
export * from "./Button.js";
export * from "./Checker.js";
export * from "./ConfirmationModal.js";
export * from "./Dropdown.js";
export * from "./Field.js";
export * from "./Glass.js";
export * from "./Inspector.js";
export * from "./Icon.js";
export * from "./MediaSourceField.js";
export * from "./RangeControl.js";
export * from "./SegmentedControl.js";
export * from "./Select.js";
export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./Window.js";