Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
@@ -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
79 changes: 40 additions & 39 deletions frontend/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
20 changes: 20 additions & 0 deletions frontend/src/hooks/useFocusController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import {
FocusController,
FocusControllerOptions,
} from '../utils/focusController';

export const useFocusController = <RowKey, FieldKey>(
options: FocusControllerOptions<RowKey, FieldKey>,
): FocusController<RowKey, FieldKey> => {
return useMemo(
() =>
new FocusController<RowKey, FieldKey>({
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],
);
};
59 changes: 59 additions & 0 deletions frontend/src/utils/focusController.test.ts
Original file line number Diff line number Diff line change
@@ -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<RowKey | null>,
) =>
new FocusController<RowKey, FieldKey>({
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);
});
});
115 changes: 115 additions & 0 deletions frontend/src/utils/focusController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
type FocusCallback = () => void;

export interface FocusControllerOptions<RowKey, FieldKey> {
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> | 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<RowKey, FieldKey> {
private readonly registry = new Map<RowKey, Map<FieldKey, FocusCallback>>();
private readonly fieldOrder: FieldKey[];
private readonly getRowOrder: () => RowKey[];
private readonly onBoundary?: (
rowKey: RowKey,
) => Promise<RowKey | null> | RowKey | null;

constructor(options: FocusControllerOptions<RowKey, FieldKey>) {
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<FieldKey, FocusCallback>();
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<boolean> {
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;
}
}
4 changes: 2 additions & 2 deletions frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"types": ["node", "jest", "@testing-library/jest-dom"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.