Skip to content

[6.x] JS modules port (Part I) - #19323

Draft
brianjhanson wants to merge 19 commits into
6.xfrom
feature/module-port
Draft

[6.x] JS modules port (Part I)#19323
brianjhanson wants to merge 19 commits into
6.xfrom
feature/module-port

Conversation

@brianjhanson

Copy link
Copy Markdown
Contributor

Description

Related issues

brianjhanson and others added 17 commits July 24, 2026 12:14
…shim

Core now emits <craft-info-icon> directly, so the .info -> element upgrade no
longer belongs in the CP bundle. Register $.fn.infoicon as a deprecated BC
shim from the adapter instead:

- legacy/web/assets/cpcompat/cp-compat.js holds the shim (warns once on use,
  still auto-upgrades third-party .info markup); future CP jQuery plugin shims
  go in this file too.
- CpCompatAsset (LegacyAssetInterface) serves it via craftAsset(); the
  RegisterLegacyCompatAssets middleware registers it on CP requests through the
  craft.cp group, mirroring how core registers CpAsset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives the craft-switch web component the runtime API the legacy
Craft.LightSwitch class exposed (turnOn/turnOff/turnIndeterminate with an
optional muteEvent argument, plus an `on` getter), so consumers can drive
it via querySelector('craft-switch').turnOn() instead of the jQuery
.data('lightswitch') seam. Muting is handled by a single suppress flag on
the change-dispatch choke point, held across the update cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Repoints the CP off the jQuery lightswitch plugin onto <craft-switch>:

- Craft.ui.createLightswitch now builds <craft-switch>/<craft-switch-button>
  markup mirroring the server-rendered Lightswitch component, instead of
  <button class="lightswitch"> + new Craft.LightSwitch.
- ElementEditor and editsection read switch state via the element's
  imperative API (el.on, el.turnOn(true)) and serialize the hidden input
  directly, instead of the .data('lightswitch') seam.
- Removes the Craft.LightSwitch class, the $.fn.lightswitch jQuery plugin,
  and the initUiElements auto-upgrade; deletes LightSwitch.js.
- Adds a deprecated $.fn.lightswitch shim to the yii2-adapter cpcompat
  bundle that upgrades third-party .lightswitch markup to <craft-switch>
  (via createLightswitch) and warns, matching the .infoicon shim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Craft.Accordion and Craft.EnvVarGenerator were defined in the legacy CP
bundle but referenced nowhere — no JS/Twig/PHP callers, no string-name
lookups, no self-registration, jQuery plugin, or initUiElements hook.
Deletes the two files and their imports.

Craft.DynamicGenerator, flagged the same way, is kept: an internal plugin
instantiates it. It stays with the generator cluster (Craft.BaseInputGenerator
plus the live Handle/Slug/UriFormat generators) until that cluster is ported.

