diff --git a/packages/dashboard/src/keyboard-controller.js b/packages/dashboard/src/keyboard-controller.js
index 9d18ba8a4d..f5d6464745 100644
--- a/packages/dashboard/src/keyboard-controller.js
+++ b/packages/dashboard/src/keyboard-controller.js
@@ -65,7 +65,7 @@ export class KeyboardController {
/** @private */
__escape(e) {
e.preventDefault();
- if (this.host.__moveMode) {
+ if (this.host.__moveMode || this.host.__resizeMode) {
this.host.__exitMode(true);
} else {
this.host.__selected = false;
diff --git a/packages/dashboard/src/vaadin-dashboard-item-mixin.js b/packages/dashboard/src/vaadin-dashboard-item-mixin.js
index cb8b3db3f4..124bacfd51 100644
--- a/packages/dashboard/src/vaadin-dashboard-item-mixin.js
+++ b/packages/dashboard/src/vaadin-dashboard-item-mixin.js
@@ -12,7 +12,7 @@ import { html } from 'lit';
import { FocusTrapController } from '@vaadin/a11y-base/src/focus-trap-controller.js';
import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';
import { KeyboardController } from './keyboard-controller.js';
-import { fireMove, fireRemove } from './vaadin-dashboard-helpers.js';
+import { fireMove, fireRemove, fireResize } from './vaadin-dashboard-helpers.js';
import { dashboardWidgetAndSectionStyles } from './vaadin-dashboard-styles.js';
/**
@@ -50,6 +50,13 @@ export const DashboardItemMixin = (superClass) =>
reflectToAttribute: true,
attribute: 'move-mode',
},
+
+ /** @private */
+ __resizeMode: {
+ type: Boolean,
+ reflectToAttribute: true,
+ attribute: 'resize-mode',
+ },
};
}
@@ -86,6 +93,16 @@ export const DashboardItemMixin = (superClass) =>
>`;
}
+ /** @private */
+ __renderResizeHandle() {
+ return html``;
+ }
+
/** @private */
__renderModeControls() {
return html`
`;
}
+ /** @private */
+ __renderResizeControls() {
+ const hasMinRowHeight = getComputedStyle(this).getPropertyValue('--vaadin-dashboard-row-min-height');
+
+ return html` e.preventDefault()}"
+ >
+
+
+
+
+
+
`;
+ }
+
constructor() {
super();
this.__keyboardController = new KeyboardController(this);
@@ -138,6 +183,12 @@ export const DashboardItemMixin = (superClass) =>
this.$['drag-handle'].focus();
this.__focusTrapController.trapFocus(this.$.focustrap);
}
+ } else if (this.__resizeMode) {
+ this.__resizeMode = false;
+ if (focus) {
+ this.$['resize-handle'].focus();
+ this.__focusTrapController.trapFocus(this.$.focustrap);
+ }
}
}
@@ -156,4 +207,13 @@ export const DashboardItemMixin = (superClass) =>
this.__focusTrapController.trapFocus(this.$['move-controls']);
});
}
+
+ /** @private */
+ __enterResizeMode() {
+ this.__selected = true;
+ this.__resizeMode = true;
+ requestAnimationFrame(() => {
+ this.__focusTrapController.trapFocus(this.$['resize-controls']);
+ });
+ }
};
diff --git a/packages/dashboard/src/vaadin-dashboard-styles.js b/packages/dashboard/src/vaadin-dashboard-styles.js
index 2598f2a2b3..3da8a8f919 100644
--- a/packages/dashboard/src/vaadin-dashboard-styles.js
+++ b/packages/dashboard/src/vaadin-dashboard-styles.js
@@ -124,4 +124,58 @@ export const dashboardWidgetAndSectionStyles = css`
:host([last-child]) #move-forward {
display: none;
}
+
+ /* Resize-mode buttons */
+ #resize-shrink-width,
+ #resize-shrink-height,
+ #resize-grow-width,
+ #resize-grow-height,
+ #resize-apply {
+ font-size: 30px;
+ cursor: pointer;
+ position: absolute;
+ }
+
+ #resize-shrink-width::before,
+ #resize-shrink-height::before,
+ #resize-grow-width::before,
+ #resize-grow-height::before,
+ #resize-apply::before {
+ content: var(--content);
+ }
+
+ #resize-shrink-width {
+ inset-inline-end: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ --content: '-';
+ }
+
+ #resize-grow-width {
+ inset-inline-start: 100%;
+ top: 50%;
+ transform: translateY(-50%);
+ --content: '+';
+ }
+
+ #resize-shrink-height {
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ --content: '-';
+ }
+
+ #resize-grow-height {
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ --content: '+';
+ }
+
+ #resize-apply {
+ left: 50%;
+ top: 50%;
+ --content: '✔';
+ transform: translate(-50%, -50%);
+ }
`;
diff --git a/packages/dashboard/src/vaadin-dashboard-widget.js b/packages/dashboard/src/vaadin-dashboard-widget.js
index d921376bfd..2283a95ea0 100644
--- a/packages/dashboard/src/vaadin-dashboard-widget.js
+++ b/packages/dashboard/src/vaadin-dashboard-widget.js
@@ -105,7 +105,7 @@ class DashboardWidget extends DashboardItemMixin(ControllerMixin(ElementMixin(Po
/** @protected */
render() {
return html`
- ${this.__renderFocusButton()} ${this.__renderModeControls()}
+ ${this.__renderFocusButton()} ${this.__renderModeControls()} ${this.__renderResizeControls()}
@@ -115,7 +115,7 @@ class DashboardWidget extends DashboardItemMixin(ControllerMixin(ElementMixin(Po
${this.__renderRemoveButton()}
-
+ ${this.__renderResizeHandle()}
diff --git a/packages/dashboard/src/widget-resize-controller.js b/packages/dashboard/src/widget-resize-controller.js
index ef039fa590..9ede1b6e97 100644
--- a/packages/dashboard/src/widget-resize-controller.js
+++ b/packages/dashboard/src/widget-resize-controller.js
@@ -85,10 +85,6 @@ export class WidgetResizeController {
this.__updateResizedItem(-1, 0);
}
- if (!gridStyle.getPropertyValue('--vaadin-dashboard-row-min-height')) {
- return;
- }
-
const currentElementHeight = itemWrapper.firstElementChild.offsetHeight;
const rowMinHeight = Math.min(...gridStyle.gridTemplateRows.split(' ').map((height) => parseFloat(height)));
if (this.__resizeHeight > currentElementHeight + gapSize + rowMinHeight / 2) {
@@ -152,6 +148,11 @@ export class WidgetResizeController {
}
const gridStyle = getComputedStyle(this.host.$.grid);
+ if (rowspanDelta && !gridStyle.getPropertyValue('--vaadin-dashboard-row-min-height')) {
+ // Do not resize vertically if the min row height is not set
+ return;
+ }
+
const columns = gridStyle.gridTemplateColumns.split(' ');
const currentColspan = item.colspan || 1;
const currentRowspan = item.rowspan || 1;
diff --git a/packages/dashboard/test/dashboard-keyboard.test.ts b/packages/dashboard/test/dashboard-keyboard.test.ts
index c700f024fb..ee6710dc28 100644
--- a/packages/dashboard/test/dashboard-keyboard.test.ts
+++ b/packages/dashboard/test/dashboard-keyboard.test.ts
@@ -11,9 +11,16 @@ import {
getMoveApplyButton,
getMoveBackwardButton,
getMoveForwardButton,
+ getResizeApplyButton,
+ getResizeGrowHeightButton,
+ getResizeGrowWidthButton,
+ getResizeHandle,
+ getResizeShrinkHeightButton,
+ getResizeShrinkWidthButton,
setGap,
setMaximumColumnWidth,
setMinimumColumnWidth,
+ setMinimumRowHeight,
} from './helpers.js';
type TestDashboardItem = DashboardItem & { id: number };
@@ -159,6 +166,8 @@ describe('dashboard - keyboard interaction', () => {
});
it('should increase the widget row span on shift + arrow down', async () => {
+ // Set minimum row height to enable vertical resizing
+ setMinimumRowHeight(dashboard, 100);
await sendKeys({ down: 'Shift' });
await sendKeys({ press: 'ArrowDown' });
await sendKeys({ up: 'Shift' });
@@ -166,6 +175,8 @@ describe('dashboard - keyboard interaction', () => {
});
it('should decrease the widget row span on shift + arrow up', async () => {
+ // Set minimum row height to enable vertical resizing
+ setMinimumRowHeight(dashboard, 100);
await sendKeys({ down: 'Shift' });
await sendKeys({ press: 'ArrowDown' });
await sendKeys({ press: 'ArrowUp' });
@@ -173,6 +184,13 @@ describe('dashboard - keyboard interaction', () => {
expect((dashboard.items[0] as DashboardItem).rowspan).to.equal(1);
});
+ it('should not increase the widget row span on shift + arrow down if row min height is not defined', async () => {
+ await sendKeys({ down: 'Shift' });
+ await sendKeys({ press: 'ArrowDown' });
+ await sendKeys({ up: 'Shift' });
+ expect((dashboard.items[0] as DashboardItem).rowspan).to.not.equal(2);
+ });
+
it('should not move the widget on arrow down if ctrl key is pressed', async () => {
await sendKeys({ down: 'Control' });
await sendKeys({ press: 'ArrowDown' });
@@ -534,4 +552,169 @@ describe('dashboard - keyboard interaction', () => {
expect(dashboard.items).to.eql([{ id: 0 }, { items: [{ id: 2 }, { id: 3 }] }, { id: 1 }]);
});
});
+
+ it('should enter resize mode', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ // Select
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ // Enter resize mode
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+
+ expect(widget.hasAttribute('focused')).to.be.true;
+ expect(widget.hasAttribute('selected')).to.be.true;
+ expect(widget.hasAttribute('resize-mode')).to.be.true;
+ });
+
+ it('should enter resize mode without selecting first', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ (getResizeHandle(widget) as HTMLElement).click();
+ await nextFrame();
+
+ expect(widget.hasAttribute('focused')).to.be.true;
+ expect(widget.hasAttribute('selected')).to.be.true;
+ expect(widget.hasAttribute('resize-mode')).to.be.true;
+ });
+
+ describe('widget in resize mode', () => {
+ beforeEach(async () => {
+ // Select
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ // Enter resize mode
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ await nextFrame();
+ });
+
+ it('should exit resize mode on escape', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ await sendKeys({ press: 'Escape' });
+ expect(widget.hasAttribute('resize-mode')).to.be.false;
+ expect(widget.hasAttribute('selected')).to.be.true;
+ expect(widget.hasAttribute('focused')).to.be.true;
+ });
+
+ it('should exit resize mode on apply', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ // Apply button focused, click it
+ await sendKeys({ press: 'Space' });
+ expect(widget.hasAttribute('resize-mode')).to.be.false;
+ expect(widget.hasAttribute('selected')).to.be.true;
+ expect(widget.hasAttribute('focused')).to.be.true;
+ });
+
+ it('should focus resize handle on exit move mode', async () => {
+ // Apply button focused, click it
+ await sendKeys({ press: 'Space' });
+ expect(getResizeHandle(getElementFromCell(dashboard, 0, 0)!).matches(':focus')).to.be.true;
+ });
+
+ it('should increase the widget column span on grow width button click', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ // Focus forward button, click it
+ getResizeGrowWidthButton(widget).focus();
+ await sendKeys({ press: 'Space' });
+ expect((dashboard.items[0] as DashboardItem).colspan).to.equal(2);
+ });
+
+ it('should increase the widget row span on grow height button click', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+
+ // Set minimum row height to enable vertical resizing
+ setMinimumRowHeight(dashboard, 100);
+ await sendKeys({ press: 'Escape' });
+ await sendKeys({ press: 'Space' });
+ await nextFrame();
+
+ // Focus forward button, click it
+ getResizeGrowHeightButton(widget).focus();
+ await sendKeys({ press: 'Space' });
+ expect((dashboard.items[0] as DashboardItem).rowspan).to.equal(2);
+ });
+
+ it('should decrease the widget column span on shrink width button click', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ // Focus forward button, click it
+ getResizeGrowWidthButton(widget).focus();
+ await sendKeys({ press: 'Space' });
+ getResizeShrinkWidthButton(widget).focus();
+ await sendKeys({ press: 'Space' });
+ expect((dashboard.items[0] as DashboardItem).colspan).to.equal(1);
+ });
+
+ it('should decrease the widget row span on shrink height button click', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+
+ // Set minimum row height to enable vertical resizing
+ setMinimumRowHeight(dashboard, 100);
+ await sendKeys({ press: 'Escape' });
+ await sendKeys({ press: 'Space' });
+ await nextFrame();
+
+ // Focus forward button, click it
+ getResizeGrowHeightButton(widget).focus();
+ await sendKeys({ press: 'Space' });
+ getResizeShrinkHeightButton(widget).focus();
+ await sendKeys({ press: 'Space' });
+ expect((dashboard.items[0] as DashboardItem).rowspan).to.equal(1);
+ });
+
+ it('should deselect the widget on blur', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ const anotherWidget = getElementFromCell(dashboard, 0, 1)!;
+ anotherWidget.focus();
+ await nextFrame();
+ expect(widget.hasAttribute('resize-mode')).to.be.false;
+ expect(widget.hasAttribute('selected')).to.be.false;
+ expect(widget.hasAttribute('focused')).to.be.false;
+ });
+
+ it('should trap focus inside the resize mode', async () => {
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ const resizeModeButtons = [
+ getResizeGrowHeightButton(widget),
+ getResizeGrowWidthButton(widget),
+ getResizeShrinkHeightButton(widget),
+ getResizeShrinkWidthButton(widget),
+ getResizeApplyButton(widget),
+ ];
+ for (let i = 0; i < resizeModeButtons.length * 2; i++) {
+ await sendKeys({ press: 'Tab' });
+ expect(resizeModeButtons.includes(widget.shadowRoot!.activeElement as HTMLElement)).to.be.true;
+ }
+ });
+
+ it('should trap back inside the widget after exiting move mode', async () => {
+ await sendKeys({ press: 'Escape' });
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+ const resizeModeButtons = [
+ getResizeGrowHeightButton(widget),
+ getResizeGrowWidthButton(widget),
+ getResizeShrinkHeightButton(widget),
+ getResizeShrinkWidthButton(widget),
+ getResizeApplyButton(widget),
+ ];
+ for (let i = 0; i < 10; i++) {
+ await sendKeys({ press: 'Tab' });
+ expect(widget.contains(document.activeElement)).to.be.true;
+ expect(resizeModeButtons.includes(widget.shadowRoot!.activeElement as HTMLElement)).to.be.false;
+ }
+ });
+
+ it('should hide the grow/shrink height buttons if row min height is not defined', async () => {
+ await sendKeys({ press: 'Escape' });
+ setMinimumRowHeight(dashboard, undefined);
+ await sendKeys({ press: 'Space' });
+ const widget = getElementFromCell(dashboard, 0, 0)!;
+
+ expect(getComputedStyle(getResizeGrowHeightButton(widget)).display).to.equal('none');
+ expect(getComputedStyle(getResizeShrinkHeightButton(widget)).display).to.equal('none');
+ expect(getComputedStyle(getResizeGrowWidthButton(widget)).display).to.not.equal('none');
+ expect(getComputedStyle(getResizeShrinkWidthButton(widget)).display).to.not.equal('none');
+ });
+ });
});
diff --git a/packages/dashboard/test/helpers.ts b/packages/dashboard/test/helpers.ts
index 8eb6c40e8c..1a91c65eaa 100644
--- a/packages/dashboard/test/helpers.ts
+++ b/packages/dashboard/test/helpers.ts
@@ -312,3 +312,23 @@ export function getMoveBackwardButton(element: HTMLElement): HTMLElement {
export function getMoveApplyButton(element: HTMLElement): HTMLElement {
return element.shadowRoot!.querySelector('#move-apply') as HTMLElement;
}
+
+export function getResizeApplyButton(element: HTMLElement): HTMLElement {
+ return element.shadowRoot!.querySelector('#resize-apply') as HTMLElement;
+}
+
+export function getResizeShrinkWidthButton(element: HTMLElement): HTMLElement {
+ return element.shadowRoot!.querySelector('#resize-shrink-width') as HTMLElement;
+}
+
+export function getResizeGrowWidthButton(element: HTMLElement): HTMLElement {
+ return element.shadowRoot!.querySelector('#resize-grow-width') as HTMLElement;
+}
+
+export function getResizeShrinkHeightButton(element: HTMLElement): HTMLElement {
+ return element.shadowRoot!.querySelector('#resize-shrink-height') as HTMLElement;
+}
+
+export function getResizeGrowHeightButton(element: HTMLElement): HTMLElement {
+ return element.shadowRoot!.querySelector('#resize-grow-height') as HTMLElement;
+}