Skip to content

[6.x] Customize sources modal - #19474

Draft
brianjhanson wants to merge 30 commits into
6.xfrom
feature/customize-sources-modal
Draft

[6.x] Customize sources modal#19474
brianjhanson wants to merge 30 commits into
6.xfrom
feature/customize-sources-modal

Conversation

@brianjhanson

Copy link
Copy Markdown
Contributor

Modernizes the customize sources model into Vue / Inertia and uses the form builder to create the form for each source in the modal.

brianjhanson and others added 27 commits August 19, 2026 20:10
`craft-checkbox-group` announces a model value as its children register,
before the group has applied ours. Treating that as a user change emitted
an empty selection over whatever the caller passed in, so a group seeded
with a value silently cleared itself on mount.

Route the handler through the existing `ignoreModelValueInitialization`
helper, as the form controls already do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two affordances the customize-sources source settings need, and which a
plain checkbox list can't express:

- `sortable()` lets the selected options be reordered, with the value
  stored in display order — what a source's table columns are.
- `allowAll()` adds an "All" option posting the `*` sentinel, which
  checks and disables every other option. This matches
  `Craft.ui.createCheckboxSelect({showAllOption: true})` and
  `Garnish.CheckboxSelect`, including clearing the selection when All is
  unchecked rather than restoring the previous one.

Both imply a multi-select checkbox list, and are only emitted in
`props()` when set so ordinary Choice payloads are unchanged. The PHP
renderer routes them through `Cp\Components\CheckboxSelect`, which
already implements this shape; the Vue side routes to the CP's
`CheckboxGroup`.

Also gives a `labelHtml`-only button an accessible name, which the
buttons presentation was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A custom element source's criteria builder labels its add button "Add a
filter" rather than the condition's default "Add a rule". Only emitted
when set, so an unset label leaves the condition's own default in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ElementSourcesController::show()` returned raw config — view modes, sort
options, available table attributes, a condition builder template with
`__SOURCE_KEY__` placeholders — so that the customize-sources modal could
hand-build every field with `Craft.ui.*`. That duplicated, in ~1,800
lines of JS, what the Form system already describes declaratively.

Each source now carries a `form` key holding a standard FormPayload,
built by the new `ElementSourceForm` and namespaced at `sources.<key>`,
so a Control at `label` posts as `sources[<key>][label]` — the shape
`store()` already reads. Everything the modal used to assemble
client-side is gone from the response.

A new `form()` endpoint serves the payload for a source the client just
added, and doubles as the FormRenderer refresh target.

`store()` gains three fixes this shakes out:

- `defaultSort` arrives as `{attr, dir}`; the legacy 2-tuple is still
  accepted. Numeric path segments do survive the round trip, but only
  through PHP's numeric-string key casting on both ends, and
  `currentValues()` would yield an object rather than a list.
- An empty `sites`/`userGroups` selection means "none", which project
  config stores as `false`. The legacy modal posted nothing at all in
  that case — jQuery skips disabled items and the unchecked All box —
  so the setting silently reverted to "all" on every save.
- A heading with no posted value, and a missing page, are no longer
  unguarded index reads.

A source with no key gets no Form: `ElementSources` synthesizes a keyless
blank heading to separate customized sources from the rest, regenerating
it on every read, so nothing can address it and it must not be saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two in-flight CP chrome tweaks: the modal's default submit button uses
the `primary` variant, and the global sidebar drops to `z-index: 10` so
it sits under modals rather than over them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports Garnish's resizable Modal onto the Vue one, behind a `resizable`
prop: a `BaseDrag` on a corner handle growing the modal by twice the
pointer delta, since it stays centered and both edges move. The upper
bound comes from the resolved max-width/max-height rather than a
hard-coded gutter — the spacing token behind them is a `calc()` and
can't be read back — so clamping keeps a drag away from the edge
responsive instead of unwinding invisible overshoot. Arrow keys resize
too, and Enter or a double-click hands the size back to CSS.

