Align selects ranges and modal catalog

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 22:02:32 +03:00
parent 41e491191c
commit 1fc39dfebc
8 changed files with 138 additions and 60 deletions

View File

@ -384,15 +384,11 @@ export function CatalogApp() {
<ControlRow label="Плашки">
<ColorField label="Цвет плашек" value={material.panelHex} onChange={(panelHex) => updateMaterial({ panelHex })} />
</ControlRow>
<ControlRow label="Прозрачность плашки">
<RangeControl label="Прозрачность плашки" showLabel={false} value={material.panelOpacity} min={18} max={96} formatValue={(value) => `${value}%`} onChange={(panelOpacity) => updateMaterial({ panelOpacity })} />
</ControlRow>
<RangeControl label="Прозрачность плашки" value={material.panelOpacity} min={18} max={96} formatValue={(value) => `${value}%`} onChange={(panelOpacity) => updateMaterial({ panelOpacity })} />
<ControlRow label="Поля ввода">
<ColorField label="Цвет полей ввода" value={material.fieldHex} onChange={(fieldHex) => updateMaterial({ fieldHex })} />
</ControlRow>
<ControlRow label="Прозрачность полей">
<RangeControl label="Прозрачность полей" showLabel={false} value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} />
</ControlRow>
<RangeControl label="Прозрачность полей" value={material.fieldOpacity} min={18} max={100} formatValue={(value) => `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} />
</>
),
},
@ -609,48 +605,58 @@ export function CatalogApp() {
);
case "modals":
return (
<div className="catalog-grid">
<Preview title="Создание" note="форма адаптируется по ширине">
<p className="catalog-preview__explanation">Поля переходят в одну колонку на узком viewport; footer остаётся видимым, прокручивается только тело окна.</p>
<div className="catalog-modal-groups">
<section className="catalog-modal-group">
<header><strong>Общие</strong><span>Launcher / Engine</span></header>
<div className="catalog-modal-grid">
<Preview title="Создание" note="адаптивная форма" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Одна колонка на узком viewport; footer остаётся видимым.</p>
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => setCreateModalOpen(true)}>Создать проект</Button>
</Preview>
<Preview title="Подтверждение удаления" note="async-safe">
<p className="catalog-preview__explanation">Destructive-цвет появляется у подтверждения, а не окрашивает всё окно заранее.</p>
<Preview title="Подтверждение" note="async-safe" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Destructive-цвет появляется только у подтверждения.</p>
<Button variant="danger" shape="pill" icon={<Icon name="trash" />} onClick={() => setConfirmOpen(true)}>Проверить удаление</Button>
</Preview>
<Preview title="Workflow sharing" note="ENGINE / access modal">
<p className="catalog-preview__explanation">Участники, роли, приглашение и immutable-владелец. API доступа остаётся в Engine.</p>
<Preview title="Workflow sharing" note="ENGINE / access" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Участники, роли, приглашение и immutable-владелец.</p>
<Button variant="primary" shape="pill" icon={<Icon name="users" />} onClick={() => setShareAccessOpen(true)}>Поделиться графом</Button>
</Preview>
<Preview title="Ссылка на модель" note="BIM / share link">
<p className="catalog-preview__explanation">Read-only ссылка, copy-state и единая механика focus/Escape/backdrop.</p>
</div>
</section>
<section className="catalog-modal-group">
<header><strong>Работа с моделями</strong><span>BIM Viewer</span></header>
<div className="catalog-modal-grid">
<Preview title="Ссылка на модель" note="share link" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Read-only ссылка и copy-state.</p>
<Button shape="pill" icon={<Icon name="copy" />} onClick={() => setShareLinkOpen(true)}>Открыть ссылку</Button>
</Preview>
<Preview title="Контекстные действия" note="BIM / measurement & object">
<p className="catalog-preview__explanation">Короткое окно действий над выбранным размером или объектом без отдельной modal-системы.</p>
<Preview title="Контекстные действия" note="measurement / object" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Действия над выбранным размером или объектом.</p>
<Button shape="pill" icon={<Icon name="sliders" />} onClick={() => setContextActionOpen(true)}>Действия размера</Button>
</Preview>
<Preview title="История версий" note="BIM / large data modal">
<p className="catalog-preview__explanation">Большое прокручиваемое data-окно; BIM-таблица остаётся доменной композицией внутри общего Window.</p>
<Preview title="История версий" note="large data" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Прокручиваемая история BIM-версий.</p>
<Button shape="pill" icon={<Icon name="list" />} onClick={() => setHistoryOpen(true)}>Открыть историю</Button>
</Preview>
<Preview title="Комментарий к модели" note="BIM / expandable detail">
<p className="catalog-preview__explanation">Рабочая detail-modal с контентом и вложениями, которая может расширяться без смены механики слоя.</p>
<Preview title="Комментарий" note="expandable detail" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Контент, вложения и detail-состояние.</p>
<Button shape="pill" icon={<Icon name="clipboard" />} onClick={() => setDetailOpen(true)}>Открыть комментарий</Button>
</Preview>
</div>
</section>
</div>
);
case "icons":
return (
<div className="catalog-icon-catalog">
<Preview title="Размеры и поверхности" note="16 / 18 / 20 px" className="catalog-preview--wide">
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
<div className="catalog-window-actions-demo">
<IconButton label="Добавить"><Icon name="plus" size={16} /></IconButton>
<IconButton label="Обновить"><Icon name="refresh" size={18} /></IconButton>
<IconButton label="Развернуть"><Icon name="expand" size={20} /></IconButton>
<IconButton label="Закрыть"><Icon name="close" size={18} /></IconButton>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
</div>
<p className="catalog-preview__explanation">Иконка наследует `currentColor`; фон, размер кнопки и active-state задаёт компонент поверхности, а не SVG. Glyph оптически уменьшен на 18% без изменения hit target.</p>
<p className="catalog-preview__explanation">Все glyph-иконки используют один размер 16 px и один stroke 1.6; surface меняет только фон и hit target.</p>
</Preview>
{iconGroups.map((group) => (
<section className="catalog-icon-group" key={group.title}>
@ -658,7 +664,7 @@ export function CatalogApp() {
<div className="catalog-icon-grid">
{group.icons.map((name) => (
<div className="catalog-icon-tile" key={name}>
<span className="catalog-icon-tile__surface"><Icon name={name} size={20} /></span>
<span className="catalog-icon-tile__surface"><Icon name={name} /></span>
<span><strong>{iconLabels[name]}</strong><code>{name}</code></span>
</div>
))}
@ -699,7 +705,7 @@ export function CatalogApp() {
}
right={
<HeaderProfile>
<IconButton label="Уведомления"><Icon name="inbox" size={20} /></IconButton>
<IconButton label="Уведомления"><Icon name="inbox" /></IconButton>
<HeaderProfileButton>Профиль</HeaderProfileButton>
<HeaderAvatar label="DC" />
</HeaderProfile>

View File

@ -215,6 +215,60 @@ textarea {
gap: 0.75rem;
}
.catalog-modal-groups {
display: grid;
gap: 1.25rem;
}
.catalog-modal-group {
display: grid;
gap: 0.65rem;
}
.catalog-modal-group > header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding-inline: 0.15rem;
}
.catalog-modal-group > header strong {
font-size: 0.9rem;
}
.catalog-modal-group > header span {
color: var(--nodedc-text-muted);
font-size: 0.68rem;
text-transform: uppercase;
}
.catalog-modal-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.nodedc-glass.catalog-modal-preview {
min-height: 10.5rem;
padding: 1rem;
}
.catalog-modal-preview .catalog-preview__head {
margin-bottom: 0.7rem;
}
.catalog-modal-preview .catalog-preview__body {
min-height: 0;
align-content: space-between;
gap: 0.75rem;
}
.catalog-modal-preview .nodedc-button {
width: 100%;
padding-inline: 0.75rem;
}
.catalog-history-table {
display: grid;
min-width: 46rem;
@ -447,6 +501,10 @@ textarea {
.catalog-icon-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.catalog-modal-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@ -492,7 +550,8 @@ textarea {
}
.catalog-preview__head,
.catalog-icon-group > header {
.catalog-icon-group > header,
.catalog-modal-group > header {
align-items: flex-start;
flex-direction: column;
gap: 0.25rem;
@ -503,6 +562,10 @@ textarea {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.catalog-modal-grid {
grid-template-columns: 1fr;
}
.catalog-shell-diagram {
grid-template-columns: 1fr 1.5fr;
grid-template-rows: 3rem 7rem 7rem;

View File

@ -4,13 +4,13 @@
## GlassSurface
Базовая непрозрачная поверхность для карточки, панели и большого окна. Варианты меняют плотность нейтральной поверхности, но не создают отдельный дизайн. Реальный glass-материал с blur и rim используется только у modal `Window`.
Базовая непрозрачная поверхность для карточки, панели и большого окна. Варианты меняют плотность нейтральной поверхности, но не создают отдельный дизайн. Реальный glass-материал с blur и rim используется только у floating-слоёв: modal `Window` и portal-dropdown/select.
- `default` — обычная карточка/панель;
- `strong` — dropdown, modal и поверхность над сложным фоном;
- `soft` — вложенная или вторичная область.
Material rim допустим только как часть modal-стекла. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
## Button и IconButton
@ -61,7 +61,7 @@ Dropdown владеет floating-layer поведением:
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
- `integrated` — единая pill-поверхность Hub/Launcher;
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`.
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.

View File

@ -38,7 +38,7 @@ NODE.DC-приложения отличаются цветовой схемой,
Accent задаётся RGB tuple. On-accent вычисляется по luminance и не должен вручную прописываться белым или чёрным в отдельном компоненте.
Default accent текущего NODE.DC shell — `#ff2f92`. `ColorField` открывает единый portal-picker с saturation/value plane, hue slider, HEX и быстрыми цветами — системный `input[type=color]` не является runtime UI. Изменение accent сразу меняет Environment Settings, active actions, range fill, media file action и theme-aware window states без создания новой темы или fork компонентов. Blur, gloss и material rim не наследуются application panels: это материал только для modal `Window`.
Default accent текущего NODE.DC shell — `#ff2f92`. `ColorField` открывает единый portal-picker с saturation/value plane, hue slider, HEX и быстрыми цветами — системный `input[type=color]` не является runtime UI. Изменение accent сразу меняет Environment Settings, active actions, range fill, media file action и theme-aware window states без создания новой темы или fork компонентов. Blur, gloss и material rim не наследуются application panels: это материал только для floating modal и portal-dropdown/select.
Semantic status не обязан совпадать с accent. Например, ошибка остаётся danger, даже если приложение использует красный брендовый accent.

View File

@ -57,6 +57,7 @@
--nodedc-primary-action-shadow: 0 12px 28px rgb(var(--nodedc-accent-rgb) / 0.2);
--nodedc-modal-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.014)), rgba(7, 7, 10, 0.54);
--nodedc-modal-shadow: 0 34px 120px rgba(0, 0, 0, 0.64), 0 10px 32px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.045);
--nodedc-floating-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012)), rgba(10, 10, 13, 0.94);
}
[data-nodedc-theme="light"] {
@ -117,4 +118,5 @@
--nodedc-primary-action-shadow: none;
--nodedc-modal-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.36)), rgba(255, 255, 255, 0.72);
--nodedc-modal-shadow: 0 24px 72px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.8);
--nodedc-floating-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.72)), rgba(255, 255, 255, 0.94);
}

