@weasel-js/labkit 1.1.0 → 1.3.0-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
- package/dist/canvas/index.d.ts +8 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/chunk-HFDVGF4X.js +170 -0
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +5 -2
- package/dist/index.d.ts +307 -107
- package/dist/index.js +797 -540
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3804 -3661
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +543 -144
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +4 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +5 -2
- package/package.json +24 -7
- package/src/canvas/AGENTS.md +12 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.test.ts +18 -0
- package/src/index.ts +38 -3
- package/src/instrument/SineWave.smoke.test.tsx +9 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +38 -3
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.stories.tsx +1 -2
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +19 -9
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +16 -2
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +8 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +6 -0
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +161 -0
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +73 -0
- package/src/trial/Trial.less +121 -4
- package/src/trial/Trial.stories.tsx +9 -1
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +112 -45
- package/src/trial/TrialChrome.tsx +110 -64
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js +0 -5986
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
- package/dist/chunk-NRKWVTVT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -53
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ErrorIcon, WarningIcon } from '@weasel-js/ui';
|
|
2
|
+
import type { JobHandle } from '../job/types';
|
|
3
|
+
|
|
4
|
+
/** Props for `<JobProgress>`. */
|
|
5
|
+
export interface JobProgressProps {
|
|
6
|
+
job: JobHandle;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A running job's progress, failures and outcome. Determinate once the job
|
|
11
|
+
* reports a total; a job that cannot count up front never sends one, so the
|
|
12
|
+
* bar stays indeterminate rather than inventing a denominator. */
|
|
13
|
+
export function JobProgress({ job, className }: JobProgressProps) {
|
|
14
|
+
const { status, done, total, failures, error } = job;
|
|
15
|
+
if (status === 'idle') return null;
|
|
16
|
+
|
|
17
|
+
const determinate = total !== null && total > 0;
|
|
18
|
+
const fraction = determinate ? Math.min(1, done / total) : 0;
|
|
19
|
+
const cls = ['lk-job', `lk-job--${status}`, className].filter(Boolean).join(' ');
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className={cls}>
|
|
23
|
+
<div
|
|
24
|
+
className={`lk-job__track${determinate ? '' : ' lk-job__track--indeterminate'}`}
|
|
25
|
+
role="progressbar"
|
|
26
|
+
aria-label="Job progress"
|
|
27
|
+
aria-valuenow={determinate ? done : undefined}
|
|
28
|
+
aria-valuemin={determinate ? 0 : undefined}
|
|
29
|
+
aria-valuemax={determinate ? total : undefined}
|
|
30
|
+
>
|
|
31
|
+
{/* Scaling a full-width bar keeps the fill off the layout path, so
|
|
32
|
+
progress updates never reflow the status bar. */}
|
|
33
|
+
<div
|
|
34
|
+
className="lk-job__fill"
|
|
35
|
+
style={{ transform: `scaleX(${determinate ? fraction : 1})` }}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<span className="lk-job__count">
|
|
40
|
+
{done}
|
|
41
|
+
{determinate ? ` / ${total}` : ''}
|
|
42
|
+
</span>
|
|
43
|
+
|
|
44
|
+
{failures.length > 0 && (
|
|
45
|
+
<span className="lk-job__failures" title={failures.map((f) => f.error).join('\n')}>
|
|
46
|
+
<WarningIcon size={14} />
|
|
47
|
+
{failures.length} failed
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
{error && (
|
|
52
|
+
<span className="lk-job__error" title={error}>
|
|
53
|
+
<ErrorIcon size={14} />
|
|
54
|
+
{error}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
{status === 'running' && (
|
|
59
|
+
<button type="button" className="lk-job__cancel" onClick={job.cancel}>
|
|
60
|
+
Cancel
|
|
61
|
+
</button>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
.lk-legend {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
list-style: none;
|
|
5
|
+
font-family: var(--wzl-font-mono);
|
|
6
|
+
font-size: var(--wzl-font-size-sm);
|
|
7
|
+
line-height: 1.4;
|
|
8
|
+
color: var(--wzl-fg-muted);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.lk-legend__row {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
gap: var(--wzl-space-sm);
|
|
15
|
+
padding: 1px 0;
|
|
16
|
+
white-space: nowrap;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.lk-legend__swatch {
|
|
20
|
+
flex: none;
|
|
21
|
+
width: 15px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.lk-legend__swatch--line {
|
|
25
|
+
height: 4px;
|
|
26
|
+
border-radius: var(--wzl-radius-sm);
|
|
27
|
+
background: var(--lk-legend-ink);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.lk-legend__swatch--dash {
|
|
31
|
+
height: 0;
|
|
32
|
+
border-top: 2px dashed var(--lk-legend-ink);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.lk-legend__swatch--dot {
|
|
36
|
+
width: 6px;
|
|
37
|
+
height: 6px;
|
|
38
|
+
margin: 0 4px 0 5px;
|
|
39
|
+
border-radius: 50%;
|
|
40
|
+
background: var(--lk-legend-ink);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.lk-legend__swatch--band {
|
|
44
|
+
height: 9px;
|
|
45
|
+
border-radius: var(--wzl-radius-sm);
|
|
46
|
+
background: var(--lk-legend-ink);
|
|
47
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Legend } from './Legend';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Legend> = {
|
|
5
|
+
title: 'labkit/Primitives/Legend',
|
|
6
|
+
component: Legend,
|
|
7
|
+
};
|
|
8
|
+
export default meta;
|
|
9
|
+
|
|
10
|
+
type Story = StoryObj<typeof Legend>;
|
|
11
|
+
|
|
12
|
+
export const AllMarks: Story = {
|
|
13
|
+
args: {
|
|
14
|
+
entries: [
|
|
15
|
+
{ key: 'contour', label: 'contour', color: '#7d7f86' },
|
|
16
|
+
{ key: 'floor', label: 'bend floor', color: '#9a9ca3', mark: 'dash' },
|
|
17
|
+
{ key: 'authored', label: 'authored', color: '#2aa87a', mark: 'dot' },
|
|
18
|
+
{ key: 'replaced', label: 'replaced', color: 'rgba(255,107,96,.28)', mark: 'band' },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const DefaultMark: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
entries: [
|
|
26
|
+
{ key: 'a', label: 'input', color: '#4c9be8' },
|
|
27
|
+
{ key: 'b', label: 'output', color: '#e8a04c' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const Empty: Story = { args: { entries: [] } };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { Legend } from './Legend';
|
|
4
|
+
|
|
5
|
+
const entries = [
|
|
6
|
+
{ key: 'contour', label: 'contour', color: '#7d7f86' },
|
|
7
|
+
{ key: 'floor', label: 'bend floor', color: '#9a9ca3', mark: 'dash' as const },
|
|
8
|
+
{ key: 'authored', label: 'authored', color: '#2aa87a', mark: 'dot' as const },
|
|
9
|
+
{ key: 'replaced', label: 'replaced', color: 'rgba(255,107,96,.28)', mark: 'band' as const },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe('Legend', () => {
|
|
13
|
+
it('renders one row per entry', () => {
|
|
14
|
+
const { container } = render(<Legend entries={entries} />);
|
|
15
|
+
expect(container.querySelectorAll('.lk-legend__row')).toHaveLength(4);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('shows every label as text', () => {
|
|
19
|
+
render(<Legend entries={entries} />);
|
|
20
|
+
for (const e of entries) expect(screen.getByText(e.label)).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('marks each swatch with its shape, defaulting to a line', () => {
|
|
24
|
+
const { container } = render(<Legend entries={entries} />);
|
|
25
|
+
const swatches = container.querySelectorAll('.lk-legend__swatch');
|
|
26
|
+
expect(swatches[0]?.className).toContain('lk-legend__swatch--line');
|
|
27
|
+
expect(swatches[1]?.className).toContain('lk-legend__swatch--dash');
|
|
28
|
+
expect(swatches[2]?.className).toContain('lk-legend__swatch--dot');
|
|
29
|
+
expect(swatches[3]?.className).toContain('lk-legend__swatch--band');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('hides swatches from assistive tech, leaving the label to carry meaning', () => {
|
|
33
|
+
const { container } = render(<Legend entries={entries} />);
|
|
34
|
+
for (const s of container.querySelectorAll('.lk-legend__swatch')) {
|
|
35
|
+
expect(s.getAttribute('aria-hidden')).toBe('true');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('takes its color from the entry', () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<Legend entries={[{ key: 'contour', label: 'contour', color: '#7d7f86' }]} />,
|
|
42
|
+
);
|
|
43
|
+
const swatch = container.querySelector('.lk-legend__swatch') as HTMLElement;
|
|
44
|
+
expect(swatch.style.getPropertyValue('--lk-legend-ink')).toBe('#7d7f86');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders nothing but an empty list for no entries', () => {
|
|
48
|
+
const { container } = render(<Legend entries={[]} />);
|
|
49
|
+
expect(container.querySelectorAll('.lk-legend__row')).toHaveLength(0);
|
|
50
|
+
expect(container.querySelector('.lk-legend')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accepts an extra class name', () => {
|
|
54
|
+
const { container } = render(<Legend entries={[]} className="is-mine" />);
|
|
55
|
+
expect(container.querySelector('.lk-legend')?.className).toContain('is-mine');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
|
|
3
|
+
/** How a legend entry's swatch is drawn, matching how the thing looks on canvas. */
|
|
4
|
+
export type LegendMark = 'line' | 'dash' | 'dot' | 'band';
|
|
5
|
+
|
|
6
|
+
/** One row of a legend: a swatch and what it means. */
|
|
7
|
+
export interface LegendEntry {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
color: string;
|
|
11
|
+
/** Defaults to `'line'`. */
|
|
12
|
+
mark?: LegendMark;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Props for `<Legend>`. */
|
|
16
|
+
export interface LegendProps {
|
|
17
|
+
entries: readonly LegendEntry[];
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A color key. Presentational only — no handlers, no state, no hover behavior. */
|
|
22
|
+
export function Legend({ entries, className }: LegendProps) {
|
|
23
|
+
return (
|
|
24
|
+
<ul className={className ? `lk-legend ${className}` : 'lk-legend'}>
|
|
25
|
+
{entries.map((entry) => {
|
|
26
|
+
const mark = entry.mark ?? 'line';
|
|
27
|
+
return (
|
|
28
|
+
<li className="lk-legend__row" key={entry.key}>
|
|
29
|
+
<span
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
className={`lk-legend__swatch lk-legend__swatch--${mark}`}
|
|
32
|
+
style={{ '--lk-legend-ink': entry.color } as CSSProperties}
|
|
33
|
+
/>
|
|
34
|
+
<span className="lk-legend__label">{entry.label}</span>
|
|
35
|
+
</li>
|
|
36
|
+
);
|
|
37
|
+
})}
|
|
38
|
+
</ul>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
display: flex;
|
|
3
3
|
flex-direction: column;
|
|
4
4
|
width: 280px;
|
|
5
|
+
flex: 1 1 auto;
|
|
5
6
|
background: var(--wzl-surface-raised);
|
|
6
|
-
border-
|
|
7
|
+
border-right: 1px solid var(--wzl-border);
|
|
8
|
+
min-height: 0;
|
|
7
9
|
transition: width 150ms ease;
|
|
8
10
|
|
|
9
11
|
&--collapsed {
|
|
@@ -21,7 +23,7 @@
|
|
|
21
23
|
|
|
22
24
|
.lk-sidebar-title {
|
|
23
25
|
flex: 1;
|
|
24
|
-
font-weight:
|
|
26
|
+
font-weight: var(--wzl-font-weight-bold);
|
|
25
27
|
color: var(--wzl-fg);
|
|
26
28
|
font-size: var(--wzl-font-size);
|
|
27
29
|
|
|
@@ -55,3 +57,31 @@
|
|
|
55
57
|
display: none;
|
|
56
58
|
}
|
|
57
59
|
}
|
|
60
|
+
|
|
61
|
+
.lk-sidebar-section {
|
|
62
|
+
border-bottom: var(--wzl-border-w) solid var(--wzl-line-subtle);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.lk-sidebar-section__head {
|
|
66
|
+
width: 100%;
|
|
67
|
+
padding: var(--wzl-space-xs);
|
|
68
|
+
border: 0;
|
|
69
|
+
background: transparent;
|
|
70
|
+
color: var(--wzl-fg-muted);
|
|
71
|
+
font-family: var(--wzl-font-display);
|
|
72
|
+
font-size: var(--wzl-font-size-sm);
|
|
73
|
+
letter-spacing: 0.08em;
|
|
74
|
+
text-align: left;
|
|
75
|
+
text-transform: uppercase;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
|
|
78
|
+
&:hover {
|
|
79
|
+
color: var(--wzl-fg);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The section body is the only thing that insets a panel — the panels
|
|
84
|
+
// themselves carry no padding, so this is the whole gutter.
|
|
85
|
+
.lk-sidebar-section__body {
|
|
86
|
+
padding: 0 var(--wzl-space-xs) var(--wzl-space-xs);
|
|
87
|
+
}
|
|
@@ -18,4 +18,11 @@
|
|
|
18
18
|
border-right: 1px solid var(--wzl-line-subtle);
|
|
19
19
|
|
|
20
20
|
&:last-child { border-right: none; }
|
|
21
|
+
|
|
22
|
+
&--end {
|
|
23
|
+
margin-left: auto;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A section pushed to the end already reads as separated by the gap.
|
|
27
|
+
&:has(+ &--end) { border-right: none; }
|
|
21
28
|
}
|
|
@@ -10,11 +10,18 @@ export function StatusBar({ children }: StatusBarProps) {
|
|
|
10
10
|
return <div className="lk-status-bar">{children}</div>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** Props for `<StatusBar.Section>`. */
|
|
14
|
+
export interface StatusBarSectionProps {
|
|
14
15
|
children: ReactNode;
|
|
16
|
+
/** Pushes this section, and everything after it, to the far end. */
|
|
17
|
+
end?: boolean;
|
|
15
18
|
}
|
|
16
|
-
function Section({ children }:
|
|
17
|
-
return
|
|
19
|
+
function Section({ children, end }: StatusBarSectionProps) {
|
|
20
|
+
return (
|
|
21
|
+
<span className={`lk-status-bar-section${end ? ' lk-status-bar-section--end' : ''}`}>
|
|
22
|
+
{children}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/** One readout within a status bar. */
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
.lk-toolbar {
|
|
2
|
+
// A trial toolbar is chrome around the instrument, not a page's own control
|
|
3
|
+
// strip, so it runs a compact control height. Overriding the token rather
|
|
4
|
+
// than the button keeps the slider and number field in step with it.
|
|
5
|
+
--wzl-control-h: 22px;
|
|
6
|
+
|
|
2
7
|
display: flex;
|
|
3
8
|
align-items: center;
|
|
4
9
|
gap: var(--wzl-space-sm);
|
|
5
|
-
padding:
|
|
10
|
+
padding: 2px var(--wzl-space-sm);
|
|
6
11
|
background: var(--wzl-surface-raised);
|
|
7
12
|
border-bottom: 1px solid var(--wzl-border);
|
|
8
|
-
min-height: calc(var(--wzl-control-h) +
|
|
13
|
+
min-height: calc(var(--wzl-control-h) + 4px);
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
.lk-toolbar-title {
|
|
12
|
-
font-weight:
|
|
17
|
+
font-weight: var(--wzl-font-weight-bold);
|
|
13
18
|
color: var(--wzl-fg);
|
|
14
19
|
margin-right: var(--wzl-space-sm);
|
|
15
20
|
}
|
|
@@ -18,7 +23,35 @@
|
|
|
18
23
|
flex: 1;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
.lk-toolbar-group {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 2px;
|
|
30
|
+
min-width: 0;
|
|
31
|
+
|
|
32
|
+
// The rule belongs between groups, so it rides on the group rather than
|
|
33
|
+
// being a separate node the caller has to remember to interleave.
|
|
34
|
+
& + & {
|
|
35
|
+
padding-left: var(--wzl-space-sm);
|
|
36
|
+
border-left: 1px solid var(--wzl-line-subtle);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&--end {
|
|
40
|
+
margin-left: auto;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A group pushed to the end already reads as separated by the gap.
|
|
44
|
+
&--end + &,
|
|
45
|
+
& + &--end {
|
|
46
|
+
border-left: none;
|
|
47
|
+
padding-left: 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
.lk-toolbar-button {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: var(--wzl-space-xs);
|
|
22
55
|
height: var(--wzl-control-h);
|
|
23
56
|
padding: 0 var(--wzl-space-sm);
|
|
24
57
|
font-size: var(--wzl-font-size);
|
|
@@ -27,15 +60,29 @@
|
|
|
27
60
|
border: 1px solid transparent;
|
|
28
61
|
border-radius: var(--wzl-radius-md);
|
|
29
62
|
cursor: pointer;
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
|
|
65
|
+
&--icon {
|
|
66
|
+
padding: 0;
|
|
67
|
+
width: var(--wzl-control-h);
|
|
68
|
+
justify-content: center;
|
|
69
|
+
}
|
|
30
70
|
|
|
31
71
|
&:hover:not(:disabled) {
|
|
32
72
|
background: var(--wzl-surface);
|
|
33
73
|
border-color: var(--wzl-border);
|
|
34
74
|
}
|
|
75
|
+
|
|
76
|
+
&--danger:hover:not(:disabled) {
|
|
77
|
+
color: var(--wzl-danger);
|
|
78
|
+
border-color: var(--wzl-danger);
|
|
79
|
+
}
|
|
80
|
+
|
|
35
81
|
&:focus-visible {
|
|
36
82
|
outline: 2px solid var(--wzl-focus-ring);
|
|
37
83
|
outline-offset: 1px;
|
|
38
84
|
}
|
|
85
|
+
|
|
39
86
|
&:disabled {
|
|
40
87
|
color: var(--wzl-fg-subtle);
|
|
41
88
|
cursor: not-allowed;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, test } from 'vitest';
|
|
3
3
|
import { Toolbar } from './Toolbar';
|
|
4
4
|
|
|
@@ -63,3 +63,69 @@ describe('Toolbar', () => {
|
|
|
63
63
|
expect(screen.getByRole('button', { name: 'X' })).toBeDisabled();
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
function threeButtons() {
|
|
68
|
+
return render(
|
|
69
|
+
<Toolbar aria-label="Trial actions">
|
|
70
|
+
<Toolbar.Group aria-label="History">
|
|
71
|
+
<Toolbar.Button onClick={() => {}} title="Undo">
|
|
72
|
+
U
|
|
73
|
+
</Toolbar.Button>
|
|
74
|
+
<Toolbar.Button onClick={() => {}} title="Redo">
|
|
75
|
+
R
|
|
76
|
+
</Toolbar.Button>
|
|
77
|
+
</Toolbar.Group>
|
|
78
|
+
<Toolbar.Button onClick={() => {}} title="Close">
|
|
79
|
+
X
|
|
80
|
+
</Toolbar.Button>
|
|
81
|
+
</Toolbar>,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('Toolbar keyboard contract', () => {
|
|
86
|
+
test('claims the toolbar role and is nameable', () => {
|
|
87
|
+
threeButtons();
|
|
88
|
+
expect(screen.getByRole('toolbar', { name: 'Trial actions' })).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('keeps exactly one button in the tab order', () => {
|
|
92
|
+
threeButtons();
|
|
93
|
+
const inOrder = screen.getAllByRole('button').filter((b) => b.tabIndex === 0);
|
|
94
|
+
expect(inOrder).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('keeps one tab stop when a button becomes enabled', async () => {
|
|
98
|
+
function Bar({ busy }: { busy: boolean }) {
|
|
99
|
+
return (
|
|
100
|
+
<Toolbar aria-label="Trial actions">
|
|
101
|
+
<Toolbar.Button onClick={() => {}} title="Undo" disabled={busy}>
|
|
102
|
+
U
|
|
103
|
+
</Toolbar.Button>
|
|
104
|
+
<Toolbar.Button onClick={() => {}} title="Close">
|
|
105
|
+
X
|
|
106
|
+
</Toolbar.Button>
|
|
107
|
+
</Toolbar>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const { rerender } = render(<Bar busy />);
|
|
111
|
+
rerender(<Bar busy={false} />);
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
const inOrder = screen
|
|
114
|
+
.getAllByRole<HTMLButtonElement>('button')
|
|
115
|
+
.filter((b) => !b.disabled && b.tabIndex === 0);
|
|
116
|
+
expect(inOrder).toHaveLength(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('moves focus with the arrow keys, wrapping at the end', async () => {
|
|
121
|
+
const { default: userEvent } = await import('@testing-library/user-event');
|
|
122
|
+
const user = userEvent.setup();
|
|
123
|
+
threeButtons();
|
|
124
|
+
await user.tab();
|
|
125
|
+
expect(screen.getByTitle('Undo')).toHaveFocus();
|
|
126
|
+
await user.keyboard('{ArrowRight}');
|
|
127
|
+
expect(screen.getByTitle('Redo')).toHaveFocus();
|
|
128
|
+
await user.keyboard('{ArrowRight}{ArrowRight}');
|
|
129
|
+
expect(screen.getByTitle('Undo')).toHaveFocus();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import type { MouseEventHandler, ReactNode } from 'react';
|
|
2
|
+
import { useRovingTabIndex } from './useRovingTabIndex';
|
|
2
3
|
|
|
3
4
|
/** Props for `<Toolbar>`. */
|
|
4
5
|
export interface ToolbarProps {
|
|
5
6
|
children: ReactNode;
|
|
7
|
+
/** Names the toolbar for assistive tech. Required by the APG pattern when a
|
|
8
|
+
* view holds more than one. */
|
|
9
|
+
'aria-label'?: string;
|
|
6
10
|
}
|
|
7
11
|
|
|
8
12
|
/** A horizontal bar of controls. Fill it with `<Toolbar.Title>`,
|
|
9
|
-
* `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
|
|
10
|
-
export function Toolbar({ children }: ToolbarProps) {
|
|
11
|
-
|
|
13
|
+
* `<Toolbar.Group>`, `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
|
|
14
|
+
export function Toolbar({ children, 'aria-label': ariaLabel }: ToolbarProps) {
|
|
15
|
+
const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>();
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
ref={ref}
|
|
19
|
+
className="lk-toolbar"
|
|
20
|
+
role="toolbar"
|
|
21
|
+
aria-label={ariaLabel}
|
|
22
|
+
onKeyDown={onKeyDown}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
12
27
|
}
|
|
13
28
|
|
|
14
29
|
interface TitleProps {
|
|
@@ -18,20 +33,69 @@ function Title({ children }: TitleProps) {
|
|
|
18
33
|
return <span className="lk-toolbar-title">{children}</span>;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
/** Props for `<Toolbar.Group>`. */
|
|
37
|
+
export interface ToolbarGroupProps {
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
/** Pushes this group, and everything after it, to the far end. */
|
|
40
|
+
end?: boolean;
|
|
41
|
+
'aria-label'?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Related controls, kept tight and ruled off from their neighbors. Group
|
|
45
|
+
* rather than separate with repeated `<Toolbar.Spacer>`: consecutive spacers
|
|
46
|
+
* share the free space, so every gap collapses to the same slack and the bar
|
|
47
|
+
* reads as one undifferentiated run. */
|
|
48
|
+
function Group({ children, end, 'aria-label': ariaLabel }: ToolbarGroupProps) {
|
|
49
|
+
return (
|
|
50
|
+
// biome-ignore lint/a11y/useSemanticElements: a group inside role="toolbar" is the APG pattern; the rule is local and cannot see the parent. <fieldset> means form controls, needs a <legend> to be named, and its UA min-width breaks flex children.
|
|
51
|
+
<div
|
|
52
|
+
className={`lk-toolbar-group${end ? ' lk-toolbar-group--end' : ''}`}
|
|
53
|
+
role="group"
|
|
54
|
+
aria-label={ariaLabel}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Props for `<Toolbar.Button>`. */
|
|
62
|
+
export interface ToolbarButtonProps {
|
|
22
63
|
children: ReactNode;
|
|
23
64
|
onClick: MouseEventHandler<HTMLButtonElement>;
|
|
24
65
|
disabled?: boolean;
|
|
25
66
|
title?: string;
|
|
67
|
+
/** Required when `children` is an icon with no text of its own. */
|
|
68
|
+
'aria-label'?: string;
|
|
69
|
+
/** `danger` reddens on hover — for actions that discard work. */
|
|
70
|
+
variant?: 'default' | 'danger';
|
|
71
|
+
/** Square, for a button whose whole content is one glyph. */
|
|
72
|
+
iconOnly?: boolean;
|
|
26
73
|
}
|
|
27
|
-
|
|
74
|
+
|
|
75
|
+
function Button({
|
|
76
|
+
children,
|
|
77
|
+
onClick,
|
|
78
|
+
disabled,
|
|
79
|
+
title,
|
|
80
|
+
'aria-label': ariaLabel,
|
|
81
|
+
variant = 'default',
|
|
82
|
+
iconOnly,
|
|
83
|
+
}: ToolbarButtonProps) {
|
|
84
|
+
const cls = [
|
|
85
|
+
'lk-toolbar-button',
|
|
86
|
+
variant === 'danger' && 'lk-toolbar-button--danger',
|
|
87
|
+
iconOnly && 'lk-toolbar-button--icon',
|
|
88
|
+
]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join(' ');
|
|
28
91
|
return (
|
|
29
92
|
<button
|
|
30
93
|
type="button"
|
|
31
|
-
className=
|
|
94
|
+
className={cls}
|
|
32
95
|
onClick={onClick}
|
|
33
96
|
disabled={disabled}
|
|
34
97
|
title={title}
|
|
98
|
+
aria-label={ariaLabel ?? title}
|
|
35
99
|
>
|
|
36
100
|
{children}
|
|
37
101
|
</button>
|
|
@@ -44,6 +108,8 @@ function Spacer() {
|
|
|
44
108
|
|
|
45
109
|
/** A label within a toolbar. */
|
|
46
110
|
Toolbar.Title = Title;
|
|
111
|
+
/** A ruled-off run of related controls. */
|
|
112
|
+
Toolbar.Group = Group;
|
|
47
113
|
/** A button within a toolbar. */
|
|
48
114
|
Toolbar.Button = Button;
|
|
49
115
|
/** Flexible space that pushes what follows to the far end of the toolbar. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
.lk-zoom {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--wzl-space-sm);
|
|
5
|
+
min-width: 0;
|
|
6
|
+
|
|
7
|
+
&__slider {
|
|
8
|
+
flex: 0 1 108px;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
&__field {
|
|
12
|
+
flex: 0 0 auto;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Both children are weasel-ui components whose module classes set `width`.
|
|
17
|
+
// See the `.lk-root` prefix convention in theme/base.less.
|
|
18
|
+
.lk-root {
|
|
19
|
+
.lk-zoom {
|
|
20
|
+
// Narrow enough to sit in a trial toolbar, wide enough for the octave
|
|
21
|
+
// detents to be separately hittable.
|
|
22
|
+
&__slider {
|
|
23
|
+
width: 108px;
|
|
24
|
+
min-width: 64px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
&__field {
|
|
28
|
+
width: 62px;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|