[6.x] Customize sources modal - #19474
Draft
brianjhanson wants to merge 30 commits into
Draft
Conversation
`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>
📚 Storybook previews@craftcms/ui — open Storybook Changed components: resources/js — open Storybook No changed components detected in this Storybook. |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Modernizes the customize sources model into Vue / Inertia and uses the form builder to create the form for each source in the modal.