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:
@@ -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="Три этапа из пяти" />
|
||||
|
||||
@@ -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
@@ -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` кольцо остаётся видимым, но не вращается.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user