Skip to content

[6.x] Feature/module port iii - #19352

Merged
brandonkelly merged 31 commits into
6.xfrom
feature/module-port-iii
Aug 1, 2026
Merged

[6.x] Feature/module port iii#19352
brandonkelly merged 31 commits into
6.xfrom
feature/module-port-iii

Conversation

@brianjhanson

Copy link
Copy Markdown
Contributor

Description

Related issues

brianjhanson and others added 29 commits July 28, 2026 10:09
Core no longer instantiates Craft.ComponentSelectInput — every core CP
surface renders the self-booting <craft-component-select> web component.
The legacy jQuery class survived only for the componentSelect.twig
`jsClass` escape hatch (plugin subclasses).

Move the real 681-line implementation verbatim out of the craftcms-legacy
CP bundle into legacy/web/assets/cpcompat/component-select-input.js so the
bundle can drop it while plugin subclasses keep a working base class. It
defines its class at top-level eval (not on DOMContentLoaded like
cp-compat.js) so a plugin's Craft.Foo = Craft.ComponentSelectInput.extend()
can run whenever its own bundle loads, and is registered ahead of
cp-compat.js via CpCompatAsset. Drop the Craft.js import and fix the
component-select README/index comments that claimed the class still ships
in the legacy bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…bundle

Both were self-contained native custom elements still defined in the
craftcms-legacy CP bundle. Move their definitions to resources/js modules
(proxy-scrollbar, element-label), registered via defineElement and imported
from both cp.ts and legacy.ts so the tags stay defined on every CP page —
matching the coverage the webpack bundle gave them.

proxy-scrollbar was already jQuery-free. element-label's `$('#tabs')`
tab-change subscription is kept as a documented jq() seam (the tab set is
still the legacy Craft.Tabs instance in jQuery's data cache) and its
`$(ready)` deferral becomes a native document-ready check.

Drop both files and their Craft.js imports; PHP/CSS/legacy consumers that
emit the tags now resolve against the Vite-registered elements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
element-label's only remaining jQuery use was reading the Craft.Tabs
instance out of jQuery's `.data('tabs')` cache to hook `selectTab` — needed
because a label inside a `display: none` tab measures width 0 until the tab
is shown. Craft.Tabs fires only a Garnish event (no native DOM event), so
there was nothing to addEventListener for.

Replace the whole tabs coupling with a ResizeObserver on the element, which
fires when the label (or an ancestor tab pane) leaves `display: none` and on
any later resize — covering both the selectTab hook and the old `$(ready)`
re-measure. The module is now fully jQuery-free with no dependency on the
still-legacy Tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add createTextInput to the factory as a jQuery-free plain <input> builder —
the shared building block many CP inputs are made of, and the next step of
the Craft.ui factory rollout. Faithfully maps the legacy attribute surface
(including a jQuery-free port of addAttributes for aria/data/class/style),
using logical padding-inline-end for the chars-left case instead of the
Craft.orientation branch.

Reduce Craft.ui.createTextInput to a thin shim over the factory that keeps
the Garnish-specific pieces the factory leaves out: mobile autofocus
suppression, the NiceText enhancement, the .passwordwrapper wrapper, and the
legacy jQuery return type. Covered by three new factory tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FormObserver watches a form (or any container of named inputs) and fires a
debounced callback when its serialized value actually changes — backing draft
autosave in ElementEditor/ContentBlockEditor (and matrix-entry).

Port it onto @craftcms/garnish Base, fully jQuery-free: serializeFormInputs
(from @craftcms/ui) replaces jQuery's .serialize(), and change detection rides
on native MutationObserver + Base.addListener (which splits the comma event
grammar). It's instantiated imperatively (new Craft.FormObserver(...)), so the
class is exposed on window.Craft as a seam for the still-legacy callers rather
than as a custom element. Imported from cp.ts + legacy.ts; legacy file and its
Craft.js import removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A thin setInterval wrapper (start/stop around settings.onInterval). Port it
onto @craftcms/garnish Base; the only jQuery was the $.noop default, now
() => {}. Craft.ProgressBar still instantiates it, so the class is exposed on
window.Craft as a seam. Imported from cp.ts + legacy.ts; legacy file and its
Craft.js import removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The imperative CP ports had each grown their own copies of these. Pull them
into resources/js/common:

- common/utils/dom.ts — resolveElement() (selector/element/array-like incl.
  jQuery → HTMLElement) + queryAll(); repoints field-toggle and form-observer,
  which had identical local copies.
- common/utils/jquery.ts — jq(), the single documented jQuery seam; repoints
  field-toggle, elevated-session, and matrix/interop (which keeps its typed
  JQueryLike wrapper, now delegating). Every remaining jQuery dependency in
  resources/js is now greppable to this one accessor.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add common/craft-global.ts — registerCraftGlobals({X}) assigns onto the legacy
window.Craft namespace (creating it if needed) so PHP-emitted markup and the
still-legacy cp bundle reach ported classes via Craft.*.

Collapse the `const craft = (window as any).Craft ?? ((window as any).Craft = {})`
boilerplate that 17 module index files were each repeating down to a single
typed call. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pilot)

