+ return html`
Tooltip content for icon 1
Tooltip content for icon 2
Tooltip content for icon 3
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..55e2653024e 100644
--- a/packages/craftcms-cp/src/components/info-icon/info-icon.ts
+++ b/packages/craftcms-cp/src/components/info-icon/info-icon.ts
@@ -27,7 +27,7 @@ export default class CraftInfoIcon extends LitElement {
@state() status = '';
- @query('c-tooltip') tooltip!: HTMLElement;
+ @query('craft-tooltip') tooltip!: HTMLElement;
#eventController = new AbortController();
@@ -46,7 +46,7 @@ export default class CraftInfoIcon extends LitElement {
const {signal} = this.#eventController;
this.addEventListener(
- 'wa-show',
+ 'craft-show',
() => {
if (
CraftInfoIcon.#openInstance &&
@@ -54,7 +54,7 @@ export default class CraftInfoIcon extends LitElement {
) {
const otherTooltip =
CraftInfoIcon.#openInstance.renderRoot.querySelector(
- 'c-tooltip'
+ 'craft-tooltip'
);
otherTooltip?.hide();
}
@@ -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;
@@ -111,7 +111,9 @@ export default class CraftInfoIcon extends LitElement {
-
+
`;
}
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/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..d4e38e7c33b 100644
--- a/packages/craftcms-cp/src/components/popover/popover.ts
+++ b/packages/craftcms-cp/src/components/popover/popover.ts
@@ -1,26 +1,150 @@
-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.
+ // @ts-ignore Lion's JSDoc types this getter as always-defined.
+ 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')) {
diff --git a/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx b/packages/craftcms-cp/src/components/tooltip/Tooltip.mdx
index d223cff065e..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
-``](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..7fbec3585f7 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';
@@ -24,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
new file mode 100644
index 00000000000..8d3a6c3cdf5
--- /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('craft-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('craft-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..c3837360f76 100644
--- a/packages/craftcms-cp/src/components/tooltip/tooltip.ts
+++ b/packages/craftcms-cp/src/components/tooltip/tooltip.ts
@@ -1,52 +1,171 @@
-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 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.
+ // @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
+ // 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;
+ }
}
-/**
- * @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 {
diff --git a/packages/craftcms-cp/src/index.ts b/packages/craftcms-cp/src/index.ts
index 01965ccd815..ca3297d65a6 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';
@@ -26,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';
@@ -70,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';
@@ -105,5 +103,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/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.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 bcd65d2df8c..8c215b719ee 100644
--- a/packages/craftcms-cp/src/utilities/icons.ts
+++ b/packages/craftcms-cp/src/utilities/icons.ts
@@ -1,77 +1,81 @@
-import {registerIconLibrary} from '@awesome.me/webawesome/dist/webawesome.js';
-
-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;
+ }
+}
/**
- * 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.
*/
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;
@@ -103,17 +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);
+ }
- // Use the default CDN
- return `/vendor/craft/icons/${folder}/${resolvedName}.svg`;
+ return icon;
+ });
+ };
}
-export function configureIcons() {
- registerIconLibrary('default', {
- resolver: (name: string, family = 'classic', variant = 'solid') => {
- return getIconUrl(name, family, variant);
- },
- mutator: (svg) => svg.setAttribute('fill', 'currentColor'),
- });
+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);
}
+
+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/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..02336a039a2
--- /dev/null
+++ b/packages/craftcms-cp/src/utilities/overlay-events.ts
@@ -0,0 +1,53 @@
+// `opened` is `unknown` because Lion's JSDoc types it as the boxed `Boolean`;
+// runtime values are primitive booleans.
+type OverlayHost = HTMLElement & {
+ opened: unknown;
+ 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 = Boolean(host.opened);
+
+ host.addEventListener('opened-changed', () => {
+ const opened = Boolean(host.opened);
+
+ if (opened === lastOpened) {
+ return;
+ }
+
+ lastOpened = opened;
+
+ host.dispatchEvent(
+ new CustomEvent(opened ? 'craft-show' : 'craft-hide', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+
+ const expectedOpened = opened;
+ void host.updateComplete.then(() => {
+ if (Boolean(host.opened) !== expectedOpened) {
+ return;
+ }
+
+ host.dispatchEvent(
+ new CustomEvent(
+ expectedOpened ? 'craft-after-show' : 'craft-after-hide',
+ {
+ bubbles: true,
+ composed: true,
+ }
+ )
+ );
+ });
+ });
+}
diff --git a/resources/js/bootstrap/cp.ts b/resources/js/bootstrap/cp.ts
index 31b77560eea..7cae8528be7 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..cb164ceaaa4
--- /dev/null
+++ b/resources/js/bootstrap/icons.ts
@@ -0,0 +1,84 @@
+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}`;
+ }
+
+ 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/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 @@
-
+
-
+
diff --git a/resources/js/modules/markdown-field/behaviors/link-popover.ts b/resources/js/modules/markdown-field/behaviors/link-popover.ts
index 4c299b19c41..3698bd6e00e 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();
diff --git a/src/Cp/Cp.php b/src/Cp/Cp.php
index 403f58e7a15..ed4182fd5be 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,