Standardize action and region loading states

Keep progress inside the initiating Button/IconButton or centered within a
LoadingRegion without changing layout or unmounting live content. Document
ownership and completion/error behavior in the registry and living catalog.

Validation: qualified NET02 DG build/typecheck/registry/loading tests;
existing browser geometry, theme and error lifecycle acceptance. All eleven
committed files match the qualified source artifact byte for byte.
This commit is contained in:
Codex
2026-09-10 09:19:12 +03:00
parent b10fd5d645
commit 8c53f73ee5
11 changed files with 188 additions and 9 deletions
+6 -5
View File
@@ -3,6 +3,7 @@ import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassM
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
import {
ActivityIndicator,
LoadingRegion,
StatusBadge,
ProgressBar,
AdminNavigationPanel,
@@ -1405,14 +1406,14 @@ export function CatalogApp() {
<ActivityIndicator label="Загружаем данные" />
<span>Загружаем данные</span>
</span>
<Button
aria-busy="true"
disabled
icon={<ActivityIndicator size="compact" />}
>Подключаем</Button>
<Button loading>Подключить</Button>
<IconButton label="Обновить" loading><Icon name="refresh" /></IconButton>
</div>
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
</Preview>
<Preview title="Загрузка содержимого" note="в центре собственной области">
<LoadingRegion loading label="Ожидаем содержимое" />
</Preview>
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><StatusBadge variant="indicator" tone="success" aria-label="Готово" title="Готово" /><StatusBadge variant="indicator" aria-label="Недоступно" title="Недоступно" /></Preview>
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
+4
View File
@@ -83,3 +83,7 @@ registry adapters or compatibility re-exports. They own no copied form markup or
presentation CSS. Node supplies Home only; Core retains all existing pages and
its wire/storage schema. Both use the existing `UserProfileMenu` avatar dropdown.
The component contract and catalog example are in `ENVIRONMENT_SETTINGS.md`.
## Mission Core loading (2026-09-08)
Core и Node используют общий sensor-ui/X4 frontend с Button.loading, IconButton.loading и LoadingRegion из Design Guideline. SDK/network semantics остаются в consumer, геометрия ожидания принадлежит DG. Этот локальный кандидат передаётся через source manifest с hashes вместе с Node installer; public npm release не публиковался, исходники компонентов в приложение не копируются.
+7 -1
View File
@@ -31,9 +31,15 @@ Icon-only action по умолчанию круглый. Квадратная к
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
`loading` в `Button` и `IconButton` помещает индикатор по центру нажатой кнопки, сохраняет её размеры и accessible name, выставляет `aria-busy` и блокирует повторное нажатие. Подробный контракт — [состояния загрузки](LOADING_STATES.md).
## LoadingRegion
`LoadingRegion loading={pending} label="Получаем данные"` центрирует ожидание внутри области содержимого, сохраняя её детей смонтированными. Приложение резервирует размер визуализатора и снимает loading после первого пригодного содержимого, ошибки или timeout. Командный spinner принадлежит кнопке и сюда не дублируется.
## ActivityIndicator
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется внутри `LoadingRegion` или явного inline-статуса, `compact` — внутри `Button.loading`/`IconButton.loading` либо фиксированного слота строки ресурса. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
+32
View File
@@ -0,0 +1,32 @@
# Состояния загрузки
Общий контракт для Mission Core, Mission Core Node и других NODE.DC consumers. Кольцо рисует ActivityIndicator; место и семантику определяют Button.loading, IconButton.loading или LoadingRegion. Локальные absolute-спиннеры и свободные ActivityIndicator под группой действий запрещены.
| Причина ожидания | Компонент и место | Завершение |
|---|---|---|
| Нажата команда | loading только у инициирующей Button/IconButton, по центру внутри её прежних границ | Подтверждение результата, ошибка или timeout команды |
| Начальная загрузка содержимого | LoadingRegion вокруг конкретной области, по центру её границ | Первое пригодное содержимое, ошибка или timeout |
| Подключение видео | LoadingRegion вокруг смонтированного video/canvas, включая расширенный вид | Первый декодированный кадр; одного ICE connected недостаточно |
| Фоновое обновление с доступным содержимым | loading у явной кнопки обновления; без overlay при автоматическом polling | Ответ запроса или ошибка |
| Измеряемая многошаговая подготовка | Существующий ProgressBar/ResourceRow.progress и статусы этапов | Результат этапа/операции |
| Ошибка, offline, отсутствие свежих кадров | Конечный статус и разрешённое действие повторения | Не оставлять бесконечный spinner |
Приложение хранит pending по device ID, session и конкретному action key. Блокировка соседних несовместимых действий не делает их загружающимися. Для одной команды показывается один индикатор; последующее получение содержимого — отдельный этап. Элемент управления сохраняет размеры, надпись для screen reader и свой порядок в layout; spinner не добавляет строку и не сдвигает соседей. Повторная активация pending-кнопки блокируется native disabled.
LoadingRegion не размонтирует video/canvas и не присваивает область страницы целиком. Визуализатор задаёт собственную стабильную высоту/aspect ratio; индикатор центрируется относительно этой области и автоматически следует её normal/expanded размеру. Компонент не блокирует управление сам: consumer отключает только конфликтующие действия. Закрытие просмотра остаётся доступным.
Владелец операции задаёт начало и конец ожидания. Компоненты не запускают запросы, не выдумывают проценты, не повторяют операции и не скрывают ошибки по таймеру. Reduced motion сохраняет статическое кольцо. Button сохраняет accessible name и aria-busy; LoadingRegion имеет один status с label, декоративное кольцо скрыто от accessibility tree.
```tsx
<Button loading={pending === 'save'} onClick={save}>Сохранить</Button>
<IconButton label="Обновить" loading={pending === 'refresh'} onClick={refresh}><Icon name="refresh" /></IconButton>
<LoadingRegion loading={connecting || awaitingFirstFrame} label="Ожидаем изображение">
<video autoPlay muted playsInline />
</LoadingRegion>
```
Living catalog показывает action, icon-action и content loading. Изменение согласовано владельцем08.09.2026 для обоих Mission Core consumers. Локальный кандидат включается в hash-bound Ubuntu source artifact; внешний package release в рамках этой задачи не публикуется.
## Проверка кандидата 08.09.2026
N09: typecheck, registry validation, четыре loading contract tests и production catalog build прошли. В браузере проверены light/dark и два accent colors: loading не меняет размер Button/IconButton, кольцо сохраняет нейтральный цвет, content status центрируется вместе с подписью. В Mission Core проверены pending read-only action, video first frame, normal/expanded/Escape и terminal error: mounted video сохраняется, после кадра или ошибки лишних индикаторов нет. Тот же shared frontend включён в установленный Node0.8.18; отдельная визуальная проверка native Node shell остаётся задачей consumer acceptance.
+53
View File
@@ -108,6 +108,7 @@
}
.nodedc-button {
position: relative;
--nodedc-button-bg: var(--nodedc-glass-control-bg);
--nodedc-button-color: var(--nodedc-text-primary);
display: inline-flex;
@@ -236,6 +237,57 @@
place-items: center;
}
.nodedc-button__content {
display: inline-flex;
align-items: center;
justify-content: inherit;
gap: inherit;
min-width: 0;
}
.nodedc-button[data-loading="true"] > .nodedc-button__content,
.nodedc-icon-button[data-loading="true"] > :not(.nodedc-action-loading) {
opacity: 0;
}
.nodedc-action-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
.nodedc-button[data-loading="true"],
.nodedc-icon-button[data-loading="true"] {
opacity: 1;
cursor: progress;
}
.nodedc-loading-region {
position: relative;
min-width: 0;
}
.nodedc-loading-region[aria-busy="true"] {
min-block-size: calc(var(--nodedc-control-height) * 2);
}
.nodedc-loading-region__status {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--nodedc-space-2);
padding: var(--nodedc-space-3);
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
text-align: center;
pointer-events: none;
}
.nodedc-button__icon > svg {
width: 1rem;
height: 1rem;
@@ -267,6 +319,7 @@
}
.nodedc-icon-button {
position: relative;
display: inline-grid;
width: var(--nodedc-icon-button-size);
height: var(--nodedc-icon-button-size);
+18
View File
@@ -1,6 +1,7 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
import { cn } from "./cn.js";
import { ActivityIndicator } from "./ActivityIndicator.js";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "default" | "compact" | "dense";
@@ -15,6 +16,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
shape?: ButtonShape;
accent?: RgbTuple;
icon?: ReactNode;
/** Pending state belongs to this action; dimensions and accessible name stay unchanged. */
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
@@ -29,6 +32,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
children,
style,
type = "button",
loading = false,
disabled,
...props
}, ref) {
const accentStyle = accent ? createAccentVariables(accent) : undefined;
@@ -44,9 +49,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
data-shape={shape === "default" ? undefined : shape}
style={accentStyle ? { ...accentStyle, ...style } : style}
{...props}
disabled={disabled || loading}
aria-busy={loading || props["aria-busy"]}
data-loading={loading || undefined}
>
<span className="nodedc-button__content">
{icon ? <span className="nodedc-button__icon" aria-hidden="true">{icon}</span> : null}
{children}
</span>
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
</button>
);
});
@@ -54,6 +65,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
shape?: "circle" | "rounded";
loading?: boolean;
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton({
@@ -62,6 +74,8 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
className,
children,
type = "button",
loading = false,
disabled,
...props
}, ref) {
return (
@@ -73,8 +87,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
aria-label={label}
title={label}
{...props}
disabled={disabled || loading}
aria-busy={loading || props["aria-busy"]}
data-loading={loading || undefined}
>
{children}
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
</button>
);
});
+23
View File
@@ -0,0 +1,23 @@
import type { HTMLAttributes } from "react";
import { ActivityIndicator } from "./ActivityIndicator.js";
import { cn } from "./cn.js";
export interface LoadingRegionProps extends HTMLAttributes<HTMLDivElement> {
loading: boolean;
label: string;
}
/** Keeps content mounted and centers pending feedback inside its own bounds. */
export function LoadingRegion({ loading, label, children, className, ...props }: LoadingRegionProps) {
return (
<div {...props} className={cn("nodedc-loading-region", className)} aria-busy={loading}>
{children}
{loading ? (
<div className="nodedc-loading-region__status" role="status" aria-label={label}>
<ActivityIndicator />
<span aria-hidden="true">{label}</span>
</div>
) : null}
</div>
);
}
+1
View File
@@ -1,5 +1,6 @@
export * from "./AppHeader.js";
export * from "./ActivityIndicator.js";
export * from "./LoadingRegion.js";
export * from "./AdminNavigationPanel.js";
export * from "./ApplicationShell.js";
export * from "./ApplicationSidePanel.js";
+17 -1
View File
@@ -102,6 +102,7 @@
"Controlled toggle actions expose aria-pressed and use the canonical active surface without changing geometry.",
"Filled accent actions derive foreground contrast from the actual accent.",
"Destructive actions remain neutral until confirmation unless danger is the principal message.",
"loading displays one centered indicator inside the initiating action, disables repeat activation and preserves dimensions and accessible name. Never place its spinner beside or below the action group.",
"Primary tone=neutral is white when enabled and gray when disabled in every theme/accent; use it for accent-independent operator actions without local CSS overrides."
]
},
@@ -115,12 +116,27 @@
"variants": ["default", "compact"],
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
"rules": [
"Use compact inside a Button icon slot and default for standalone inline progress.",
"Use Button.loading or IconButton.loading for actions and LoadingRegion for content. Standalone ActivityIndicator is limited to an explicit inline status or resource-row slot.",
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
"The component never owns operation state, timing or completion.",
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
]
},
{
"id": "loading-region",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["LoadingRegion", "LoadingRegionProps"],
"domContract": ["nodedc-loading-region", "nodedc-loading-region__status"],
"summary": "Centered pending feedback within the content region that owns the request, without unmounting content.",
"rules": [
"The consumer owns loading and its terminal result; no timer or implicit network operation exists in this component.",
"Keep content mounted and reserve its dimensions while pending. Center the indicator within this region in normal and expanded layouts.",
"A command belongs to its initiating Button.loading; do not duplicate that command in a region spinner.",
"Remove loading on first usable content, failure or timeout. A live media connection alone is not usable content.",
"Use only for blocking initial content or explicit recovery. Background refresh with usable content does not cover it."
]
},
{
"id": "field",
"status": "baseline",
+1
View File
@@ -43,6 +43,7 @@
"documentation": {
"architecture": "../docs/ARCHITECTURE.md",
"components": "../docs/COMPONENTS.md",
"loading": "../docs/LOADING_STATES.md",
"theming": "../docs/THEMING.md",
"operationalTypography": "../docs/OPERATIONAL_TYPOGRAPHY.md",
"windows": "../docs/WINDOWS_AND_LAYERS.md",
+26 -2
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ActivityIndicator } from "../packages/ui-react/dist/index.js";
import { ActivityIndicator, Button, IconButton, LoadingRegion } from "../packages/ui-react/dist/index.js";
test("ActivityIndicator separates decorative and announced progress", () => {
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
@@ -37,5 +37,29 @@ test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/);
assert.match(docs, /## ActivityIndicator/);
assert.match(catalog, /<ActivityIndicator label="Загружаем данные"/);
assert.match(catalog, /icon=\{<ActivityIndicator size="compact" \/>\}/);
assert.match(catalog, /<Button loading>/);
assert.match(catalog, /<LoadingRegion loading/);
});
test("pending actions preserve their name and content while disabling only the pending control", () => {
for (const [Component, props] of [[Button, {}], [IconButton, {label: "Обновить"}]]) {
const busy = renderToStaticMarkup(createElement(Component, {...props, loading: true, disabled: false}, "Обновить"));
const idle = renderToStaticMarkup(createElement(Component, props, "Обновить"));
assert.match(busy, /disabled=""/);
assert.match(busy, /aria-busy="true"/);
assert.match(busy, /Обновить/);
assert.equal((busy.match(/class="nodedc-activity-indicator"/g) || []).length, 1);
assert.doesNotMatch(idle, /nodedc-action-loading|disabled=""/);
}
});
test("content remains mounted throughout loading and the status disappears on completion", () => {
const view = createElement("video", {"data-source": "synthetic"});
const pending = renderToStaticMarkup(createElement(LoadingRegion, {loading:true, label:"Ожидаем изображение"}, view));
const ready = renderToStaticMarkup(createElement(LoadingRegion, {loading:false, label:"Ожидаем изображение"}, view));
assert.match(pending, /<video data-source="synthetic"/);
assert.match(ready, /<video data-source="synthetic"/);
assert.equal((pending.match(/role="status"/g) || []).length, 1);
assert.doesNotMatch(ready, /role="status"|nodedc-activity-indicator/);
});