Two stacking fixes this needs:

- `.cp-modal` had a content-sized column but a row stretched to the
  viewport, so the handle's `align-self: end` landed on the viewport's
  edge rather than the content's. It now centers both axes.
- `.content` was positioned but not a stacking context, so slotted
  content's own z-indexes — a sticky pane footer — painted over the
  handle and swallowed the pointer. It now isolates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces `Craft.CustomizeSourcesModal` and its TypeScript port with a
native Vue master/detail: sources on the left, the selected source's
settings on the right, rendered by a FormRenderer per source over the
payloads the controller now returns.

Settings are built on a source's first selection and stay mounted, as
the legacy modal did, so unsaved edits and server-rendered condition
builders survive switching between sources. A source that was never
selected falls through to the payload the server sent, so it round-trips
unchanged without having to be mounted at all.

Ports the structural behavior the old modal owned: drag and keyboard
reordering, the per-source action menu, adding and removing headings and
custom sources, and the multi-page sidebar with its page settings,
rename and delete. Reordering reuses `useReorderableItems`.

Retires the legacy module, its cp/legacy entry points, its global
typing, and the "Customize sources" action on the Garnish element index,
which would otherwise construct a class that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The view-mode and sort-direction pickers inlined `Icons::svg()` output into
each option's `labelHtml`. Options now carry an icon *name* instead, which
`<craft-button>`'s own `icon` attribute resolves and renders as a
`<craft-icon>`.

This fixes a silent failure: `Icons::svg()` only accepts `[\da-z-]+`, so
the Cards view mode's `custom-icons/element-cards` fell through to
`Html::svg()`, threw, and returned an empty string — the button rendered
its title as text instead of an icon. Both renderers now hand the
family-prefixed name to the web component, which resolves it client-side.

An icon option slots nothing, since `<craft-button>` keys its square
icon-only treatment on having an empty light DOM, and the option's label
becomes the button's `aria-label` — an icon with no label of its own is
aria-hidden. The Vue side binds `icon` as an attribute because
`craft-button` doesn't reflect the property, and that CSS selector needs
the attribute.

Payloads also shrink: an icon name rather than ~700 bytes of SVG per
option.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pages and sources sidebars had each grown their own copy of the same
reorderable list: the `cs-list` markup, the `useReorderableItems` wiring,
a bounds-checked reorder, and ~50 lines of near-identical styles. The two
copies had already drifted — only the sources list truncated a long label
or showed a grab cursor mid-drag.

CustomSourceList owns the row: markup, drag and keyboard reordering,
selected and dragging states. Callers keep what actually differs — how a
row is identified, what its label looks like, and what its action menu
offers — through `itemId`/`disabled` props and `label`/`actions` slots.
It's generic over the item type, so both slots stay typed.

The sources list still reorders against indexes into the unfiltered list,
since it only ever renders one page's worth; that translation moved to a
handler rather than being folded into the shared component.

Also fixes a stray `actions(source, …)` in the pages list, where the loop
variable is `page` — the shared row passes the item into the slot, so the
reference can't drift again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The actions slot left one line duplicated in both sidebars, and that line
invoked the callback twice per row per render — once for the `v-if`, once
for the binding.

CustomSourceList now takes an `actions` callback and renders the menu
itself, alongside the id it already resolved. Both are computed once per
row rather than per binding, and the caller's own reactive reads are
tracked through the computed, so a source's "Move to {page}" list still
updates when a page is added or renamed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sidebars now describe a row entirely through callbacks — `label`
and an optional `icon` join `itemId`/`disabled`/`actions` — and neither
passes a slot at all. The blank-label fallback lives in one place rather
than only in the sources list, and the truncation styles no longer have
to reach through `:slotted()`.

