diff --git a/CHANGELOG.md b/CHANGELOG.md index d34152bb32c..bb499544612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,39 @@ > [!IMPORTANT] > This update contains breaking changes for plugins. See [#19882](https://github.com/craftcms/cms/pull/19882) for details. +- Control panel screens from plugins that haven’t been updated for Craft 6 now render inside the control panel shell, with their tabs, assets, and JavaScript intact. +- Element index screens from plugins that haven’t been updated for Craft 6 now use the new element index, with the element type’s sources in the secondary nav and a Customize Sources action. +- Element sources’ `status` and `badgeCount` values are now shown in the secondary nav, as they were in the Craft 5 sidebar. ([#19892](https://github.com/craftcms/cms/pull/19892)) +- Tabs now mirror the selected tab in the URL hash, select the tab named by the hash on page load, and keep it after saving. +- Selectize fields are now rendered as `` web components, rather than Selectize instances. +- Autosuggest inputs are now rendered as `` web components, rather than Vue 2 `vue-autosuggest` instances. +- Autosuggest inputs now match suggestions on their hint as well as their label, and no longer cap each suggestion group at five items. +- Added `CraftCms\Cms\Cp\Navigation::sourceSubnav()`, which builds a subnav from an element type’s sources, for plugins adding their own control panel section. +- Added `CraftCms\Cms\Cp\Data\ActionItem::$status` and `status()`. ([#19892](https://github.com/craftcms/cms/pull/19892)) +- Added `craft.cp.selectize()` and `CraftCms\Cms\Cp\FormFields::selectizeFromConfig()`, which render the legacy selectize variables as a combobox. +- Added `craft.cp.select()` and `CraftCms\Cms\Cp\FormFields::selectFromConfig()`, which render the legacy select variables as a ``. +- Added `craft.cp.autosuggest()` and `CraftCms\Cms\Cp\FormFields::autosuggestFromConfig()`, which render the legacy autosuggest variables as a combobox. Craft 5’s `suggestions` shape is still accepted. +- Added `CraftCms\Cms\Cp\Components\Combobox::labelledBy()`, for a combobox labelled by an element the caller renders itself. +- Added `CraftCms\Cms\Plugin\Concerns\HasSettings::createSettings()`, a static method which replaces `CraftCms\Cms\Contracts\PluginInterface::createSettings()`. ([#19574](https://github.com/craftcms/cms/pull/19574)) +- `CraftCms\Cms\Element\Element::searchableAttributes()` is now `final`. `defineSearchableAttributes()` should be overridden instead, including by element types extending `craft\base\Element` through the Yii adapter. +- `CraftCms\Cms\Element\Element::prepareEditScreen()` now accepts a `Symfony\Component\HttpFoundation\Response|CraftCms\Cms\Http\Responses\CpScreenResponse` argument, rather than `yii\web\Response`. Overrides must update their signatures. +- `CraftCms\Cms\Element\Queries\ElementQuery::statusCondition()` now returns a `Closure` which modifies an `Illuminate\Database\Query\Builder`, rather than a Yii query condition. Overrides must update their signatures and return closures. +- `craft\base\Model::attributes()` now declares an `array` return type. Overrides must update their signatures. +- Plugin settings models must now extend `CraftCms\Cms\Plugin\PluginSettings` rather than `craft\base\Model`. +- Plugin settings are now validated using Laravel validation rules returned by `CraftCms\Cms\Plugin\PluginSettings::getRules()`, rather than Yii validation rules returned by `rules()`. Validation errors are added via the `$fail` callback or `errors()`, rather than `addError()`. +- Plugin settings are now persisted based on the settings model’s public properties, via `CraftCms\Cms\Plugin\PluginSettings::configData()`. Settings stored in private properties with getter and setter methods must be redeclared as public properties, or included by overriding `configData()`. +- Removed the `data` and `methods` blocks from the `_includes/forms/autosuggest.twig` template, along with the Vue instance they configured. Templates that overrode them should pass `suggestions` instead. +- Removed `CraftCms\Cms\Plugin\Contracts\PluginInterface::createSettingsModel()`. The static `createSettings()` must be implemented instead. (`createSettingsModel()` remains supported for plugins extending `craft\base\Plugin` through the Yii adapter.) ([#19574](https://github.com/craftcms/cms/pull/19574)) - Improved control panel styling for medium-sized viewports. - Renamed `CraftCms\Cms\Form` to `CraftCms\Cms\Ui`, along with its `Form` classes and methods, and the JavaScript `forms` module and its definition APIs to `ui`, including payload fields and refresh endpoints. ([#19882](https://github.com/craftcms/cms/pull/19882)) - Fixed an error that could occur after upgrading to Craft 6 when Laravel queue or other supporting database tables were missing. ([#19889](https://github.com/craftcms/cms/pull/19889)) - Fixed a bug where new control panel assets weren’t getting published when updating via the control panel or `craft:update` command. +- Fixed an error that occurred when a Craft 5 plugin registered a utility, which no longer satisfied `CraftCms\Cms\Utility\UtilityTypes`’ contract. ([#19852](https://github.com/craftcms/cms/pull/19852)) +- Fixed an error that occurred when registering asset bundles that depend on `CraftCms\Cms\View\LegacyAssets\HtmxAsset`. The bundle is now deprecated and does nothing. +- Fixed a bug where the tab strip wasn’t guttered with the content it sits above. ([#19861](https://github.com/craftcms/cms/pull/19861)) +- Fixed a bug where an editable table that wasn’t visible when the page loaded didn’t initialize until the window was resized. +- Fixed a bug where environment variable, alias, and template suggestions weren’t sorted alphabetically. +- Fixed a bug where status indicators for disabled elements were filled in rather than outlined, and custom status colors were shown as gray. ([#19892](https://github.com/craftcms/cms/pull/19892)) - Fixed a bug where admin tables no longer had selectable rows. ([#19893](https://github.com/craftcms/cms/pull/19893)) - Fixed a bug where the license enforcement screen wasn’t showing the “Continue to the control panel” link after the countdown ended. ([#19895](https://github.com/craftcms/cms/issues/19895)) diff --git a/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js b/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js index 12e16b24809..254568773dc 100644 --- a/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js +++ b/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js @@ -3928,7 +3928,11 @@ Craft.BaseElementIndex = Garnish.Base.extend( }) .appendTo($form); - const $exportSubmit = new Garnish.MultiFunctionBtn($submitBtn); + // Guarded: the modernized Garnish has no MultiFunctionBtn. Without it + // the export button just stays a plain button rather than throwing. + const $exportSubmit = Garnish.MultiFunctionBtn + ? new Garnish.MultiFunctionBtn($submitBtn) + : null; var hud = new Garnish.HUD(this.$exportBtn, $form); @@ -3946,7 +3950,7 @@ Craft.BaseElementIndex = Garnish.Base.extend( } submitting = true; - $exportSubmit.busyEvent(); + $exportSubmit?.busyEvent(); var params = this.getViewParams(); delete params.baseCriteria.offset; @@ -3986,7 +3990,7 @@ Craft.BaseElementIndex = Garnish.Base.extend( }) .finally(() => { submitting = false; - $exportSubmit.successEvent(); + $exportSubmit?.successEvent(); }); }); }, diff --git a/packages/craftcms-legacy/cp/src/js/Craft.js b/packages/craftcms-legacy/cp/src/js/Craft.js index b82f1e08947..a9fc29afe10 100644 --- a/packages/craftcms-legacy/cp/src/js/Craft.js +++ b/packages/craftcms-legacy/cp/src/js/Craft.js @@ -3041,7 +3041,7 @@ $.extend($.fn, { */ checkboxselect: function () { return this.each(function () { - if (!$.data(this, 'checkboxSelect')) { + if (!$.data(this, 'checkboxSelect') && Garnish.CheckboxSelect) { new Garnish.CheckboxSelect(this, { storageKey: this.getAttribute('data-storage-key'), }); @@ -3062,7 +3062,7 @@ $.extend($.fn, { nicetext: function () { return this.each(function () { - if (!$.data(this, 'nicetext')) { + if (!$.data(this, 'nicetext') && Garnish.NiceText) { new Garnish.NiceText(this); } }); @@ -3277,16 +3277,22 @@ $.extend($.fn, { }, }); -// Override Garnish.NiceText.charsLeftHtml() to be more accessible -Garnish.NiceText.charsLeftHtml = (charsLeft) => { - return Craft.t( - 'app', - 'Characters left: {chars, number}', - { - chars: charsLeft, - } - ); -}; +// Override Garnish.NiceText.charsLeftHtml() to be more accessible. +// Guarded: the modernized Garnish has no NiceText — `craft-input`'s +// `showCharsLeft` covers it — and this runs at module scope, so an +// unguarded assignment aborts the rest of Craft.js on any page where the +// modern namespace owns `window.Garnish`. +if (Garnish.NiceText) { + Garnish.NiceText.charsLeftHtml = (charsLeft) => { + return Craft.t( + 'app', + 'Characters left: {chars, number}', + { + chars: charsLeft, + } + ); + }; +} Garnish.$doc.ready(function () { Craft.initUiElements(); diff --git a/packages/craftcms-legacy/cp/src/js/UI.js b/packages/craftcms-legacy/cp/src/js/UI.js index 6a48b2bcc70..9c6c039d281 100644 --- a/packages/craftcms-legacy/cp/src/js/UI.js +++ b/packages/craftcms-legacy/cp/src/js/UI.js @@ -125,7 +125,9 @@ Craft.ui = { ) ); - if (config.placeholder || config.showCharsLeft) { + // Guarded: the modernized Garnish has no NiceText, and `craft-input`'s + // own `showCharsLeft` covers it where that namespace is in play. + if ((config.placeholder || config.showCharsLeft) && Garnish.NiceText) { new Garnish.NiceText($input); } @@ -535,7 +537,11 @@ Craft.ui = { if (config.includeSortActions) { new Craft.SortableCheckboxSelect($container); } else { - new Garnish.CheckboxSelect($container); + // Guarded: superseded by the CheckboxSelect component where the + // modernized Garnish owns the namespace. + if (Garnish.CheckboxSelect) { + new Garnish.CheckboxSelect($container); + } if (config.sortable) { const dragSort = new Garnish.DragSort( diff --git a/packages/craftcms-ui/src/components/combobox/combobox-form.browser.test.ts b/packages/craftcms-ui/src/components/combobox/combobox-form.browser.test.ts new file mode 100644 index 00000000000..1f80dd7b902 --- /dev/null +++ b/packages/craftcms-ui/src/components/combobox/combobox-form.browser.test.ts @@ -0,0 +1,91 @@ +import {beforeEach, expect, it} from 'vite-plus/test'; +import type CraftCombobox from './combobox.js'; +import './combobox.js'; +import '../../styles/cp.css'; + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +/** + * A single-choice combobox in a plain form — the shape a page that posts itself + * uses, rather than going through an Inertia form. + */ +async function fixture( + configure: (control: CraftCombobox) => void = () => {} +): Promise<{control: CraftCombobox; form: HTMLFormElement}> { + const control = document.createElement('craft-combobox') as CraftCombobox; + control.setAttribute('name', 'apiVersion'); + control.options = [ + {label: '2026-01', value: '2026-01'}, + {label: '2026-04', value: '2026-04'}, + ]; + configure(control); + + const form = document.createElement('form'); + form.append(control); + document.body.append(form); + await control.updateComplete; + // Lion adopts a requested `model-value` only once the option naming it has + // registered, so the value lands a tick after the first render. + await new Promise((resolve) => requestAnimationFrame(resolve)); + await control.updateComplete; + + return {control, form}; +} + +const entries = (form: HTMLFormElement) => [...new FormData(form).entries()]; + +it('posts its value under the host name', async () => { + const {control, form} = await fixture((c) => + c.setAttribute('model-value', '2026-01') + ); + + expect(entries(form)).toEqual([['apiVersion', '2026-01']]); + + // Changing the selection changes what the form carries, so a save writes the + // value the field is showing. + control.modelValue = '2026-04'; + await control.updateComplete; + + expect(entries(form)).toEqual([['apiVersion', '2026-04']]); +}); + +it('posts a custom value when it does not match an option', async () => { + const {control, form} = await fixture((c) => { + c.requireOptionMatch = false; + c.setAttribute('model-value', '$SHOPIFY_API_VERSION'); + }); + + expect(entries(form)).toEqual([['apiVersion', '$SHOPIFY_API_VERSION']]); + expect(control.modelValue).toBe('$SHOPIFY_API_VERSION'); +}); + +it('posts a value its markup carried, with the options as markup too', async () => { + // How a server-rendered page arrives: both `model-value` and `options` are + // attributes on the element, and it upgrades with them already set. + const options = JSON.stringify([ + {label: '$SHOPIFY_API_VERSION', value: '$SHOPIFY_API_VERSION'}, + ]); + document.body.innerHTML = `
`; + + const form = document.querySelector('form')!; + const control = document.querySelector('craft-combobox') as CraftCombobox; + await control.updateComplete; + await new Promise((resolve) => requestAnimationFrame(resolve)); + await control.updateComplete; + + expect(control.modelValue).toBe('@webroot/cache/blitz'); + expect(entries(form)).toEqual([['folderPath', '@webroot/cache/blitz']]); +}); + +it('posts nothing while disabled', async () => { + const {control, form} = await fixture((c) => { + c.setAttribute('model-value', '2026-01'); + c.disabled = true; + }); + + await control.updateComplete; + + expect(entries(form)).toEqual([]); +}); diff --git a/packages/craftcms-ui/src/components/combobox/combobox-options.browser.test.ts b/packages/craftcms-ui/src/components/combobox/combobox-options.browser.test.ts new file mode 100644 index 00000000000..9076c139af8 --- /dev/null +++ b/packages/craftcms-ui/src/components/combobox/combobox-options.browser.test.ts @@ -0,0 +1,73 @@ +import {beforeEach, expect, it} from 'vite-plus/test'; +import type CraftCombobox from './combobox.js'; +import './combobox.js'; + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +async function fixture( + options: CraftCombobox['options'] +): Promise { + const control = document.createElement('craft-combobox') as CraftCombobox; + control.options = options; + document.body.append(control); + await control.updateComplete; + await new Promise((resolve) => requestAnimationFrame(resolve)); + await control.updateComplete; + + return control; +} + +it('colours an option’s indicator', async () => { + const control = await fixture([ + {label: 'Online', value: 'true', data: {indicator: {fill: 'success'}}}, + {label: 'Offline', value: 'false', data: {indicator: {fill: 'danger'}}}, + ]); + + const fills = [...control.querySelectorAll('craft-indicator')].map((el) => + el.getAttribute('fill') + ); + + expect(fills).toEqual(['success', 'danger']); +}); + +it('leaves an indicator without a fill on the component’s default', async () => { + const control = await fixture([ + {label: 'Plain', value: 'plain', data: {indicator: {}}}, + ]); + + const indicator = control.querySelector('craft-indicator')!; + + expect(indicator.getAttribute('fill')).toBe('var(--c-color-fill-loud)'); +}); + +it('renders an indicator’s appearance', async () => { + const control = await fixture([ + {label: 'Off', value: 'off', data: {indicator: {appearance: 'outline'}}}, + ]); + + expect( + control.querySelector('craft-indicator')!.getAttribute('appearance') + ).toBe('outline'); +}); + +it('colours an option’s icon', async () => { + const control = await fixture([ + {label: 'Red', value: 'red', data: {icon: 'circle', color: 'danger'}}, + ]); + + expect(control.querySelector('craft-icon')!.getAttribute('style')).toContain( + '--icon-color: danger' + ); +}); + +it('leaves an icon without a colour unstyled', async () => { + const control = await fixture([ + {label: 'Plain', value: 'plain', data: {icon: 'circle'}}, + ]); + + expect(control.querySelector('craft-icon')!.hasAttribute('style')).toBe( + false + ); +}); diff --git a/packages/craftcms-ui/src/components/combobox/combobox.stories.ts b/packages/craftcms-ui/src/components/combobox/combobox.stories.ts index b934d7b53c4..a60b52b0639 100644 --- a/packages/craftcms-ui/src/components/combobox/combobox.stories.ts +++ b/packages/craftcms-ui/src/components/combobox/combobox.stories.ts @@ -16,8 +16,8 @@ const countries: ComboboxItem[] = [ ]; const statuses: ComboboxItem[] = [ - {label: 'Online', value: 'true', data: {indicator: {variant: 'success'}}}, - {label: 'Offline', value: 'false', data: {indicator: {variant: 'danger'}}}, + {label: 'Online', value: 'true', data: {indicator: {fill: 'success'}}}, + {label: 'Offline', value: 'false', data: {indicator: {fill: 'danger'}}}, ]; const grouped: ComboboxItem[] = [ diff --git a/packages/craftcms-ui/src/components/combobox/combobox.ts b/packages/craftcms-ui/src/components/combobox/combobox.ts index c20a73fb97e..0bac91186c5 100644 --- a/packages/craftcms-ui/src/components/combobox/combobox.ts +++ b/packages/craftcms-ui/src/components/combobox/combobox.ts @@ -17,8 +17,11 @@ export interface ComboboxOptionData { keywords?: string; /** Secondary text rendered after the label. */ hint?: string; - /** Renders a `craft-indicator` before the label. */ - indicator?: {variant?: string} & Record; + /** + * Renders a `craft-indicator` before the label. `fill` takes what the + * component's own does: a status variant, a palette swatch, or any colour. + */ + indicator?: {fill?: string; appearance?: string} & Record; /** Name of a `craft-icon` rendered before the label, and in the textbox while this option is selected. */ icon?: string; [key: string]: unknown; @@ -244,6 +247,7 @@ export default class CraftCombobox extends HasLabel(LionCombobox) { this.#lastNotifiedValue = this.modelValue; this.#committedValue = this.modelValue; this.#renderOptions(); + this.#adoptPendingModelValue(); if (this.multipleChoice) { this.initialValues = [ ...(Array.isArray(this.pendingModelValue) @@ -253,6 +257,35 @@ export default class CraftCombobox extends HasLabel(LionCombobox) { } } + /** + * Re-applies the value the `model-value` attribute asked for. + * + * Lion adopts a value once an option names it, and on an element upgraded + * from server-rendered markup the attribute lands before the options exist, + * so the value is dropped. Nothing has been announced yet at this point, so + * this is silent. + */ + #adoptPendingModelValue(): void { + if ( + this.pendingModelValue === undefined || + JSON.stringify(this.modelValue) === JSON.stringify(this.pendingModelValue) + ) { + return; + } + + const pending = this.pendingModelValue; + this.changingValues = true; + + try { + this.modelValue = pending; + } finally { + this.changingValues = false; + } + + this.#lastNotifiedValue = this.modelValue; + this.#committedValue = this.modelValue; + } + /** Emits native `change` when the value has moved since the last commit. */ #commit(): void { // Multiple choice announces its own `change` once per user edit (see the @@ -271,6 +304,15 @@ export default class CraftCombobox extends HasLabel(LionCombobox) { override updated(changed: Map) { super.updated(changed); + this.#syncFormValue(); + // An `aria-labelledby` on the host names a label the consumer renders + // itself — a table column heading, say — rather than one in the `label` + // slot. Lion rewrites the textbox's own `aria-labelledby` from its (empty) + // label on every update, so this has to be reapplied after `super`. + const labelledBy = this.getAttribute('aria-labelledby'); + if (labelledBy) { + this._inputNode?.setAttribute('aria-labelledby', labelledBy); + } // Lion names the listbox after its own label only, which is empty when a // wrapping craft-field provides the label. this._listboxNode?.setAttribute( @@ -576,11 +618,15 @@ export default class CraftCombobox extends HasLabel(LionCombobox) { ${data.indicator ? html`` : nothing} ${data.icon - ? html`` + ? html`` : nothing} ${isEnv ? html`${label}` : label} @@ -684,6 +730,39 @@ export default class CraftCombobox extends HasLabel(LionCombobox) { ); } + /** + * Publishes the value for native form submission. + * + * The host carries the `name` and the class declares `formAssociated`, but a + * single-choice combobox never told `ElementInternals` what its value was, so + * it contributed nothing to a native POST — a form read with `FormData`, or a + * page that posts itself rather than going through an Inertia form, saw the + * field as absent and left the old value in place. Multiple choice has its + * own hidden inputs; this is the single-choice equivalent. + */ + #syncFormValue() { + if (this.multipleChoice) { + return; + } + + /** + * `ElementInternals` is the whole mechanism here, so without it there is + * nothing to publish. happy-dom doesn't implement `attachInternals()`, and + * this runs from `updated()` on every render — so an unguarded call made + * rendering a combobox throw outright in any test using that environment. + */ + if (typeof this.attachInternals !== 'function') { + return; + } + + this.internals ??= this.attachInternals(); + + const omit = !this.name || this.disabled || this.fieldsetDisabled; + const value = typeof this.modelValue === 'string' ? this.modelValue : ''; + + this.internals.setFormValue(omit ? null : value); + } + private syncInputs() { if (!this.inputs) { this.inputs = document.createElement('span'); @@ -717,7 +796,11 @@ export default class CraftCombobox extends HasLabel(LionCombobox) { this.modelValue = [...this.initialValues]; this.value = ''; this.syncInputs(); + + return; } + + this.#syncFormValue(); } /** Tracks whether an ancestor `
` has disabled the control. */ diff --git a/packages/craftcms-ui/src/components/tabs/tabs.test.ts b/packages/craftcms-ui/src/components/tabs/tabs.test.ts index 2227e0d7367..54b3253eb66 100644 --- a/packages/craftcms-ui/src/components/tabs/tabs.test.ts +++ b/packages/craftcms-ui/src/components/tabs/tabs.test.ts @@ -82,6 +82,9 @@ function declarations(fragment: string): string { beforeEach(() => { document.body.innerHTML = ''; + // Tabs sync the hash by default, so one test's selection would pick the + // starting tab for the next. + window.history.replaceState(null, '', window.location.pathname); }); describe('structure', () => { @@ -632,6 +635,85 @@ describe('external-panel mode', () => { * skips a selection it can't find in it — needs a `slotchange` happy-dom never * fires, so it's covered by the IconToolbar story. */ +describe('location hash', () => { + const setHash = (hash: string) => { + window.history.replaceState(null, '', `#${hash}`); + }; + + it('selects the panel the hash names on load', async () => { + setHash('panel-2'); + + const {element} = await createExternalTabs(); + + expect(element.selectedIndex).toBe(2); + }); + + it('writes the selected panel’s id to the hash', async () => { + const {element} = await createExternalTabs(); + + element.selectedIndex = 1; + await element.updateComplete; + + expect(window.location.hash).toBe('#panel-1'); + }); + + it('syncs without being asked to', async () => { + const {element} = await createExternalTabs(); + + element.selectedIndex = 1; + await element.updateComplete; + + expect(window.location.hash).toBe('#panel-1'); + }); + + it('leaves the hash alone when turned off', async () => { + const {element} = await createExternalTabs({ + attrs: {'sync-location-hash': 'false'}, + }); + + element.selectedIndex = 1; + await element.updateComplete; + + expect(window.location.hash).toBe(''); + }); + + it('leaves the hash alone for a slotted strip, which has no panel ids', async () => { + const element = document.createElement('craft-tabs') as CraftTabs; + for (let i = 0; i < 2; i++) { + const tab = document.createElement('craft-tab') as CraftTab; + tab.slot = 'tab'; + tab.textContent = `Tab ${i}`; + element.append(tab); + } + document.body.append(element); + await element.updateComplete; + + element.selectedIndex = 1; + await element.updateComplete; + + expect(window.location.hash).toBe(''); + }); + + it('ignores a hash that names no panel', async () => { + setHash('nothing-here'); + + const {element} = await createExternalTabs(); + + expect(element.selectedIndex).toBe(0); + expect(window.location.hash).toBe('#nothing-here'); + }); + + it('follows the hash when it changes', async () => { + const {element} = await createExternalTabs(); + + setHash('panel-2'); + window.dispatchEvent(new HashChangeEvent('hashchange')); + await element.updateComplete; + + expect(element.selectedIndex).toBe(2); + }); +}); + describe('collapsible', () => { /** Escape, as it arrives from a focused tab. */ function escape(tab: CraftTab) { diff --git a/packages/craftcms-ui/src/components/tabs/tabs.ts b/packages/craftcms-ui/src/components/tabs/tabs.ts index 08a9bf08e21..5d80c620be0 100644 --- a/packages/craftcms-ui/src/components/tabs/tabs.ts +++ b/packages/craftcms-ui/src/components/tabs/tabs.ts @@ -251,6 +251,23 @@ export default class CraftTabs extends LionTabs { @property({type: Boolean, reflect: true, attribute: 'equal-width'}) equalWidth = false; + /** + * Whether the selected tab is mirrored in `location.hash`, on by default. + * + * Only a tab naming a panel through `controls` has a hash: that id is the + * page's, so it means something in a URL and survives a reload. A slotted + * strip's ids are generated per render, so there is nothing to link to and + * nothing is written. + * + * Set `sync-location-hash="false"` where the strip isn't the page's own — a + * dialog, or a slideout over a page whose URL belongs to what's behind it. + */ + @property({ + attribute: 'sync-location-hash', + converter: {fromAttribute: (value: string | null) => value !== 'false'}, + }) + syncLocationHash = true; + /** * Which axis the tab strip runs along: `horizontal` or `vertical`. * @@ -395,6 +412,11 @@ export default class CraftTabs extends LionTabs { this.addEventListener('click', this.#handleClick, true); this.addEventListener('keydown', this.#handleEscape); + if (this.syncLocationHash) { + this.#selectFromHash(); + window.addEventListener('hashchange', this.#selectFromHash); + } + // Overflow is independent of the mode: the strip is the same either way. tabSlot?.addEventListener('slotchange', this.#queueMeasure); this.#resizeObserver = new ResizeObserver(this.#queueMeasure); @@ -404,6 +426,7 @@ export default class CraftTabs extends LionTabs { override disconnectedCallback() { super.disconnectedCallback(); + window.removeEventListener('hashchange', this.#selectFromHash); this.#teardownExternal(); this.#resizeObserver?.disconnect(); this.#resizeObserver = undefined; @@ -430,6 +453,7 @@ export default class CraftTabs extends LionTabs { // here under our own name. The guard keeps the initial render quiet: // there is no previous index to have changed from. if (changedProperties.get('selectedIndex') !== undefined) { + this.#writeHash(); this.dispatchEvent(new CustomEvent('craft-tab-show')); } } @@ -569,6 +593,49 @@ export default class CraftTabs extends LionTabs { * The tab a closed strip keeps in the tab order and reopens on: the one the * selection was last on, or the first usable tab if that one's gone. */ + /** The hash a tab is reached by: the id of the panel it names. */ + #hashFor(index: number): string | null { + return this.#tabs[index]?.controls || null; + } + + /** Selects the tab the current hash names, if it names one. */ + #selectFromHash = () => { + const hash = decodeURIComponent(window.location.hash.slice(1)); + + if (hash === '') { + return; + } + + const index = this.#tabs.findIndex( + (_, position) => this.#hashFor(position) === hash + ); + + if (index >= 0 && index !== this.selectedIndex) { + this.selectedIndex = index; + } + }; + + /** + * Mirrors the selection into the hash. + * + * Replaces rather than pushes: moving between tabs isn't navigation, and a + * history entry per tab would make Back walk them instead of leaving the + * page. + */ + #writeHash(): void { + const hash = this.syncLocationHash + ? this.#hashFor(this.selectedIndex) + : null; + + if (hash === null) { + return; + } + + const url = new URL(window.location.href); + url.hash = hash; + window.history.replaceState(window.history.state, '', url); + } + #entryTab(): number { const tabs = this.#tabs; const focusable = (index: number) => diff --git a/packages/craftcms-ui/src/styles/shared/base.css b/packages/craftcms-ui/src/styles/shared/base.css index cce203d8692..82f937c7997 100644 --- a/packages/craftcms-ui/src/styles/shared/base.css +++ b/packages/craftcms-ui/src/styles/shared/base.css @@ -217,6 +217,7 @@ Modify with `cp-table--auto` to apply table-layout: auto th, td { + text-align: start; padding-block: var(--_cell-spacing-block); padding-inline: var(--_cell-spacing-inline); position: relative; diff --git a/packages/craftcms-ui/src/utilities/dom.ts b/packages/craftcms-ui/src/utilities/dom.ts index 3944209abd2..c281963fbdd 100644 --- a/packages/craftcms-ui/src/utilities/dom.ts +++ b/packages/craftcms-ui/src/utilities/dom.ts @@ -22,7 +22,8 @@ export type AppendHtmlDisposer = () => void; export async function appendElementHtml( html: string, parent: HTMLElement, - rejectOnError = false + rejectOnError = false, + before: Node | null = null ): Promise { const appended: Node[] = []; const releases: Array<() => void> = []; @@ -69,7 +70,13 @@ export async function appendElementHtml( ? waitForScript(node, asset.value) : null; - parent.appendChild(node); + // `insertBefore(node, null)` appends, but going through `appendChild` + // keeps the primitive every caller without a marker already used. + if (before) { + parent.insertBefore(node, before); + } else { + parent.appendChild(node); + } if ( asset && @@ -182,12 +189,22 @@ function releaseAsset(key: string, asset: OwnedAsset): void { /** * Appends HTML to the page ``. * + * Inserts ahead of the `craft-head-anchor` marker where the document carries + * one, so assets injected during a client-side visit land in the same band as + * the ones the root view renders — above the control panel stylesheet, which + * stays authoritative in both cases. Without the marker this appends, as before. + * * Returns a disposer that removes the appended nodes when called. */ export async function appendHeadHtml( html: string ): Promise { - return appendElementHtml(html, document.head); + return appendElementHtml( + html, + document.head, + false, + document.head.querySelector('meta[name="craft-head-anchor"]') + ); } /** diff --git a/resources/css/legacy.css b/resources/css/legacy.css new file mode 100644 index 00000000000..901e7fafd4a --- /dev/null +++ b/resources/css/legacy.css @@ -0,0 +1,198 @@ +/** +Legacy compat styles + */ +@scope (.cp-legacy-screen) { + .skip-link { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + + .field { + margin-block: var(--c-spacing-lg); + } + + table.data { + width: 100%; + } + + /* Tables — `_elements/revisions.twig` and any other legacy `table.data`. + Ported from the legacy stylesheet onto current tokens, because the vars it + used (`--gray-050`, `--light-text-color`, `--padding`) aren't loaded here. */ + + /* The legacy rule also carried negative margins to bleed the table out of a + pane's padding. There's no such pane in this shell — `CpContainer` owns the + gutters — so only the scroll behavior comes across. */ + .tablepane { + overflow-x: auto; + } + + table.data td, + table.data th { + box-sizing: border-box; + padding-inline: var(--c-spacing-md); + position: relative; + } + + table.data th { + font-weight: 700; + } + + table.data thead td, + table.data thead th { + background-color: var(--c-color-neutral-fill-quiet); + cursor: default; + padding-block: var(--c-spacing-sm); + vertical-align: middle; + white-space: nowrap; + width: auto; + } + + table.data thead:first-child tr:first-child th:first-child { + border-start-start-radius: var(--c-radius-sm); + } + + table.data thead:first-child tr:first-child th:last-child { + border-start-end-radius: var(--c-radius-sm); + } + + table.data tbody td, + table.data tbody th { + max-width: 33vw; + padding-block: var(--c-spacing-sm); + } + + table.data tbody tr:not(.disabled):hover td, + table.data tbody tr:not(.disabled):hover th { + background-color: var(--c-color-neutral-fill-quiet); + } + + /* Revision rows are focusable (`tabindex="0"`), so a focused row has to sit + above its neighbours' borders. */ + table.data tbody tr:not(.disabled):focus { + position: relative; + z-index: 1; + } + + /* Transparent on purpose, as in the legacy sheet: it reserves the row's + border box so selecting a row can colour it without shifting the layout. */ + table.data thead + tbody tr td, + table.data thead + tbody tr th, + table.data tr + tr td, + table.data tr + tr th { + border-block-start: 1px solid transparent; + } + + /* Narrow viewports: legacy JS adds `.collapsed`, and each cell's `data-title` + becomes its inline label. */ + table.collapsed tbody [data-title] { + margin-inline-end: 0; + } + + table.collapsed tbody [data-title]::before { + content: attr(data-title) ': '; + font-weight: 700; + margin-inline-end: 5px; + } + + /* Empty state — "This entry doesn't have revisions." */ + .zilch { + color: var(--c-text-quiet); + font-size: 20px; + line-height: 24px; + padding-block: 100px; + padding-inline: 0; + text-align: center; + } + + .cp-details { + padding: var(--c-spacing-md); + } + + .slideout-screen__details { + padding: var(--c-spacing-md); + } + + /* Draft notice — the provisional-draft banner the legacy element editor puts + in `#content-notice`, with its icon and "Discard changes" button. Ported + onto current tokens: the legacy rule's blues (`--blue-600`/`--blue-800`, + plus `color.adjust()` on the SCSS `$blue*` variables) map to the accent + group, which also means the notice follows dark mode rather than staying + light-on-light. */ + + /* The legacy rule also styled `.so-notice > .draft-notice`, bleeding the + notice out of a slideout's padding with negative margins and a gradient. + Slideouts don't render through this shell, so that branch isn't carried + over. */ + .draft-notice { + display: flex; + align-items: center; + place-content: stretch center; + gap: var(--c-spacing-md); + color: var(--c-color-accent-on-quiet); + } + + #content-notice .draft-notice { + display: inline-flex; + justify-content: flex-start; + } + + .draft-notice p { + flex: 1; + margin: 0; + } + + .draft-notice .draft-icon { + position: relative; + flex-shrink: 0; + width: 34px; + height: 34px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 100%; + border: 2px solid var(--c-color-accent-border-quiet); + box-sizing: border-box; + /* Lifts the icon off whatever sits behind the notice. */ + box-shadow: 0 1px 1px 1px var(--c-surface-default); + } + + /* The glyph itself comes from the icon font, so it needs the colour forced + past the font's own rule — as in the legacy stylesheet. */ + .draft-notice .draft-icon::before { + position: relative; + inset-inline-start: 1px; + color: var(--c-color-accent-on-quiet) !important; + font-size: 17px; + } + + .draft-notice .draft-icon::after { + content: ''; + font-size: 0; + position: absolute; + inset: -2px; + border-radius: 100%; + box-shadow: inset 0 2px 0 var(--c-color-accent-border-quiet); + } + + .draft-notice .discard-changes-btn { + margin-inline-start: var(--c-spacing-sm); + background-color: transparent !important; + color: var(--c-color-accent-on-quiet) !important; + border: 2px solid var(--c-color-accent-border-normal) !important; + } + + /* The legacy rule darkened the border in two steps, hover then active. The + accent group has one step past `normal`, so both share it. */ + .draft-notice .discard-changes-btn:hover, + .draft-notice .discard-changes-btn:focus, + .draft-notice .discard-changes-btn:active { + border-color: var(--c-color-accent-border-loud); + } +} diff --git a/resources/js/common/components/ActionList.test.ts b/resources/js/common/components/ActionList.test.ts index 3e2c16686aa..0274056b211 100644 --- a/resources/js/common/components/ActionList.test.ts +++ b/resources/js/common/components/ActionList.test.ts @@ -2,6 +2,7 @@ import {createApp, defineComponent, h, nextTick} from 'vue'; import {afterEach, describe, expect, it, vi} from 'vite-plus/test'; import '@craftcms/ui/components/action-item/action-item'; import '@craftcms/ui/components/button/button'; +import '@craftcms/ui/components/indicator/indicator'; import type {ActionItems} from '@/common/types'; import ActionList from './ActionList.vue'; @@ -287,4 +288,26 @@ describe('ActionList', () => { expect(container.querySelector('p')?.textContent).toBe('Ten selected'); }); + + it.each(['craft-nav-item', 'craft-action-item'] as const)( + 'draws a status as a dot in the icon slot of a %s', + async (as) => { + const container = mount({ + actions: [ + {type: 'link', href: '/draft', label: 'Draft', status: 'blue'}, + {type: 'link', href: '/all', label: 'All'}, + ], + as, + }); + await nextTick(); + + const [draft, all] = [...container.querySelectorAll(as)]; + const dot = draft!.querySelector( + 'craft-indicator[slot="icon"]' + ) as HTMLElement & {fill?: string}; + + expect(dot.fill).toBe('blue'); + expect(all!.querySelector('craft-indicator')).toBeNull(); + } + ); }); diff --git a/resources/js/common/components/ActionList.vue b/resources/js/common/components/ActionList.vue index bf5aedd7454..5245ccd8e9b 100644 --- a/resources/js/common/components/ActionList.vue +++ b/resources/js/common/components/ActionList.vue @@ -40,6 +40,7 @@ href?: string; external?: boolean; label?: string; + status?: string; onClick?: (event: Event) => void; /** * Everything optional, with the unset keys left out entirely. Binding an @@ -162,6 +163,7 @@ href: link.href, external: link.external, label: action.label, + status: isItem ? action.status : undefined, onClick: action.onClick, attrs: defined({...attrs, size}), }, @@ -172,6 +174,7 @@ { kind: 'button', label: action.label, + status: isItem ? action.status : undefined, onClick: action.onClick, attrs: defined({ ...attrs, @@ -292,6 +295,11 @@ return action.type === 'group' ? undefined : action.iconSvg; } + /** A status dot, filling the same slot an icon would. */ + function statusOf(action: NavItem): string | undefined { + return action.type === 'group' ? undefined : action.status; + } + const actionsLentTo = useNavItemActions(); /** @@ -403,6 +411,11 @@ slot="icon" v-html="iconSvgOf(action)" > + {{ labelOf(action) }} @@ -459,6 +472,11 @@ :href="action.href" @click="action.onClick" > + {{ action.label }} @@ -469,6 +487,11 @@ :href="action.href!" :inertia="!action.external" > + {{ action.label }} @@ -479,6 +502,11 @@ :is="as" @click="action.onClick" > + {{ action.label }} diff --git a/resources/js/common/components/ActionMenu.vue b/resources/js/common/components/ActionMenu.vue index 0fdf331d868..100a848065f 100644 --- a/resources/js/common/components/ActionMenu.vue +++ b/resources/js/common/components/ActionMenu.vue @@ -99,6 +99,7 @@
+
diff --git a/resources/js/common/components/CpHeaderBar.vue b/resources/js/common/components/CpHeaderBar.vue index 7da2f8853b7..5b0cb9564de 100644 --- a/resources/js/common/components/CpHeaderBar.vue +++ b/resources/js/common/components/CpHeaderBar.vue @@ -44,10 +44,25 @@ toggleButton.value = (el as HTMLElement | null) ?? null; } - const page = usePage<{craft: CraftData}>(); + const page = usePage<{ + craft: CraftData; + bridged?: 'screen' | 'elementIndex'; + }>(); const maintenanceMode = computed(() => page.props.craft.maintenanceMode); const notifications = computed(() => page.props.craft.general.notifications); const devMode = computed(() => page.props.craft.devMode); + + /** + * Which bridge drew this screen, for the badge below. Only the two bridged + * pages set it, so a ported page shows nothing. + */ + const bridged = computed(() => + page.props.bridged === 'screen' + ? t('Bridged screen') + : page.props.bridged === 'elementIndex' + ? t('Bridged index') + : null + ); const generalSettingsUrl = computed(() => generalSettings.url({cpTrigger: page.props.craft.general.cpTrigger ?? ''}) ); @@ -88,6 +103,13 @@ + + diff --git a/resources/js/common/components/nav.fixture.ts b/resources/js/common/components/nav.fixture.ts index ea6931d202e..149f593adf7 100644 --- a/resources/js/common/components/nav.fixture.ts +++ b/resources/js/common/components/nav.fixture.ts @@ -25,6 +25,7 @@ export function node(label: string, extra: Partial = {}): NavNode { iconSvg: null, fontIcon: null, iconColor: null, + status: null, id: null, variant: null, badgeCount: 0, diff --git a/resources/js/common/composables/navActions.test.ts b/resources/js/common/composables/navActions.test.ts index bc687d801bb..962ad275aea 100644 --- a/resources/js/common/composables/navActions.test.ts +++ b/resources/js/common/composables/navActions.test.ts @@ -70,3 +70,13 @@ it('describes an item with no destination as something you cannot follow', () => expect(administration.type).not.toBe('link'); expect((administration as {disabled?: boolean}).disabled).toBe(true); }); + +it('carries a source’s status across, and leaves it off when there is none', () => { + const [draft, all] = navItemActions([ + node('Draft', {href: '/admin/transfers?source=draft', status: 'blue'}), + node('All', {href: '/admin/transfers'}), + ]) as ActionItemLink[]; + + expect(draft!.status).toBe('blue'); + expect(all!).not.toHaveProperty('status'); +}); diff --git a/resources/js/common/composables/navActions.ts b/resources/js/common/composables/navActions.ts index cceec1c1203..74b95c9a837 100644 --- a/resources/js/common/composables/navActions.ts +++ b/resources/js/common/composables/navActions.ts @@ -44,6 +44,7 @@ export function navItemAction( selected: item.selected, ...(item.icon ? {icon: item.icon} : {}), ...(item.iconSvg ? {iconSvg: item.iconSvg} : {}), + ...(item.status ? {status: item.status} : {}), ...(item.badgeCount > 0 ? {indicator: true} : {}), ...(children.length > 0 ? {subnav: navItemActions(children)} : {}), }; diff --git a/resources/js/common/composables/useAppLayout.ts b/resources/js/common/composables/useAppLayout.ts index ffcdaef1a88..75376a10b04 100644 --- a/resources/js/common/composables/useAppLayout.ts +++ b/resources/js/common/composables/useAppLayout.ts @@ -18,10 +18,17 @@ export interface UseAppLayoutOptions { formAdditionalButtons?: Array; /** Controls below the secondary nav. See `ScreenProps.subnavActions`. */ subnavActions?: Array; + /** The secondary nav itself. See `ScreenProps.subnav`. */ + subnav?: Array; /** Caps and centers the content column. See `ScreenProps.contentMaxWidth`. */ contentMaxWidth?: boolean | string; /** The screen's own edit page. See `ScreenProps.editUrl`. */ editUrl?: string | null; + /** + * Restores Craft 5's `#main-form`/`#content`/`#details`/`#page-container`/ + * `#toolbar`. See `ScreenProps.legacyIds`. + */ + legacyIds?: boolean; onSave?: (options?: FormSaveOptions) => void; } diff --git a/resources/js/common/layouts/screens/PageScreen.vue b/resources/js/common/layouts/screens/PageScreen.vue index f4adcd94ba5..51b95ee3d9e 100644 --- a/resources/js/common/layouts/screens/PageScreen.vue +++ b/resources/js/common/layouts/screens/PageScreen.vue @@ -109,7 +109,9 @@ // Page chrome from props and shared page data. const pageTitle = computed(() => props.title?.trim() ?? page.props.title); - const subnav = computed(() => page.props.subnav ?? []); + // A page that knows its own nav — an index listing its sources — sets it + // through `useAppLayout`; everything else takes the server's page prop. + const subnav = computed(() => props.subnav ?? page.props.subnav ?? []); // The secondary nav's trail joins the crumbs, so location reads the same // with or without the nav on screen, and each level brings its switcher. The @@ -155,6 +157,7 @@ const hasFooter = computed( () => Boolean(props.form) || + Boolean(props.fullPageForm) || regions.has('content-footer') || regions.has('additional-buttons') ); @@ -211,6 +214,19 @@ emit('save', options); } + /** + * A Vue page drives saving through its Inertia form, so the native submit is + * cancelled. A bridged legacy screen has no such form: it posts to the + * current URL, and the hidden `action` input in its own content names the + * controller action — the same contract Craft 5's page form used. + */ + function onSubmit(event: Event): void { + if (props.form) { + event.preventDefault(); + save(); + } + } + useAppendHtml(); // Bridge `@craftcms/ui` action redirects into Inertia SPA visits. @@ -256,11 +272,15 @@
-
+
@@ -372,7 +395,15 @@
- + +
+ +
+
+