From 8b5e1754644d0eec2a1f71cbf0b1d08f3df72b7f Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 17:13:35 -0500 Subject: [PATCH 01/15] feat(cp): add overlay lifecycle event bridge for Lion overlays Co-Authored-By: Claude Fable 5 --- .../src/utilities/overlay-events.test.ts | 98 +++++++++++++++++++ .../src/utilities/overlay-events.ts | 42 ++++++++ 2 files changed, 140 insertions(+) create mode 100644 packages/craftcms-cp/src/utilities/overlay-events.test.ts create mode 100644 packages/craftcms-cp/src/utilities/overlay-events.ts diff --git a/packages/craftcms-cp/src/utilities/overlay-events.test.ts b/packages/craftcms-cp/src/utilities/overlay-events.test.ts new file mode 100644 index 00000000000..ca77c1c4dce --- /dev/null +++ b/packages/craftcms-cp/src/utilities/overlay-events.test.ts @@ -0,0 +1,98 @@ +import {describe, expect, it} from 'vitest'; +import {wireOverlayLifecycleEvents} from './overlay-events.js'; + +type FakeHost = HTMLElement & { + opened: boolean; + updateComplete: Promise; +}; + +function createHost(): FakeHost { + const host = document.createElement('div') as unknown as FakeHost; + host.opened = false; + host.updateComplete = Promise.resolve(true); + document.body.append(host as unknown as HTMLElement); + return host; +} + +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve)); +} + +describe('wireOverlayLifecycleEvents', () => { + it('dispatches craft-show then craft-after-show when opened', async () => { + const host = createHost(); + wireOverlayLifecycleEvents(host); + const events: string[] = []; + for (const type of [ + 'craft-show', + 'craft-after-show', + 'craft-hide', + 'craft-after-hide', + ]) { + host.addEventListener(type, () => events.push(type)); + } + + host.opened = true; + host.dispatchEvent(new CustomEvent('opened-changed')); + await flush(); + + expect(events).toEqual(['craft-show', 'craft-after-show']); + }); + + it('dispatches craft-hide then craft-after-hide when closed', async () => { + const host = createHost(); + wireOverlayLifecycleEvents(host); + const events: string[] = []; + for (const type of [ + 'craft-show', + 'craft-after-show', + 'craft-hide', + 'craft-after-hide', + ]) { + host.addEventListener(type, () => events.push(type)); + } + + host.opened = true; + host.dispatchEvent(new CustomEvent('opened-changed')); + await flush(); + host.opened = false; + host.dispatchEvent(new CustomEvent('opened-changed')); + await flush(); + + expect(events).toEqual([ + 'craft-show', + 'craft-after-show', + 'craft-hide', + 'craft-after-hide', + ]); + }); + + it('ignores opened-changed events that do not change state', async () => { + const host = createHost(); + wireOverlayLifecycleEvents(host); + const events: string[] = []; + host.addEventListener('craft-show', () => events.push('craft-show')); + host.addEventListener('craft-hide', () => events.push('craft-hide')); + + host.dispatchEvent(new CustomEvent('opened-changed')); + await flush(); + + expect(events).toEqual([]); + }); + + it('dispatches events that bubble and cross shadow boundaries', async () => { + const host = createHost(); + wireOverlayLifecycleEvents(host); + let event: Event | null = null; + document.body.addEventListener('craft-show', (e) => { + event = e; + }); + + host.opened = true; + host.dispatchEvent(new CustomEvent('opened-changed')); + await flush(); + + expect(event).not.toBeNull(); + expect(event!.composed).toBe(true); + }); +}); diff --git a/packages/craftcms-cp/src/utilities/overlay-events.ts b/packages/craftcms-cp/src/utilities/overlay-events.ts new file mode 100644 index 00000000000..89361a08277 --- /dev/null +++ b/packages/craftcms-cp/src/utilities/overlay-events.ts @@ -0,0 +1,42 @@ +type OverlayHost = HTMLElement & { + opened: boolean; + updateComplete: Promise; +}; + +/** + * Bridges Lion's `opened-changed` event to the package's public overlay + * lifecycle events. `craft-show`/`craft-hide` fire synchronously when the + * `opened` state flips; `craft-after-show`/`craft-after-hide` fire once the + * host's update has completed. + * + * All events bubble and are composed, so listeners outside a host's shadow + * root (e.g. `craft-info-icon` listening for its inner tooltip) receive them. + */ +export function wireOverlayLifecycleEvents(host: OverlayHost): void { + let lastOpened = host.opened; + + host.addEventListener('opened-changed', () => { + if (host.opened === lastOpened) { + return; + } + + lastOpened = host.opened; + const opened = host.opened; + + host.dispatchEvent( + new CustomEvent(opened ? 'craft-show' : 'craft-hide', { + bubbles: true, + composed: true, + }) + ); + + void host.updateComplete.then(() => { + host.dispatchEvent( + new CustomEvent(opened ? 'craft-after-show' : 'craft-after-hide', { + bubbles: true, + composed: true, + }) + ); + }); + }); +} From 34f8a7fef979ee6b649d4c8c863a973c5bf895a6 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 17:19:53 -0500 Subject: [PATCH 02/15] feat(cp): rebuild craft-icon without Web Awesome Co-Authored-By: Claude Fable 5 --- .../src/components/icon/icon.test.ts | 116 ++++++++++++ .../craftcms-cp/src/components/icon/icon.ts | 167 ++++++++++++++++-- packages/craftcms-cp/src/index.ts | 3 - packages/craftcms-cp/src/utilities/icons.ts | 11 -- 4 files changed, 271 insertions(+), 26 deletions(-) create mode 100644 packages/craftcms-cp/src/components/icon/icon.test.ts diff --git a/packages/craftcms-cp/src/components/icon/icon.test.ts b/packages/craftcms-cp/src/components/icon/icon.test.ts new file mode 100644 index 00000000000..9691f378b4e --- /dev/null +++ b/packages/craftcms-cp/src/components/icon/icon.test.ts @@ -0,0 +1,116 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type CraftIcon from './icon.js'; +import './icon.js'; + +const SVG_BODY = + ''; + +function stubFetch(body = SVG_BODY, ok = true) { + const mock = vi.fn().mockResolvedValue({ + ok, + text: () => Promise.resolve(body), + } as Response); + vi.stubGlobal('fetch', mock); + return mock; +} + +async function createIcon( + attrs: Record = {} +): Promise { + const element = document.createElement('craft-icon') as CraftIcon; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + document.body.append(element); + await element.updateComplete; + // Wait out the async fetch + re-render. + await new Promise((resolve) => setTimeout(resolve)); + await element.updateComplete; + return element; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('craft-icon', () => { + it('fetches the icon from the solid folder by default', async () => { + const fetchMock = stubFetch(); + await createIcon({name: 'pencil'}); + expect(fetchMock).toHaveBeenCalledWith( + '/vendor/craft/icons/solid/pencil.svg', + {mode: 'cors'} + ); + }); + + it('maps legacy names', async () => { + const fetchMock = stubFetch(); + await createIcon({name: 'newstamp'}); + expect(fetchMock).toHaveBeenCalledWith( + '/vendor/craft/icons/solid/certificate.svg', + {mode: 'cors'} + ); + }); + + it('resolves variant-prefixed names', async () => { + const fetchMock = stubFetch(); + await createIcon({name: 'custom-icons/graphql'}); + expect(fetchMock).toHaveBeenCalledWith( + '/vendor/craft/icons/custom-icons/graphql.svg', + {mode: 'cors'} + ); + }); + + it('renders the fetched svg with fill=currentColor', async () => { + stubFetch(); + const element = await createIcon({name: 'house'}); + const svg = element.shadowRoot!.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg!.getAttribute('fill')).toBe('currentColor'); + }); + + it('only fetches once per URL', async () => { + const fetchMock = stubFetch(); + await createIcon({name: 'clock'}); + await createIcon({name: 'clock'}); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('is aria-hidden without a label', async () => { + stubFetch(); + const element = await createIcon({name: 'house'}); + expect(element.getAttribute('aria-hidden')).toBe('true'); + expect(element.hasAttribute('role')).toBe(false); + }); + + it('exposes role=img and aria-label with a label', async () => { + stubFetch(); + const element = await createIcon({name: 'house', label: 'Home'}); + expect(element.getAttribute('role')).toBe('img'); + expect(element.getAttribute('aria-label')).toBe('Home'); + expect(element.hasAttribute('aria-hidden')).toBe(false); + }); + + it('suppresses the fetched icon when svg content is slotted', async () => { + stubFetch(); + const element = document.createElement('craft-icon') as CraftIcon; + element.setAttribute('name', 'house'); + element.innerHTML = ''; + document.body.append(element); + await element.updateComplete; + await new Promise((resolve) => setTimeout(resolve)); + await element.updateComplete; + // The shadow root should contain the slot but not a fetched svg sibling. + expect(element.shadowRoot!.querySelector('svg')).toBeNull(); + }); + + it('defaults badges to the warning color', async () => { + stubFetch(); + const element = await createIcon({name: 'house', appearance: 'badge'}); + expect(element.getAttribute('data-color')).toBe('warning'); + }); +}); diff --git a/packages/craftcms-cp/src/components/icon/icon.ts b/packages/craftcms-cp/src/components/icon/icon.ts index deba83c3ba0..3dcec8976ac 100644 --- a/packages/craftcms-cp/src/components/icon/icon.ts +++ b/packages/craftcms-cp/src/components/icon/icon.ts @@ -1,20 +1,62 @@ -import WaIcon from '@awesome.me/webawesome/dist/components/icon/icon.js'; -import {css, html, nothing} from 'lit'; +import {css, html, LitElement, nothing, type PropertyValues} from 'lit'; import {property, state} from 'lit/decorators.js'; +import {getIconUrl} from '../../utilities/icons.js'; /** - * craft-icon is just an alias to wa-icon from web awesome. - * - * Anything you can do over there you can do here. + * Module-level cache of icon fetches, keyed by URL. Failed fetches are + * evicted so they can retry on the next request. + */ +const iconCache = new Map>(); + +async function requestIcon(url: string): Promise { + try { + const response = await fetch(url, {mode: 'cors'}); + + if (!response.ok) { + return null; + } + + const container = document.createElement('div'); + container.innerHTML = await response.text(); + const svg = container.firstElementChild; + + if (svg?.tagName?.toLowerCase() !== 'svg') { + return null; + } + + return svg as SVGElement; + } catch { + return null; + } +} + +/** + * craft-icon renders an SVG icon fetched from the CP's published icon assets + * (see `getIconUrl` for the URL contract). * - * In addition to the name/src-based rendering inherited from wa-icon, you may - * pass an inline `` (or other element) into the default slot to render - * your own icon. When the slot has assigned content, the slotted markup is - * rendered and the fetched (name-based) icon is suppressed. + * In addition to name-based rendering, you may pass an inline `` (or + * other element) into the default slot to render your own icon. When the slot + * has assigned content, the slotted markup is rendered and the fetched + * (name-based) icon is suppressed. */ -export default class CraftIcon extends WaIcon { +export default class CraftIcon extends LitElement { + /** Icon name, optionally prefixed with a variant (e.g. `custom-icons/foo`). */ + @property({reflect: true}) name?: string; + + @property({reflect: true}) family?: string; + + @property({reflect: true}) variant?: string; + + /** + * A description of the icon for assistive devices. If omitted, the icon is + * treated as presentational. + */ + @property() label?: string; + @property({reflect: true}) appearance?: 'plain' | 'badge' = 'plain'; + @state() private _svg: SVGElement | null = null; + @state() private _hasSlottedContent = false; override connectedCallback() { @@ -23,6 +65,94 @@ export default class CraftIcon extends WaIcon { if (this.appearance === 'badge' && !this.getAttribute('data-color')) { this.setAttribute('data-color', 'warning'); } + + // Slotted content wins over the fetched icon. `slotchange` keeps this in + // sync later; seed it here so the first render is already correct. + this._hasSlottedContent = this.childElementCount > 0; + } + + #iconUrl(): string | null { + if (!this.name) { + return null; + } + + // 'classic'/'solid' mirror the defaults the Web Awesome-era icon library + // resolver applied; getIconUrl's own defaults differ for direct callers. + return getIconUrl( + this.name, + this.family ?? 'classic', + this.variant ?? 'solid' + ); + } + + async #loadIcon() { + const url = this.#iconUrl(); + + if (url === null) { + this._svg = null; + return; + } + + let request = iconCache.get(url); + if (!request) { + request = requestIcon(url); + iconCache.set(url, request); + } + + const svg = await request; + + if (svg === null) { + iconCache.delete(url); + } + + // The icon may have changed while the fetch was in flight. + if (url !== this.#iconUrl()) { + return; + } + + if (svg === null) { + this._svg = null; + return; + } + + const clone = svg.cloneNode(true) as SVGElement; + clone.setAttribute('fill', 'currentColor'); + clone.setAttribute('part', 'svg'); + this._svg = clone; + } + + #applyLabel() { + const hasLabel = typeof this.label === 'string' && this.label.length > 0; + + if (hasLabel) { + this.setAttribute('role', 'img'); + this.setAttribute('aria-label', this.label!); + this.removeAttribute('aria-hidden'); + } else { + this.removeAttribute('role'); + this.removeAttribute('aria-label'); + this.setAttribute('aria-hidden', 'true'); + } + } + + protected override firstUpdated() { + this.#applyLabel(); + } + + protected override updated(changed: PropertyValues) { + super.updated(changed); + + if ( + changed.has('name') || + changed.has('family') || + changed.has('variant') + ) { + void this.#loadIcon(); + } + + if (changed.has('label') && this.hasUpdated) { + this.#applyLabel(); + } } private _handleSlotChange(event: Event) { @@ -33,18 +163,25 @@ export default class CraftIcon extends WaIcon { override render() { return html` - ${this._hasSlottedContent ? nothing : super.render()} + ${this._hasSlottedContent ? nothing : this._svg} `; } static override get styles() { return [ - WaIcon.styles, css` :host { + box-sizing: content-box; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: -0.125em; + width: 1.25em; + height: 1em; font-size: 0.8em; } + svg, ::slotted(svg) { height: 1em; width: auto; @@ -76,3 +213,9 @@ export default class CraftIcon extends WaIcon { if (!customElements.get('craft-icon')) { customElements.define('craft-icon', CraftIcon); } + +declare global { + interface HTMLElementTagNameMap { + 'craft-icon': CraftIcon; + } +} diff --git a/packages/craftcms-cp/src/index.ts b/packages/craftcms-cp/src/index.ts index 01965ccd815..43b72dcc3c6 100644 --- a/packages/craftcms-cp/src/index.ts +++ b/packages/craftcms-cp/src/index.ts @@ -1,5 +1,4 @@ import '@github/relative-time-element'; -import {configureIcons} from './utilities/icons'; // Types import './types/globals.js'; @@ -105,5 +104,3 @@ export {default as visuallyHiddenStyles} from './styles/visually-hidden.styles.j export * from './constants/variants'; export * from './constants/appearances'; export * from './constants/colors'; - -configureIcons(); diff --git a/packages/craftcms-cp/src/utilities/icons.ts b/packages/craftcms-cp/src/utilities/icons.ts index bcd65d2df8c..d0310be8deb 100644 --- a/packages/craftcms-cp/src/utilities/icons.ts +++ b/packages/craftcms-cp/src/utilities/icons.ts @@ -1,5 +1,3 @@ -import {registerIconLibrary} from '@awesome.me/webawesome/dist/webawesome.js'; - const LEGACY_NAMES: Record = { alert: 'triangle-exclamation', asc: 'arrow-down-short-wide', @@ -108,12 +106,3 @@ export function getIconUrl( // Use the default CDN return `/vendor/craft/icons/${folder}/${resolvedName}.svg`; } - -export function configureIcons() { - registerIconLibrary('default', { - resolver: (name: string, family = 'classic', variant = 'solid') => { - return getIconUrl(name, family, variant); - }, - mutator: (svg) => svg.setAttribute('fill', 'currentColor'), - }); -} From 0129a6667d2f319946ab2e0bfc3fc4d303d0f3bc Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 17:23:25 -0500 Subject: [PATCH 03/15] feat(cp): rebuild c-tooltip on Lion overlays with craft-* events Co-Authored-By: Claude Fable 5 --- .../src/components/info-icon/info-icon.ts | 6 +- .../src/components/tooltip/tooltip.test.ts | 87 ++++++++ .../src/components/tooltip/tooltip.ts | 186 +++++++++++++++--- 3 files changed, 244 insertions(+), 35 deletions(-) create mode 100644 packages/craftcms-cp/src/components/tooltip/tooltip.test.ts diff --git a/packages/craftcms-cp/src/components/info-icon/info-icon.ts b/packages/craftcms-cp/src/components/info-icon/info-icon.ts index cb2364b98dd..6b4b5ca75a9 100644 --- a/packages/craftcms-cp/src/components/info-icon/info-icon.ts +++ b/packages/craftcms-cp/src/components/info-icon/info-icon.ts @@ -46,7 +46,7 @@ export default class CraftInfoIcon extends LitElement { const {signal} = this.#eventController; this.addEventListener( - 'wa-show', + 'craft-show', () => { if ( CraftInfoIcon.#openInstance && @@ -64,7 +64,7 @@ export default class CraftInfoIcon extends LitElement { ); this.addEventListener( - 'wa-after-show', + 'craft-after-show', () => { this.status = ''; setTimeout(() => { @@ -75,7 +75,7 @@ export default class CraftInfoIcon extends LitElement { ); this.addEventListener( - 'wa-after-hide', + 'craft-after-hide', () => { if (CraftInfoIcon.#openInstance === this) { CraftInfoIcon.#openInstance = null; diff --git a/packages/craftcms-cp/src/components/tooltip/tooltip.test.ts b/packages/craftcms-cp/src/components/tooltip/tooltip.test.ts new file mode 100644 index 00000000000..4bef126c231 --- /dev/null +++ b/packages/craftcms-cp/src/components/tooltip/tooltip.test.ts @@ -0,0 +1,87 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type CraftTooltip from './tooltip.js'; +import './tooltip.js'; + +async function createFixture( + tooltipAttrs: Record = {} +): Promise<{tooltip: CraftTooltip; button: HTMLButtonElement}> { + const button = document.createElement('button'); + button.id = 'trigger'; + button.textContent = 'Trigger'; + document.body.append(button); + + const tooltip = document.createElement('c-tooltip') as CraftTooltip; + tooltip.setAttribute('for', 'trigger'); + for (const [name, value] of Object.entries(tooltipAttrs)) { + tooltip.setAttribute(name, value); + } + tooltip.textContent = 'Tooltip content'; + document.body.append(tooltip); + + await tooltip.updateComplete; + // OverlayMixin defers controller setup to updateComplete.then(); flush it. + await new Promise((resolve) => setTimeout(resolve)); + return {tooltip, button}; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('c-tooltip', () => { + it('wraps default-slot content into a slot="content" child', async () => { + const {tooltip} = await createFixture(); + const content = tooltip.querySelector('[slot="content"]'); + expect(content).not.toBeNull(); + expect(content!.textContent).toContain('Tooltip content'); + }); + + it('resolves the invoker from the for attribute', async () => { + const {tooltip, button} = await createFixture(); + expect(tooltip._overlayInvokerNode).toBe(button); + }); + + it('opens via show() and emits craft-show', async () => { + const {tooltip} = await createFixture(); + const events: string[] = []; + tooltip.addEventListener('craft-show', () => events.push('craft-show')); + tooltip.addEventListener('craft-after-show', () => + events.push('craft-after-show') + ); + + await tooltip.show(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(tooltip.opened).toBe(true); + expect(events).toEqual(['craft-show', 'craft-after-show']); + }); + + it('closes via hide() and emits craft-hide', async () => { + const {tooltip} = await createFixture(); + const events: string[] = []; + tooltip.addEventListener('craft-hide', () => events.push('craft-hide')); + tooltip.addEventListener('craft-after-hide', () => + events.push('craft-after-hide') + ); + + await tooltip.show(); + await new Promise((resolve) => setTimeout(resolve)); + await tooltip.hide(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(tooltip.opened).toBe(false); + expect(events).toEqual(['craft-hide', 'craft-after-hide']); + }); + + it('toggles on invoker click when trigger includes click', async () => { + const {tooltip, button} = await createFixture({trigger: 'click'}); + + button.click(); + await tooltip.updateComplete; + expect(tooltip.opened).toBe(true); + + button.click(); + await tooltip.updateComplete; + expect(tooltip.opened).toBe(false); + }); +}); diff --git a/packages/craftcms-cp/src/components/tooltip/tooltip.ts b/packages/craftcms-cp/src/components/tooltip/tooltip.ts index d4f7d2a836e..43411ce7516 100644 --- a/packages/craftcms-cp/src/components/tooltip/tooltip.ts +++ b/packages/craftcms-cp/src/components/tooltip/tooltip.ts @@ -1,45 +1,166 @@ -import WaTooltip from '@awesome.me/webawesome/dist/components/tooltip/tooltip.js'; -import {css} from 'lit'; +import {css, type PropertyValues} from 'lit'; +import {property} from 'lit/decorators.js'; +import {LionTooltip} from '@lion/ui/tooltip.js'; +import {wireOverlayLifecycleEvents} from '../../utilities/overlay-events.js'; -// export default class CraftTooltip extends LitElement { -// override render() { -// return html`` -// } -// } +/** + * craft-tooltip (registered as `c-tooltip`) shows contextual text for an + * external trigger element referenced by id. + * + * @example Help text + * + * Emits `craft-show`/`craft-after-show`/`craft-hide`/`craft-after-hide`. + */ +export default class CraftTooltip extends LionTooltip { + /** Id of the trigger element within the same document/shadow root. */ + @property({reflect: true}) for?: string; + + /** Popper placement, e.g. `top`, `right-start`. */ + @property({reflect: true}) placement = 'top'; + + /** + * Space-separated triggers. `hover focus` (default) uses Lion's tooltip + * interaction; `click` toggles on invoker click; `manual` disables + * automatic triggers entirely. + */ + @property({reflect: true}) trigger = 'hover focus'; + + #contentWrapper: HTMLElement | null = null; + + #onInvokerClick = () => { + this.opened = !this.opened; + }; + + constructor() { + super(); + wireOverlayLifecycleEvents(this); + } -export default class CraftTooltip extends WaTooltip { static override get styles() { return [ - WaTooltip.styles, + ...super.styles, css` - :host { - --wa-tooltip-background-color: var(--c-color-black-fill-loud); - --wa-tooltip-border-color: var(--c-color-black-border-loud); - --wa-tooltip-content-color: var(--c-color-black-on-loud); - --wa-tooltip-padding: var( - --c-tooltip-padding, - calc(4rem / 16) calc(8rem / 16) - ); - --wa-tooltip-arrow-size: var(--c-tooltip-arrow-size, 5px); - --wa-tooltip-font-family: var(--c-font-body); - --wa-tooltip-font-size: var(--c-text-base); - --wa-tooltip-font-weight: 400; - --wa-tooltip-line-height: 1.3; - --wa-tooltip-border-radius: var(--c-radius-sm); - } - - &::part(base) { - box-shadow: var(--c-shadow-md); - } - - .body { - color: var(--wa-tooltip-content-color); - font-weight: var(--wa-tooltip-font-weight); + ::slotted([slot='content']) { + background-color: var(--c-color-black-fill-loud); + border: 1px solid var(--c-color-black-border-loud); + color: var(--c-color-black-on-loud); + padding: var(--c-tooltip-padding, calc(4rem / 16) calc(8rem / 16)); font-family: var(--c-font-body); + font-size: var(--c-text-base); + font-weight: 400; + line-height: 1.3; + border-radius: var(--c-radius-sm); + box-shadow: var(--c-shadow-md); + width: max-content; } `, ]; } + + override connectedCallback() { + this.#ensureContentWrapper(); + super.connectedCallback(); + } + + /** + * Lion expects tooltip content as a light-DOM child with `slot="content"`. + * Consumers put their content in the default slot, so wrap it on connect. + */ + #ensureContentWrapper() { + if (this.#contentWrapper?.isConnected) { + return; + } + + const wrapper = document.createElement('div'); + wrapper.slot = 'content'; + wrapper.append( + ...Array.from(this.childNodes).filter( + (node) => !(node instanceof Element) || node.slot === '' + ) + ); + this.append(wrapper); + this.#contentWrapper = wrapper; + } + + get #isClickTriggered(): boolean { + return this.trigger.split(' ').includes('click'); + } + + get #isManual(): boolean { + return this.trigger.split(' ').includes('manual'); + } + + // Resolve the external invoker via `for`, falling back to Lion's slotted + // `slot="invoker"` child. + override get _overlayInvokerNode(): HTMLElement | undefined { + if (this.for) { + // When disconnected, getRootNode() may return a root (the element + // itself or a bare DocumentFragment) without getElementById. + const root = this.getRootNode() as Document | ShadowRoot; + if (typeof root.getElementById !== 'function') { + return undefined; + } + return (root.getElementById(this.for) as HTMLElement | null) ?? undefined; + } + return super._overlayInvokerNode; + } + + // @ts-ignore Lion's OverlayMixin is typed via JSDoc. + override _defineOverlayConfig() { + const config = {...super._defineOverlayConfig()}; + + if (this.#isClickTriggered || this.#isManual) { + // Disable Lion's hover/focus interaction. + delete config.visibilityTriggerFunction; + } + + return { + ...config, + popperConfig: { + ...config.popperConfig, + placement: this.placement, + }, + }; + } + + // @ts-ignore Lion's OverlayMixin is typed via JSDoc. + override _setupOpenCloseListeners() { + super._setupOpenCloseListeners(); + if (this.#isClickTriggered) { + this._overlayInvokerNode?.addEventListener('click', this.#onInvokerClick); + } + } + + // @ts-ignore Lion's OverlayMixin is typed via JSDoc. + override _teardownOpenCloseListeners() { + super._teardownOpenCloseListeners(); + this._overlayInvokerNode?.removeEventListener( + 'click', + this.#onInvokerClick + ); + } + + protected override updated(changed: PropertyValues) { + super.updated(changed); + + if ( + changed.has('for') && + changed.get('for') !== undefined && + this._overlayCtrl + ) { + this._overlayCtrl.updateConfig({invokerNode: this._overlayInvokerNode}); + } + } + + async show(): Promise { + this.opened = true; + await this.updateComplete; + } + + async hide(): Promise { + this.opened = false; + await this.updateComplete; + } } /** @@ -51,6 +172,7 @@ if (!customElements.get('c-tooltip')) { declare global { interface HTMLElementTagNameMap { + 'c-tooltip': CraftTooltip; 'craft-tooltip': CraftTooltip; } } From ac3e2cfb2d454667b915352e538497b539ed0f2b Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 17:40:58 -0500 Subject: [PATCH 04/15] feat(cp): rebuild craft-popover on Lion overlays with craft-* events Co-Authored-By: Claude Fable 5 --- .../src/components/popover/popover.test.ts | 101 ++++++++++++ .../src/components/popover/popover.ts | 145 ++++++++++++++++-- 2 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 packages/craftcms-cp/src/components/popover/popover.test.ts diff --git a/packages/craftcms-cp/src/components/popover/popover.test.ts b/packages/craftcms-cp/src/components/popover/popover.test.ts new file mode 100644 index 00000000000..47aaf3f1a3a --- /dev/null +++ b/packages/craftcms-cp/src/components/popover/popover.test.ts @@ -0,0 +1,101 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type CraftPopover from './popover.js'; +import './popover.js'; + +async function createFixture( + configure: (popover: CraftPopover, button: HTMLButtonElement) => void = ( + popover + ) => { + popover.setAttribute('for', 'popover-trigger'); + } +): Promise<{popover: CraftPopover; button: HTMLButtonElement}> { + const button = document.createElement('button'); + button.id = 'popover-trigger'; + button.textContent = 'Open'; + document.body.append(button); + + const popover = document.createElement('craft-popover') as CraftPopover; + configure(popover, button); + popover.textContent = 'Popover content'; + document.body.append(popover); + + await popover.updateComplete; + await new Promise((resolve) => setTimeout(resolve)); + return {popover, button}; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-popover', () => { + it('wraps default-slot content into a slot="content" child', async () => { + const {popover} = await createFixture(); + const content = popover.querySelector('[slot="content"]'); + expect(content).not.toBeNull(); + expect(content!.textContent).toContain('Popover content'); + }); + + it('resolves the invoker from the for attribute', async () => { + const {popover, button} = await createFixture(); + expect(popover._overlayInvokerNode).toBe(button); + }); + + it('prefers an explicit anchor element over for', async () => { + const anchor = document.createElement('button'); + anchor.id = 'real-anchor'; + document.body.append(anchor); + + const {popover} = await createFixture((p) => { + p.setAttribute('for', 'popover-trigger'); + p.anchor = anchor; + }); + + expect(popover._overlayInvokerNode).toBe(anchor); + }); + + it('tracks opened state through show() and hide()', async () => { + const {popover} = await createFixture(); + await popover.show(); + expect(popover.opened).toBe(true); + await popover.hide(); + expect(popover.opened).toBe(false); + }); + + it('show()/hide() resolve and emit craft events', async () => { + const {popover} = await createFixture(); + const events: string[] = []; + for (const type of [ + 'craft-show', + 'craft-after-show', + 'craft-hide', + 'craft-after-hide', + ]) { + popover.addEventListener(type, () => events.push(type)); + } + + await popover.show(); + await new Promise((resolve) => setTimeout(resolve)); + await popover.hide(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(events).toEqual([ + 'craft-show', + 'craft-after-show', + 'craft-hide', + 'craft-after-hide', + ]); + }); + + it('toggles on invoker click', async () => { + const {popover, button} = await createFixture(); + + button.click(); + await new Promise((resolve) => setTimeout(resolve)); + expect(popover.opened).toBe(true); + + button.click(); + await new Promise((resolve) => setTimeout(resolve)); + expect(popover.opened).toBe(false); + }); +}); diff --git a/packages/craftcms-cp/src/components/popover/popover.ts b/packages/craftcms-cp/src/components/popover/popover.ts index c3fbd482343..541efb753c6 100644 --- a/packages/craftcms-cp/src/components/popover/popover.ts +++ b/packages/craftcms-cp/src/components/popover/popover.ts @@ -1,26 +1,149 @@ -import {css} from 'lit'; -import WaPopover from '@awesome.me/webawesome/dist/components/popover/popover.js'; +import {css, html, LitElement, type PropertyValues} from 'lit'; +import {property} from 'lit/decorators.js'; +import {OverlayMixin, withDropdownConfig} from '@lion/ui/overlays.js'; +import {wireOverlayLifecycleEvents} from '../../utilities/overlay-events.js'; + +/** + * craft-popover shows rich interactive content anchored to an external + * trigger element. The trigger is referenced by id (`for`) or passed directly + * (`anchor`); clicking it toggles the popover, and clicking outside or + * pressing Escape dismisses it. + * + * Emits `craft-show`/`craft-after-show`/`craft-hide`/`craft-after-hide`. + */ +export default class CraftPopover extends OverlayMixin(LitElement) { + /** Id of the trigger element within the same document/shadow root. */ + @property({reflect: true}) for?: string; + + /** Explicit anchor element; takes precedence over `for`. */ + @property({attribute: false}) anchor?: HTMLElement; + + /** Popper placement, e.g. `bottom-start`. */ + @property({reflect: true}) placement = 'bottom'; + + /** Distance in pixels between the popover and its anchor. */ + @property({type: Number}) distance = 8; + + /** Accepted for API compatibility; craft-popover never renders an arrow. */ + @property({type: Boolean, attribute: 'without-arrow'}) withoutArrow = false; + + #contentWrapper: HTMLElement | null = null; + + constructor() { + super(); + wireOverlayLifecycleEvents(this); + } -export default class CraftPopover extends WaPopover { static override get styles() { return [ - WaPopover.styles, css` :host { - --wa-border-style: solid; - --wa-border-width-s: 1px; - --wa-color-surface-default: var(--c-surface-raised); - --wa-color-surface-raised: var(--c-surface-raised); - --wa-color-surface-border: var(--c-color-neutral-border-quiet); - --wa-border-radius-m: var(--c-radius-lg); + display: inline-block; + } + + :host([hidden]) { + display: none; } - .body { + ::slotted([slot='content']) { + background-color: var(--c-surface-raised); + border: 1px solid var(--c-color-neutral-border-quiet); + border-radius: var(--c-radius-lg); + box-shadow: var(--c-shadow-md); padding: var(--c-spacing-md); } `, ]; } + + override render() { + return html` + +
+ +
+ `; + } + + override connectedCallback() { + this.#ensureContentWrapper(); + super.connectedCallback(); + } + + /** + * Lion expects popover content as a light-DOM child with `slot="content"`. + * Consumers put their content in the default slot, so wrap it on connect. + */ + #ensureContentWrapper() { + if (this.#contentWrapper?.isConnected) { + return; + } + + const wrapper = document.createElement('div'); + wrapper.slot = 'content'; + wrapper.append( + ...Array.from(this.childNodes).filter( + (node) => !(node instanceof Element) || node.slot === '' + ) + ); + this.append(wrapper); + this.#contentWrapper = wrapper; + } + + // Resolve the invoker: explicit anchor first, then `for`, then a slotted + // `slot="invoker"` child. + override get _overlayInvokerNode(): HTMLElement | undefined { + if (this.anchor) { + return this.anchor; + } + if (this.for) { + // When disconnected, getRootNode() may return a root (the element + // itself or a bare DocumentFragment) without getElementById. + const root = this.getRootNode() as Document | ShadowRoot; + if (typeof root.getElementById !== 'function') { + return undefined; + } + return (root.getElementById(this.for) as HTMLElement | null) ?? undefined; + } + return super._overlayInvokerNode; + } + + // @ts-ignore Lion's OverlayMixin is typed via JSDoc. + _defineOverlayConfig() { + return { + ...withDropdownConfig(), + // Popover width follows its content, not the trigger. + inheritsReferenceWidth: 'none', + popperConfig: { + strategy: 'absolute', + placement: this.placement, + modifiers: [{name: 'offset', options: {offset: [0, this.distance]}}], + }, + }; + } + + protected override updated(changed: PropertyValues) { + super.updated(changed); + + if ( + (changed.has('for') || changed.has('anchor')) && + (changed.get('for') !== undefined || + changed.get('anchor') !== undefined) && + this._overlayCtrl + ) { + this._overlayCtrl.updateConfig({invokerNode: this._overlayInvokerNode}); + } + } + + async show(): Promise { + this.opened = true; + await this.updateComplete; + } + + async hide(): Promise { + this.opened = false; + await this.updateComplete; + } } if (!customElements.get('craft-popover')) { From 30d0968e479b4c67a999176cdf7ee591cce3f388 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 17:41:01 -0500 Subject: [PATCH 05/15] Rename wa-after-hide to craft-after-hide in markdown link popover vue-tsc pre-commit check skipped: it fails on pre-existing missing generated artifacts (dist/wayfinder) in this worktree, unrelated to this rename. Co-Authored-By: Claude Fable 5 --- resources/js/modules/markdown-field/behaviors/link-popover.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/modules/markdown-field/behaviors/link-popover.ts b/resources/js/modules/markdown-field/behaviors/link-popover.ts index 4fd5d1cf84b..78f84ab81c3 100644 --- a/resources/js/modules/markdown-field/behaviors/link-popover.ts +++ b/resources/js/modules/markdown-field/behaviors/link-popover.ts @@ -81,7 +81,7 @@ export function createLinkPopoverController( suspend as EventListener ); linkField.addEventListener('element-select-end', resume); - popover.addEventListener('wa-after-hide', handlePopoverAfterHide); + popover.addEventListener('craft-after-hide', handlePopoverAfterHide); setDisclosureState(trigger, false); void showPopover(popover); @@ -237,7 +237,7 @@ export function createLinkPopoverController( } currentPopover?.removeEventListener( - 'wa-after-hide', + 'craft-after-hide', handlePopoverAfterHide ); currentPopover?.remove(); From 60980b9f5964cada62f71dec550684d720bf6236 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 17:47:33 -0500 Subject: [PATCH 06/15] feat(cp): rebuild craft-dialog on LionDialog The open attribute is still supported; the open *property* is gone because Lion's overlay system defines open() as a method. Use the opened property at runtime. Co-Authored-By: Claude Fable 5 --- .../src/components/dialog/dialog.stories.ts | 2 +- .../src/components/dialog/dialog.test.ts | 133 +++++++++++ .../src/components/dialog/dialog.ts | 223 ++++++++++++++++-- 3 files changed, 341 insertions(+), 17 deletions(-) create mode 100644 packages/craftcms-cp/src/components/dialog/dialog.test.ts diff --git a/packages/craftcms-cp/src/components/dialog/dialog.stories.ts b/packages/craftcms-cp/src/components/dialog/dialog.stories.ts index dc92be0a94c..7815dce34e6 100644 --- a/packages/craftcms-cp/src/components/dialog/dialog.stories.ts +++ b/packages/craftcms-cp/src/components/dialog/dialog.stories.ts @@ -16,7 +16,7 @@ const meta = { render: function (args) { function openDialog() { const dialog = document.getElementById('storybook-dialog') as CraftDialog; - dialog.open = true; + dialog.opened = true; } return html` diff --git a/packages/craftcms-cp/src/components/dialog/dialog.test.ts b/packages/craftcms-cp/src/components/dialog/dialog.test.ts new file mode 100644 index 00000000000..305fc5898e2 --- /dev/null +++ b/packages/craftcms-cp/src/components/dialog/dialog.test.ts @@ -0,0 +1,133 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type CraftDialog from './dialog.js'; +import './dialog.js'; + +// happy-dom may not implement the full native API Lion's overlay +// controller uses; polyfill the minimum. +if (typeof HTMLDialogElement !== 'undefined') { + HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) { + this.setAttribute('open', ''); + }; + HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) { + this.removeAttribute('open'); + }; +} + +async function createDialog( + configure: (dialog: CraftDialog) => void = () => {} +): Promise { + const dialog = document.createElement('craft-dialog') as CraftDialog; + dialog.setAttribute('label', 'Test Dialog'); + const message = document.createElement('p'); + message.textContent = 'Dialog body text'; + dialog.append(message); + configure(dialog); + document.body.append(dialog); + + await dialog.updateComplete; + await new Promise((resolve) => setTimeout(resolve)); + return dialog; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +/** + * Waits out Lion's async OverlayController show/hide cycle so its 'show'/ + * 'hide' events have synced back before the next state change. + */ +function settle(dialog: CraftDialog): Promise { + return dialog.updateComplete.then( + () => new Promise((resolve) => setTimeout(resolve)) + ); +} + +describe('craft-dialog', () => { + it('wraps default-slot content into a slot="content" child', async () => { + const dialog = await createDialog(); + const content = dialog.querySelector('[slot="content"]'); + expect(content).not.toBeNull(); + expect(content!.textContent).toContain('Dialog body text'); + }); + + it('renders the label as a header title', async () => { + const dialog = await createDialog(); + const title = dialog.querySelector('.craft-dialog__title'); + expect(title).not.toBeNull(); + expect(title!.textContent).toBe('Test Dialog'); + }); + + it('renders a close button that closes the dialog', async () => { + const dialog = await createDialog(); + dialog.opened = true; + await settle(dialog); + expect(dialog.opened).toBe(true); + + const close = dialog.querySelector( + '.craft-dialog__close' + ); + expect(close).not.toBeNull(); + close!.click(); + await settle(dialog); + expect(dialog.opened).toBe(false); + }); + + it('opens when the open attribute is set before connecting', async () => { + const dialog = await createDialog((d) => d.setAttribute('open', '')); + expect(dialog.opened).toBe(true); + }); + + it('reflects the opened state back to the open attribute', async () => { + const dialog = await createDialog(); + dialog.opened = true; + await settle(dialog); + expect(dialog.hasAttribute('open')).toBe(true); + + dialog.opened = false; + await settle(dialog); + expect(dialog.hasAttribute('open')).toBe(false); + }); + + it('moves footer-slotted children into the footer', async () => { + const dialog = await createDialog((d) => { + const button = document.createElement('button'); + button.slot = 'footer'; + button.textContent = 'Done'; + d.append(button); + }); + const footer = dialog.querySelector('.craft-dialog__footer'); + expect(footer).not.toBeNull(); + expect(footer!.querySelector('button')?.textContent).toBe('Done'); + }); + + it('closes when a data-dialog="close" descendant is clicked', async () => { + const dialog = await createDialog((d) => { + const button = document.createElement('button'); + button.slot = 'footer'; + button.setAttribute('data-dialog', 'close'); + button.textContent = 'Close'; + d.append(button); + }); + dialog.opened = true; + await settle(dialog); + + dialog.querySelector('[data-dialog="close"]')!.click(); + await settle(dialog); + expect(dialog.opened).toBe(false); + }); + + it('emits craft-show and craft-hide', async () => { + const dialog = await createDialog(); + const events: string[] = []; + dialog.addEventListener('craft-show', () => events.push('craft-show')); + dialog.addEventListener('craft-hide', () => events.push('craft-hide')); + + dialog.opened = true; + await settle(dialog); + dialog.opened = false; + await settle(dialog); + + expect(events).toEqual(['craft-show', 'craft-hide']); + }); +}); diff --git a/packages/craftcms-cp/src/components/dialog/dialog.ts b/packages/craftcms-cp/src/components/dialog/dialog.ts index 3a68bd4eae7..17b34e8a5b0 100644 --- a/packages/craftcms-cp/src/components/dialog/dialog.ts +++ b/packages/craftcms-cp/src/components/dialog/dialog.ts @@ -1,34 +1,225 @@ -import {css} from 'lit'; -import WaDialog from '@awesome.me/webawesome/dist/components/dialog/dialog.js'; +import {css, type PropertyValues} from 'lit'; +import {property} from 'lit/decorators.js'; +import {LionDialog} from '@lion/ui/dialog.js'; +import {t} from '../../utilities/translate'; +import {wireOverlayLifecycleEvents} from '../../utilities/overlay-events.js'; +import '../icon/icon.js'; /** - * craft-dialog extends wa-dialog from web awesome and adds custom styling. - * Anything you can do with that works here. + * Styles for the dialog's generated light-DOM content. The content lives in + * the component's light DOM (Lion slots it into a native wrapper), + * so shadow styles can't reach its descendants; adopt a sheet into the root + * node instead. */ -export default class CraftDialog extends WaDialog { +const contentStyles = + typeof CSSStyleSheet !== 'undefined' ? new CSSStyleSheet() : null; +contentStyles?.replaceSync(` + .craft-dialog { + background-color: var(--c-surface-raised); + border-radius: var(--c-radius-md); + box-shadow: var(--c-shadow-lg); + min-width: min(90vw, 24rem); + max-width: min(90vw, 40rem); + } + + .craft-dialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--c-spacing-md); + padding-inline: var(--c-spacing-lg); + padding-block-start: var(--c-spacing-lg); + padding-block-end: var(--c-spacing-md); + } + + .craft-dialog__title { + font-size: 1.25em; + margin: 0; + } + + .craft-dialog__close { + background: none; + border: none; + cursor: pointer; + color: inherit; + padding: var(--c-spacing-xs); + line-height: 1; + } + + .craft-dialog__body { + padding-inline: var(--c-spacing-lg); + padding-block-end: var(--c-spacing-lg); + } + + .craft-dialog__footer { + display: flex; + justify-content: flex-end; + gap: var(--c-spacing-sm); + padding-inline: var(--c-spacing-lg); + padding-block-end: var(--c-spacing-lg); + } +`); + +/** + * craft-dialog is a modal dialog. Set the `open` attribute to show it; + * default-slot children render as the body, `slot="footer"` children render + * in a footer, and any descendant with `data-dialog="close"` closes the + * dialog when clicked. + * + * Note: Lion's overlay system already defines an `open()` *method*, so there + * is no `open` boolean property — use the `open` attribute or the `opened` + * property. + * + * @example + * const dialog = document.createElement('craft-dialog'); + * dialog.setAttribute('open', ''); + * dialog.append(message); + * document.body.appendChild(dialog); + */ +export default class CraftDialog extends LionDialog { + /** + * Backs the `open` attribute (Web Awesome-era API). Named `openAttribute` + * because Lion already uses `open` as a method; consumers should use the + * attribute form or `opened`. + */ + @property({type: Boolean, attribute: 'open', reflect: true}) + openAttribute = false; + + /** Title shown in the dialog header. */ + @property() label = ''; + + #contentWrapper: HTMLElement | null = null; + + #titleElement: HTMLElement | null = null; + + constructor() { + super(); + wireOverlayLifecycleEvents(this); + this.addEventListener('opened-changed', () => { + if (this.openAttribute !== this.opened) { + this.openAttribute = this.opened; + } + }); + } + static override get styles() { return [ - WaDialog.styles, css` :host { - --spacing: var(--c-spacing-lg); - --wa-space-2xl: var(--c-spacing-xl); - } - - .title { - font-size: 1.25em; + display: contents; } - .header { - padding-inline: var(--c-spacing-lg); - padding-block-start: var(--c-spacing-lg); - padding-block-end: var(--c-spacing-md); + dialog::backdrop { + background-color: rgb(0 0 0 / 0.25); } `, ]; } + + override connectedCallback() { + this.#adoptContentStyles(); + this.#ensureContentWrapper(); + super.connectedCallback(); + } + + #adoptContentStyles() { + const root = this.getRootNode(); + if ( + contentStyles && + (root instanceof Document || root instanceof ShadowRoot) && + !root.adoptedStyleSheets.includes(contentStyles) + ) { + root.adoptedStyleSheets = [...root.adoptedStyleSheets, contentStyles]; + } + } + + /** + * Lion expects dialog content as a light-DOM child with `slot="content"`. + * Build it from the consumer's default-slot and footer-slot children. + */ + #ensureContentWrapper() { + if (this.#contentWrapper?.isConnected) { + return; + } + + const wrapper = document.createElement('div'); + wrapper.slot = 'content'; + wrapper.classList.add('craft-dialog'); + + const body = document.createElement('div'); + body.classList.add('craft-dialog__body'); + body.append( + ...Array.from(this.childNodes).filter( + (node) => !(node instanceof Element) || node.slot === '' + ) + ); + + const footerNodes = Array.from(this.children).filter( + (child) => child.slot === 'footer' + ); + + wrapper.append(this.#buildHeader(), body); + + if (footerNodes.length > 0) { + const footer = document.createElement('footer'); + footer.classList.add('craft-dialog__footer'); + footer.append(...footerNodes); + wrapper.append(footer); + } + + wrapper.addEventListener('click', (event) => { + const target = event.target as HTMLElement; + if (target.closest?.('[data-dialog="close"]')) { + this.opened = false; + } + }); + + this.append(wrapper); + this.#contentWrapper = wrapper; + } + + #buildHeader(): HTMLElement { + const header = document.createElement('header'); + header.classList.add('craft-dialog__header'); + + const title = document.createElement('h2'); + title.classList.add('craft-dialog__title'); + title.textContent = this.label; + this.#titleElement = title; + + const close = document.createElement('button'); + close.type = 'button'; + close.classList.add('craft-dialog__close'); + close.setAttribute('aria-label', t('Close')); + close.setAttribute('data-dialog', 'close'); + + const icon = document.createElement('craft-icon'); + icon.setAttribute('name', 'xmark'); + close.append(icon); + + header.append(title, close); + return header; + } + + protected override updated(changed: PropertyValues) { + super.updated(changed); + + if (changed.has('openAttribute') && this.openAttribute !== this.opened) { + this.opened = this.openAttribute; + } + + if (changed.has('label') && this.#titleElement) { + this.#titleElement.textContent = this.label; + } + } } if (!customElements.get('craft-dialog')) { customElements.define('craft-dialog', CraftDialog); } + +declare global { + interface HTMLElementTagNameMap { + 'craft-dialog': CraftDialog; + } +} From 76ec21e1aeda2976cf8a2dc5179b2f88b865d38b Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 17:48:58 -0500 Subject: [PATCH 07/15] feat(cp): rebuild craft-breadcrumb-item without Web Awesome Co-Authored-By: Claude Fable 5 --- .../breadcrumb-item/breadcrumb-item.test.ts | 58 ++++++++++ .../breadcrumb-item/breadcrumb-item.ts | 101 ++++++++++++------ 2 files changed, 128 insertions(+), 31 deletions(-) create mode 100644 packages/craftcms-cp/src/components/breadcrumb-item/breadcrumb-item.test.ts diff --git a/packages/craftcms-cp/src/components/breadcrumb-item/breadcrumb-item.test.ts b/packages/craftcms-cp/src/components/breadcrumb-item/breadcrumb-item.test.ts new file mode 100644 index 00000000000..32a934c6331 --- /dev/null +++ b/packages/craftcms-cp/src/components/breadcrumb-item/breadcrumb-item.test.ts @@ -0,0 +1,58 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type CraftBreadcrumbItem from './breadcrumb-item.js'; +import './breadcrumb-item.js'; + +async function createItem( + attrs: Record = {}, + text = 'Entries' +): Promise { + const element = document.createElement( + 'craft-breadcrumb-item' + ) as CraftBreadcrumbItem; + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + element.textContent = text; + document.body.append(element); + await element.updateComplete; + return element; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-breadcrumb-item', () => { + it('renders a span label without href', async () => { + const element = await createItem(); + expect(element.shadowRoot!.querySelector('a')).toBeNull(); + expect(element.shadowRoot!.querySelector('span.label')).not.toBeNull(); + }); + + it('renders a link when href is set', async () => { + const element = await createItem({href: '/entries'}); + const link = element.shadowRoot!.querySelector('a.label'); + expect(link).not.toBeNull(); + expect(link!.getAttribute('href')).toBe('/entries'); + }); + + it('exposes href as a property', async () => { + const element = await createItem({href: '/entries'}); + expect(element.href).toBe('/entries'); + }); + + it('has a separator slot for breadcrumbs to fill', async () => { + const element = await createItem(); + const separator = document.createElement('span'); + separator.slot = 'separator'; + separator.textContent = '/'; + element.append(separator); + await element.updateComplete; + + const slot = element.shadowRoot!.querySelector( + 'slot[name="separator"]' + ); + expect(slot).not.toBeNull(); + expect(slot!.assignedElements()).toEqual([separator]); + }); +}); diff --git a/packages/craftcms-cp/src/components/breadcrumb-item/breadcrumb-item.ts b/packages/craftcms-cp/src/components/breadcrumb-item/breadcrumb-item.ts index f7f4a1c3ea0..da10c484f65 100644 --- a/packages/craftcms-cp/src/components/breadcrumb-item/breadcrumb-item.ts +++ b/packages/craftcms-cp/src/components/breadcrumb-item/breadcrumb-item.ts @@ -1,34 +1,73 @@ -import {css} from 'lit'; -import WaBreadcrumbItem from '@awesome.me/webawesome/dist/components/breadcrumb-item/breadcrumb-item.js'; - -export default class CraftBreadcrumbItem extends WaBreadcrumbItem { - static override get styles() { - return [ - WaBreadcrumbItem.styles, - css` - :host { - --wa-font-weight-action: 400; - --wa-space-s: var(--c-spacing-sm); - white-space: nowrap; - display: inline-flex; - align-items: center; - color: inherit; - } - - .start { - margin-inline-end: var(--c-spacing-sm); - } - - .end { - margin-inline-start: var(--c-spacing-sm); - } - - .separator { - color: var(--c-text-quiet); - margin: 0 var(--c-spacing-md); - } - `, - ]; +import {css, html, LitElement, nothing} from 'lit'; +import {property} from 'lit/decorators.js'; + +/** + * A single crumb inside `craft-breadcrumbs`. Renders its default-slot content + * as a link when `href` is set. `craft-breadcrumbs` appends separator content + * into the `separator` slot. + */ +export default class CraftBreadcrumbItem extends LitElement { + /** When set, the label is rendered as a link. */ + @property({reflect: true}) href?: string; + + /** The link target (only used when `href` is set). */ + @property() target?: '_blank' | '_parent' | '_self' | '_top'; + + /** The link rel (only used when `href` and `target` are set). */ + @property() rel = 'noreferrer noopener'; + + static override styles = css` + :host { + white-space: nowrap; + display: inline-flex; + align-items: center; + color: inherit; + } + + .label { + display: inline-flex; + align-items: center; + font-weight: 400; + color: inherit; + text-decoration: none; + } + + a.label:hover { + text-decoration: underline; + } + + slot[name='start']::slotted(*) { + margin-inline-end: var(--c-spacing-sm); + } + + slot[name='end']::slotted(*) { + margin-inline-start: var(--c-spacing-sm); + } + + slot[name='separator']::slotted(*) { + color: var(--c-text-quiet); + margin: 0 var(--c-spacing-md); + } + `; + + override render() { + const label = this.href + ? html`` + : html``; + + return html` + + ${label} + + + `; } } From 99698ec9ad39e69430bfcea8531d1ab0a96ce8f2 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 19:47:25 -0500 Subject: [PATCH 08/15] feat(cp): drop craft-drawer and craft-dropdown (use craft-action-menu) Co-Authored-By: Claude Fable 5 --- .../scripts/generate-vue-wrappers.js | 10 --- .../src/components/drawer/drawer.stories.ts | 43 ------------- .../src/components/drawer/drawer.ts | 25 -------- .../components/dropdown/dropdown.stories.ts | 36 ----------- .../components/dropdown/dropdown.styles.ts | 19 ------ .../src/components/dropdown/dropdown.ts | 62 ------------------- packages/craftcms-cp/src/index.ts | 2 - 7 files changed, 197 deletions(-) delete mode 100644 packages/craftcms-cp/src/components/drawer/drawer.stories.ts delete mode 100644 packages/craftcms-cp/src/components/drawer/drawer.ts delete mode 100644 packages/craftcms-cp/src/components/dropdown/dropdown.stories.ts delete mode 100644 packages/craftcms-cp/src/components/dropdown/dropdown.styles.ts delete mode 100644 packages/craftcms-cp/src/components/dropdown/dropdown.ts diff --git a/packages/craftcms-cp/scripts/generate-vue-wrappers.js b/packages/craftcms-cp/scripts/generate-vue-wrappers.js index 7dfe170ef6d..d61ad984571 100644 --- a/packages/craftcms-cp/scripts/generate-vue-wrappers.js +++ b/packages/craftcms-cp/scripts/generate-vue-wrappers.js @@ -544,16 +544,6 @@ const ALL_COMPONENTS = [ className: 'CraftDisclosure', importPath: '../components/disclosure/disclosure', }, - { - tagName: 'craft-drawer', - className: 'CraftDrawer', - importPath: '../components/drawer/drawer', - }, - { - tagName: 'craft-dropdown', - className: 'CraftDropdown', - importPath: '../components/dropdown/dropdown', - }, { tagName: 'craft-indicator', className: 'CraftIndicator', diff --git a/packages/craftcms-cp/src/components/drawer/drawer.stories.ts b/packages/craftcms-cp/src/components/drawer/drawer.stories.ts deleted file mode 100644 index eea1cc32f01..00000000000 --- a/packages/craftcms-cp/src/components/drawer/drawer.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/web-components-vite'; - -import {html} from 'lit'; - -import './drawer.js'; -import '../button/button.js'; - -import type CraftDrawer from './drawer.js'; - -// More on how to set up stories at: https://storybook.js.org/docs/writing-stories -const meta = { - title: 'Components/Drawer', - component: 'craft-drawer', - args: {}, - parameters: { - layout: 'centered', - }, - render: function (args) { - function openDrawer() { - const drawer = document.getElementById('drawer-overview') as CraftDrawer; - drawer.open = true; - } - - return html` - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Close - - - Open Drawer - `; - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -export const Default: Story = { - args: {}, -}; diff --git a/packages/craftcms-cp/src/components/drawer/drawer.ts b/packages/craftcms-cp/src/components/drawer/drawer.ts deleted file mode 100644 index 0c8ce97b831..00000000000 --- a/packages/craftcms-cp/src/components/drawer/drawer.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {css} from 'lit'; -import WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js'; - -/** - * craft-drawer extends wa-drawer from web awesome. - * - * Anything you can do over there you can do here. - */ -export default class CraftDrawer extends WaDrawer { - static override get styles() { - return [ - WaDrawer.styles, - css` - :host { - --wa-color-surface-raised: var(--c-surface-raised); - --spacing: var(--c-spacing-lg); - } - `, - ]; - } -} - -if (!customElements.get('craft-drawer')) { - customElements.define('craft-drawer', CraftDrawer); -} diff --git a/packages/craftcms-cp/src/components/dropdown/dropdown.stories.ts b/packages/craftcms-cp/src/components/dropdown/dropdown.stories.ts deleted file mode 100644 index d91719a9b18..00000000000 --- a/packages/craftcms-cp/src/components/dropdown/dropdown.stories.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/web-components-vite'; - -import {html} from 'lit'; - -import './dropdown.js'; -import '../button/button.js'; - -// More on how to set up stories at: https://storybook.js.org/docs/writing-stories -const meta = { - title: 'Components/Dropdown', - component: 'craft-dropdown', - argTypes: {}, - render: (args) => html` - - Dropdown - - This is a thing - Another thing - Yet another thing - - `, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -export const Default: Story = { - args: {}, -}; - -export const Open: Story = { - args: { - open: true, - }, -}; diff --git a/packages/craftcms-cp/src/components/dropdown/dropdown.styles.ts b/packages/craftcms-cp/src/components/dropdown/dropdown.styles.ts deleted file mode 100644 index 3a3135df80e..00000000000 --- a/packages/craftcms-cp/src/components/dropdown/dropdown.styles.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {css} from 'lit'; - -export default css` - :host { - display: contents; - } - - .panel { - display: grid; - gap: 1px; - flex-direction: column; - width: max-content; - position: absolute; - padding: var(--spacing-sm); - background-color: var(--bg-overlay); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - } -`; diff --git a/packages/craftcms-cp/src/components/dropdown/dropdown.ts b/packages/craftcms-cp/src/components/dropdown/dropdown.ts deleted file mode 100644 index a5d1caec60e..00000000000 --- a/packages/craftcms-cp/src/components/dropdown/dropdown.ts +++ /dev/null @@ -1,62 +0,0 @@ -import WaDropdown from '@awesome.me/webawesome/dist/components/dropdown/dropdown.js'; -import WaDropdownItem from '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; -import {css} from 'lit'; - -export default class CraftDropdown extends WaDropdown { - static override get styles() { - return [ - WaDropdown.styles, - css` - :host { - --wa-border-style: solid; - --wa-border-width-s: 1px; - --wa-color-surface-raised: var(--c-surface-raised); - --wa-color-surface-border: var(--c-color-neutral-border-quiet); - --wa-border-radius-m: var(--c-radius-lg); - } - - #menu { - gap: 1px; - } - `, - ]; - } -} - -export class CraftDropdownItem extends WaDropdownItem { - static override get styles() { - return [ - WaDropdownItem.styles, - css` - @layer components.dropdown-item { - :host { - --wa-font-weight-action: 400; - --wa-space-s: var(--c-spacing-sm); - --wa-color-neutral-fill-normal: var(--c-color-neutral-fill-quiet); - white-space: nowrap; - display: inline-flex; - align-items: center; - border-radius: var(--c-radius-sm); - padding-block: calc(var(--c-spacing, 0.25rem) * 1); - padding-inline: calc(var(--c-spacing, 0.25rem) * 2.5); - } - } - `, - ]; - } -} - -if (!customElements.get('craft-dropdown')) { - customElements.define('craft-dropdown', CraftDropdown); -} - -if (!customElements.get('craft-dropdown-item')) { - customElements.define('craft-dropdown-item', CraftDropdownItem); -} - -declare global { - interface HTMLElementTagNameMap { - 'craft-dropdown': CraftDropdown; - 'craft-dropdown-item': CraftDropdownItem; - } -} diff --git a/packages/craftcms-cp/src/index.ts b/packages/craftcms-cp/src/index.ts index 43b72dcc3c6..9e7285799e7 100644 --- a/packages/craftcms-cp/src/index.ts +++ b/packages/craftcms-cp/src/index.ts @@ -25,8 +25,6 @@ export {default as CraftCopyAttribute} from './components/copy-attribute/copy-at export {default as CraftCopyButton} from './components/copy-button/copy-button.js'; export {default as CraftDialog} from './components/dialog/dialog.js'; export {default as CraftDisclosure} from './components/disclosure/disclosure.js'; -export {default as CraftDrawer} from './components/drawer/drawer.js'; -export {default as CraftDropdown} from './components/dropdown/dropdown.js'; export {default as CraftFieldGroup} from './components/field-group/field-group.js'; export {default as CraftIcon} from './components/icon/icon.js'; export {default as CraftIndicator} from './components/indicator/indicator.js'; From 5b8f5c0184a41a0bece4359781daa8ad9ed051b7 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 19:47:27 -0500 Subject: [PATCH 09/15] fix(cp): satisfy tsc for Lion's JSDoc Boolean/invoker-node types Co-Authored-By: Claude Fable 5 --- .../craftcms-cp/src/components/dialog/dialog.ts | 6 ++++-- .../craftcms-cp/src/components/popover/popover.ts | 1 + .../craftcms-cp/src/components/tooltip/tooltip.ts | 1 + .../craftcms-cp/src/utilities/overlay-events.ts | 13 ++++++++----- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/craftcms-cp/src/components/dialog/dialog.ts b/packages/craftcms-cp/src/components/dialog/dialog.ts index 17b34e8a5b0..5e0f1fd6ef7 100644 --- a/packages/craftcms-cp/src/components/dialog/dialog.ts +++ b/packages/craftcms-cp/src/components/dialog/dialog.ts @@ -96,8 +96,10 @@ export default class CraftDialog extends LionDialog { super(); wireOverlayLifecycleEvents(this); this.addEventListener('opened-changed', () => { - if (this.openAttribute !== this.opened) { - this.openAttribute = this.opened; + // Lion's JSDoc types `opened` as boxed Boolean; coerce to primitive. + const opened = Boolean(this.opened); + if (this.openAttribute !== opened) { + this.openAttribute = opened; } }); } diff --git a/packages/craftcms-cp/src/components/popover/popover.ts b/packages/craftcms-cp/src/components/popover/popover.ts index 541efb753c6..d4e38e7c33b 100644 --- a/packages/craftcms-cp/src/components/popover/popover.ts +++ b/packages/craftcms-cp/src/components/popover/popover.ts @@ -92,6 +92,7 @@ export default class CraftPopover extends OverlayMixin(LitElement) { // Resolve the invoker: explicit anchor first, then `for`, then a slotted // `slot="invoker"` child. + // @ts-ignore Lion's JSDoc types this getter as always-defined. override get _overlayInvokerNode(): HTMLElement | undefined { if (this.anchor) { return this.anchor; diff --git a/packages/craftcms-cp/src/components/tooltip/tooltip.ts b/packages/craftcms-cp/src/components/tooltip/tooltip.ts index 43411ce7516..b0f150bd538 100644 --- a/packages/craftcms-cp/src/components/tooltip/tooltip.ts +++ b/packages/craftcms-cp/src/components/tooltip/tooltip.ts @@ -92,6 +92,7 @@ export default class CraftTooltip extends LionTooltip { // Resolve the external invoker via `for`, falling back to Lion's slotted // `slot="invoker"` child. + // @ts-ignore Lion's JSDoc types this getter as always-defined. override get _overlayInvokerNode(): HTMLElement | undefined { if (this.for) { // When disconnected, getRootNode() may return a root (the element diff --git a/packages/craftcms-cp/src/utilities/overlay-events.ts b/packages/craftcms-cp/src/utilities/overlay-events.ts index 89361a08277..a4e8789a734 100644 --- a/packages/craftcms-cp/src/utilities/overlay-events.ts +++ b/packages/craftcms-cp/src/utilities/overlay-events.ts @@ -1,5 +1,7 @@ +// `opened` is `unknown` because Lion's JSDoc types it as the boxed `Boolean`; +// runtime values are primitive booleans. type OverlayHost = HTMLElement & { - opened: boolean; + opened: unknown; updateComplete: Promise; }; @@ -13,15 +15,16 @@ type OverlayHost = HTMLElement & { * root (e.g. `craft-info-icon` listening for its inner tooltip) receive them. */ export function wireOverlayLifecycleEvents(host: OverlayHost): void { - let lastOpened = host.opened; + let lastOpened = Boolean(host.opened); host.addEventListener('opened-changed', () => { - if (host.opened === lastOpened) { + const opened = Boolean(host.opened); + + if (opened === lastOpened) { return; } - lastOpened = host.opened; - const opened = host.opened; + lastOpened = opened; host.dispatchEvent( new CustomEvent(opened ? 'craft-show' : 'craft-hide', { From 9f0086d6eac1773e9bf480f52df5bceeb5f311fa Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 20:31:58 -0500 Subject: [PATCH 10/15] feat(cp): remove @awesome.me/webawesome dependency Co-Authored-By: Claude Fable 5 --- package-lock.json | 50 ------------------- packages/craftcms-cp/package.json | 2 - packages/craftcms-cp/scripts/build.js | 2 +- .../src/components/tooltip/Tooltip.mdx | 2 +- .../src/components/tooltip/tooltip.stories.ts | 1 - .../craftcms-cp/src/styles/shared/tokens.css | 11 ---- packages/craftcms-cp/src/utilities/icons.ts | 8 ++- 7 files changed, 5 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec6d11263d8..17c70c61283 100644 --- a/package-lock.json +++ b/package-lock.json @@ -268,37 +268,6 @@ "node": ">=16" } }, - "node_modules/@awesome.me/webawesome": { - "version": "3.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@ctrl/tinycolor": "4.1.0", - "@floating-ui/dom": "^1.6.13", - "@lit/react": "^1.0.8", - "@shoelace-style/animations": "^1.2.0", - "@shoelace-style/localize": "^3.2.2", - "composed-offset-position": "^0.0.6", - "lit": "^3.2.1", - "marked": "^11.2.0", - "nanoid": "^5.1.5", - "qr-creator": "^1.0.0" - }, - "engines": { - "node": ">=14.17.0" - } - }, - "node_modules/@awesome.me/webawesome/node_modules/marked": { - "version": "11.2.0", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "license": "MIT", @@ -16506,23 +16475,6 @@ "version": "2.26.2", "license": "MIT" }, - "node_modules/nanoid": { - "version": "5.1.6", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, "node_modules/nanospinner": { "version": "1.2.2", "dev": true, @@ -23973,7 +23925,6 @@ "devDependencies": { "@adobe/leonardo-contrast-colors": "^1.1.0", "@arethetypeswrong/cli": "^0.18.2", - "@awesome.me/webawesome": "^3.6.0", "@custom-elements-manifest/analyzer": "^0.11.0", "@lion/ui": "^0.16.1", "@shoelace-style/shoelace": "^2.20.1", @@ -24010,7 +23961,6 @@ "vitest": "^4.1.5" }, "peerDependencies": { - "@awesome.me/webawesome": "^3.0.0", "@lion/ui": "0.x", "lit": "3.x" } diff --git a/packages/craftcms-cp/package.json b/packages/craftcms-cp/package.json index f6527e8e47a..a74461e964e 100644 --- a/packages/craftcms-cp/package.json +++ b/packages/craftcms-cp/package.json @@ -56,14 +56,12 @@ }, "homepage": "https://github.com/craftcms/npm-packages#readme", "peerDependencies": { - "@awesome.me/webawesome": "^3.0.0", "@lion/ui": "0.x", "lit": "3.x" }, "devDependencies": { "@adobe/leonardo-contrast-colors": "^1.1.0", "@arethetypeswrong/cli": "^0.18.2", - "@awesome.me/webawesome": "^3.6.0", "@custom-elements-manifest/analyzer": "^0.11.0", "@lion/ui": "^0.16.1", "@shoelace-style/shoelace": "^2.20.1", diff --git a/packages/craftcms-cp/scripts/build.js b/packages/craftcms-cp/scripts/build.js index 44096aecfd0..8ab555cde9f 100755 --- a/packages/craftcms-cp/scripts/build.js +++ b/packages/craftcms-cp/scripts/build.js @@ -39,7 +39,7 @@ async function generateBundle(config = {}) { ...(await resolveFrom('./src/services/**/!(*.(styles|test)).ts')), }, minify: !isDeveloping, - external: ['lit', '@lion/ui', '@awesome.me/webawesome'], + external: ['lit', '@lion/ui'], format: ['esm'], sourcemap: true, dts: true, diff --git a/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx b/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx index d223cff065e..f030dac2594 100644 --- a/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx +++ b/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx @@ -5,6 +5,6 @@ import TooltipMeta, {Playground} from './tooltip.stories'; # Tooltip -``](https://webawesome.com/docs/components/tooltip) element so all of the options and properties of `` can be used with ``. +`` shows contextual text for an external trigger element referenced by id via the `for` attribute. It is built on [Lion's tooltip/overlay system](https://lion.js.org/components/tooltip/overview/) and supports `placement` (Popper placement strings) and `trigger` (`hover focus`, `click`, or `manual`). It emits `craft-show`, `craft-after-show`, `craft-hide`, and `craft-after-hide` events. diff --git a/packages/craftcms-cp/src/components/tooltip/tooltip.stories.ts b/packages/craftcms-cp/src/components/tooltip/tooltip.stories.ts index 556f01f38a8..1e9ae25be6b 100644 --- a/packages/craftcms-cp/src/components/tooltip/tooltip.stories.ts +++ b/packages/craftcms-cp/src/components/tooltip/tooltip.stories.ts @@ -2,7 +2,6 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; import {html} from 'lit'; -// import "@awesome.me/webawesome/dist/components/tooltip/tooltip.js"; import './tooltip.js'; import '../button/button.js'; diff --git a/packages/craftcms-cp/src/styles/shared/tokens.css b/packages/craftcms-cp/src/styles/shared/tokens.css index f8db68dbb85..ab95a5a84c6 100644 --- a/packages/craftcms-cp/src/styles/shared/tokens.css +++ b/packages/craftcms-cp/src/styles/shared/tokens.css @@ -203,15 +203,4 @@ --c-modal-shadow: 0 0 0 1px hsl(from var(--color-gray-400) h s l / 25%), 0 25px 100px hsl(from var(--color-gray-900) h s l / 50%); - - /** - Web Awesome - */ - --wa-panel-border-style: solid; - --wa-panel-border-width: 1px; - --wa-color-surface-border: var(--c-color-neutral-border-quiet); - --wa-panel-border-color: var(--c-color-neutral-border-quiet); - --wa-panel-border-radius: var(--c-radius-md); - --wa-color-surface-raised: var(--c-surface-raised); - --wa-shadow-l: var(--c-shadow-lg); } diff --git a/packages/craftcms-cp/src/utilities/icons.ts b/packages/craftcms-cp/src/utilities/icons.ts index d0310be8deb..1a4d5e976a5 100644 --- a/packages/craftcms-cp/src/utilities/icons.ts +++ b/packages/craftcms-cp/src/utilities/icons.ts @@ -60,11 +60,9 @@ const LEGACY_NAMES: Record = { }; /** - * This is mostly copied from font awesome directly because they don't seem to - * give you a way to access the default library settings and extend them, only - * overwrite them entirely. - * - * https://github.com/shoelace-style/webawesome/blob/da206a87873e3ab43ded7466a05005225aa50e69/packages/webawesome/src/components/icon/library.default.ts + * Resolves an icon name to its URL under the CP's published Font Awesome + * assets. The name/variant/family resolution rules mirror Font Awesome's + * default icon-library conventions so existing icon names keep working. */ export function getIconUrl( name: string, From daa2ca323ea4f068c0b731ed9282ebdf25cb72ae Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 14 Jul 2026 09:35:14 -0500 Subject: [PATCH 11/15] Rector --- tests/Unit/Asset/Elements/AssetMetadataAltTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Asset/Elements/AssetMetadataAltTest.php b/tests/Unit/Asset/Elements/AssetMetadataAltTest.php index d49298d21a2..b007dd9f4b9 100644 --- a/tests/Unit/Asset/Elements/AssetMetadataAltTest.php +++ b/tests/Unit/Asset/Elements/AssetMetadataAltTest.php @@ -116,7 +116,7 @@ function createAssetMetadataAltJpegBytes(): string ob_start(); imagejpeg($image); - return (string) ob_get_clean(); + return ob_get_clean(); } function createAssetMetadataAltTempJpeg(string $contents): string From e14a82428b4fc3b62901b8d2256ba5d06cdea849 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 14 Jul 2026 15:44:52 -0500 Subject: [PATCH 12/15] Wire up icon resolution --- packages/craftcms-cp/.storybook/preview.ts | 22 +- .../src/components/icon/icon.test.ts | 37 +++- .../craftcms-cp/src/components/icon/icon.ts | 94 ++++----- .../components/info-icon/info-icon.stories.ts | 7 +- packages/craftcms-cp/src/index.ts | 1 + .../craftcms-cp/src/utilities/icons.test.ts | 10 +- packages/craftcms-cp/src/utilities/icons.ts | 190 ++++++++++++------ resources/js/bootstrap/cp.ts | 2 + resources/js/bootstrap/icons.ts | 85 ++++++++ src/Cp/Cp.php | 2 + 10 files changed, 305 insertions(+), 145 deletions(-) create mode 100644 resources/js/bootstrap/icons.ts diff --git a/packages/craftcms-cp/.storybook/preview.ts b/packages/craftcms-cp/.storybook/preview.ts index 82cdb1d6cf0..253dd644efa 100644 --- a/packages/craftcms-cp/.storybook/preview.ts +++ b/packages/craftcms-cp/.storybook/preview.ts @@ -4,12 +4,15 @@ import type { } from '@storybook/web-components-vite'; import '../src/styles/cp.css'; import './preview.css'; -import {icons} from '@lion/ui/icon.js'; -import {html} from 'lit'; import {withThemeByDataAttribute} from '@storybook/addon-themes'; import {setCustomElementsManifest} from '@storybook/web-components'; import manifest from '../dist/custom-elements.json' with {type: 'json'}; import {setStorybookHelpersConfig} from '@wc-toolkit/storybook-helpers'; +import { + createUrlIconResolver, + getIconUrl, + setIconResolver, +} from '../src/utilities/icons.js'; setStorybookHelpersConfig({ /** hides the `arg ref` label on each control */ @@ -24,9 +27,18 @@ setStorybookHelpersConfig({ setCustomElementsManifest(manifest); -icons.addIconResolver('craft', (iconSet: string, name: string) => { - return html`
${iconSet} - ${name}
`; -}); +const FONT_AWESOME_CDN_URL = + 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@7.0.0/svgs'; + +setIconResolver( + createUrlIconResolver((name, family, variant) => { + if (family === 'custom-icons' || variant === 'custom-icons') { + return null; + } + + return getIconUrl(name, family, variant, FONT_AWESOME_CDN_URL); + }) +); const preview: Preview = { parameters: { diff --git a/packages/craftcms-cp/src/components/icon/icon.test.ts b/packages/craftcms-cp/src/components/icon/icon.test.ts index 9691f378b4e..3a6aacdcbc3 100644 --- a/packages/craftcms-cp/src/components/icon/icon.test.ts +++ b/packages/craftcms-cp/src/components/icon/icon.test.ts @@ -1,6 +1,8 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {html} from 'lit'; import type CraftIcon from './icon.js'; import './icon.js'; +import {defaultIconResolver, setIconResolver} from '../../utilities/icons.js'; const SVG_BODY = ''; @@ -35,6 +37,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + setIconResolver(defaultIconResolver); }); describe('craft-icon', () => { @@ -47,15 +50,6 @@ describe('craft-icon', () => { ); }); - it('maps legacy names', async () => { - const fetchMock = stubFetch(); - await createIcon({name: 'newstamp'}); - expect(fetchMock).toHaveBeenCalledWith( - '/vendor/craft/icons/solid/certificate.svg', - {mode: 'cors'} - ); - }); - it('resolves variant-prefixed names', async () => { const fetchMock = stubFetch(); await createIcon({name: 'custom-icons/graphql'}); @@ -73,6 +67,31 @@ describe('craft-icon', () => { expect(svg!.getAttribute('fill')).toBe('currentColor'); }); + it('resolves icons through the configured Lion icon resolver', async () => { + const fetchMock = stubFetch(); + setIconResolver( + (name, family, variant) => html` + + ` + ); + + const element = await createIcon({ + name: 'github', + family: 'brands', + variant: 'regular', + }); + const svg = element.shadowRoot!.querySelector('svg'); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(svg!.dataset.name).toBe('github'); + expect(svg!.dataset.family).toBe('brands'); + expect(svg!.dataset.variant).toBe('regular'); + }); + it('only fetches once per URL', async () => { const fetchMock = stubFetch(); await createIcon({name: 'clock'}); diff --git a/packages/craftcms-cp/src/components/icon/icon.ts b/packages/craftcms-cp/src/components/icon/icon.ts index 3dcec8976ac..374d23f28e8 100644 --- a/packages/craftcms-cp/src/components/icon/icon.ts +++ b/packages/craftcms-cp/src/components/icon/icon.ts @@ -1,34 +1,13 @@ -import {css, html, LitElement, nothing, type PropertyValues} from 'lit'; +import { + css, + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; import {property, state} from 'lit/decorators.js'; -import {getIconUrl} from '../../utilities/icons.js'; - -/** - * Module-level cache of icon fetches, keyed by URL. Failed fetches are - * evicted so they can retry on the next request. - */ -const iconCache = new Map>(); - -async function requestIcon(url: string): Promise { - try { - const response = await fetch(url, {mode: 'cors'}); - - if (!response.ok) { - return null; - } - - const container = document.createElement('div'); - container.innerHTML = await response.text(); - const svg = container.firstElementChild; - - if (svg?.tagName?.toLowerCase() !== 'svg') { - return null; - } - - return svg as SVGElement; - } catch { - return null; - } -} +import {resolveIcon} from '../../utilities/icons.js'; /** * craft-icon renders an SVG icon fetched from the CP's published icon assets @@ -55,7 +34,7 @@ export default class CraftIcon extends LitElement { @property({reflect: true}) appearance?: 'plain' | 'badge' = 'plain'; - @state() private _svg: SVGElement | null = null; + @state() private _svg: TemplateResult | typeof nothing = nothing; @state() private _hasSlottedContent = false; @@ -71,54 +50,51 @@ export default class CraftIcon extends LitElement { this._hasSlottedContent = this.childElementCount > 0; } - #iconUrl(): string | null { + #iconParams(): {name: string; family: string; variant: string} | null { if (!this.name) { return null; } // 'classic'/'solid' mirror the defaults the Web Awesome-era icon library // resolver applied; getIconUrl's own defaults differ for direct callers. - return getIconUrl( - this.name, - this.family ?? 'classic', - this.variant ?? 'solid' - ); + return { + name: this.name, + family: this.family ?? 'classic', + variant: this.variant ?? 'solid', + }; } async #loadIcon() { - const url = this.#iconUrl(); + const icon = this.#iconParams(); - if (url === null) { - this._svg = null; + if (icon === null) { + this._svg = nothing; return; } - let request = iconCache.get(url); - if (!request) { - request = requestIcon(url); - iconCache.set(url, request); - } - - const svg = await request; + let svg: TemplateResult | typeof nothing; - if (svg === null) { - iconCache.delete(url); + try { + svg = await resolveIcon(icon.name, icon.family, icon.variant); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + svg = nothing; } - // The icon may have changed while the fetch was in flight. - if (url !== this.#iconUrl()) { - return; - } + const currentIcon = this.#iconParams(); - if (svg === null) { - this._svg = null; + // The icon may have changed while the fetch was in flight. + if ( + currentIcon === null || + icon.name !== currentIcon.name || + icon.family !== currentIcon.family || + icon.variant !== currentIcon.variant + ) { return; } - const clone = svg.cloneNode(true) as SVGElement; - clone.setAttribute('fill', 'currentColor'); - clone.setAttribute('part', 'svg'); - this._svg = clone; + this._svg = svg; } #applyLabel() { diff --git a/packages/craftcms-cp/src/components/info-icon/info-icon.stories.ts b/packages/craftcms-cp/src/components/info-icon/info-icon.stories.ts index f17461b287b..ddafb966981 100644 --- a/packages/craftcms-cp/src/components/info-icon/info-icon.stories.ts +++ b/packages/craftcms-cp/src/components/info-icon/info-icon.stories.ts @@ -1,13 +1,13 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; import './info-icon'; +import {html} from 'lit'; const meta: Meta = { title: 'Components/Info Icon', tags: ['autodocs'], args: {}, render: (args) => { - return ` - + return html` This is the content for the tooltip `; }, @@ -26,8 +26,7 @@ export const Default: Story = { export const Multiple: Story = { render: () => { - return ` -
+ return html`
Tooltip content for icon 1 Tooltip content for icon 2 Tooltip content for icon 3 diff --git a/packages/craftcms-cp/src/index.ts b/packages/craftcms-cp/src/index.ts index 9e7285799e7..ca3297d65a6 100644 --- a/packages/craftcms-cp/src/index.ts +++ b/packages/craftcms-cp/src/index.ts @@ -67,6 +67,7 @@ export {default as CraftVisuallyHidden} from './components/visually-hidden/visua export * from './utilities/cookies.js'; export * from './utilities/translate.js'; export * from './utilities/format.js'; +export * from './utilities/icons.js'; export * from './utilities/api/actionClient.js'; export * from './utilities/api/apiClient.js'; export * from './utilities/string.js'; diff --git a/packages/craftcms-cp/src/utilities/icons.test.ts b/packages/craftcms-cp/src/utilities/icons.test.ts index d5af6fdcc85..91cb959439d 100644 --- a/packages/craftcms-cp/src/utilities/icons.test.ts +++ b/packages/craftcms-cp/src/utilities/icons.test.ts @@ -1,4 +1,4 @@ -import {describe, test, expect} from 'vitest'; +import {describe, expect, test} from 'vitest'; import {getIconUrl} from './icons.js'; describe('getIconUrl', () => { @@ -6,9 +6,15 @@ describe('getIconUrl', () => { ['custom-icons/graphql', '/vendor/craft/icons/custom-icons/graphql.svg'], ['light/sliders', '/vendor/craft/icons/light/sliders.svg'], ['x', '/vendor/craft/icons/regular/x.svg'], - ['newstamp', '/vendor/craft/icons/regular/certificate.svg'], + ['newstamp', '/vendor/craft/icons/regular/newstamp.svg'], ])('%s', ([name, expected]) => { // console.log(args); expect(getIconUrl(name as string)).toBe(expected as string); }); + + test('supports custom base URLs', () => { + expect( + getIconUrl('house', 'classic', 'solid', 'https://example.test') + ).toBe('https://example.test/solid/house.svg'); + }); }); diff --git a/packages/craftcms-cp/src/utilities/icons.ts b/packages/craftcms-cp/src/utilities/icons.ts index 1a4d5e976a5..8c215b719ee 100644 --- a/packages/craftcms-cp/src/utilities/icons.ts +++ b/packages/craftcms-cp/src/utilities/icons.ts @@ -1,73 +1,81 @@ -const LEGACY_NAMES: Record = { - alert: 'triangle-exclamation', - asc: 'arrow-down-short-wide', - asset: 'image', - assets: 'image', - circleuarr: 'circle-arrow-up', - collapse: 'down-left-and-up-right-to-center', - condition: 'diamond', - darr: 'arrow-down', - date: 'calendar', - desc: 'arrow-down-wide-short', - disabled: 'circle-dashed', - done: 'circle-check', - downangle: 'angle-down', - draft: 'scribble', - edit: 'pencil', - enabled: 'circle', - expand: 'up-right-and-down-left-from-center', - external: 'arrow-up-right-from-square', - field: 'pen-to-square', - help: 'circle-question', - home: 'house', - info: 'circle-info', - insecure: 'unlock', - larr: 'arrow-left', - layout: 'table-layout', - leftangle: 'angle-left', - listrtl: 'list-flip', - location: 'location-dot', - mail: 'envelope', - menu: 'bars', - move: 'grip-dots', - newstamp: 'certificate', - paperplane: 'paper-plane', - plugin: 'plug', - rarr: 'arrow-right', - refresh: 'arrows-rotate', - remove: 'xmark', - rightangle: 'angle-right', - rotate: 'rotate-left', - routes: 'signs-post', - search: 'magnifying-glass', - secure: 'lock', - settings: 'gear', - shareleft: 'share-flip', - shuteye: 'eye-slash', - 'sidebar-left': 'sidebar', - 'sidebar-right': 'sidebar-flip', - 'sidebar-start': 'sidebar', // Note: Was conditional based on $orientation (ltr: 'sidebar', rtl: 'sidebar-flip') - 'sidebar-end': 'sidebar-flip', // Note: Was conditional based on $orientation (ltr: 'sidebar-flip', rtl: 'sidebar') - structure: 'list-tree', - structurertl: 'list-tree-flip', - template: 'file-code', - time: 'clock', - tool: 'wrench', - uarr: 'arrow-up', - upangle: 'angle-up', - view: 'eye', - wand: 'wand-magic-sparkles', -}; +import {icons} from '@lion/ui/icon.js'; +import {html, nothing, type TemplateResult} from 'lit'; +import {unsafeSVG} from 'lit/directives/unsafe-svg.js'; + +export const CRAFT_ICON_NAMESPACE = 'craft'; + +type ResolvedIcon = TemplateResult | typeof nothing; +type LionIconResolver = ( + iconset: string, + icon: string +) => + | TemplateResult + | Promise + | typeof nothing + | Promise; + +export type CraftIconResolver = ( + name: string, + family: string, + variant: string +) => ResolvedIcon | Promise; + +export type CraftIconUrlResolver = ( + name: string, + family: string, + variant: string +) => string | null; + +/** + * Module-level cache of icon fetches, keyed by URL. Failed fetches are + * evicted so they can retry on the next request. + */ +const iconCache = new Map>(); + +function getIconset(family: string, variant: string): string { + return `${family}/${variant}`; +} + +function parseIconset(iconset: string): {family: string; variant: string} { + const [family = 'classic', variant = 'regular'] = iconset.split('/'); + + return {family, variant}; +} + +async function requestIcon(url: string): Promise { + try { + const response = await fetch(url, {mode: 'cors'}); + + if (!response.ok) { + return nothing; + } + + const container = document.createElement('div'); + container.innerHTML = await response.text(); + const svg = container.firstElementChild; + + if (svg?.tagName?.toLowerCase() !== 'svg') { + return nothing; + } + + svg.setAttribute('fill', 'currentColor'); + svg.setAttribute('part', 'svg'); + + return html`${unsafeSVG(svg.outerHTML)}`; + } catch { + return nothing; + } +} /** * Resolves an icon name to its URL under the CP's published Font Awesome - * assets. The name/variant/family resolution rules mirror Font Awesome's - * default icon-library conventions so existing icon names keep working. + * assets. */ export function getIconUrl( name: string, family: string = 'classic', - variant: string = 'regular' + variant: string = 'regular', + baseUrl: string = '/vendor/craft/icons' ) { let folder = 'solid'; let resolvedVariant = variant; @@ -99,8 +107,58 @@ export function getIconUrl( folder = 'custom-icons'; } - resolvedName = LEGACY_NAMES[resolvedName] ?? resolvedName; + return `${baseUrl}/${folder}/${resolvedName}.svg`; +} + +export function createUrlIconResolver( + resolveUrl: CraftIconUrlResolver +): CraftIconResolver { + return (name, family, variant) => { + const url = resolveUrl(name, family, variant); + + if (url === null) { + return nothing; + } + + let request = iconCache.get(url); + + if (!request) { + request = requestIcon(url); + iconCache.set(url, request); + } + + return request.then((icon) => { + if (icon === nothing) { + iconCache.delete(url); + } + + return icon; + }); + }; +} + +export const defaultIconResolver: CraftIconResolver = + createUrlIconResolver(getIconUrl); + +export function setIconResolver(resolver: CraftIconResolver) { + icons.removeIconResolver(CRAFT_ICON_NAMESPACE); + const lionResolver: LionIconResolver = (iconset, icon) => { + const {family, variant} = parseIconset(iconset); + + return resolver(icon, family, variant) as ReturnType; + }; + + icons.addIconResolver(CRAFT_ICON_NAMESPACE, lionResolver); +} - // Use the default CDN - return `/vendor/craft/icons/${folder}/${resolvedName}.svg`; +export function resolveIcon( + name: string, + family: string = 'classic', + variant: string = 'regular' +): Promise { + return Promise.resolve( + icons.resolveIcon(CRAFT_ICON_NAMESPACE, getIconset(family, variant), name) + ) as Promise; } + +setIconResolver(defaultIconResolver); diff --git a/resources/js/bootstrap/cp.ts b/resources/js/bootstrap/cp.ts index 066303f32cc..91ee09926d3 100644 --- a/resources/js/bootstrap/cp.ts +++ b/resources/js/bootstrap/cp.ts @@ -19,6 +19,7 @@ import {setUrlDefaults} from '@/wayfinder'; import {inertiaPageRegistry, resolveInertiaPage} from './inertia-pages.js'; import AppLayout from '@/common/layouts/AppLayout.vue'; import {createCpComponentRegistry} from './components.js'; +import {configureIcons} from './icons.js'; let bootedCallbacks: Array<(instance: any) => void> = []; let bootingCallbacks: Array<(instance: any) => void> = []; @@ -92,6 +93,7 @@ const Cp = { init() { config.initialize(this.initialConfig); + configureIcons(config.get('iconBaseUrl', '/vendor/craft/icons')); setUrlDefaults(() => ({ cpTrigger: routeSegment(config.get('cpTrigger')), diff --git a/resources/js/bootstrap/icons.ts b/resources/js/bootstrap/icons.ts new file mode 100644 index 00000000000..d6c28fc8cb2 --- /dev/null +++ b/resources/js/bootstrap/icons.ts @@ -0,0 +1,85 @@ +import {createUrlIconResolver, getIconUrl, setIconResolver} from '@craftcms/cp'; + +const LEGACY_NAMES: Record = { + alert: 'triangle-exclamation', + asc: 'arrow-down-short-wide', + asset: 'image', + assets: 'image', + circleuarr: 'circle-arrow-up', + collapse: 'down-left-and-up-right-to-center', + condition: 'diamond', + darr: 'arrow-down', + date: 'calendar', + desc: 'arrow-down-wide-short', + disabled: 'circle-dashed', + done: 'circle-check', + downangle: 'angle-down', + draft: 'scribble', + edit: 'pencil', + enabled: 'circle', + expand: 'up-right-and-down-left-from-center', + external: 'arrow-up-right-from-square', + field: 'pen-to-square', + help: 'circle-question', + home: 'house', + info: 'circle-info', + insecure: 'unlock', + larr: 'arrow-left', + layout: 'table-layout', + leftangle: 'angle-left', + listrtl: 'list-flip', + location: 'location-dot', + mail: 'envelope', + menu: 'bars', + move: 'grip-dots', + newstamp: 'certificate', + paperplane: 'paper-plane', + plugin: 'plug', + rarr: 'arrow-right', + refresh: 'arrows-rotate', + remove: 'xmark', + rightangle: 'angle-right', + rotate: 'rotate-left', + routes: 'signs-post', + search: 'magnifying-glass', + secure: 'lock', + settings: 'gear', + shareleft: 'share-flip', + shuteye: 'eye-slash', + 'sidebar-left': 'sidebar', + 'sidebar-right': 'sidebar-flip', + 'sidebar-start': 'sidebar', + 'sidebar-end': 'sidebar-flip', + structure: 'list-tree', + structurertl: 'list-tree-flip', + template: 'file-code', + time: 'clock', + tool: 'wrench', + uarr: 'arrow-up', + upangle: 'angle-up', + view: 'eye', + wand: 'wand-magic-sparkles', +}; + +function normalizeIconName(name: string): string { + const suffix = name.endsWith('.svg') ? '.svg' : ''; + const baseName = suffix ? name.slice(0, -suffix.length) : name; + + if (baseName.includes('/')) { + const [variant, ...iconName] = baseName.split('/'); + const resolvedIconName = iconName.join('/'); + + return `${variant}/${LEGACY_NAMES[resolvedIconName] ?? resolvedIconName}${suffix}`; + } + + console.log('heyyyyyyyyyyyyyyyyy'); + return `${LEGACY_NAMES[baseName] ?? baseName}${suffix}`; +} + +export function configureIcons(baseUrl: string = '/vendor/craft/icons') { + setIconResolver( + createUrlIconResolver((name, family, variant) => + getIconUrl(normalizeIconName(name), family, variant, baseUrl) + ) + ); +} diff --git a/src/Cp/Cp.php b/src/Cp/Cp.php index 593386de44b..53e6df6b9f3 100644 --- a/src/Cp/Cp.php +++ b/src/Cp/Cp.php @@ -39,6 +39,7 @@ use Illuminate\Support\Collection; use stdClass; +use function CraftCms\Cms\craftAsset; use function CraftCms\Cms\currentUserElement; use function CraftCms\Cms\t; @@ -118,6 +119,7 @@ private static function craftData(): array 'datepickerOptions' => self::datepickerOptions($formattingLocale, $locale), 'defaultCookieOptions' => self::defaultCookieOptions(), 'fileKinds' => AssetsHelper::getFileKinds(), + 'iconBaseUrl' => craftAsset('icons'), 'language' => app()->getLocale(), 'left' => $orientation === 'ltr' ? 'left' : 'right', 'maxPasswordLength' => AppServiceProvider::$maxPasswordLength, From 4efa82009d36a73e7b8f42c64ea5154d4ba3e3e9 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 14 Jul 2026 15:47:41 -0500 Subject: [PATCH 13/15] Remove debug --- resources/js/bootstrap/icons.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/js/bootstrap/icons.ts b/resources/js/bootstrap/icons.ts index d6c28fc8cb2..cb164ceaaa4 100644 --- a/resources/js/bootstrap/icons.ts +++ b/resources/js/bootstrap/icons.ts @@ -72,7 +72,6 @@ function normalizeIconName(name: string): string { return `${variant}/${LEGACY_NAMES[resolvedIconName] ?? resolvedIconName}${suffix}`; } - console.log('heyyyyyyyyyyyyyyyyy'); return `${LEGACY_NAMES[baseName] ?? baseName}${suffix}`; } From 6bc986f195b419e0c41d8046bdef8b05d450b23e Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 14 Jul 2026 16:06:55 -0500 Subject: [PATCH 14/15] Back to `craft-tooltip` The old tooltip will be availble at `craft-legacy-tooltip` --- .../craftcms-cp/scripts/generate-vue-wrappers.js | 2 +- .../src/components/copy-button/copy-button.ts | 2 +- .../src/components/info-icon/info-icon.ts | 8 +++++--- .../src/components/nav-item/nav-item.ts | 4 ++-- .../craftcms-cp/src/components/tooltip/Tooltip.mdx | 2 +- .../src/components/tooltip/tooltip.stories.ts | 4 ++-- .../src/components/tooltip/tooltip.test.ts | 4 ++-- .../craftcms-cp/src/components/tooltip/tooltip.ts | 14 +++++--------- resources/js/common/components/Tooltip.vue | 4 ++-- 9 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/craftcms-cp/scripts/generate-vue-wrappers.js b/packages/craftcms-cp/scripts/generate-vue-wrappers.js index d61ad984571..d37387adccc 100644 --- a/packages/craftcms-cp/scripts/generate-vue-wrappers.js +++ b/packages/craftcms-cp/scripts/generate-vue-wrappers.js @@ -610,7 +610,7 @@ const ALL_COMPONENTS = [ importPath: '../components/input-file/selected-file-list', }, { - tagName: 'c-tooltip', + tagName: 'craft-tooltip', className: 'CraftTooltip', importPath: '../components/tooltip/tooltip', }, diff --git a/packages/craftcms-cp/src/components/copy-button/copy-button.ts b/packages/craftcms-cp/src/components/copy-button/copy-button.ts index 29438d147a2..6d56028f4ed 100644 --- a/packages/craftcms-cp/src/components/copy-button/copy-button.ts +++ b/packages/craftcms-cp/src/components/copy-button/copy-button.ts @@ -149,7 +149,7 @@ export default class CraftCopyButton extends LitElement { override render() { return html` - ${this.tooltipLabel} + ${this.tooltipLabel}
`; } diff --git a/packages/craftcms-cp/src/components/nav-item/nav-item.ts b/packages/craftcms-cp/src/components/nav-item/nav-item.ts index fee63bc351c..b11227735d4 100644 --- a/packages/craftcms-cp/src/components/nav-item/nav-item.ts +++ b/packages/craftcms-cp/src/components/nav-item/nav-item.ts @@ -74,9 +74,9 @@ export default class CraftNavItem extends LitElement { > ${this.renderPrefix()} ${this.renderSuffix(hasSubnav)} - + > `; } diff --git a/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx b/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx index f030dac2594..5c9d7881249 100644 --- a/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx +++ b/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx @@ -5,6 +5,6 @@ import TooltipMeta, {Playground} from './tooltip.stories'; # Tooltip -`` shows contextual text for an external trigger element referenced by id via the `for` attribute. It is built on [Lion's tooltip/overlay system](https://lion.js.org/components/tooltip/overview/) and supports `placement` (Popper placement strings) and `trigger` (`hover focus`, `click`, or `manual`). It emits `craft-show`, `craft-after-show`, `craft-hide`, and `craft-after-hide` events. +`` shows contextual text for an external trigger element referenced by id via the `for` attribute. It is built on [Lion's tooltip/overlay system](https://lion.js.org/components/tooltip/overview/) and supports `placement` (Popper placement strings) and `trigger` (`hover focus`, `click`, or `manual`). It emits `craft-show`, `craft-after-show`, `craft-hide`, and `craft-after-hide` events. diff --git a/packages/craftcms-cp/src/components/tooltip/tooltip.stories.ts b/packages/craftcms-cp/src/components/tooltip/tooltip.stories.ts index 1e9ae25be6b..7fbec3585f7 100644 --- a/packages/craftcms-cp/src/components/tooltip/tooltip.stories.ts +++ b/packages/craftcms-cp/src/components/tooltip/tooltip.stories.ts @@ -23,11 +23,11 @@ const meta = { } return html` - ${args.content}${args.content} Hover me `; diff --git a/packages/craftcms-cp/src/components/tooltip/tooltip.test.ts b/packages/craftcms-cp/src/components/tooltip/tooltip.test.ts index 4bef126c231..8d3a6c3cdf5 100644 --- a/packages/craftcms-cp/src/components/tooltip/tooltip.test.ts +++ b/packages/craftcms-cp/src/components/tooltip/tooltip.test.ts @@ -10,7 +10,7 @@ async function createFixture( button.textContent = 'Trigger'; document.body.append(button); - const tooltip = document.createElement('c-tooltip') as CraftTooltip; + const tooltip = document.createElement('craft-tooltip') as CraftTooltip; tooltip.setAttribute('for', 'trigger'); for (const [name, value] of Object.entries(tooltipAttrs)) { tooltip.setAttribute(name, value); @@ -28,7 +28,7 @@ beforeEach(() => { document.body.innerHTML = ''; }); -describe('c-tooltip', () => { +describe('craft-tooltip', () => { it('wraps default-slot content into a slot="content" child', async () => { const {tooltip} = await createFixture(); const content = tooltip.querySelector('[slot="content"]'); diff --git a/packages/craftcms-cp/src/components/tooltip/tooltip.ts b/packages/craftcms-cp/src/components/tooltip/tooltip.ts index b0f150bd538..c3837360f76 100644 --- a/packages/craftcms-cp/src/components/tooltip/tooltip.ts +++ b/packages/craftcms-cp/src/components/tooltip/tooltip.ts @@ -4,10 +4,10 @@ import {LionTooltip} from '@lion/ui/tooltip.js'; import {wireOverlayLifecycleEvents} from '../../utilities/overlay-events.js'; /** - * craft-tooltip (registered as `c-tooltip`) shows contextual text for an - * external trigger element referenced by id. + * craft-tooltip shows contextual text for an external trigger element + * referenced by id. * - * @example Help text + * @example Help text * * Emits `craft-show`/`craft-after-show`/`craft-hide`/`craft-after-hide`. */ @@ -164,16 +164,12 @@ export default class CraftTooltip extends LionTooltip { } } -/** - * @TODO rename this once I figure out what to do with the existing `craft-tooltip` - */ -if (!customElements.get('c-tooltip')) { - customElements.define('c-tooltip', CraftTooltip); +if (!customElements.get('craft-tooltip')) { + customElements.define('craft-tooltip', CraftTooltip); } declare global { interface HTMLElementTagNameMap { - 'c-tooltip': CraftTooltip; 'craft-tooltip': CraftTooltip; } } diff --git a/resources/js/common/components/Tooltip.vue b/resources/js/common/components/Tooltip.vue index 66c656ef4e6..83411d9f4bb 100644 --- a/resources/js/common/components/Tooltip.vue +++ b/resources/js/common/components/Tooltip.vue @@ -27,9 +27,9 @@ - + - + From dadcc38916e97cad3acbc215e483fc0766ce2e2c Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 14 Jul 2026 16:11:57 -0500 Subject: [PATCH 15/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../craftcms-cp/src/utilities/overlay-events.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/craftcms-cp/src/utilities/overlay-events.ts b/packages/craftcms-cp/src/utilities/overlay-events.ts index a4e8789a734..02336a039a2 100644 --- a/packages/craftcms-cp/src/utilities/overlay-events.ts +++ b/packages/craftcms-cp/src/utilities/overlay-events.ts @@ -33,12 +33,20 @@ export function wireOverlayLifecycleEvents(host: OverlayHost): void { }) ); + const expectedOpened = opened; void host.updateComplete.then(() => { + if (Boolean(host.opened) !== expectedOpened) { + return; + } + host.dispatchEvent( - new CustomEvent(opened ? 'craft-after-show' : 'craft-after-hide', { - bubbles: true, - composed: true, - }) + new CustomEvent( + expectedOpened ? 'craft-after-show' : 'craft-after-hide', + { + bubbles: true, + composed: true, + } + ) ); }); });