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();
}