Fixes styling that never applied. These components were authored against
token names that don't exist — `--c-bg-subtle`, `--c-bg-selected`,
`--c-border-radius-md`, `--c-spacing-m/-s`, `--c-font-size-sm` — so the
rows had no radius, no margin, and a selected state that painted nothing.
It went unnoticed because the retired legacy stylesheet still ships an
unscoped `.cs-item` rule that was supplying the background; the row now
sets its own so it doesn't depend on which stylesheet wins.

The icon also sat flush against the label, since the row gap is 2px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Modal now holds the CP's existing `body.no-scroll` lock while it's open,
the same convention the slideout panel stack and the legacy Garnish modal
use.

The lock is ref-counted, because CP modals nest — customize sources opens
page settings, which opens the icon picker — and a naive add/remove would
unlock the body as soon as the innermost one closed. It's also released
when the owning scope is disposed, so a modal unmounted while still open
can't strand the page unscrollable.

`body.no-scroll` was only defined in the legacy stylesheet and in
panel-stack.css, which loads with the slideouts; cp.css declares it too so
a page with a modal and neither of those still locks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swapping a source's settings for a new heading's single field dropped the
customize-sources modal from ~918px to ~539px, resizing the box out from
under whoever was reading it.

Modal now remembers the tallest it has been while open and holds that as a
floor. The floor only ever rises, and resets on close, so reopening sizes
itself to the new content rather than inheriting the last session's. It
stays clamped to the same cap a drag-resize uses, since min-height beats
max-height and a shrinking viewport would otherwise leave the modal taller
than the screen — and it steps aside entirely when the height is already
explicit, from the `height` prop or a drag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The floor kept `.content` from collapsing, but the form inside it stayed
at its natural height — dropping from 1514px to 537px and leaving the
pane, and its Cancel/Save footer, stranded partway up an otherwise
full-height modal.

`.content` is now a column flex box, and the form, the pane and the pane's
own surface each grow to fill it. They grow but never shrink: `craft-pane`
clips rather than scrolls, so constraining it below its content would hide
a long form instead of scrolling it. `flex: 1 0 auto` keeps a tall form
overflowing into `.content`'s scroll, exactly as before.

The pane stacks its regions as blocks and its footer is only sticky while
something scrolls, so a short body would still strand the footer. Its
`base` and `body` parts are reached through `::part()`, keeping the change
to modals built on ModalForm rather than every pane in the CP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five stories covering what the component actually decides: the default
sources list, the icon-led pages variant, a blank label falling back to
"(blank)" alongside an unselectable row, a single row losing its drag
handles, and long labels truncating.

The component is controlled — it emits `select` and `reorder` and the
caller owns `items` and `selected` — so each story holds that state
rather than passing static args. Otherwise the rows would render but
nothing would answer a click or a drag.

Also serves the icon directory Storybook was missing: `craft-icon`
fetches `/vendor/craft/icons/<family>/<name>.svg`, which is a symlink to
`cms-assets/resources` in the CP and simply 404'd here, so every icon in
every story rendered nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`active` styles on `:host([active])` but the property wasn't reflected, so
setting it from a framework binding — Vue assigns the property on a custom
element, not the attribute — left the selector unmatched and the active
state invisible. The component's own stories bind `?active`, an attribute,
which is why it looked fine there.

Also renders a `prefix` slot, so a row can lead with its own control ahead
of the icon.
The header carried its spacing only on top and ran into the body with no
separator, which reads as unfinished once the pane is tall enough for the
header to sit against content.
Escape and the overlay already closed a modal, but nothing did so by
pointer from the modal itself.
The rows were hand-built markup — a button, a handle span and a menu
wrapped in a list item. `craft-action-item` already covers that shape, so
the list renders one per row and slots the reorder button, icon, label and
action menu into it.

