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',
},
defaultdialogs: {
nodeNameLinkPicker: 'Link title',
nodeNameLinkPicker: 'Title',
urlLinkPicker: 'Link',
anchorLinkPicker: 'Anchor / querystring',
anchorLinkPicker: 'Anchor or querystring',
anchorInsert: 'Name',
closeThisWindow: 'Close this window',
confirmdelete: 'Are you sure you want to delete',
@@ -563,14 +563,14 @@ export default {
includeDescendants: 'Include descendants',
theFriendliestCommunity: 'The friendliest community',
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',
selectContentStartNode: 'Select content start node',
selectMedia: 'Select media',
selectMediaType: 'Select media type',
selectIcon: 'Select icon',
selectItem: 'Select item',
selectLink: 'Select link',
selectLink: 'Configure link',
selectMacro: 'Select macro',
selectContent: 'Select content',
selectContentType: 'Select content type',
@@ -671,7 +671,7 @@ export default {
email: 'Enter your email',
enterMessage: 'Enter a message...',
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...',
generatingAlias: 'Generating alias...',
a11yCreateItem: 'Create item',

View File

@@ -491,9 +491,9 @@ export default {
copiedItemOfItems: 'Copied %0% out of %1% items',
},
defaultdialogs: {
nodeNameLinkPicker: 'Link title',
nodeNameLinkPicker: 'Title',
urlLinkPicker: 'Link',
anchorLinkPicker: 'Anchor / querystring',
anchorLinkPicker: 'Anchor or querystring',
anchorInsert: 'Name',
closeThisWindow: 'Close this window',
confirmdelete: 'Are you sure you want to delete',
@@ -553,7 +553,7 @@ export default {
includeDescendants: 'Include descendants',
theFriendliestCommunity: 'The friendliest community',
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',
selectContentStartNode: 'Select content start node',
selectEvent: 'Select event',
@@ -561,7 +561,7 @@ export default {
selectMediaType: 'Select media type',
selectIcon: 'Select icon',
selectItem: 'Select item',
selectLink: 'Select link',
selectLink: 'Configure link',
selectMacro: 'Select macro',
selectContent: 'Select content',
selectContentType: 'Select content type',
@@ -663,7 +663,7 @@ export default {
email: 'Enter your email',
enterMessage: 'Enter a message...',
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...',
generatingAlias: 'Generating alias...',
a11yCreateItem: 'Create item',
@@ -671,6 +671,7 @@ export default {
a11yName: 'Name',
rteParagraph: 'Write something amazing...',
rteHeading: "What's the title?",
enterUrl: 'Enter a URL...',
},
editcontenttype: {
createListView: 'Create custom list view',
@@ -798,6 +799,7 @@ export default {
dictionary: 'Dictionary',
dimensions: 'Dimensions',
discard: 'Discard',
document: 'Document',
down: 'Down',
download: 'Download',
edit: 'Edit',
@@ -2671,4 +2673,13 @@ export default {
toolbar_removeItem: 'Remove action',
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;

View File

@@ -6,38 +6,67 @@ import type {
} from './link-picker-modal.token.js';
import { css, customElement, html, nothing, query, state, when } from '@umbraco-cms/backoffice/external/lit';
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 { UmbMediaDetailRepository } from '@umbraco-cms/backoffice/media';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import type { UmbInputDocumentElement } from '@umbraco-cms/backoffice/document';
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 } };
@customElement('umb-link-picker-modal')
export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPickerModalData, UmbLinkPickerModalValue> {
#propertyLayoutOrientation: 'horizontal' | 'vertical' = 'vertical';
#validationContext = new UmbValidationContext(this);
@state()
private _allowedMediaTypeUniques?: Array<string>;
@state()
private _config: UmbLinkPickerConfig = {
hideAnchor: false,
hideTarget: false,
};
@state()
private _allowedMediaTypeUniques?: Array<string>;
@query('umb-input-document')
private _documentPickerElement?: UmbInputDocumentElement;
@query('umb-input-media')
private _mediaPickerElement?: UmbInputMediaElement;
override async firstUpdated() {
@query('#link-anchor', true)
private _linkAnchorInput?: UUIInputElement;
override connectedCallback() {
super.connectedCallback();
if (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.
const mediaTypeStructureRepository = new UmbMediaTypeStructureRepository(this);
const { data: mediaTypes } = await mediaTypeStructureRepository.requestAllowedChildrenOf(null);
@@ -61,7 +90,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
} else if (query) {
this.#partialUpdateLink({ queryString: `#${query}` });
} else {
this.#partialUpdateLink({ queryString: query });
this.#partialUpdateLink({ queryString: '' });
}
}
@@ -100,24 +129,29 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
const unique = event.target.value;
if (unique) {
if (type === 'document') {
const documentRepository = new UmbDocumentDetailRepository(this);
const { data: documentData } = await documentRepository.requestByUnique(unique);
if (documentData) {
icon = documentData.documentType.icon;
name = documentData.variants[0].name;
url = documentData.urls[0].url;
switch (type) {
case 'document': {
const documentRepository = new UmbDocumentDetailRepository(this);
const { data: documentData } = await documentRepository.requestByUnique(unique);
if (documentData) {
icon = documentData.documentType.icon;
name = documentData.variants[0].name;
url = documentData.urls[0]?.url ?? '';
}
break;
}
}
if (type === 'media') {
const mediaRepository = new UmbMediaDetailRepository(this);
const { data: mediaData } = await mediaRepository.requestByUnique(unique);
if (mediaData) {
icon = mediaData.mediaType.icon;
name = mediaData.variants[0].name;
url = mediaData.urls[0].url;
case 'media': {
const mediaRepository = new UmbMediaDetailRepository(this);
const { data: mediaData } = await mediaRepository.requestByUnique(unique);
if (mediaData) {
icon = mediaData.mediaType.icon;
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,
type: unique ? type : undefined,
unique,
url,
url: url ?? this.value.link.url,
};
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() {
@@ -140,12 +194,16 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
this._mediaPickerElement?.shadowRoot?.querySelector('#btn-add')?.dispatchEvent(new Event('click'));
}
#triggerExternalUrl() {
this.#partialUpdateLink({ type: 'external' });
}
override render() {
return html`
<umb-body-layout headline=${this.localize.term('defaultdialogs_selectLink')}>
<uui-box>
${this.#renderLinkUrlInput()} ${this.#renderLinkTitleInput()} ${this.#renderLinkTargetInput()}
${this.#renderInternals()}
${this.#renderLinkType()} ${this.#renderLinkAnchorInput()} ${this.#renderLinkTitleInput()}
${this.#renderLinkTargetInput()}
</uui-box>
<div slot="actions">
<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"
look="primary"
label=${this.localize.term('general_submit')}
?disabled=${!this.value.link.url && !this.value.link.queryString}
@click=${this._submitModal}></uui-button>
?disabled=${!this.value.link.type}
@click=${this.#onSubmit}></uui-button>
</div>
</umb-body-layout>
`;
}
#renderLinkUrlInput() {
#renderLinkType() {
return html`
<umb-property-layout orientation="vertical">
<div class="side-by-side" slot="editor">
<umb-property-layout
orientation="vertical"
label=${this.localize.term('defaultdialogs_link')}
style="padding:0;">
<uui-input
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>
`,
)}
<umb-property-layout
orientation=${this.#propertyLayoutOrientation}
label=${this.localize.term('linkPicker_modalSource')}
mandatory>
<div slot="editor">
${this.#renderLinkTypeSelection()} ${this.#renderDocumentPicker()} ${this.#renderMediaPicker()}
${this.#renderLinkUrlInput()} ${this.#renderLinkUrlInputReadOnly()}
</div>
</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() {
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
data-mark="input:title"
slot="editor"
label=${this.localize.term('defaultdialogs_nodeNameLinkPicker')}
placeholder=${this.localize.term('defaultdialogs_nodeNameLinkPicker')}
@@ -216,7 +352,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
#renderLinkTargetInput() {
if (this._config.hideTarget) return nothing;
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
slot="editor"
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 = [
css`
uui-box {
@@ -277,15 +376,9 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
uui-input {
width: 100%;
}
.side-by-side {
display: flex;
flex-wrap: wrap;
gap: var(--uui-size-space-5);
umb-property-layout {
flex: 1 1 0px;
&[readonly] {
margin-top: var(--uui-size-space-2);
}
}
`,