Files
twenty/packages
Félix Malfait 8990334cad Make record chips open records natively on touch devices (#23424)
Two mobile problems in the record table: tapping a chip in the first
column takes two taps, and chips in every other column open a side panel
where a full page is wanted. #23422 is stacked on this branch.

## Two taps to open a record

The table's interactive layer lives in a hover portal mounted from
`onMouseMove` on the table wrapper. Touch has no hover, so the browser
fakes one, and the synthesised `mousemove` arrives *before* the
`mousedown`. React commits the portal in the microtask between them, so
the whole tap is hit-tested against a subtree that did not exist when
the user aimed.

Confirmed in Chromium with real touch input (`page.tap`, Pixel 5
emulation), mounting an overlay from the mousemove handler:

```
mousemove target=chip
>>> overlay mounted          <- the hover portal
mousedown target=portalChip  <- a node that did not exist when the finger went down
mouseup   target=portalChip
click     target=portalChip
```

The same test also ruled out `preventDefault` on the compat `mousedown`
as a cause, and showed a `setTimeout`-deferred mount does *not* retarget
— it is specifically React's sync flush timing that does.

So hover state is now only tracked on hover-capable pointers.
`useMoveHoverToCurrentCell` becomes the single writer and absorbs the
deduplication `RecordTableContent` was duplicating inline.

The interaction/layout split matters here: `useIsMobile` is a 768px
width query, which answers "how much room is there to lay out", not "how
does this person point". The new `useIsTouchDevice` uses `(hover: none)
and (pointer: coarse)`. Layout keeps using width; interaction uses
capability.

## Side panel on mobile

"Where does a record open" was computed independently in six places and
only `useOpenRecordFromIndexView` knew about mobile. `RecordChip` —
every chip outside the first column, plus board cards and relation
fields — had its own copy without that check. On mobile the side panel
animates to `fullScreen`, so it is a full-page view with no URL and no
back button.

That decision now lives in one `resolveOpenRecordIn`: the view setting
is an intent, and the side panel is only a real destination when there
is room for it and the object supports it.

Also here: `MOUSE_DOWN` navigation downgrades to `CLICK` on touch. It
only buys a frame on a real pointer, since a tap synthesises its mouse
events after the finger is already gone.

## Hover styling

Separate layer, same root cause. A tap leaves CSS `:hover` applied until
the next tap lands elsewhere, so a row you came back from keeps reading
as selected. Nine `:hover` blocks across the record table, `Chip` and
`Avatar` are now fenced behind `(hover: hover)` — the same media feature
`useIsTouchDevice` branches on, via a new `hover-capable` SCSS mixin on
the twenty-ui side and inline media queries in the Linaria components.

Desktop rendering is unchanged, since Chrome matches `hover: hover`.
Verified the built CSS emits the wrapper correctly, and checked the
nested form through stylis directly for the Linaria side.

## Testing

- New unit tests for `resolveOpenRecordIn` and for hover not being
tracked on touch devices.
- Full frontend suite: 951 suites, 5598 tests passing. Typecheck and
lint clean.
- Not observed end to end in a running app: no database in this
environment, and the `RecordIndexPage` story renders an empty table
under its msw mocks. The browser-level mechanism is verified and the fix
removes the mid-gesture DOM change, but it is worth one pass on a real
device before merge.

## Follow-ups not in this PR

- The whole first cell navigates but only the chip-sized part of it
gives tap feedback, and `isRecordTableRowActive` is only set on the side
panel path — setting it on the navigate path too would keep the row lit
while the page loads.
- Rows are 32px against a 44px minimum touch target.
- Giving the side panel a URL would make "panel vs page" a rendering
decision on the same location, rather than something each call site has
to branch on.


---
_Generated by [Claude
Code](https://claude.ai/code/session_019cDWPgWESbdRUhGxGb66j8)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23424?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-28 22:44:36 +02:00
..