Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`
<div class="border" aria-hidden="true"></div>
<div class="tab"><slot>${this.label}</slot></div>
`;
}

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;
}
}
Original file line number Diff line number Diff line change
@@ -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<UmbEntityFrameElement> = {
component: 'umb-entity-frame',
title: 'Generic Components/Entity Frame',
args: {
label: 'Document: Hero Banner',
},
decorators: [(story) => html`<div style="padding: 1.75rem; max-width: 600px;">${story()}</div>`],
render: (args) => html`
<div
style="position: relative; width: 240px; height: 120px; padding: 16px; border: 1px dashed rgba(0,0,0,0.2); border-radius: var(--uui-border-radius);">
<p>Entity label tab with full opacity.</p>
<umb-entity-frame .label=${args.label}></umb-entity-frame>
</div>
`,
};

export default meta;
type Story = StoryObj<UmbEntityFrameElement>;

export const Docs: Story = {};

export const OnHover: Story = {
render: (args) => html`
<style>
.demo-on-hover {
--umb-entity-frame-opacity: 0;
position: relative;
width: 240px;
height: 120px;
padding: 16px;
border: 1px dashed rgba(0, 0, 0, 0.2);
border-radius: var(--uui-border-radius);
}
.demo-on-hover:hover {
--umb-entity-frame-opacity: 1;
}
</style>
<div class="demo-on-hover">
<p>Hover this container to reveal.</p>
<umb-entity-frame .label=${args.label}></umb-entity-frame>
</div>
`,
};

export const OnHoverOrFocus: Story = {
render: (args) => html`
<style>
.demo-hover-focus {
--umb-entity-frame-opacity: 0;
position: relative;
width: 240px;
height: 120px;
padding: 16px;
border: 1px dashed rgba(0, 0, 0, 0.2);
border-radius: var(--uui-border-radius);
}
.demo-hover-focus:hover,
.demo-hover-focus:focus-within {
--umb-entity-frame-opacity: 1;
}
</style>
<div class="demo-hover-focus">
<p>Hover, or Tab into the button below.</p>
<uui-button look="primary" color="default">Focusable child</uui-button>
<umb-entity-frame .label=${args.label}></umb-entity-frame>
</div>
`,
};

export const WithSlot: Story = {
render: () => html`
<div
style="position: relative; width: 240px; height: 120px; padding: 16px; border: 1px dashed rgba(0,0,0,0.2); border-radius: var(--uui-border-radius);">
<p>Slotted content overrides the label.</p>
<umb-entity-frame>
<uui-icon name="icon-document"></uui-icon>
<span><strong>Document</strong>: Hero Banner</span>
</umb-entity-frame>
</div>
`,
};

export const WrappingButton: Story = {
render: () => html`
<style>
.demo-wrapping-button {
--umb-entity-frame-opacity: 0;

position: relative;
display: inline-block;
}
.demo-wrapping-button:hover {
--umb-entity-frame-opacity: 1;
}
</style>
<div class="demo-wrapping-button">
<uui-button look="primary">
Edit Hero Banner
<umb-entity-frame label="uui-button"></umb-entity-frame>
</uui-button>
</div>
`,
};

export const Nested: Story = {
render: () => html`
<style>
.demo-nested-outer {
--umb-entity-frame-opacity: 0;

position: relative;
width: 320px;
height: 200px;
padding: 32px;
border: 1px dashed rgba(0, 0, 0, 0.2);
border-radius: var(--uui-border-radius);
}
.demo-nested-outer:hover {
--umb-entity-frame-opacity: 1;
}
.demo-nested-inner {
--umb-entity-frame-opacity: 0;

position: relative;
margin-top: 16px;
width: 200px;
height: 80px;
padding: 16px;
border: 1px dashed rgba(0, 0, 0, 0.2);
border-radius: var(--uui-border-radius);
}
.demo-nested-inner:hover {
--umb-entity-frame-opacity: 1;
}
</style>
<div class="demo-nested-outer">
<p style="margin: 0;">Outer container (both visible when hovering inner)</p>
<umb-entity-frame label="Outer"></umb-entity-frame>

<div class="demo-nested-inner">
<p style="margin: 0;">Inner container (only inner visible without hovering outer)</p>
<umb-entity-frame label="Inner"></umb-entity-frame>
</div>
</div>
`,
};

export const WithCustomColor: Story = {
render: () => html`
<div
style="position: relative; width: 240px; height: 120px; padding: 16px; border: 1px dashed rgba(0,0,0,0.2); border-radius: var(--uui-border-radius); --umb-entity-frame-color: #7532c8;">
<p style="margin: 0;">Themed via <code>--umb-entity-frame-color</code>.</p>
<umb-entity-frame label="Custom"></umb-entity-frame>
</div>
`,
};

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`
<style>
uui-ref-list {
--umb-entity-frame-opacity: 0;
}
uui-ref-node:hover,
uui-ref-node:focus-within {
--umb-entity-frame-opacity: 1;
}
</style>
<uui-ref-list>
${items.map(
(item) => html`
<uui-ref-node name=${item.label} detail="path/to/nowhere" style="--umb-entity-frame-color: ${item.color}">
<umb-entity-frame label=${item.label}></umb-entity-frame>
</uui-ref-node>
`,
)}
</uui-ref-list>
`;
},
};
Original file line number Diff line number Diff line change
@@ -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<UmbEntityFrameElement>(html`<umb-entity-frame></umb-entity-frame>`);
});

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);
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import './entity-frame.element.js';
export * from './entity-frame.element.js';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down