EntryMover opens a modal listing the sections the selected entries can move
to, and performs the move on confirm (booted from MoveToSection.php).

Port it onto @craftcms/garnish Base — the first resources/js consumer of the
modern Modal and Select, which drop in 1:1 for their legacy Garnish.*
equivalents (Modal autoShows + attaches to <body>; Select's settings —
vertical/checkboxMode/filter/onSelectionChange — and its HTMLElement[]
$selectedItems map directly). Modal markup is built with plain DOM. Two
documented seams remain: Craft.ui.createSubmitButton (not yet in
@craftcms/ui/factory) and the legacy jQuery elementIndex passed in by the
still-legacy element index. Craft.EntryMover stays a window.Craft global;
legacy file and its Craft.js import removed.

This proves the modal substrate is a drop-in, unblocking PromptHandler,
DeleteUserModal, CpModal, and createCopyTextPrompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PromptHandler shows a modal to resolve conflicts (e.g. filename clashes on
upload), one prompt at a time across a batch with an optional "apply to
remaining" checkbox; booted by Craft.AssetIndex.

Second modal-substrate port. jQuery-free on @craftcms/garnish Base + Modal:
modal markup built with plain DOM, and the legacy jQuery .fadeOut('fast')
becomes a WAAPI opacity animation. The one adaptation flagged in the
EntryMover pilot — legacy pokes the shared static Garnish.Modal.$shade to make
a shade click cancel the prompt — becomes a per-instance this.modal.$shade
listener wired once at modal creation, paired with hideOnShadeClick:false so
the modal doesn't also hide. Public API (resetPrompts/addPrompt/
getPromptCount/showBatchPrompts) is unchanged for AssetIndex; Craft.PromptHandler
stays a window.Craft global. Legacy file and its Craft.js import removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
createCopyTextPrompt opens a modal showing a value in a read-only field with a
copy-to-clipboard button (used by LinkInput's "Copy URL", the plugin manager,
and the upgrade utility).

@craftcms/ui doesn't depend on @craftcms/garnish, so this factory is built on
@craftcms/ui's own components rather than the garnish Modal: <craft-dialog>
(the modal) + <craft-copy-button> (which copies via navigator.clipboard and
shows its own feedback, replacing the legacy execCommand('copy') +
displayNotice). Supports the text and textarea variants its callers use, and
closes on copy. Reduce Craft.ui.createCopyTextPrompt to a thin shim delegating
to the factory. Covered by two new factory tests.

