Make application shell components package-native
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/ui-react",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"files": ["dist"],
|
||||
"main": "./dist/index.js",
|
||||
@@ -16,7 +16,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.3.0",
|
||||
"@nodedc/ui-core": "0.4.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface AdminNavigationContext {
|
||||
id: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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,5 +1,5 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface InspectorSectionSpec {
|
||||
id: string;
|
||||
|
||||
@@ -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,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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user