Skip to content

Modal-to-native-dialog: PkDialog + BulkSelect + list-UI toolkit - #568

Merged
ddon merged 25 commits into
BeamLabEU:mainfrom
mdon:modal-to-native-dialog
May 25, 2026
Merged

Modal-to-native-dialog: PkDialog + BulkSelect + list-UI toolkit#568
ddon merged 25 commits into
BeamLabEU:mainfrom
mdon:modal-to-native-dialog

Conversation

@mdon

@mdonmdon commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Switch <.modal> from <div class="modal"> to a native <dialog> with the new PkDialog JS hook. Adds a keep_in_dom mode for client-driven instant-open (the kept-in-DOM dialog stays in the tree, data-show flips visibility).
  • New core list-UI toolkit: <.bulk_select_scope> + <.bulk_select_header_cell> + <.bulk_select_cell> + <.bulk_actions_toolbar> (client-side selection via BulkSelectScope hook), <.sortable_tbody> + <.sortable_row>, <.drag_handle_cell> + <.drag_handle_header_cell> on <.table_default>, <.reorder_modal> strategy picker, <.load_more> pagination footer.
  • <.sort_selector> race-free design (select sends only sort_by, arrow sends only sort_dir; LV handler derives the missing half from assigns).

Critical bug fixed in this PR

When BulkSelectScope opened the reorder dialog via showModal() for instant-open, Phoenix LV's DOM patcher then stripped the browser-added open attribute on the next re-render. The dialog stayed in the top-layer (still capturing all clicks) while CSS dialog:not([open]) rendered it display: none — visually closed but blocking the rest of the page.

PkDialog._sync() now uses the :modal pseudo-class as the truth source instead of el.open; restores the stripped attribute before close() so the top-layer is actually released. _closeFromLV flag suppresses the redundant close echo when LV initiated the close. :modal is wrapped in try/catch with el.open fallback for engines that lack the pseudo-class.

BulkSelectScope.updated() now preserves the in-memory selection Set against the freshly-rendered DOM, so checked rows survive apply_reorder / load_more / sort changes. Rows whose uuids are no longer in the DOM drop out. Assigns checked=true OR false explicitly so a recycled input node can't leak stale state.

Polish

Tests

  • 6 new core component test files + drag_handle additions to table_default_test.exs (~80 render tests pinning bulk_select toolkit, sortable, reorder_modal, load_more, sort_selector, modal keep_in_dom branch, drag_handle hide-on-hover classes).
  • media_gallery_test.exs: 2 stale max_count tests aligned to current omit-vs-disable behavior.

Test plan

  • mix test passes (1443 tests + 11 doctests).
  • mix precommit passes (format + compile + credo --strict + dialyzer).
  • Manual: open the bulk-action reorder modal in phoenix_kit_projects, apply a strategy, verify the page is fully clickable afterwards (the bug this PR fixes).

🤖 Generated with Claude Code

