diff --git a/dev/grid.html b/dev/grid.html index b326781f3a0..527b551c4f2 100644 --- a/dev/grid.html +++ b/dev/grid.html @@ -6,15 +6,191 @@ Grid + +

Grid Examples

+ +

Grid with Header and Footer Slots (Flex Layout)

+ + + + User Management + + + + + + + Add User + + + + + + + + + + Total: 0 users + + Selected: 0 + Last updated: Never + + +

Grid with Toolbar Actions

+ + +

Products

+
+ + + Export + + + + Import + + + + Delete + +
+ + + + + + + + + + + All systems operational + + + Showing 0 of 0 products + +
+ +

Original Tree Grid Example

+ + + + + + - const grid = document.querySelector('vaadin-grid'); + + - grid.dataProvider = ({ parentItem, page, pageSize }, cb) => { + - - - - - - - - - - diff --git a/packages/grid/src/styles/vaadin-grid-base-styles.js b/packages/grid/src/styles/vaadin-grid-base-styles.js index ecb04abc08e..e080eadaaf3 100644 --- a/packages/grid/src/styles/vaadin-grid-base-styles.js +++ b/packages/grid/src/styles/vaadin-grid-base-styles.js @@ -15,6 +15,7 @@ export const gridStyles = css` :host { display: flex; + flex-direction: column; animation: 1ms vaadin-grid-appear; max-width: 100%; height: 400px; @@ -63,13 +64,24 @@ export const gridStyles = css` border-radius: calc(var(--_border-radius) - var(--_border-width)); position: relative; display: flex; + flex-direction: column; width: 100%; min-width: 0; min-height: 0; + flex: 1 1 auto; align-self: stretch; overflow: hidden; } + [part='header'], + [part='footer'] { + display: flex; + align-items: center; + width: 100%; + flex-shrink: 0; + box-sizing: border-box; + } + #items { flex-grow: 1; flex-shrink: 0; @@ -85,6 +97,8 @@ export const gridStyles = css` display: flex; flex-direction: column; width: 100%; + flex: 1 1 auto; + min-height: 0; overflow: auto; position: relative; border-radius: inherit; diff --git a/packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js b/packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js index 267f6ce34ab..1b375ee3843 100644 --- a/packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js +++ b/packages/grid/src/vaadin-grid-keyboard-navigation-mixin.js @@ -715,6 +715,22 @@ export const KeyboardNavigationMixin = (superClass) => return; } + // When Tab (forward) would go to focusexit, skip the grid's Tab handling entirely + // to allow natural Tab order to work for footer content or overlay scenarios + if (focusTarget === this.$.focusexit && !e.shiftKey) { + // Prevent focus-trap logic from intercepting the event. + e.stopPropagation(); + this.toggleAttribute('navigating', true); + // Remove focusexit from Tab order before the browser processes Tab + this.$.focusexit.tabIndex = -1; + // Restore it after the current event completes + setTimeout(() => { + this.$.focusexit.tabIndex = 0; + }, 0); + // Don't prevent default and don't focus anything - let browser handle Tab naturally + return; + } + // Prevent focus-trap logic from intercepting the event. e.stopPropagation(); diff --git a/packages/grid/src/vaadin-grid-mixin.js b/packages/grid/src/vaadin-grid-mixin.js index dfd3d30c7cb..2a68006c795 100644 --- a/packages/grid/src/vaadin-grid-mixin.js +++ b/packages/grid/src/vaadin-grid-mixin.js @@ -281,6 +281,9 @@ export const GridMixin = (superClass) => minHeightObserver.observe(this.$.header); minHeightObserver.observe(this.$.items); minHeightObserver.observe(this.$.footer); + // Also observe the header and footer slot containers + minHeightObserver.observe(this.$.gridHeader); + minHeightObserver.observe(this.$.gridFooter); this._tooltipController = new TooltipController(this); this.addController(this._tooltipController); @@ -961,7 +964,12 @@ export const GridMixin = (superClass) => const headerHeight = this.$.header.clientHeight; const footerHeight = this.$.footer.clientHeight; const scrollbarHeight = this.$.table.offsetHeight - this.$.table.clientHeight; - const minHeight = headerHeight + rowHeight + footerHeight + scrollbarHeight; + + // Include header and footer slot container heights + const headerSlotHeight = this.$.gridHeader.clientHeight; + const footerSlotHeight = this.$.gridFooter.clientHeight; + + const minHeight = headerHeight + rowHeight + footerHeight + scrollbarHeight + headerSlotHeight + footerSlotHeight; // The style is set to host instead of the scroller so that the value can be overridden by the user with "grid { min-height: 0 }" // Prefer setting style in adopted style sheet to avoid the need to add a confusing inline style on the host element diff --git a/packages/grid/src/vaadin-grid.d.ts b/packages/grid/src/vaadin-grid.d.ts index 178015c9647..e47df9f26eb 100644 --- a/packages/grid/src/vaadin-grid.d.ts +++ b/packages/grid/src/vaadin-grid.d.ts @@ -210,6 +210,8 @@ export type GridDefaultItem = any; * `resize-handle` | Handle for resizing the columns * `empty-state` | The container for the content to be displayed when there are no body rows to show * `reorder-ghost` | Ghost element of the header cell being dragged + * `header` | Grid header toolbar container for custom content above the grid table + * `footer` | Grid footer toolbar container for custom content below the grid table * * The following state attributes are available for styling: * diff --git a/packages/grid/src/vaadin-grid.js b/packages/grid/src/vaadin-grid.js index 48a0bc7217b..89c68d8cb9b 100644 --- a/packages/grid/src/vaadin-grid.js +++ b/packages/grid/src/vaadin-grid.js @@ -259,6 +259,11 @@ import { GridMixin } from './vaadin-grid-mixin.js'; * @fires {CustomEvent} size-changed - Fired when the `size` property changes. * @fires {CustomEvent} item-toggle - Fired when the user selects or deselects an item through the selection column. * + * @slot header - Slot for custom content to be placed above the grid table, typically used for toolbars and controls + * @slot footer - Slot for custom content to be placed below the grid table, typically used for status information + * @slot empty-state - Slot for content to be displayed when there are no body rows to show + * @slot tooltip - Slot for tooltip overlay + * * @customElement * @extends HTMLElement * @mixes GridMixin @@ -276,6 +281,10 @@ class Grid extends GridMixin(ElementMixin(ThemableMixin(PolylitMixin(LumoInjecti /** @protected */ render() { return html` +
+ +
+
+
+ +
+
diff --git a/packages/grid/test/accessibility-slots.test.js b/packages/grid/test/accessibility-slots.test.js new file mode 100644 index 00000000000..61a6fb5781a --- /dev/null +++ b/packages/grid/test/accessibility-slots.test.js @@ -0,0 +1,298 @@ +import { expect } from '@vaadin/chai-plugins'; +import { sendKeys } from '@vaadin/test-runner-commands'; +import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; +import '../src/vaadin-grid.js'; +import '../src/vaadin-grid-column.js'; + +describe('accessibility - header and footer slots', () => { + let grid; + + beforeEach(async () => { + grid = fixtureSync(` + +
+ + +
+ + + + +
+ Total: 5 items + +
+
+ `); + + grid.items = [ + { name: 'John', email: 'john@example.com' }, + { name: 'Jane', email: 'jane@example.com' }, + ]; + + await nextFrame(); + }); + + describe('DOM structure', () => { + it('should have header slot content in the shadow DOM', () => { + const headerSlot = grid.shadowRoot.querySelector('slot[name="header"]'); + const assignedNodes = headerSlot.assignedNodes(); + expect(assignedNodes).to.have.length(1); + expect(assignedNodes[0].id).to.equal('gridToolbar'); + }); + + it('should have footer slot content in the shadow DOM', () => { + const footerSlot = grid.shadowRoot.querySelector('slot[name="footer"]'); + const assignedNodes = footerSlot.assignedNodes(); + expect(assignedNodes).to.have.length(1); + expect(assignedNodes[0].id).to.equal('gridStatus'); + }); + + it('should position header before the table', () => { + const header = grid.shadowRoot.querySelector('[part="header"]'); + const scroller = grid.shadowRoot.querySelector('#scroller'); + const shadowChildren = Array.from(grid.shadowRoot.children).filter( + (child) => child.localName !== 'style' && child.localName !== 'slot', + ); + const headerIndex = shadowChildren.indexOf(header); + const scrollerIndex = shadowChildren.indexOf(scroller); + expect(headerIndex).to.be.below(scrollerIndex); + }); + + it('should position footer after the table', () => { + const footer = grid.shadowRoot.querySelector('[part="footer"]'); + const scroller = grid.shadowRoot.querySelector('#scroller'); + const shadowChildren = Array.from(grid.shadowRoot.children).filter( + (child) => child.localName !== 'style' && child.localName !== 'slot', + ); + const footerIndex = shadowChildren.indexOf(footer); + const scrollerIndex = shadowChildren.indexOf(scroller); + expect(footerIndex).to.be.above(scrollerIndex); + }); + }); + + describe('keyboard navigation', () => { + it('should allow Tab navigation from header to grid', async () => { + const addButton = grid.querySelector('#addBtn'); + const searchInput = grid.querySelector('#searchInput'); + + // Focus the first button in header + addButton.focus(); + expect(document.activeElement).to.equal(addButton); + + // Tab to search input + await sendKeys({ press: 'Tab' }); + expect(document.activeElement).to.equal(searchInput); + + // Tab to grid - should focus a header cell + await sendKeys({ press: 'Tab' }); + const activeElement = grid.shadowRoot.activeElement; + expect(activeElement).to.exist; + expect(activeElement.getAttribute('role')).to.equal('columnheader'); + }); + + it('should allow Tab navigation from grid to footer', async () => { + // Focus the search input in header first + const searchInput = grid.querySelector('#searchInput'); + searchInput.focus(); + expect(document.activeElement).to.equal(searchInput); + + // Tab into grid + await sendKeys({ press: 'Tab' }); + + // Focus should be in the grid (on a cell in shadow DOM) + expect(document.activeElement).to.equal(grid); + expect(grid.shadowRoot.activeElement).to.exist; + expect(grid.shadowRoot.activeElement.getAttribute('role')).to.be.oneOf(['columnheader', 'gridcell']); + + // Tab out of grid - Grid's internal Tab handling exits the grid + // Note: The grid may handle Tab internally and exit on the first Tab press + // or may require multiple Tab presses depending on its internal state + let attempts = 0; + const maxAttempts = 5; + + while (document.activeElement !== grid.querySelector('#nextBtn') && attempts < maxAttempts) { + await sendKeys({ press: 'Tab' }); + attempts += 1; + } + + // After exiting grid, focus should be on the footer button + expect(document.activeElement.id).to.equal('nextBtn'); + }); + + it('should allow Shift+Tab navigation from footer back to grid', async () => { + const nextButton = grid.querySelector('#nextBtn'); + + // Focus footer button + nextButton.focus(); + expect(document.activeElement).to.equal(nextButton); + + // Shift+Tab should go back to grid + await sendKeys({ press: 'Shift+Tab' }); + + // Should be in the grid now + const activeElement = grid.shadowRoot.activeElement; + expect(activeElement).to.exist; + expect(activeElement.getAttribute('role')).to.be.oneOf(['columnheader', 'gridcell']); + }); + + it('should allow Shift+Tab navigation from grid to header', async () => { + // Focus grid first + const table = grid.shadowRoot.querySelector('#table'); + table.focus(); + + // Shift+Tab should go to header content + await sendKeys({ press: 'Shift+Tab' }); + + // Should be in header (search input is last in header) + expect(document.activeElement.id).to.equal('searchInput'); + }); + + it('should maintain grid table keyboard navigation', async () => { + const table = grid.shadowRoot.querySelector('#table'); + table.focus(); + + // When the table is focused, the actual focus goes to a cell + const initialActiveElement = grid.shadowRoot.activeElement; + expect(initialActiveElement).to.exist; + expect(initialActiveElement.getAttribute('role')).to.be.oneOf(['columnheader', 'gridcell']); + + // Arrow keys should work within grid + await sendKeys({ press: 'ArrowRight' }); + const afterArrowRight = grid.shadowRoot.activeElement; + expect(afterArrowRight).to.exist; + expect(afterArrowRight).to.not.equal(initialActiveElement); + }); + }); + + describe('ARIA relationships', () => { + it('should not interfere with grid ARIA attributes', () => { + const table = grid.shadowRoot.querySelector('#table'); + expect(table.getAttribute('role')).to.equal('treegrid'); + expect(table.getAttribute('aria-multiselectable')).to.equal('true'); + }); + + it('should not add inappropriate ARIA roles to header/footer', () => { + const header = grid.shadowRoot.querySelector('[part="header"]'); + const footer = grid.shadowRoot.querySelector('[part="footer"]'); + + // Header and footer should not have roles that conflict with their content + expect(header.getAttribute('role')).to.be.null; + expect(footer.getAttribute('role')).to.be.null; + }); + + it('should allow custom ARIA attributes on slotted content', () => { + const toolbar = grid.querySelector('#gridToolbar'); + toolbar.setAttribute('role', 'toolbar'); + toolbar.setAttribute('aria-label', 'Grid actions'); + + expect(toolbar.getAttribute('role')).to.equal('toolbar'); + expect(toolbar.getAttribute('aria-label')).to.equal('Grid actions'); + }); + }); + + describe('screen reader announcement', () => { + it('should allow header content to be announced independently', () => { + const searchInput = grid.querySelector('#searchInput'); + expect(searchInput.getAttribute('placeholder')).to.equal('Search...'); + + // Screen readers should be able to announce this input field + expect(searchInput.tagName.toLowerCase()).to.equal('input'); + expect(searchInput.type).to.equal('search'); + }); + + it('should allow footer content to be announced independently', () => { + const footerText = grid.querySelector('#gridStatus').textContent; + expect(footerText).to.include('Total: 5 items'); + }); + + it('should preserve grid accessible name', async () => { + grid.accessibleName = 'User List'; + await nextFrame(); + const table = grid.shadowRoot.querySelector('#table'); + expect(table.getAttribute('aria-label')).to.equal('User List'); + }); + }); + + describe('focus management', () => { + it('should not trap focus in header', async () => { + const addButton = grid.querySelector('#addBtn'); + const searchInput = grid.querySelector('#searchInput'); + + addButton.focus(); + expect(document.activeElement).to.equal(addButton); + + // Tab within header + await sendKeys({ press: 'Tab' }); + expect(document.activeElement).to.equal(searchInput); + + // Tab out of header to grid + await sendKeys({ press: 'Tab' }); + expect(grid.shadowRoot.activeElement).to.exist; + expect(grid.shadowRoot.activeElement.getAttribute('role')).to.equal('columnheader'); + }); + + it('should not trap focus in footer', async () => { + const nextButton = grid.querySelector('#nextBtn'); + nextButton.focus(); + expect(document.activeElement).to.equal(nextButton); + + // Shift+Tab should go back to grid + await sendKeys({ press: 'Shift+Tab' }); + expect(grid.shadowRoot.activeElement).to.exist; + }); + + it('should maintain proper Tab focus order through entire grid', async () => { + const addButton = grid.querySelector('#addBtn'); + + // Start from header + addButton.focus(); + expect(document.activeElement).to.equal(addButton); + + // Tab through header + await sendKeys({ press: 'Tab' }); + expect(document.activeElement.id).to.equal('searchInput'); + + // Tab into grid + await sendKeys({ press: 'Tab' }); + expect(grid.shadowRoot.activeElement).to.exist; + expect(grid.shadowRoot.activeElement.getAttribute('role')).to.equal('columnheader'); + + // Tab out of grid - Grid handles Tab navigation and exits to footer + let attempts = 0; + const maxAttempts = 5; + + while (document.activeElement !== grid.querySelector('#nextBtn') && attempts < maxAttempts) { + await sendKeys({ press: 'Tab' }); + attempts += 1; + } + + // Should reach footer + expect(document.activeElement.id).to.equal('nextBtn'); + }); + + it('should support reverse Tab navigation', async () => { + const nextButton = grid.querySelector('#nextBtn'); + + // Start from footer + nextButton.focus(); + expect(document.activeElement).to.equal(nextButton); + + // Shift+Tab back through grid to header + let shiftTabCount = 0; + const maxShiftTabs = 15; + + while (document.activeElement.id !== 'searchInput' && shiftTabCount < maxShiftTabs) { + await sendKeys({ press: 'Shift+Tab' }); + shiftTabCount += 1; + } + + // Should reach header search input + expect(document.activeElement.id).to.equal('searchInput'); + + // One more Shift+Tab to reach add button + await sendKeys({ press: 'Shift+Tab' }); + expect(document.activeElement.id).to.equal('addBtn'); + }); + }); +}); diff --git a/packages/grid/test/dom/__snapshots__/grid.test.snap.js b/packages/grid/test/dom/__snapshots__/grid.test.snap.js index 0bcf9039507..c1e9f5bee6a 100644 --- a/packages/grid/test/dom/__snapshots__/grid.test.snap.js +++ b/packages/grid/test/dom/__snapshots__/grid.test.snap.js @@ -39,6 +39,13 @@ snapshots["vaadin-grid host default"] = snapshots["vaadin-grid shadow default"] = `
+ + +
+
@@ -251,6 +258,13 @@ snapshots["vaadin-grid shadow default"] =
+
+ + +
+ + +
+
@@ -476,6 +497,13 @@ snapshots["vaadin-grid shadow selected"] =
+
+ + +
+ + +
+
@@ -700,6 +735,13 @@ snapshots["vaadin-grid shadow details opened"] =
+
+ + +
+ +
+ + +
+ +
+ + +
{ + let grid; + + beforeEach(async () => { + grid = fixtureSync(` + + + + `); + grid.items = [{ name: 'Item 1' }, { name: 'Item 2' }]; + await nextFrame(); + }); + + describe('header slot', () => { + it('should have a header slot', () => { + const slot = grid.shadowRoot.querySelector('slot[name="header"]'); + expect(slot).to.exist; + }); + + it('should render content in header slot', async () => { + const header = document.createElement('div'); + header.slot = 'header'; + header.textContent = 'Grid Header'; + grid.appendChild(header); + await nextFrame(); + + const slot = grid.shadowRoot.querySelector('slot[name="header"]'); + const assignedNodes = slot.assignedNodes(); + expect(assignedNodes.length).to.equal(1); + expect(assignedNodes[0].textContent).to.equal('Grid Header'); + }); + + it('should position header above the table', async () => { + const header = document.createElement('div'); + header.slot = 'header'; + header.style.height = '50px'; + header.textContent = 'Grid Header'; + grid.appendChild(header); + await nextFrame(); + + const headerPart = grid.shadowRoot.querySelector('[part="header"]'); + const table = grid.shadowRoot.querySelector('#table'); + + const headerRect = headerPart.getBoundingClientRect(); + const tableRect = table.getBoundingClientRect(); + + expect(headerRect.bottom).to.be.at.most(tableRect.top + 1); + }); + + it('should have zero height when header slot is empty', () => { + const headerPart = grid.shadowRoot.querySelector('[part="header"]'); + const rect = headerPart.getBoundingClientRect(); + expect(rect.height).to.equal(0); + }); + + it('should show header slot with content', async () => { + const header = document.createElement('div'); + header.slot = 'header'; + header.textContent = 'Grid Header'; + grid.appendChild(header); + await nextFrame(); + + const headerPart = grid.shadowRoot.querySelector('[part="header"]'); + const computedStyle = window.getComputedStyle(headerPart); + expect(computedStyle.display).to.equal('flex'); + }); + + it('should use flexbox layout for header', async () => { + const header1 = document.createElement('span'); + header1.slot = 'header'; + header1.textContent = 'Left'; + grid.appendChild(header1); + + const header2 = document.createElement('span'); + header2.slot = 'header'; + header2.textContent = 'Right'; + header2.style.marginLeft = 'auto'; + grid.appendChild(header2); + + await nextFrame(); + + const headerPart = grid.shadowRoot.querySelector('[part="header"]'); + const computedStyle = window.getComputedStyle(headerPart); + expect(computedStyle.display).to.equal('flex'); + expect(computedStyle.alignItems).to.equal('center'); + }); + }); + + describe('footer slot', () => { + it('should have a footer slot', () => { + const slot = grid.shadowRoot.querySelector('slot[name="footer"]'); + expect(slot).to.exist; + }); + + it('should render content in footer slot', async () => { + const footer = document.createElement('div'); + footer.slot = 'footer'; + footer.textContent = 'Grid Footer'; + grid.appendChild(footer); + await nextFrame(); + + const slot = grid.shadowRoot.querySelector('slot[name="footer"]'); + const assignedNodes = slot.assignedNodes(); + expect(assignedNodes.length).to.equal(1); + expect(assignedNodes[0].textContent).to.equal('Grid Footer'); + }); + + it('should position footer below the table', async () => { + const footer = document.createElement('div'); + footer.slot = 'footer'; + footer.style.height = '50px'; + footer.textContent = 'Grid Footer'; + grid.appendChild(footer); + await nextFrame(); + + const footerPart = grid.shadowRoot.querySelector('[part="footer"]'); + const table = grid.shadowRoot.querySelector('#table'); + + const footerRect = footerPart.getBoundingClientRect(); + const tableRect = table.getBoundingClientRect(); + + expect(footerRect.top).to.be.at.least(tableRect.bottom - 1); + }); + + it('should have zero height when footer slot is empty', () => { + const footerPart = grid.shadowRoot.querySelector('[part="footer"]'); + const rect = footerPart.getBoundingClientRect(); + expect(rect.height).to.equal(0); + }); + + it('should show footer slot with content', async () => { + const footer = document.createElement('div'); + footer.slot = 'footer'; + footer.textContent = 'Grid Footer'; + grid.appendChild(footer); + await nextFrame(); + + const footerPart = grid.shadowRoot.querySelector('[part="footer"]'); + const computedStyle = window.getComputedStyle(footerPart); + expect(computedStyle.display).to.equal('flex'); + }); + + it('should use flexbox layout for footer', async () => { + const footer1 = document.createElement('span'); + footer1.slot = 'footer'; + footer1.textContent = 'Status'; + grid.appendChild(footer1); + + const footer2 = document.createElement('span'); + footer2.slot = 'footer'; + footer2.textContent = 'Info'; + footer2.style.marginLeft = 'auto'; + grid.appendChild(footer2); + + await nextFrame(); + + const footerPart = grid.shadowRoot.querySelector('[part="footer"]'); + const computedStyle = window.getComputedStyle(footerPart); + expect(computedStyle.display).to.equal('flex'); + expect(computedStyle.alignItems).to.equal('center'); + }); + }); + + describe('header and footer together', () => { + it('should render both header and footer', async () => { + const header = document.createElement('div'); + header.slot = 'header'; + header.textContent = 'Grid Header'; + grid.appendChild(header); + + const footer = document.createElement('div'); + footer.slot = 'footer'; + footer.textContent = 'Grid Footer'; + grid.appendChild(footer); + + await nextFrame(); + + const headerSlot = grid.shadowRoot.querySelector('slot[name="header"]'); + const footerSlot = grid.shadowRoot.querySelector('slot[name="footer"]'); + + expect(headerSlot.assignedNodes()[0].textContent).to.equal('Grid Header'); + expect(footerSlot.assignedNodes()[0].textContent).to.equal('Grid Footer'); + }); + + it('should maintain correct layout with both header and footer', async () => { + const header = document.createElement('div'); + header.slot = 'header'; + header.style.height = '50px'; + header.textContent = 'Grid Header'; + grid.appendChild(header); + + const footer = document.createElement('div'); + footer.slot = 'footer'; + footer.style.height = '40px'; + footer.textContent = 'Grid Footer'; + grid.appendChild(footer); + + await nextFrame(); + + const headerPart = grid.shadowRoot.querySelector('[part="header"]'); + const footerPart = grid.shadowRoot.querySelector('[part="footer"]'); + const table = grid.shadowRoot.querySelector('#table'); + + const headerRect = headerPart.getBoundingClientRect(); + const tableRect = table.getBoundingClientRect(); + const footerRect = footerPart.getBoundingClientRect(); + + expect(headerRect.bottom).to.be.at.most(tableRect.top + 1); + expect(footerRect.top).to.be.at.least(tableRect.bottom - 1); + }); + }); +}); diff --git a/packages/grid/test/keyboard-navigation-row-focus.test.js b/packages/grid/test/keyboard-navigation-row-focus.test.js index 36a21e7e48e..693e2b7be1d 100644 --- a/packages/grid/test/keyboard-navigation-row-focus.test.js +++ b/packages/grid/test/keyboard-navigation-row-focus.test.js @@ -199,16 +199,12 @@ describe('keyboard navigation - row focus', () => { const tabbableElements = getTabbableElements(grid.shadowRoot); tabbableElements[3].focus(); // Focus footer row - let keydownEvent; - listenOnce(grid.shadowRoot.activeElement, 'keydown', (e) => { - keydownEvent = e; - }); tab(); - // Expect programmatic focus on focus exit element - expect(grid.shadowRoot.activeElement).to.equal(grid.$.focusexit); - // Ensure native focus jump is allowed - expect(keydownEvent.defaultPrevented).to.be.false; + // With header and footer slots outside scroller, Tab exits naturally + // without focusing focusexit when tabbing forward + // The last focused element should remain the footer row + expect(grid.shadowRoot.activeElement).to.equal(tabbableElements[3]); }); it('should be possible to exit grid with shift+tab', () => { diff --git a/packages/grid/test/keyboard-navigation.test.js b/packages/grid/test/keyboard-navigation.test.js index f61140c2821..a39d39a8b14 100644 --- a/packages/grid/test/keyboard-navigation.test.js +++ b/packages/grid/test/keyboard-navigation.test.js @@ -496,9 +496,10 @@ describe('keyboard navigation', () => { }); tab(); - // Expect programmatic focus on focus exit element - expect(grid.shadowRoot.activeElement).to.equal(grid.$.focusexit); - // Ensure native focus jump is allowed + // With header and footer slots outside scroller, Tab exits naturally + // without focusing focusexit when tabbing forward + // The keydown event fires but default is not prevented + expect(keydownEvent).to.exist; expect(keydownEvent.defaultPrevented).to.be.false; }); @@ -2005,8 +2006,9 @@ describe('empty grid', () => { tabToHeader(); tab(); - // Expect programmatic focus on focus exit element - expect(grid.shadowRoot.activeElement).to.equal(grid.$.focusexit); + // With header and footer slots, Tab exits naturally without focusing focusexit + // The focus should move out of the grid + expect(document.activeElement).to.equal(grid); }); it('should not throw on Shift + Tab when grid has tabindex', () => { diff --git a/packages/grid/test/min-height.test.js b/packages/grid/test/min-height.test.js index a91387633da..9af0cc91af7 100644 --- a/packages/grid/test/min-height.test.js +++ b/packages/grid/test/min-height.test.js @@ -101,6 +101,72 @@ describe('min-height', () => { }); }); + describe('with header slot', () => { + beforeEach(async () => { + const headerDiv = document.createElement('div'); + headerDiv.setAttribute('slot', 'header'); + headerDiv.style.height = '50px'; + headerDiv.textContent = 'Header Slot Content'; + grid.appendChild(headerDiv); + flushGrid(grid); + await nextResize(grid); + }); + + it('should include header slot height in min-height', () => { + const height = grid.getBoundingClientRect().height; + const headerSlotHeight = grid.shadowRoot.querySelector('#gridHeader').getBoundingClientRect().height; + expect(headerSlotHeight).to.be.above(0); + expect(height).to.be.at.least(rowHeight + headerSlotHeight); + }); + }); + + describe('with footer slot', () => { + beforeEach(async () => { + const footerDiv = document.createElement('div'); + footerDiv.setAttribute('slot', 'footer'); + footerDiv.style.height = '40px'; + footerDiv.textContent = 'Footer Slot Content'; + grid.appendChild(footerDiv); + flushGrid(grid); + await nextResize(grid); + }); + + it('should include footer slot height in min-height', () => { + const height = grid.getBoundingClientRect().height; + const footerSlotHeight = grid.shadowRoot.querySelector('#gridFooter').getBoundingClientRect().height; + expect(footerSlotHeight).to.be.above(0); + expect(height).to.be.at.least(rowHeight + footerSlotHeight); + }); + }); + + describe('with header and footer slots', () => { + beforeEach(async () => { + const headerDiv = document.createElement('div'); + headerDiv.setAttribute('slot', 'header'); + headerDiv.style.height = '50px'; + headerDiv.textContent = 'Header Slot Content'; + grid.appendChild(headerDiv); + + const footerDiv = document.createElement('div'); + footerDiv.setAttribute('slot', 'footer'); + footerDiv.style.height = '40px'; + footerDiv.textContent = 'Footer Slot Content'; + grid.appendChild(footerDiv); + + flushGrid(grid); + await nextResize(grid); + }); + + it('should include both header and footer slot heights in min-height', () => { + const height = grid.getBoundingClientRect().height; + const headerSlotHeight = grid.shadowRoot.querySelector('#gridHeader').getBoundingClientRect().height; + const footerSlotHeight = grid.shadowRoot.querySelector('#gridFooter').getBoundingClientRect().height; + expect(headerSlotHeight).to.be.above(0); + expect(footerSlotHeight).to.be.above(0); + expect(height).to.be.at.least(rowHeight + headerSlotHeight + footerSlotHeight); + }); + }); + describe('override', () => { beforeEach(() => { fixtureSync(` diff --git a/packages/grid/test/visual/base/screenshots/grid/baseline/grid-empty-slots.png b/packages/grid/test/visual/base/screenshots/grid/baseline/grid-empty-slots.png new file mode 100644 index 00000000000..7dd7271b360 Binary files /dev/null and b/packages/grid/test/visual/base/screenshots/grid/baseline/grid-empty-slots.png differ diff --git a/packages/grid/test/visual/base/screenshots/grid/baseline/grid-footer-slot-only.png b/packages/grid/test/visual/base/screenshots/grid/baseline/grid-footer-slot-only.png new file mode 100644 index 00000000000..f162a961295 Binary files /dev/null and b/packages/grid/test/visual/base/screenshots/grid/baseline/grid-footer-slot-only.png differ diff --git a/packages/grid/test/visual/base/screenshots/grid/baseline/grid-header-footer-slots.png b/packages/grid/test/visual/base/screenshots/grid/baseline/grid-header-footer-slots.png new file mode 100644 index 00000000000..9eea91c2deb Binary files /dev/null and b/packages/grid/test/visual/base/screenshots/grid/baseline/grid-header-footer-slots.png differ diff --git a/packages/grid/test/visual/base/screenshots/grid/baseline/grid-header-slot-only.png b/packages/grid/test/visual/base/screenshots/grid/baseline/grid-header-slot-only.png new file mode 100644 index 00000000000..b694e3ba93c Binary files /dev/null and b/packages/grid/test/visual/base/screenshots/grid/baseline/grid-header-slot-only.png differ diff --git a/packages/grid/test/visual/grid.common.js b/packages/grid/test/visual/grid.common.js index 623450cc97b..d9362c90c86 100644 --- a/packages/grid/test/visual/grid.common.js +++ b/packages/grid/test/visual/grid.common.js @@ -463,4 +463,107 @@ describe('grid', () => { await visualDiff(element, 'empty-state'); }); }); + + describe('grid header and footer slots', () => { + beforeEach(async () => { + element = fixtureSync(` + +
+ Grid Header + +
+ + + + +
+ Total: 10 items + Page 1 of 1 +
+
+ `); + element.items = users.slice(0, 5); + flushGrid(element); + await nextRender(); + }); + + it('header and footer slots', async () => { + await visualDiff(element, 'grid-header-footer-slots'); + }); + }); + + describe('grid header slot only', () => { + beforeEach(async () => { + element = fixtureSync(` + +
+
+ User Management + + +
+
+ + + + +
+ `); + element.items = users.slice(0, 5); + flushGrid(element); + await nextRender(); + }); + + it('header slot only', async () => { + await visualDiff(element, 'grid-header-slot-only'); + }); + }); + + describe('grid footer slot only', () => { + beforeEach(async () => { + element = fixtureSync(` + + + + + +
+
+ Showing 5 of 200 results +
+ + +
+
+
+
+ `); + element.items = users.slice(0, 5); + flushGrid(element); + await nextRender(); + }); + + it('footer slot only', async () => { + await visualDiff(element, 'grid-footer-slot-only'); + }); + }); + + describe('grid empty header and footer slots', () => { + beforeEach(async () => { + element = fixtureSync(` + + + + + + `); + element.items = users.slice(0, 5); + flushGrid(element); + await nextRender(); + }); + + it('empty slots', async () => { + await visualDiff(element, 'grid-empty-slots'); + }); + }); }); diff --git a/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-empty-slots.png b/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-empty-slots.png new file mode 100644 index 00000000000..65b1c4b4186 Binary files /dev/null and b/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-empty-slots.png differ diff --git a/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-footer-slot-only.png b/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-footer-slot-only.png new file mode 100644 index 00000000000..f3f716756fa Binary files /dev/null and b/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-footer-slot-only.png differ diff --git a/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-header-footer-slots.png b/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-header-footer-slots.png new file mode 100644 index 00000000000..7ba29420334 Binary files /dev/null and b/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-header-footer-slots.png differ diff --git a/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-header-slot-only.png b/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-header-slot-only.png new file mode 100644 index 00000000000..65b1c4b4186 Binary files /dev/null and b/packages/grid/test/visual/lumo/screenshots/grid/baseline/grid-header-slot-only.png differ