Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit eff042b

Browse files
committed
feat(admin): focus scope + keyboard navigation for content lists
- FocusScope primitive (refcounted) tracks the active interaction area via global pointerdown/focusin; sidebar + each list view declare their scope. - useListSelection / useListShortcuts: single-source row selection (single / toggle / range) and Backspace/Enter/$mod+Enter actions gated on scope. - useScopeArrowNav: J/K + Arrow + Home/End move focus through visible items in the active scope, with checkVisibility-aware filtering for sidebar collapse. onItemFocus callback mirrors cursor into selection state. - Posts/notes row actions extracted into a ListAction registry so the right-click menu and shortcut bindings share a single source. - Row visuals: Vercel-style neutral selection palette, no transition-colors, data-id + tabIndex on the article so the cursor focus and cmd/shift modifier clicks behave consistently.
1 parent a9fab9a commit eff042b

24 files changed

Lines changed: 1256 additions & 251 deletions

apps/admin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"shiki": "3.21.0",
100100
"socket.io-client": "4.8.3",
101101
"sonner": "2.0.7",
102+
"tinykeys": "4.0.0",
102103
"validator": "13.15.26",
103104
"xss": "1.0.15",
104105
"zod": "4.3.6"

apps/admin/src/features/_shared/components/content-list-item.tsx

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,22 @@ import { ContextMenuTrigger, showContextMenu } from '~/ui/overlay/context-menu'
1313
import { Checkbox } from '~/ui/primitives/checkbox'
1414
import { cn } from '~/utils/cn'
1515