CustomSourceList also takes an `itemType` callback now, resolved onto the
row so a heading can be styled apart via `cs-item--heading`. It stays an
opaque string: the sources sidebar is the only place that knows what a
source's type means, and this list shouldn't have to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its `.cs-*` rules outlived the jQuery modal they were written for, and
being unscoped they were still reaching into the Vue rows that replaced it
— quietly supplying a background the new component thought it was setting
itself.
`form()` drained HtmlStack into `headHtml`/`bodyHtml`, copying the shape of
the field-layout settings endpoint. But that endpoint renders HTML, and
this one doesn't — it resolves a Form payload, which is pure JSON. So what
it actually returned was 16KB of the whole CP's asset bootstrap, and the
modal fed it to `appendBodyHtml`, which ran it.

Among those initializers is the selectize one, which does
`$('#id').data('selectize').$wrapper` against an id that only exists on a
full page render. On an empty jQuery set `.selectize()` is a no-op, so the
data is never set and it threw on every custom source added:

    Uncaught TypeError: Cannot read properties of undefined (reading '$wrapper')

Nothing is lost by dropping them: a Control that renders server-side HTML
fetches its own assets when it renders, which is how the condition builder
already worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`formChangeFromEvent` returned any CustomEvent's `detail`, so anything
bubbling through a form was forwarded as a FormChange. htmx's request
lifecycle puts `{elt, xhr, requestConfig}` there — truthy, so it sailed
past the guard and reached listeners as a change with no `path`.

`recordChange` survived it, since `JSON.stringify(undefined)` is harmless,
and the failure surfaced one level out as an unhandled error in whichever
`change` listener touched `change.path` — in the customize-sources modal,
the one keeping a source's sidebar label in step with its input.

It also broke the condition builder outright: adding a rule swapped the
new markup in and then tore the whole builder back out of the DOM, so the
initializers htmx appended ran against elements that were no longer there
and threw reading `$wrapper`. With the bogus change no longer propagating,
the rule lands and stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Modal had grown ~180 lines of sizing: bounds from computed CSS, a clamp, a
BaseDrag on the corner handle, arrow-key nudging, and the height floor. It
now composes that and is left with what a modal is actually about —
transitions, focus, the overlay, and turning the numbers into a style.

Deliberately a sibling of `useResizable` rather than part of it. That one
resizes a layout column: one axis, anchored to an edge, persisted, exposed
to assistive tech as a splitter. A centered box grows from both edges at
once, has no meaningful side, and is capped by whatever max-width and
max-height CSS already gives it. Folding the two together would have meant
one composable with two disjoint halves.

The height floor came along because it answers the same question — how big
is this box — and clamps against the same cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/uiopen Storybook

Changed components:

resources/jsopen Storybook

No changed components detected in this Storybook.

brianjhanson and others added 2 commits August 21, 2026 17:08
Its header-actions block landed unformatted; `vp fmt --check` flags it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composable handed back three numbers and left every consumer to turn
them into CSS. That put a rule in the wrong place: whether the height floor
applies is a fact about resizing — it is meaningless once the height is
fixed — but it lived in Modal as `!style.height` on a locally built object,
where a second consumer would have had to rediscover it.

It now returns `style`, named to match the same output `useResizable`
already had. The one thing it cannot see for itself is a consumer that
fixes the height by other means, so that arrives as the `fixedHeight`
option. Both paths matter because min-height beats max-height: a floor
under a fixed height is either redundant or actively wrong, and a drag that
shrinks the box past the floor has to be able to.

Modal is left owning only the viewport cap it applies to its own props, and
composes the two through `:style`, where later entries win — so a dragged
size still beats the width class and the height prop.

The corner grip becomes CornerResizeHandle rather than an inline button, so
it carries its own appearance and Modal keeps only placement. It is a
sibling of ResizeHandle, not a replacement: that one is an edge divider
reporting a width as a WAI-ARIA splitter, while a corner grip drives both
axes at once, so there is no single value to report and no separator role
that fits.

What the two do share is how a handle talks to whatever it drives, so
`setHandle`/`onKeydown`/`reset` are named once as ResizeHandleControls and
both returns extend it. Their keyboard handling stays separate — one axis
with Home/End against stored bounds versus two axes against a CSS cap would
have made one handler out of two disjoint halves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to 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.

1 participant