From 3dbd72a5d44fd38186f29148141116203bb44585 Mon Sep 17 00:00:00 2001 From: Kristjan Prints Date: Mon, 12 Oct 2026 03:05:38 +0300 Subject: [PATCH] feat(dock-panel): add internal resizing and simplify component and optional header description #961 --- .../dock-panel/dock-panel.module.scss | 275 +++++--- .../components/dock-panel/dock-panel.spec.tsx | 587 +++++++++++++++--- .../dock-panel/dock-panel.stories.tsx | 309 +++------ .../components/dock-panel/dock-panel.tsx | 377 ++++++----- 4 files changed, 960 insertions(+), 588 deletions(-) diff --git a/src/community/components/dock-panel/dock-panel.module.scss b/src/community/components/dock-panel/dock-panel.module.scss index 9f92b2ee3..fa245e36c 100644 --- a/src/community/components/dock-panel/dock-panel.module.scss +++ b/src/community/components/dock-panel/dock-panel.module.scss @@ -1,4 +1,5 @@ .tedi-dock-panel, +.tedi-dock-panel__resize-layout, .tedi-dock-panel-layout { --dock-panel-gap: var(--tedi-dimensions-04); --dock-panel-rail-size: calc(var(--button-md-height) + 2 * var(--dock-panel-gap) + 2px); @@ -30,54 +31,20 @@ border: 1px solid var(--general-border-primary); } -.tedi-dock-panel[data-size='small'] { - --dock-panel-padding: var(--modal-body-padding-sm); - --dock-panel-header-padding-x: var(--modal-heading-padding-x-sm); - --dock-panel-header-padding-y: var(--modal-heading-padding-y-sm); - --dock-panel-footer-padding-x: var(--modal-footer-padding-x-sm); - --dock-panel-footer-padding-y: var(--modal-footer-padding-y-sm); -} - -.tedi-dock-panel[data-placement='right'] { - top: 0; - right: 0; - width: var(--dock-panel-width); - height: var(--dock-panel-height); -} - -.tedi-dock-panel[data-placement='left'] { - top: 0; - left: 0; - width: var(--dock-panel-width); - height: var(--dock-panel-height); -} - -.tedi-dock-panel[data-placement='top'], -.tedi-dock-panel[data-placement='bottom'] { - --dock-panel-height: 50%; -} - -.tedi-dock-panel[data-placement='top'] { - top: 0; - right: 0; - left: 0; - height: var(--dock-panel-height); -} - -.tedi-dock-panel[data-placement='bottom'] { - right: 0; - bottom: 0; - left: 0; - height: var(--dock-panel-height); -} - .tedi-dock-panel__header { display: flex; flex: 0 0 auto; + flex-direction: column; + gap: var(--tedi-dimensions-05); + min-width: 0; + padding: var(--dock-panel-header-padding-y) var(--dock-panel-header-padding-x); +} + +.tedi-dock-panel__header-row { + display: flex; gap: var(--dock-panel-gap); align-items: center; min-width: 0; - padding: var(--dock-panel-header-padding-y) var(--dock-panel-header-padding-x); } .tedi-dock-panel__title { @@ -90,13 +57,11 @@ white-space: nowrap; } -.tedi-dock-panel__header-actions { - display: flex; - flex: 0 0 auto; - gap: var(--dock-panel-gap); - align-items: center; +.tedi-dock-panel__description { + color: var(--general-text-secondary); } +.tedi-dock-panel__header-actions, .tedi-dock-panel__controls { display: flex; flex: 0 0 auto; @@ -119,14 +84,7 @@ border-top: 1px solid var(--general-border-primary); } -/* Override flex declarations so hidden content does not remain interactive. */ -.tedi-dock-panel[hidden], -.tedi-dock-panel__body[hidden], -.tedi-dock-panel__header-actions[hidden] { - display: none; -} - -.tedi-dock-panel__content { +.tedi-dock-panel__scroll-area { flex: 1 1 auto; min-width: 0; min-height: 0; @@ -136,7 +94,7 @@ } /* Keep padding inside the scrollport so short panels cannot push the footer out of view. */ -.tedi-dock-panel__content-inner { +.tedi-dock-panel__content { min-width: 0; padding: var(--dock-panel-padding); } @@ -147,6 +105,101 @@ border-top: 1px solid var(--general-border-primary); } +.tedi-dock-panel__resize-measurement { + visibility: hidden; + pointer-events: none; +} + +.tedi-dock-panel__resize-layout { + position: absolute; + inset: 0; + z-index: var(--dock-panel-layer, 1); + display: flex; + min-width: 0; + height: 100%; + min-height: 0; + pointer-events: none; +} + +.tedi-dock-panel__resizer { + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + min-height: 0; + max-height: 100%; + pointer-events: auto; +} + +.tedi-dock-panel-layout { + position: relative; + box-sizing: border-box; + display: grid; + grid-template: + 'top top top' auto + 'left workspace right' minmax(0, 1fr) + 'bottom bottom bottom' auto / auto minmax(0, 1fr) auto; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + isolation: isolate; +} + +.tedi-dock-panel-layout__content { + position: relative; + z-index: 0; + grid-area: workspace; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.tedi-dock-panel[data-size='small'] { + --dock-panel-padding: var(--modal-body-padding-sm); + --dock-panel-header-padding-x: var(--modal-heading-padding-x-sm); + --dock-panel-header-padding-y: var(--modal-heading-padding-y-sm); + --dock-panel-footer-padding-x: var(--modal-footer-padding-x-sm); + --dock-panel-footer-padding-y: var(--modal-footer-padding-y-sm); +} + +.tedi-dock-panel[data-placement='left'], +.tedi-dock-panel[data-placement='right'] { + top: 0; + width: var(--dock-panel-width); + height: var(--dock-panel-height); +} + +.tedi-dock-panel[data-placement='left'] { + left: 0; +} + +.tedi-dock-panel[data-placement='right'] { + right: 0; +} + +.tedi-dock-panel[data-placement='top'], +.tedi-dock-panel[data-placement='bottom'] { + --dock-panel-height: 50%; + + right: 0; + left: 0; + height: var(--dock-panel-height); +} + +.tedi-dock-panel[data-placement='top'] { + top: 0; +} + +.tedi-dock-panel[data-placement='bottom'] { + bottom: 0; +} + +/* Override flex declarations so hidden content does not remain interactive. */ +.tedi-dock-panel__body[hidden], +.tedi-dock-panel__header-actions[hidden] { + display: none; +} + /* The minimized rail must fit its controls independently of expanded padding density. */ .tedi-dock-panel[data-collapsed='true'] .tedi-dock-panel__header { padding: var(--dock-panel-gap); @@ -156,12 +209,16 @@ .tedi-dock-panel[data-collapsed='true'][data-placement='right'] { width: var(--dock-panel-rail-size); - .tedi-dock-panel__header { + .tedi-dock-panel__header, + .tedi-dock-panel__header-row { flex: 1 1 auto; - flex-direction: column; min-height: 0; } + .tedi-dock-panel__header-row { + flex-direction: column; + } + .tedi-dock-panel__title { order: 2; text-align: center; @@ -170,7 +227,7 @@ } .tedi-dock-panel__controls { - flex-direction: column; + flex-direction: column-reverse; order: 1; } } @@ -180,49 +237,89 @@ height: var(--dock-panel-rail-size); } -.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'] { +.tedi-dock-panel[data-resizable='true'] { + height: 100%; + max-height: 100%; + pointer-events: auto; +} + +.tedi-dock-panel[data-resizable='true'][data-collapsed='false'] { + width: 100%; +} + +.tedi-dock-panel__resize-layout[data-placement='right'] { + justify-content: flex-end; +} + +.tedi-dock-panel__resize-layout[data-placement='top'] > .tedi-dock-panel__resizer, +.tedi-dock-panel__resize-layout[data-placement='bottom'] > .tedi-dock-panel__resizer { + flex: 1 1 0; +} + +.tedi-dock-panel__resize-layout[data-placement='top'] > .tedi-dock-panel__resizer { + align-self: flex-start; +} + +.tedi-dock-panel__resize-layout[data-placement='bottom'] { + top: auto; + + > .tedi-dock-panel__resizer { + align-self: flex-end; + } +} + +.tedi-dock-panel__resize-layout[data-collapsed='true'] { + overflow: clip; + + > .tedi-dock-panel__resizer { + flex-shrink: 0; + max-width: none; + max-height: none; + pointer-events: none; + } +} + +.tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='left'], +.tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='right'] { + width: var(--dock-panel-rail-size); +} + +.tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='top'], +.tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='bottom'] { + height: var(--dock-panel-rail-size); +} + +.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'], +.tedi-dock-panel-layout > .tedi-dock-panel__resize-layout[data-collapsed='true'] { position: relative; inset: auto; align-self: start; } -.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='left'] { - grid-area: 2 / 1; +.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='left'], +.tedi-dock-panel-layout > .tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='left'] { + grid-area: left; } -.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='right'] { - grid-area: 2 / 3; +.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='right'], +.tedi-dock-panel-layout > .tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='right'] { + grid-area: right; justify-self: end; } -.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='top'] { - grid-area: 1 / 1 / 2 / 4; +.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='top'], +.tedi-dock-panel-layout > .tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='top'], +.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='bottom'], +.tedi-dock-panel-layout > .tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='bottom'] { width: 100%; } -.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='bottom'] { - grid-area: 3 / 1 / 4 / 4; - width: 100%; +.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='top'], +.tedi-dock-panel-layout > .tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='top'] { + grid-area: top; } -.tedi-dock-panel-layout { - position: relative; - box-sizing: border-box; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - grid-template-columns: auto minmax(0, 1fr) auto; - width: 100%; - min-width: 0; - height: 100%; - min-height: 0; - isolation: isolate; -} - -.tedi-dock-panel-layout__content { - position: relative; - z-index: 0; - grid-area: 2 / 2; - min-width: 0; - min-height: 0; - overflow: hidden; +.tedi-dock-panel-layout > .tedi-dock-panel[data-collapsed='true'][data-placement='bottom'], +.tedi-dock-panel-layout > .tedi-dock-panel__resize-layout[data-collapsed='true'][data-placement='bottom'] { + grid-area: bottom; } diff --git a/src/community/components/dock-panel/dock-panel.spec.tsx b/src/community/components/dock-panel/dock-panel.spec.tsx index ec4d60736..1beee4b02 100644 --- a/src/community/components/dock-panel/dock-panel.spec.tsx +++ b/src/community/components/dock-panel/dock-panel.spec.tsx @@ -1,6 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef, useState } from 'react'; +import { useState } from 'react'; import { DockPanel, @@ -10,6 +10,8 @@ import { DockPanelPlacement, } from './dock-panel'; +jest.mock('react-sticky-box', () => ({ __esModule: true, default: () => null })); + jest.mock('../../../tedi/helpers', () => ({ ...jest.requireActual('../../../tedi/helpers'), useBreakpointProps: () => ({ @@ -31,7 +33,7 @@ Editor.displayName = 'DockPanelTestEditor'; describe('DockPanel', () => { it('renders an expanded, labelled, non-modal region by default', () => { - render( + const { container } = render( Content @@ -41,10 +43,30 @@ describe('DockPanel', () => { expect(panel).toHaveAttribute('data-placement', 'right'); expect(panel).toHaveAttribute('data-collapsed', 'false'); expect(panel).not.toHaveAttribute('aria-modal'); - expect(screen.getByRole('heading', { name: 'Details', level: 3 })).toBeInTheDocument(); + expect(panel).toHaveAttribute('aria-labelledby', screen.getByRole('heading', { name: 'Details', level: 3 }).id); expect(screen.getByRole('button', { name: labels.collapse })).toHaveAttribute('aria-expanded', 'true'); + expect(container.querySelector('.tedi-dock-panel__resizer')).not.toBeInTheDocument(); }); + it.each(['small', 'default'] as const)( + 'preserves the heading override with %s density when minimizing', + async (size) => { + const user = userEvent.setup(); + render( + + Content + + ); + expect(screen.getByRole('heading', { name: 'Details', level: 2 })).not.toHaveClass('text-small'); + + await user.click(screen.getByRole('button', { name: labels.collapse })); + expect(screen.getByRole('heading', { name: 'Details', level: 2 })).toHaveClass('text-small'); + + await user.click(screen.getByRole('button', { name: labels.expand })); + expect(screen.getByRole('heading', { name: 'Details', level: 2 })).not.toHaveClass('text-small'); + } + ); + it.each(['left', 'right', 'top', 'bottom'])('supports %s placement', (placement) => { render( @@ -147,6 +169,176 @@ describe('DockPanel', () => { expect(screen.getByText('Content')).not.toBeVisible(); }); + describe('minimizeCounter', () => { + it.each([undefined, 0, 5])('ignores the initial value %s', (minimizeCounter) => { + const onChange = jest.fn(); + render( + + Content + + ); + expect(screen.getByText('Content')).toBeVisible(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('minimizes on changes in either direction and restores with its content preserved', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const props = { title: 'Details', labels, onCollapsedChange: onChange }; + const { rerender } = render( + + + + ); + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, 'Unsaved changes'); + + rerender( + + + + ); + expect(input).toBeInTheDocument(); + expect(input).not.toBeVisible(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(true); + + await user.click(screen.getByRole('button', { name: labels.expand })); + expect(screen.getByRole('textbox')).toBe(input); + expect(input).toHaveValue('Unsaved changes'); + expect(onChange).toHaveBeenLastCalledWith(false); + + rerender( + + + + ); + expect(input).toBeVisible(); + expect(onChange).toHaveBeenCalledTimes(2); + + rerender( + + + + ); + expect(input).not.toBeVisible(); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenLastCalledWith(true); + }); + + it.each([ + [undefined, 0], + [0, undefined], + ])('treats a change from %s to %s as a minimize request', (initial, next) => { + const onChange = jest.fn(); + const props = { title: 'Details', labels, onCollapsedChange: onChange }; + const { rerender } = render( + + Content + + ); + rerender( + + Content + + ); + expect(screen.getByText('Content')).not.toBeVisible(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('consumes counter changes while already minimized without affecting restoration', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const props = { title: 'Details', labels, defaultCollapsed: true, onCollapsedChange: onChange }; + const { rerender } = render( + + Content + + ); + rerender( + + Content + + ); + expect(screen.getByText('Content')).not.toBeVisible(); + expect(onChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: labels.expand })); + expect(screen.getByText('Content')).toBeVisible(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('requests controlled minimization once and leaves state changes to the parent', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const props = { title: 'Details', labels, collapsed: false, onCollapsedChange: onChange }; + const { rerender } = render( + + Content + + ); + expect(onChange).not.toHaveBeenCalled(); + + rerender( + + Content + + ); + expect(screen.getByText('Content')).toBeVisible(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + + const onNextChange = jest.fn(); + const nextProps = { ...props, title: 'Updated title', onCollapsedChange: onNextChange }; + rerender( + + Content + + ); + expect(screen.getByText('Content')).toBeVisible(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onNextChange).not.toHaveBeenCalled(); + + rerender( + + Content + + ); + expect(screen.getByText('Content')).toBeVisible(); + expect(onNextChange).toHaveBeenCalledTimes(1); + expect(onNextChange).toHaveBeenCalledWith(true); + + rerender( + + Content + + ); + expect(screen.getByText('Content')).not.toBeVisible(); + rerender( + + Content + + ); + expect(onNextChange).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: labels.expand })); + expect(onNextChange).toHaveBeenCalledTimes(2); + expect(onNextChange).toHaveBeenLastCalledWith(false); + expect(screen.getByText('Content')).not.toBeVisible(); + + rerender( + + Content + + ); + expect(screen.getByText('Content')).toBeVisible(); + expect(onNextChange).toHaveBeenCalledTimes(2); + }); + }); + it('keeps keyboard focus on the toggle when minimized', async () => { const user = userEvent.setup(); render( @@ -162,19 +354,21 @@ describe('DockPanel', () => { expect(screen.getByRole('button', { name: labels.collapse })).toHaveAttribute('aria-expanded', 'true'); }); - it('moves focus out of content on a programmatic collapse', () => { + it('does not move focus to the toggle on a programmatic collapse', () => { const { rerender } = render( ); - act(() => screen.getByRole('textbox').focus()); + const input = screen.getByRole('textbox'); + act(() => input.focus()); rerender( ); - expect(screen.getByRole('button', { name: labels.expand })).toHaveFocus(); + expect(input).not.toBeVisible(); + expect(screen.getByRole('button', { name: labels.expand })).not.toHaveFocus(); }); it('does not steal focus from the application on a programmatic collapse', () => { @@ -193,24 +387,65 @@ describe('DockPanel', () => { expect(screen.getByRole('button', { name: 'Outside' })).toHaveFocus(); }); - it('hides header actions and the footer without unmounting them', async () => { + it.each([null, undefined, false, true])('omits optional slot wrappers for %s', (content) => { + const { container } = render( + + Content + + ); + expect(container.querySelector('.tedi-dock-panel__header-actions')).not.toBeInTheDocument(); + expect(container.querySelector('.tedi-dock-panel__footer')).not.toBeInTheDocument(); + }); + + it('renders numeric zero in both optional slots', () => { + const { container } = render( + + Content + + ); + expect(container.querySelector('.tedi-dock-panel__header-actions')).toHaveTextContent('0'); + expect(container.querySelector('.tedi-dock-panel__footer')).toHaveTextContent('0'); + }); + + it.each([undefined, ''])('omits the description when it is %s', (description) => { + const { container } = render( + + Content + + ); + expect(container.querySelector('.tedi-dock-panel__description')).not.toBeInTheDocument(); + }); + + it('hides the description, header actions and footer without unmounting them', async () => { const user = userEvent.setup(); render( Header action} footer={} > Content ); + const description = screen.getByText('Supporting information'); const headerAction = screen.getByRole('button', { name: 'Header action' }); const footerAction = screen.getByRole('button', { name: 'Footer action' }); + expect(description).toBeVisible(); await user.click(screen.getByRole('button', { name: labels.collapse })); + expect(description).toBeInTheDocument(); + expect(description).not.toBeVisible(); expect(headerAction).toBeInTheDocument(); expect(headerAction).not.toBeVisible(); + expect(footerAction).toBeInTheDocument(); expect(footerAction).not.toBeVisible(); + + await user.click(screen.getByRole('button', { name: labels.expand })); + expect(screen.getByText('Supporting information')).toBe(description); + expect(description).toBeVisible(); + expect(screen.getByRole('button', { name: 'Header action' })).toBe(headerAction); + expect(screen.getByRole('button', { name: 'Footer action' })).toBe(footerAction); }); it('skips minimized content when tabbing', async () => { @@ -229,34 +464,29 @@ describe('DockPanel', () => { expect(screen.getByRole('button', { name: 'After panel' })).toHaveFocus(); }); - it('does not restore focus after the user explicitly blurred the editor', () => { - const { rerender } = render( - - - - ); - const input = screen.getByRole('textbox'); - act(() => input.focus()); - act(() => input.blur()); - rerender( - - - - ); - expect(screen.getByRole('button', { name: labels.expand })).not.toHaveFocus(); - }); - - it('delegates closing to the parent', async () => { + it.each([false, true])('delegates closing without changing collapsed=%s', async (defaultCollapsed) => { const user = userEvent.setup(); const onClose = jest.fn(); + const onCollapsedChange = jest.fn(); render( - + Content ); await user.click(screen.getByRole('button', { name: labels.close })); expect(onClose).toHaveBeenCalledTimes(1); + expect(onCollapsedChange).not.toHaveBeenCalled(); expect(screen.getByRole('region')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: defaultCollapsed ? labels.expand : labels.collapse })).toHaveAttribute( + 'aria-expanded', + String(!defaultCollapsed) + ); }); it('does not render a close button unless requested', () => { @@ -286,21 +516,20 @@ describe('DockPanel', () => { ids.forEach((id) => expect(document.getElementById(id ?? '')).toBeInTheDocument()); }); - it('forwards the ref and accepts per-instance dimensions', () => { - const ref = createRef(); + it('accepts an explicit id and per-instance dimensions', () => { render( - + Content ); - expect(ref.current).toBe(screen.getByRole('region')); - expect(ref.current).toHaveAttribute('id', 'details'); - expect(ref.current?.style.getPropertyValue('--dock-panel-width')).toBe('420px'); - expect(ref.current?.style.getPropertyValue('--dock-panel-height')).toBe('60%'); + const panel = screen.getByRole('region'); + expect(panel).toHaveAttribute('id', 'details'); + expect(panel.style.getPropertyValue('--dock-panel-width')).toBe('420px'); + expect(panel.style.getPropertyValue('--dock-panel-height')).toBe('60%'); }); it.each(['left', 'right', 'top', 'bottom'])( - 'accepts content height and a height limit for %s placement without leaking props to the DOM', + 'sets content height and a height limit for %s placement', (placement) => { render( @@ -310,8 +539,6 @@ describe('DockPanel', () => { const panel = screen.getByRole('region'); expect(panel.style.getPropertyValue('--dock-panel-height')).toBe('auto'); expect(panel.style.getPropertyValue('--dock-panel-max-height')).toBe('calc(100% - 4rem)'); - expect(panel).not.toHaveAttribute('height'); - expect(panel).not.toHaveAttribute('maxHeight'); } ); @@ -374,74 +601,201 @@ describe('DockPanel', () => { expect(panel.style.getPropertyValue('--dock-panel-max-height')).toBe(''); }); - describe.each(['left', 'right', 'top', 'bottom'])('%s controls', (placement) => { - it.each([false, true])('keeps toggle and close together when collapsed=%s', (defaultCollapsed) => { + it.each([false, true])( + 'keeps toggle and Close consecutive in the keyboard order when collapsed=%s', + async (defaultCollapsed) => { + const user = userEvent.setup(); render( - Custom action} - > - Content - + <> + Custom action} + > + + + + ); - const toggle = screen.getByRole('button', { name: defaultCollapsed ? labels.expand : labels.collapse }); - const close = screen.getByRole('button', { name: labels.close }); - const controls = toggle.closest('.tedi-dock-panel__controls'); + await user.tab(); + if (!defaultCollapsed) { + expect(screen.getByRole('button', { name: 'Custom action' })).toHaveFocus(); + await user.tab(); + } + expect(screen.getByRole('button', { name: defaultCollapsed ? labels.expand : labels.collapse })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole('button', { name: labels.close })).toHaveFocus(); + await user.tab(); + if (!defaultCollapsed) { + expect(screen.getByRole('textbox')).toHaveFocus(); + await user.tab(); + } + expect(screen.getByRole('button', { name: 'After panel' })).toHaveFocus(); + } + ); + + describe('resizing', () => { + const measurePanel = () => ({ + width: 500, + height: 400, + top: 0, + left: 0, + right: 500, + bottom: 400, + x: 0, + y: 0, + toJSON: () => ({}), + }); - // The structural assertion prevents a title or custom action separating these controls. - expect(controls).not.toBeNull(); - expect(controls).toContainElement(close); - expect(controls).not.toContainElement(screen.getByRole('heading', { name: 'Details' })); - expect(controls).not.toContainElement(screen.getByText('Custom action')); - expect(close).toBeVisible(); + beforeEach(() => { + const workspace = document.createElement('div'); + Object.defineProperties(workspace, { + clientWidth: { value: 1000 }, + clientHeight: { value: 800 }, + }); + const getBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + return this.classList.contains('tedi-dock-panel__resize-measurement') + ? measurePanel() + : getBoundingClientRect.call(this); + }); + jest.spyOn(HTMLElement.prototype, 'offsetParent', 'get').mockReturnValue(workspace); }); - it('can close while minimized without restoring first', async () => { + afterEach(() => jest.restoreAllMocks()); + + it.each([ + ['left', 200, 100, '600px', 'auto'], + ['right', 0, 100, '600px', 'auto'], + ['top', 100, 200, '500px', '500px'], + ['bottom', 100, 0, '500px', '500px'], + ] as const)('resizes %s placement from its workspace-facing edge', async (placement, x, y, width, height) => { const user = userEvent.setup(); - const onClose = jest.fn(); - const onCollapsedChange = jest.fn(); - render( - + const { container } = render( + Content ); + const resizer = container.querySelector('.tedi-dock-panel__resizer') as HTMLElement; + const handle = resizer.querySelector('[data-show-indicator]') as HTMLElement; + expect(resizer.style.width).toBe('500px'); + expect(resizer.style.height).toBe(placement === 'left' || placement === 'right' ? 'auto' : '400px'); - await user.click(screen.getByRole('button', { name: labels.close })); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onCollapsedChange).not.toHaveBeenCalled(); - expect(screen.getByRole('region')).toHaveAttribute('data-collapsed', 'true'); + await user.pointer([ + { target: handle, keys: '[MouseLeft>]', coords: { x: 100, y: 100 } }, + { coords: { x, y } }, + { keys: '[/MouseLeft]' }, + ]); + expect(resizer.style.width).toBe(width); + expect(resizer.style.height).toBe(height); }); - }); - it('keeps restore and close consecutive in the minimized keyboard order', async () => { - const user = userEvent.setup(); - render( - <> - - - - - + it.each(['right', 'bottom'] as const)( + 'starts minimized at %s and restores the measured size', + async (placement) => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const { container } = render( + + Content + + ); + expect(screen.getByText('Content')).not.toBeVisible(); + expect(onChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: labels.expand })); + expect(screen.getByText('Content')).toBeVisible(); + expect(container.querySelector('.tedi-dock-panel__resizer')).toHaveStyle({ + width: '500px', + height: placement === 'right' ? 'auto' : '400px', + }); + } ); - await user.tab(); - expect(screen.getByRole('button', { name: labels.expand })).toHaveFocus(); - await user.tab(); - expect(screen.getByRole('button', { name: labels.close })).toHaveFocus(); - await user.tab(); - expect(screen.getByRole('button', { name: 'After panel' })).toHaveFocus(); + it.each(['right', 'bottom'] as const)( + 'keeps %s resizing between zero and the measured workspace size', + async (placement) => { + const user = userEvent.setup(); + const { container } = render( + + Content + + ); + const resizer = container.querySelector('.tedi-dock-panel__resizer') as HTMLElement; + const handle = resizer.querySelector('[data-show-indicator]') as HTMLElement; + const dimension = placement === 'right' ? 'width' : 'height'; + + await user.pointer([ + { target: handle, keys: '[MouseLeft>]', coords: { x: 100, y: 100 } }, + { coords: { x: -2000, y: -2000 } }, + { keys: '[/MouseLeft]' }, + ]); + expect(resizer.style[dimension]).toBe(placement === 'right' ? '1000px' : '800px'); + + await user.pointer([ + { target: handle, keys: '[MouseLeft>]', coords: { x: 100, y: 100 } }, + { coords: { x: 2100, y: 2100 } }, + { keys: '[/MouseLeft]' }, + ]); + expect(resizer.style[dimension]).toBe('0px'); + } + ); + + it.each(['right', 'bottom'] as const)( + 'preserves %s resized dimensions and content through minimize/restore and rerenders', + async (placement) => { + const user = userEvent.setup(); + const props = { title: 'Details', labels, placement, resizable: true }; + const { container, rerender } = render( + + + + ); + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, 'Keep resized changes'); + const resizer = container.querySelector('.tedi-dock-panel__resizer') as HTMLElement; + const handle = resizer.querySelector('[data-show-indicator]') as HTMLElement; + await user.pointer([ + { target: handle, keys: '[MouseLeft>]', coords: { x: 100, y: 100 } }, + { coords: { x: 50, y: 50 } }, + { keys: '[/MouseLeft]' }, + ]); + const dimensions = { + width: placement === 'right' ? '550px' : '500px', + height: placement === 'right' ? 'auto' : '450px', + }; + expect(resizer).toHaveStyle(dimensions); + + await user.click(screen.getByRole('button', { name: labels.collapse })); + expect(input).toBeInTheDocument(); + expect(input).not.toBeVisible(); + expect(container.querySelector('.tedi-dock-panel__resizer')).toHaveStyle(dimensions); + + await user.click(screen.getByRole('button', { name: labels.expand })); + rerender( + + + + ); + expect(screen.getByRole('textbox')).toBe(input); + expect(input).toHaveValue('Keep resized changes'); + expect(input).toBeVisible(); + expect(container.querySelector('.tedi-dock-panel__resizer')).toHaveStyle(dimensions); + } + ); }); describe('density and dimension presets', () => { @@ -453,7 +807,6 @@ describe('DockPanel', () => { ); const panel = screen.getByRole('region'); expect(panel).toHaveAttribute('data-size', 'default'); - expect(panel).not.toHaveAttribute('size'); }); it.each(['small', 'default'] as const)('supports %s density independently of dimensions', (size) => { @@ -464,7 +817,7 @@ describe('DockPanel', () => { ); const panel = screen.getByRole('region'); expect(panel).toHaveAttribute('data-size', size); - expect(panel).not.toHaveAttribute('size'); + expect(screen.getByRole('heading', { name: 'Details', level: size === 'small' ? 4 : 3 })).toBeInTheDocument(); expect(panel.style.getPropertyValue('--dock-panel-width')).toBe('420px'); expect(panel.style.getPropertyValue('--dock-panel-height')).toBe('70%'); }); @@ -484,7 +837,6 @@ describe('DockPanel', () => { ); const panel = screen.getByRole('region'); expect(panel.style.getPropertyValue('--dock-panel-width')).toBe(expected); - expect(panel).not.toHaveAttribute('width'); }); }); @@ -535,7 +887,6 @@ describe('DockPanel', () => { ); const panel = screen.getByRole('region'); expect(panel.style.getPropertyValue('--dock-panel-height')).toBe(expected); - expect(panel).not.toHaveAttribute('height'); }); it.each(['left', 'right', 'top', 'bottom'])( @@ -562,6 +913,47 @@ describe('DockPanel', () => { expect(panel.style.getPropertyValue('--dock-panel-max-height')).toBe('90%'); }); + it('converts zero dimensions to pixels', () => { + render( + + Content + + ); + const panel = screen.getByRole('region'); + expect(panel.style.getPropertyValue('--dock-panel-width')).toBe('0px'); + expect(panel.style.getPropertyValue('--dock-panel-height')).toBe('0px'); + expect(panel.style.getPropertyValue('--dock-panel-max-height')).toBe('0px'); + }); + + it('overrides style dimensions only while dimension props are supplied', () => { + const style = { + color: 'red', + '--dock-panel-width': '10rem', + '--dock-panel-height': '20rem', + '--dock-panel-max-height': '30rem', + }; + const { rerender } = render( + + Content + + ); + const panel = screen.getByRole('region'); + expect(panel.style.getPropertyValue('--dock-panel-width')).toBe('35%'); + expect(panel.style.getPropertyValue('--dock-panel-height')).toBe('360px'); + expect(panel.style.getPropertyValue('--dock-panel-max-height')).toBe('90%'); + expect(panel.style.color).toBe('red'); + + rerender( + + Content + + ); + expect(panel.style.getPropertyValue('--dock-panel-width')).toBe('10rem'); + expect(panel.style.getPropertyValue('--dock-panel-height')).toBe('20rem'); + expect(panel.style.getPropertyValue('--dock-panel-max-height')).toBe('30rem'); + expect(panel.style.color).toBe('red'); + }); + it('updates presets and density without resetting child state', async () => { const user = userEvent.setup(); const { rerender } = render( @@ -660,10 +1052,8 @@ describe('DockPanel revised controls and composition', () => { describe('DockPanelLayout', () => { it('puts the panel and workspace in separate sibling elements', () => { - const ref = createRef(); - render( + const { container } = render( Panel content @@ -675,9 +1065,8 @@ describe('DockPanelLayout', () => { ); const content = screen.getByText('Workspace content').parentElement; const panel = screen.getByRole('region', { name: 'Details' }); - expect(content).toHaveAttribute('data-dock-panel-layout-content'); - expect(content?.parentElement).toBe(ref.current); - expect(panel.parentElement).toBe(ref.current); + expect(content?.parentElement).toBe(container.firstChild); + expect(panel.parentElement).toBe(container.firstChild); expect(content).not.toContainElement(panel); }); diff --git a/src/community/components/dock-panel/dock-panel.stories.tsx b/src/community/components/dock-panel/dock-panel.stories.tsx index ca2148997..0303a05fa 100644 --- a/src/community/components/dock-panel/dock-panel.stories.tsx +++ b/src/community/components/dock-panel/dock-panel.stories.tsx @@ -5,15 +5,7 @@ import { useArgs } from 'storybook/preview-api'; import { Button } from '../../../tedi/components/buttons/button/button'; import { TextField } from '../../../tedi/components/form/textfield/textfield'; import { Col, Row } from '../../../tedi/components/layout/grid'; -import { Resizer } from '../map-components/resizer/resizer'; -import { - DockPanel, - DockPanelDimensionPreset, - DockPanelLabels, - DockPanelLayout, - DockPanelPlacement, - DockPanelProps, -} from './dock-panel'; +import { DockPanel, DockPanelDimensionPreset, DockPanelLabels, DockPanelLayout, DockPanelProps } from './dock-panel'; const labels: DockPanelLabels = { collapse: 'Minimize panel', @@ -77,7 +69,6 @@ const Workspace = ({ children, height = '36rem' }: { children: ReactNode; height const [count, setCount] = useState(0); return (