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

View File

@ -215,6 +215,60 @@ textarea {
gap: 0.75rem; 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 { .catalog-history-table {
display: grid; display: grid;
min-width: 46rem; min-width: 46rem;
@ -447,6 +501,10 @@ textarea {
.catalog-icon-grid { .catalog-icon-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.catalog-modal-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@ -492,7 +550,8 @@ textarea {
} }
.catalog-preview__head, .catalog-preview__head,
.catalog-icon-group > header { .catalog-icon-group > header,
.catalog-modal-group > header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
@ -503,6 +562,10 @@ textarea {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.catalog-modal-grid {
grid-template-columns: 1fr;
}
.catalog-shell-diagram { .catalog-shell-diagram {
grid-template-columns: 1fr 1.5fr; grid-template-columns: 1fr 1.5fr;
grid-template-rows: 3rem 7rem 7rem; grid-template-rows: 3rem 7rem 7rem;

View File

@ -4,13 +4,13 @@
## GlassSurface ## GlassSurface
Базовая непрозрачная поверхность для карточки, панели и большого окна. Варианты меняют плотность нейтральной поверхности, но не создают отдельный дизайн. Реальный glass-материал с blur и rim используется только у modal `Window`. Базовая непрозрачная поверхность для карточки, панели и большого окна. Варианты меняют плотность нейтральной поверхности, но не создают отдельный дизайн. Реальный glass-материал с blur и rim используется только у floating-слоёв: modal `Window` и portal-dropdown/select.
- `default` — обычная карточка/панель; - `default` — обычная карточка/панель;
- `strong` — dropdown, modal и поверхность над сложным фоном; - `strong` — dropdown, modal и поверхность над сложным фоном;
- `soft` — вложенная или вторичная область. - `soft` — вложенная или вторичная область.
Material rim допустим только как часть modal-стекла. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim. Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
## Button и IconButton ## Button и IconButton
@ -61,7 +61,7 @@ Dropdown владеет floating-layer поведением:
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы: Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
- `integrated` — единая pill-поверхность Hub/Launcher; - `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`. - `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`.
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 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 и не должен вручную прописываться белым или чёрным в отдельном компоненте. 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. 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-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-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-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"] { [data-nodedc-theme="light"] {
@ -117,4 +118,5 @@
--nodedc-primary-action-shadow: none; --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-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-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; overflow: auto;
border: 0; border: 0;
border-radius: var(--nodedc-radius-control); border-radius: var(--nodedc-radius-control);
background: var(--nodedc-glass-panel-surface-strong); background: var(--nodedc-floating-surface);
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
padding: 0.45rem; padding: 0.45rem;
box-shadow: var(--nodedc-glass-dropdown-shadow); 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); 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 { .nodedc-dropdown-surface.nodedc-select__menu {
gap: 0.125rem; gap: 0.125rem;
border-radius: var(--nodedc-radius-control); border-radius: var(--nodedc-radius-control);
background: var(--nodedc-field-bg); background: var(--nodedc-floating-surface);
padding: 0.4375rem; padding: 0.4375rem;
} }
@ -1820,6 +1822,12 @@ textarea.nodedc-field__control {
white-space: nowrap; white-space: nowrap;
} }
.nodedc-select-trigger__icon {
display: inline-grid;
min-width: 0;
place-items: center;
}
.nodedc-select-trigger__chevron { .nodedc-select-trigger__chevron {
width: 0.5rem; width: 0.5rem;
height: 0.5rem; height: 0.5rem;
@ -2077,9 +2085,11 @@ textarea.nodedc-field__control {
gap: 0.25rem; gap: 0.25rem;
overflow-y: auto; overflow-y: auto;
border-radius: var(--nodedc-radius-panel); border-radius: var(--nodedc-radius-panel);
background: var(--nodedc-glass-panel-surface-strong); background: var(--nodedc-floating-surface);
padding: 0.6rem; padding: 0.6rem;
box-shadow: var(--nodedc-glass-dropdown-shadow); 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 { .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"> { export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
label: string; label: string;
/** Keeps the accessible name while letting a parent ControlRow render the visible label. */
showLabel?: boolean;
value: number; value: number;
min: number; min: number;
max: number; max: number;
@ -15,7 +13,6 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
export function RangeControl({ export function RangeControl({
label, label,
showLabel = true,
value, value,
min, min,
max, max,
@ -29,7 +26,7 @@ export function RangeControl({
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100)); const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties; const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties;
return ( return (
<label className={cn("nodedc-range", className)} data-label-hidden={showLabel ? undefined : "true"} style={style}> <label className={cn("nodedc-range", className)} style={style}>
<input <input
type="range" type="range"
value={value} value={value}
@ -40,10 +37,10 @@ export function RangeControl({
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => onChange(Number(event.target.value))}
{...props} {...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__value">{formatValue(value)}</span>
<span className="nodedc-range__fill-text" aria-hidden="true"> <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 className="nodedc-range__value">{formatValue(value)}</span>
</span> </span>
</label> </label>

View File

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