16+
export type ContentEntrySelectMode = 'single' | 'toggle' | 'range'
17+
1618
export interface ContentEntryListItemProps {
1719
checkboxLabel?: string
1820
className?: string
21+
/** Stable id of the underlying entity. Exposed on the row as `data-id`. */
22+
dataId?: string
1923
editTitle: string
2024
editTo: string
2125
externalHref: string
2226
leading?: ReactNode
2327
menuItems: ContextMenuItem[] | (() => ContextMenuItem[])
2428
meta?: ReactNode
29+
/** Called when the row body is clicked (away from links / buttons / checkbox). */
30+
onSelect?: (mode: ContentEntrySelectMode) => void
31+
/** Called when the checkbox itself is toggled. */
2532
onSelectedChange?: (checked: boolean) => void
2633
openTitle: string
2734
selected?: boolean
@@ -30,15 +37,21 @@ export interface ContentEntryListItemProps {
3037
titleTo: string
3138
}
3239

40+
const INTERACTIVE_SELECTOR =
41+
'a[href], button, input, [role="menuitem"], [role="checkbox"]'
42+
3343
/**
3444
* Two-line list item shared by `/posts` and `/notes`.
3545
*
3646
* ☐ [leading] Title text [status] ✎ ↗ ⋯
3747
* category · tags · 👁 N · ♡ N relative time
3848
*
39-
* The whole row is wrapped in `<ContextMenuTrigger>` (lobe-ui style): right-clicking
40-
* anywhere on the row opens the menu; while the menu is open, the row receives
41-
* `data-popup-open=""` so it can highlight via the `data-[popup-open]:` variant.
49+
* The whole row is wrapped in `<ContextMenuTrigger>`; right-clicking
50+
* anywhere on the row opens the menu and the row receives
51+
* `data-popup-open=""` so it can highlight via the `data-[popup-open]:`
52+
* variant. Left-clicking the row body (outside links, buttons, checkbox)
53+
* fires `onSelect` with a modifier-aware mode so consumers can do
54+
* single / toggle (`$mod`+click) / range (`Shift`+click) selection.
4255
*/
4356
export function ContentEntryListItem(props: ContentEntryListItemProps) {
4457
const selectable = Boolean(props.checkboxLabel && props.onSelectedChange)
@@ -53,17 +66,47 @@ export function ContentEntryListItem(props: ContentEntryListItemProps) {
5366
showContextMenu(resolved)
5467
}
5568

69+
const onRowClick = (event: ReactMouseEvent<HTMLElement>) => {
70+
if (event.defaultPrevented) return
71+
if (!props.onSelect) return
72+
const target = event.target as Element | null
73+
// Skip clicks on links, buttons, inputs, the checkbox — those interactive
74+
// descendants do their own thing.
75+
if (target?.closest(INTERACTIVE_SELECTOR)) return
76+
const mode: ContentEntrySelectMode = event.shiftKey
77+
? 'range'
78+
: event.metaKey || event.ctrlKey
79+
? 'toggle'
80+
: 'single'
81+
props.onSelect(mode)
82+
}
83+
5684
return (
5785
<ContextMenuTrigger items={props.menuItems}>
5886
<article
5987
className={cn(
60-
'group grid gap-x-3 gap-y-1.5 px-4 py-3 transition-colors',
88+
'group grid cursor-default gap-x-3 gap-y-1.5 px-4 py-3',
6189
selectable
6290
? 'grid-cols-[auto_minmax(0,1fr)_auto]'
6391
: 'grid-cols-[minmax(0,1fr)_auto]',
64-
'hover:bg-neutral-50 data-[popup-open]:bg-neutral-100 dark:hover:bg-neutral-900/50 dark:data-[popup-open]:bg-neutral-800/60',
92+
// Vercel/Geist convention: selection lives on the neutral scale,
93+
// brand color stays out of row backgrounds. Hover < popup-open ≈
94+
// selected < selected+hover < selected+popup-open.
95+
'hover:bg-neutral-50 dark:hover:bg-neutral-900/50',
96+
'data-popup-open:bg-neutral-100 dark:data-popup-open:bg-neutral-800/60',
97+
'data-selected:bg-neutral-100 dark:data-selected:bg-neutral-800/60',
98+
'data-selected:hover:bg-neutral-200/60 dark:data-selected:hover:bg-neutral-800/80',
99+
'data-selected:data-popup-open:bg-neutral-200 dark:data-selected:data-popup-open:bg-neutral-800',
100+
// Keyboard cursor (J/K/Arrow). `:focus-visible` only fires on
101+
// keyboard focus, so mouse clicks don't draw the ring.
102+
'outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-400 dark:focus-visible:outline-neutral-500',
65103
props.className,
66104
)}
105+
data-id={props.dataId}
106+
data-scope-item="row"
107+
data-selected={props.selected ? '' : undefined}
108+
onClick={onRowClick}
109+
tabIndex={-1}
67110
>
68111
{selectable ? (
69112
<Checkbox
@@ -78,7 +121,7 @@ export function ContentEntryListItem(props: ContentEntryListItemProps) {
78121
<div className="flex min-w-0 items-center gap-2">
79122
{props.leading}
80123
<Link
81-
className="outline-hidden min-w-0 truncate text-sm font-medium text-neutral-950 transition-colors hover:text-neutral-600 focus-visible:ring-2 focus-visible:ring-[var(--color-primary-shallow)] dark:text-neutral-50 dark:hover:text-neutral-300"
124+
className="outline-hidden min-w-0 truncate text-sm font-medium text-neutral-950 hover:text-neutral-600 focus-visible:ring-2 focus-visible:ring-[var(--color-primary-shallow)] dark:text-neutral-50 dark:hover:text-neutral-300"
82125
to={props.titleTo}
83126
>
84127
{props.title}
@@ -94,9 +137,9 @@ export function ContentEntryListItem(props: ContentEntryListItemProps) {
94137

95138
<div
96139
className={cn(
97-
'row-start-1 flex shrink-0 items-center gap-0.5 self-start text-neutral-300 transition-colors',
98-
'group-hover:text-neutral-600 group-data-[popup-open]:text-neutral-600',
99-
'dark:text-neutral-700 dark:group-hover:text-neutral-300 dark:group-data-[popup-open]:text-neutral-300',
140+
'row-start-1 flex shrink-0 items-center gap-0.5 self-start text-neutral-300',
141+
'group-hover:text-neutral-600 group-data-[popup-open]:text-neutral-600 group-data-[selected]:text-neutral-600',
142+
'dark:text-neutral-700 dark:group-hover:text-neutral-300 dark:group-data-[popup-open]:text-neutral-300 dark:group-data-[selected]:text-neutral-300',
100143
selectable ? 'col-start-3' : 'col-start-2',
101144
)}
102145
>
@@ -121,7 +164,7 @@ export function ContentEntryListItem(props: ContentEntryListItemProps) {
121164
}
122165

123166
const actionSlotClassName =
124-
'outline-hidden inline-flex h-7 w-7 items-center justify-center rounded transition-colors hover:bg-neutral-100 hover:text-neutral-950 focus-visible:ring-2 focus-visible:ring-[var(--color-primary-shallow)] dark:hover:bg-neutral-800 dark:hover:text-neutral-50'
167+
'outline-hidden inline-flex h-7 w-7 items-center justify-center rounded text-current no-underline hover:bg-neutral-100 hover:text-neutral-950 focus-visible:ring-2 focus-visible:ring-[var(--color-primary-shallow)] dark:hover:bg-neutral-800 dark:hover:text-neutral-50'
125168

126169
type ActionLinkProps =
127170
| ({ href: string; to?: never } & Omit<

apps/admin/src/features/notes/components/NoteRow.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Bookmark, BookOpen, EyeOff, Heart, MapPin } from 'lucide-react'
22
import type { NoteModel } from '~/models/note'
3+
import type { ListAction } from '~/ui/list-actions'
34
import type { NoteMetadataUpdate } from '../types/notes'
45

56
import { WEB_URL } from '~/constants/env'
@@ -13,10 +14,11 @@ import { buildNotePublicPath, formatCompactNumber } from '../utils/format'
1314
import { buildNoteMenuItems } from './buildNoteMenuItems'
1415

1516
export function NoteRow(props: {
17+
actions: ReadonlyArray<ListAction<NoteModel>>
1618
note: NoteModel
17-
onDelete: (id: string) => void
1819
onMetadataChange: (id: string, data: NoteMetadataUpdate) => void
1920
onPublishChange: (id: string, isPublished: boolean) => void
21+
onSelect: (id: string, mode: 'single' | 'toggle' | 'range') => void
2022
onSelectedChange: (checked: boolean) => void
2123
selected: boolean
2224
}) {
@@ -28,13 +30,10 @@ export function NoteRow(props: {
2830

2931
const menuItems = () =>
3032
buildNoteMenuItems(note, {
33+
actions: props.actions,
3134
externalHref: publicHref,
3235
onBookmarkToggle: (next) =>
3336
props.onMetadataChange(note.id, { bookmark: next }),
34-
onDelete: () => props.onDelete(note.id),
35-
onEdit: () => {
36-
window.location.hash = `#${editPath}`
37-
},
3837
onMoodChange: (next) => props.onMetadataChange(note.id, { mood: next }),
3938
onPublishToggle: (next) => props.onPublishChange(note.id, next),
4039
onWeatherChange: (next) =>
@@ -44,6 +43,7 @@ export function NoteRow(props: {
4443
return (
4544
<ContentEntryListItem
4645
checkboxLabel={`选择手记「${title}」`}
46+
dataId={note.id}
4747
editTitle="编辑手记"
4848
editTo={editPath}
4949
externalHref={publicHref}
@@ -94,6 +94,7 @@ export function NoteRow(props: {
9494
</time>
9595
</>
9696
}
97+
onSelect={(mode) => props.onSelect(note.id, mode)}
9798
onSelectedChange={props.onSelectedChange}
9899
openTitle="打开手记"
99100
selected={props.selected}

0 commit comments

Comments
 (0)