diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index d54fb17..d1b441a 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, + ProgressBar, AdminNavigationPanel, AppHeader, ApplicationPanel, @@ -1410,10 +1411,15 @@ export function CatalogApp() {

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

+ + + + +
  • } title="Сохранённый результат" metadata="Сегодня · доступен для просмотра" actions={ setProjectName("Сохранённый результат")}>} />
  • -
  • } title="Получение сведений" metadata="Действие выполняется" aria-busy="true" actions={} />
  • +
  • } title="Подготовка камеры" metadata="Проверка потоков" progress={{label:"Подготовка камеры",value:0.8}} aria-busy="true" actions={} />
  • diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 5e19703..f9a837e 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -321,3 +321,11 @@ Engine продолжает владеть определениями полей Светлый одноцветный знак шапки явно помечается `AppHeader.brandMonochrome` и `HeaderWorkspace.monochrome`: общая тема обеспечивает контраст на светлом фоне. По умолчанию флаг выключен; цветные изображения и аватары не меняются. + +## ProgressBar + +Линейная полоса выполнения: `label`, измеренная доля `value` 0…1 или +неопределённая продолжительность без `value`, доступный `valueText`. +В `ResourceRow` передаётся через `progress` и заменяет статус между +наименованием и кнопками. Процент принадлежит приложению, анимация его не выдумывает. +См. [контракт](PROGRESS_BAR.md). diff --git a/docs/PROGRESS_BAR.md b/docs/PROGRESS_BAR.md new file mode 100644 index 0000000..1825927 --- /dev/null +++ b/docs/PROGRESS_BAR.md @@ -0,0 +1,15 @@ +# ProgressBar + +Владелец Mission Core 05.09.2026 явно запросил горизонтальную заполняющуюся +полосу вместо кружка и точки в строке подготовки устройства. Общий компонент +добавлен в Design Guideline до подключения в Node/Core. + +`value` — завершённая доля 0…1; NaN/Infinity трактуются как неизвестная величина, +выход за границы ограничивается. Без value используется движущийся сегмент, +aria-valuenow отсутствует. `label` обязателен; `valueText` описывает текущий этап. +Нет интерактивности и focus. Темы используют существующие семантические токены. +При reduced motion движение выключено, видимый сегмент сохраняется. + +ResourceRow.progress занимает свободное место между текстом и actions, скрывает +status, на узком экране переносится ниже текста. Готовность не вычисляется +компонентом. Каталог содержит завершённый, определённый и неизвестный прогресс. diff --git a/docs/RESOURCE_ROW.md b/docs/RESOURCE_ROW.md index 89fff3c..daa9c50 100644 --- a/docs/RESOURCE_ROW.md +++ b/docs/RESOURCE_ROW.md @@ -21,3 +21,7 @@ disabled и keyboard/focus остаются у общего контрола. С объект доступным или подключённым самостоятельно. Пример находится в живом каталоге, раздел «Контролы → Строки ресурсов». + +Для длительных операций с линейной индикацией используйте `progress` +(ProgressBar: label, value, valueText). Он заменяет status, оставляет действия +справа и занимает промежуток после названия. diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 28e17cb..9482925 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -3796,3 +3796,18 @@ textarea.nodedc-field__control { } [data-nodedc-theme="light"] :is(.nodedc-header__brand, .nodedc-header__workspace)[data-monochrome="true"] img { filter: brightness(0); } + +/* Owner-admitted linear progress in the shared resource row. */ +.nodedc-progress-bar { min-width: 3rem; height: var(--nodedc-space-2); overflow: hidden; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-glass-control-bg); } +.nodedc-progress-bar__fill { display: block; width: 100%; height: 100%; background: var(--nodedc-text-secondary); border-radius: inherit; transform: scaleX(var(--nodedc-progress-fraction)); transform-origin: left; transition: transform var(--nodedc-duration-normal) var(--nodedc-ease-standard); } +.nodedc-progress-bar[data-indeterminate] > .nodedc-progress-bar__fill { width: 35%; animation: nodedc-progress-travel 1.5s ease-in-out infinite alternate; } +@keyframes nodedc-progress-travel { from { transform: translateX(-100%); } to { transform: translateX(285%); } } +.nodedc-resource-row--progress > .nodedc-resource-row__copy { flex: 0 1 35%; } +.nodedc-resource-row--progress > .nodedc-progress-bar { flex: 1; } +@media (max-width: 40rem) { + .nodedc-resource-row--progress > .nodedc-resource-row__copy { flex-basis: calc(100% - 3rem); } +} +@media (prefers-reduced-motion: reduce) { + .nodedc-progress-bar__fill { transition: none; } + .nodedc-progress-bar[data-indeterminate] > .nodedc-progress-bar__fill { animation: none; width: 100%; transform: scaleX(0.5); } +} diff --git a/packages/ui-react/src/ProgressBar.tsx b/packages/ui-react/src/ProgressBar.tsx new file mode 100644 index 0000000..18b5e28 --- /dev/null +++ b/packages/ui-react/src/ProgressBar.tsx @@ -0,0 +1,20 @@ +import type { CSSProperties, HTMLAttributes } from "react"; +import { cn } from "./cn.js"; + +export interface ProgressBarProps extends Omit, "children"> { + label: string; + /** Completed fraction from 0 to 1. Omit when the amount is unknown. */ + value?: number; + valueText?: string; +} + +export function ProgressBar({ label, value, valueText, className, style, ...props }: ProgressBarProps) { + const fraction = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : undefined; + return
    + +
    ; +} diff --git a/packages/ui-react/src/ResourceRow.tsx b/packages/ui-react/src/ResourceRow.tsx index 3af99e0..755700e 100644 --- a/packages/ui-react/src/ResourceRow.tsx +++ b/packages/ui-react/src/ResourceRow.tsx @@ -1,4 +1,5 @@ import type { HTMLAttributes, ReactNode } from "react"; +import { ProgressBar, type ProgressBarProps } from "./ProgressBar.js"; import { cn } from "./cn.js"; export interface ResourceRowProps extends Omit, "title"> { @@ -8,19 +9,21 @@ export interface ResourceRowProps extends Omit, " metadata?: ReactNode; status?: ReactNode; actions?: ReactNode; + progress?: Pick; } /** Compact resource presentation extracted from Mission Core AI Inference. * Actions stay canonical controls; the row itself is not a nested button. */ -export function ResourceRow({ title, icon, description, metadata, status, actions, className, ...props }: ResourceRowProps) { - return
    +export function ResourceRow({ title, icon, description, metadata, status, actions, progress, className, ...props }: ResourceRowProps) { + return
    {icon ? : null}
    {title} {description ? {description} : null} {metadata ? {metadata} : null}
    - {status ?
    {status}
    : null} + {progress ? : null} + {!progress && status ?
    {status}
    : null} {actions ?
    {actions}
    : null}
    ; } diff --git a/packages/ui-react/src/index.ts b/packages/ui-react/src/index.ts index e36097a..9b3c999 100644 --- a/packages/ui-react/src/index.ts +++ b/packages/ui-react/src/index.ts @@ -28,3 +28,5 @@ export * from "./Toast.js"; export * from "./UserProfileMenu.js"; export * from "./Window.js"; export * from "./WorkspaceWindow.js"; + +export { ProgressBar, type ProgressBarProps } from "./ProgressBar.js"; diff --git a/registry/components.json b/registry/components.json index f2c6f1f..496bc57 100644 --- a/registry/components.json +++ b/registry/components.json @@ -1,6 +1,28 @@ { "schemaVersion": "1.0.0", "components": [ +{ + "id": "progress-bar", + "status": "baseline", + "package": "@nodedc/ui-react", + "exports": [ + "ProgressBar", + "ProgressBarProps" + ], + "domContract": [ + "nodedc-progress-bar" + ], + "summary": "Accessible determinate or indeterminate linear progress, including the central ResourceRow slot.", + "variants": [ + "determinate", + "indeterminate" + ], + "rules": [ + "Consumer supplies measured progress; elapsed time must not invent completion.", + "ResourceRow progress replaces status and occupies space between copy and actions.", + "Reduced motion preserves a visible non-animated indicator." + ] +}, { "id": "resource-row", "status": "baseline", diff --git a/scripts/progress-bar-contract.test.mjs b/scripts/progress-bar-contract.test.mjs new file mode 100644 index 0000000..043a61f --- /dev/null +++ b/scripts/progress-bar-contract.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {createElement} from "react"; +import {renderToStaticMarkup} from "react-dom/server"; +import {ProgressBar,ResourceRow} from "../packages/ui-react/dist/index.js"; + +test("progress exposes bounded measured values and unknown state without false percentages",()=>{ + for(const [value, expected] of [[-1,0],[.5,50],[2,100]]) { + const html=renderToStaticMarkup(createElement(ProgressBar,{label:"Этапы",value})); + assert.match(html,new RegExp(`aria-valuenow="${expected}"`)); + } + for(const value of [undefined,NaN,Infinity]) { + const html=renderToStaticMarkup(createElement(ProgressBar,{label:"Ожидание",value})); + assert.doesNotMatch(html,/aria-valuenow/);assert.match(html,/data-indeterminate="true"/); + } +}); +test("row keeps progress between name and actions and suppresses duplicate status",()=>{ + const html=renderToStaticMarkup(createElement(ResourceRow,{title:"Камера",status:"Old status",actions:"Actions",progress:{label:"Загрузка",value:.2}})); + assert.ok(html.indexOf("Камера")