', {
- class: `elements ${this.settings.showInGrid ? 'card-grid' : 'cards'}`,
- }).prependTo(this.$container);
- this.$container.children('.zilch').addClass('hidden');
- }
-
- if (this.settings.selectable) {
- this.elementSelect = new Garnish.Select(
- this.$elements,
- this.$elements.children().children('.element'),
- {
- multi: true,
- vertical: !this.settings.showInGrid,
- filter: (target) => {
- return !$(target).closest(
- 'a[href],.toggle,.btn,[role=button],.move,craft-copy-attribute'
- ).length;
- },
- checkboxMode: true,
- waitForDoubleClicks: true,
- }
- );
- }
-
- // only initialise drag-sorting if the device has mouse events
- if (this.settings.sortable && Craft.hasMousePointerEvents()) {
- this.elementSort = new Garnish.DragSort({
- container: this.$elements,
- filter: this.settings.selectable
- ? () => {
- // Only return all the selected items if the target item is selected
- if (
- this.elementSort.$targetItem
- .children('.element')
- .hasClass('sel')
- ) {
- return this.elementSelect.getSelectedItems().parent('li');
- } else {
- return this.elementSort.$targetItem;
- }
- }
- : null,
- handle:
- '> .element > .card-titlebar > .card-actions-container > .card-actions > .move-btn',
- ignoreHandleSelector: null,
- collapseDraggees: true,
- magnetStrength: 4,
- helperLagBase: 1.5,
- onSortChange: () => {
- this.onSortChange(this.elementSort.$draggee);
- },
- });
- }
-
- for (const element of this.$elements.children().toArray()) {
- this.initElement($(element).children('.element'));
- }
- },
-
- deinitCards() {
- if (!this.$elements) {
- return;
- }
-
- this.$elements.remove();
- this.$elements = null;
- this.elementSort?.destroy();
- this.elementSort = null;
- this.$container.children('.zilch').removeClass('hidden');
- },
-
- initElementIndex() {
- this.elementIndex = Craft.createElementIndex(
- this.elementType,
- this.$container,
- Object.assign(
- {
- context: 'embedded-index',
- sortable: this.settings.sortable,
- prevalidate: this.settings.prevalidate,
- },
- this.settings.indexSettings,
- {
- canDuplicateElements: ($selectedItems) => {
- return this.canCreate($selectedItems.length);
- },
- canDeleteElements: ($selectedItems) => {
- return this.canDelete($selectedItems.length);
- },
- onBeforeMoveElementsToPage: async () => {
- await this.markAsDirty();
- },
- onMoveElementsToPage: async () => {
- await this.markAsDirty();
- },
- onBeforeReorderElements: async () => {
- await this.markAsDirty();
- },
- onReorderElements: async () => {
- await this.markAsDirty();
- },
- onBeforeDuplicateElements: async () => {
- await this.markAsDirty();
- },
- onDuplicateElements: async () => {
- await this.markAsDirty();
- },
- onBeforeDeleteElements: async () => {
- await this.markAsDirty();
- },
- onDeleteElements: async () => {
- if (!(await this.markAsDirty())) {
- // save the element anyway in case any conditional fields should be shown/hidden
- this.elementEditor?.checkForm(true);
- }
- },
- onBeforeUpdateElements: () => {
- if (this.$createBtn) {
- this.$createBtn.addClass('disabled');
- }
- },
- onCountResults: () => {
- this.updateCreateBtn();
- },
- onSortChange: async ($draggee) => {
- await this.onSortChange($draggee);
- },
- }
- )
- );
- },
-
- async markAsDirty() {
- if (!this.elementEditor || !this.settings.baseInputName) {
- return false;
- }
- return await this.elementEditor.setFormValue(
- this.settings.baseInputName,
- '*'
- );
- },
-
- async getBaseActionData() {
- // this could end up updating this.settings.ownerId
- await this.markAsDirty();
-
- return {
- ownerElementType: this.settings.ownerElementType,
- ownerId: this.settings.ownerId,
- ownerSiteId: this.settings.ownerSiteId,
- attribute: this.settings.attribute,
- };
- },
-
- async onSortChange($draggee) {
- const elementIds = $draggee
- .find('.element')
- .toArray()
- .map((element) => parseInt($(element).data('id')));
-
- try {
- const response = await this.updateSortOrder(elementIds);
- Craft.cp.displayNotice(response.data.message);
- if (Craft.broadcaster && this.elementEditor?.settings.draftId) {
- Craft.broadcaster.postMessage({
- pageId: Craft.pageId,
- event: 'reorderNestedElements',
- canonicalId: this.elementEditor.settings.canonicalId,
- draftId: this.elementEditor.settings.draftId,
- isProvisionalDraft: this.elementEditor.settings.isProvisionalDraft,
- elementType: this.elementType,
- elementIds,
- });
- }
- } catch (e) {
- Craft.cp.displayError(e?.response?.data?.message);
- }
-
- if (!(await this.markAsDirty())) {
- // Refresh Live Preview
- Craft.Preview.refresh();
- }
- },
-
- async updateSortOrder(elementIds) {
- elementIds = Array.isArray(elementIds) ? elementIds : [elementIds];
- elementIds = elementIds.map((id) => parseInt(id));
- const allIds = this.getElementIds();
-
- const data = Object.assign(await this.getBaseActionData(), {
- elementIds,
- offset: this.getBaseElementOffset() + allIds.indexOf(elementIds[0]),
- });
-
- return await Craft.sendActionRequest('POST', 'nested-elements/reorder', {
- data,
- });
- },
-
- updateCreateBtn() {
- if (!this.$createBtn) {
- return;
- }
-
- if (this.canCreate()) {
- this.$createBtn.removeClass('disabled');
- } else {
- this.$createBtn.addClass('disabled');
- }
-
- this.updatePasteButton();
- },
-
- updatePasteButton(elementInfo = null) {
- elementInfo = elementInfo || Craft.cp.getCopiedElements();
- if (this.canPaste(elementInfo)) {
- if (!this.$pasteBtn) {
- this.$pasteBtn = Craft.ui.createPasteButton();
- this.addButton(this.$pasteBtn);
- this.addListener(this.$pasteBtn, 'activate', 'pasteElements');
- } else {
- this.$pasteBtn.removeClass('hidden');
- }
- } else {
- this.$pasteBtn?.addClass('hidden');
- }
- },
-
- canCreate(num = 1) {
- if (!this.settings.canCreate || num === 0) {
- return false;
- }
-
- if (!this.settings.maxElements) {
- return true;
- }
-
- const total = this.getTotalElements();
-
- return total !== null && total + num <= this.settings.maxElements;
- },
-
- canDelete() {
- if (!this.settings.minElements) {
- return true;
- }
-
- return this.getTotalElements() !== null;
- },
-
- canPaste(elementInfo) {
- if (!this.settings.canPaste || !this.canCreate(elementInfo.length)) {
- return false;
- }
-
- for (const e of elementInfo) {
- if (e.type !== this.elementType) {
- return false;
- }
- }
-
- if (typeof this.settings.canPaste === 'function') {
- return this.settings.canPaste(elementInfo);
- }
-
- if (typeof this.settings.canPaste === 'string') {
- return eval(this.settings.canPaste)(elementInfo);
- }
-
- return true;
- },
-
- getElementIds() {
- let elements;
-
- if (this.settings.mode === 'cards') {
- elements = this.$elements.find('> li > .element').toArray();
- } else {
- elements = this.elementIndex.view
- .getAllElements()
- .toArray()
- .map((container) => container.querySelector('.element'));
- }
-
- return elements
- .map((element) => element.getAttribute('data-id'))
- .filter((id) => id)
- .map((id) => parseInt(id));
- },
-
- getTotalElements() {
- if (this.settings.mode === 'cards') {
- return this.$elements ? this.$elements.children().length : 0;
- }
-
- if (this.elementIndex.isIndexBusy) {
- return null;
- }
- return this.elementIndex.totalUnfilteredResults;
- },
-
- getBaseElementOffset() {
- if (this.settings.mode === 'cards') {
- return 0;
- }
-
- return (
- this.elementIndex.settings.batchSize * (this.elementIndex.page - 1)
- );
- },
-
- createElement: async function (attributes) {
- if (this.creatingElement) {
- return;
- }
- this.creatingElement = true;
-
- Craft.cp.announce(Craft.t('app', 'Loading'));
-
- try {
- await this.markAsDirty();
-
- attributes = Object.assign(
- {
- elementType: this.elementType,
- ownerId: this.settings.ownerId,
- fieldId: this.settings.fieldId,
- siteId: this.settings.ownerSiteId,
- },
- attributes
- );
-
- const {data} = await Craft.sendActionRequest(
- 'POST',
- 'elements/create',
- {
- data: attributes,
- }
- );
-
- const slideout = Craft.createElementEditor(this.elementType, {
- siteId: data.element.siteId,
- elementId: data.element.id,
- draftId: data.element.draftId,
- params: {
- fresh: 1,
- },
- });
-
- let shownElement = false;
- let $card;
-
- const showElement = async (data) => {
- if (!shownElement) {
- shownElement = true;
-
- if (this.settings.mode === 'cards') {
- $card = await this.addElementCard(data);
- } else {
- this.elementIndex.clearSearch();
- this.elementIndex.updateElements();
- }
-
- await this.markAsDirty();
- }
- };
-
- slideout.on('load', () => {
- slideout.elementEditor.once('afterSaveDraft', (ev) => {
- showElement(data.element);
- });
- });
-
- slideout.on('submit', async () => {
- await showElement(data.element);
- });
-
- slideout.on('close', () => {
- if (this.$createBtn) {
- this.$createBtn.filter(':visible:first').focus();
- }
-
- // save the element in case any conditional fields should be shown/hidden
- this.elementEditor?.checkForm(true);
- });
- } catch (e) {
- Craft.cp.displayError(e?.response?.data?.message);
- } finally {
- this.creatingElement = false;
- Craft.cp.announce(Craft.t('app', 'Loading complete'));
- }
- },
-
- async duplicateElement(element) {
- const $element = $(element);
-
- Craft.cp.announce(Craft.t('app', 'Loading'));
- await this.markAsDirty();
-
- let data;
- try {
- const elementId = $element.data('id');
- const response = await Craft.sendActionRequest(
- 'POST',
- 'elements/duplicate',
- {
- data: {
- elementType: this.elementType,
- ownerId: this.settings.ownerId,
- siteId: this.settings.ownerSiteId,
- elementId:
- this.elementEditor?.getDraftElementId(elementId) || elementId,
- },
- }
- );
- data = response.data;
- } catch (e) {
- Craft.cp.displayError(e?.response?.data?.message);
- }
-
- const $card = await this.addElementCard(data.element);
- $card.parent().insertAfter($element.parent());
- await this.updateSortOrder(data.element.id);
- // save the element in case any conditional fields should be shown/hidden
- this.elementEditor?.checkForm(true);
- },
-
- async duplicateElements(elements) {
- if (elements instanceof jQuery) {
- elements = elements.toArray();
- }
-
- for (const element of elements) {
- await this.duplicateElement(element);
- }
- },
-
- async pasteElements($before = null) {
- Craft.cp.announce(Craft.t('app', 'Loading'));
- this.$pasteBtn.addClass('loading');
-
- try {
- await this.markAsDirty();
- const newElementInfo = await Craft.cp.pasteElements(
- Object.assign(
- {
- primaryOwnerId: this.settings.ownerId,
- ownerId: this.settings.ownerId,
- fieldId: this.settings.fieldId,
- siteId: this.settings.ownerSiteId,
- },
- this.settings.pasteAttributes || {}
- )
- );
-
- if (!newElementInfo.length) {
- return;
- }
-
- if (this.settings.mode === 'cards') {
- const $cards = await this.addElementCards(newElementInfo, $before);
- await this.updateSortOrder(newElementInfo[0].id);
- Garnish.firstFocusableElement($cards).focus();
- } else {
- this.elementIndex.clearSearch();
- await this.elementIndex.updateElements();
- }
- } finally {
- this.$pasteBtn.removeClass('loading');
- }
-
- // save the element in case any conditional fields should be shown/hidden
- this.elementEditor?.checkForm(true);
- },
-
- initElement($element) {
- setTimeout(() => {
- if (this.settings.selectable) {
- this.elementSelect.addItems($element);
- }
-
- const editable = Garnish.hasAttr($element, 'data-editable');
-
- if (editable) {
- // "Edit" button
- const $editBtn = $element.find('.edit-btn');
- if ($editBtn.length) {
- // Override the default event listener
- $editBtn.off('activate');
- this.addListener($editBtn, 'activate', (ev) => {
- // focus on the button so that when the slideout is closed, it's returned to the button
- $editBtn.focus();
- const cpUrl = $element.data('cpUrl');
- if (cpUrl && Garnish.isCtrlKeyPressed(ev.originalEvent)) {
- window.open(cpUrl);
- } else {
- this.createElementEditor($element);
- }
- });
- }
-
- // Double-clicks
- this.addListener($element, 'dblclick,taphold', (ev) => {
- if (!$(ev.target).closest('a[href],button,[role=button]').length) {
- this.createElementEditor($element);
- }
- });
- }
-
- const actionDisclosure = $element
- .find('.action-btn')
- .removeClass('hidden')
- .disclosureMenu()
- .data('disclosureMenu');
-
- if (actionDisclosure) {
- const $actionMenu = actionDisclosure.$container;
-
- const destructiveGroup = actionDisclosure.getFirstDestructiveGroup();
- let moveUpButton,
- moveDownButton,
- duplicateButton,
- copyButton,
- pasteButton,
- deleteButton;
-
- const $li = $element.parent();
- const getPrev = () => $li.prev('li');
- const getNext = () => $li.next('li');
-
- if (this.settings.sortable) {
- this.elementSort?.addItems($li);
-
- const ul = actionDisclosure.addGroup(null, true, destructiveGroup);
-
- // Move up/forward
- moveUpButton = actionDisclosure.addItem(
- {
- icon: async () =>
- await Craft.ui.icon(
- this.settings.showInGrid
- ? Craft.orientation === 'ltr'
- ? 'arrow-left'
- : 'arrow-right'
- : 'arrow-up'
- ),
- label: this.settings.showInGrid
- ? Craft.t('app', 'Move forward')
- : Craft.t('app', 'Move up'),
- onActivate: () => {
- const $prev = getPrev();
- if ($prev.length) {
- $li.insertBefore($prev);
- this.onSortChange($li);
- }
- },
- },
- ul
- );
-
- // Move down/backward
- moveDownButton = actionDisclosure.addItem(
- {
- icon: async () =>
- await Craft.ui.icon(
- this.settings.showInGrid
- ? Craft.orientation === 'ltr'
- ? 'arrow-right'
- : 'arrow-left'
- : 'arrow-down'
- ),
- label: this.settings.showInGrid
- ? Craft.t('app', 'Move backward')
- : Craft.t('app', 'Move down'),
- onActivate: () => {
- const $next = getNext();
- if ($next.length) {
- $li.insertAfter($next);
- this.onSortChange($li);
- }
- },
- },
- ul
- );
- }
-
- const duplicatable = Garnish.hasAttr($element, 'data-duplicatable');
- const copyable = Garnish.hasAttr($element, 'data-copyable');
-
- if (duplicatable || copyable) {
- const ul = actionDisclosure.addGroup(null, true, destructiveGroup);
-
- if (duplicatable) {
- // Duplicate
- duplicateButton = actionDisclosure.addItem(
- {
- icon: async () => await Craft.ui.icon('clone'),
- label: Craft.t('app', 'Duplicate'),
- onActivate: () => {
- if (this.bulkActionMode($element)) {
- this.duplicateElements(
- this.elementSelect.getSelectedItems()
- );
- } else {
- this.duplicateElement($element);
- }
- },
- },
- ul
- );
- }
-
- if (copyable) {
- // Copy
- const $oldCopyBtn = $actionMenu.find('[data-copy-action]');
- if ($oldCopyBtn.length) {
- actionDisclosure.removeItem($oldCopyBtn[0]);
- }
-
- copyButton = actionDisclosure.addItem(
- {
- icon: async () => await Craft.ui.icon('clone-dashed'),
- iconColor: 'fuchsia',
- label: Craft.t('app', 'Copy'),
- onActivate: () => {
- if (this.bulkActionMode($element)) {
- Craft.cp.copyElements(
- this.elementSelect.getSelectedItems()
- );
- } else {
- Craft.cp.copyElements($element);
- }
- },
- },
- ul
- );
- }
-
- // Paste
- pasteButton = actionDisclosure.addItem(
- {
- icon: async () => await Craft.ui.icon('duplicate'),
- iconColor: 'fuchsia',
- label: this.settings.showInGrid
- ? Craft.t('app', 'Paste {type} before', {
- type: Craft.elementTypeNames[this.elementType][3],
- })
- : Craft.t('app', 'Paste {type} above', {
- type: Craft.elementTypeNames[this.elementType][3],
- }),
- onActivate: async () => {
- if (this.canPaste(Craft.cp.getCopiedElements())) {
- await this.pasteElements($element.parent());
- }
- },
- },
- ul
- );
- }
-
- if (Garnish.hasAttr($element, 'data-deletable')) {
- const ul = actionDisclosure.addGroup();
- deleteButton = actionDisclosure.addItem(
- {
- icon: async () => await Craft.ui.icon('trash'),
- label: this.settings.deleteLabel || Craft.t('app', 'Delete'),
- destructive: true,
- onActivate: () => {
- if (this.bulkActionMode($element)) {
- if (confirm(this.settings.bulkDeleteConfirmationMessage)) {
- this.deleteElements(
- this.elementSelect.getSelectedItems()
- );
- }
- } else {
- if (confirm(this.settings.deleteConfirmationMessage)) {
- this.deleteElement($element);
- }
- }
- },
- },
- ul
- );
- }
-
- actionDisclosure.on('show', () => {
- const bulk = this.bulkActionMode($element);
-
- if (moveUpButton) {
- actionDisclosure.toggleItem(moveUpButton, getPrev().length);
- }
-
- if (moveDownButton) {
- actionDisclosure.toggleItem(moveDownButton, getNext().length);
- }
-
- if (duplicateButton) {
- actionDisclosure.toggleItem(duplicateButton, this.canCreate());
-
- $(duplicateButton)
- .children('.menu-item-label')
- .text(
- bulk
- ? Craft.t('app', 'Duplicate selected {type}', {
- type: Craft.elementTypeNames[this.elementType][3],
- })
- : Craft.t('app', 'Duplicate')
- );
- }
-
- if (copyButton) {
- $(copyButton)
- .children('.menu-item-label')
- .text(
- bulk
- ? Craft.t('app', 'Copy selected {type}', {
- type: Craft.elementTypeNames[this.elementType][3],
- })
- : Craft.t('app', 'Copy')
- );
- }
-
- const copiedElements = Craft.cp.getCopiedElements();
- const showPasteButton =
- copiedElements.length && this.canPaste(copiedElements);
- actionDisclosure.toggleItem(pasteButton, showPasteButton);
- if (showPasteButton) {
- $(pasteButton)
- .children('.menu-item-label')
- .text(
- this.settings.showInGrid
- ? Craft.t('app', 'Paste {type} before', {
- type:
- copiedElements.length === 1
- ? Craft.elementTypeNames[this.elementType][2]
- : Craft.elementTypeNames[this.elementType][3],
- })
- : Craft.t('app', 'Paste {type} above', {
- type:
- copiedElements.length === 1
- ? Craft.elementTypeNames[this.elementType][2]
- : Craft.elementTypeNames[this.elementType][3],
- })
- );
- }
-
- if (deleteButton) {
- $(deleteButton)
- .children('.menu-item-label')
- .text(
- bulk
- ? Craft.t('app', 'Delete selected {type}', {
- type: Craft.elementTypeNames[this.elementType][3],
- })
- : Craft.t('app', 'Delete')
- );
- }
- });
- }
- }, 1);
- },
-
- createElementEditor($element) {
- const slideout = Craft.createElementEditor(this.elementType, $element, {
- ownerId: this.elementEditor?.getDraftElementId(
- $element.data('ownerId')
- ),
- onLoad: () => {
- slideout.elementEditor.on('update', () => {
- Craft.Preview.refresh();
- });
- },
- onBeforeSubmit: async () => {
- // If the nested element is primarily owned by the same owner element it was queried for,
- // then ensure we're working with a draft and save the nested element changes to the draft
- // note: this workflow doesn't apply to elements nested directly in global sets as globals don't use element editor
- if (
- typeof this.elementEditor !== 'undefined' &&
- Garnish.hasAttr($element, 'data-owner-is-canonical') &&
- !Garnish.hasAttr($element, 'data-is-unpublished-draft') &&
- !this.elementEditor.settings.isUnpublishedDraft
- ) {
- await slideout.elementEditor.checkForm(true, true);
- await this.markAsDirty();
- if (
- this.elementEditor.settings.draftId &&
- slideout.elementEditor.settings.draftId
- ) {
- if (!slideout.elementEditor.settings.saveParams) {
- slideout.elementEditor.settings.saveParams = {};
- }
- slideout.elementEditor.settings.saveParams.action =
- 'elements/save-nested-element-for-derivative';
- slideout.elementEditor.settings.saveParams.newOwnerId =
- this.settings.ownerId;
- }
- }
- },
- onSubmit: (ev) => {
- if (ev.data.id != $element.data('id')) {
- // swap the element with the new one
- $element
- .attr('data-id', ev.data.id)
- .data('id', ev.data.id)
- .data('owner-id', ev.data.ownerId);
- Craft.refreshElementInstances(ev.data.id);
- }
- },
- });
- },
-
- async deleteElement(element) {
- const $element = $(element);
-
- const data = Object.assign(await this.getBaseActionData(), {
- elementId: $element.data('id'),
- });
-
- try {
- const response = await Craft.sendActionRequest(
- 'POST',
- 'nested-elements/delete',
- {data}
- );
- Craft.cp.displayNotice(response.data.message);
- } catch (e) {
- Craft.cp.displayError(e?.response?.data?.message);
- throw e;
- }
-
- if (this.settings.sortable) {
- this.elementSort?.removeItems($element);
- }
-
- $element.parent().remove();
-
- // :empty isn't reliable due to text nodes
- if (this.$elements.children().length === 0) {
- this.deinitCards();
- }
-
- if (this.$createBtn) {
- this.updateCreateBtn();
- if (this.canCreate()) {
- this.$createBtn.filter(':visible:first').focus();
- }
- }
-
- if (!(await this.markAsDirty())) {
- // save the element anyway in case any conditional fields should be shown/hidden
- this.elementEditor?.checkForm(true);
- }
- },
-
- async deleteElements(elements) {
- if (elements instanceof jQuery) {
- elements = elements.toArray();
- }
-
- for (const element of elements) {
- await this.deleteElement(element);
- }
- },
-
- async addElementCard(element) {
- return await this.addElementCards([element]);
- },
-
- async addElementCards(elements, $before = null) {
- if (this.creatingElement) {
- return null;
- }
-
- Craft.cp.announce(Craft.t('app', 'Loading'));
-
- let data;
- try {
- const response = await Craft.sendActionRequest(
- 'POST',
- 'app/render-elements',
- {
- data: {
- elements: elements.map((element) => ({
- type: this.elementType,
- id: element.id,
- siteId: element.siteId,
- instances: [
- {
- context: 'field',
- ui: 'card',
- sortable: this.settings.sortable,
- selectable: this.settings.selectable,
- showActionMenu: true,
- hyperlink: false,
- },
- ],
- })),
- },
- }
- );
- data = response.data;
- } catch (e) {
- Craft.cp.displayError(e?.response?.data?.message);
- throw e?.response?.data?.message ?? e;
- }
-
- if (!this.$elements) {
- this.initCards();
- }
-
- let $cards = $();
-
- for (const elementInfo of elements) {
- for (const card of data.elements[elementInfo.id] || []) {
- const $li = $('');
- if ($before?.length) {
- $li.insertBefore($before);
- } else {
- $li.appendTo(this.$elements);
- }
- const $card = $(card).appendTo($li);
- $cards = $cards.add($card);
- this.initElement($card);
- Craft.cp.elementThumbLoader.load($card);
- }
- }
-
- await Craft.appendHeadHtml(data.headHtml);
- await Craft.appendBodyHtml(data.bodyHtml);
- this.updateCreateBtn();
-
- return $cards;
- },
-
- destroy: function () {
- this.$container.removeData('nestedElementManager');
- this.base();
- },
- },
- {
- ownerId: null,
- defaults: {
- mode: 'cards',
- showInGrid: false,
- ownerElementType: null,
- ownerId: null,
- ownerSiteId: null,
- attribute: null,
- selectable: false,
- sortable: false,
- indexSettings: {},
- canCreate: false,
- canPaste: false,
- minElements: null,
- maxElements: null,
- createButtonLabel: Craft.t('app', 'Create'),
- ownerIdParam: null,
- createAttributes: null,
- pasteAttributes: null,
- fieldId: null,
- fieldHandle: null,
- baseInputName: null,
- deleteLabel: null,
- deleteConfirmationMessage: null,
- bulkDeleteConfirmationMessage: null,
- prevalidate: false,
- },
- }
-);
diff --git a/packages/craftcms-ui/src/actions/index.ts b/packages/craftcms-ui/src/actions/index.ts
index 21eba3a889e..2d49e2d3938 100644
--- a/packages/craftcms-ui/src/actions/index.ts
+++ b/packages/craftcms-ui/src/actions/index.ts
@@ -55,7 +55,47 @@ export interface ActionItemButton extends BaseActionItem {
export type ActionItem = ActionItemButton | ActionItemHr | ActionItemLink;
-export async function runAction(action: BaseAction): Promise {
+export interface RunActionOptions {
+ /**
+ * The element that invoked the action (e.g. the clicked button). For
+ * `event` actions it's merged into the dispatched event's `detail` as
+ * `trigger`, so listeners can manage focus or read contextual `data-*`
+ * attributes from the invoker's ancestors.
+ */
+ trigger?: Element;
+ /**
+ * The DOM event that invoked the action. For `event` actions it's merged
+ * into the dispatched event's `detail` as `sourceEvent`, so listeners can
+ * inspect modifier keys (e.g. ctrl-click opening a new window).
+ */
+ sourceEvent?: Event;
+}
+
+/**
+ * Normalizes an action property value to a `BaseAction`. Vue's in-DOM
+ * template compiler (e.g. server HTML rendered through `DynamicHtmlRenderer`)
+ * assigns attribute values directly to an upgraded element's property as a
+ * raw JSON string, bypassing Lit's attribute converter — so components accept
+ * both and normalize before running.
+ */
+export function normalizeAction(
+ action: BaseAction | string | null
+): BaseAction | null {
+ if (typeof action !== 'string') {
+ return action;
+ }
+
+ try {
+ return JSON.parse(action);
+ } catch {
+ return null;
+ }
+}
+
+export async function runAction(
+ action: BaseAction,
+ options: RunActionOptions = {}
+): Promise {
switch (action.type) {
case 'clipboard':
await navigator.clipboard.writeText(action.value);
@@ -101,7 +141,13 @@ export async function runAction(action: BaseAction): Promise {
}
window.dispatchEvent(
- new CustomEvent(action.name, {detail: action.detail ?? {}})
+ new CustomEvent(action.name, {
+ detail: {
+ ...(action.detail ?? {}),
+ ...(options.trigger ? {trigger: options.trigger} : {}),
+ ...(options.sourceEvent ? {sourceEvent: options.sourceEvent} : {}),
+ },
+ })
);
break;
diff --git a/packages/craftcms-ui/src/components/action-item/action-item.ts b/packages/craftcms-ui/src/components/action-item/action-item.ts
index 4cdc614923a..dae63b07ae1 100644
--- a/packages/craftcms-ui/src/components/action-item/action-item.ts
+++ b/packages/craftcms-ui/src/components/action-item/action-item.ts
@@ -10,6 +10,7 @@ import {
type ActionFeedback,
type BaseAction,
type FeedbackData,
+ normalizeAction,
runAction,
} from '@src/actions';
import {Variant, type VariantValue} from '@src/constants/variants';
@@ -43,7 +44,7 @@ export default class CraftActionItem extends LitElement {
@property({type: Boolean}) checked: boolean = false;
@property({type: Boolean}) active: boolean = false;
@property() type: 'button' | 'checkbox' = 'button';
- @property({type: Object}) action: BaseAction | null = null;
+ @property({type: Object}) action: BaseAction | string | null = null;
@property({type: Object}) feedback: ActionFeedback | null = null;
@property({type: Number, attribute: 'feedback-duration'})
feedbackDuration: number = 1000;
@@ -115,7 +116,7 @@ export default class CraftActionItem extends LitElement {
composed: true,
detail: {
state,
- actionType: this.action?.type,
+ actionType: normalizeAction(this.action)?.type,
...detail,
},
})
@@ -128,14 +129,16 @@ export default class CraftActionItem extends LitElement {
return;
}
- if (event.type === 'click' && this.action) {
+ const action = normalizeAction(this.action);
+
+ if (event.type === 'click' && action) {
// Only show loading spinner for http requests
- if (this.action.type === 'http') {
+ if (action.type === 'http') {
this.setState(AsyncStates.Loading);
}
try {
- await runAction(this.action);
+ await runAction(action, {trigger: this, sourceEvent: event});
this.setState(AsyncStates.Success, this.feedback?.success);
} catch (error: any) {
this.setState(AsyncStates.Error, {
diff --git a/packages/craftcms-ui/src/components/button/button.test.ts b/packages/craftcms-ui/src/components/button/button.test.ts
index 83c66ed4e2f..52982bc9238 100644
--- a/packages/craftcms-ui/src/components/button/button.test.ts
+++ b/packages/craftcms-ui/src/components/button/button.test.ts
@@ -158,3 +158,91 @@ describe('craft-button link semantics', () => {
expect(element.tabIndex).toBe(0);
});
});
+
+describe('craft-button actions', () => {
+ it('parses a JSON action attribute into the action property', async () => {
+ const element = await createButton({
+ action: '{"type":"event","name":"craft:test"}',
+ });
+ expect(element.action).toEqual({type: 'event', name: 'craft:test'});
+ });
+
+ it('runs an event action on click, with trigger and sourceEvent in the detail', async () => {
+ const element = await createButton({
+ action:
+ '{"type":"event","name":"craft:test-action","detail":{"foo":"bar"}}',
+ });
+
+ let detail: any = null;
+ window.addEventListener(
+ 'craft:test-action',
+ ((ev: CustomEvent) => {
+ detail = ev.detail;
+ }) as EventListener,
+ {once: true}
+ );
+ element.click();
+
+ expect(detail).not.toBeNull();
+ expect(detail.foo).toBe('bar');
+ expect(detail.trigger).toBe(element);
+ expect(detail.sourceEvent).toBeInstanceOf(Event);
+ });
+
+ it('runs an action assigned as a raw JSON string property (Vue in-DOM compilation)', async () => {
+ const element = await createButton();
+ element.action = '{"type":"event","name":"craft:test-string"}';
+
+ let fired = false;
+ window.addEventListener(
+ 'craft:test-string',
+ () => {
+ fired = true;
+ },
+ {once: true}
+ );
+ element.click();
+
+ expect(fired).toBe(true);
+ });
+
+ it('does not run the action when disabled', async () => {
+ const element = await createButton({
+ action: '{"type":"event","name":"craft:test-disabled"}',
+ disabled: '',
+ });
+
+ let fired = false;
+ window.addEventListener(
+ 'craft:test-disabled',
+ () => {
+ fired = true;
+ },
+ {once: true}
+ );
+ element.click();
+
+ expect(fired).toBe(false);
+ });
+
+ it('stops running the action once it is removed', async () => {
+ const element = await createButton({
+ action: '{"type":"event","name":"craft:test-removed"}',
+ });
+ element.removeAttribute('action');
+ element.action = null;
+ await element.updateComplete;
+
+ let fired = false;
+ window.addEventListener(
+ 'craft:test-removed',
+ () => {
+ fired = true;
+ },
+ {once: true}
+ );
+ element.click();
+
+ expect(fired).toBe(false);
+ });
+});
diff --git a/packages/craftcms-ui/src/components/button/button.ts b/packages/craftcms-ui/src/components/button/button.ts
index 856d022a2eb..a96491d3249 100644
--- a/packages/craftcms-ui/src/components/button/button.ts
+++ b/packages/craftcms-ui/src/components/button/button.ts
@@ -7,6 +7,7 @@ import '../icon/icon.js';
import {computeAccessibleName} from 'dom-accessibility-api';
import {classMap} from 'lit/directives/class-map.js';
import variantsStyles from '@src/styles/variants.styles';
+import {type BaseAction, normalizeAction, runAction} from '@src/actions';
export const ButtonAppearance = {
Solid: 'solid',
@@ -56,8 +57,35 @@ export default class CraftButton extends LionButtonSubmit {
}
super.connectedCallback();
this.syncLinkHostState();
+ this.addEventListener('click', this.#handleActionClick);
}
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ this.removeEventListener('click', this.#handleActionClick);
+ }
+
+ #handleActionClick = async (event: Event) => {
+ const action = normalizeAction(this.action);
+
+ if (!action || this.disabled) {
+ return;
+ }
+
+ event.preventDefault();
+
+ // Only show the spinner for http requests, matching craft-action-item.
+ if (action.type === 'http') {
+ this.loading = true;
+ }
+
+ try {
+ await runAction(action, {trigger: this, sourceEvent: event});
+ } finally {
+ this.loading = false;
+ }
+ };
+
override updated(changedProperties: Map) {
super.updated(changedProperties);
if (changedProperties.has('href') || changedProperties.has('disabled')) {
@@ -140,6 +168,15 @@ export default class CraftButton extends LionButtonSubmit {
/** Icon to be rendered within the content. */
@property() icon: string | null = null;
+ /**
+ * Declarative action to run when the button is clicked, as a JSON `action`
+ * attribute — the same primitives `craft-action-item` supports
+ * (`http`/`event`/`clipboard`/`download`, run via `runAction()`). A raw
+ * JSON string is accepted too (Vue's in-DOM compiler sets attribute
+ * values as string properties on upgraded elements).
+ */
+ @property({type: Object}) action: BaseAction | string | null = null;
+
/** When set, the button renders as a link to this URL. */
@property({reflect: true}) href: string | null = null;
diff --git a/packages/craftcms-ui/src/components/card/card.stories.ts b/packages/craftcms-ui/src/components/card/card.stories.ts
index e6cbc5af65a..181ace4c982 100644
--- a/packages/craftcms-ui/src/components/card/card.stories.ts
+++ b/packages/craftcms-ui/src/components/card/card.stories.ts
@@ -2,25 +2,232 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite';
import {html} from 'lit';
+import type CraftCard from './card.js';
import './card.js';
+import '../button/button.js';
+import '../icon/icon.js';
+
+const placeholderThumb = html`
+
+`;
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
const meta = {
title: 'Components/Card',
component: 'craft-card',
- argTypes: {},
- render: (args) => html`
-
- Cards are really pretty boring, their mostly available as a convienience
- for layout.
+ args: {
+ label: 'Card label',
+ active: false,
+ thumbAlignment: 'start',
+ },
+ argTypes: {
+ label: {
+ control: {type: 'text'},
+ description:
+ 'Header label; the header only renders when this (or a header/label/actions slot) is present',
+ },
+ active: {
+ control: {type: 'boolean'},
+ },
+ thumbAlignment: {
+ control: {type: 'select'},
+ options: ['start', 'end'],
+ },
+ },
+ render: ({label, active}) => html`
+
+ Cards group related content into a bordered surface, with optional header,
+ footer, and thumbnail regions.
`,
-} satisfies Meta;
+} satisfies Meta;
export default meta;
-type Story = StoryObj;
+type Story = StoryObj;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
+
+/** Body only — with no label or header/label/actions slot, no header renders. */
export const Default: Story = {
- args: {},
+ render: () => html`
+
+ A bare card is just the bordered body surface — no header or footer chrome
+ until content asks for it.
+
+ `,
+};
+
+/** The `label` attribute alone brings in the header region. */
+export const WithLabel: Story = {};
+
+/** Buttons in the `actions` slot sit at the end of the header. */
+export const WithActions: Story = {
+ render: ({label}) => html`
+
+
+
+
+ Header actions are slotted content, so anything works — icon buttons, an
+ action menu, a switch.
+
+ `,
+};
+
+/** The `label` slot replaces the label text while keeping the header layout. */
+export const CustomLabel: Story = {
+ render: () => html`
+
+
+
+ Article
+ article
+
+
+ A slotted label can carry richer content than plain text — icons, handles,
+ badges.
+
+ `,
+};
+
+/** The `header` slot replaces the whole default label/actions header. */
+export const CustomHeader: Story = {
+ render: () => html`
+
+
+ Custom header
+ anything goes here
+
+
+ When the header slot is filled, the default label/actions layout is
+ replaced entirely.
+
+ `,
+};
+
+/** The footer region only renders when the `footer` slot is filled. */
+export const WithFooter: Story = {
+ render: ({label}) => html`
+
+ Body content.
+
+
+ Updated 2 hours ago
+ Draft
+
+
+ `,
+};
+
+/** Thumbnail content renders in a fixed-width column beside the body. */
+export const WithThumbnail: Story = {
+ render: ({label, thumbAlignment}) => html`
+
+ ${placeholderThumb} The thumbnail slot gets a 120px column;
+ thumb-alignment puts it at the start or end of the body.
+
+ `,
+};
+
+export const ThumbnailAlignment: Story = {
+ render: () => html`
+
+
+ ${placeholderThumb} Thumbnail leading the body content.
+
+
+ ${placeholderThumb} Thumbnail trailing the body content.
+
+
+ `,
+};
+
+/** The reflected `active` attribute marks selection (e.g. element index rows). */
+export const Active: Story = {
+ render: ({label}) => html`
+
+ An inactive card for comparison.
+
+ An active card — loud header/footer fill and border.
+
+
+ `,
+};
+
+/** Radius, shadow, and padding are themeable via custom properties. */
+export const CustomProperties: Story = {
+ render: ({label}) => html`
+
+ Square corners, no shadow, and roomier padding via
+ --c-card-* custom properties.
+
+ `,
+};
+
+export const KitchenSink: Story = {
+ render: ({label, active, thumbAlignment}) => html`
+
+
+
+ ${placeholderThumb}
+
+ Autumn on the Coast
+
+ Header label and actions, a leading thumbnail, body content, and a
+ metadata footer — all regions at once.
+