For the two removed classes, adds inert deprecation stubs to the yii2-adapter
cpcompat bundle: a plugin that still does `new Craft.Accordion()` gets a
one-time console.warn (matching the CP's deprecation idiom) instead of an
opaque "undefined is not a constructor". The stubs preserve the
Garnish.Base.extend/new shape and are otherwise inert.

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

Craft.LightSwitch (removed with the craft-switch migration) and Craft.InfoIcon
(removed with the craft-info-icon migration) were public classes plugins could
instantiate directly — new Craft.LightSwitch(el, settings) / new Craft.InfoIcon(el).
Only their markup-conversion jQuery plugins ($.fn.lightswitch / $.fn.infoicon)
were shimmed, so programmatic callers hit "undefined is not a constructor".

Adds warn-and-delegate stubs to the yii2-adapter cpcompat bundle:
- Craft.LightSwitch warns once, upgrades legacy .lightswitch markup (or accepts
  an existing <craft-switch>), and delegates on/turnOn/turnOff/turnIndeterminate/
  toggle to the element's imperative API, wiring settings.onChange to its change
  event.
- Craft.InfoIcon reproduces the old class's contract: warn, convert the element
  to <craft-info-icon>, expose it as this.icon.

Factors the legacy->component conversion for both into shared helpers reused by
the existing $.fn.* shims, so each config mapping stays in one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Checkbox renders its info via <craft-info-icon> since the InfoIcon migration
(7b4de37), but this test still asserted the old class="info" markup. Update
the assertion to match the emitted element.

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

Ports the password input onto the existing <craft-input-password> web component
(a LionInput with a built-in show/hide reveal toggle), retiring the legacy
Craft.PasswordInput JS that added the toggle to a .passwordwrapper input.

Server side (the AGENTS.md form-input pattern):
- Adds the InputPassword component (extends Input; tag craft-input-password,
  type fixed to password). It keeps only the control props Lion pushes onto the
  slotted input (placeholder/name/disabled/readonly) on the host — the
  craft-input-only styling attrs and `type` are dropped (the component owns its
  type via the reveal toggle); the native input keeps type/maxlength.
- Registers it, adds FormFields::password* (reusing the text mapping and
  preserving the legacy .password class), a craft.cp.password() Twig delegate,
  and reduces _includes/forms/password.twig to that delegate. Every
  passwordField now renders the toggle natively — no JS needed.

Consumers / legacy removal:
- Drops the now-redundant new Craft.PasswordInput('#newPassword') from
  users/_password.twig (the only passwordField consumer).
- Points UI.js createPasswordInput/createPasswordField at craft-input-password.
- Deletes PasswordInput.js and its import; adds a warn-and-delegate
  Craft.PasswordInput stub to the cpcompat bundle that upgrades a legacy
  password input to <craft-input-password>.

The legacy installer's new Craft.PasswordInput('#account-password') is left as
dead code (InstallerAsset is unregistered; the install screen is the Inertia/Vue
AccountFields using craft-input-password) — the cpcompat stub covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports the color input onto the existing <craft-input-color> web component (a
LionInput that pairs a hex text field with a native color picker, swatch, and
preset datalist, with built-in # stripping / hex normalization), retiring the
legacy Craft.ColorInput JS and the hand-built .color-container markup.

Server side (the AGENTS.md form-input pattern):
- Adds the InputColor component (extends Input; tag craft-input-color). Like
  InputPassword it keeps only the Lion-pushed control props on the host and adds
  a `presets` JSON attribute for the picker datalist; the value is stored as
  bare hex.
- Registers it, adds FormFields::colorFromConfig (reusing the text mapping,
  # stripping the value, preserving the legacy .color-input class), a
  craft.cp.color() Twig delegate, and reduces _includes/forms/color.twig to that
  delegate. This also updates the Color field type, which renders via colorHtml.

Consumers / legacy removal:
- Points UI.js createColorInput at craft-input-color (the modern
  editable-table.ts color cell uses that factory) and fixes a pre-existing
  stray `o;` ReferenceError in createColorField.
- Deletes ColorInput.js and its import; adds a warn-and-delegate Craft.ColorInput
  stub to the cpcompat bundle that upgrades a legacy .color-container to
  <craft-input-color>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Moves Craft.BaseInputGenerator + the Handle/Slug/UriFormat/Dynamic generators
from the legacy cp bundle into a modern TS module
(resources/js/modules/input-generators), following the module pattern (logic
class on @craftcms/garnish Base + an index.ts global shim). There's no custom
element or support.ts: generators link two existing inputs rather than wrapping
their own markup, and the legacy base never stored a .data() back-reference.

The base orchestrator is jQuery-free — plain DOM (querySelectorAll,
element.value, closest, dispatchEvent, layout-based visibility) over garnish
Base's jQuery-free listener registry; Craft.selectFullValue is inlined so the
base doesn't touch Craft either. It accepts the same argument forms the legacy
class did — a selector, an element, or an array-like (NodeList / array / jQuery
object) — resolved via Array.from, so legacy jQuery callers keep working without
the module importing jQuery. The only remaining globals are Craft (live config)
and XRegExp (slug), in the subclasses — neither is jQuery.

Instantiation is unchanged — every consumer still does
new Craft.HandleGenerator('#name', '#handle') (Twig {% js %} blocks, TitleField,
editable-table, generated-fields), now hitting the TS classes via the assigned
window.Craft.* globals, so no templates change.

Reuses the shared transforms instead of duplicating them: Handle/UriFormat
delegate to toHandle / toUriFormat from @craftcms/ui/utilities/string (the same
functions the Vue settings pages use via the useInputGenerator composable), and
Slug reuses asciiString. BaseInputGenerator is the DOM orchestrator counterpart
to useInputGenerator (Vue) — a Vue composable can't watch raw DOM inputs on
not-yet-migrated Twig pages — and both share the transforms so the paths stay in
sync. Slug generation keeps its transform inline (it depends on the page-global
XRegExp and live Craft.* config). UriFormat now joins with `-` (the modern
toUriFormat canonical) rather than Craft.slugWordSeparator.

Deletes the five legacy JS files and their imports; the ported DynamicGenerator
keeps the plugin BC its earlier restoration preserved, now in modern TS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Craft.IconPicker extended Craft.BaseInputGenerator but used none of its
generation behavior — it overrides init() entirely and is really a Garnish.Base
widget. Porting the generators to modern TS made Craft.BaseInputGenerator a plain
ES class with no legacy .extend(), so `Craft.BaseInputGenerator.extend(...)` threw
at runtime. Extend Garnish.Base directly, severing the vestigial coupling (and
making the generator port's "nothing subclasses these via .extend()" hold true).

No reimplementation: the modern replacement already exists as
resources/js/common/form/IconPicker.vue (used by the migrated entry-types Edit
page). This legacy jQuery widget stays until its remaining Twig/jQuery surfaces
(editableTable, CustomizeSourcesModal, editable-table icon cells) migrate to Vue
— the Vue component is Inertia-coupled (useHttp / IconController), so it isn't
drop-in for those non-Inertia surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The icon picker's functionality already lives in the Vue component
resources/js/common/form/IconPicker.vue (used directly by the migrated
entry-types Edit page), so rather than re-porting the 246-line jQuery widget this
adds a thin mount bridge and reuses it.

- New module resources/js/modules/icon-picker: <craft-icon-picker> is a plain
  custom element that createApp(IconPicker, props).mount(this) from its attributes
  on connect and unmount()s on disconnect (mirrors the auth/elevated-session Vue
  island, per-instance). IconPicker.vue works standalone — its only Inertia dep is
  useHttp, which already runs outside an Inertia page (markdown-field). Adds
  id/labelledBy/describedBy props for the legacy label surfaces.
- Repoints consumers to emit the element: _includes/forms/iconPicker.twig, the
  Craft.ui.createIconPicker/createIconPickerField factories (editable-table icon
  cells), and CustomizeSourcesModal (now reads the element's `change` CustomEvent).
  iconPickerFieldHtml sets fieldset:true so the field's legend labels the group and
  the component stays label-less (no double label / id-association fragility).
- Deletes IconPicker.js and its import; adds a Craft.IconPicker deprecation stub to
  the cpcompat bundle for stray plugin callers.

Deferred: `small` isn't a component prop yet (editable-table cells render at normal
size) — noted in the module README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports the segmented slider onto a new <craft-slide-picker> Lit web component
(keyboard/RTL/ARIA, value-change event), following the web-component-swap pattern
used for the input swaps.

- Adds the component (packages/craftcms-ui) with a vitest test, styles, and
  CEM-typed stories (getStorybookHelpers, like avatar); exports it from the
  @craftcms/ui barrel (registered CP-wide via cp.ts).
- Dashboard and the field-layout-designer emit the element directly; UI.js
  createSlidePicker builds <craft-slide-picker> (with a hidden input for form
  posting) instead of new Craft.SlidePicker.
- Deletes the legacy SlidePicker.js + import and its legacy .slide-picker CSS.
- Adds a single Craft.SlidePicker warn-and-delegate stub to the yii2-adapter
  cpcompat bundle for stray plugin callers.

Conforms an initial pass to the established pattern: drops a redundant
SlidePickerCompat.js (BC shims don't belong in the legacy bundle) and a duplicate
resources/js/modules/slide-picker wrapper (which reintroduced jQuery and an
unjustified compatify — nothing subclasses Craft.SlidePicker), leaving the
cpcompat stub as the single BC path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports the image editor's ruler-style rotation control onto a new
<craft-slide-rule> Lit web component, following the web-component-swap pattern.

- Adds the component (packages/craftcms-ui) with the legacy ruler markup, the
  verbatim position math, keyboard nav, and ARIA slider semantics; drag is
  reimplemented on pointer events (modern @craftcms/garnish has no tap events).
  Emits start/change/end CustomEvents. Includes a vitest test (layout-independent
  behavior) and CEM-typed stories; exported from the @craftcms/ui barrel
  (registered CP-wide via cp.ts). Its shadow styles are ported from the legacy
  _image_editor.scss .slide-rule block, which is removed.
- Repoints image_editor.twig to emit <craft-slide-rule>, and AssetImageEditor to
  read the element (getElementById + start/change/end listeners; value=... instead
  of setValue()); drops the no-arg removeAllListeners() call (the component owns
  its listeners).
- Deletes SlideRuleInput.js + import; adds a Craft.SlideRuleInput warn stub to the
  cpcompat bundle.

Behavior — especially the drag feel and width-based position math — must be
verified live in the image editor's straighten tool; it can't be exercised here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Moves the conditional field-reveal widget from the legacy cp bundle into a modern
TS module (resources/js/modules/field-toggle), following the logic-class +
global-shim pattern (like the input generators). A toggle control
(checkbox/radio/switch, select, boolean menu, button, fieldset) shows/hides target
fields (data-target / data-reverse-target / data-target-prefix) based on its
value.

The engine is DOM-based and the velocity height animation is reimplemented on the
Web Animations API. jQuery survives only at the legacy `.data('fieldtoggle')` /
`.data('selectize')` seam that still-jQuery readers depend on (LinkField, and
craft-switch's __syncToggleTargets guard, which defers the switch reveal to a
bound FieldToggle); the instance is mirrored in a support.ts WeakMap that the
radio-group refresh uses jQuery-free — the sortable-checkbox-select precedent.

Instantiation is unchanged: the still-legacy $.fn.fieldtoggle plugin and the
Craft.initUiElements `.fieldtoggle` sweep (both in cp/src/js/Craft.js), plus
new Craft.FieldToggle(...) in UI.js / LinkField / ElementDeletionManager, boot via
the assigned window.Craft.FieldToggle global. Also drops a bogus
`import postcssValueParser from 'tailwindcss/src/value-parser'` that was in the
legacy file.

Per the taken-stock decision this stays the single full multi-type implementation;
craft-switch (switch case on Inertia surfaces) and craft-disclosure (button case)
remain for their surfaces.

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

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/uiopen Storybook

Changed components:

resources/jsopen Storybook

No changed components detected in this Storybook.

The craft-tooltip web component (LionTooltip) already covers the legacy tooltip,
and CP.js / CraftElementLabel / CraftGlobalSidebar already emit it directly. The
only remaining Craft.Tooltip consumer was ElementEditor's collaborative-editing
activity tooltips.

- Repoints ElementEditor: each activity button now gets a stable id and a
  sibling <craft-tooltip for="…"> recreated alongside it each poll (dropping the
  reuse/hide bookkeeping and the activityTooltips map); keyboard focus is
  preserved across the re-render via a data-user-id lookup. The message shows on
  hover/focus (Lion) instead of the legacy click-toggletip.
- Deletes Tooltip.js + its import; adds a Craft.Tooltip warn-and-delegate stub to
  the cpcompat bundle (creates a <craft-tooltip> for the trigger, exposes the
  legacy message/$trigger/show/hide/toggle surface) for stray plugin callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brianjhanson brianjhanson changed the title [6.x] JS modules port [6.x] JS modules port (Part I) Jul 27, 2026
Phase 1 of relocating the Craft.ui element factories out of the craftcms-legacy
bundle. Adds a new @craftcms/ui/factory layer — the client-side twin of the PHP
src/Cp/Components builders: config-object functions that return the typed custom
element, jQuery-free. Names align to the tags/PHP components.

- New packages/craftcms-ui/src/factory: createSwitch, createSlidePicker,
  createInputColor, createInputPassword (+ a shared slotted-input helper that
  replaces the createTextInput dependency for the two inputs). Exported at
  @craftcms/ui/factory; wired into the build entry list and a vitest project.
- Craft.ui.createLightswitch/createColorInput/createPasswordInput/createSlidePicker
  become thin shims that delegate to the factory and jQuery-wrap the element, so
  legacy + current TS callers of the window.Craft.ui global keep working.
- createIconPicker stays in UI.js for now: craft-icon-picker is an app-level
  Vue-mount element (resources/js/modules), not a @craftcms/ui component.

Note: the legacy webpack bundle now imports @craftcms/ui/factory, so build:ui must
run before build:bundles (build:all already orders this).

Co-Authored-By: Claude Opus 4.8 <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