From 8c53f73ee521561a1cc7944a7f7cf113702f40b5 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 09:19:12 +0300 Subject: [PATCH] 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. --- apps/catalog/src/CatalogApp.tsx | 11 ++-- docs/ADOPTION.md | 4 ++ docs/COMPONENTS.md | 8 ++- docs/LOADING_STATES.md | 32 ++++++++++++ packages/ui-core/styles.css | 53 ++++++++++++++++++++ packages/ui-react/src/Button.tsx | 18 +++++++ packages/ui-react/src/LoadingRegion.tsx | 23 +++++++++ packages/ui-react/src/index.ts | 1 + registry/components.json | 18 ++++++- registry/registry.json | 1 + scripts/activity-indicator-contract.test.mjs | 28 ++++++++++- 11 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 docs/LOADING_STATES.md create mode 100644 packages/ui-react/src/LoadingRegion.tsx diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 6cbdb99..1638529 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -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() { Загружаем данные - + +

При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.

+ + + diff --git a/docs/ADOPTION.md b/docs/ADOPTION.md index 2b2e607..b8efb7e 100644 --- a/docs/ADOPTION.md +++ b/docs/ADOPTION.md @@ -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 не публиковался, исходники компонентов в приложение не копируются. diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 1fafe11..965dfd3 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -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` кольцо остаётся видимым, но не вращается. diff --git a/docs/LOADING_STATES.md b/docs/LOADING_STATES.md new file mode 100644 index 0000000..4c6a8ac --- /dev/null +++ b/docs/LOADING_STATES.md @@ -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 + + + + +``` + +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. diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 2b37849..a238032 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -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); diff --git a/packages/ui-react/src/Button.tsx b/packages/ui-react/src/Button.tsx index 7b1d00a..5c9f341 100644 --- a/packages/ui-react/src/Button.tsx +++ b/packages/ui-react/src/Button.tsx @@ -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 { shape?: ButtonShape; accent?: RgbTuple; icon?: ReactNode; + /** Pending state belongs to this action; dimensions and accessible name stay unchanged. */ + loading?: boolean; } export const Button = forwardRef(function Button({ @@ -29,6 +32,8 @@ export const Button = forwardRef(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(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} > + {icon ? : null} {children} + + {loading ? : null} ); }); @@ -54,6 +65,7 @@ export const Button = forwardRef(function Button export interface IconButtonProps extends ButtonHTMLAttributes { label: string; shape?: "circle" | "rounded"; + loading?: boolean; } export const IconButton = forwardRef(function IconButton({ @@ -62,6 +74,8 @@ export const IconButton = forwardRef(functio className, children, type = "button", + loading = false, + disabled, ...props }, ref) { return ( @@ -73,8 +87,12 @@ export const IconButton = forwardRef(functio aria-label={label} title={label} {...props} + disabled={disabled || loading} + aria-busy={loading || props["aria-busy"]} + data-loading={loading || undefined} > {children} + {loading ? : null} ); }); diff --git a/packages/ui-react/src/LoadingRegion.tsx b/packages/ui-react/src/LoadingRegion.tsx new file mode 100644 index 0000000..e8b24f2 --- /dev/null +++ b/packages/ui-react/src/LoadingRegion.tsx @@ -0,0 +1,23 @@ +import type { HTMLAttributes } from "react"; +import { ActivityIndicator } from "./ActivityIndicator.js"; +import { cn } from "./cn.js"; + +export interface LoadingRegionProps extends HTMLAttributes { + 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 ( +
+ {children} + {loading ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/packages/ui-react/src/index.ts b/packages/ui-react/src/index.ts index 01c0d34..bf3df57 100644 --- a/packages/ui-react/src/index.ts +++ b/packages/ui-react/src/index.ts @@ -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"; diff --git a/registry/components.json b/registry/components.json index 0eaee93..fbeabce 100644 --- a/registry/components.json +++ b/registry/components.json @@ -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", diff --git a/registry/registry.json b/registry/registry.json index 81ec4e3..cd1e557 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -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", diff --git a/scripts/activity-indicator-contract.test.mjs b/scripts/activity-indicator-contract.test.mjs index d0681f6..1e95939 100644 --- a/scripts/activity-indicator-contract.test.mjs +++ b/scripts/activity-indicator-contract.test.mjs @@ -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, /\}/); + assert.match(catalog, /