@@ -13,15 +13,22 @@ import { ContextMenuTrigger, showContextMenu } from '~/ui/overlay/context-menu'
1313import { Checkbox } from '~/ui/primitives/checkbox'
1414import { cn } from '~/utils/cn'
1515
16+ export type ContentEntrySelectMode = 'single' | 'toggle' | 'range'
17+
1618export 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 */
4356export 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
123166const 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
126169type ActionLinkProps =
127170 | ( { href : string ; to ?: never } & Omit <
0 commit comments