Skip to content

Commit

Permalink
feat: add basic dashboard widget structure (#7679)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomivirkki authored Aug 21, 2024
1 parent 19ccbe9 commit 3443482
Show file tree
Hide file tree
Showing 7 changed files with 315 additions and 12 deletions.
50 changes: 41 additions & 9 deletions dev/dashboard-layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@

<script type="module">
import '@vaadin/dashboard/vaadin-dashboard-layout.js';
import '@vaadin/dashboard/vaadin-dashboard-widget.js';
</script>

<style>
vaadin-dashboard-layout div {
vaadin-dashboard-widget {
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 1em;
text-align: center;
height: 100px;
padding: 10px;
}

vaadin-dashboard-layout {
Expand All @@ -27,16 +26,49 @@
--vaadin-dashboard-gap: 20px;
--vaadin-dashboard-col-max-count: 3;
}

.kpi-number {
font-size: 80px;
font-weight: bold;
color: #4caf50;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}

.chart {
height: 300px;
background: repeating-linear-gradient(45deg, #e0e0e0, #e0e0e0 10px, #f5f5f5 10px, #f5f5f5 20px);
}
</style>
</head>

<body>
<vaadin-dashboard-layout>
<div>Item 0</div>
<div style="--vaadin-dashboard-item-colspan: 2">Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
<div>Item 4</div>
<vaadin-dashboard-widget widget-title="Total cost">
<span slot="header">2023-2024</span>
<div class="kpi-number">+203%</div>
</vaadin-dashboard-widget>

<vaadin-dashboard-widget style="--vaadin-dashboard-item-colspan: 2" widget-title="Sales">
<span slot="header">2023-2024</span>
<div class="chart"></div>
</vaadin-dashboard-widget>

<vaadin-dashboard-widget widget-title="Sales closed this month">
<div class="kpi-number">54 000€</div>
</vaadin-dashboard-widget>

<vaadin-dashboard-widget widget-title="Just some number">
<span slot="header">2014-2024</span>
<div class="kpi-number">1234</div>
</vaadin-dashboard-widget>

<vaadin-dashboard-widget>
<h2 slot="title">Activity since 2023</h2>
<div class="chart"></div>
</vaadin-dashboard-widget>
</vaadin-dashboard-layout>
</body>
</html>
21 changes: 21 additions & 0 deletions packages/dashboard/src/title-controller.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @license
* Copyright (c) 2019 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { SlotChildObserveController } from '@vaadin/component-base/src/slot-child-observe-controller.js';

/**
* A controller to manage the widget title element.
*/
export class TitleController extends SlotChildObserveController {
/**
* String used for the widget title.
*/
protected widgetTitle: string | null | undefined;

/**
* Set widget title based on corresponding host property.
*/
setWidgetTitle(widgetTitle: string | null | undefined): void;
}
64 changes: 64 additions & 0 deletions packages/dashboard/src/title-controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @license
* Copyright (c) 2019 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { SlotChildObserveController } from '@vaadin/component-base/src/slot-child-observe-controller.js';

/**
* A controller to manage the widget title element.
*/
export class TitleController extends SlotChildObserveController {
constructor(host) {
super(host, 'title', null);
}

/**
* Set widget title based on corresponding host property.
*
* @param {string} widgetTitle
*/
setWidgetTitle(widgetTitle) {
this.widgetTitle = widgetTitle;

// Restore the default widgetTitle, if needed.
const widgetTitleNode = this.getSlotChild();
if (!widgetTitleNode) {
this.restoreDefaultNode();
}

// When default widgetTitle is used, update it.
if (this.node === this.defaultNode) {
this.updateDefaultNode(this.node);
}
}

/**
* Override method inherited from `SlotChildObserveController`
* to restore and observe the default widget title element.
*
* @protected
* @override
*/
restoreDefaultNode() {
this.tagName = 'h2';
this.attachDefaultNode();
}

/**
* Override method inherited from `SlotChildObserveController`
* to update the default widgetTitle element text content.
*
* @param {Node | undefined} node
* @protected
* @override
*/
updateDefaultNode(node) {
if (node) {
node.textContent = this.widgetTitle;
}

// Notify the host after update.
super.updateDefaultNode(node);
}
}
8 changes: 7 additions & 1 deletion packages/dashboard/src/vaadin-dashboard-widget.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@
* See https://vaadin.com/commercial-license-and-service-terms for the full
* license.
*/
import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js';
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';

/**
* A Widget component for use with the Dashboard component
*/
declare class DashboardWidget extends ElementMixin(HTMLElement) {}
declare class DashboardWidget extends ControllerMixin(ElementMixin(HTMLElement)) {
/**
* The title of the widget
*/
widgetTitle: string | null | undefined;
}

declare global {
interface HTMLElementTagNameMap {
Expand Down
80 changes: 78 additions & 2 deletions packages/dashboard/src/vaadin-dashboard-widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,101 @@
* license.
*/
import { html, LitElement } from 'lit';
import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js';
import { defineCustomElement } from '@vaadin/component-base/src/define.js';
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
import { css } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
import { TitleController } from './title-controller.js';

/**
* A Widget component for use with the Dashboard component
*
* @customElement
* @extends HTMLElement
* @mixes ElementMixin
* @mixes ControllerMixin
*/
class DashboardWidget extends ElementMixin(PolylitMixin(LitElement)) {
class DashboardWidget extends ControllerMixin(ElementMixin(PolylitMixin(LitElement))) {
static get is() {
return 'vaadin-dashboard-widget';
}

static get styles() {
return css`
:host {
display: flex;
flex-direction: column;
}
:host([hidden]) {
display: none !important;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
#content {
flex: 1;
}
`;
}

static get properties() {
return {
/**
* The title of the widget.
*/
widgetTitle: {
type: String,
value: '',
observer: '__onWidgetTitleChanged',
},
};
}

/** @protected */
render() {
return html``;
return html`
<header>
<slot name="title" @slotchange="${this.__onTitleSlotChange}"></slot>
<slot name="header"></slot>
<div id="header-actions"></div>
</header>
<div id="content">
<slot></slot>
</div>
`;
}

constructor() {
super();
this.__titleController = new TitleController(this);
this.__titleController.addEventListener('slot-content-changed', (event) => {
const { node } = event.target;
if (node) {
this.setAttribute('aria-labelledby', node.id);
}
});
}

/** @protected */
ready() {
super.ready();
this.addController(this.__titleController);

if (!this.hasAttribute('role')) {
this.setAttribute('role', 'article');
}
}

/** @private */
__onWidgetTitleChanged(widgetTitle) {
this.__titleController.setWidgetTitle(widgetTitle);
}
}

Expand Down
97 changes: 97 additions & 0 deletions packages/dashboard/test/dashboard-widget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { expect } from '@vaadin/chai-plugins';
import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
import '../vaadin-dashboard-widget.js';
import type { DashboardWidget } from '../vaadin-dashboard-widget.js';

describe('dashboard widget', () => {
let widget: DashboardWidget;

beforeEach(async () => {
widget = fixtureSync(`<vaadin-dashboard-widget>Widget content</vaadin-dashboard-widget>`);
await nextFrame();
});

it('should not display when hidden', () => {
expect(widget.offsetHeight).to.be.above(0);
widget.hidden = true;
expect(widget.offsetHeight).to.eql(0);
});

describe('a11y', () => {
it('should have role="article"', () => {
expect(widget.getAttribute('role')).to.eql('article');
});

it('should not override custom role', async () => {
widget = fixtureSync(`<vaadin-dashboard-widget role="region"></vaadin-dashboard-widget>`);
await nextFrame();
expect(widget.getAttribute('role')).to.eql('region');
});

it('should add title id to aria-labelledby attribute when using property', async () => {
widget.widgetTitle = 'Custom title';
await nextFrame();
const title = widget.querySelector('[slot="title"]');
expect(widget.getAttribute('aria-labelledby')).equal(title?.id);
});

it('should add title id to aria-labelledby attribute when using slot', async () => {
const title = document.createElement('div');
title.id = 'custom-title';
title.slot = 'title';
title.textContent = 'Custom title';
widget.appendChild(title);

await nextFrame();
expect(widget.getAttribute('aria-labelledby')).equal(title?.id);
});

it('should have text content for the title', async () => {
widget.widgetTitle = 'Custom title';
await nextFrame();
const title = widget.querySelector('[slot="title"]');
expect(title?.textContent).equal('Custom title');
});
});

describe('title', () => {
it('should not override custom title element', async () => {
const title = document.createElement('div');
title.id = 'custom-title';
title.slot = 'title';
title.textContent = 'Custom title';
widget.appendChild(title);
await nextFrame();

widget.widgetTitle = 'New title';
await nextFrame();

const titles = widget.querySelectorAll('[slot="title"]');
expect(titles.length).to.eql(1);
expect(titles[0]).to.eql(title);
expect(titles[0].textContent).to.eql('Custom title');
});

it('should not throw when initialized with a custom title', async () => {
expect(() => {
fixtureSync(`
<vaadin-dashboard-widget>
<div slot="title">Custom title</div>
</vaadin-dashboard-widget>
`);
}).not.to.throw(Error);
await nextFrame();
});

it('should empty title element when cleared', async () => {
widget.widgetTitle = 'New title';
await nextFrame();

widget.widgetTitle = null;
await nextFrame();

const title = widget.querySelector('[slot="title"]');
expect(title?.textContent).to.eql('');
});
});
});
Loading

0 comments on commit 3443482

Please sign in to comment.