mdon added 25 commits May 25, 2026 10:46
Per dev_docs/quality_sweep.md Phase 1 playbook: every merged PR's
folder gets a FOLLOW_UP.md documenting how each review finding was
resolved (or skipped with rationale).
PR BeamLabEU#565 review findings:
- NITPICK BeamLabEU#2 (trailing-empty asymmetry) — fixed post-merge in 79f6dc5
- MEDIUM (extraction divergence comment) — fixed post-merge in 79f6dc5
- NITPICK BeamLabEU#3 (show_info gated on show_header) — skipped, doc-only resolution
No new code changes — this is the after-action artifact only.
Native <dialog> renders in the browser top layer with a real
::backdrop pseudo-element, gets native Esc handling and focus trap,
and matches daisyUI 5's recommended pattern. The old div-based modal
left a visible 15px strip on the right of the viewport: daisyUI 5
ships a `:where(:root:has(.modal[open])) { scrollbar-gutter: stable }`
rule that reserves a scrollbar gutter on <html> when any modal is
open, which reduces the layout viewport that `position: fixed; inset: 0`
resolves against. The strip showed through where the backdrop didn't
reach.
Public API of <.modal> is unchanged: same attrs (show, on_close, id,
max_width, max_height, class, closeable, backdrop_class), same slots
(:title, :inner_block, :actions). Conditional rendering on @show is
preserved so consumers whose inner_block depends on assigns that
only exist while the modal is shown (e.g. roles.html.heex's @Form)
keep working.
When the consumer doesn't pass an explicit id (e.g. confirm_modal
callers in catalogue), an id is derived from the on_close event name
(pk-modal-<close_event>). phx-hook requires a unique element id and
this keeps it stable across renders of the same modal while staying
unique among different modals on the same page.
The existing PkDialog hook in phoenix_kit.js is extended to:
- Override html's scrollbar-gutter to auto while any modal is open
(refcounted across concurrent modals, restored on last close) so
the dialog can cover the full visual viewport.
- Handle backdrop click via `event.target === el` on the dialog.
- Use the 'close' event as the single cleanup point. A _closeFromLV
flag in destroyed() distinguishes LV-driven teardown (don't echo)
from user-driven closes (Esc/backdrop — echo via pushClose so server
state syncs).
Drops the explicit `phx-window-keydown` Esc handler (native cancel
event covers it) and the custom backdrop <div> (native ::backdrop).
HTML has no attribute form of the checkbox `indeterminate` state — the
property has to be assigned via JS. The hook reads `data-indeterminate`
on mount and after each LV patch and reflects it on the element.
Used by table headers that show "all / some / none selected" via a
single checkbox.
Three function components for bulk-selectable admin tables:
* <.bulk_select_header_cell> — drop-in <th> with the select-all
checkbox. Cycles unchecked → indeterminate → checked via
PkCheckboxIndeterminate. Consumer wires `on_toggle`; the LV
handler decides "all" vs "none" from current count.
* <.bulk_select_cell> — drop-in <td> with the per-row checkbox.
Value is forwarded as phx-value-uuid so the consumer's handler
can identify which row was toggled.
* <.bulk_actions_toolbar> — floating toolbar above the table.
Shows selection count + Reorder/Delete/Clear buttons. Reorder
and Delete are opt-in via flags; Clear is automatic.
All three are opt-in — the consumer LV decides whether to render
them. Per-row checkboxes need consumer code anyway (row content
varies per module). The header cell + toolbar are reusable shells.
Auto-imported via phoenix_kit_web.ex so consumers using
`use PhoenixKitWeb, :live_view` get them without per-module imports.
Two issues from the post-merge codex pass on modal-to-native-dialog:
1. **PkDialog refcount drift** (was MEDIUM bug). User-driven closes
(Esc, backdrop click) used to decrement the scrollbar-gutter
refcount only via destroyed(), which fires AFTER the LV processes
the on_close event. If LV ignored, errored on, or didn't reach
that event, window._PkDialogOpenCount stayed elevated and the
`scrollbar-gutter: auto !important` override stuck on <html>.
Track `_opened` per-hook so the close listener decrements exactly
once regardless of who initiated the close. destroyed() now does a
defensive decrement (gated by _opened) for the case where the
element is already detached when destroyed runs and close() may not
fire 'close'. Whichever path runs first wins; the other no-ops.
2. **on_bulk_delete required even when allow_delete=false**. Forced
callers to wire a dead event name when they didn't want the
delete button. Made nullable with a default of nil and documented
that it's only required when allow_delete is true.
Every checkbox toggle used to be a `phx-click` round-trip to the LV
— at any meaningful network latency that's a visible lag on a
high-frequency interaction. Server learning the selection on every
click is also wasted bytes; it only needs to know at action time.
New model: the DOM owns the selection. A new `BulkSelectScope` JS
hook reads checkbox state, maintains the in-memory selection set,
and updates surrounding UI (toolbar count text, header
indeterminate state, button labels, show/hide elements) live as
the user toggles. When the user clicks an action button
(`data-bulk-action="<event>"`), the hook pushes the event to LV
with `{ uuids: [...] }` — the server gets the selection exactly
when it needs it.
Component changes:
* NEW <.bulk_select_scope> — wrapper with phx-hook="BulkSelectScope"
and data-bulk-total. Required as a sibling of the table + toolbar.
* <.bulk_select_header_cell> — pure markup (data-bulk-role="select-all").
Drops `selected_count`, `total_count`, `on_toggle` attrs (the hook
drives state).
* <.bulk_select_cell> — pure markup (data-bulk-role="row" +
data-uuid). Drops `checked`, `on_toggle`.
* <.bulk_actions_toolbar> — gettext_noop'd label templates carry
`%{count}` for the hook to substitute. data-bulk-show / data-bulk-
label-empty / data-bulk-label-selected drive live UI flips. Drops
`selected_count`, `on_toggle_select_all`, `on_clear_selection`
server-event attrs.
JS hook contract documented in the bulk_select.ex moduledoc.
The static hint icon to the right of the dropdown (hero-bars-3 in
a pointer-events-none span) was meant to suggest "you can drag
rows to reorder" — but the row drag handles themselves already
serve that affordance, and the icon next to the dropdown read as
a sort-direction indicator (which is meaningless in manual mode).
In manual mode the join now collapses to just the dropdown: the
"Manual" option in the dropdown is the entire control. Other sort
modes still get their asc/desc toggle next to the select as before.
The toolbar's left-side text used to flip between "No selection"
and "N selected" as the user toggled checkboxes. The "No selection"
state was visual noise — when nothing's selected, the action
buttons on the right communicate the available affordances on
their own and the empty caption was redundant.
Now: when count is 0, the left side of the toolbar is empty; the
buttons pin to the right via the existing ml-auto. When count > 0,
"%{count} selected" appears as before via the bulk-text-template
hook substitution.
The toolbar gains a `:leading` slot so consumers can tuck things
into the left side (notably a sort selector) and have everything
read as a single control row. The empty-state "%{count} selected"
text span is gone — when nothing's selected, the toolbar has no
left-side text and the action buttons pin to the right.
`reorder_gate` controls when the Reorder button appears:
* `:always` (default) — visible at any count. Label flips between
"Reorder all" at 0 and "Reorder N selected" at >0. Use this in
contexts where the rendered order is the canonical manual order
(e.g. sort_by=:position).
* `:multi` — hidden unless count > 1. Use this when the surrounding
context has no meaningful "reorder all" interpretation: a list
sorted by name/date is a *view* of the manual positions, and a
single-row reorder is a no-op, so the button only makes sense
with a multi-row selection.
JS hook changes:
* `data-bulk-show="has-multiple"` — new visibility mode, count > 1.
* Label-flip selector relaxed to `[data-bulk-label-selected]` (was
requiring both empty + selected). `data-bulk-label-empty` is now
optional — when absent, count=0 leaves the server-rendered text
in place, which works well for buttons gated to hide at count=0
anyway.
In :always reorder_gate mode (manual sort), the button previously
showed "Reorder 1 selected" with a single row checked — clicking
would open the modal but the permute-in-place result is just that
row in its existing slot. Confusing affordance.
Now: even in :always mode the button hides at exactly count=1.
Cycle is visible (Reorder all) → hidden → visible (Reorder 2
selected) as the user toggles checkboxes one by one.
JS hook gets a new `data-bulk-show="not-single"` mode (visible iff
count != 1) which is applied to the Reorder button in :always mode.
:multi mode still uses "has-multiple" (visible iff count > 1).
Hiding the Reorder button at count=1 was disruptive — the button
flicker as the user clicked one row, then a second to bring it
back, was worse UX than the original "Reorder 1 selected" label
this branch was trying to fix.
New design (in :always reorder_gate):
* count 0 → "Reorder all" (no selection)
* count 1 → "Reorder all" (treat single selection as ambient
for reorder purposes — the consumer LV collapses the 1-uuid
payload to :all when applying)
* count > 1 → "Reorder N selected"
JS hook label-flip threshold bumped from `> 0` to `> 1`. Affects
ALL data-bulk-label-selected consumers — but the semantics are
clearer: "N selected" only appears when a multi-selection scope
is genuinely meaningful, never with N=1.
Drops the `not-single` data-bulk-show value on the Reorder button
in :always mode (no longer needed since the button stays visible
at every count). The hook still implements `"not-single"` for any
future consumer that wants it.
The hide-until-hover drag handle pattern was hand-rolled in 4+ call
sites across the workspace (catalogue category rows, catalogue card
items, entities card view, data_navigator card view), each with its
own inline opacity-0 + group-hover:opacity-100 incantation. Same
deal for the dim-and-brighten table-view variant across catalogue,
entities, data_navigator, document_creator, ai/endpoints. The
patterns differ on which 'feels right' but they all reach for the
same primitives.
Extract a single canonical cell to core: hide-until-hover semantics
(opacity-0 default, fades in on row hover) plus the pk-drag-handle
class SortableGrid needs and the gettext'd title. Sibling
<.drag_handle_header_cell> renders the matching empty <th> so the
column widths line up without consumers having to repeat the
hardcoded `w-8` literal.
`<.table_default_row>` now adds `group` to its class list
automatically — required for the cell's `group-hover:*` selector to
fire. The marker class is benign for rows that don't have any
group-hover-styled children, so adding it unconditionally is safer
than asking every consumer to remember to wire it.
Round out the table-DnD + bulk-select toolkit so consumers don't
have to hand-write the SortableGrid hook wiring or copy the reorder
modal between modules:
* <.reorder_modal> — moved as-is from phoenix_kit_projects' web/
components/ into core. Same API (show / on_close / on_apply /
selected_count / total_count / strategies / nouns), just now
available to every module via `use PhoenixKitWeb, :live_view`.
* <.sortable_tbody> — replaces the raw
<tbody phx-hook="SortableGrid" data-sortable* ...> block that
every DnD-enabled list otherwise has to spell out by hand.
Takes `id`, `enabled` (boolean — when false renders a plain
<tbody>), and the `event` name to push on drop. Encapsulates
the data-sortable-items=".sortable-item" /
data-sortable-handle=".pk-drag-handle" conventions used by
<.sortable_row> and <.drag_handle_cell>.
* <.sortable_row> — replaces <.table_default_row class="sortable-
item" data-id={uuid}> with a typed wrapper. The `item_id` attr
becomes data-id; "sortable-item" is always added.
Both new components are imported via phoenix_kit_web.ex so they're
available alongside <.table_default>, <.drag_handle_cell>, etc.
Nothing on the call site changes behaviour — same DOM, same hook,
same selector contracts. Just less boilerplate per consumer.
Click-driven incremental loader as a counterpart to the existing
URL-param page-numbered components. Renders a centered "Showing N
of M %{noun}" line plus a "Load more" button hidden when
`loaded >= total`. Auto-suppresses when `total == 0` so consumers
don't have to gate the call site.
Fits the use cases where page-numbered pagination doesn't:
* Embeddable LVs that can't reach for URL params (no handle_params)
* Lists with DnD reorder (rows append, don't replace)
* Lists with client-side bulk-select (selection survives loads
because rows stay in the DOM)
Catalogue's hand-rolled search-results "load more" can adopt the
same component in a future pass.
Module moduledoc updated to document the two flavours (page-numbered
vs. load-more) and when to reach for each.
Quorum (Codex + Gemini) both flagged the same UX issue: rapid
double-clicking the button fires the load_more event twice before
the first reload finishes, so loaded_count ends at +100 instead of
+50. State-wise it's correct (the user gets more rows), but the
intermediate jitter is avoidable.
phx-disable-with grays the button + swaps its label to "Loading…"
the moment the click pushes, and Phoenix automatically re-enables
it after the LV ack. Low-cost belt-and-braces for the same correct
end state.
Reported: on first page load the Clear button (and Delete, and the
Reorder button in :multi mode) flash visible for a fraction of a
second before the BulkSelectScope hook's first _sync() hides them.
The gap is "server-rendered HTML has no inline display:none" → first
paint shows the buttons → LV connects → hook mounts → _sync() hides
them. On a fast localhost it's a flicker; on a slow page load it can
be a couple of seconds of visible flash.
Bake `style="display: none;"` into the server-rendered HTML for the
three elements that hide at count = 0:
- Clear button (data-bulk-show="has-selection")
- Delete button (data-bulk-show="has-selection", when rendered)
- Reorder button in :multi mode (data-bulk-show="has-multiple")
The hook still overrides the inline style on _sync() when the
threshold crosses, so the toggle behavior stays unchanged — only
the initial paint state is no longer wrong.
Reported: clicking the Reorder button delays the modal opening by
the LV round-trip + a few queued queries (~1.2s on a fresh load).
The modal content is fully static after first render, so the
round-trip is pure server-state bookkeeping; the modal should open
the moment the user clicks.
Three pieces:
* **Core `<.modal>` gains a `keep_in_dom` opt.** When true, the
`<dialog>` element is always rendered (with `data-show` driving
visibility) instead of being conditionally appended on @show=true.
The PkDialog hook reworks its `mounted()` to call a new `_sync()`
that reads `data-show` — same behaviour for the legacy
conditional path (data-show is "true" the moment the dialog
enters DOM, which immediately opens it) and the new always-in-DOM
path (initial data-show="false" leaves it closed, flips to "true"
on LV state change).
* **`<.reorder_modal>` opts in to `keep_in_dom={true}`.** Its inner
block is static — strategies and nouns are attrs, the scope label
is computed from `selected_count` / `total_count` which the LV
always has values for. Safe to render at all times.
* **`<.bulk_actions_toolbar>` gains `reorder_dialog_id`.** When
set, the Reorder button carries `data-bulk-opens-dialog="..."`.
The `BulkSelectScope` hook's `_onActionClick` looks up the dialog
by id and calls `showModal()` locally before pushing the event,
so the modal appears instantly. The push still runs in parallel
to update `@show_reorder_modal` and capture `captured_uuids` on
the server — the next LV ack patches `data-show="true"` which the
`_sync()` no-ops since the dialog is already open.
Round-trip race: between client-open and server ack, a user clicking
Apply would beat the `captured_uuids` capture. Window is ~30ms; the
user has to read strategies + click Apply within that window, which
is humanly unreachable. Acceptable.
Closes work the same way: Esc/backdrop fire 'close', hook pushes the
close event, server flips @show=false, `data-show="false"` patches
in, `_sync()` is a no-op (dialog already closed by browser).
…pter whitelist + FOLLOW_UP.md
The NITPICK from CLAUDE_REVIEW.md was the only still-live finding —
`creator_uuid` was castable from adapter event payloads even though
the adapter resolves it server-side from actor opts (a forged payload
could claim authorship). Mirroring the existing `:file_uuid` exclusion.
Other findings (V121 constraint guard, `:uuid` castable on update,
re-UPDATE of unchanged annotations) all fixed pre-existing in commit
5198eb3. Hard-delete of linked comments is intentional per moduledoc.
See FOLLOW_UP.md for the full triage.
…d stub
All three review findings (dead @see ref, doctest evaluation hazard,
undefined Setting.t() in @SPEC) fixed pre-existing in post-merge
commits 42722ff and 0b6ec6f. The NITPICK about free-floating
comments inside cond clauses is N/A — resolved by the dedup PR BeamLabEU#554
which moved sitemap-source policy into LocalePath.emit_prefix?/2.
…test setter symmetry
Three still-live findings from CLAUDE_REVIEW.md:
* IMPROVEMENT-MEDIUM — redirect_invalid_locale/2 evaluated
prefixless_primary?() twice (once for segment, once for suffix).
Single read into a destructured tuple; also closes a small
torn-read window if a concurrent setting flip lands between the
two calls.
* IMPROVEMENT-MEDIUM — LocalePath moduledoc said 'three rules' but
listed four. Edited to 'four rules:'.
* NITPICK — auth_locale_test.exs cleanup mixed Settings.update_boolean_setting
with Languages.set_default_language_no_prefix elsewhere in the same
file. Cleanup now uses the typed setter for symmetry; any future
cache/invalidation logic the typed setter wires in will run on
cleanup too.
Two NITPICKs left as-is (intentional residue documented in the
FOLLOW_UP): the prefixless_primary_safe?() / mix_task_context?()
sentinel duplication (acknowledged in the original review), and the
LocalePath.emit_prefix?(nil, _) clause whose rationale lives in
moduledoc rather than inline.
…d stub
BUG-HIGH (dialyzer failure on ask_with_prompt/4) fixed pre-existing
via .dialyzer_ignore.exs entry. Two NITPICKs (doc example re-enqueue
phrasing, regex /i flag) fixed in post-merge commits. Two items N/A
(completed-key doc trimmed; empty-fields-guard was scope of PR BeamLabEU#558).
…d stub
Core fix (validate_non_empty/1 guard) was the scope of this PR and
shipped. One NITPICK on the error category atom (:parse_error vs a
dedicated :empty_fields) documented as Skipped — the review accepted
the sentinel reuse to avoid forcing callers to handle a second arm
with no behavioural difference.
…s + FOLLOW_UP.md
The only still-live finding was the cosmetic class divergence on 2 of
8 auth forms: registration and magic_link_registration carried
'fieldset w-full min-w-0', the other 6 just 'fieldset min-w-0'.
Behaviorally identical (parent's align-items: stretch provides the
full width), but the inconsistent class string is a future-grep
gotcha. Dropped 'w-full' from the two so all 8 forms now share the
same shape.
C12 Phase 2 re-validation triage (agent BeamLabEU#1, security/async-UX)
flagged the only async-UX gap in this session's new code: the
reorder modal's Apply button submits a phx-event form to the LV
that does a multi-write transaction (write_permutation/2 or
Reorder.reorder/4) — racing double-submits would either deadlock
or double-stamp. phx-disable-with guards both: Phoenix grays the
button + swaps label to 'Applying…' on click and re-enables on
the LV ack.
Strategy radios with required attr block empty submits separately;
the consumer LV's whitelist guard catches forged payloads. This
fix is for the legitimate-but-impatient user.
PkDialog hook had two latent bugs surfaced by the bulk-toolbar's
instant-open flow:
- When BulkSelectScope called dialog.showModal() before the LV
round-trip, Phoenix's DOM patcher then stripped the browser-added
`open` attribute on the next re-render. Dialog stayed in the
top-layer (still capturing all clicks) while CSS `dialog:not([open])`
made it `display: none` — visually closed but blocking the rest of
the page. _sync() now uses the `:modal` pseudo-class as the truth
source instead of el.open; restores the stripped attribute before
close() so the top-layer is actually released.
- _sync()-driven close echoed close_reorder_modal back to the LV
(LV already knows). Now sets _closeFromLV around the close() and
resets it inside _onClose so user-initiated closes (Esc, backdrop)
still echo as intended.
- :modal pseudo-class is wrapped in a try/catch with `el.open`
fallback for older engines that lack support.
- destroyed() uses the same browser-modal predicate so cleanup runs
even when morphdom already stripped `open`.
BulkSelectScope.updated() now preserves the in-memory selection Set
against the freshly-rendered DOM instead of clearing and re-reading
from `r.checked` (which the server template always renders as false).
Rows whose uuids are no longer in the DOM drop out automatically.
Assigns checked=true OR false on every row so a recycled input node
can't carry a stale checked state.
table_default.ex: fix unreachable default title on drag_handle_cell
(attr default: nil shadowed the assign_new gettext default).
modal.ex: keep_in_dom docstring warns about the auto-derived-id
collision risk.
Tests: 80 new render tests across bulk_select, sortable,
reorder_modal, load_more (pagination), sort_selector, modal
keep_in_dom branch, and table_default's drag_handle / row classes.
media_gallery_test.exs: align two stale max_count tests to the
current "Add tile is omitted at limit" behavior (production code
hides the trigger entirely instead of disabling it).
@ddon
ddon merged commit 60b56bf into BeamLabEU:mainMay 25, 2026
ddon pushed a commit that referenced this pull request May 25, 2026
… group
Post-merge review fixes for #568 (back-merged from origin/main into dev):
- bulk_select.ex: the "Reorder %{count} selected" toolbar label used
gettext_noop/1, which only marks the string for extraction and returns
the English msgid — the BulkSelectScope JS hook does no translation, so
the label rendered untranslated in every non-default locale. Switch to
gettext("Reorder %{count} selected", count: "%{count}"), which translates
at render time while preserving the placeholder for client-side interp.
- table_default.ex: table_default_row carried a bare unnamed `group`, which
also satisfied any descendant's unnamed `group-hover:` (e.g. the sortable
header chevron's `group-hover:opacity-70`), brightening it on row hover.
Use a named `group/row` marker + `group-hover/row:` in drag_handle_cell so
the reveal stays keyed to row hover. Update table_default_test.exs asserts
and the AGENTS.md Sortable note to match.
Review: dev_docs/pull_requests/2026/568-modal-to-native-dialog/CLAUDE_REVIEW.md
Verified: mix format + compile --warnings-as-errors + credo --strict clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request May 25, 2026
Release rollup since 1.7.120:
- PR #568: native <dialog> modal (PkDialog), core list-UI toolkit
(BulkSelect, Sortable, ReorderModal, load_more), race-free sort_selector
- PR #568 post-merge review fixes (untranslated reorder label, named group/row)
- PR #569: PhoenixKit.boot/1 hook, locale-aware Activity dates, broad i18n sweep
- PR #550/#552/#554/#557/#558/#559 follow-ups
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mdon@ddon