diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/index.ts index ccae01905f8c..364cf37ac3a7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/index.ts @@ -1 +1,2 @@ export * from './media-picker-folder-path.element.js'; +export * from './media-picker-table-column-name.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-table-column-name.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-table-column-name.element.ts new file mode 100644 index 000000000000..46d9135f8bc4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-table-column-name.element.ts @@ -0,0 +1,81 @@ +import { css, customElement, html, nothing, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import type { UmbTableColumn, UmbTableColumnLayoutElement, UmbTableItem } from '@umbraco-cms/backoffice/components'; + +export interface UmbMediaPickerTableColumnNameValue { + name: string; + ancestorPath?: string; + /** When set, the name renders as a button that invokes this on click (e.g. to open a folder). */ + navigate?: () => void; +} + +@customElement('umb-media-picker-table-column-name') +export class UmbMediaPickerTableColumnNameElement extends UmbLitElement implements UmbTableColumnLayoutElement { + column!: UmbTableColumn; + item!: UmbTableItem; + + @property({ attribute: false }) + value!: UmbMediaPickerTableColumnNameValue; + + #onClick(event: Event) { + event.stopPropagation(); + this.value?.navigate?.(); + } + + override render() { + if (!this.value) return nothing; + return html` + ${this.value.navigate + ? html`${this.value.name}` + : html`${this.value.name}`} + ${this.value.ancestorPath + ? html`${this.value.ancestorPath}` + : nothing} + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + } + + uui-button { + --uui-button-padding-left-factor: 0; + text-align: left; + margin-bottom: -9px; + } + + .name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ancestor-path { + display: block; + font-size: 0.8em; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + /* Truncate from the start so the deepest (most disambiguating) ancestor stays visible. */ + direction: rtl; + text-align: left; + } + `, + ]; +} + +export default UmbMediaPickerTableColumnNameElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-media-picker-table-column-name': UmbMediaPickerTableColumnNameElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-table-column-name.test.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-table-column-name.test.ts new file mode 100644 index 000000000000..8084fb896566 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-table-column-name.test.ts @@ -0,0 +1,75 @@ +import { UmbMediaPickerTableColumnNameElement } from './media-picker-table-column-name.element.js'; +import { expect, fixture, html } from '@open-wc/testing'; +import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils'; + +describe('UmbMediaPickerTableColumnNameElement', () => { + let element: UmbMediaPickerTableColumnNameElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbMediaPickerTableColumnNameElement); + }); + + it('renders the name', async () => { + element.value = { name: 'My image' }; + await element.updateComplete; + expect(element.shadowRoot?.textContent).to.contain('My image'); + }); + + it('renders the ancestor path when provided', async () => { + element.value = { name: 'My image', ancestorPath: 'Media / Holiday' }; + await element.updateComplete; + const path = element.shadowRoot?.querySelector('.ancestor-path'); + expect(path).to.exist; + expect(path?.textContent).to.contain('Media / Holiday'); + }); + + it('does not render an ancestor path when not provided', async () => { + element.value = { name: 'My image' }; + await element.updateComplete; + expect(element.shadowRoot?.querySelector('.ancestor-path')).to.not.exist; + }); + + it('renders the name as a plain span when not navigable', async () => { + element.value = { name: 'My image' }; + await element.updateComplete; + expect(element.shadowRoot?.querySelector('uui-button')).to.not.exist; + expect(element.shadowRoot?.querySelector('span.name')).to.exist; + }); + + it('renders the name as a button and invokes navigate on click', async () => { + let navigated = false; + element.value = { name: 'A folder', navigate: () => (navigated = true) }; + await element.updateComplete; + + const button = element.shadowRoot?.querySelector('uui-button'); + expect(button).to.exist; + + button!.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + expect(navigated).to.be.true; + }); + + it('stops the click from bubbling so it does not trigger row selection', async () => { + element.value = { name: 'A folder', navigate: () => {} }; + await element.updateComplete; + + const button = element.shadowRoot?.querySelector('uui-button'); + + let bubbledToHost = false; + element.addEventListener('click', () => (bubbledToHost = true)); + button!.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + + expect(bubbledToHost).to.be.false; + }); + + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { + it('passes the a11y audit', async () => { + element.value = { name: 'My image', ancestorPath: 'Media / Holiday' }; + await element.updateComplete; + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); + } +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts index be4d9146506e..d6fe9d90794a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts @@ -7,6 +7,7 @@ import type { UmbMediaTreeItemModel, UmbMediaSearchItemModel, UmbMediaItemModel import { UmbMediaPickerContext } from './media-picker.context.js'; import type { UmbMediaPathModel } from './types.js'; import type { UmbMediaPickerFolderPathElement } from './components/media-picker-folder-path.element.js'; +import type { UmbMediaPickerTableColumnNameValue } from './components/media-picker-table-column-name.element.js'; import type { UmbMediaPickerModalData, UmbMediaPickerModalValue } from './media-picker-modal.token.js'; import { css, @@ -469,6 +470,11 @@ export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement< return this.#folderTypeUniques.has(item.mediaType.unique) || item.hasChildren; } + #getAncestorPath(item: UmbMediaTreeItemModel | UmbMediaSearchItemModel): string { + const ancestors = 'ancestors' in item ? item.ancestors : undefined; + return ancestors?.length ? ancestors.map((a) => a.name || '(Untitled)').join(' / ') : ''; + } + // TODO: move to search manager in context #onSearchFromChange(e: CustomEvent) { const checked = (e.target as HTMLInputElement).checked; @@ -669,6 +675,11 @@ export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement< // When not in selection mode, navigable items open on click (matching card behaviour). // Mark them as non-selectable so the row doesn't intercept the click for selection. const selectableInTable = this._isSelectionMode ? selectable : !canNavigate && selectable; + const nameColumnValue: UmbMediaPickerTableColumnNameValue = { + name: item.name, + ancestorPath: this.#getAncestorPath(item) || undefined, + navigate: canNavigate && !this._isSelectionMode ? () => this.#onOpen(item) : undefined, + }; return { id: item.unique, icon: item.mediaType.icon, @@ -676,19 +687,7 @@ export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement< data: [ { columnAlias: 'name', - value: - canNavigate && !this._isSelectionMode - ? html` { - e.stopPropagation(); - this.#onOpen(item); - }} - >${item.name}` - : html`${item.name}`, + value: nameColumnValue, }, { columnAlias: 'createDate', @@ -716,6 +715,7 @@ export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement< { name: this.localize.term('general_name'), alias: 'name', + elementName: 'umb-media-picker-table-column-name', }, { name: this.localize.term('content_createDate'), @@ -815,13 +815,6 @@ export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement< margin-top: var(--uui-size-layout-1); } - .table-name { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - #view-dropdown { padding: var(--uui-size-space-3); --uui-button-content-align: left;