V15: Link Picker Modal UX Flow (#17994)

* Link Picker: reworked modal UX flow

* Tweaked "Target" description

* Link Picker modal tweaks

* Localized "Reset URL" confirm modal

* Awaits validation on picker change

* Added `data-mark` attributes

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
This commit is contained in:
Lee Kelleher
2025-01-20 12:46:34 +00:00
committed by GitHub
parent 51823a7671
commit dab932329b
3 changed files with 223 additions and 119 deletions

View File

@@ -502,9 +502,9 @@ export default {
copiedItemOfItems: 'Copied %0% out of %1% items', copiedItemOfItems: 'Copied %0% out of %1% items',
}, },
defaultdialogs: { defaultdialogs: {
nodeNameLinkPicker: 'Link title', nodeNameLinkPicker: 'Title',
urlLinkPicker: 'Link', urlLinkPicker: 'Link',
anchorLinkPicker: 'Anchor / querystring', anchorLinkPicker: 'Anchor or querystring',
anchorInsert: 'Name', anchorInsert: 'Name',
closeThisWindow: 'Close this window', closeThisWindow: 'Close this window',
confirmdelete: 'Are you sure you want to delete', confirmdelete: 'Are you sure you want to delete',
@@ -563,14 +563,14 @@ export default {
includeDescendants: 'Include descendants', includeDescendants: 'Include descendants',
theFriendliestCommunity: 'The friendliest community', theFriendliestCommunity: 'The friendliest community',
linkToPage: 'Link to document', linkToPage: 'Link to document',
openInNewWindow: 'Opens the linked document in a new window or tab', openInNewWindow: 'Opens the link in a new window or tab',
linkToMedia: 'Link to media', linkToMedia: 'Link to media',
selectContentStartNode: 'Select content start node', selectContentStartNode: 'Select content start node',
selectMedia: 'Select media', selectMedia: 'Select media',
selectMediaType: 'Select media type', selectMediaType: 'Select media type',
selectIcon: 'Select icon', selectIcon: 'Select icon',
selectItem: 'Select item', selectItem: 'Select item',
selectLink: 'Select link', selectLink: 'Configure link',
selectMacro: 'Select macro', selectMacro: 'Select macro',
selectContent: 'Select content', selectContent: 'Select content',
selectContentType: 'Select content type', selectContentType: 'Select content type',
@@ -671,7 +671,7 @@ export default {
email: 'Enter your email', email: 'Enter your email',
enterMessage: 'Enter a message...', enterMessage: 'Enter a message...',
usernameHint: 'Your username is usually your email', usernameHint: 'Your username is usually your email',
anchor: '#value or ?key=value', anchor: 'Enter an anchor or querystring, #value or ?key=value',
enterAlias: 'Enter alias...', enterAlias: 'Enter alias...',
generatingAlias: 'Generating alias...', generatingAlias: 'Generating alias...',
a11yCreateItem: 'Create item', a11yCreateItem: 'Create item',

View File

@@ -491,9 +491,9 @@ export default {
copiedItemOfItems: 'Copied %0% out of %1% items', copiedItemOfItems: 'Copied %0% out of %1% items',
}, },
defaultdialogs: { defaultdialogs: {
nodeNameLinkPicker: 'Link title', nodeNameLinkPicker: 'Title',
urlLinkPicker: 'Link', urlLinkPicker: 'Link',
anchorLinkPicker: 'Anchor / querystring', anchorLinkPicker: 'Anchor or querystring',
anchorInsert: 'Name', anchorInsert: 'Name',
closeThisWindow: 'Close this window', closeThisWindow: 'Close this window',
confirmdelete: 'Are you sure you want to delete', confirmdelete: 'Are you sure you want to delete',
@@ -553,7 +553,7 @@ export default {
includeDescendants: 'Include descendants', includeDescendants: 'Include descendants',
theFriendliestCommunity: 'The friendliest community', theFriendliestCommunity: 'The friendliest community',
linkToPage: 'Link to document', linkToPage: 'Link to document',
openInNewWindow: 'Opens the linked document in a new window or tab', openInNewWindow: 'Opens the link in a new window or tab',
linkToMedia: 'Link to media', linkToMedia: 'Link to media',
selectContentStartNode: 'Select content start node', selectContentStartNode: 'Select content start node',
selectEvent: 'Select event', selectEvent: 'Select event',
@@ -561,7 +561,7 @@ export default {
selectMediaType: 'Select media type', selectMediaType: 'Select media type',
selectIcon: 'Select icon', selectIcon: 'Select icon',
selectItem: 'Select item', selectItem: 'Select item',
selectLink: 'Select link', selectLink: 'Configure link',
selectMacro: 'Select macro', selectMacro: 'Select macro',
selectContent: 'Select content', selectContent: 'Select content',
selectContentType: 'Select content type', selectContentType: 'Select content type',
@@ -663,7 +663,7 @@ export default {
email: 'Enter your email', email: 'Enter your email',
enterMessage: 'Enter a message...', enterMessage: 'Enter a message...',
usernameHint: 'Your username is usually your email', usernameHint: 'Your username is usually your email',
anchor: '#value or ?key=value', anchor: 'Enter an anchor or querystring, #value or ?key=value',
enterAlias: 'Enter alias...', enterAlias: 'Enter alias...',
generatingAlias: 'Generating alias...', generatingAlias: 'Generating alias...',
a11yCreateItem: 'Create item', a11yCreateItem: 'Create item',
@@ -671,6 +671,7 @@ export default {
a11yName: 'Name', a11yName: 'Name',
rteParagraph: 'Write something amazing...', rteParagraph: 'Write something amazing...',
rteHeading: "What's the title?", rteHeading: "What's the title?",
enterUrl: 'Enter a URL...',
}, },
editcontenttype: { editcontenttype: {
createListView: 'Create custom list view', createListView: 'Create custom list view',
@@ -798,6 +799,7 @@ export default {
dictionary: 'Dictionary', dictionary: 'Dictionary',
dimensions: 'Dimensions', dimensions: 'Dimensions',
discard: 'Discard', discard: 'Discard',
document: 'Document',
down: 'Down', down: 'Down',
download: 'Download', download: 'Download',
edit: 'Edit', edit: 'Edit',
@@ -2671,4 +2673,13 @@ export default {
toolbar_removeItem: 'Remove action', toolbar_removeItem: 'Remove action',
toolbar_emptyGroup: 'Empty', toolbar_emptyGroup: 'Empty',
}, },
linkPicker: {
modalSource: 'Source',
modalManual: 'Manual',
modalAnchorValidationMessage:
'Please enter an anchor or querystring, or select a published document or media item, or manually configure the URL.',
resetUrlHeadline: 'Reset URL?',
resetUrlMessage: 'Are you sure you want to reset this URL?',
resetUrlLabel: 'Reset',
},
} as UmbLocalizationDictionary; } as UmbLocalizationDictionary;

View File

@@ -6,38 +6,67 @@ import type {
} from './link-picker-modal.token.js'; } from './link-picker-modal.token.js';
import { css, customElement, html, nothing, query, state, when } from '@umbraco-cms/backoffice/external/lit'; import { css, customElement, html, nothing, query, state, when } from '@umbraco-cms/backoffice/external/lit';
import { isUmbracoFolder, UmbMediaTypeStructureRepository } from '@umbraco-cms/backoffice/media-type'; import { isUmbracoFolder, UmbMediaTypeStructureRepository } from '@umbraco-cms/backoffice/media-type';
import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { umbBindToValidation, UmbValidationContext } from '@umbraco-cms/backoffice/validation';
import { umbConfirmModal, UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import { UmbDocumentDetailRepository } from '@umbraco-cms/backoffice/document'; import { UmbDocumentDetailRepository } from '@umbraco-cms/backoffice/document';
import { UmbMediaDetailRepository } from '@umbraco-cms/backoffice/media'; import { UmbMediaDetailRepository } from '@umbraco-cms/backoffice/media';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import type { UmbInputDocumentElement } from '@umbraco-cms/backoffice/document'; import type { UmbInputDocumentElement } from '@umbraco-cms/backoffice/document';
import type { UmbInputMediaElement } from '@umbraco-cms/backoffice/media'; import type { UmbInputMediaElement } from '@umbraco-cms/backoffice/media';
import type { UUIBooleanInputEvent, UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import type { UUIBooleanInputEvent, UUIInputElement, UUIInputEvent } from '@umbraco-cms/backoffice/external/uui';
type UmbInputPickerEvent = CustomEvent & { target: { value?: string } }; type UmbInputPickerEvent = CustomEvent & { target: { value?: string } };
@customElement('umb-link-picker-modal') @customElement('umb-link-picker-modal')
export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPickerModalData, UmbLinkPickerModalValue> { export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPickerModalData, UmbLinkPickerModalValue> {
#propertyLayoutOrientation: 'horizontal' | 'vertical' = 'vertical';
#validationContext = new UmbValidationContext(this);
@state()
private _allowedMediaTypeUniques?: Array<string>;
@state() @state()
private _config: UmbLinkPickerConfig = { private _config: UmbLinkPickerConfig = {
hideAnchor: false, hideAnchor: false,
hideTarget: false, hideTarget: false,
}; };
@state()
private _allowedMediaTypeUniques?: Array<string>;
@query('umb-input-document') @query('umb-input-document')
private _documentPickerElement?: UmbInputDocumentElement; private _documentPickerElement?: UmbInputDocumentElement;
@query('umb-input-media') @query('umb-input-media')
private _mediaPickerElement?: UmbInputMediaElement; private _mediaPickerElement?: UmbInputMediaElement;
override async firstUpdated() { @query('#link-anchor', true)
private _linkAnchorInput?: UUIInputElement;
override connectedCallback() {
super.connectedCallback();
if (this.data?.config) { if (this.data?.config) {
this._config = this.data.config; this._config = this.data.config;
} }
if (this.modalContext) {
this.observe(this.modalContext.size, (size) => {
if (size === 'large' || size === 'full') {
this.#propertyLayoutOrientation = 'horizontal';
}
});
}
this.#getMediaTypes();
}
protected override firstUpdated() {
this._linkAnchorInput?.addValidator(
'valueMissing',
() => this.localize.term('linkPicker_modalAnchorValidationMessage'),
() => !this.value.link.url && !this.value.link.queryString,
);
}
async #getMediaTypes() {
// Get all the media types, excluding the folders, so that files are selectable media items. // Get all the media types, excluding the folders, so that files are selectable media items.
const mediaTypeStructureRepository = new UmbMediaTypeStructureRepository(this); const mediaTypeStructureRepository = new UmbMediaTypeStructureRepository(this);
const { data: mediaTypes } = await mediaTypeStructureRepository.requestAllowedChildrenOf(null); const { data: mediaTypes } = await mediaTypeStructureRepository.requestAllowedChildrenOf(null);
@@ -61,7 +90,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
} else if (query) { } else if (query) {
this.#partialUpdateLink({ queryString: `#${query}` }); this.#partialUpdateLink({ queryString: `#${query}` });
} else { } else {
this.#partialUpdateLink({ queryString: query }); this.#partialUpdateLink({ queryString: '' });
} }
} }
@@ -100,24 +129,29 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
const unique = event.target.value; const unique = event.target.value;
if (unique) { if (unique) {
if (type === 'document') { switch (type) {
const documentRepository = new UmbDocumentDetailRepository(this); case 'document': {
const { data: documentData } = await documentRepository.requestByUnique(unique); const documentRepository = new UmbDocumentDetailRepository(this);
if (documentData) { const { data: documentData } = await documentRepository.requestByUnique(unique);
icon = documentData.documentType.icon; if (documentData) {
name = documentData.variants[0].name; icon = documentData.documentType.icon;
url = documentData.urls[0].url; name = documentData.variants[0].name;
url = documentData.urls[0]?.url ?? '';
}
break;
} }
} case 'media': {
const mediaRepository = new UmbMediaDetailRepository(this);
if (type === 'media') { const { data: mediaData } = await mediaRepository.requestByUnique(unique);
const mediaRepository = new UmbMediaDetailRepository(this); if (mediaData) {
const { data: mediaData } = await mediaRepository.requestByUnique(unique); icon = mediaData.mediaType.icon;
if (mediaData) { name = mediaData.variants[0].name;
icon = mediaData.mediaType.icon; url = mediaData.urls[0].url;
name = mediaData.variants[0].name; }
url = mediaData.urls[0].url; break;
} }
default:
break;
} }
} }
@@ -126,10 +160,30 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
name: this.value.link.name || name, name: this.value.link.name || name,
type: unique ? type : undefined, type: unique ? type : undefined,
unique, unique,
url, url: url ?? this.value.link.url,
}; };
this.#partialUpdateLink(link); this.#partialUpdateLink(link);
await this.#validationContext.validate();
}
async #onResetUrl() {
if (this.value.link.url) {
await umbConfirmModal(this, {
color: 'danger',
headline: this.localize.term('linkPicker_resetUrlHeadline'),
content: this.localize.term('linkPicker_resetUrlMessage'),
confirmLabel: this.localize.term('linkPicker_resetUrlLabel'),
});
}
this.#partialUpdateLink({ type: null, url: null });
}
async #onSubmit() {
await this.#validationContext.validate();
this.modalContext?.submit();
} }
#triggerDocumentPicker() { #triggerDocumentPicker() {
@@ -140,12 +194,16 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
this._mediaPickerElement?.shadowRoot?.querySelector('#btn-add')?.dispatchEvent(new Event('click')); this._mediaPickerElement?.shadowRoot?.querySelector('#btn-add')?.dispatchEvent(new Event('click'));
} }
#triggerExternalUrl() {
this.#partialUpdateLink({ type: 'external' });
}
override render() { override render() {
return html` return html`
<umb-body-layout headline=${this.localize.term('defaultdialogs_selectLink')}> <umb-body-layout headline=${this.localize.term('defaultdialogs_selectLink')}>
<uui-box> <uui-box>
${this.#renderLinkUrlInput()} ${this.#renderLinkTitleInput()} ${this.#renderLinkTargetInput()} ${this.#renderLinkType()} ${this.#renderLinkAnchorInput()} ${this.#renderLinkTitleInput()}
${this.#renderInternals()} ${this.#renderLinkTargetInput()}
</uui-box> </uui-box>
<div slot="actions"> <div slot="actions">
<uui-button label=${this.localize.term('general_close')} @click=${this._rejectModal}></uui-button> <uui-button label=${this.localize.term('general_close')} @click=${this._rejectModal}></uui-button>
@@ -153,56 +211,134 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
color="positive" color="positive"
look="primary" look="primary"
label=${this.localize.term('general_submit')} label=${this.localize.term('general_submit')}
?disabled=${!this.value.link.url && !this.value.link.queryString} ?disabled=${!this.value.link.type}
@click=${this._submitModal}></uui-button> @click=${this.#onSubmit}></uui-button>
</div> </div>
</umb-body-layout> </umb-body-layout>
`; `;
} }
#renderLinkUrlInput() { #renderLinkType() {
return html` return html`
<umb-property-layout orientation="vertical"> <umb-property-layout
<div class="side-by-side" slot="editor"> orientation=${this.#propertyLayoutOrientation}
<umb-property-layout label=${this.localize.term('linkPicker_modalSource')}
orientation="vertical" mandatory>
label=${this.localize.term('defaultdialogs_link')} <div slot="editor">
style="padding:0;"> ${this.#renderLinkTypeSelection()} ${this.#renderDocumentPicker()} ${this.#renderMediaPicker()}
<uui-input ${this.#renderLinkUrlInput()} ${this.#renderLinkUrlInputReadOnly()}
slot="editor"
placeholder=${this.localize.term('general_url')}
label=${this.localize.term('general_url')}
.value=${this.value.link.url ?? ''}
?disabled=${this.value.link.unique ? true : false}
@change=${this.#onLinkUrlInput}
${umbFocus()}>
</uui-input>
</umb-property-layout>
${when(
!this._config.hideAnchor,
() => html`
<umb-property-layout
orientation="vertical"
label=${this.localize.term('defaultdialogs_anchorLinkPicker')}
style="padding:0;">
<uui-input
slot="editor"
placeholder=${this.localize.term('placeholders_anchor')}
label=${this.localize.term('placeholders_anchor')}
@change=${this.#onLinkAnchorInput}
.value=${this.value.link.queryString ?? ''}></uui-input>
</umb-property-layout>
`,
)}
</div> </div>
</umb-property-layout> </umb-property-layout>
`; `;
} }
#renderLinkTypeSelection() {
if (this.value.link.type) return nothing;
return html`
<uui-button-group>
<uui-button
data-mark="action:document"
look="placeholder"
label=${this.localize.term('general_document')}
@click=${this.#triggerDocumentPicker}></uui-button>
<uui-button
data-mark="action:media"
look="placeholder"
label=${this.localize.term('general_media')}
@click=${this.#triggerMediaPicker}></uui-button>
<uui-button
data-mark="action:external"
look="placeholder"
label=${this.localize.term('linkPicker_modalManual')}
@click=${this.#triggerExternalUrl}></uui-button>
</uui-button-group>
`;
}
#renderDocumentPicker() {
return html`
<umb-input-document
?hidden=${!this.value.link.unique || this.value.link.type !== 'document'}
.max=${1}
.showOpenButton=${true}
.value=${this.value.link.unique && this.value.link.type === 'document' ? this.value.link.unique : ''}
@change=${(e: UmbInputPickerEvent) => this.#onPickerSelection(e, 'document')}>
</umb-input-document>
`;
}
#renderMediaPicker() {
return html`
<umb-input-media
?hidden=${!this.value.link.unique || this.value.link.type !== 'media'}
.allowedContentTypeIds=${this._allowedMediaTypeUniques}
.max=${1}
.value=${this.value.link.unique && this.value.link.type === 'media' ? this.value.link.unique : ''}
@change=${(e: UmbInputPickerEvent) => this.#onPickerSelection(e, 'media')}></umb-input-media>
`;
}
#renderLinkUrlInput() {
if (this.value.link.type !== 'external') return nothing;
return html`
<uui-input
data-mark="input:url"
label=${this.localize.term('placeholders_enterUrl')}
placeholder=${this.localize.term('placeholders_enterUrl')}
.value=${this.value.link.url ?? ''}
?disabled=${!!this.value.link.unique}
?required=${this._config.hideAnchor}
@change=${this.#onLinkUrlInput}
${umbBindToValidation(this)}>
${when(
!this.value.link.unique,
() => html`
<div slot="append">
<uui-button
slot="append"
compact
label=${this.localize.term('general_remove')}
@click=${this.#onResetUrl}>
<uui-icon name="remove"></uui-icon>
</uui-button>
</div>
`,
)}
</uui-input>
`;
}
#renderLinkUrlInputReadOnly() {
if (!this.value.link.unique || !this.value.link.url) return nothing;
return html`<uui-input readonly value=${this.value.link.url}></uui-input>`;
}
#renderLinkAnchorInput() {
if (this._config.hideAnchor) return nothing;
return html`
<umb-property-layout
orientation=${this.#propertyLayoutOrientation}
label=${this.localize.term('defaultdialogs_anchorLinkPicker')}>
<uui-input
data-mark="input:anchor"
slot="editor"
id="link-anchor"
label=${this.localize.term('placeholders_anchor')}
placeholder=${this.localize.term('placeholders_anchor')}
.value=${this.value.link.queryString ?? ''}
@change=${this.#onLinkAnchorInput}
${umbBindToValidation(this)}></uui-input>
</umb-property-layout>
`;
}
#renderLinkTitleInput() { #renderLinkTitleInput() {
return html` return html`
<umb-property-layout orientation="vertical" label=${this.localize.term('defaultdialogs_nodeNameLinkPicker')}> <umb-property-layout
orientation=${this.#propertyLayoutOrientation}
label=${this.localize.term('defaultdialogs_nodeNameLinkPicker')}>
<uui-input <uui-input
data-mark="input:title"
slot="editor" slot="editor"
label=${this.localize.term('defaultdialogs_nodeNameLinkPicker')} label=${this.localize.term('defaultdialogs_nodeNameLinkPicker')}
placeholder=${this.localize.term('defaultdialogs_nodeNameLinkPicker')} placeholder=${this.localize.term('defaultdialogs_nodeNameLinkPicker')}
@@ -216,7 +352,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
#renderLinkTargetInput() { #renderLinkTargetInput() {
if (this._config.hideTarget) return nothing; if (this._config.hideTarget) return nothing;
return html` return html`
<umb-property-layout orientation="vertical" label=${this.localize.term('content_target')}> <umb-property-layout orientation=${this.#propertyLayoutOrientation} label=${this.localize.term('content_target')}>
<uui-toggle <uui-toggle
slot="editor" slot="editor"
label=${this.localize.term('defaultdialogs_openInNewWindow')} label=${this.localize.term('defaultdialogs_openInNewWindow')}
@@ -228,43 +364,6 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
`; `;
} }
#renderInternals() {
return html`
<umb-property-layout orientation="vertical" label=${this.localize.term('defaultdialogs_linkinternal')}>
<div slot="editor">
${when(
!this.value.link.unique,
() => html`
<uui-button-group>
<uui-button
look="placeholder"
label=${this.localize.term('defaultdialogs_linkToPage')}
@click=${this.#triggerDocumentPicker}></uui-button>
<uui-button
look="placeholder"
label=${this.localize.term('defaultdialogs_linkToMedia')}
@click=${this.#triggerMediaPicker}></uui-button>
</uui-button-group>
`,
)}
<umb-input-document
?hidden=${!this.value.link.unique || this.value.link.type !== 'document'}
.max=${1}
.showOpenButton=${true}
.value=${this.value.link.unique && this.value.link.type === 'document' ? this.value.link.unique : ''}
@change=${(e: UmbInputPickerEvent) => this.#onPickerSelection(e, 'document')}>
</umb-input-document>
<umb-input-media
?hidden=${!this.value.link.unique || this.value.link.type !== 'media'}
.allowedContentTypeIds=${this._allowedMediaTypeUniques}
.max=${1}
.value=${this.value.link.unique && this.value.link.type === 'media' ? this.value.link.unique : ''}
@change=${(e: UmbInputPickerEvent) => this.#onPickerSelection(e, 'media')}></umb-input-media>
</div>
</umb-property-layout>
`;
}
static override styles = [ static override styles = [
css` css`
uui-box { uui-box {
@@ -277,15 +376,9 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
uui-input { uui-input {
width: 100%; width: 100%;
}
.side-by-side { &[readonly] {
display: flex; margin-top: var(--uui-size-space-2);
flex-wrap: wrap;
gap: var(--uui-size-space-5);
umb-property-layout {
flex: 1 1 0px;
} }
} }
`, `,