@@ -10,7 +10,8 @@ import type {
1010} from 'react'
1111
1212import { useI18n } from '~/i18n'
13- import { ContextMenuTrigger , showContextMenu } from '~/ui/overlay/context-menu'
13+ import { ListRow } from '~/ui/list-actions'
14+ import { showContextMenu } from '~/ui/overlay/context-menu'
1415import { Checkbox } from '~/ui/primitives/checkbox'
1516import { cn } from '~/utils/cn'
1617
@@ -19,17 +20,16 @@ export type ContentEntrySelectMode = 'single' | 'toggle' | 'range'
1920export interface ContentEntryListItemProps {
2021 checkboxLabel ?: string
2122 className ?: string
22- /** Stable id of the underlying entity. Exposed on the row as `data-id`. */
23+ /** Stable id of the underlying entity. */
2324 dataId ?: string
2425 editTitle : string
2526 editTo : string
2627 externalHref : string
28+ /** Visual leading slot rendered inline with the title (icon, pin, etc.). */
2729 leading ?: ReactNode
2830 menuItems : ContextMenuItem [ ] | ( ( ) => ContextMenuItem [ ] )
2931 meta ?: ReactNode
30- /** Called when the row body is clicked (away from links / buttons / checkbox). */
3132 onSelect ?: ( mode : ContentEntrySelectMode ) => void
32- /** Called when the checkbox itself is toggled. */
3333 onSelectedChange ?: ( checked : boolean ) => void
3434 openTitle : string
3535 selected ?: boolean
@@ -38,21 +38,19 @@ export interface ContentEntryListItemProps {
3838 titleTo : string
3939}
4040
41- const INTERACTIVE_SELECTOR =
42- 'a[href], button, input, [role="menuitem"], [role="checkbox"]'
43-
4441/**
4542 * Two-line list item shared by `/posts` and `/notes`.
4643 *
4744 * ☐ [leading] Title text [status] ✎ ↗ ⋯
4845 * category · tags · 👁 N · ♡ N relative time
4946 *
50- * The whole row is wrapped in `<ContextMenuTrigger>`; right-clicking
51- * anywhere on the row opens the menu and the row receives
52- * `data-popup-open=""` so it can highlight via the `data-[popup-open]:`
53- * variant. Left-clicking the row body (outside links, buttons, checkbox)
54- * fires `onSelect` with a modifier-aware mode so consumers can do
55- * single / toggle (`$mod`+click) / range (`Shift`+click) selection.
47+ * Delegates row keyboard/selection/context-menu plumbing to `ListRow`. Owns the
48+ * checkbox column + title link + status badge + meta line + edit/external/more
49+ * action button trio.
50+ *
51+ * Note: `props.leading` is the *visual* leading slot rendered next to the title
52+ * (e.g. a pin icon). It is distinct from `ListRow.leading`, which holds the
53+ * selection-column checkbox.
5654 */
5755export function ContentEntryListItem ( props : ContentEntryListItemProps ) {
5856 const { t } = useI18n ( )
@@ -68,103 +66,82 @@ export function ContentEntryListItem(props: ContentEntryListItemProps) {
6866 showContextMenu ( resolved )
6967 }
7068
71- const onRowClick = ( event : ReactMouseEvent < HTMLElement > ) => {
72- if ( event . defaultPrevented ) return
73- if ( ! props . onSelect ) return
74- const target = event . target as Element | null
75- // Skip clicks on links, buttons, inputs, the checkbox — those interactive
76- // descendants do their own thing.
77- if ( target ?. closest ( INTERACTIVE_SELECTOR ) ) return
78- const mode : ContentEntrySelectMode = event . shiftKey
79- ? 'range'
80- : event . metaKey || event . ctrlKey
81- ? 'toggle'
82- : 'single'
83- props . onSelect ( mode )
84- }
85-
8669 return (
87- < ContextMenuTrigger items = { props . menuItems } >
88- < article
89- className = { cn (
90- 'group grid cursor-default gap-x-3 gap-y-1.5 px-4 py-3' ,
91- selectable
92- ? 'grid-cols-[auto_minmax(0,1fr)_auto]'
93- : 'grid-cols-[minmax(0,1fr)_auto]' ,
94- // Vercel/Geist convention: selection lives on the neutral scale,
95- // brand color stays out of row backgrounds. Hover < popup-open ≈
96- // selected < selected+hover < selected+popup-open.
97- 'hover:bg-neutral-50 dark:hover:bg-neutral-900/50' ,
98- 'data-popup-open:bg-neutral-100 dark:data-popup-open:bg-neutral-800/60' ,
99- 'data-selected:bg-neutral-100 dark:data-selected:bg-neutral-800/60' ,
100- 'data-selected:hover:bg-neutral-200/60 dark:data-selected:hover:bg-neutral-800/80' ,
101- 'data-selected:data-popup-open:bg-neutral-200 dark:data-selected:data-popup-open:bg-neutral-800' ,
102- // Keyboard cursor (J/K/Arrow). `:focus-visible` only fires on
103- // keyboard focus, so mouse clicks don't draw the ring.
104- 'outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-400 dark:focus-visible:outline-neutral-500' ,
105- props . className ,
106- ) }
107- data-id = { props . dataId }
108- data-scope-item = "row"
109- data-selected = { props . selected ? '' : undefined }
110- onClick = { onRowClick }
111- tabIndex = { - 1 }
112- >
113- { selectable ? (
70+ < ListRow
71+ as = "article"
72+ className = { cn (
73+ 'group grid cursor-default gap-x-3 gap-y-1.5 px-4 py-3' ,
74+ selectable
75+ ? 'grid-cols-[auto_minmax(0,1fr)_auto]'
76+ : 'grid-cols-[minmax(0,1fr)_auto]' ,
77+ 'hover:bg-neutral-50 dark:hover:bg-neutral-900/50' ,
78+ 'data-popup-open:bg-neutral-100 dark:data-popup-open:bg-neutral-800/60' ,
79+ 'data-selected:bg-neutral-100 dark:data-selected:bg-neutral-800/60' ,
80+ 'data-selected:hover:bg-neutral-200/60 dark:data-selected:hover:bg-neutral-800/80' ,
81+ 'data-selected:data-popup-open:bg-neutral-200 dark:data-selected:data-popup-open:bg-neutral-800' ,
82+ 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-400 dark:focus-visible:outline-neutral-500' ,
83+ props . className ,
84+ ) }
85+ dataId = { props . dataId ?? '' }
86+ leading = {
87+ selectable ? (
11488 < Checkbox
11589 aria-label = { props . checkboxLabel }
11690 checked = { props . selected ?? false }
11791 className = "mt-0.5"
11892 onCheckedChange = { handleSelectedChange }
11993 />
120- ) : null }
121-
122- < div className = "min-w-0" >
123- < div className = "flex min-w-0 items-center gap-2" >
124- { props . leading }
125- < Link
126- 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"
127- to = { props . titleTo }
128- >
129- { props . title }
130- </ Link >
131- { props . status }
132- </ div >
133- { props . meta ? (
134- < div className = "mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-500 dark:text-neutral-400" >
135- { props . meta }
136- </ div >
137- ) : null }
94+ ) : null
95+ }
96+ menuItems = { props . menuItems }
97+ onSelect = { props . onSelect }
98+ selected = { props . selected }
99+ >
100+ < div className = "min-w-0" >
101+ < div className = "flex min-w-0 items-center gap-2" >
102+ { props . leading }
103+ < Link
104+ 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"
105+ to = { props . titleTo }
106+ >
107+ { props . title }
108+ </ Link >
109+ { props . status }
138110 </ div >
111+ { props . meta ? (
112+ < div className = "mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-500 dark:text-neutral-400" >
113+ { props . meta }
114+ </ div >
115+ ) : null }
116+ </ div >
139117
140- < div
141- className = { cn (
142- 'row-start-1 flex shrink-0 items-center gap-0.5 self-start text-neutral-300' ,
143- 'group-hover:text-neutral-600 group-data-[popup-open]:text-neutral-600 group-data-[selected]:text-neutral-600' ,
144- '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' ,
145- selectable ? 'col-start-3' : 'col-start-2' ,
146- ) }
118+ < div
119+ className = { cn (
120+ 'row-start-1 flex shrink-0 items-center gap-0.5 self-start text-neutral-300' ,
121+ 'group-hover:text-neutral-600 group-data-[popup-open]:text-neutral-600 group-data-[selected]:text-neutral-600' ,
122+ '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' ,
123+ selectable ? 'col-start-3' : 'col-start-2' ,
124+ ) }
125+ >
126+ < ActionLink title = { props . editTitle } to = { props . editTo } >
127+ < Pencil aria-hidden = "true" className = "size-4" />
128+ </ ActionLink >
129+ < ActionLink
130+ href = { props . externalHref }
131+ rel = "noreferrer"
132+ target = "_blank"
133+ title = { props . openTitle }
147134 >
148- < ActionLink title = { props . editTitle } to = { props . editTo } >
149- < Pencil aria-hidden = "true" className = "size-4" />
150- </ ActionLink >
151- < ActionLink
152- href = { props . externalHref }
153- rel = "noreferrer"
154- target = "_blank"
155- title = { props . openTitle }
156- >
157- < ExternalLink aria-hidden = "true" className = "size-4" />
158- </ ActionLink >
159- < ActionButton
160- onClick = { onMoreClick }
161- title = { t ( 'shared.contentListItem.moreActions' ) }
162- >
163- < MoreHorizontal aria-hidden = "true" className = "size-4" />
164- </ ActionButton >
165- </ div >
166- </ article >
167- </ ContextMenuTrigger >
135+ < ExternalLink aria-hidden = "true" className = "size-4" />
136+ </ ActionLink >
137+ < ActionButton
138+ onClick = { onMoreClick }
139+ title = { t ( 'shared.contentListItem.moreActions' ) }
140+ >
141+ < MoreHorizontal aria-hidden = "true" className = "size-4" />
142+ </ ActionButton >
143+ </ div >
144+ </ ListRow >
168145 )
169146}
170147
0 commit comments