diff --git a/apps/web/src/features/graph/production-graph.component.css b/apps/web/src/features/graph/production-graph.component.css index 63c2da1..057366f 100644 --- a/apps/web/src/features/graph/production-graph.component.css +++ b/apps/web/src/features/graph/production-graph.component.css @@ -122,6 +122,77 @@ background: color-mix(in srgb, var(--color-accent) 26%, transparent); } +.selected-node-tray { + position: absolute; + right: 0; + bottom: calc(100% + 6px); + z-index: 24; + display: inline-flex; + gap: 3px; + max-width: 100%; + padding: 3px; + border: 1px solid color-mix(in srgb, var(--node-accent, var(--color-accent)) 58%, black); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-panel-raised) 90%, black); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 10px 22px rgba(0, 0, 0, 0.34); +} + +.selected-node-tray::after { + position: absolute; + right: 14px; + bottom: -5px; + width: 8px; + height: 8px; + border-right: 1px solid color-mix(in srgb, var(--node-accent, var(--color-accent)) 58%, black); + border-bottom: 1px solid color-mix(in srgb, var(--node-accent, var(--color-accent)) 58%, black); + background: color-mix(in srgb, var(--color-panel-raised) 90%, black); + content: ''; + pointer-events: none; + transform: rotate(45deg); +} + +.selected-node-tray__button { + position: relative; + z-index: 1; + display: inline-grid; + place-items: center; + min-width: 44px; + min-height: 28px; + padding: 0 8px; + border: 0; + border-radius: calc(var(--radius-sm) - 2px); + appearance: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.selected-node-tray__button[aria-pressed='true'] { + background: color-mix(in srgb, var(--color-output) 18%, transparent); + color: color-mix(in srgb, var(--color-output) 80%, white); +} + +.selected-node-tray__button:hover { + background: color-mix(in srgb, var(--node-accent, var(--color-accent)) 18%, transparent); + color: color-mix(in srgb, var(--node-accent, var(--color-accent)) 76%, white); +} + +.selected-node-tray__button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--node-accent, var(--color-accent)) 72%, white); + outline-offset: -2px; +} + +.selected-node-tray__button:active { + background: color-mix(in srgb, var(--node-accent, var(--color-accent)) 26%, transparent); +} + .production-node { box-sizing: border-box; display: flex; diff --git a/apps/web/src/features/graph/production-graph.component.html b/apps/web/src/features/graph/production-graph.component.html index ea67d8c..7581fb5 100644 --- a/apps/web/src/features/graph/production-graph.component.html +++ b/apps/web/src/features/graph/production-graph.component.html @@ -106,6 +106,40 @@ [attr.aria-pressed]="isNodeSelected(node.id)" (fNodePositionChange)="handleNodePosition(node.id, $event)" > + @if (isNodeSelected(node.id)) { +
+ + +
+ }
{ expect(targetAmountChanged).toEqual([{ targetId: 'target-plate', amountPerMinute: 42 }]); }); + it('renders a compact selected-node tray for done and clear actions', async () => { + const { controls, fixture, nodeDoneToggled, nodeSelectionSet } = + await createRenderedGraphHarness(); + + expect(selectedNodeTray(fixture)).toBeNull(); + + controls.selectedNodeId.set(OUTPUT_NODE_ID); + fixture.detectChanges(); + + let tray = requiredSelectedNodeTray(fixture); + expect(tray.getAttribute('role')).toBe('group'); + expect(tray.getAttribute('aria-label')).toBe('Selected node actions'); + let buttons = Array.from(tray.querySelectorAll('button')); + expect(buttons).toHaveLength(2); + + const doneButton = requiredSelectedNodeTrayButton(fixture, 'Done'); + expect(doneButton.getAttribute('aria-label')).toBe('Mark Iron Plate as done'); + expect(doneButton.getAttribute('aria-pressed')).toBe('false'); + + const documentClick = vi.fn(); + document.addEventListener('click', documentClick); + doneButton.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + document.removeEventListener('click', documentClick); + + expect(documentClick).not.toHaveBeenCalled(); + expect(nodeDoneToggled).toEqual([OUTPUT_NODE_ID]); + + controls.completedNodeIds.set(new Set([OUTPUT_NODE_ID])); + fixture.detectChanges(); + + const undoButton = requiredSelectedNodeTrayButton(fixture, 'Undo'); + expect(undoButton.getAttribute('aria-label')).toBe('Mark Iron Plate as not done'); + expect(undoButton.getAttribute('aria-pressed')).toBe('true'); + + requiredSelectedNodeTrayButton(fixture, 'Clear').dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ); + + expect(nodeSelectionSet).toEqual([null]); + + controls.selectedNodeId.set(null); + fixture.detectChanges(); + + expect(selectedNodeTray(fixture)).toBeNull(); + + controls.selectedNodeId.set(OUTPUT_NODE_ID); + fixture.detectChanges(); + + tray = requiredSelectedNodeTray(fixture); + buttons = Array.from(tray.querySelectorAll('button')); + expect(buttons).toHaveLength(2); + }); + it('fits a freshly rendered graph into the canvas', async () => { const { controls, fixture } = await createRenderedGraphHarness(); const canvas = { fitToScreen: vi.fn() }; @@ -747,13 +800,17 @@ async function createRenderedGraphHarness(): Promise = []; const targetAmountChanged: Array<{ targetId: string; amountPerMinute: number }> = []; + fixture.componentInstance.nodeDoneToggled.subscribe((nodeId) => nodeDoneToggled.push(nodeId)); + fixture.componentInstance.nodeSelectionSet.subscribe((nodeId) => nodeSelectionSet.push(nodeId)); fixture.componentInstance.targetAmountChanged.subscribe((change) => targetAmountChanged.push(change), ); fixture.detectChanges(); - return { controls, fixture, targetAmountChanged }; + return { controls, fixture, nodeDoneToggled, nodeSelectionSet, targetAmountChanged }; } function installRenderedGraphInputs( @@ -874,6 +931,36 @@ function requiredZoomControlButtons( return { zoomIn, zoomOut }; } +function selectedNodeTray( + fixture: ComponentFixture, +): HTMLElement | null { + return fixture.nativeElement.querySelector('.selected-node-tray') as HTMLElement | null; +} + +function requiredSelectedNodeTray( + fixture: ComponentFixture, +): HTMLElement { + const tray = selectedNodeTray(fixture); + if (!tray) { + throw new Error('Expected the selected-node tray to render'); + } + return tray; +} + +function requiredSelectedNodeTrayButton( + fixture: ComponentFixture, + label: string, +): HTMLButtonElement { + const buttons = Array.from( + requiredSelectedNodeTray(fixture).querySelectorAll('button'), + ) as HTMLButtonElement[]; + const button = buttons.find((candidate) => candidate.textContent?.trim() === label); + if (!button) { + throw new Error(`Expected the selected-node tray ${label} button to render`); + } + return button; +} + function installGraphCanvas( component: ProductionGraphComponent, canvas: Pick, @@ -975,6 +1062,8 @@ interface ProductionGraphHarness { interface RenderedProductionGraphHarness { controls: RenderedProductionGraphControls; fixture: ComponentFixture; + nodeDoneToggled: string[]; + nodeSelectionSet: Array; targetAmountChanged: Array<{ targetId: string; amountPerMinute: number }>; } diff --git a/apps/web/src/features/graph/production-graph.component.ts b/apps/web/src/features/graph/production-graph.component.ts index 645563c..46f6d6f 100644 --- a/apps/web/src/features/graph/production-graph.component.ts +++ b/apps/web/src/features/graph/production-graph.component.ts @@ -338,6 +338,28 @@ export class ProductionGraphComponent implements AfterViewChecked, DoCheck, OnDe this.handleTargetAmountChange(node, event); } + public doneTrayLabel(nodeId: string): string { + return this.isNodeDone(nodeId) ? 'Undo' : 'Done'; + } + + public doneTrayTitle(node: BeltwiseFoblexFlowNode): string { + return this.isNodeDone(node.id) + ? `Mark ${node.data.label} as not done` + : `Mark ${node.data.label} as done`; + } + + public toggleNodeDoneFromTray(nodeId: string, event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.nodeDoneToggled.emit(nodeId); + } + + public clearNodeSelectionFromTray(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.nodeSelectionSet.emit(null); + } + public stopNodeControlEvent(event: Event): void { event.stopPropagation(); }