This unblocks the deferred LinkField/LinkInput port (its last non-disclosure
seam was this prompt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Link field's client code: LinkField (outer orchestrator — syncs label/
filename placeholders to the active link type) and LinkInput (inner control —
toggles between a text input and a committed-value chip, validating against the
type's pattern/prefixes). Booted from Link.php / BaseTextLinkType.php.

Port both onto @craftcms/garnish Base in resources/js/modules/link-field:
- The chip's action menu (legacy Garnish.DisclosureMenu addItem/addHr) becomes
  a Craft.addActionsToChip descriptor array — the smart craft-action-menu
  helper, same seam component-select uses. String icon names, and the
  destructive Remove item auto-groups, so no manual divider.
- createTextInput + createCopyTextPrompt come from @craftcms/ui/factory (the
  earlier factory ports); modal markup and chips are built with plain DOM.
- LinkField reuses the FieldToggle peer module directly (get-or-create via its
  WeakMap) instead of the jQuery .fieldtoggle() plugin, and moves its
  linkLabel/filename storage from jQuery .data to a WeakMap.
- Remaining documented seams: Craft.addActionsToChip, the Craft.* string
  helpers, and the .data('linkField') pair handoff. punycode is imported (with
  a local .d.ts) for the IDN-aware URL validation.

Removed both legacy files and their Craft.js imports; Craft.LinkField/LinkInput
stay window.Craft globals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Craft.DeleteUserModal was deprecated in 5.10.0 and is now unused — nothing in
core (JS, Twig, or PHP) instantiates it. Remove it from the cp bundle and add a
cpcompat defineRemovedClass warn-stub so a stray plugin caller gets a clear
deprecation notice instead of an opaque "undefined is not a constructor".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CpModal loads a control-panel screen into a modal form, submits it with
delta-encoded data, and renders validation errors inline; booted by the PHP
element-deletion blockers. This is the first extends-Modal subclass port,
proving that shape on the modern @craftcms/garnish Modal (which de-risks the
P4 modal heavies — AssetImageEditor, PreviewFileModal, CustomizeSourcesModal).

Key pieces:
- Subclass pre-super pattern: the parent Modal is constructed container-less
  and non-auto-showing (super(undefined, {autoShow:false})) since TS forbids
  `this` before super(); the form container is built after, then setContainer +
  an explicit show() once the screen loads.
- onShow/onHide are overridden as real methods (calling super), and shade/esc
  closing routes through closeMeMaybe() (dirty-check confirm), so the Modal's
  hideOnShadeClick/hideOnEsc are disabled.
- uiLayerManager() save (Ctrl-S) / Esc shortcuts, mirroring cp-screen-slideout.

Modeled on its sibling cp-screen-slideout: keeps the declare-const-$ seam for
the container/form ops, because the delta-tracking data
(.data('initialSerializedValue'|'delta-names'|'serializer')) and Craft.ui
field-error helpers share those conventions with the CP-screen framework.
Removed the legacy file and its Craft.js import; Craft.CpModal stays a global.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tabs manages an ARIA tablist — click/keyboard tab selection, roving-tabindex
focus, horizontal scroll-into-view, and an overflow "more" menu. Booted by CP,
Preview, cp-screen-slideout, and matrix-entry.

Port it onto @craftcms/garnish Base with the selection/keyboard-nav/scroll
logic all plain DOM (native key names, getBoundingClientRect/scrollLeft,
prev/next sibling walking). Two documented jQuery seams remain: the overflow
menu button is still the legacy .disclosureMenu() plugin (read via
.data('trigger')), and the selectTab/deselectTab events keep a jQuery-wrapped
$tab payload because every consumer reads ev.$tab.attr('href'). The
.data('tabs') self-storage is kept for BC (no active reader since element-label
moved to a ResizeObserver). Craft.Tabs stays a window.Craft global; legacy file
and its Craft.js import removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grid is a masonry / bin-packing layout manager, not a CSS-grid layout: its
default fillMode 'top' runs a combinatorial optimizer (Craft.Grid.LayoutGenerator)
that enumerates every colspan/position combination to minimize total height and
empty space, then absolutely-positions variable-width (data-colspan) items —
something CSS display:grid can't express (native masonry isn't shipped
cross-browser). Booted via new Craft.Grid(...) and the .grid jQuery plugin;
used by the Dashboard (masonry widgets) and field-layout-designer.

Port it onto @craftcms/garnish Base with the LayoutGenerator optimizer and all
the colspan/position/layout math transcribed to typed TS (dropping the legacy
`this.method._ = {}` scratch-object pattern for normal locals). Following the
CpModal precedent, it keeps a declare-const-$ seam for the DOM: masonry
correctness depends on jQuery's box-model measurement, and the public
$items/$container API + addItems(jQuery) are consumed by the still-legacy
Dashboard and field-layout-designer. muteResizeEvents comes from
@craftcms/garnish. Craft.Grid stays a window.Craft global (with
Craft.Grid.LayoutGenerator for BC); the legacy file and its Craft.js import are
removed, while the .grid plugin in Craft.js keeps working through the global.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chart.js is the Craft.charts namespace: DataTable (typed cell parsing), Tip
(tooltip), BaseChart (settings/lifecycle), Area (a d3 time-series area/line
chart with axes, gridlines, plots, and hover tooltips), and a utils formatter
object. Booted via new Craft.charts.Area(...) by the chart widgets (e.g.
NewUsersWidget).

