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`
+ + + + + +
`; + } + 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; +}