From 356f3e318cc13ad8adc7bea37fe827609addb309 Mon Sep 17 00:00:00 2001 From: Demian Date: Thu, 18 Dec 2025 01:17:49 -0500 Subject: [PATCH 1/3] feat: add focus controller utility with tests --- frontend/src/hooks/useFocusController.ts | 20 ++++ frontend/src/utils/focusController.test.ts | 59 +++++++++++ frontend/src/utils/focusController.ts | 115 +++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 frontend/src/hooks/useFocusController.ts create mode 100644 frontend/src/utils/focusController.test.ts create mode 100644 frontend/src/utils/focusController.ts diff --git a/frontend/src/hooks/useFocusController.ts b/frontend/src/hooks/useFocusController.ts new file mode 100644 index 0000000..93d390a --- /dev/null +++ b/frontend/src/hooks/useFocusController.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import { + FocusController, + FocusControllerOptions, +} from '../utils/focusController'; + +export const useFocusController = ( + options: FocusControllerOptions, +): FocusController => { + return useMemo( + () => + new FocusController({ + fieldOrder: options.fieldOrder, + getRowOrder: options.getRowOrder, + onBoundary: options.onBoundary, + }), + // fieldOrder and getRowOrder are expected to be stable or memoized by caller + [options.fieldOrder, options.getRowOrder, options.onBoundary], + ); +}; diff --git a/frontend/src/utils/focusController.test.ts b/frontend/src/utils/focusController.test.ts new file mode 100644 index 0000000..580d673 --- /dev/null +++ b/frontend/src/utils/focusController.test.ts @@ -0,0 +1,59 @@ +import { FocusController } from './focusController'; + +type RowKey = string; +type FieldKey = 'location' | 'quantity' | 'save'; + +const makeController = ( + rows: RowKey[], + boundary?: (row: RowKey) => RowKey | null | Promise, +) => + new FocusController({ + fieldOrder: ['location', 'quantity', 'save'], + getRowOrder: () => rows, + onBoundary: boundary, + }); + +describe('FocusController', () => { + it('focuses next field within a row', async () => { + const calls: string[] = []; + const controller = makeController(['row-1']); + + controller.register('row-1', 'location', () => calls.push('row-1:location')); + controller.register('row-1', 'quantity', () => calls.push('row-1:quantity')); + controller.register('row-1', 'save', () => calls.push('row-1:save')); + + await controller.focusNext('row-1', 'location'); + expect(calls).toContain('row-1:quantity'); + }); + + it('advances to next row first field when current row fields are exhausted', async () => { + const calls: string[] = []; + const controller = makeController(['row-1', 'row-2']); + + controller.register('row-1', 'save', () => calls.push('row-1:save')); + controller.register('row-2', 'location', () => calls.push('row-2:location')); + + await controller.focusNext('row-1', 'save'); + expect(calls).toContain('row-2:location'); + }); + + it('invokes boundary handler when at the last row', async () => { + const calls: string[] = []; + const controller = makeController(['row-1'], (row) => { + calls.push(`boundary:${row}`); + return 'row-2'; + }); + + controller.register('row-1', 'save', () => calls.push('row-1:save')); + controller.register('row-2', 'location', () => calls.push('row-2:location')); + + await controller.focusNext('row-1', 'save'); + expect(calls).toEqual(['boundary:row-1', 'row-2:location']); + }); + + it('returns false when target field is missing', async () => { + const controller = makeController(['row-1']); + const result = await controller.focusNext('row-1', 'location'); + expect(result).toBe(false); + }); +}); diff --git a/frontend/src/utils/focusController.ts b/frontend/src/utils/focusController.ts new file mode 100644 index 0000000..16ef179 --- /dev/null +++ b/frontend/src/utils/focusController.ts @@ -0,0 +1,115 @@ +type FocusCallback = () => void; + +export interface FocusControllerOptions { + fieldOrder: FieldKey[]; + /** + * Returns the current rendered row order (after filters/sorts/pagination). + */ + getRowOrder: () => RowKey[]; + /** + * Invoked when focus tries to advance past the last row. + * Return a row key to focus (e.g., first row on next page) or null/undefined to stop. + */ + onBoundary?: (rowKey: RowKey) => Promise | RowKey | null; +} + +/** + * FocusController centralizes focus transitions for grid-like UIs (e.g., Editor Mode). + * It keeps a registry of focusable fields per row and provides deterministic + * navigation across fields and rows, including pagination boundaries. + */ +export class FocusController { + private readonly registry = new Map>(); + private readonly fieldOrder: FieldKey[]; + private readonly getRowOrder: () => RowKey[]; + private readonly onBoundary?: ( + rowKey: RowKey, + ) => Promise | RowKey | null; + + constructor(options: FocusControllerOptions) { + this.fieldOrder = options.fieldOrder; + this.getRowOrder = options.getRowOrder; + this.onBoundary = options.onBoundary; + } + + /** + * Register a focus callback for a row/field combination. + * Returns an unregister function. + */ + register(rowKey: RowKey, fieldKey: FieldKey, focus: FocusCallback): () => void { + let fields = this.registry.get(rowKey); + if (!fields) { + fields = new Map(); + this.registry.set(rowKey, fields); + } + fields.set(fieldKey, focus); + return () => this.unregister(rowKey, fieldKey); + } + + unregister(rowKey: RowKey, fieldKey?: FieldKey): void { + const fields = this.registry.get(rowKey); + if (!fields) { + return; + } + if (fieldKey === undefined) { + this.registry.delete(rowKey); + return; + } + fields.delete(fieldKey); + if (fields.size === 0) { + this.registry.delete(rowKey); + } + } + + /** + * Attempt to focus a specific row/field. + */ + focus(rowKey: RowKey, fieldKey: FieldKey): boolean { + const fields = this.registry.get(rowKey); + const focus = fields?.get(fieldKey); + if (!focus) { + return false; + } + // Defer to next frame to avoid focusing an element that is being re-rendered. + requestAnimationFrame(() => { + focus(); + }); + return true; + } + + /** + * Advance focus to the next field in the same row, or the next row's first field. + * If at the last row, delegates to onBoundary to resolve the next target (e.g., next page). + */ + async focusNext(currentRow: RowKey, currentField: FieldKey): Promise { + const fieldIndex = this.fieldOrder.indexOf(currentField); + const rowOrder = this.getRowOrder(); + const rowIndex = rowOrder.indexOf(currentRow); + + if (fieldIndex < 0 || rowIndex < 0) { + return false; + } + + // Next field in the same row + if (fieldIndex < this.fieldOrder.length - 1) { + const nextField = this.fieldOrder[fieldIndex + 1]; + return this.focus(currentRow, nextField); + } + + // Next row's first field + const nextRow = rowOrder[rowIndex + 1]; + if (nextRow !== undefined) { + return this.focus(nextRow, this.fieldOrder[0]); + } + + // Boundary handler (e.g., next page) + if (this.onBoundary) { + const boundaryRow = await this.onBoundary(currentRow); + if (boundaryRow) { + return this.focus(boundaryRow, this.fieldOrder[0]); + } + } + + return false; + } +} From 1c1b7e5598c423adb281fbaceab0cbe5598f1c1f Mon Sep 17 00:00:00 2001 From: Demian Date: Thu, 18 Dec 2025 01:25:06 -0500 Subject: [PATCH 2/3] ci: add frontend lint/typecheck/build workflow --- .github/workflows/frontend-ci.yml | 91 +++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/frontend-ci.yml diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..795a643 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,91 @@ +name: Frontend CI + +on: + push: + paths: + - 'frontend/**' + - '.github/workflows/**' + pull_request: + paths: + - 'frontend/**' + - '.github/workflows/**' + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run linter + working-directory: frontend + run: pnpm run lint + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run typecheck + working-directory: frontend + run: pnpm run typecheck + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, typecheck] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build frontend + working-directory: frontend + run: pnpm run build From 5bd5c12e1311335baa6d611a9f297b6c93e9c113 Mon Sep 17 00:00:00 2001 From: Demian Date: Thu, 18 Dec 2025 01:28:14 -0500 Subject: [PATCH 3/3] chore: add jest types for frontend tests and fix typecheck --- frontend/package.json | 79 +++++++++++++++++++++--------------------- frontend/tsconfig.json | 4 +-- pnpm-lock.yaml | 3 ++ 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 9ee6e54..ca1ce8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,41 +1,42 @@ { - "name": "frontend", - "version": "1.0.0", - "private": true, - "type": "module", - "dependencies": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", - "@mui/icons-material": "^6.5.0", - "@mui/material": "^6.5.0", - "axios": "^1.6.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.20.0" - }, - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "typecheck": "tsc --noEmit", - "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "clean": "rm -rf build node_modules/.vite" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.1.5", - "@testing-library/react": "^14.1.2", - "@testing-library/user-event": "^14.5.1", - "@types/node": "^20.10.0", - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@types/react-router-dom": "^5.3.3", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", - "@vitejs/plugin-react": "^4.2.1", - "eslint": "^8.55.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "typescript": "^5.3.3", - "vite": "^5.0.8" - } + "name": "frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^6.5.0", + "@mui/material": "^6.5.0", + "axios": "^1.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "clean": "rm -rf build node_modules/.vite" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.1", + "@types/jest": "^29.5.14", + "@types/node": "^20.10.0", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@types/react-router-dom": "^5.3.3", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.3.3", + "vite": "^5.0.8" + } } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6c488ed..edf2e3f 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -24,9 +24,9 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"] - } + }, + "types": ["node", "jest", "@testing-library/jest-dom"] }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } - \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 571b3ec..f6c590f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,9 @@ importers: '@testing-library/user-event': specifier: ^14.5.1 version: 14.6.1(@testing-library/dom@9.3.4) + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 '@types/node': specifier: ^20.10.0 version: 20.19.25