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 @@ + + + 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 @@