Port the namespace onto @craftcms/garnish Base (Area extends BaseChart extends
Base; this.base(...) -> super.*). d3 stays a real ESM import — Vite code-splits
it into a separate chart-*.js chunk, so it no longer sits in the main bundle.
Like the other complex legacy managers it keeps a declare-const-$ seam: the
$container API is passed as jQuery by the still-legacy widgets, and the
tooltip/measurement rely on jQuery's box model. Fixed the getDuration helper's
self-referencing object literal (a latent bug that would throw if called).
Craft.charts stays a window.Craft namespace (with utils for BC); legacy file and
its Craft.js import removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First two leaves of the element-index/select clusters:

- DataTableSorter onto @craftcms/garnish DragSort — proves the modern DragSort
  subclass extension (build config → super($rows, settings) → wire the
  drag-time `helper` callback after super, since it needs `this`). Booted by
  Craft.AdminTable and the modern editable-table module.
- ElementActionTrigger onto @craftcms/garnish Base — an element-index action
  button's enable/disable + activate wiring; booted by PHP element actions and
  the element index.

Both keep documented seams for the still-legacy surfaces they interop with: the
jQuery `$trigger`/`$selectedItems`/table, and Craft.currentElementIndex (the
P4a element index, not yet ported). Craft.* globals preserved; legacy files and
Craft.js imports removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ElementThumbLoader was already modern code — a thin bridge over @craftcms/ui's
ThumbnailLoader (it imported the package even from the legacy bundle). This just
moves it into resources/js/modules/element-thumb-loader; Craft.ElementThumbLoader
stays a window.Craft global. Legacy file and its Craft.js import removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The structure-aware element-table row sorter: drag-reorders rows including
descendant rows, computes target indentation levels/ancestors, and persists
moves to the server. Created by Craft.TableElementIndexView.

Port onto @craftcms/garnish DragSort (subclass pre-super pattern; overrides
findDraggee/onDrag*/onSortChange/canInsert* calling super). The one real
adaptation: modern DragSort exposes $targetItem/$draggee/helpers as
HTMLElement(s) rather than jQuery, so those are wrapped with $(...) at the
boundaries where the legacy structural traversal (.next()/.first()/.add()/…)
needs them, and findDraggee returns a plain array. The scratch-object locals
(this.method._ = {}) become normal locals. Keeps the declare-const-$ +
Craft.* seams and interop with the still-legacy table view. Craft global;
legacy file and Craft.js import removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the two Garnish.Base leaves off the legacy jQuery bundle onto
@craftcms/garnish Base. ElementDeletionManager composes the modern
Modal for its blocker dialog; the server-provided callback eval
contract is preserved. Both keep their window.Craft.* globals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the asset uploader pair off the legacy jQuery bundle onto
@craftcms/garnish Base. The blueimp jQuery File Upload plugin has no
modern equivalent, so the $ transport seam is retained. The
Craft.createUploader/registerUploaderClass registry (still in the
legacy bundle) resolves the reassigned window.Craft.* globals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the
  legacy Garnish.CustomSelect (floating listbox) and Garnish.MenuBtn (combobox trigger) to jQuery-free TypeScript on Base, unblocking the P3 element-select cluster on the modern stack. Exposed as named exports and on the core Garnish
  namespace, so the compat layer auto-wraps them with extend()/base and activates the Garnish.Menu alias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lectorModal to TypeScript

Moves the element selector modal sub-tree (Phase B step 2) out of the legacy
jQuery webpack bundle and into a modern TypeScript module under
resources/js/modules/element-selector-modal/.

The three classes extend Garnish's Modal (via the modern @craftcms/garnish
package) using the same new.target init-guard pattern as the BaseUploader/
Uploader port, allowing the subclass hierarchy to share a single Modal
construction.

Key seam decisions:
- doubletap on element-index elements uses $(el).on('doubletap') because
  doubletap is a jQuery synthetic event; native addEventListener would miss it
- Buttons use addListener(el, 'click') — <button> fires native click on
  keyboard activation, so jQuery activate is not needed here
