refactor(ui): finalize review fixes for action cards and view model
- Guard ActionCard click-outside listener behind open state - Add aria-expanded to ActionGroup collapse toggle - Use optional chaining and drop non-null assertions in column projection
This commit is contained in:
@@ -25,8 +25,20 @@ function queueTestContent() {
|
|||||||
return buildContent({
|
return buildContent({
|
||||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||||
actions: [
|
actions: [
|
||||||
{ id: 'a', name: 'A', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
{
|
||||||
{ id: 'b', name: 'B', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
id: 'a',
|
||||||
|
name: 'A',
|
||||||
|
group: DEFAULT_GROUP,
|
||||||
|
durationMs: 3000,
|
||||||
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'b',
|
||||||
|
name: 'B',
|
||||||
|
group: DEFAULT_GROUP,
|
||||||
|
durationMs: 3000,
|
||||||
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { content } from '../../content/index';
|
||||||
import { buildContent } from '../../content/schema';
|
import { buildContent } from '../../content/schema';
|
||||||
import { buildStoryContent } from '../../content/storySchema';
|
import { buildStoryContent } from '../../content/storySchema';
|
||||||
import { content } from '../../content/index';
|
|
||||||
import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game';
|
import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game';
|
||||||
import { enterStoryNode } from '../../engine/story';
|
import { enterStoryNode } from '../../engine/story';
|
||||||
import { formatOfflineDuration, toView } from '../viewModel';
|
import { formatOfflineDuration, toView } from '../viewModel';
|
||||||
@@ -91,8 +91,20 @@ describe('toView()', () => {
|
|||||||
const content = contentWithActions(
|
const content = contentWithActions(
|
||||||
[{ id: 'gold', name: 'Gold' }],
|
[{ id: 'gold', name: 'Gold' }],
|
||||||
[
|
[
|
||||||
{ id: 'a', name: 'Alpha', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
{
|
||||||
{ id: 'b', name: 'Bravo', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
id: 'a',
|
||||||
|
name: 'Alpha',
|
||||||
|
group: DEFAULT_GROUP,
|
||||||
|
durationMs: 1000,
|
||||||
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'b',
|
||||||
|
name: 'Bravo',
|
||||||
|
group: DEFAULT_GROUP,
|
||||||
|
durationMs: 1000,
|
||||||
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const state = createGameState(content);
|
const state = createGameState(content);
|
||||||
@@ -200,7 +212,11 @@ describe('action columns projection', () => {
|
|||||||
const state = createGameState(content);
|
const state = createGameState(content);
|
||||||
const view = toView(state, content);
|
const view = toView(state, content);
|
||||||
expect(view.actionColumns.map((c) => c.kind)).toEqual([
|
expect(view.actionColumns.map((c) => c.kind)).toEqual([
|
||||||
'instant', 'loop', 'timed', 'story', 'context',
|
'instant',
|
||||||
|
'loop',
|
||||||
|
'timed',
|
||||||
|
'story',
|
||||||
|
'context',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,7 +262,10 @@ describe('story tree projection', () => {
|
|||||||
enterStoryNode(state, content, 'fork_choice');
|
enterStoryNode(state, content, 'fork_choice');
|
||||||
const view = toView(state, content);
|
const view = toView(state, content);
|
||||||
// fork_choice should be a node in the tree, marked active+seen, with route children
|
// fork_choice should be a node in the tree, marked active+seen, with route children
|
||||||
const findNode = (nodes: typeof view.story.tree, id: string): (typeof nodes)[number] | undefined => {
|
const findNode = (
|
||||||
|
nodes: typeof view.story.tree,
|
||||||
|
id: string,
|
||||||
|
): (typeof nodes)[number] | undefined => {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.id === id) return n;
|
if (n.id === id) return n;
|
||||||
const deeper = findNode(n.children, id);
|
const deeper = findNode(n.children, id);
|
||||||
|
|||||||
+11
-6
@@ -147,7 +147,9 @@ export function toView(state: GameState, content: GameContent): GameView {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
|
const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
|
||||||
const actionProgress = action && action.durationMs ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0;
|
const actionProgress = action?.durationMs
|
||||||
|
? Math.min(1, state.actionElapsedMs / action.durationMs)
|
||||||
|
: 0;
|
||||||
const queuedActionIds = [...state.actionQueue];
|
const queuedActionIds = [...state.actionQueue];
|
||||||
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
|
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
|
||||||
|
|
||||||
@@ -180,9 +182,10 @@ export function toView(state: GameState, content: GameContent): GameView {
|
|||||||
const kindActions = content.actions.filter((a) => a.kind === kind);
|
const kindActions = content.actions.filter((a) => a.kind === kind);
|
||||||
|
|
||||||
// For story kind: only include available actions (hides siblings after fork)
|
// For story kind: only include available actions (hides siblings after fork)
|
||||||
const includedActions = kind === 'story'
|
const includedActions =
|
||||||
? kindActions.filter((a) => actionViewMap.get(a.id)?.available === true)
|
kind === 'story'
|
||||||
: kindActions;
|
? kindActions.filter((a) => actionViewMap.get(a.id)?.available === true)
|
||||||
|
: kindActions;
|
||||||
|
|
||||||
// Group by action.group, preserving first-seen order
|
// Group by action.group, preserving first-seen order
|
||||||
const groupOrder: string[] = [];
|
const groupOrder: string[] = [];
|
||||||
@@ -194,10 +197,12 @@ export function toView(state: GameState, content: GameContent): GameView {
|
|||||||
groupOrder.push(a.group.id);
|
groupOrder.push(a.group.id);
|
||||||
groupMap.set(a.group.id, { id: a.group.id, label: a.group.label, actions: [] });
|
groupMap.set(a.group.id, { id: a.group.id, label: a.group.label, actions: [] });
|
||||||
}
|
}
|
||||||
groupMap.get(a.group.id)!.actions.push(view);
|
groupMap.get(a.group.id)?.actions.push(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups: ActionGroupView[] = groupOrder.map((gid) => groupMap.get(gid)!);
|
const groups: ActionGroupView[] = groupOrder
|
||||||
|
.map((gid) => groupMap.get(gid))
|
||||||
|
.filter((g): g is ActionGroupView => g !== undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ export function ActionCard({ action }: ActionCardProps) {
|
|||||||
const [openInfoId, setOpenInfoId] = useState<string | null>(null);
|
const [openInfoId, setOpenInfoId] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isOpen = openInfoId === action.id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
setOpenInfoId(null);
|
setOpenInfoId(null);
|
||||||
@@ -25,7 +29,7 @@ export function ActionCard({ action }: ActionCardProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleClickOutside);
|
document.removeEventListener('click', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isOpen]);
|
||||||
|
|
||||||
const isActive = action.id === activeActionId;
|
const isActive = action.id === activeActionId;
|
||||||
const isDisabled = !action.available && !isActive;
|
const isDisabled = !action.available && !isActive;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function ActionGroup({ group, actionKind }: ActionGroupProps) {
|
|||||||
<div className="flex flex-col gap-1 w-full">
|
<div className="flex flex-col gap-1 w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
onClick={() => gameRuntime.toggleActionGroupCollapsed(`${actionKind}:${group.id}`)}
|
onClick={() => gameRuntime.toggleActionGroupCollapsed(`${actionKind}:${group.id}`)}
|
||||||
className="flex w-full items-center justify-between py-1.5 text-left text-slate-400 hover:text-slate-200 transition-colors focus:outline-none"
|
className="flex w-full items-center justify-between py-1.5 text-left text-slate-400 hover:text-slate-200 transition-colors focus:outline-none"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user