Skip to content

Commit 96b76b3

Browse files
authored
feat: implement navigation on mousedown (#459)
1 parent 04d4e04 commit 96b76b3

15 files changed

Lines changed: 103 additions & 22 deletions

File tree

.agents/skills/tamery-ui/patterns.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- **Copy**: kit `CopyButton` (copy→check morph); silent `copy(text)` when the morph is confirmation enough.
1717
- **Interactive icons — app-wide rule**: hover surface (`ghost` icon Button + `hover:bg-foreground/10`), icon `muted → foreground`, `Tooltip` naming the action. Toggles (pin/unpin) swap icon on button hover (`group/pin` + hidden/block pair). In cmdk rows, reveal via `group-data-selected/command-item:opacity-100` (keyboard too) and wrap the button in `CommandShortcut`. Reference: `ResourceRow` in `connections-list.tsx`. Upgrade bare icons whenever touching a file.
1818
- **Count badges**: absolute `-top-1.5 -right-1.5 h-4 min-w-4 rounded-full bg-primary text-2xs`; Button needs `relative overflow-visible`.
19+
- **Nav fires on mousedown, not click** — for instant (Linear-style) feel, route navigation activates on press. **Always import `Link` from `~/components/link.tsx`, never from `@tanstack/react-router`** (the two wrappers `SidebarLink`/`ConnectionResourceLink` already route through it). It's a `createLink` wrapper whose anchor fires a synthetic `.click()` on mousedown, then swallows the real follow-up click in the capture phase — telling the two apart by `e.detail` (synthetic `.click()` and keyboard Enter are `detail 0`; a real mouse click is `detail >= 1`). Guards preserve cmd/ctrl/shift/alt-click (open-in-new-tab) and skip `target=_blank`. **Menus are the exception** — base-ui `DropdownMenuItem render={<Link/>}` must pass `activateOn="click"` to keep native mouseup activation (macOS menu semantics). For imperative press-nav on plain buttons/tabs (not `<Link>`), spread `pressNavProps(fire)` from `~/lib/press-nav.ts` (`onMouseDown` on plain press + `onClick` keyboard fallback via `detail 0`); if the button has a nested click target (e.g. a tab's close ✕), give that child `onMouseDown={e => e.stopPropagation()}` so the press doesn't navigate first. Reference: `tab-bar.tsx` `Tab`.
1920
- **Sidebar-style nav rows** (definitions nav, dialog rails): `SidebarLink`/`SidebarButton` from `~/components/sidebar-link.tsx``h-7 rounded-md text-sm`, solid `bg-primary` active.
2021
- **Unified filter field** (`table/-components/toolbar/filter-search-bar.tsx`): input-styled container (`min-h-8 flex-wrap bg-input rounded-xl`, `has-[input:focus]` ring) with chips inline + bare `CommandPrimitive.Input`. Suggestion panel opens upward (`absolute bottom-full mb-2 bg-popover rounded-xl p-1`, kit `CommandList` in unstyled `CommandPrimitive`); `onMouseDown preventDefault` keeps focus. Stage machine column → operator → value; pending chip styled like committed chips minus eye/✕. Value stage adds "Suggested values" (enums via `column.availableValues`, booleans true/false) — pick applies immediately, or toggles into the comma list for `isArray`. Backspace on empty steps back / pops last chip; ⌘F focuses; Escape cancels stage then blurs. Chips are `h-5 bg-[color-mix(in_oklch,var(--input),var(--foreground)_4%)] shadow-2xs` (search icon gets `mr-1` so chips/text don't crowd it) — the foreground-mix over the field's own surface reads slightly darker in light and slightly lighter in dark (`bg-background` was rejected: darker than the field in dark mode).
2122
- **Command palette** (`routes/-components/actions-center.tsx` + kit `CommandDialog`): shell is `top-[16svh] translate-y-0` with `max-h-[min(35rem,calc(84svh-2rem))] flex flex-col sm:max-w-xl` so it never overflows short viewports; surface is `bg-background` (from DialogContent), inner `Command` gets `bg-transparent p-0 min-h-0 flex-1`. Header is a borderless large input row (`border-b px-4`, search icon + bare `CommandPrimitive.Input` `h-12 text-base`), not the boxed kit `CommandInput`. List: `flex-1 max-h-none scroll-fade scroll-py-2 p-1` (kit default `max-h-72` must be overridden). Footer: `border-t px-4 py-2 text-2xs` kbd hints. Item icons rely on kit auto `size-4` — pass `text-muted-foreground` only.

apps/app/src/components/link.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// oxlint-disable jsx-a11y/anchor-has-content, jsx-a11y/no-static-element-interactions
2+
import type { LinkComponent } from '@tanstack/react-router'
3+
import { createLink } from '@tanstack/react-router'
4+
import type { ComponentProps } from 'react'
5+
import { forwardRef, useRef } from 'react'
6+
7+
import { isPlainPress } from '~/lib/press-nav'
8+
9+
interface PressAnchorProps extends ComponentProps<'a'> {
10+
activateOn?: 'press' | 'click'
11+
}
12+
13+
const PressAnchor = forwardRef<HTMLAnchorElement, PressAnchorProps>(
14+
({ activateOn = 'press', onMouseDown, onClickCapture, ...props }, ref) => {
15+
const pressNavRef = useRef(false)
16+
17+
if (activateOn === 'click') {
18+
return <a ref={ref} onMouseDown={onMouseDown} onClickCapture={onClickCapture} {...props} />
19+
}
20+
21+
return (
22+
<a
23+
ref={ref}
24+
{...props}
25+
onMouseDown={e => {
26+
onMouseDown?.(e)
27+
if (e.defaultPrevented || !isPlainPress(e)) return
28+
if (props.target && props.target !== '_self') return
29+
pressNavRef.current = true
30+
e.currentTarget.click()
31+
}}
32+
onClickCapture={e => {
33+
onClickCapture?.(e)
34+
if (e.detail === 0) return
35+
if (pressNavRef.current) {
36+
pressNavRef.current = false
37+
e.preventDefault()
38+
e.stopPropagation()
39+
}
40+
}}
41+
/>
42+
)
43+
},
44+
)
45+
46+
const CreatedLink = createLink(PressAnchor)
47+
48+
export const Link: LinkComponent<typeof PressAnchor> = props => <CreatedLink {...props} />

apps/app/src/components/sidebar-link.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { cn } from '@tamery/ui/lib/utils'
22
import type { LinkProps } from '@tanstack/react-router'
3-
import { Link } from '@tanstack/react-router'
43
import type { ComponentProps } from 'react'
54

5+
import { Link } from '~/components/link'
6+
67
const baseClasses = `
78
flex h-7 w-full cursor-default items-center gap-2 rounded-md px-2 text-sm
89
text-foreground select-none
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import type { LinkProps } from '@tanstack/react-router'
2-
import { Link } from '@tanstack/react-router'
32
import type { ComponentProps } from 'react'
43

4+
import { Link } from '~/components/link'
5+
56
import { useConnectionResourceLinkParams } from '../hooks'
67

78
export function ConnectionResourceLink({
89
resourceId,
910
...props
10-
}: { resourceId: string } & Omit<LinkProps, 'to' | 'params' | 'search'> & ComponentProps<'a'>) {
11+
}: { resourceId: string; activateOn?: 'press' | 'click' } & Omit<
12+
LinkProps,
13+
'to' | 'params' | 'search'
14+
> &
15+
ComponentProps<'a'>) {
1116
const params = useConnectionResourceLinkParams(resourceId)
1217
return <Link {...params} {...props} />
1318
}

apps/app/src/entities/connection/components/react-flow-node.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import {
1010
import { Button } from '@tamery/ui/components/button'
1111
import { Tooltip, TooltipContent, TooltipTrigger } from '@tamery/ui/components/tooltip'
1212
import { cn } from '@tamery/ui/lib/utils'
13-
import { Link } from '@tanstack/react-router'
1413
import type { Edge, Node, NodeProps } from '@xyflow/react'
1514
import { Handle, Position } from '@xyflow/react'
1615

16+
import { Link } from '~/components/link'
17+
1718
import type { Column } from './table/cell'
1819

1920
export type NodeType = Node<

apps/app/src/entities/connection/components/table/cell/cell-table.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { DEFAULT_COLUMN_WIDTH } from '@tamery/table/constants'
77
import { Badge } from '@tamery/ui/components/badge'
88
import { Button } from '@tamery/ui/components/button'
99
import { useInfiniteQuery } from '@tanstack/react-query'
10-
import { getRouteApi, Link } from '@tanstack/react-router'
10+
import { getRouteApi } from '@tanstack/react-router'
1111

12+
import { Link } from '~/components/link'
1213
import { resourceRowsQueryInfiniteOptions } from '~/entities/connection/queries'
1314
import { createTransformer } from '~/entities/connection/transformers'
1415
import { TableError } from '~/routes/_protected/connection/$resourceId/table/-components/table/table'

apps/app/src/lib/press-nav.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { MouseEvent } from 'react'
2+
3+
export function isPlainPress(e: MouseEvent) {
4+
return e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
5+
}
6+
7+
export function pressNavProps(fire: () => void) {
8+
return {
9+
onMouseDown: (e: MouseEvent) => {
10+
if (isPlainPress(e)) fire()
11+
},
12+
onClick: (e: MouseEvent) => {
13+
if (e.detail === 0) fire()
14+
},
15+
}
16+
}

apps/app/src/routes/_protected/-components/connections-list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import { copy } from '@tamery/ui/lib/copy'
3232
import { cn } from '@tamery/ui/lib/utils'
3333
import { caseWhen, eq, useLiveQuery } from '@tanstack/react-db'
3434
import { useQuery } from '@tanstack/react-query'
35-
import { Link } from '@tanstack/react-router'
3635
import { type } from 'arktype'
3736
import { AnimatePresence, motion } from 'motion/react'
3837
import type { ComponentRef } from 'react'
@@ -43,6 +42,7 @@ import { toast } from 'sonner'
4342

4443
import type { AppMenuNode } from '~/components/app-context-menu'
4544
import { AppContextMenu } from '~/components/app-context-menu'
45+
import { Link } from '~/components/link'
4646
import { useCollections } from '~/entities/collections'
4747
import type { Connection } from '~/entities/connection'
4848
import {

apps/app/src/routes/_protected/-components/last-opened-resources.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { RiCloseLine } from '@remixicon/react'
22
import { Button } from '@tamery/ui/components/button'
33
import { Tooltip, TooltipContent, TooltipTrigger } from '@tamery/ui/components/tooltip'
44
import { eq, useLiveQuery } from '@tanstack/react-db'
5-
import { Link } from '@tanstack/react-router'
65
import { useSubscription } from 'seitu/react'
76

7+
import { Link } from '~/components/link'
88
import { useCollections } from '~/entities/collections'
99
import { ConnectionIcon } from '~/entities/connection/components'
1010
import type { Connection, ConnectionResource } from '~/entities/connection/core'

apps/app/src/routes/_protected/-components/protected-titlebar.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import {
2121
} from '@tamery/ui/components/dropdown-menu'
2222
import { Tooltip, TooltipContent, TooltipTrigger } from '@tamery/ui/components/tooltip'
2323
import { eq, useLiveQuery } from '@tanstack/react-db'
24-
import { Link, useNavigate, useParams } from '@tanstack/react-router'
24+
import { useNavigate, useParams } from '@tanstack/react-router'
2525
import type { ComponentRef } from 'react'
2626
import { useRef, useState } from 'react'
2727
import { useSubscription } from 'seitu/react'
2828

29+
import { Link } from '~/components/link'
2930
import { TitleBar } from '~/components/title-bar'
3031
import { UpdateButton } from '~/components/update-button'
3132
import { useCollections } from '~/entities/collections'
@@ -84,7 +85,7 @@ function ConnectionSubMenu({
8485
{resources.map(resource => (
8586
<DropdownMenuItem
8687
key={resource.id}
87-
render={<ConnectionResourceLink resourceId={resource.id} />}
88+
render={<ConnectionResourceLink resourceId={resource.id} activateOn="click" />}
8889
>
8990
<span data-mask className="truncate">
9091
{resource.name || CONNECTION_RESOURCE_ROOT_LABEL}
@@ -154,7 +155,7 @@ function ConnectionsDropdown({
154155
)
155156
})}
156157
<DropdownMenuSeparator />
157-
<DropdownMenuItem render={<Link to="/create" />}>
158+
<DropdownMenuItem render={<Link to="/create" activateOn="click" />}>
158159
<RiAddLine className="size-4 shrink-0" />
159160
Add new connection
160161
</DropdownMenuItem>
@@ -184,7 +185,7 @@ function ResourcesDropdown({
184185
{resources.map(resource => (
185186
<DropdownMenuItem
186187
key={resource.id}
187-
render={<ConnectionResourceLink resourceId={resource.id} />}
188+
render={<ConnectionResourceLink resourceId={resource.id} activateOn="click" />}
188189
>
189190
<span data-mask className="truncate">
190191
{resource.name || CONNECTION_RESOURCE_ROOT_LABEL}

0 commit comments

Comments
 (0)