From 021a23f4b9189e2f36cb5b29e6d24e3ccba3dca5 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Thu, 14 May 2026 11:32:52 +0100 Subject: [PATCH 1/3] feat(components): adds `umb-entity-frame` component + Storybook stories --- .../entity-frame/entity-frame.element.ts | 80 ++++++++ .../entity-frame/entity-frame.stories.ts | 194 ++++++++++++++++++ .../entity-frame/entity-frame.test.ts | 42 ++++ .../core/components/entity-frame/index.ts | 2 + .../src/packages/core/components/index.ts | 1 + 5 files changed, 319 insertions(+) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.stories.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts new file mode 100644 index 000000000000..d6d23683aa27 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts @@ -0,0 +1,80 @@ +import { css, customElement, html, property, LitElement } from '@umbraco-cms/backoffice/external/lit'; + +/** + * A passive overlay that frames its parent with a rounded border and shows a label tab + * just above the parent's top-right corner. Visibility is controlled by the consumer via + * `--umb-entity-frame-opacity` (defaults to `1`); the typical pattern is for the parent + * container to set it to `0` by default and toggle to `1` on `:hover` and/or `:focus-within`. + * The parent must establish a positioning context (e.g. `position: relative`), and must not + * clip overflow above its top edge (the tab renders outside the parent's content box). + * @element umb-entity-frame + * @slot - Optional rich content for the tab. Falls back to the `label` property. + * @cssprop --umb-entity-frame-border-width - Thickness of the border. Defaults to `2px`. + * @cssprop --umb-entity-frame-color - Accent colour for the border and tab background. Defaults to `--uui-color-focus`. Should be dark enough to maintain contrast against the white tab text. + * @cssprop --umb-entity-frame-opacity - Opacity of the border and tab. Defaults to `1`. Set to `0` on the parent and toggle to `1` on `:hover` / `:focus-within` to gate visibility. + * @augments {LitElement} + */ +@customElement('umb-entity-frame') +export class UmbEntityFrameElement extends LitElement { + /** + * Text displayed in the tab when no slot content is projected. + * @type {string} + * @attr + * @default '' + */ + @property({ type: String }) + label: string = ''; + + override render() { + return html` + +
${this.label}
+ `; + } + + static override readonly styles = [ + css` + :host { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; + } + + .border, + .tab { + opacity: var(--umb-entity-frame-opacity, 1); + transition: opacity 120ms ease-out; + } + + .border { + position: absolute; + inset: 0; + border: var(--umb-entity-frame-border-width, 2px) solid var(--umb-entity-frame-color, var(--uui-color-focus)); + border-radius: var(--uui-border-radius); + border-top-right-radius: 0; + box-sizing: border-box; + pointer-events: none; + } + + .tab { + position: absolute; + bottom: 100%; + right: 0; + background: var(--umb-entity-frame-color, var(--uui-color-focus)); + color: var(--uui-color-surface, white); + padding: var(--uui-size-2) var(--uui-size-2) var(--uui-size-1); + border-radius: var(--uui-border-radius) var(--uui-border-radius) 0 0; + font-size: var(--uui-type-small-size); + line-height: 1; + pointer-events: auto; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-entity-frame': UmbEntityFrameElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.stories.ts new file mode 100644 index 000000000000..acad6e1a636c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.stories.ts @@ -0,0 +1,194 @@ +import { html } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbEntityFrameElement } from './entity-frame.element.js'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; + +import './entity-frame.element.js'; + +const meta: Meta = { + component: 'umb-entity-frame', + title: 'Generic Components/Entity Frame', + args: { + label: 'Document: Hero Banner', + }, + decorators: [(story) => html`
${story()}
`], + render: (args) => html` +
+

Entity label tab with full opacity.

+ +
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Docs: Story = {}; + +export const OnHover: Story = { + render: (args) => html` + +
+

Hover this container to reveal.

+ +
+ `, +}; + +export const OnHoverOrFocus: Story = { + render: (args) => html` + +
+

Hover, or Tab into the button below.

+ Focusable child + +
+ `, +}; + +export const WithSlot: Story = { + render: () => html` +
+

Slotted content overrides the label.

+ + + Document: Hero Banner + +
+ `, +}; + +export const WrappingButton: Story = { + render: () => html` + +
+ + Edit Hero Banner + + +
+ `, +}; + +export const Nested: Story = { + render: () => html` + +
+

Outer container (both visible when hovering inner)

+ + +
+

Inner container (only inner visible without hovering outer)

+ +
+
+ `, +}; + +export const WithCustomColor: Story = { + render: () => html` +
+

Themed via --umb-entity-frame-color.

+ +
+ `, +}; + +export const ReferenceList: Story = { + render: () => { + const items = [ + { label: 'Home', color: 'maroon' }, + { label: 'About', color: 'green' }, + { label: 'Contact', color: 'blue' }, + { label: 'Blog', color: 'purple' }, + ]; + + return html` + + + ${items.map( + (item) => html` + + + + `, + )} + + `; + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.test.ts new file mode 100644 index 000000000000..268afbdde278 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.test.ts @@ -0,0 +1,42 @@ +import { UmbEntityFrameElement } from './entity-frame.element.js'; +import { expect, fixture, html } from '@open-wc/testing'; +import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils'; + +describe('UmbEntityFrameElement', () => { + let element: UmbEntityFrameElement; + + beforeEach(async () => { + element = await fixture(html``); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbEntityFrameElement); + }); + + it('renders a border element', () => { + const border = element.shadowRoot!.querySelector('.border'); + expect(border).to.not.equal(null); + }); + + it('renders a tab with a default slot', () => { + const slot = element.shadowRoot!.querySelector('.tab slot'); + expect(slot).to.not.equal(null); + }); + + it('has a label property that defaults to an empty string', () => { + expect(element.label).to.equal(''); + }); + + it('renders the label inside the tab when no slot content is projected', async () => { + element.label = 'Document: Hero'; + await element.updateComplete; + const tab = element.shadowRoot!.querySelector('.tab')!; + expect(tab.textContent?.trim()).to.equal('Document: Hero'); + }); + + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); + } +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/index.ts new file mode 100644 index 000000000000..8efc97ba542f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/index.ts @@ -0,0 +1,2 @@ +import './entity-frame.element.js'; +export * from './entity-frame.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts index 24b71521f6ec..94d225a3bfd3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts @@ -8,6 +8,7 @@ export * from './figure-card/figure-card.element.js'; export * from './code-block/index.js'; export * from './dropdown/index.js'; export * from './entity-actions-bundle/index.js'; +export * from './entity-frame/index.js'; export * from './footer-layout/index.js'; export * from './header-app/index.js'; export * from './history/index.js'; From 84869d0fe9e985562b4f23806ab9efcfb3b19601 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Thu, 14 May 2026 17:16:46 +0100 Subject: [PATCH 2/3] fix(components): address review feedback for `umb-entity-frame` - Remove `pointer-events: auto` from `.tab` so the overlay is truly passive (was intercepting events above the parent and causing hover flicker when toggled via opacity). - Replace `--uui-color-surface` tab text with `--uui-color-selected-contrast` (the proper paired contrast token) and expose `--umb-entity-frame-contrast-color` so consumers can override when supplying a non-default `--umb-entity-frame-color`. Fixes contrast in dark and high-contrast themes. - Add `aria-hidden="true"` to `.tab`; the frame is purely decorative and the parent owns the real semantics. - Add a unit test verifying slot content takes precedence over the `label` property. --- .../components/entity-frame/entity-frame.element.ts | 8 ++++---- .../core/components/entity-frame/entity-frame.test.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts index d6d23683aa27..14ca725f889f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts @@ -10,7 +10,8 @@ import { css, customElement, html, property, LitElement } from '@umbraco-cms/bac * @element umb-entity-frame * @slot - Optional rich content for the tab. Falls back to the `label` property. * @cssprop --umb-entity-frame-border-width - Thickness of the border. Defaults to `2px`. - * @cssprop --umb-entity-frame-color - Accent colour for the border and tab background. Defaults to `--uui-color-focus`. Should be dark enough to maintain contrast against the white tab text. + * @cssprop --umb-entity-frame-color - Accent colour for the border and tab background. Defaults to `--uui-color-focus`. + * @cssprop --umb-entity-frame-contrast-color - Text colour for the tab. Defaults to `--uui-color-selected-contrast`. Override when using a custom `--umb-entity-frame-color` that does not pair with the default contrast token. * @cssprop --umb-entity-frame-opacity - Opacity of the border and tab. Defaults to `1`. Set to `0` on the parent and toggle to `1` on `:hover` / `:focus-within` to gate visibility. * @augments {LitElement} */ @@ -28,7 +29,7 @@ export class UmbEntityFrameElement extends LitElement { override render() { return html` -
${this.label}
+ `; } @@ -62,12 +63,11 @@ export class UmbEntityFrameElement extends LitElement { bottom: 100%; right: 0; background: var(--umb-entity-frame-color, var(--uui-color-focus)); - color: var(--uui-color-surface, white); + color: var(--umb-entity-frame-contrast-color, var(--uui-color-selected-contrast)); padding: var(--uui-size-2) var(--uui-size-2) var(--uui-size-1); border-radius: var(--uui-border-radius) var(--uui-border-radius) 0 0; font-size: var(--uui-type-small-size); line-height: 1; - pointer-events: auto; } `, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.test.ts index 268afbdde278..8c7b4a89c49c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.test.ts @@ -34,6 +34,17 @@ describe('UmbEntityFrameElement', () => { expect(tab.textContent?.trim()).to.equal('Document: Hero'); }); + it('renders slot content in preference to the label property', async () => { + const slotted = await fixture( + html`Slotted`, + ); + const slot = slotted.shadowRoot!.querySelector('.tab slot') as HTMLSlotElement; + const assigned = slot.assignedNodes({ flatten: true }); + expect(assigned.length).to.be.greaterThan(0); + const projected = assigned.find((node): node is HTMLElement => node instanceof HTMLElement); + expect(projected?.textContent?.trim()).to.equal('Slotted'); + }); + if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) { it('passes the a11y audit', async () => { await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); From 2b16093636e520fa0f6c2ca776e04dc168e21089 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 20 May 2026 09:00:04 +0100 Subject: [PATCH 3/3] Removed `aria-hidden` from the label tab As will need to be used with assistive technologies. --- .../core/components/entity-frame/entity-frame.element.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts index 14ca725f889f..cc57ff246196 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts @@ -1,7 +1,7 @@ import { css, customElement, html, property, LitElement } from '@umbraco-cms/backoffice/external/lit'; /** - * A passive overlay that frames its parent with a rounded border and shows a label tab + * An overlay that frames its parent with a rounded border and shows a label tab * just above the parent's top-right corner. Visibility is controlled by the consumer via * `--umb-entity-frame-opacity` (defaults to `1`); the typical pattern is for the parent * container to set it to `0` by default and toggle to `1` on `:hover` and/or `:focus-within`. @@ -29,7 +29,7 @@ export class UmbEntityFrameElement extends LitElement { override render() { return html` - +
${this.label}
`; }