Skip to content

Commit f2e914e

Browse files
alicodingclaude
andauthored
feat: atlas selection tray -- visible selection outline + Group/Delete action surface (goal 0092 follow-up) (#203)
A multi-selection was previously invisible (React Flow's own .selected class ships unstyled) and had no action surface beyond a right-click. Adds a 2px accent outline + soft ring to every Atlas node type keyed off .react-flow__node.selected, and a floating selection tray (AtlasSelectionTray) that replaces the creation tray while 2+ cards/ notes are selected -- count label, Group into new area (2+ cards), Delete, and a bare-G / Escape keyboard door mirroring the tray's own buttons. useAtlasSelection now also exposes selectedCards/ selectedNotes as reactive state alongside its existing ref-only menu snapshot. The tray/Escape-clear threshold is 2+ selected, not 1+: React Flow marks a node .selected via its own built-in Escape/Enter/Space keyboard accessibility handling independent of any deliberate multi-select gesture, so a 1+ threshold fired on every ordinary flip-click and broke the existing flip/unflip behavior -- verified via two previously-passing e2e tests failing, fixed by aligning with the same >=2 threshold the multi-select context menu's own gate already uses. The keyboard listener (useAtlasSelectionTray) registers exactly once and reads current values through a ref rather than resubscribing per render: the earlier per-dependency-change resubscription opened a real window where a fast keypress landed between an unsubscribe and the next resubscribe and was silently dropped -- reproduced live via a flaking Escape-unflip assertion, fixed by the stable-listener/ latest-ref pattern this codebase already uses elsewhere for the same class of problem. Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a6e67b3 commit f2e914e

12 files changed

Lines changed: 334 additions & 14 deletions

frontend/e2e/atlas-select-group.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,46 @@ test('atlas shift-click select: toggle membership, group via member right-click,
206206
await cardB.click({ modifiers: ['Shift'] })
207207
await expect(selected).toHaveCount(2)
208208

209+
// Visible selection state (goal 0092 follow-up): both member nodes
210+
// carry a real, non-empty outline/ring, not just React Flow's own
211+
// unstyled .selected class.
212+
await expect.poll(() => cardA.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none')
213+
await expect.poll(() => cardB.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe('none')
214+
215+
// The selection tray replaces the creation tray while 2+ cards are
216+
// selected: count label, Group (2+ cards only), Delete, both with
217+
// their kbd chips.
218+
const selectionTray = page.getByTestId('atlas-selection-tray')
219+
await expect(selectionTray).toBeVisible()
220+
await expect(page.getByTestId('atlas-creation-tray')).toHaveCount(0)
221+
await expect(page.getByTestId('atlas-selection-count')).toHaveText('2 selected')
222+
const trayGroup = page.getByTestId('atlas-selection-group')
223+
await expect(trayGroup).toContainText('Group')
224+
await expect(trayGroup).toContainText('G')
225+
const trayDelete = page.getByTestId('atlas-selection-delete')
226+
await expect(trayDelete).toContainText('Delete')
227+
await expect(trayDelete).toContainText('⌫')
228+
229+
// Escape clears the selection (takes precedence over the board's
230+
// own unflip duty) -- the creation tray comes back.
231+
await page.keyboard.press('Escape')
232+
await expect(selected).toHaveCount(0)
233+
await expect(selectionTray).toHaveCount(0)
234+
await expect(page.getByTestId('atlas-creation-tray')).toBeVisible()
235+
236+
// Re-select, then bare G opens the SAME group popover a member
237+
// right-click's own menu item does -- closed here without
238+
// submitting so the flow below (member right-click -> Group) is
239+
// the one that actually creates the area.
240+
await cardA.click({ modifiers: ['Shift'] })
241+
await cardB.click({ modifiers: ['Shift'] })
242+
await expect(selected).toHaveCount(2)
243+
await page.keyboard.press('g')
244+
await expect(popover).toBeVisible()
245+
await expect(page.getByTestId('atlas-placement-context')).toContainText('2 cards')
246+
await popover.getByTestId('atlas-placement-cancel').click()
247+
await expect(popover).not.toBeVisible()
248+
209249
// Member right-click reaches the multi menu -> Group into new area
210250
// (same full-gesture retry as above: Primer's menu overlay animates
211251
// in, and a too-early item click lands outside and closes it).

frontend/src/atlas/AtlasBoard.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import { useAtlasCreation, type AtlasGroupRequest, type AtlasPlacementRequest, t
2222
import { useAtlasAreaDraw } from './useAtlasAreaDraw'
2323
import { useAtlasDragFiling, type FrameBox } from './useAtlasDragFiling'
2424
import { useAtlasSelection } from './useAtlasSelection'
25+
import { useAtlasSelectionTray } from './useAtlasSelectionTray'
2526
import { useAtlasSlotDrag } from './useAtlasSlotDrag'
2627
import { AtlasSlotDragLine } from './AtlasSlotDragLine'
2728
import { buildBoardCardNodes } from './atlasBuildBoardNodes'
2829
import { buildStickyNodes } from './atlasStickyNodes'
2930
import { AtlasCreationTray, ATLAS_TOOL_DRAG_MIME, type AtlasCreationTool } from './AtlasCreationTray'
31+
import { AtlasSelectionTray } from './AtlasSelectionTray'
3032
import { AtlasPlacementPopover } from './AtlasPlacementPopover'
3133
import { useAtlasNativeFileDrop } from './useAtlasNativeFileDrop'
3234
import { useAtlasPaste } from './useAtlasPaste'
@@ -68,7 +70,7 @@ export interface AtlasFocusRequest {
6870
// media-query gate AtlasNoteCardNode.module.css's own flip already
6971
// uses, read here in JS via usePrefersReducedMotion since React Flow's
7072
// own transition durations are JS options, not CSS.
71-
function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, parentID, arrangeRequest, viewedID, focusRequest, onDrill, onOpenOverlay, onFocusHandled, onCardContextMenu, onPaneContextMenu, onArteryContextMenu, onNoteContextMenu, onFrameContextMenu, onFrameInteriorContextMenu, onMultiSelectContextMenu, onDeleteSelection, placementRequest, promoteRequest, groupRequest, onJumpToChip }: {
73+
function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, parentID, arrangeRequest, viewedID, focusRequest, onDrill, onOpenOverlay, onFocusHandled, onCardContextMenu, onPaneContextMenu, onArteryContextMenu, onNoteContextMenu, onFrameContextMenu, onFrameInteriorContextMenu, onMultiSelectContextMenu, onDeleteSelection, onGroupSelection, placementRequest, promoteRequest, groupRequest, onJumpToChip }: {
7274
cards: Card[]
7375
allCards: Card[]
7476
kinds: Kind[]
@@ -117,6 +119,8 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare
117119
// React Flow's own deleteKeyCode stays disabled -- a local node
118120
// removal would just resurrect on the next data refresh.
119121
onDeleteSelection: (cardIDs: string[], noteIDs: string[]) => void
122+
// The selection tray's own "Group into new area" -- the multi-select context menu's own dispatcher, reused.
123+
onGroupSelection: (cardIDs: string[], noteIDs: string[], pos: { x: number; y: number }) => void
120124
// AtlasView's own downward creation requests (the pane menu's "Add
121125
// card"/"Add note"/"Promote to card…" items, extended by slice A2's
122126
// frame-scoped placements and "Group into new area") -- see
@@ -160,14 +164,6 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare
160164
return () => window.removeEventListener('keydown', onKeyDown)
161165
}, [cards, notes, onDeleteSelection, selection.selectedIDsRef])
162166

163-
useEffect(() => {
164-
const onKeyDown = (e: KeyboardEvent) => {
165-
if (e.key === 'Escape') setFlippedID(null)
166-
}
167-
window.addEventListener('keydown', onKeyDown)
168-
return () => window.removeEventListener('keydown', onKeyDown)
169-
}, [])
170-
171167
const toggleFlip = useCallback((id: string) => setFlippedID((cur) => (cur === id ? null : id)), [])
172168

173169
// Zoom chip / group-header click / Enter on a region frame (routed
@@ -333,6 +329,8 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare
333329
setNodes(sel.length > 0 ? allNodes.map((n) => (sel.includes(n.id) ? { ...n, selected: true } : n)) : allNodes)
334330
}, [allNodes, setNodes, selection.selectedIDsRef])
335331

332+
const { trayRef, hasSelection: haveSelection, onGroup: onTrayGroup, onDelete: onTrayDelete } = useAtlasSelectionTray({ selectedCards: selection.selectedCards, selectedNotes: selection.selectedNotes, clearSelection: selection.clearSelection, setNodes, onDeleteSelection, onGroupSelection, onUnflip: () => setFlippedID(null) })
333+
336334
// Every re-root (drill in, breadcrumb out, jump) settles the new
337335
// board with an animated fitView rather than an instant snap. The
338336
// very first paint stays on ReactFlow's own `fitView` prop below
@@ -475,7 +473,9 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare
475473
{marqueeStyle && <div className={styles.marquee} data-testid="atlas-area-marquee" style={marqueeStyle} />}
476474
{slotDrag.dragLine && <AtlasSlotDragLine line={slotDrag.dragLine} />}
477475
{fileDrop.dropError && <div className={`${styles.dropError} ${runbookStyles.error}`} data-testid="atlas-file-drop-error">{fileDrop.dropError}</div>}
478-
{!readOnly && <AtlasCreationTray armedTool={creation.armedTool} onToggle={creation.toggleArm} />}
476+
{!readOnly && (haveSelection
477+
? <AtlasSelectionTray ref={trayRef} selectedCardCount={selection.selectedCards.length} selectedNoteCount={selection.selectedNotes.length} onGroup={onTrayGroup} onDelete={onTrayDelete} />
478+
: <AtlasCreationTray armedTool={creation.armedTool} onToggle={creation.toggleArm} />)}
479479
{creation.popover && (
480480
<AtlasPlacementPopover
481481
mode={creation.popover.mode}

frontend/src/atlas/AtlasGroupNode.module.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
perspective: 1000px;
1515
}
1616

17+
/* Multi-selection outline (owner-caught follow-up to goal 0092): same
18+
box-shadow ring AtlasNoteCardNode.module.css uses, keyed off React
19+
Flow's own .selected on the node's outer wrapper -- never the
20+
frame's own border-width, so a selected frame's size never shifts. */
21+
:global(.react-flow__node.selected) .frame {
22+
box-shadow:
23+
0 0 0 2px var(--borderColor-accent-emphasis),
24+
0 0 0 5px var(--bgColor-accent-muted);
25+
}
26+
1727
/* The frame's own flip (goal 0072 slice C item 4) -- same rotateY
1828
transition AtlasNoteCardNode.module.css's own .flipInner uses. */
1929
.flipInner {

frontend/src/atlas/AtlasNoteCardNode.module.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
cursor: pointer;
1212
}
1313

14+
/* Multi-selection outline (owner-caught follow-up to goal 0092): keyed
15+
off React Flow's own .selected class on the node's outer wrapper --
16+
box-shadow, not border-width, so it never shifts this card's fixed
17+
190x128 footprint or its own hit-testing box. */
18+
:global(.react-flow__node.selected) .flipScene {
19+
border-radius: var(--borderRadius-medium);
20+
box-shadow:
21+
0 0 0 2px var(--borderColor-accent-emphasis),
22+
0 0 0 5px var(--bgColor-accent-muted);
23+
}
24+
1425
/* Present only so React Flow can measure a real connection point for
1526
a link edge -- never visible, never a click/drag target of its own
1627
(nodesConnectable={false} on the board already disables interactive

frontend/src/atlas/AtlasRegionChipNode.module.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
outline: 2px solid var(--focus-outlineColor);
1212
outline-offset: 1px;
1313
}
14+
15+
/* Multi-selection outline (owner-caught follow-up to goal 0092): same
16+
box-shadow ring AtlasNoteCardNode.module.css uses, keyed off React
17+
Flow's own .selected on the node's outer wrapper. */
18+
:global(.react-flow__node.selected) .chip {
19+
box-shadow:
20+
0 0 0 2px var(--borderColor-accent-emphasis),
21+
0 0 0 5px var(--bgColor-accent-muted);
22+
}
1423
.flipInner {
1524
position: relative;
1625
width: 100%;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/* Same floating position/container the creation tray occupies
2+
(AtlasCreationTray.module.css .tray) -- AtlasBoard renders exactly
3+
one of the two, so the two never need to share a class. */
4+
.tray {
5+
position: absolute;
6+
left: 50%;
7+
bottom: 24px;
8+
transform: translateX(-50%);
9+
z-index: 5;
10+
display: inline-flex;
11+
align-items: center;
12+
gap: 4px;
13+
padding: 6px;
14+
background: var(--bgColor-default);
15+
border: 1px solid var(--borderColor-accent-emphasis);
16+
border-radius: var(--borderRadius-large);
17+
box-shadow: var(--shadow-floating-large, 0 4px 16px rgba(0, 0, 0, 0.12));
18+
}
19+
20+
.count {
21+
padding: 0 8px;
22+
font-size: 13px;
23+
font-weight: 650;
24+
color: var(--fgColor-accent);
25+
white-space: nowrap;
26+
}
27+
28+
.divider {
29+
width: 1px;
30+
align-self: stretch;
31+
background: var(--borderColor-muted);
32+
}
33+
34+
.action {
35+
display: inline-flex;
36+
align-items: center;
37+
gap: 6px;
38+
padding: 6px 12px;
39+
border: 1px solid transparent;
40+
border-radius: var(--borderRadius-medium);
41+
background: transparent;
42+
color: var(--fgColor-default);
43+
font-size: 13px;
44+
cursor: pointer;
45+
}
46+
.action:hover {
47+
background: var(--bgColor-neutral-muted);
48+
}
49+
50+
.label {
51+
font-weight: 500;
52+
}
53+
54+
.kbd {
55+
font-family: var(--mill-mono);
56+
font-size: 10px;
57+
color: var(--fgColor-muted);
58+
border: 1px solid var(--borderColor-muted);
59+
border-radius: 3px;
60+
padding: 0 4px;
61+
}
62+
63+
.hint {
64+
display: inline-flex;
65+
align-items: center;
66+
gap: 6px;
67+
padding: 0 8px 0 4px;
68+
font-size: 11px;
69+
color: var(--fgColor-muted);
70+
white-space: nowrap;
71+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { forwardRef } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { SquareIcon, TrashIcon } from '@primer/octicons-react'
4+
import styles from './AtlasSelectionTray.module.css'
5+
6+
// Same non-copy kbd-glyph shape AtlasCreationTray.tsx's own TOOL_KEY
7+
// uses -- a keycap label, not translatable UI copy.
8+
const DELETE_KBD = '⌫'
9+
const CLEAR_KBD = 'esc'
10+
11+
// The floating tray a live multi-selection replaces the creation tray
12+
// with (owner-caught follow-up to goal 0092): same bottom-center
13+
// position/container the creation tray occupies -- AtlasBoard renders
14+
// exactly one of the two, never both. Group only ever shows for 2+
15+
// selected CARDS (notes don't group, same rule the multi-select
16+
// context menu's own item already enforces) -- a notes-only selection
17+
// still gets the count label, Delete, and the clear hint.
18+
export const AtlasSelectionTray = forwardRef<HTMLDivElement, {
19+
selectedCardCount: number
20+
selectedNoteCount: number
21+
onGroup: (pos: { x: number; y: number }) => void
22+
onDelete: () => void
23+
}>(function AtlasSelectionTray({ selectedCardCount, selectedNoteCount, onGroup, onDelete }, ref) {
24+
const { t } = useTranslation('atlas')
25+
const count = selectedCardCount + selectedNoteCount
26+
27+
return (
28+
<div ref={ref} className={styles.tray} data-testid="atlas-selection-tray" role="toolbar" aria-label={t('board.selectionTrayAriaLabel')}>
29+
<span className={styles.count} data-testid="atlas-selection-count">{t('board.selectionCount', { count })}</span>
30+
<span className={styles.divider} aria-hidden="true" />
31+
{selectedCardCount >= 2 && (
32+
<button
33+
type="button"
34+
className={styles.action}
35+
data-testid="atlas-selection-group"
36+
onClick={(e) => onGroup({ x: e.clientX, y: e.clientY })}
37+
>
38+
<SquareIcon size={14} />
39+
<span className={styles.label}>{t('board.selectionGroup')}</span>
40+
<span className={styles.kbd}>G</span>
41+
</button>
42+
)}
43+
<button type="button" className={styles.action} data-testid="atlas-selection-delete" onClick={onDelete}>
44+
<TrashIcon size={14} />
45+
<span className={styles.label}>{t('board.selectionDelete')}</span>
46+
<span className={styles.kbd}>{DELETE_KBD}</span>
47+
</button>
48+
<span className={styles.hint}>
49+
<span className={styles.kbd}>{CLEAR_KBD}</span>
50+
{t('board.selectionClearHint')}
51+
</span>
52+
</div>
53+
)
54+
})

frontend/src/atlas/AtlasStickyNode.module.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717
cursor: text;
1818
}
1919

20+
/* Multi-selection outline (owner-caught follow-up to goal 0092): same
21+
box-shadow ring AtlasNoteCardNode.module.css uses, keyed off React
22+
Flow's own .selected on the node's outer wrapper. */
23+
:global(.react-flow__node.selected) .sticky {
24+
box-shadow:
25+
0 0 0 2px var(--borderColor-accent-emphasis),
26+
0 0 0 5px var(--bgColor-accent-muted);
27+
}
28+
2029
.text {
2130
font-size: 12px;
2231
line-height: 1.35;

frontend/src/atlas/AtlasView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) {
443443
onFrameInteriorContextMenu={containmentMenus.openFrameInteriorMenu}
444444
onMultiSelectContextMenu={containmentMenus.openMultiSelectMenu}
445445
onDeleteSelection={containmentMenus.deleteSelection}
446+
onGroupSelection={(cardIDs, noteIDs, pos) => creationRequests.requestGroup(cardIDs, noteIDs, pos)}
446447
placementRequest={creationRequests.placementRequest}
447448
promoteRequest={creationRequests.promoteRequest}
448449
groupRequest={creationRequests.groupRequest}

frontend/src/atlas/useAtlasSelection.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef } from 'react'
1+
import { useCallback, useRef, useState } from 'react'
22
import type { OnSelectionChangeFunc } from '@xyflow/react'
33
import type { Card, Note } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
44

@@ -29,9 +29,30 @@ export function useAtlasSelection({ cards, notes, onMultiSelectContextMenu }: {
2929
}) {
3030
const selectedIDsRef = useRef<string[]>([])
3131
const contextSelectionRef = useRef<string[]>([])
32+
// Reactive split (owner-caught follow-up to goal 0092): the
33+
// selection tray and every node type's outline need to re-render on
34+
// a selection change, unlike the ref-only menu logic above (which
35+
// deliberately avoids re-rendering -- see this file's own header
36+
// comment on React error #185). A second, independent state mirror
37+
// of the same ref, split into card/note ids the way openMultiMenu
38+
// already splits them below.
39+
const [selectedCards, setSelectedCards] = useState<string[]>([])
40+
const [selectedNotes, setSelectedNotes] = useState<string[]>([])
3241

3342
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selected }) => {
34-
selectedIDsRef.current = selected.map((n) => n.id)
43+
const ids = selected.map((n) => n.id)
44+
selectedIDsRef.current = ids
45+
setSelectedCards(ids.filter((id) => cards.some((c) => c.ID === id)))
46+
setSelectedNotes(ids.filter((id) => notes.some((n) => n.ID === id)))
47+
}, [cards, notes])
48+
49+
// The selection tray's own clear (Escape, or the tray's clear
50+
// affordance): resets both halves so a stale ref can't reopen a
51+
// menu against members that no longer read as selected.
52+
const clearSelection = useCallback(() => {
53+
selectedIDsRef.current = []
54+
setSelectedCards([])
55+
setSelectedNotes([])
3556
}, [])
3657

3758
// Snapshot the selection BEFORE React Flow's own handlers re-select
@@ -66,5 +87,5 @@ export function useAtlasSelection({ cards, notes, onMultiSelectContextMenu }: {
6687
return openMultiMenu(sel, pos)
6788
}, [openMultiMenu])
6889

69-
return { selectedIDsRef, onSelectionChange, snapshotSelection, onSelectionContextMenu, tryNodeMultiMenu }
90+
return { selectedIDsRef, selectedCards, selectedNotes, onSelectionChange, snapshotSelection, onSelectionContextMenu, tryNodeMultiMenu, clearSelection }
7091
}

0 commit comments

Comments
 (0)