diff --git a/packages/craftcms-legacy/cp/src/Craft.js b/packages/craftcms-legacy/cp/src/Craft.js index e145fefc0d9..feae5013fee 100644 --- a/packages/craftcms-legacy/cp/src/Craft.js +++ b/packages/craftcms-legacy/cp/src/Craft.js @@ -98,5 +98,8 @@ import './js/UriFormatGenerator.js'; import './js/UserIndex.js'; // Custom elements +// craft-element-label is superseded by @craftcms/ui's craft-truncate, but keep +// it registered for backwards compatibility (e.g. plugins / the yii2 adapter). +import '@craftcms/ui/components/truncate/truncate'; import './js/CraftElementLabel'; import './js/CraftProxyScrollbar'; diff --git a/packages/craftcms-legacy/cp/src/js/BaseElementSelectInput.js b/packages/craftcms-legacy/cp/src/js/BaseElementSelectInput.js index d4d5cf3a79c..bab121ec59b 100644 --- a/packages/craftcms-legacy/cp/src/js/BaseElementSelectInput.js +++ b/packages/craftcms-legacy/cp/src/js/BaseElementSelectInput.js @@ -228,10 +228,10 @@ Craft.BaseElementSelectInput = Garnish.Base.extend( onBeforeDragStart: () => { this.elementEditor?.pause(); - // Disable all craft-element-labels so connectedCallback() - // doesn't get fired constantly during drag + // Disable the labels' overflow tooltips while dragging so they + // don't recompute/pop up during the drag. this.$elementsContainer - .find('craft-element-label') + .find('craft-truncate') .attr('disabled', true); }, onDragStop: () => { @@ -239,7 +239,7 @@ Craft.BaseElementSelectInput = Garnish.Base.extend( // Put things back where we found them. this.$elementsContainer - .find('craft-element-label') + .find('craft-truncate') .removeAttr('disabled'); }, onSortChange: () => { diff --git a/packages/craftcms-legacy/cp/src/js/CraftElementLabel.js b/packages/craftcms-legacy/cp/src/js/CraftElementLabel.js index b72d09367bd..f6f9fb2a3b8 100644 --- a/packages/craftcms-legacy/cp/src/js/CraftElementLabel.js +++ b/packages/craftcms-legacy/cp/src/js/CraftElementLabel.js @@ -1,6 +1,9 @@ /** global: $ */ /** global: jQuery */ +// Fallback for generating a unique invoker id when the element itself has none. +let elementLabelTooltipId = 0; + /** * Element label * @@ -56,7 +59,9 @@ class CraftElementLabel extends HTMLElement { } update() { - this.desiredWidth = this.calculateWidth(this.innerText); + // Measure the label link's text, not `this.innerText`, so the tooltip's + // own (light-DOM) text content can't skew the overflow calculation. + this.desiredWidth = this.calculateWidth(this.labelLink.innerText); this.hasOverflow = this.desiredWidth > this.scrollWidth; // If the label has an overflow, add a tooltip @@ -75,38 +80,37 @@ class CraftElementLabel extends HTMLElement { } createTooltip() { - this.tooltip = document.createElement('craft-tooltip'); - this.tooltip.setAttribute('self-managed', 'true'); - this.tooltip.setAttribute('text', this.innerText); - this.tooltip.setAttribute('aria-hidden', 'true'); - - // Make sure tooltips created show ellipses - Object.assign(this.tooltip.style, { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }); + const labelLink = this.labelLink; + + // The new craft-tooltip references its invoker (the label link) by id + // rather than wrapping it, so make sure the link has one. The CSS on + // `.label-link` already handles the visual ellipsis truncation. + if (!labelLink.id) { + labelLink.id = this.id + ? `${this.id}-link` + : `element-label-link-${elementLabelTooltipId++}`; + } - // If there's a context label, make it a little nicer + // The tooltip shows the full, untruncated label text; the tooltip content + // lives in its default slot. Present a context label (e.g. a draft name) + // in parentheses, matching the inline label. + let text = labelLink.innerText; const contextLabel = this.querySelector('.context-label'); if (contextLabel) { - this.tooltip.innerText = this.tooltip.innerText.replace( + text = text.replace( contextLabel.innerText, ` (${contextLabel.innerText})` ); } - this.insertBefore(this.tooltip, this.labelLink); - this.tooltip.appendChild(this.labelLink); + this.tooltip = document.createElement('craft-tooltip'); + this.tooltip.setAttribute('for', labelLink.id); + this.tooltip.textContent = text; + + this.appendChild(this.tooltip); } disconnectedCallback() { - // put the .label-link back into - // so that when connectedCallback() is called after insetBefore/insertAfter - // everything can get initialised as expected - // we can't use Element.moveBefore/Element.moveAfter as those are experimental at the moment and not available in Safari & FF - this.append(this.labelLink); - this.tooltip?.remove(); if (this.$tabs?.length) { this.$tabs.data('tabs')?.off('selectTab'); diff --git a/packages/craftcms-legacy/cp/src/js/ElementEditorSlideout.js b/packages/craftcms-legacy/cp/src/js/ElementEditorSlideout.js index 29d5158965b..bc56878ed30 100644 --- a/packages/craftcms-legacy/cp/src/js/ElementEditorSlideout.js +++ b/packages/craftcms-legacy/cp/src/js/ElementEditorSlideout.js @@ -175,7 +175,7 @@ Craft.ElementEditorSlideout = Craft.CpScreenSlideout.extend( if (e?.response?.data?.invalidNestedElementIds) { const $cards = this.$content.find('.element.card').removeClass('error'); $cards - .find('craft-element-label > span[data-icon="triangle-exclamation"]') + .find('craft-truncate > span[data-icon="triangle-exclamation"]') .remove(); if (e.response.data.invalidNestedElementIds.length) { const $errorCards = $cards @@ -186,7 +186,7 @@ Craft.ElementEditorSlideout = Craft.CpScreenSlideout.extend( ) .addClass('error'); for (let i = 0; i < $errorCards.length; i++) { - const $label = $errorCards.eq(i).find('craft-element-label'); + const $label = $errorCards.eq(i).find('craft-truncate'); $('', { 'data-icon': 'triangle-exclamation', 'aria-label': Craft.t('app', 'Error'), diff --git a/packages/craftcms-legacy/cp/src/js/TagSelectInput.js b/packages/craftcms-legacy/cp/src/js/TagSelectInput.js index d489b9974b0..c53f51a8b9a 100644 --- a/packages/craftcms-legacy/cp/src/js/TagSelectInput.js +++ b/packages/craftcms-legacy/cp/src/js/TagSelectInput.js @@ -332,7 +332,7 @@ Craft.TagSelectInput = Craft.BaseElementSelectInput.extend( class: 'chip-content', }).appendTo($element); - const $titleContainer = $('', { + const $titleContainer = $('', { class: 'label', }).appendTo($chipContent); diff --git a/packages/craftcms-ui/src/components/breadcrumb-item/breadcrumb-item.ts b/packages/craftcms-ui/src/components/breadcrumb-item/breadcrumb-item.ts index da10c484f65..06334027b43 100644 --- a/packages/craftcms-ui/src/components/breadcrumb-item/breadcrumb-item.ts +++ b/packages/craftcms-ui/src/components/breadcrumb-item/breadcrumb-item.ts @@ -20,6 +20,7 @@ export default class CraftBreadcrumbItem extends LitElement { :host { white-space: nowrap; display: inline-flex; + gap: var(--c-spacing-sm); align-items: center; color: inherit; } @@ -36,11 +37,11 @@ export default class CraftBreadcrumbItem extends LitElement { text-decoration: underline; } - slot[name='start']::slotted(*) { + slot[name='prefix']::slotted(*) { margin-inline-end: var(--c-spacing-sm); } - slot[name='end']::slotted(*) { + slot[name='suffix']::slotted(*) { margin-inline-start: var(--c-spacing-sm); } @@ -63,9 +64,9 @@ export default class CraftBreadcrumbItem extends LitElement { : html``; return html` - + ${label} - + `; } diff --git a/packages/craftcms-ui/src/components/chip/chip.styles.ts b/packages/craftcms-ui/src/components/chip/chip.styles.ts index 4cd52e029d0..3a1c5791f77 100644 --- a/packages/craftcms-ui/src/components/chip/chip.styles.ts +++ b/packages/craftcms-ui/src/components/chip/chip.styles.ts @@ -5,6 +5,10 @@ export default css` display: contents; } + ::slotted([slot='status']) { + display: inline-flex; + } + .cp-chip { --_min-height: var(--c-chip-height, none); --_thumb-size: calc(24rem / 16); @@ -72,19 +76,25 @@ export default css` } .cp-chip__prefix { + position: relative; padding-inline-end: var(--c-spacing-sm); + display: flex; + align-items: center; + flex-direction: row; + flex-wrap: nowrap; } .cp-chip__suffix { padding-inline-start: var(--c-spacing-md); } - .cp-chip__indicator { + .cp-chip__status { display: inline-flex; - padding-inline: var(--c-spacing-sm); + padding-inline: var(--c-spacing-xs); } .cp-chip__thumbnail { + position: relative; padding: var(--c-spacing-sm); /*border-radius: calc(var(--_radius) - var(--c-spacing-xs));*/ } diff --git a/packages/craftcms-ui/src/components/chip/chip.ts b/packages/craftcms-ui/src/components/chip/chip.ts index cff876fe53f..3b848997fe3 100644 --- a/packages/craftcms-ui/src/components/chip/chip.ts +++ b/packages/craftcms-ui/src/components/chip/chip.ts @@ -1,5 +1,5 @@ import {property} from 'lit/decorators.js'; -import type {CSSResultGroup, PropertyValueMap, PropertyValues} from 'lit'; +import type {CSSResultGroup, PropertyValues} from 'lit'; import {html, LitElement, nothing} from 'lit'; import styles from './chip.styles.js'; import {classMap} from 'lit/directives/class-map.js'; @@ -52,25 +52,30 @@ export default class CraftChip extends LitElement { /** Shortcut for adding an icon as the prefix */ @property() icon: string | null = null; + @property({attribute: 'show-indicators', type: Boolean}) + showIndicators: boolean = false; + @property({attribute: 'show-status', type: Boolean}) + showStatus: boolean = false; + @property({attribute: 'show-thumb', type: Boolean}) showThumb: boolean = + false; + @property({type: Boolean}) selectable: boolean = false; + #thumbLoader = new ThumbnailLoader(); renderPrefix() { - const hasThumb = !!this.querySelector('[slot="thumbnail"]'); - const hasIndicator = !!this.querySelector('[slot="indicator"]'); - - return html`
+ return html`
- ${hasThumb + ${this.showThumb ? html`` : nothing} - ${hasIndicator - ? html`` + ${this.icon + ? html`` + : nothing} + ${this.showStatus + ? html`` : nothing} - - ${this.icon - ? html` ` - : nothing} -
`; } @@ -99,8 +104,13 @@ export default class CraftChip extends LitElement { 'cp-chip--medium': this.size === 'medium', 'cp-chip--large': this.size === 'large', 'cp-chip--plain': this.appearance === Appearance.Plain, + 'cp-chip--selectable': this.selectable, + 'cp-chip--show-thumb': this.showThumb, + 'cp-chip--show-indicators': this.showIndicators, + 'cp-chip--show-status': this.showStatus, })}" > + ${this.selectable ? html` ` : nothing} ${renderPrefix ? this.renderPrefix() : nothing}
diff --git a/packages/craftcms-ui/src/components/indicator/indicator.ts b/packages/craftcms-ui/src/components/indicator/indicator.ts index 4952ff06f4e..6e9fc4ac3ea 100644 --- a/packages/craftcms-ui/src/components/indicator/indicator.ts +++ b/packages/craftcms-ui/src/components/indicator/indicator.ts @@ -17,6 +17,10 @@ export default class CraftIndicator extends LitElement { static override styles = [ variantsStyles, css` + :host { + display: contents; + } + .indicator { --_fill: var(--fill, var(--c-color-fill-loud)); --_size: var(--size, 0.5em); diff --git a/packages/craftcms-ui/src/components/truncate/truncate.stories.ts b/packages/craftcms-ui/src/components/truncate/truncate.stories.ts new file mode 100644 index 00000000000..2eabdf6d4ea --- /dev/null +++ b/packages/craftcms-ui/src/components/truncate/truncate.stories.ts @@ -0,0 +1,61 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; + +import {html} from 'lit'; + +import './truncate.js'; + +const meta = { + title: 'Components/Truncate', + component: 'craft-truncate', + args: { + width: '200px', + content: + 'This is a fairly long piece of text that will be truncated when it does not fit', + placement: 'top', + }, + argTypes: { + width: {control: {type: 'text'}}, + content: {control: {type: 'text'}}, + placement: { + control: {type: 'select'}, + options: [ + 'top-start', + 'top', + 'top-end', + 'right-start', + 'right', + 'right-end', + 'bottom-end', + 'bottom', + 'bottom-start', + 'left-end', + 'left', + 'left-start', + ], + }, + }, + parameters: { + layout: 'centered', + }, + render: (args) => html` +
+ ${args.content} +
+ `, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +export const FitsWithoutTooltip: Story = { + args: { + width: '400px', + content: 'Short label', + }, +}; diff --git a/packages/craftcms-ui/src/components/truncate/truncate.styles.ts b/packages/craftcms-ui/src/components/truncate/truncate.styles.ts new file mode 100644 index 00000000000..ab475d3ea47 --- /dev/null +++ b/packages/craftcms-ui/src/components/truncate/truncate.styles.ts @@ -0,0 +1,17 @@ +import {css} from 'lit'; + +export default css` + :host { + display: inline-flex; + /* Allow the element to shrink below its content size in flex/grid layouts + so the text actually truncates instead of forcing the container wider. */ + min-width: 0; + } + + .truncate { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +`; diff --git a/packages/craftcms-ui/src/components/truncate/truncate.test.ts b/packages/craftcms-ui/src/components/truncate/truncate.test.ts new file mode 100644 index 00000000000..93154a79467 --- /dev/null +++ b/packages/craftcms-ui/src/components/truncate/truncate.test.ts @@ -0,0 +1,62 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type CraftTruncate from './truncate.js'; +import './truncate.js'; + +async function fixture(text: string): Promise { + const el = document.createElement('craft-truncate') as CraftTruncate; + el.textContent = text; + document.body.append(el); + await el.updateComplete; + return el; +} + +// happy-dom has no layout engine, so fake the inner span's metrics and nudge a +// re-measure through a slotchange. +function simulateOverflow(el: CraftTruncate, overflow: boolean) { + const span = el.renderRoot.querySelector('.truncate') as HTMLElement; + Object.defineProperty(span, 'scrollWidth', { + value: overflow ? 500 : 100, + configurable: true, + }); + Object.defineProperty(span, 'clientWidth', {value: 100, configurable: true}); + span.querySelector('slot')!.dispatchEvent(new Event('slotchange')); + return el.updateComplete; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-truncate', () => { + it('renders its content in a truncating container', async () => { + const el = await fixture('Hello world'); + const truncate = el.renderRoot.querySelector('.truncate'); + expect(truncate).not.toBeNull(); + expect(truncate!.querySelector('slot')).not.toBeNull(); + }); + + it('does not render a tooltip when the content fits', async () => { + const el = await fixture('Short'); + await simulateOverflow(el, false); + expect(el.renderRoot.querySelector('craft-tooltip')).toBeNull(); + }); + + it('renders a tooltip with the full text when the content overflows', async () => { + const el = await fixture('A very long label that will not fit'); + await simulateOverflow(el, true); + + const tooltip = el.renderRoot.querySelector('craft-tooltip'); + expect(tooltip).not.toBeNull(); + expect(tooltip!.getAttribute('for')).toBe('content'); + expect(tooltip!.textContent).toContain( + 'A very long label that will not fit' + ); + }); + + it('does not render a tooltip when disabled, even on overflow', async () => { + const el = await fixture('A very long label that will not fit'); + el.disabled = true; + await simulateOverflow(el, true); + expect(el.renderRoot.querySelector('craft-tooltip')).toBeNull(); + }); +}); diff --git a/packages/craftcms-ui/src/components/truncate/truncate.ts b/packages/craftcms-ui/src/components/truncate/truncate.ts new file mode 100644 index 00000000000..7b8b3d998b1 --- /dev/null +++ b/packages/craftcms-ui/src/components/truncate/truncate.ts @@ -0,0 +1,93 @@ +import {html, LitElement} from 'lit'; +import {property, query, state} from 'lit/decorators.js'; + +import '../tooltip/tooltip'; +import styles from './truncate.styles.js'; + +/** + * craft-truncate renders its content on a single line, clipping it with an + * ellipsis when it doesn't fit, and revealing the full text in a tooltip on + * hover/focus. When the content fits, no tooltip is rendered. + * + * The element truncates against its own width, so give it a bounded width via + * the surrounding layout (e.g. a fixed width, or `flex: 1; min-width: 0` in a + * flex row). + * + * @example A label that might be too long to fit + * + * @slot - The content to truncate. Its text is used for the tooltip. + */ +export default class CraftTruncate extends LitElement { + static override styles = styles; + + /** Popper placement for the overflow tooltip, e.g. `top`, `right-start`. */ + @property({reflect: true}) placement = 'top'; + + /** Disables the overflow tooltip. The content still truncates visually. */ + @property({type: Boolean, reflect: true}) disabled = false; + + /** Whether the content currently overflows its container. */ + @state() private overflowing = false; + + /** The full, untruncated text shown in the tooltip. */ + @state() private text = ''; + + @query('.truncate') private content?: HTMLElement; + + #resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => this.#measure()) + : null; + + override connectedCallback() { + super.connectedCallback(); + // Observing fires an initial callback once the element has been laid out, + // which seeds the first measurement. + this.#resizeObserver?.observe(this); + } + + override disconnectedCallback() { + this.#resizeObserver?.disconnect(); + super.disconnectedCallback(); + } + + protected override firstUpdated() { + this.#measure(); + } + + #measure() { + this.text = (this.textContent ?? '').replace(/\s+/g, ' ').trim(); + + const el = this.content; + this.overflowing = !!el && el.scrollWidth > el.clientWidth; + } + + #onSlotChange() { + this.#measure(); + } + + override render() { + const showTooltip = this.overflowing && !this.disabled; + + return html` + + + + ${showTooltip + ? html`${this.text}` + : ''} + `; + } +} + +if (!customElements.get('craft-truncate')) { + customElements.define('craft-truncate', CraftTruncate); +} + +declare global { + interface HTMLElementTagNameMap { + 'craft-truncate': CraftTruncate; + } +} diff --git a/packages/craftcms-ui/src/index.ts b/packages/craftcms-ui/src/index.ts index dd43dfd4354..f62317d11fd 100644 --- a/packages/craftcms-ui/src/index.ts +++ b/packages/craftcms-ui/src/index.ts @@ -66,6 +66,7 @@ export {default as CraftTabs} from './components/tabs/tabs.js'; export {default as CraftTab} from './components/tab/tab.js'; export {default as CraftTextarea} from './components/textarea/textarea.js'; export {default as CraftTooltip} from './components/tooltip/tooltip.js'; +export {default as CraftTruncate} from './components/truncate/truncate.js'; export {default as CraftVisuallyHidden} from './components/visually-hidden/visually-hidden.js'; export {default as CraftThumbnail} from './components/thumbnail/thumbnail.js'; export {default as CraftThumbnailLoader} from './components/thumbnail-loader/thumbnail-loader.js'; diff --git a/packages/craftcms-ui/src/utilities/api/actionClient.ts b/packages/craftcms-ui/src/utilities/api/actionClient.ts index e63a267c062..e01775d60ec 100644 --- a/packages/craftcms-ui/src/utilities/api/actionClient.ts +++ b/packages/craftcms-ui/src/utilities/api/actionClient.ts @@ -40,7 +40,8 @@ actionClient.interceptors.request.use(async (config) => { // trigger + action trigger (e.g. `/admin/actions/fields/render-settings`), // so the base only needs the scheme + host (+ port). `URL.origin` supplies // all three without the `protocol` trailing-colon / port-doubling pitfalls. - config.baseURL = new URL(getActionUrl()).origin; + const {origin, pathname} = new URL(getActionUrl()); + config.baseURL = `${origin}/${pathname}`; // Set X-Requested-With header config.headers.set('X-Requested-With', 'XMLHttpRequest'); diff --git a/resources/css/cp.css b/resources/css/cp.css index 11002ca9e64..a065fb7c494 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -159,3 +159,17 @@ Form controls min-height: 0; overflow: visible; } + +/** +Drag-and-drop move targets + +Shared highlight for any element the asset-move drag marks as the active drop +target — folder rows, sidebar sources, and breadcrumb crumbs alike. Garnish adds +`.active-drop-target` while the cursor is over a valid target. + */ +[data-folder-drop-target].active-drop-target { + outline: 2px solid var(--c-color-accent-border-loud); + outline-offset: -2px; + border-radius: var(--c-radius-sm); + background-color: var(--c-color-accent-fill-quiet); +} diff --git a/resources/js/common/components/Breadcrumbs.vue b/resources/js/common/components/Breadcrumbs.vue index abdc701454b..654c66c77f0 100644 --- a/resources/js/common/components/Breadcrumbs.vue +++ b/resources/js/common/components/Breadcrumbs.vue @@ -1,12 +1,16 @@ - + // Opt-in SPA navigation: when a parent listens for `navigate`, intercept + // plain left-clicks and hand the URL up (e.g. to preserve the current view + // state) instead of letting CpLink do a full Inertia visit. Without a + // listener, breadcrumbs behave as ordinary CpLinks. + const instance = getCurrentInstance(); + const interceptNavigation = computed( + () => !!instance?.vnode.props?.onNavigate + ); - + diff --git a/resources/js/modules/elements/ElementSources.vue b/resources/js/modules/elements/ElementSources.vue index 508d985a5ff..157aa98d43f 100644 --- a/resources/js/modules/elements/ElementSources.vue +++ b/resources/js/modules/elements/ElementSources.vue @@ -94,6 +94,26 @@ }, }); } + + // Folder/volume sources double as drag-and-drop move targets: their `data` + // carries the backend `folder-id` / `can-move-to` flags. Inert unless an + // asset-move drag is set up on the page. + function sourceMoveAttrs(source: Source): Record { + const data = 'data' in source ? source.data : undefined; + const folderId = data?.['folder-id']; + if (folderId == null || folderId === false) { + return {}; + } + + const attrs: Record = { + 'data-folder-drop-target': '', + 'data-folder-id': String(folderId), + }; + if (data?.['can-move-to']) { + attrs['data-can-move-to'] = ''; + } + return attrs; + }