- this.settings is declared as any (shadowing Modal's typed settings) to hold
  Craft-specific settings alongside the Modal settings we need (resizable,
  hideOnShadeClick, lifecycle no-op callbacks)
- Sidebar layer management via uiLayerManager() from the slideout module
- Craft.t() values resolved lazily in init(), not at static class field time

AssetSelectorModal registers itself via Craft.registerElementSelectorModalClass
for the Asset element type, matching the legacy registration call.
VolumeFolderSelectorModal forces foldersOnly mode and allows selecting the
currently open folder even with no element highlighted.

Both Craft.ts entry points (cp.ts and legacy.ts) import the new index.ts,
which registers all three classes on window.Craft.* via registerCraftGlobals.
The three legacy .js source files have been emptied and their import lines
removed from packages/craftcms-legacy/cp/src/Craft.js.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Script modules

Migrate three legacy jQuery CP classes from
packages/craftcms-legacy/cp/src/js/ to the modern TypeScript module
pattern under resources/js/modules/element-select-input/.

- base-element-select-input.ts: port of BaseElementSelectInput (~580 lines);
  extends Base; handles element selection, sorting (DragSort), search
  autocomplete, replace/create flows, and modal launching
- entry-select-input.ts: thin subclass that opens an EntrySelectModal
- tag-select-input.ts: replaces the 'add' button with an inline text
  input; searches tags via the tags/search-for-tags action and creates
  new tags via tags/create-tag
- index.ts: barrel + registerCraftGlobals wiring

Remove the corresponding legacy JS files from the webpack bundle (Craft.js
imports removed; legacy files emptied). Import both modules from cp.ts and
legacy.ts so they boot on every CP page.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eScript modules

Migrate three legacy jQuery CP classes and seven nested companion classes from
packages/craftcms-legacy/cp/src/js/ to the modern TypeScript module pattern
under resources/js/modules/.

preview-file-modal/
- preview-file-modal.ts: port of Craft.PreviewFileModal; extends Modal;
  uses quickShow() to skip fade-in; supports arrow-key navigation between
  sibling assets in an element select; static openInstance, showForAsset,
  resizePreviewImage preserved and registered on window.Craft.PreviewFileModal

asset-select-input/
- asset-select-input.ts: port of Craft.AssetSelectInput; extends
  BaseElementSelectInput; adds upload button + hidden file input wired to
  Craft.createUploader, ProgressBar overlay during uploads, and Shift-Space
  keyboard shortcut to open PreviewFileModal

customize-sources-modal/
- customize-sources-modal.ts: port of Craft.CustomizeSourcesModal and all
  seven nested classes (PageSettingsModal, SourceDrag, Page, BaseSource,
  Source, CustomSource, Heading); extends Modal/DragSort/Base as appropriate;
  all nested classes registered on window.Craft.CustomizeSourcesModal.*

Remove the corresponding legacy JS files from the webpack bundle (Craft.js
imports removed; legacy files emptied). Import all new modules from cp.ts and
legacy.ts so they boot on every CP page.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The PreviewFileModal, AssetSelectInput, and CustomizeSourcesModal ports
defined a local registerCraftGlobals() that was never called, so
window.Craft.PreviewFileModal / .AssetSelectInput / .CustomizeSourcesModal
(and its nested classes) were never assigned at runtime — a break that
typecheck and bundling can't catch. Switch all three to the shared
@/common/craft-global helper and invoke it at import time, matching the
element-selector-modal and element-select-input modules.

Also delete the emptied legacy .js stubs (they were truncated rather than
removed) and pick up the Prettier reformats left in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolved import-list conflicts in the CP entrypoints — kept this branch's
ported-module set and added 6.x's new nested-element-manager + ui module
imports (cp.ts, legacy.ts); kept the ported files unimported in the legacy
Craft.js bundle; took 6.x's slideout craft:edit-element listener addition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/uiopen Storybook

No changed components detected in this Storybook.

resources/jsopen Storybook

No changed components detected in this Storybook.

# Conflicts:
#	packages/craftcms-legacy/cp/src/js/BaseElementSelectInput.js
[ci skip]
@brandonkelly
brandonkelly merged commit e998d4c into 6.x Aug 1, 2026
@brandonkelly
brandonkelly deleted the feature/module-port-iii branch August 1, 2026 23:27
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.

2 participants