Commit 53cf145
feat(spa): column lock / freeze — sticky columns + contiguous-prefix invariant
A new column-lock feature on the list page. The pk column was already
the "permanently first" column; this lets the operator extend that
sticky region to any contiguous prefix of the column list — locked
columns stay visible when the table scrolls horizontally, work as
frozen columns, and disable drag-and-drop until unlocked.
UX summary
----------
- Layout modal's pk row already showed a lock icon; that stays.
- Every non-pk row gets a lock button next to its drag handle.
Click locks; the contiguous-prefix invariant kicks in automatically.
- Locked rows render above the sortable block with their own
"unlock" button. Drag handle is removed entirely — to drag a
locked column, unlock it first.
- The Reset button (#590) now also clears the locked set, so a
one-click reset goes back to the ModelAdmin's registered default
(which today is "only pk locked").
Contiguous-prefix invariant (the load-bearing rule)
---------------------------------------------------
Locked columns MUST form a contiguous prefix from the pk. If they
weren't contiguous, the Table primitive's sticky-cluster on the left
would have visible gaps where unlocked non-sticky columns scrolled
underneath the next locked one — a broken-looking frozen header.
Two pure-function helpers in ColumnLayoutModal.tsx enforce that on
each click:
- `lockThrough(ordered, name)` — locks every non-pk column from
the start to `name` inclusive. Used on a Lock click.
- `unlockFrom(ordered, locked, name)` — unlocks `name` and every
column to its right (the ones above stay whatever they were).
Used on an Unlock click.
9 new unit tests pin the semantics: locking through, the
first-and-last-column edges, the round-trip lock-then-unlock,
the keep-earlier-locked-entries-unchanged case, and the
"target not in the order" defensive path.
Table primitive — frozen-columns rendering (@dar/ui)
---------------------------------------------------
New `sticky?: boolean` on `TableColumn`. When set:
- The header `<th>` and every body `<td>` get
`position: sticky; left: <px>; z-index: 10` + a background colour
so scrolling content doesn't bleed through.
- The pixel offset is measured via `useLayoutEffect` over the
header refs and accumulated left-to-right, so widths can be
whatever the column actually rendered at (no required fixed
width). Selection checkbox column counts as the first sticky
column when `selectable` is set.
- The LAST sticky column gets a subtle right-edge shadow to
visually separate the frozen block from the scrolling content.
- Sticky cells use `bg-white` (which has a `.dark` remap so the
dark mode is automatic) for the header `bg-gray-50` (also
remapped).
Hooks were carefully placed BEFORE the empty-state early return so
the rules-of-hooks linter stays happy across loading / empty /
loaded paths.
Persistence
-----------
New `lockedColsKey(app, model)` localStorage key
(`dar:colslocked:<app>:<model>`) — a `Set<string>` of LOCKED non-pk
column names (pk is implicitly locked, never stored). Persisted via
the existing `usePersistedSet` from @dar/customization, alongside
the saved order (`dar:colorder:v1:*`) and the hidden-columns set
(`dar:cols:*`). All three preferences evolve independently.
Mobile no-op
------------
The mobile `RecordCardList` rendering uses a stacked card layout
with no horizontal scroll — the `sticky` flag is meaningless there
and is naturally ignored (only the desktop `Table` primitive reads
it). Tablet falls in the desktop bucket since it still scrolls
horizontally when there are enough columns.
Bundle impact
-------------
- Main JS: 266.14 → 267.31 kB (+1.2 kB raw / +0.4 kB gz) for the
sticky-offset measurement + lockedCols wiring on the page side.
- ColumnLayoutModal lazy chunk: 48.79 → 50.69 kB (+1.9 kB raw /
+0.5 kB gz) for the lock buttons + the LockedRow variants + the
contiguous-prefix invariant helpers.
- CSS: 30.86 → 31.37 kB (+0.5 kB) for the sticky-shadow utility.
- No new runtime dep — `Lock` and `Unlock` already exist in lucide
via existing imports.
Tests
-----
- Frontend: 154 → 163 tests (+9 for the lock helpers).
- Backend: unchanged at 56 passed (no backend code change).
No API contract / schema / settings change. Minor bump
(1.3.4 → 1.4.0) because of the new public lock UI surface and
storage key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent 2cb02d5 commit 53cf145
7 files changed
Lines changed: 421 additions & 80 deletions
File tree
- frontend
- apps/web/src
- pages
- packages
- customization/src
- ui/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
0 commit comments