Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
25ed18a
Load the adapter-only jQuery modules only with the adapter
brianjhanson Nov 2, 2026
bff7fca
Port the auth method setup and entry type override settings off jQuery
brianjhanson Nov 2, 2026
36e8664
Build chip action fallbacks without jQuery
brianjhanson Nov 2, 2026
a3c3781
Port the grid layout off jQuery
brianjhanson Nov 2, 2026
c742a8a
Port the file preview modal and focal point editor off jQuery
brianjhanson Nov 2, 2026
f7b860d
Port the CP modal and element deletion manager off jQuery
brianjhanson Nov 2, 2026
0137a70
Port the charts off jQuery
brianjhanson Nov 3, 2026
abfc5c7
Port the slideout off jQuery
brianjhanson Nov 3, 2026
c68f39d
Port the component select off jQuery
brianjhanson Nov 3, 2026
cf3bab4
Merge branch 'chore/js-modules' into chore/js-modules-jquery
brianjhanson Nov 3, 2026
d5759f4
Drop the jQuery getters from the ported slideout, grid, chart and modals
brianjhanson Nov 3, 2026
ec6715b
Boot element select inputs through their custom elements
brianjhanson Nov 3, 2026
8a336da
Give the adapter its own jQuery element select inputs
brianjhanson Nov 3, 2026
3675c7c
Skip dashboard grid refreshes after it unmounts
brianjhanson Nov 3, 2026
568b40c
Port core's element select inputs off jQuery
brianjhanson Nov 3, 2026
733ecb7
Stop core modules calling jQuery unconditionally
brianjhanson Nov 3, 2026
756748d
Drop jQuery from the inline scripts core templates emit
brianjhanson Nov 3, 2026
eaf0b01
Stop core's CpAsset loading jQuery and Garnish
brianjhanson Nov 3, 2026
9bd6ce9
Expect relation fields to boot through their custom element
brianjhanson Nov 3, 2026
8430ccb
Render server menus as craft-action-menu
brianjhanson Nov 3, 2026
fe3cb63
Move Craft.Tabs into the adapter
brianjhanson Nov 3, 2026
df6151a
Make the adapter's FocalPointAsset register nothing
brandonkelly Nov 4, 2026
ff284c1
Port core's remaining inline $ scripts to the DOM
brandonkelly Nov 4, 2026
bc77609
Keep element select inputs through DOM moves
brandonkelly Nov 4, 2026
07d2aa8
Render the menu when _includes/disclosuremenu is included directly
brandonkelly Nov 4, 2026
95f11ad
Measure chart tooltips after showing them
brandonkelly Nov 4, 2026
e03663c
Route chip menu actions through their element select input
brandonkelly Nov 4, 2026
e7d2e70
Find the Link field without jQuery
brandonkelly Nov 4, 2026
39cbced
Boot Screen.vue's fallback tabs without the adapter
brandonkelly Nov 4, 2026
8e3229c
Fix two accessibility problems in server-rendered menus
brandonkelly Nov 4, 2026
7079437
Tidy server-rendered menus
brandonkelly Nov 4, 2026
6a5a945
Load jQuery ahead of Inputmask
brandonkelly Nov 4, 2026
6d3e61e
Release element select chips' listeners when they're removed
brandonkelly Nov 4, 2026
c1da2e2
Update docs for the ported slideout and preview modal
brandonkelly Nov 4, 2026
e9934db
Note plugin-facing jQuery, menu, grid, and tab changes in the changelog
brandonkelly Nov 4, 2026
a6c5765
Port the remaining core form scripts off jQuery
brandonkelly Nov 4, 2026
70b5419
Hide all of a hidden menu group, and name only icon-only buttons
brandonkelly Nov 4, 2026
36809dd
Keep spaces in CP modal submissions
brandonkelly Nov 4, 2026
e4d66c2
Merge branch 'chore/js-modules' into chore/js-modules-jquery
brandonkelly Nov 4, 2026
f13eb91
Merge branch 'chore/js-modules' into chore/js-modules-jquery
brandonkelly Nov 4, 2026
d71607b
Run scripts nested in appended HTML
brandonkelly Nov 4, 2026
1dc8ea1
Merge branch 'chore/js-modules' into chore/js-modules-jquery
brianjhanson Nov 4, 2026
e0e1cd7
Load nested scripts in order before appendElementHtml() resolves
brandonkelly Nov 4, 2026
e45f74d
Strip IDs from grouped action menu items in UI payloads
brandonkelly Nov 4, 2026
5177d21
Initialize deletion blockers once their details are in
brandonkelly Nov 4, 2026
f76bd5f
Merge branch 'chore/js-modules' into chore/js-modules-jquery
brianjhanson Nov 4, 2026
ed99db0
Merge branch 'chore/js-modules' into chore/js-modules-jquery
brianjhanson Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -847,10 +847,16 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/
- The legacy element index JavaScript is now provided by the Yii2 adapter: `Craft.createElementIndex()`, `Craft.registerElementIndexClass()`, `Craft.BaseElementIndex`, `Craft.BaseElementIndexView` and its subclasses, `Craft.AssetIndex`, `Craft.CategoryIndex`, `Craft.EntryIndex`, `Craft.UserIndex`, `Craft.Structure`, `Craft.StructureDrag`, `Craft.ProgressBar`, `Craft.ElementTableSorter`, `Craft.IntervalManager`, `Craft.PromptHandler`, and `Craft.openCustomizeSourcesModal()`.
- `Craft.AdminTable` and `Craft.DataTableSorter` are now provided by the Yii2 adapter.
- `Craft.ui` and `Craft.EditableTable` are now provided by the Yii2 adapter.
- `Craft.CpScreenSlideout`, `Craft.ElementActionTrigger`, `Craft.Tabs`, and `Craft.createElementEditor()` are now provided by the Yii2 adapter. `Craft.Tabs`’ `selectTab` and `deselectTab` events now include the tab element as `tab`.
- The control panel no longer loads jQuery, Garnish, jQuery Touch Events, or Velocity. The Yii2 adapter still loads them for plugins, along with `Craft.js`. Asset bundles that use jQuery should depend on `craft\web\assets\jquery\JqueryAsset` rather than relying on `craft\web\assets\cp\CpAsset` to load it.
- Server-rendered menus (the `disclosureMenu()` Twig function, screen action and context menus, and breadcrumb menus) now render as `<craft-action-menu>`. With the Yii2 adapter installed, they keep the legacy disclosure menu markup. Without it, items’ `liAttributes` and groups’ `headingTag`, `headingAttributes`, `listAttributes`, and `padded` settings no longer apply, and menus with `withButton: false` get a default button unless an `invoker` is passed.
- Element select inputs now boot through `<craft-element-select-input>`, `<craft-entry-select-input>`, and `<craft-asset-select-input>` rather than inline JavaScript. `Craft.BaseElementSelectInput`, `Craft.EntrySelectInput`, and `Craft.AssetSelectInput` are now provided by the Yii2 adapter, and a relation field’s custom `$inputJsClass` is booted by name via the element’s `input-class` attribute.
- `Craft.Slideout`, `Craft.Grid`, `Craft.PreviewFileModal`, `Craft.ElementDeletionManager`, and the `Craft.charts` classes now store plain elements (`container`, `items`, etc.) instead of jQuery collections, and no longer have `$container`, `$items`, `$outerContainer`, `$shade`, `$chart`, or other `$`-prefixed properties. `Craft.Grid` reads its items’ column spans from their `data-colspan` attributes rather than jQuery data.
- `Craft.ElementEditor` and `Craft.ElementEditorSlideout` are now provided by the Yii2 adapter. Core’s element editors, slideouts included, all use the Vue editor.
- `Craft.CP` and `Craft.cp` are now provided by the Yii2 adapter. Control panel messages, the element clipboard and screen reader announcements no longer use them. `showNotice()`, `showSuccess()`, `showError()`, `copyElements()` and `announce()` are exported from `@craftcms/cp`.
- The legacy `Craft` helpers (`Craft.t()`, `Craft.sendActionRequest()`, `Craft.getUrl()`, `Craft.initUiElements()` and the rest of `Craft.js`), its jQuery plugins (`$.fn.disclosureMenu()`, `$.fn.menubtn()`, `$.fn.formsubmit()`…) and `Craft.queue` are now provided by the Yii2 adapter. Core uses `@craftcms/cp` and `@craftcms/ui` modules instead.
- Removed the `Craft.FocalPoint` and `Craft.Updater` control panel JavaScript classes. The image preview and the updater now start themselves from their markup.
- `craft\web\assets\focalpoint\FocalPointAsset` now does nothing.
- Added `AuthChallengeForm`, `AssetSelectorController`, `ElementSelectorController`, `Uploads`, `createElementSelectorModal`, `highlighter`, and `registerElementSelectorController` to the `@craftcms/cp` import-map module. Control panel plugin code should import them from there; they’re no longer `Craft.*` globals, except `Craft.createElementSelectorModal` and `Craft.highlighter`, which the Yii2 adapter still provides.
- Removed the classic (pre-3.2) `Craft.LivePreview` control panel JavaScript class, which Craft hasn’t used since 3.2. ([#19964](https://github.com/craftcms/cms/pull/19964))
- Deprecated the `Craft.LightSwitch`, `Craft.InfoIcon`, `Craft.ColorInput`, `Craft.PasswordInput`, `Craft.IconPicker`, `Craft.SlidePicker`, `Craft.SlideRuleInput`, and `Craft.Tooltip` control panel JavaScript classes, along with the `.infoicon` jQuery plugin. The corresponding `@craftcms/ui` web components should be used instead.
Expand Down
6 changes: 3 additions & 3 deletions packages/craftcms-legacy/dashboard/src/Dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,10 @@ import './dashboard.scss';
// Append the new widget after the last one
// (can't simply append it to the grid container, since that will place it after the resize listener object)

if (this.grid.$items.length) {
$gridItem.insertAfter(this.grid.$items.last());
if (this.grid.items.length) {
$gridItem.insertAfter(this.grid.items[this.grid.items.length - 1]);
} else {
$gridItem.prependTo(this.grid.$container);
$gridItem.prependTo(this.grid.container);
}

this.grid.addItems($gridItem);
Expand Down
26 changes: 26 additions & 0 deletions packages/craftcms-ui/src/utilities/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export async function appendElementHtml(
} else {
// Just inserted into `parent`, so it's a child node.
appended.push(node as ChildNode);
await runNestedScripts(node, rejectOnError);
}

if (loaded) {
Expand All @@ -104,6 +105,31 @@ export async function appendElementHtml(
}
}

/**
* Scripts nested in a wrapper clone as already started, so they're inert once
* the wrapper is inserted. Recreate them one at a time, waiting for each
* external script to load, so they run in order, as jQuery's `.html()` did.
*/
async function runNestedScripts(
node: Node,
rejectOnError: boolean
): Promise<void> {
if (!(node instanceof Element)) {
return;
}

for (const inert of node.querySelectorAll('script')) {
const script = cloneNode(inert) as HTMLScriptElement;
const loaded = script.src ? waitForScript(script, script.src) : null;

inert.replaceWith(script);

if (loaded) {
await awaitAsset(loaded, rejectOnError);
}
}
}

function cloneNode(source: Node): Node {
if (!(source instanceof HTMLScriptElement)) {
return source.cloneNode(true);
Expand Down
9 changes: 9 additions & 0 deletions resources/js/common/craft/chip-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,12 @@ it('puts prepended actions ahead of the existing ones', async () => {
const menu = el.querySelector('craft-action-menu')!;
await vi.waitFor(() => expect(itemLabels(menu)).toEqual(['Replace', 'Edit']));
});

it('leaves a chip without an action container alone when the legacy menu plugin isn’t loaded', () => {
const el = document.createElement('div');
el.className = 'chip';
document.body.append(el);

expect(() => addActionsToChip(el, [{label: 'Replace'}])).not.toThrow();
expect(el.children).toHaveLength(0);
});
62 changes: 32 additions & 30 deletions resources/js/common/craft/chip-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import {t} from '@craftcms/ui/utilities/translate';
import {setElementAttributes} from './attributes';
import {initLegacyUi} from './legacy-ui';

declare const $: any;

/** An action in the shape the legacy `disclosureMenu()` plugin accepts. */
export interface ChipAction {
label?: string;
Expand Down Expand Up @@ -172,9 +170,16 @@ function addActionsToDisclosureMenu(
actions: ChipAction[],
prepend: boolean
): void {
let $trigger = $(trigger);
// The legacy menu plugin comes with the Yii2 adapter
// oxlint-disable-next-line typescript/no-explicit-any
const $ = (window as {jQuery?: any}).jQuery;
if (typeof $?.fn.disclosureMenu !== 'function') {
return;
}

let triggerElement = trigger;

if (!trigger) {
if (!triggerElement) {
console.warn(
'A chip was given actions without an action container, so a deprecated ' +
'disclosure menu was built for it. Render the chip with a ' +
Expand All @@ -185,34 +190,31 @@ function addActionsToDisclosureMenu(

const menuId = `actions-${Math.floor(Math.random() * 1000000)}`;
const labelId = `${menuId}-label`;
const $label = $('<label/>', {
id: labelId,
class: 'visually-hidden',
text: t('Actions'),
});
$trigger = $('<button/>', {
class: 'btn action-btn',
type: 'button',
title: t('Actions'),
'aria-controls': menuId,
'aria-describedby': labelId,
'data-disclosure-trigger': 'true',
});
const $menu = $('<div/>', {
id: menuId,
class: 'menu menu--disclosure',
});

$('<div/>', {class: 'chip-actions'})
.append($label, $trigger, $menu)
.appendTo(chip);
}

// The legacy menu plugin comes with the Yii2 adapter
if (typeof $trigger.disclosureMenu !== 'function') {
return;
const label = document.createElement('label');
label.id = labelId;
label.className = 'visually-hidden';
label.textContent = t('Actions');

const button = document.createElement('button');
button.className = 'btn action-btn';
button.type = 'button';
button.title = t('Actions');
button.setAttribute('aria-controls', menuId);
button.setAttribute('aria-describedby', labelId);
button.dataset.disclosureTrigger = 'true';

const menu = document.createElement('div');
menu.id = menuId;
menu.className = 'menu menu--disclosure';

const container = document.createElement('div');
container.className = 'chip-actions';
container.append(label, button, menu);
chip.append(container);
triggerElement = button;
}

const $trigger = $(triggerElement);
const disclosureMenu = $trigger.disclosureMenu().data('disclosureMenu');
const before = prepend
? disclosureMenu.$container.children().first().get(0)
Expand Down
144 changes: 80 additions & 64 deletions resources/js/common/craft/element-markup.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,129 @@
import {PromiseQueue} from '@/common/utils/promise-queue';
import {jq} from '@/common/utils/jquery';

/**
* Helpers for legacy `div.element` markup, which element select inputs still
* render. They take and return jQuery collections, as their callers do.
*/

// oxlint-disable-next-line typescript/no-explicit-any
type JQueryLike = any;
/** Helpers for the legacy `div.element` chip markup element select inputs render. */

export interface ElementInfo {
id: unknown;
siteId: unknown;
label: unknown;
status: unknown;
url: unknown;
id: number | null;
siteId: number | null;
label: string | null;
status: string | null;
url: string | null;
hasThumb: boolean;
$element: JQueryLike;
element: HTMLElement | null;
}

/** The element's data, from it or the first `.element` inside it. */
export function getElementInfo(element: unknown): ElementInfo {
const $: JQueryLike = jq();
let $element = $(element);
function numberData(value: string | undefined): number | null {
if (value === undefined || value === '') {
return null;
}

if (!$element.hasClass('element')) {
$element = $element.find('.element:first');
const number = Number(value);

return Number.isFinite(number) ? number : null;
}

/** The element's data, from it or the first `.element` inside it. */
export function getElementInfo(source: Element | string): ElementInfo {
let root: Element | null = null;

if (typeof source === 'string') {
const template = document.createElement('template');
template.innerHTML = source.trim();
root = template.content.firstElementChild;
} else {
root = source;
}

const element = root?.classList.contains('element')
? (root as HTMLElement)
: (root?.querySelector<HTMLElement>('.element') ?? null);

return {
id: $element.data('id'),
siteId: $element.data('site-id'),
label: $element.data('label'),
status: $element.data('status'),
url: $element.data('url'),
hasThumb: $element.hasClass('has-thumb'),
$element,
id: numberData(element?.dataset.id),
siteId: numberData(element?.dataset.siteId),
label: element?.dataset.label ?? null,
status: element?.dataset.status ?? null,
url: element?.dataset.url ?? null,
hasThumb: element?.classList.contains('has-thumb') ?? false,
element,
};
}

/** Switches an element between its `small` and `large` sizes. */
export function setElementSize(element: unknown, size: string): void {
const $: JQueryLike = jq();
const $element = $(element);
export function setElementSize(element: HTMLElement, size: string): void {
const newSize = size === 'large' ? 'large' : 'small';

if ($element.hasClass(newSize)) {
if (element.classList.contains(newSize)) {
return;
}

$element
.addClass(newSize)
.removeClass(newSize === 'small' ? 'large' : 'small');

if ($element.hasClass('has-thumb')) {
const $oldImg = $element.find('> .thumb > img');
$oldImg.replaceWith(
$('<img/>', {
sizes: `${newSize === 'small' ? 30 : 100}px`,
srcset: $oldImg.attr('srcset') || $oldImg.attr('data-pfsrcset'),
})
element.classList.add(newSize);
element.classList.remove(newSize === 'small' ? 'large' : 'small');

const oldImg = element.classList.contains('has-thumb')
? element.querySelector<HTMLImageElement>(':scope > .thumb > img')
: null;

if (oldImg) {
const img = document.createElement('img');
img.sizes = `${newSize === 'small' ? 30 : 100}px`;
img.srcset =
oldImg.getAttribute('srcset') ||
oldImg.getAttribute('data-pfsrcset') ||
'';
oldImg.replaceWith(img);
}
}

type Styles = Record<string, string | number>;

function applyStyles(element: HTMLElement, styles: Styles): void {
for (const [property, value] of Object.entries(styles)) {
const name = property.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
element.style.setProperty(
name,
typeof value === 'number' && name !== 'opacity' && name !== 'z-index'
? `${value}px`
: String(value)
);
}
}

const transitions = new PromiseQueue();

/** Animates an element to the given CSS, after any animations already running. */
/** Animates an element to the given styles, after any animations already running. */
export function animate(
element: unknown,
css: Record<string, string | number>
element: HTMLElement,
styles: Styles
): Promise<unknown> {
return animateAll([[element, css]]);
return animateAll([[element, styles]]);
}

/**
* Animates several elements at once, in a view transition, after any
* animations already running. Without view transitions, the CSS just applies.
* animations already running. Without view transitions, the styles just apply.
*/
export function animateAll(
animations: Array<[unknown, Record<string, string | number>]>
animations: Array<[HTMLElement, Styles]>
): Promise<unknown> {
const $: JQueryLike = jq();

return transitions.push(async () => {
const $animations = animations.map(
([element, css]) => [$(element), css] as const
);

if (!document.startViewTransition) {
for (const [$element, css] of $animations) {
$element.css(css);
for (const [element, styles] of animations) {
applyStyles(element, styles);
}
return;
}

for (const [$element] of $animations) {
if ($element.css('view-transition-name') === 'none') {
$element.css(
'view-transition-name',
`vt-${Math.floor(Math.random() * 100000)}`
);
for (const [element] of animations) {
if (getComputedStyle(element).viewTransitionName === 'none') {
element.style.viewTransitionName = `vt-${Math.floor(Math.random() * 100000)}`;
}
}

try {
await document.startViewTransition(() => {
for (const [$element, css] of $animations) {
$element.css(css);
for (const [element, styles] of animations) {
applyStyles(element, styles);
}
}).finished;
} catch (error) {
Expand Down
10 changes: 4 additions & 6 deletions resources/js/common/slideouts/create-element-editor.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import $ from 'jquery';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vite-plus/test';
import type {OpenSlideoutOptions} from './types';

Expand All @@ -15,7 +14,6 @@ function lastOptions(): OpenSlideoutOptions {

describe('createElementEditor', () => {
beforeEach(() => {
vi.stubGlobal('$', $);
vi.stubGlobal('Craft', {actionUrl: '/actions'});
openSlideout.mockResolvedValue({id: 'slideout-1'});
});
Expand All @@ -26,9 +24,9 @@ describe('createElementEditor', () => {
});

it('opens the element’s editor in a Vue slideout', async () => {
const chip = $(
'<div class="element" data-id="83" data-site-id="2" data-draft-id="5"></div>'
);
const chip = document.createElement('div');
chip.className = 'element';
Object.assign(chip.dataset, {id: '83', siteId: '2', draftId: '5'});

const editor = createElementEditor('Entry', chip, {
params: {fresh: 1},
Expand All @@ -44,7 +42,7 @@ describe('createElementEditor', () => {
siteId: '2',
fresh: '1',
});
expect(lastOptions().opener).toBe(chip[0]);
expect(lastOptions().opener).toBe(chip);
});

it('reports a save as the legacy submit event', async () => {
Expand Down
Loading
Loading