View File

@ -1623,10 +1623,12 @@ textarea.nodedc-field__control {
overflow: auto;
border: 0;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-glass-panel-surface-strong);
background: var(--nodedc-floating-surface);
color: var(--nodedc-text-primary);
padding: 0.45rem;
box-shadow: var(--nodedc-glass-dropdown-shadow);
backdrop-filter: blur(var(--nodedc-blur-dropdown)) saturate(1.12);
-webkit-backdrop-filter: blur(var(--nodedc-blur-dropdown)) saturate(1.12);
animation: nodedc-popover-in var(--nodedc-duration-fast) var(--nodedc-ease-standard);
}
@ -1782,7 +1784,7 @@ textarea.nodedc-field__control {
.nodedc-dropdown-surface.nodedc-select__menu {
gap: 0.125rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-field-bg);
background: var(--nodedc-floating-surface);
padding: 0.4375rem;
}
@ -1820,6 +1822,12 @@ textarea.nodedc-field__control {
white-space: nowrap;
}
.nodedc-select-trigger__icon {
display: inline-grid;
min-width: 0;
place-items: center;
}
.nodedc-select-trigger__chevron {
width: 0.5rem;
height: 0.5rem;
@ -2077,9 +2085,11 @@ textarea.nodedc-field__control {
gap: 0.25rem;
overflow-y: auto;
border-radius: var(--nodedc-radius-panel);
background: var(--nodedc-glass-panel-surface-strong);
background: var(--nodedc-floating-surface);
padding: 0.6rem;
box-shadow: var(--nodedc-glass-dropdown-shadow);
backdrop-filter: blur(var(--nodedc-blur-dropdown)) saturate(1.12);
-webkit-backdrop-filter: blur(var(--nodedc-blur-dropdown)) saturate(1.12);
}
.nodedc-share-access__summary {

View File

@ -4,8 +4,6 @@ import { Dropdown } from "./Dropdown.js";
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
label: string;
/** Keeps the accessible name while letting a parent ControlRow render the visible label. */
showLabel?: boolean;
value: number;
min: number;
max: number;
@ -15,7 +13,6 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
export function RangeControl({
label,
showLabel = true,
value,
min,
max,
@ -29,7 +26,7 @@ export function RangeControl({
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)} data-label-hidden={showLabel ? undefined : "true"} style={style}>
<label className={cn("nodedc-range", className)} style={style}>
<input
type="range"
value={value}
@ -40,10 +37,10 @@ export function RangeControl({
onChange={(event) => onChange(Number(event.target.value))}
{...props}
/>
{showLabel ? <span className="nodedc-range__label">{label}</span> : null}
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
<span className="nodedc-range__fill-text" aria-hidden="true">
{showLabel ? <span className="nodedc-range__label">{label}</span> : null}
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
</span>
</label>

View File

@ -75,8 +75,8 @@ export function Select<T extends string>({
if (variant === "split") {
return (
<div className={cn("nodedc-select", triggerClassName)}>
<div className="nodedc-select__control">
<div ref={setTriggerRef} className="nodedc-select__value">
<div ref={setTriggerRef} className="nodedc-select__control">
<div className="nodedc-select__value">
{selected?.icon ? <span className="nodedc-select__value-icon">{selected.icon}</span> : null}
<span>{selected?.label ?? "—"}</span>
</div>
@ -111,7 +111,7 @@ export function Select<T extends string>({
onClick={toggle}
onKeyDown={handleKeyDown}
>
{selected?.icon ?? null}
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : <span className="nodedc-select-trigger__icon" aria-hidden="true" />}
<span className="nodedc-select-trigger__label">{selected?.label ?? "—"}</span>
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
</button>