diff --git a/docs/examples/register-widget.md b/docs/examples/register-widget.md index b8c73a40..f93eb014 100644 --- a/docs/examples/register-widget.md +++ b/docs/examples/register-widget.md @@ -325,11 +325,21 @@ All sizes are pixels, passed to `openstation_register_widget()`: | `max_height` | Optional ceiling on user-driven resize | | `default_width` | Starting width when first added as a floating widget | | `default_height` | Starting height when first added as a floating widget | +| `fit_content` | Grow past the user's height when the content needs it (see below) | `movable: true` lets the user drag the widget off the column. `resizable: true` adds resize handles — `movable: true` gives all 8 corner and edge handles; column-docked widgets get a bottom-edge handle only. +`fit_content: true` lets the card grow past the user's height when its +content needs the room, for a widget that adds controls after mounting +(a form that reveals a Save row, a list that loads more items). The card +grows up to `max_height` and never past the work-area bottom, shrinks back +as the content does, and never goes below the user's height. Only the +user's height is saved, never the grown one; it applies to floating cards +and to docked cards the user resized. A user who drags the card shorter +than its content sees it grow back to fit on release. + --- ## Theme tokens diff --git a/docs/javascript-reference.md b/docs/javascript-reference.md index b0ee8ded..3e4a2de7 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -5189,6 +5189,7 @@ wp.os.registerWidget( { | `minWidth`, `minHeight` | `number` | Lower bounds enforced during user resize (px). | | `maxWidth`, `maxHeight` | `number` | Upper bounds enforced during user resize (px). | | `defaultWidth`, `defaultHeight` | `number` | Initial floating size — used the first time the widget is liberated. | +| `fitContent` | `boolean` | Let the card grow taller than the user's height when its content needs the room, so controls a widget adds after the size was saved stay visible instead of scrolling inside the body. Applies to floating cards and to docked cards with a resized height. Grows up to `maxHeight` and never past the work-area bottom, shrinks back as the content does, never goes below the user's height, and never changes width or position. | ```js wp.os.registerWidget( { @@ -5217,6 +5218,8 @@ User-placed geometry (position + size of liberated widgets) persists per-user in Floating widgets follow the desktop when it changes size (a resized browser or PWA window, the dock moved to a side). Cards that touch (within 32 px) form one arrangement, and each arrangement keeps its distance to the edge it sits nearest to on each axis: a group beside the right-hand column follows the right edge, a group on the left stays, a roughly centred group stays centred. Cards never move relative to their own group. The area size the geometry was saved against lives under `openstation-widgets-geometry-frame`, so a session opened at a different size reflows on boot too. +A `fitContent` card's grown height is never stored: both records keep the height the user chose, and the card grows from it on every load. A user resize sets that height anew, so a `fitContent` card dragged shorter than its content grows back to fit on release. + ##### `wp.os.widgets.redock( id )` — Stable Programmatically un-float a liberated widget back into the right-side column. Idempotent — already-docked widgets and unknown ids silently no-op. Mirrors what the user gets by clicking the re-dock affordance in the floating widget's chrome header. diff --git a/includes/registries/widgets.php b/includes/registries/widgets.php index 0d6ad18e..91171401 100644 --- a/includes/registries/widgets.php +++ b/includes/registries/widgets.php @@ -74,6 +74,14 @@ * @type int $max_height * @type int $default_width First-mount floating width. * @type int $default_height First-mount floating height. + * @type bool $fit_content Let the card grow taller than the + * user's saved height when its + * content needs the room, up to + * `max_height` and the work-area + * bottom. Applies to floating cards + * and to docked cards with a resized + * height. The grown height is never + * saved. Default false. * @type string[] $capabilities Gate: ALL caps must match. Any * missed cap returns * `WP_Error openstation_capability_denied`. @@ -102,6 +110,7 @@ function openstation_register_widget( $id, $args = array() ) { 'max_height' => 0, 'default_width' => 0, 'default_height' => 0, + 'fit_content' => false, 'capabilities' => array(), ); $args = wp_parse_args( $args, $defaults ); @@ -148,6 +157,7 @@ function openstation_register_widget( $id, $args = array() ) { 'max_height' => (int) $args['max_height'], 'default_width' => (int) $args['default_width'], 'default_height' => (int) $args['default_height'], + 'fit_content' => (bool) $args['fit_content'], ); openstation_desktop_widget_registry( $id, $entry ); @@ -216,6 +226,7 @@ function openstation_build_desktop_widgets_payload() { 'maxHeight' => $entry['max_height'], 'defaultWidth' => $entry['default_width'], 'defaultHeight' => $entry['default_height'], + 'fitContent' => $entry['fit_content'], 'scriptUrl' => $script_payload['url'], // The packages this widget declares, in load order. // WordPress resolves a script's dependencies when it diff --git a/includes/widgets/widget-starter.php b/includes/widgets/widget-starter.php index b1b8d727..3c13fe24 100644 --- a/includes/widgets/widget-starter.php +++ b/includes/widgets/widget-starter.php @@ -154,6 +154,8 @@ function openstation_enqueue_starter_widget_styles() { * max_height int Optional ceiling on user-driven height resize (px). * default_width int Starting width when first added as a floating widget. * default_height int Starting height when first added as a floating widget. + * fit_content bool true = the card grows to fit content that outgrows the + * user's height, up to max_height. Default false. * capabilities array Optional. ALL listed capabilities must be held by the * current user or the widget will not register for them. * e.g. array( 'edit_posts', 'publish_posts' ) diff --git a/src/types.ts b/src/types.ts index c65ad45a..9669222e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1195,6 +1195,8 @@ export interface DesktopWidgetServerEntry { maxHeight: number; defaultWidth: number; defaultHeight: number; + /** Let the card grow to fit its content. See `WidgetDef.fitContent`. */ + fitContent?: boolean; /** Absolute URL of the plugin's enqueued script. Empty when no script was declared. */ scriptUrl: string; /** WordPress script handle (informational). */ diff --git a/src/widgets/fit-content.ts b/src/widgets/fit-content.ts new file mode 100644 index 00000000..205c6cf9 --- /dev/null +++ b/src/widgets/fit-content.ts @@ -0,0 +1,151 @@ +/** + * OpenStation: widget "fit content" height. + * + * A card whose def sets `fitContent` grows past the user's height + * when its content needs the room, so a control a widget adds after + * the size was saved shows instead of scrolling out of reach inside + * the body. Two heights are kept apart here: + * + * - the FLOOR is the user's height: the saved geometry height of a + * floating card, or the resized height of a docked one. It is the + * only height the frame ever hands to persistence. + * - the drawn height is the floor grown to fit the content, capped + * at `maxHeight` and at the work-area bottom. It lives in the + * card's inline style and nowhere else. + * + * A floor of `null` means the card sizes naturally (a docked card the + * user never resized), and nothing is written. + */ + +import { subscribeWorkArea, workAreaRectOf } from '../work-area'; +import type { WidgetDef } from './types'; + +export interface FitContentOptions { + card: HTMLElement; + body: HTMLElement; + def: WidgetDef; + /** The desktop area; the work area is measured against it. */ + parent: HTMLElement; + /** The user's height at mount, or `null` for a natural height. */ + floor: number | null; + /** Air kept between the card and the work-area bottom. */ + margin: number; + isFloating(): boolean; + /** True mid-drag or mid-resize, when the pointer owns the size. */ + isBusy(): boolean; +} + +export interface FitContent { + /** The user's height, or `null` while the card sizes naturally. */ + floor(): number | null; + /** Record a new user height. Does not refit; call `refit()`. */ + setFloor( height: number | null ): void; + /** Measure the content and write the drawn height. */ + refit(): void; + dispose(): void; +} + +export function attachFitContent( opts: FitContentOptions ): FitContent { + const { card, body, def, parent } = opts; + let floor = opts.floor; + let wasFloating = opts.isFloating(); + let frame = 0; + let mutations: MutationObserver | null = null; + + // The work-area bottom, with the same air the drag clamp keeps. + const cap = (): number => { + const area = workAreaRectOf( parent ); + const top = + card.getBoundingClientRect().top - parent.getBoundingClientRect().top; + return Math.min( + def.maxHeight ?? Infinity, + area.y + area.height - top - opts.margin, + ); + }; + + const refit = (): void => { + // Liberated or re-docked (by the chrome or by + // `wp.os.widgetLayer.redock()`): the height just written for + // the new state is the user's. + const floating = opts.isFloating(); + if ( floating !== wasFloating ) { + wasFloating = floating; + floor = parseFloat( card.style.height ) || null; + } + if ( floor === null || opts.isBusy() ) { + return; + } + // Measure at the floor: once the content fits, `scrollHeight` + // equals `clientHeight`, so a grown card could never tell how + // much it may shrink. Both writes land before the next paint. + card.style.height = `${ floor }px`; + const overflow = body.scrollHeight - body.clientHeight; + const target = + overflow > 0 ? Math.max( floor, Math.min( floor + overflow, cap() ) ) : floor; + card.style.height = `${ target }px`; + // Our own style writes are not news, but a child that arrived + // in the same batch still needs watching. + if ( mutations ) { + watchChildren( mutations.takeRecords() ); + } + }; + + // A ResizeObserver callback that resizes what it observes ends in + // a "loop completed with undelivered notifications" error, so it + // refits on the next frame instead. + const schedule = (): void => { + if ( ! frame ) { + frame = requestAnimationFrame( () => { + frame = 0; + refit(); + } ); + } + }; + + // The body keeps its box while its content grows (it scrolls), so + // the content is watched through the body's children instead. + // `null` where ResizeObserver is missing (jsdom). + const resizes = + typeof ResizeObserver === 'function' ? new ResizeObserver( schedule ) : null; + const watchChildren = ( records?: MutationRecord[] ): void => { + if ( records && ! records.some( ( r ) => r.target === body ) ) { + return; + } + resizes?.disconnect(); + for ( const child of Array.from( body.children ) ) { + resizes?.observe( child ); + } + }; + + if ( typeof MutationObserver === 'function' ) { + mutations = new MutationObserver( ( records ) => { + watchChildren( records ); + refit(); + } ); + } + // Body children come and go; card style and class writes are the + // layer re-placing the card (reclamp, redock) or a gesture ending. + mutations?.observe( body, { childList: true } ); + mutations?.observe( card, { + attributes: true, + attributeFilter: [ 'style', 'class' ], + } ); + watchChildren(); + const offWorkArea = subscribeWorkArea( refit ); + + return { + floor: () => floor, + setFloor: ( height ) => { + floor = height; + }, + refit, + dispose: () => { + mutations?.disconnect(); + resizes?.disconnect(); + if ( frame ) { + cancelAnimationFrame( frame ); + } + offWorkArea(); + }, + }; +} diff --git a/src/widgets/frame.ts b/src/widgets/frame.ts index 1967fa7e..531db670 100644 --- a/src/widgets/frame.ts +++ b/src/widgets/frame.ts @@ -20,6 +20,8 @@ import { __, sprintf } from '../i18n'; import { osIconSvg } from '../ui/icons'; import { workAreaRectOf } from '../work-area'; +import { contentBox } from './content-box'; +import { attachFitContent, type FitContent } from './fit-content'; import type { WidgetDef, WidgetGeometry } from './types'; const FLOATING_CLASS = 'os-widgets__card--floating'; @@ -170,7 +172,9 @@ export function buildFrame( // current parent bounds so a stale entry (smaller screen, an old // bug's leftovers) can never mount the card off-screen where the // user has no way to grab it back. + let userHeight: number | null = null; if ( ctx.geometry ) { + userHeight = ctx.geometry.height; applyGeometry( card, clampGeometryToParent( ctx.geometry, ctx.floatingParent ), @@ -180,7 +184,8 @@ export function buildFrame( // Docked card with a persisted height resize — re-apply it, // clamped to the def's current limits in case the widget's // min/max changed between sessions. - card.style.height = `${ clampDockedHeight( ctx.dockedHeight, def ) }px`; + userHeight = clampDockedHeight( ctx.dockedHeight, def ); + card.style.height = `${ userHeight }px`; } /** @@ -194,6 +199,22 @@ export function buildFrame( const isFloating = (): boolean => card.classList.contains( FLOATING_CLASS ); + const fit: FitContent | null = + def.fitContent === true + ? attachFitContent( { + card, + body, + def, + parent: ctx.floatingParent, + floor: userHeight, + margin: VIEWPORT_MARGIN, + isFloating, + isBusy: () => + card.classList.contains( DRAGGING_CLASS ) || + card.classList.contains( RESIZING_CLASS ), + } ) + : null; + // Resize handles — always built for resizable widgets, but only // the ones that match the movable state are visible (CSS hides // non-matching dirs). We attach listeners to every handle @@ -207,7 +228,7 @@ export function buildFrame( handle.dataset.dir = dir; card.appendChild( handle ); resizeCleanups.push( - attachResize( card, handle, dir, def, ctx, handlers, isFloating ), + attachResize( card, handle, dir, def, ctx, handlers, isFloating, fit ), ); } } @@ -222,7 +243,7 @@ export function buildFrame( '.os-widgets__chrome', ); if ( chrome ) { - dragCleanup = attachDrag( card, chrome, def, ctx, handlers ); + dragCleanup = attachDrag( card, chrome, def, ctx, handlers, fit ); } } @@ -230,6 +251,7 @@ export function buildFrame( card, body, dispose: () => { + fit?.dispose(); for ( const fn of resizeCleanups ) { try { fn(); @@ -361,6 +383,7 @@ function attachDrag( def: WidgetDef, ctx: FrameContext, handlers: FrameHandlers, + fit: FitContent | null, ): () => void { let pointerId: number | null = null; let startX = 0; @@ -418,6 +441,7 @@ function attachDrag( if ( ! card.classList.contains( FLOATING_CLASS ) ) { const parentRect = ctx.floatingParent.getBoundingClientRect(); const rect = card.getBoundingClientRect(); + const box = contentBox( card ); // Preserve the CURRENT on-screen size when liberating — // fall back to the registered defaults only when the // card hasn't been laid out yet (rect.width / height === @@ -429,12 +453,17 @@ function attachDrag( // leaving an empty band where the heart used to be. What // the user sees in the column is what they should keep // when floating. + // A fit-content card that grew past its docked height + // takes the user's height with it, not the grown one. const initial: WidgetGeometry = { x: rect.left - parentRect.left, y: rect.top - parentRect.top, - width: rect.width || def.defaultWidth || DEFAULT_WIDTH, - height: rect.height || def.defaultHeight || DEFAULT_HEIGHT, + width: rect.width ? box.width : def.defaultWidth || DEFAULT_WIDTH, + height: + fit?.floor() ?? + ( rect.height ? box.height : def.defaultHeight || DEFAULT_HEIGHT ), }; + fit?.setFloor( initial.height ); applyGeometry( card, initial ); card.classList.add( FLOATING_CLASS ); handlers.onLiberate( initial ); @@ -502,7 +531,7 @@ function attachDrag( } committed = false; card.classList.remove( DRAGGING_CLASS ); - handlers.onGeometryChanged( currentGeometry( card ) ); + handlers.onGeometryChanged( currentGeometry( card, fit ) ); }; chrome.addEventListener( 'pointerdown', onDown ); @@ -530,8 +559,11 @@ function attachResize( ctx: FrameContext, handlers: FrameHandlers, isFloating: () => boolean, + fit: FitContent | null, ): () => void { let pointerId: number | null = null; + /** A press on a handle that never moved is not a resize. */ + let resized = false; let startX = 0; let startY = 0; let startLeft = 0; @@ -561,8 +593,12 @@ function attachResize( const parentRect = ctx.floatingParent.getBoundingClientRect(); startLeft = rect.left - parentRect.left; startTop = rect.top - parentRect.top; - startW = rect.width; - startH = rect.height; + // The size the inline style takes, so a resize that ends where + // it started writes back what was there. + const box = contentBox( card ); + startW = box.width; + startH = box.height; + resized = false; handle.setPointerCapture( pointerId ); card.classList.add( RESIZING_CLASS ); }; @@ -595,6 +631,11 @@ function attachResize( card.style.width = `${ next.width }px`; } card.style.height = `${ next.height }px`; + // Only a vertical pull draws a new height; an `e` / `w` edge + // (or a corner moved sideways) carries the grown height along. + if ( /[ns]/.test( dir ) && dy !== 0 ) { + resized = true; + } }; const onUp = ( e: PointerEvent ): void => { @@ -608,14 +649,22 @@ function attachResize( } pointerId = null; card.classList.remove( RESIZING_CLASS ); + // The height the user just drew is their new floor; the fit + // grows from it, if the content needs more, after this. + if ( resized && fit ) { + fit.setFloor( parseFloat( card.style.height ) || card.offsetHeight ); + } // Floating and docked resizes persist through DIFFERENT // channels: a geometry record's presence is what marks a // widget as floating on the next boot, so a docked height // resize must never write one — it persists height alone. if ( isFloating() ) { - handlers.onGeometryChanged( currentGeometry( card ) ); + handlers.onGeometryChanged( currentGeometry( card, fit ) ); } else { - handlers.onDockedHeightChanged( card.offsetHeight ); + handlers.onDockedHeightChanged( + fit?.floor() ?? + ( parseFloat( card.style.height ) || contentBox( card ).height ), + ); } }; @@ -654,12 +703,26 @@ export function applyGeometry( card.style.height = `${ geometry.height }px`; } -function currentGeometry( card: HTMLElement ): WidgetGeometry { +/** + * The geometry to persist. A fit-content card reports the user's + * height (its floor), never the height it grew to for its content, + * so storage only ever holds a size the user chose. + * + * Sizes are content-box, the box `applyGeometry` writes: the card is + * `box-sizing: content-box` with a border, so its border box is + * larger than its inline size, and saving that would grow the card + * by the border on every save and apply. + */ +function currentGeometry( + card: HTMLElement, + fit: FitContent | null, +): WidgetGeometry { + const box = contentBox( card ); return { x: parseFloat( card.style.left ) || 0, y: parseFloat( card.style.top ) || 0, - width: card.offsetWidth, - height: card.offsetHeight, + width: box.width, + height: fit?.floor() ?? box.height, }; } diff --git a/src/widgets/server-sync.ts b/src/widgets/server-sync.ts index 94f2f156..4ed3d788 100644 --- a/src/widgets/server-sync.ts +++ b/src/widgets/server-sync.ts @@ -129,6 +129,7 @@ export function createWidgetRegistrySync( maxHeight: entry.maxHeight || undefined, defaultWidth: entry.defaultWidth || undefined, defaultHeight: entry.defaultHeight || undefined, + fitContent: entry.fitContent === true, mount: async ( container, mountCtx ) => { await ensureScript( entry ); const mount = readMount( entry.id ); diff --git a/src/widgets/types.ts b/src/widgets/types.ts index da20daa0..63e08ad2 100644 --- a/src/widgets/types.ts +++ b/src/widgets/types.ts @@ -124,6 +124,20 @@ export interface WidgetDef { */ defaultWidth?: number; defaultHeight?: number; + /** + * Let the card grow taller than the user's saved height when its + * content needs the room, so controls added after the size was + * saved stay visible instead of scrolling inside the body. Applies + * to floating cards and to docked cards with a resized height (a + * docked card without one already sizes to its content). The card + * grows up to `maxHeight` and never past the work-area bottom, + * shrinks back as the content does, and never goes below the + * user's height. Width and position never change, and the grown + * height is never saved: a resize sets a new user height, so a + * card dragged shorter than its content grows back on release. + * Default `false`. + */ + fitContent?: boolean; /** * Paint the widget into `container`. Return a teardown. May be * sync or async — async mounts are awaited and race-checked diff --git a/tests/phpunit/tests/registrationActions.php b/tests/phpunit/tests/registrationActions.php index a62108ab..f6814368 100644 --- a/tests/phpunit/tests/registrationActions.php +++ b/tests/phpunit/tests/registrationActions.php @@ -83,6 +83,14 @@ public function test_widget_registered_action_fires_on_success() { $this->assertCount( 1, $calls ); $this->assertSame( 'act-widget', $calls[0]['id'] ); $this->assertSame( 'Act Widget', $calls[0]['entry']['label'] ); + // `fit_content` is opt-in and normalized to a real bool. + $this->assertFalse( $calls[0]['entry']['fit_content'] ); + + openstation_register_widget( 'fit-widget', array( + 'label' => 'Fit Widget', + 'fit_content' => '1', + ) ); + $this->assertTrue( $calls[1]['entry']['fit_content'] ); } /** diff --git a/tests/vitest/widgets-lazy.test.ts b/tests/vitest/widgets-lazy.test.ts index 8c03fa04..a8ab54b5 100644 --- a/tests/vitest/widgets-lazy.test.ts +++ b/tests/vitest/widgets-lazy.test.ts @@ -98,7 +98,7 @@ describe( 'widgets — deferred bundle loading', () => { .mockResolvedValue( undefined ); const sync = m.serverSync.createWidgetRegistrySync( { layer: null } ); - await sync( [ serverEntry() ] ); + await sync( [ serverEntry( { fitContent: true } ) ] ); expect( load ).not.toHaveBeenCalled(); const def = m.registry.all().find( ( d ) => d.id === 'os/drafts' ); @@ -106,6 +106,7 @@ describe( 'widgets — deferred bundle loading', () => { expect( def?.label ).toBe( 'Drafts' ); expect( def?.description ).toBe( 'Your unfinished posts.' ); expect( def?.icon ).toBe( 'dashicons-edit' ); + expect( def?.fitContent ).toBe( true ); } ); test( 'mounting loads the bundle and delegates to the plugin callback', async () => { diff --git a/tests/vitest/widgets.test.ts b/tests/vitest/widgets.test.ts index 63b0c2a5..cd379d2f 100644 --- a/tests/vitest/widgets.test.ts +++ b/tests/vitest/widgets.test.ts @@ -1416,6 +1416,297 @@ describe( 'widgets/layer', () => { layer.disposeAll(); } ); + /* + * `fitContent`: the card grows to its content, never persists the + * grown height. jsdom lays nothing out, so the stubs below play the + * browser: the body is the card minus a 40 px chrome band and + * scrolls whatever content does not fit in it. + */ + const mountFitCards = async ( + defs: Array< { id: string; fitContent?: boolean; maxHeight?: number } >, + ) => { + const roCallbacks: Array< () => void > = []; + class FakeResizeObserver { + constructor( cb: () => void ) { + roCallbacks.push( cb ); + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal( 'ResizeObserver', FakeResizeObserver ); + // Runs the frame at once; returning 0 tells the caller no + // frame is still pending. + vi.stubGlobal( 'requestAnimationFrame', ( cb: FrameRequestCallback ) => { + cb( 0 ); + return 0; + } ); + const registry = await import( '../../src/widgets/registry' ); + const { WidgetLayer } = await import( '../../src/widgets/layer' ); + const geometry: Record< string, unknown > = {}; + for ( const def of defs ) { + registry.register( { + label: def.id, + description: '', + icon: 'dashicons-star-filled', + movable: true, + resizable: true, + mount: () => () => undefined, + ...def, + } ); + geometry[ def.id ] = { x: 100, y: 100, width: 240, height: 120 }; + } + window.localStorage.setItem( + 'desktop-mode-widgets', + JSON.stringify( defs.map( ( d ) => d.id ) ), + ); + window.localStorage.setItem( + 'desktop-mode-widgets-geometry', + JSON.stringify( geometry ), + ); + document.body.getBoundingClientRect = (): DOMRect => ( { + x: 0, y: 0, width: 1024, height: 768, + top: 0, left: 0, right: 1024, bottom: 768, + toJSON: () => ( {} ), + } ); + const layer = new WidgetLayer( host, '' ); + layer.hydrate(); + + const content: Record< string, number > = {}; + const cards: Record< string, HTMLElement > = {}; + for ( const { id } of defs ) { + const card = document.body.querySelector< HTMLElement >( + `[data-widget-id="${ id }"]`, + )!; + const body = card.querySelector< HTMLElement >( + '.os-widgets__card-body', + )!; + const h = (): number => parseFloat( card.style.height ) || 0; + content[ id ] = 0; + cards[ id ] = card; + Object.defineProperty( card, 'offsetWidth', { + configurable: true, + get: () => 240, + } ); + Object.defineProperty( card, 'offsetHeight', { + configurable: true, + get: h, + } ); + card.getBoundingClientRect = (): DOMRect => { + const top = parseFloat( card.style.top ) || 0; + const left = parseFloat( card.style.left ) || 0; + return { + x: left, y: top, width: 240, height: h(), + top, left, right: left + 240, bottom: top + h(), + toJSON: () => ( {} ), + }; + }; + Object.defineProperty( body, 'clientHeight', { + configurable: true, + get: () => h() - 40, + } ); + Object.defineProperty( body, 'scrollHeight', { + configurable: true, + get: () => Math.max( content[ id ], h() - 40 ), + } ); + } + // Content changed: fire every observer, the way a real layout + // would after the body's children resized. + const settle = async (): Promise< void > => { + roCallbacks.forEach( ( cb ) => cb() ); + await new Promise( ( r ) => setTimeout( r, 0 ) ); + }; + const savedHeight = ( id: string ): number => + JSON.parse( + window.localStorage.getItem( 'desktop-mode-widgets-geometry' ) || + '{}', + )[ id ]?.height; + return { layer, cards, content, settle, savedHeight }; + }; + + const ptr = ( type: string, x: number, y: number ): Event => { + const e = new Event( type, { bubbles: true } ); + Object.defineProperty( e, 'pointerId', { value: 1 } ); + Object.defineProperty( e, 'button', { value: 0 } ); + Object.defineProperty( e, 'clientX', { value: x } ); + Object.defineProperty( e, 'clientY', { value: y } ); + return e; + }; + const capture = ( el: HTMLElement ): void => { + ( el as unknown as { setPointerCapture: () => void } ).setPointerCapture = () => undefined; + ( el as unknown as { releasePointerCapture: () => void } ).releasePointerCapture = () => undefined; + }; + + test( 'a fitContent card grows to its content, within its caps, and shrinks back to the user height', async () => { + const mounted = mountFitCards( [ + { id: 'fit', fitContent: true, maxHeight: 600 }, + { id: 'plain' }, + ] ); + try { + const { cards, content, settle, savedHeight } = await mounted; + const fit = cards.fit; + + // 200 px of content in an 80 px body: grows by the 120 px + // that scrolled, and the saved size stays the user's. + content.fit = 200; + content.plain = 200; + await settle(); + expect( fit.style.height ).toBe( '240px' ); + expect( savedHeight( 'fit' ) ).toBe( 120 ); + // Without the opt-in, the content keeps scrolling. + expect( cards.plain.style.height ).toBe( '120px' ); + + // Capped at maxHeight... + content.fit = 2000; + await settle(); + expect( fit.style.height ).toBe( '600px' ); + + // ...and at the work-area bottom once that is nearer: the + // layer re-placing the card (as reclamp does) refits it. + // 768 - 500 - 20 px of margin. No observer fires here: the + // style write alone is the trigger. + fit.style.top = '500px'; + await new Promise( ( r ) => setTimeout( r, 0 ) ); + expect( fit.style.height ).toBe( '248px' ); + + // Content shrinks: back down, but never below the floor. + content.fit = 10; + await settle(); + expect( fit.style.height ).toBe( '120px' ); + + } finally { + ( await mounted ).layer.disposeAll(); + vi.unstubAllGlobals(); + delete ( document.body as { getBoundingClientRect?: unknown } ).getBoundingClientRect; + } + } ); + + test( 'a fitContent card persists the user height, not the grown one, and a resize sets a new floor', async () => { + const mounted = mountFitCards( [ { id: 'fit', fitContent: true } ] ); + try { + const { cards, content, settle, savedHeight } = await mounted; + const fit = cards.fit; + content.fit = 200; + await settle(); + expect( fit.style.height ).toBe( '240px' ); + + // A drag after growth saves the floor, not the drawn 240. + const chrome = fit.querySelector< HTMLElement >( + '.os-widgets__chrome', + )!; + capture( chrome ); + chrome.dispatchEvent( ptr( 'pointerdown', 0, 0 ) ); + chrome.dispatchEvent( ptr( 'pointermove', 37, -23 ) ); + chrome.dispatchEvent( ptr( 'pointerup', 37, -23 ) ); + await settle(); + expect( fit.style.top ).toBe( '80px' ); + expect( savedHeight( 'fit' ) ).toBe( 120 ); + expect( fit.style.height ).toBe( '240px' ); + + // Widening a grown card leaves its height alone: the floor + // stays 120, saved and drawn once the content shrinks. + const east = fit.querySelector< HTMLElement >( + '.os-widgets__resize--e', + )!; + capture( east ); + east.dispatchEvent( ptr( 'pointerdown', 337, 200 ) ); + east.dispatchEvent( ptr( 'pointermove', 377, 200 ) ); + east.dispatchEvent( ptr( 'pointerup', 377, 200 ) ); + await settle(); + expect( savedHeight( 'fit' ) ).toBe( 120 ); + content.fit = 10; + await settle(); + expect( fit.style.height ).toBe( '120px' ); + content.fit = 200; + await settle(); + expect( fit.style.height ).toBe( '240px' ); + + // A resize from the grown size: what the user drew is the + // new floor, saved as is, and the card stays at it when + // the content no longer needs the room. + const handle = fit.querySelector< HTMLElement >( + '.os-widgets__resize--s', + )!; + capture( handle ); + handle.dispatchEvent( ptr( 'pointerdown', 200, 320 ) ); + handle.dispatchEvent( ptr( 'pointermove', 200, 357 ) ); + handle.dispatchEvent( ptr( 'pointerup', 200, 357 ) ); + await settle(); + expect( fit.style.height ).toBe( '280px' ); + expect( savedHeight( 'fit' ) ).toBe( 280 ); + content.fit = 10; + await settle(); + expect( fit.style.height ).toBe( '280px' ); + + } finally { + ( await mounted ).layer.disposeAll(); + vi.unstubAllGlobals(); + delete ( document.body as { getBoundingClientRect?: unknown } ).getBoundingClientRect; + } + } ); + + test( 'a drag or a resize saves the size it was given, not the size plus the card border', async () => { + // The card is content-box with a 1 px border, so the browser + // measures it 2 px larger than its inline width and height. + const css = document.createElement( 'style' ); + css.textContent = + '.os-widgets__card { box-sizing: content-box; border: 1px solid; }'; + document.head.appendChild( css ); + const mounted = mountFitCards( [ { id: 'plain' } ] ); + try { + const { cards, settle } = await mounted; + const card = cards.plain; + const w = (): number => ( parseFloat( card.style.width ) || 0 ) + 2; + const h = (): number => ( parseFloat( card.style.height ) || 0 ) + 2; + Object.defineProperty( card, 'offsetWidth', { configurable: true, get: w } ); + Object.defineProperty( card, 'offsetHeight', { configurable: true, get: h } ); + card.getBoundingClientRect = (): DOMRect => { + const top = parseFloat( card.style.top ) || 0; + const left = parseFloat( card.style.left ) || 0; + return { + x: left, y: top, width: w(), height: h(), + top, left, right: left + w(), bottom: top + h(), + toJSON: () => ( {} ), + }; + }; + const saved = (): { width: number; height: number } => { + const g = JSON.parse( + window.localStorage.getItem( 'desktop-mode-widgets-geometry' ) || + '{}', + ).plain; + return { width: g.width, height: g.height }; + }; + + // Save, apply, save: two drags in a row keep the stored size. + const chrome = card.querySelector< HTMLElement >( '.os-widgets__chrome' )!; + capture( chrome ); + for ( const dx of [ 40, 80 ] ) { + chrome.dispatchEvent( ptr( 'pointerdown', 0, 0 ) ); + chrome.dispatchEvent( ptr( 'pointermove', dx, 0 ) ); + chrome.dispatchEvent( ptr( 'pointerup', dx, 0 ) ); + await settle(); + expect( saved() ).toEqual( { width: 240, height: 120 } ); + expect( card.style.width ).toBe( '240px' ); + } + + // A resize that ends where it started changes nothing. + const corner = card.querySelector< HTMLElement >( '.os-widgets__resize--se' )!; + capture( corner ); + corner.dispatchEvent( ptr( 'pointerdown', 300, 300 ) ); + corner.dispatchEvent( ptr( 'pointermove', 300, 300 ) ); + corner.dispatchEvent( ptr( 'pointerup', 300, 300 ) ); + await settle(); + expect( saved() ).toEqual( { width: 240, height: 120 } ); + expect( card.style.width ).toBe( '240px' ); + expect( card.style.height ).toBe( '120px' ); + } finally { + css.remove(); + ( await mounted ).layer.disposeAll(); + vi.unstubAllGlobals(); + delete ( document.body as { getBoundingClientRect?: unknown } ).getBoundingClientRect; + } + } ); + /* * `setVisibleIds` — the workspace primitive. *