feat(ui): add spatial clipping scale and compact record actions

This commit is contained in:
Codex
2026-09-21 08:45:10 +03:00
parent 67025ee209
commit 8dd9190573
15 changed files with 263 additions and 21 deletions
+43
View File
@@ -23,6 +23,49 @@ test("RangeControl keeps native drag semantics and exposes one persistent exact-
assert.doesNotMatch(markup, /nodedc-range__value-hit/);
});
test("vertical scale keeps endpoints outside and the label/value centered at every fill level", () => {
for (const [value, progress] of [[-1.2, 0], [25.15, 50], [51.5, 100]]) {
const markup = renderToStaticMarkup(createElement(RangeControl, {
label: "Срез", orientation: "vertical", value, min: -1.2, max: 51.5, step: "any",
formatValue: (n) => `${n.toFixed(1).replace('.', ',')} м`,
formatLimit: (n) => n.toFixed(1).replace('.', ','), onChange: () => {},
}));
const renderedProgress = Number(markup.match(/--nodedc-range-progress:([\d.]+)%/)?.[1]);
assert.ok(Math.abs(renderedProgress - progress) < 1e-9);
assert.match(markup, /aria-label="Срез" aria-orientation="vertical"/);
assert.match(markup, /min="-1.2" max="51.5" step="any"/);
assert.equal((markup.match(/nodedc-range-scale__limit--max">51,5<\/span>/g) ?? []).length, 1);
assert.equal((markup.match(/nodedc-range-scale__limit--min">-1,2<\/span>/g) ?? []).length, 1);
assert.match(markup, /<\/span><\/span><\/div><span class="nodedc-range-scale__limit/);
assert.equal((markup.match(/nodedc-range__label">Срез · /g) ?? []).length, 2);
assert.match(markup, /nodedc-range__fill-text" aria-hidden="true"/);
assert.doesNotMatch(markup, /nodedc-range__value|nodedc-range__editor|formatLimit=/);
}
});
test("vertical scale mirrors only endpoint labels for the opposite scene edge", () => {
const markup = renderToStaticMarkup(createElement(RangeControl, {
label: "Срез", orientation: "vertical", limitSide: "left", value: 25.15, min: -1.2, max: 51.5,
step: "any", formatLimit: (n) => n.toFixed(1).replace('.', ','), onChange: () => {},
}));
assert.match(markup, /class="nodedc-range-scale" data-limit-side="left"/);
assert.match(markup, /nodedc-range-scale__limit--max">51,5<\/span>/);
assert.match(markup, /nodedc-range-scale__limit--min">-1,2<\/span>/);
});
test("vertical contrast clips both glyph layers and visible focus has no perimeter", async () => {
const styles = await readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8");
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \.nodedc-range__fill-text \{ clip-path: inset\(calc\(100% - var\(--nodedc-range-progress\)\) 0 0 round var\(--nodedc-radius-circle\)\)/);
assert.match(styles, /\.nodedc-range-scale \{[^}]*width: calc\(var\(--nodedc-control-height\) \* 0\.4\)/);
assert.match(styles, /\.nodedc-range-scale__limit \{[^}]*left: calc\(100% \+ 0\.4rem\);[^}]*color: var\(--nodedc-text-primary\);[^}]*font-size: calc\(var\(--nodedc-font-size-xs\) \* 0\.9\)/);
assert.match(styles, /\.nodedc-range-scale\[data-limit-side="left"\] \.nodedc-range-scale__limit \{[^}]*right: calc\(100% \+ 0\.4rem\);[^}]*left: auto;[^}]*text-align: right/);
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \.nodedc-range__label \{[^}]*font-size: calc\(var\(--nodedc-font-size-sm\) \* 0\.9\)/);
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \{[^}]*border: 0; outline: 0; box-shadow: none;/);
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\]:has\(input:focus-visible\) \.nodedc-range__label \{ text-decoration: underline; text-decoration-thickness: 2px;/);
assert.doesNotMatch(styles, /\.nodedc-range\[data-orientation="vertical"\]:has\(input:focus-visible\) \{/);
assert.doesNotMatch(styles, /\.nodedc-range\[data-orientation="vertical"\]:focus-within/);
});
test("exact editing is a stable native caret field without remount, forced selection or a dark editor box", async () => {
const [component, styles, windowComponent] = await Promise.all([
readFile(new URL("../packages/ui-react/src/RangeControl.tsx", import.meta.url), "utf8"),
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Select } from '../packages/ui-react/dist/index.js';
test('select action menus use dialog semantics in all variants; ordinary select stays a listbox', () => {
for (const variant of ['integrated', 'split', 'inline']) {
for (const withAction of [false, true]) {
const markup = renderToStaticMarkup(createElement(Select, {
label: 'Records', variant, value: 'one', onChange: () => {},
options: [{value: 'one', label: 'One', ...(withAction ? {action: {label: 'Delete One', icon: 'x', onAction: () => {}}} : {})}],
}));
assert.match(markup, new RegExp(`aria-haspopup="${withAction ? 'dialog' : 'listbox'}"`));
}
}
});
test('row action is independent of selection, closes first and uses canonical IconButton', async () => {
const source = await readFile(new URL('../packages/ui-react/src/Select.tsx', import.meta.url), 'utf8');
assert.match(source, /<\/button>\s*\{option\.action \? <IconButton/);
assert.match(source, /disabled=\{option\.action\.disabled\}/);
assert.match(source, /close\(\);[\s\S]*triggerButton.current\?\.focus\(\);\s*option\.action\?\.onAction\(\)/);
assert.match(source, /role=\{withActions \? undefined : "option"\}/);
});
test('row action matches the compact session catalog without a circular backing', async () => {
const css = await readFile(new URL('../packages/ui-core/styles.css', import.meta.url), 'utf8');
const rule = css.match(/\.nodedc-select-option-row > \.nodedc-icon-button \{([^}]+)\}/)?.[1];
assert.match(rule, /width: 2\.35rem/);
assert.match(rule, /height: auto/);
assert.match(rule, /border-radius: 0/);
assert.match(rule, /background: transparent/);
assert.match(rule, /box-shadow: none/);
assert.match(rule, /color: var\(--nodedc-text-muted\)/);
assert.match(css, /\.nodedc-select-option-row > \.nodedc-icon-button > svg \{ width: 15px; height: 15px; transform: none/);
assert.match(css, /data-tone="danger"\]\:focus-visible \{ color: rgb\(var\(--nodedc-danger-rgb\)\)/);
});
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import {readFileSync} from "node:fs";
import test from "node:test";
import {createElement} from "react";
import {renderToStaticMarkup} from "react-dom/server";
import {StatusBadge} from "../packages/ui-react/dist/index.js";
test("status pulse is opt-in and does not replace the status text",()=>{
const status=renderToStaticMarkup(createElement(StatusBadge,{tone:"danger",pulse:true},"Привязка потеряна"));
assert.match(status,/data-pulse="true"/);
assert.match(status,/Привязка потеряна/);
assert.doesNotMatch(renderToStaticMarkup(createElement(StatusBadge,{},"Готово")),/data-pulse/);
});
test("only the lamp animates, with static reduced-motion fallback",()=>{
const css=readFileSync(new URL("../packages/ui-core/styles.css",import.meta.url),"utf8");
assert.match(css,/\.nodedc-status\[data-pulse="true"\]::before\s*\{[^}]*animation: nodedc-status-pulse/);
assert.match(css,/@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-status\[data-pulse="true"\]::before \{ animation: none;/);
});