feat(ui): add measured linear progress to resource rows
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,
|
||||
ProgressBar,
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
@@ -1410,10 +1411,15 @@ export function CatalogApp() {
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
||||
</Preview>
|
||||
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
|
||||
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
|
||||
<ProgressBar label="Получение сведений" />
|
||||
<ProgressBar label="Завершено" value={1} />
|
||||
</Preview>
|
||||
<Preview title="Строки ресурсов" note="Mission Core / общий список">
|
||||
<ResourceList aria-label="Пример списка ресурсов">
|
||||
<li><ResourceRow icon={<Icon name="file" />} title="Сохранённый результат" metadata="Сегодня · доступен для просмотра" actions={<IconButton label="Просмотреть пример" onClick={() => setProjectName("Сохранённый результат")}><Icon name="eye" /></IconButton>} /></li>
|
||||
<li><ResourceRow icon={<ActivityIndicator size="compact" />} title="Получение сведений" metadata="Действие выполняется" aria-busy="true" actions={<IconButton label="Просмотр недоступен" disabled><Icon name="eye" /></IconButton>} /></li>
|
||||
<li><ResourceRow icon={<Icon name="camera" />} title="Подготовка камеры" metadata="Проверка потоков" progress={{label:"Подготовка камеры",value:0.8}} aria-busy="true" actions={<IconButton label="Просмотр недоступен" disabled><Icon name="eye" /></IconButton>} /></li>
|
||||
</ResourceList>
|
||||
</Preview>
|
||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||
|
||||
@@ -321,3 +321,11 @@ Engine продолжает владеть определениями полей
|
||||
Светлый одноцветный знак шапки явно помечается `AppHeader.brandMonochrome` и
|
||||
`HeaderWorkspace.monochrome`: общая тема обеспечивает контраст на светлом фоне.
|
||||
По умолчанию флаг выключен; цветные изображения и аватары не меняются.
|
||||
|
||||
## ProgressBar
|
||||
|
||||
Линейная полоса выполнения: `label`, измеренная доля `value` 0…1 или
|
||||
неопределённая продолжительность без `value`, доступный `valueText`.
|
||||
В `ResourceRow` передаётся через `progress` и заменяет статус между
|
||||
наименованием и кнопками. Процент принадлежит приложению, анимация его не выдумывает.
|
||||
См. [контракт](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, на узком экране переносится ниже текста. Готовность не вычисляется
|
||||
компонентом. Каталог содержит завершённый, определённый и неизвестный прогресс.
|
||||
@@ -21,3 +21,7 @@ disabled и keyboard/focus остаются у общего контрола. С
|
||||
объект доступным или подключённым самостоятельно.
|
||||
|
||||
Пример находится в живом каталоге, раздел «Контролы → Строки ресурсов».
|
||||
|
||||
Для длительных операций с линейной индикацией используйте `progress`
|
||||
(ProgressBar: label, value, valueText). Он заменяет status, оставляет действия
|
||||
справа и занимает промежуток после названия.
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface ProgressBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "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 <div {...props} className={cn("nodedc-progress-bar", className)} role="progressbar"
|
||||
aria-label={label} aria-valuemin={0} aria-valuemax={100}
|
||||
aria-valuenow={fraction === undefined ? undefined : Math.round(fraction * 100)}
|
||||
aria-valuetext={valueText} data-indeterminate={fraction === undefined || undefined}
|
||||
style={{ ...style, "--nodedc-progress-fraction": fraction ?? 0 } as CSSProperties}>
|
||||
<span className="nodedc-progress-bar__fill" />
|
||||
</div>;
|
||||
}
|
||||
@@ -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<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
@@ -8,19 +9,21 @@ export interface ResourceRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "
|
||||
metadata?: ReactNode;
|
||||
status?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
progress?: Pick<ProgressBarProps, "label" | "value" | "valueText">;
|
||||
}
|
||||
|
||||
/** 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 <div className={cn("nodedc-resource-row", className)} {...props}>
|
||||
export function ResourceRow({ title, icon, description, metadata, status, actions, progress, className, ...props }: ResourceRowProps) {
|
||||
return <div className={cn("nodedc-resource-row", progress && "nodedc-resource-row--progress", className)} {...props}>
|
||||
{icon ? <span className="nodedc-resource-row__icon" aria-hidden="true">{icon}</span> : null}
|
||||
<div className="nodedc-resource-row__copy">
|
||||
<strong>{title}</strong>
|
||||
{description ? <span>{description}</span> : null}
|
||||
{metadata ? <small>{metadata}</small> : null}
|
||||
</div>
|
||||
{status ? <div className="nodedc-resource-row__status">{status}</div> : null}
|
||||
{progress ? <ProgressBar {...progress} /> : null}
|
||||
{!progress && status ? <div className="nodedc-resource-row__status">{status}</div> : null}
|
||||
{actions ? <div className="nodedc-resource-row__actions">{actions}</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("Камера")<html.indexOf('role="progressbar"'));
|
||||
assert.ok(html.indexOf('role="progressbar"')<html.indexOf("Actions"));
|
||||
assert.doesNotMatch(html,/Old status/);
|
||||
});
|
||||
Reference in New Issue
Block a user