diff --git a/packages/craftcms-ui/scripts/generate-vue-wrappers.js b/packages/craftcms-ui/scripts/generate-vue-wrappers.js
index afb4a4417a8..c970c621725 100644
--- a/packages/craftcms-ui/scripts/generate-vue-wrappers.js
+++ b/packages/craftcms-ui/scripts/generate-vue-wrappers.js
@@ -175,7 +175,8 @@ const VALUE_COMPONENTS = [
tagName: 'craft-input-file',
className: 'CraftInputFile',
fileName: 'CraftInputFile',
- modelType: "import('../components/input-file/input-file.ts.mjs').default['modelValue']",
+ modelType:
+ "import('../components/input-file/input-file.ts.mjs').default['modelValue']",
importPath: '../components/input-file/input-file',
slots: [
'label',
@@ -654,6 +655,9 @@ function generateComboboxWrapper(component) {
});
const model = defineModel<${component.modelType}>();
+ const emit = defineEmits<{
+ 'model-value-changed': [event: CustomEvent, cancelModelUpdate: () => void];
+ }>();
withDefaults(
defineProps<{
@@ -675,12 +679,20 @@ function generateComboboxWrapper(component) {
}
);
- function onModelValueChanged(event: Event) {
+ function onModelValueChanged(event: CustomEvent) {
+ // Lion's event is not cancelable; let consumers reject a transient selection
+ // before the Vue model is updated.
+ let canceled = false;
+ emit('model-value-changed', event, () => (canceled = true));
+ if (canceled) {
+ return;
+ }
+
// Lion fires an initial model-value-changed with detail.initialize=true and
// its default (empty) value while the element boots — before Vue's
// .modelValue binding has settled. Honoring that flag (as Lion's own
// form-group repropagation does) prevents it from clobbering a bound value.
- if ((event as CustomEvent).detail?.initialize) {
+ if (event.detail?.initialize) {
return;
}
model.value = (event.target as ${component.className})?.modelValue ?? undefined;
@@ -723,6 +735,10 @@ function generateValueDeclaration(component) {
component.tagName === 'craft-input'
? ` textExpanderTriggers?: import('../components/text-expander/text-expander').TextExpanderTriggers;\n`
: '';
+ const comboboxEvent =
+ component.tagName === 'craft-combobox'
+ ? ` 'onModel-value-changed'?: (event: CustomEvent, cancelModelUpdate: () => void) => void;\n`
+ : '';
return `/**
* Auto-generated type declaration for ${component.fileName}.vue
@@ -734,6 +750,7 @@ declare const _default: DefineComponent<{
${textExpanderProp}
modelValue?: ${component.modelType};
'onUpdate:modelValue'?: (val: ${component.modelType}) => void;
+${comboboxEvent}
}>;
export default _default;
`;
diff --git a/packages/craftcms-ui/src/components/callout/callout.styles.ts b/packages/craftcms-ui/src/components/callout/callout.styles.ts
index 6789b5961e7..5f5b3e2f352 100644
--- a/packages/craftcms-ui/src/components/callout/callout.styles.ts
+++ b/packages/craftcms-ui/src/components/callout/callout.styles.ts
@@ -7,12 +7,6 @@ export default css`
.callout {
--_radius: var(--c-callout-radius, var(--c-radius-md));
- /*
- The two padding axes are declared separately because the callout's
- default is asymmetric — a tight block edge, a roomier inline one. The
- \`padding\` attribute writes both of these on this element when it's set,
- so these fallbacks are what a callout with no \`padding\` renders with.
- */
--_callout-padding-block: var(
--c-callout-padding-block,
var(--c-spacing-sm)
@@ -28,6 +22,8 @@ export default css`
align-items: start;
padding: var(--_callout-padding-block) var(--_callout-padding-inline);
border: 1px solid transparent;
+ /* The display: contents host cannot take a grid span, so its rendered box does. */
+ grid-column: 1 / -1;
}
.callout--hide-icon {
diff --git a/packages/craftcms-ui/src/components/select-color/select-color.ts b/packages/craftcms-ui/src/components/select-color/select-color.ts
index 6f761915719..074fd26728f 100644
--- a/packages/craftcms-ui/src/components/select-color/select-color.ts
+++ b/packages/craftcms-ui/src/components/select-color/select-color.ts
@@ -1,6 +1,7 @@
import {html, LitElement} from 'lit';
import {property} from 'lit/decorators.js';
-import {colors} from '@src/constants/colors';
+import type {Validator} from '@lion/ui/form-core.js';
+import {colors as paletteColors} from '@src/constants/colors';
import {t} from '@src/utilities/translate';
import styles from './select-color.styles.js';
import '../select-rich/select-rich.js';
@@ -15,7 +16,8 @@ function titleCase(value: string): string {
/**
* @summary A color picker built on top of the rich select. Renders one option
- * per color from `constants/colors`, with an optional "transparent" option.
+ * per color from `constants/colors` (or {@link CraftSelectColor.colors}, when
+ * narrowed), with an optional "transparent" option.
*
* @since 1.0
*/
@@ -41,11 +43,36 @@ export default class CraftSelectColor extends LitElement {
modelValue: string | null = null;
/**
- * When enabled, a "Transparent" option is prepended to the list of colors.
+ * When enabled, a blank option (labelled {@link blankLabel}) is prepended
+ * to the list of colors.
*/
@property({type: Boolean, reflect: true, attribute: 'allow-transparent'})
allowTransparent = false;
+ /**
+ * Label for the blank option (default "Transparent").
+ */
+ @property({attribute: 'blank-label'})
+ blankLabel: string | null = null;
+
+ /**
+ * Offered colors, in order. Defaults to the shared palette.
+ */
+ @property({type: Array})
+ colors: string[] = [...paletteColors];
+
+ @property({type: Boolean, reflect: true})
+ disabled = false;
+
+ @property({type: Boolean, reflect: true, attribute: 'readonly'})
+ readOnly = false;
+
+ @property({type: Boolean, reflect: true})
+ required = false;
+
+ @property({attribute: false})
+ validators: Validator[] = [];
+
/**
* Renders a color swatch for the given color value. The special `__blank__`
* value (the "Transparent" option) reuses the checkerboard treatment so it
@@ -66,8 +93,6 @@ export default class CraftSelectColor extends LitElement {
'border-radius:var(--c-radius-full);' +
'box-shadow:inset 0 0 0 1px rgb(0 0 0 / 15%);';
- // Reuses the checkerboard treatment from input-color so the transparent
- // option reads as "no color".
const transparent =
'background:' +
'linear-gradient(45deg, var(--c-color-neutral-fill-quiet) 25%, transparent 25%),' +
@@ -118,14 +143,11 @@ export default class CraftSelectColor extends LitElement {
* read the up-to-date `this.modelValue` off `event.target`.
*/
protected _handleModelValueChanged(event: Event) {
- // Don't let the inner (non-composed) event escape; we re-dispatch our own.
event.stopPropagation();
const inner = event.target as {modelValue?: string | null} | null;
this.modelValue = inner?.modelValue ?? null;
- // Re-dispatch from the host so it crosses the shadow boundary (composed)
- // and Vue's `@model-value-changed` listener fires with the host as target.
this.dispatchEvent(
new CustomEvent('model-value-changed', {bubbles: true, composed: true})
);
@@ -137,12 +159,16 @@ export default class CraftSelectColor extends LitElement {
label=${this.label}
name=${this.name}
.modelValue=${this.modelValue}
+ .disabled=${this.disabled}
+ .readOnly=${this.readOnly}
+ .required=${this.required}
+ .validators=${this.validators}
@model-value-changed=${this._handleModelValueChanged}
>
${this.allowTransparent
- ? this._optionTemplate('__blank__', t('Transparent'))
+ ? this._optionTemplate('__blank__', this.blankLabel ?? t('Transparent'))
: ''}
- ${colors.map((color) =>
+ ${this.colors.map((color) =>
this._optionTemplate(color, t(titleCase(color)))
)}
diff --git a/resources/js/bootstrap/cp.ts b/resources/js/bootstrap/cp.ts
index 19f451146c9..4711d705cef 100644
--- a/resources/js/bootstrap/cp.ts
+++ b/resources/js/bootstrap/cp.ts
@@ -71,6 +71,11 @@ const Cp = {
return cpComponentRegistry;
},
+ // Plugin bundles must use the mounted app's router, not their own module copy.
+ get $router() {
+ return router;
+ },
+
get $elementDetailsTabs() {
return elementDetailsTabRegistry;
},
diff --git a/resources/js/common/components/ActionList.vue b/resources/js/common/components/ActionList.vue
index be2b4317bb2..e2746bad9c6 100644
--- a/resources/js/common/components/ActionList.vue
+++ b/resources/js/common/components/ActionList.vue
@@ -39,6 +39,8 @@
href?: string;
external?: boolean;
label?: string;
+ /** A colored status dot before the label — `craft-indicator`'s own `fill` values. */
+ fill?: string;
onClick?: (event: Event) => void;
/**
* Everything optional, with the unset keys left out entirely. Binding an
@@ -165,6 +167,7 @@
{
kind: 'button',
label: action.label,
+ fill: action.fill,
onClick: action.onClick,
attrs: defined({
...attrs,
@@ -469,6 +472,7 @@
:is="as"
@click="action.onClick"
>
+
{{ action.label }}
diff --git a/resources/js/common/types/globals.d.ts b/resources/js/common/types/globals.d.ts
index a3d976c7423..c91b9bbfd6e 100644
--- a/resources/js/common/types/globals.d.ts
+++ b/resources/js/common/types/globals.d.ts
@@ -3,6 +3,7 @@ import type {QueueService} from '@/modules/queue/queue';
import type {CpComponentRegistry} from '@/bootstrap/components';
import type {ElementDetailsTabRegistry} from '@/bootstrap/element-details-tabs';
import type {InertiaPageRegistry} from '@/bootstrap/inertia-pages';
+import type {Router} from '@inertiajs/core';
import type {AxiosRequestConfig, AxiosResponse} from 'axios';
type LegacySettingValue =
@@ -90,6 +91,7 @@ interface CpStatic extends CpServices {
$components: CpComponentRegistry;
$elementDetailsTabs: ElementDetailsTabRegistry;
$inertia: InertiaPageRegistry;
+ $router: Router;
}
interface CpNotificationSettings {
diff --git a/resources/js/common/types/index.ts b/resources/js/common/types/index.ts
index 0a83935e51d..63900d8e892 100644
--- a/resources/js/common/types/index.ts
+++ b/resources/js/common/types/index.ts
@@ -1,5 +1,6 @@
import type {ActionFeedback, BaseAction, VariantKey} from '@craftcms/ui';
import type {ComboboxOptionData} from '@craftcms/ui/components/combobox/combobox';
+import type {UrlMethodPair} from '@inertiajs/core';
import type {Component} from 'vue';
import type {FormValues} from '@/modules/forms/types';
@@ -128,6 +129,8 @@ export interface ActionItemButton {
feedback?: ActionFeedback;
keywords?: string;
iconColor?: string;
+ /** A colored status dot before the label — `craft-indicator`'s own `fill` values. */
+ fill?: string;
/**
* Items that hang off this one — the nav's own children.
*
@@ -210,10 +213,21 @@ export type ActionItem =
export type ActionItems = Array;
+/** A server-described action that resubmits the current form values. */
+export interface FormAltAction {
+ label: string;
+ destructive?: boolean;
+ action?: string;
+ params?: FormValues;
+ confirm?: string;
+}
+
export interface FormSaveOptions {
redirect?: boolean;
data?: FormValues;
preserveState?: boolean;
+ /** Overrides the screen's default submit destination. */
+ action?: UrlMethodPair;
}
export interface EntryType {
@@ -280,6 +294,7 @@ export type EditableTableCellType =
| 'autosuggest'
| 'template'
| 'number'
+ | 'money'
| 'singleline'
| 'multiline'
| 'heading'
diff --git a/resources/js/modules/admin-table/components/AdminTable.vue b/resources/js/modules/admin-table/components/AdminTable.vue
index 009584e3640..089320748d1 100644
--- a/resources/js/modules/admin-table/components/AdminTable.vue
+++ b/resources/js/modules/admin-table/components/AdminTable.vue
@@ -4,7 +4,7 @@
import BaseElementIndex from '@/modules/elements/components/BaseElementIndex.vue';
import DataTable from '@/modules/elements/components/DataTable.vue';
import {TableSpacing, type TableSpacingValue} from '@/common/types';
- import type {BulkActionItem} from '@/modules/elements/types/actions';
+ import type {BulkAction} from '@/modules/elements/types/actions';
const props = withDefaults(
defineProps<{
@@ -21,7 +21,10 @@
total?: number;
enableAdjustPageSize?: boolean;
pageSizeOptions?: Array;
- actions?: Array | null;
+ actions?: Array | null;
+ /** A caller with its own bulk-action shape supplies this directly; see `BaseElementIndex`. */
+ statuses?: Array | null;
+ idsField?: string;
elementType?: string;
source?: string | null;
context?: string;
@@ -56,6 +59,8 @@
enableAdjustPageSize: props.enableAdjustPageSize,
pageSizeOptions: props.pageSizeOptions,
actions: props.actions,
+ statuses: props.statuses,
+ idsField: props.idsField,
elementType: props.elementType,
source: props.source,
context: props.context,
diff --git a/resources/js/modules/admin-table/components/MoveToPageButton.vue b/resources/js/modules/admin-table/components/MoveToPageButton.vue
new file mode 100644
index 00000000000..7c34d732844
--- /dev/null
+++ b/resources/js/modules/admin-table/components/MoveToPageButton.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ {{
+ t('Move to page…')
+ }}
+
+
+
+ {{ t('Move') }}
+
+
+
diff --git a/resources/js/modules/editable-table/editable-table.ts b/resources/js/modules/editable-table/editable-table.ts
index 6ab63b770eb..6c9f7a337b5 100644
--- a/resources/js/modules/editable-table/editable-table.ts
+++ b/resources/js/modules/editable-table/editable-table.ts
@@ -1,12 +1,15 @@
import {Base} from '@craftcms/garnish';
import type CraftCombobox from '@craftcms/ui/components/combobox/combobox';
+import type {ComboboxItem} from '@craftcms/ui/components/combobox/combobox';
import type CraftTextExpander from '@craftcms/ui/components/text-expander/text-expander';
import '@craftcms/ui/components/text-expander/text-expander';
+import CraftInputMoney from '@craftcms/ui/components/input-money/input-money';
import {editableTableData, editableTableRowData} from './support';
import type {
EditableTableColumn,
EditableTableColumns,
EditableTableOption,
+ EditableTableOptionGroup,
EditableTableOptions,
EditableTableRow,
EditableTableValue,
@@ -24,6 +27,12 @@ declare const $: any;
const noop = (): void => {};
+function isOptionGroup(
+ option: EditableTableOption | EditableTableOptionGroup
+): option is EditableTableOptionGroup {
+ return Array.isArray((option as EditableTableOptionGroup).options);
+}
+
function defaultOptionValue(
options: EditableTableOptions | EditableTableOption[] | undefined
): EditableTableValue | null {
@@ -579,8 +588,11 @@ export class EditableTable extends Base {
): any {
void staticRows;
+ // Keep hidden rows' inputs mounted so they retain and submit their values.
+ // Some hosts override the UA [hidden] rule, so the class is also needed.
const $tr = $('
', {
'data-id': rowId,
+ ...(values._hidden ? {hidden: true, class: 'hidden'} : {}),
});
for (const colId in columns) {
@@ -681,6 +693,45 @@ export class EditableTable extends Base {
.appendTo($cell);
break;
+ case 'money': {
+ // New rows may start with an empty string rather than {value, locale}.
+ const moneyValue =
+ value instanceof Object && !Array.isArray(value)
+ ? ((value as Record).value ?? null)
+ : (value ?? null);
+ const moneyLocale =
+ (value instanceof Object && !Array.isArray(value)
+ ? (value as Record).locale
+ : undefined) ??
+ col.locale ??
+ 'en-US';
+ const money = document.createElement(
+ 'craft-input-money'
+ ) as CraftInputMoney;
+ money.name = `${name}[value]`;
+ money.modelValue = moneyValue === null ? '' : String(moneyValue);
+ money.currency = col.currency ?? 'USD';
+ money.locale = String(moneyLocale);
+ if (col.decimals !== undefined) money.decimals = col.decimals;
+ if (col.decimalSeparator !== undefined) {
+ money.decimalSeparator = col.decimalSeparator;
+ }
+ if (col.groupSeparator !== undefined) {
+ money.groupSeparator = col.groupSeparator;
+ }
+ if (col.showCurrency !== undefined) {
+ money.showCurrency = col.showCurrency;
+ }
+ if (col.clearable !== undefined) money.clearable = col.clearable;
+ $cell.append(money);
+ $('', {
+ type: 'hidden',
+ name: `${name}[locale]`,
+ value: String(moneyLocale),
+ }).appendTo($cell);
+ break;
+ }
+
case 'time':
Craft.ui
.createTimeInput({
@@ -724,10 +775,24 @@ export class EditableTable extends Base {
combobox.name = name;
combobox.label = col.heading ?? colId;
combobox.options = Array.isArray(col.options)
- ? col.options.map((option) => ({
- label: option.label ?? String(option.value ?? ''),
- value: String(option.value ?? ''),
- }))
+ ? col.options.map(
+ (option): ComboboxItem =>
+ isOptionGroup(option)
+ ? {
+ type: 'optgroup',
+ label: option.label ?? '',
+ options: option.options.map((groupedOption) => ({
+ label:
+ groupedOption.label ??
+ String(groupedOption.value ?? ''),
+ value: String(groupedOption.value ?? ''),
+ })),
+ }
+ : {
+ label: option.label ?? String(option.value ?? ''),
+ value: String(option.value ?? ''),
+ }
+ )
: [];
combobox.modelValue = String(value ?? '');
combobox.showAllOnEmpty = true;
diff --git a/resources/js/modules/editable-table/types.ts b/resources/js/modules/editable-table/types.ts
index 90d0543669c..76a44984f73 100644
--- a/resources/js/modules/editable-table/types.ts
+++ b/resources/js/modules/editable-table/types.ts
@@ -16,7 +16,10 @@ export interface EditableTableColumn {
rows?: number;
code?: boolean;
value?: string | number;
- options?: EditableTableOptions | EditableTableOption[];
+ options?:
+ | EditableTableOptions
+ | EditableTableOption[]
+ | EditableTableOptionGroup[];
textExpanderTriggers?: TextExpanderTriggers;
/** Checkbox: only one in the column may be checked at a time. */
radioMode?: boolean;
@@ -24,8 +27,20 @@ export interface EditableTableColumn {
toggle?: string[];
/** Auto-populate this column's value (a handle) from another column. */
autopopulate?: string;
- /** Number column: locale used for formatting/parsing. */
+ /** Number/money column: locale used for formatting/parsing. */
locale?: string;
+ /** Money column: ISO currency code (e.g. `USD`). Defaults to `USD`. */
+ currency?: string;
+ /** Money column: fraction digits to allow. Defaults to the currency's own. */
+ decimals?: number;
+ /** Money column: overrides the locale's own decimal separator. */
+ decimalSeparator?: string;
+ /** Money column: overrides the locale's own thousands separator. */
+ groupSeparator?: string;
+ /** Money column: shows the currency code/symbol prefix. Defaults to `true`. */
+ showCurrency?: boolean;
+ /** Money column: shows a clear button once there's a value. Defaults to `true`. */
+ clearable?: boolean;
[key: string]: EditableTableColumnValue;
}
@@ -37,6 +52,7 @@ export type EditableTableValue =
| EditableTableValue[]
| EditableTableRow;
+/** `_hidden` hides a row without removing its inputs or submitted values. */
export interface EditableTableRow {
[key: string]: EditableTableValue;
}
@@ -51,6 +67,12 @@ export interface EditableTableOptions {
[key: string]: EditableTableOption;
}
+export interface EditableTableOptionGroup {
+ label?: string;
+ type?: 'optgroup';
+ options: EditableTableOption[];
+}
+
type EditableTableColumnValue =
| string
| number
@@ -59,6 +81,7 @@ type EditableTableColumnValue =
| string[]
| EditableTableOptions
| EditableTableOption[]
+ | EditableTableOptionGroup[]
| TextExpanderTriggers;
/** Map of column ID → column definition. */
diff --git a/resources/js/modules/elements/components/BaseElementIndex.vue b/resources/js/modules/elements/components/BaseElementIndex.vue
index 45599513efb..c19834efe45 100644
--- a/resources/js/modules/elements/components/BaseElementIndex.vue
+++ b/resources/js/modules/elements/components/BaseElementIndex.vue
@@ -7,7 +7,8 @@
import Select from '@/common/form/Select.vue';
import BulkActionsBar from '@/modules/elements/components/BulkActionsBar.vue';
import {useElementIndexSelection} from '@/modules/elements/composables/useElementIndexSelection';
- import type {BulkActionItem} from '@/modules/elements/types/actions';
+ import type {BulkAction, BulkActionItem} from '@/modules/elements/types/actions';
+ import PerformElementActionController from '@actions/Elements/PerformElementActionController';
import VarDump from '@/common/components/VarDump.vue';
const props = withDefaults(
@@ -21,7 +22,12 @@
total?: number;
enableAdjustPageSize?: boolean;
pageSizeOptions?: Array;
- actions?: Array | null;
+ /** The "Actions" menu's items — not element-specific, see `BulkActionsBar`. */
+ actions?: Array | null;
+ /** A separate "Set status" menu; see `resolved` below. */
+ statuses?: Array | null;
+ /** The body key the selection posts under, forwarded to `BulkActionsBar`. */
+ idsField?: string;
elementType?: string;
source?: string | null;
context?: string;
@@ -42,6 +48,57 @@
const page = usePage<{readOnly: boolean}>();
const readOnly = computed(() => props.readOnly ?? page.props.readOnly);
+ const SET_STATUS_KEY = 'CraftCms\\Cms\\Element\\Actions\\SetStatus';
+
+ function isSetStatusAction(item: BulkAction): item is BulkActionItem {
+ return 'key' in item && item.key === SET_STATUS_KEY;
+ }
+
+ function setStatusAction(status: string) {
+ return {
+ type: 'http' as const,
+ url: PerformElementActionController.url(),
+ body: {elementAction: SET_STATUS_KEY, status},
+ };
+ }
+
+ /** A real element index has no `statuses` prop — derives Enabled/Disabled from its `SetStatus` action instead, dropping it from `actions`. */
+ const resolved = computed(() => {
+ if (props.statuses) {
+ return {actions: props.actions ?? [], statuses: props.statuses};
+ }
+
+ const setStatus = (props.actions ?? []).find(isSetStatusAction);
+
+ return {
+ actions: (props.actions ?? []).filter((item) => item !== setStatus),
+ statuses: setStatus
+ ? [
+ {
+ key: `${SET_STATUS_KEY}:enabled`,
+ label: t('Enabled'),
+ fill: 'success',
+ disabled: setStatus.disabled,
+ action: setStatusAction('enabled'),
+ },
+ {
+ key: `${SET_STATUS_KEY}:disabled`,
+ label: t('Disabled'),
+ fill: 'danger',
+ disabled: setStatus.disabled,
+ action: setStatusAction('disabled'),
+ },
+ ]
+ : [],
+ };
+ });
+
+ const actionContext = computed(() => ({
+ elementType: props.elementType,
+ source: props.source,
+ context: props.context,
+ }));
+
const {
selection,
selectedIds,
@@ -52,9 +109,14 @@
} = useElementIndexSelection(() => props.table, {
selectable: () => props.selectable,
readOnly,
- actions: () => props.actions,
+ actions: () => resolved.value.actions,
+ statuses: () => resolved.value.statuses,
});
+ const showSelectionBar = computed(
+ () => showBulkActions.value && hasSelection.value
+ );
+
function onActionPerformed() {
clearSelection();
emit('action-performed');
@@ -91,7 +153,7 @@
showPagination.value ||
showPageSize.value ||
showDisplayedRows.value ||
- (showBulkActions.value && hasSelection.value)
+ showSelectionBar.value
);
// --- ARIA live region ---
@@ -131,17 +193,18 @@