From 9d7f4124d79f7e534a31e26e253c6909504921d4 Mon Sep 17 00:00:00 2001 From: Juan Lentino <74268474+juanlentino@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:31:59 -0400 Subject: [PATCH 1/3] Widgets: let a floating card grow to fit its content (opt-in) A floating card takes a fixed inline height from its saved geometry, and the body scrolls whatever no longer fits, so a control a widget adds after the size was saved (a Save row, a revealed field) is hidden below the fold. A new opt-in def field, `fitContent` (PHP `fit_content`), lets the card grow past the user's height when its content needs the room. - The user's height is a floor: the saved geometry height, or the resized height of a docked card. The card grows from it up to `maxHeight` and the work-area bottom (the floating `s` handle's far edge), shrinks back as the content does, never below the floor, and never changes width or position. - The grown height is never persisted. A drag saves the floor; a user resize sets a new floor and saves that. - Content is watched through the body's children (ResizeObserver, one frame later) and a MutationObserver on the body and the card, which also catches the layer re-placing or re-docking the card. Measuring happens at the floor, since `scrollHeight` cannot report slack once the content fits. - The logic lives in src/widgets/fit-content.ts; frame.ts only wires it, so layer.ts and state.ts are untouched. Co-Authored-By: Claude Opus 5.5 --- docs/examples/register-widget.md | 9 + docs/javascript-reference.md | 3 + includes/registries/widgets.php | 11 ++ src/types.ts | 2 + src/widgets/fit-content.ts | 151 ++++++++++++++ src/widgets/frame.ts | 63 +++++- src/widgets/server-sync.ts | 1 + src/widgets/types.ts | 14 ++ tests/phpunit/tests/registrationActions.php | 8 + tests/vitest/widgets-lazy.test.ts | 3 +- tests/vitest/widgets.test.ts | 209 ++++++++++++++++++++ 11 files changed, 464 insertions(+), 10 deletions(-) create mode 100644 src/widgets/fit-content.ts diff --git a/docs/examples/register-widget.md b/docs/examples/register-widget.md index b8c73a40f..0be8ceaab 100644 --- a/docs/examples/register-widget.md +++ b/docs/examples/register-widget.md @@ -330,6 +330,15 @@ All sizes are pixels, passed to `openstation_register_widget()`: `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 ad1db63d3..5d6e56165 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -5178,6 +5178,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( { @@ -5204,6 +5205,8 @@ wp.os.registerWidget( { User-placed geometry (position + size of liberated widgets) persists per-user in `localStorage` under `desktop-mode-widgets-geometry`. Height resizes made while a resizable widget is docked in the column persist separately under `desktop-mode-widgets-docked-heights` (height only — column widgets have no free position, and a full geometry record would mark the widget as floating at boot). Removing a widget clears both records so a re-add starts docked at its natural height. +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 0d6ad18e0..911714018 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/src/types.ts b/src/types.ts index 869c4f00a..814e786d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1186,6 +1186,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 000000000..d5ffd0263 --- /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; + + // Same far edge the floating `s` resize handle stops at. + 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 1967fa7e0..1bc201605 100644 --- a/src/widgets/frame.ts +++ b/src/widgets/frame.ts @@ -20,6 +20,7 @@ import { __, sprintf } from '../i18n'; import { osIconSvg } from '../ui/icons'; import { workAreaRectOf } from '../work-area'; +import { attachFitContent, type FitContent } from './fit-content'; import type { WidgetDef, WidgetGeometry } from './types'; const FLOATING_CLASS = 'os-widgets__card--floating'; @@ -170,7 +171,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 +183,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 +198,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 +227,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 +242,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 +250,7 @@ export function buildFrame( card, body, dispose: () => { + fit?.dispose(); for ( const fn of resizeCleanups ) { try { fn(); @@ -361,6 +382,7 @@ function attachDrag( def: WidgetDef, ctx: FrameContext, handlers: FrameHandlers, + fit: FitContent | null, ): () => void { let pointerId: number | null = null; let startX = 0; @@ -429,12 +451,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, + height: + fit?.floor() ?? + ( rect.height || def.defaultHeight || DEFAULT_HEIGHT ), }; + fit?.setFloor( initial.height ); applyGeometry( card, initial ); card.classList.add( FLOATING_CLASS ); handlers.onLiberate( initial ); @@ -502,7 +529,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 +557,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; @@ -563,6 +593,7 @@ function attachResize( startTop = rect.top - parentRect.top; startW = rect.width; startH = rect.height; + resized = false; handle.setPointerCapture( pointerId ); card.classList.add( RESIZING_CLASS ); }; @@ -595,6 +626,7 @@ function attachResize( card.style.width = `${ next.width }px`; } card.style.height = `${ next.height }px`; + resized = true; }; const onUp = ( e: PointerEvent ): void => { @@ -608,14 +640,19 @@ 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() ?? card.offsetHeight ); } }; @@ -654,12 +691,20 @@ 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. + */ +function currentGeometry( + card: HTMLElement, + fit: FitContent | null, +): WidgetGeometry { return { x: parseFloat( card.style.left ) || 0, y: parseFloat( card.style.top ) || 0, width: card.offsetWidth, - height: card.offsetHeight, + height: fit?.floor() ?? card.offsetHeight, }; } diff --git a/src/widgets/server-sync.ts b/src/widgets/server-sync.ts index 94f2f1569..4ed3d788d 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 da20daa09..63e08ad26 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 a62108abc..f68143682 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 8c03fa04d..a8ab54b5a 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 c4685f04c..53e4ccbdb 100644 --- a/tests/vitest/widgets.test.ts +++ b/tests/vitest/widgets.test.ts @@ -1373,6 +1373,215 @@ 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(); + } + } ); + + 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' ); + + // 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(); + } + } ); + /* * `setVisibleIds` — the workspace primitive. * From db0066b76df9584977e94f5e427747730a63d8df Mon Sep 17 00:00:00 2001 From: Juan Lentino <74268474+juanlentino@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:50:33 -0400 Subject: [PATCH 2/3] Widgets: a sideways resize keeps a fit card's user height Widening a grown fit-content card with an e / w edge (or a corner moved sideways) recorded the grown height as the user's floor and persisted it. Only a vertical pull now sets the floor. Test added. Also: the cap comment names the drag clamp's margin, the size-args table and the starter args list name fit_content, and the fit tests restore document.body.getBoundingClientRect. Co-Authored-By: Claude Opus 5.5 --- docs/examples/register-widget.md | 1 + includes/widgets/widget-starter.php | 2 ++ src/widgets/fit-content.ts | 2 +- src/widgets/frame.ts | 6 +++++- tests/vitest/widgets.test.ts | 20 ++++++++++++++++++++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/examples/register-widget.md b/docs/examples/register-widget.md index 0be8ceaab..f93eb014c 100644 --- a/docs/examples/register-widget.md +++ b/docs/examples/register-widget.md @@ -325,6 +325,7 @@ 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 diff --git a/includes/widgets/widget-starter.php b/includes/widgets/widget-starter.php index b1b8d727d..3c13fe243 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/widgets/fit-content.ts b/src/widgets/fit-content.ts index d5ffd0263..205c6cf9c 100644 --- a/src/widgets/fit-content.ts +++ b/src/widgets/fit-content.ts @@ -52,7 +52,7 @@ export function attachFitContent( opts: FitContentOptions ): FitContent { let frame = 0; let mutations: MutationObserver | null = null; - // Same far edge the floating `s` resize handle stops at. + // The work-area bottom, with the same air the drag clamp keeps. const cap = (): number => { const area = workAreaRectOf( parent ); const top = diff --git a/src/widgets/frame.ts b/src/widgets/frame.ts index 1bc201605..c26bd1fff 100644 --- a/src/widgets/frame.ts +++ b/src/widgets/frame.ts @@ -626,7 +626,11 @@ function attachResize( card.style.width = `${ next.width }px`; } card.style.height = `${ next.height }px`; - resized = true; + // 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 => { diff --git a/tests/vitest/widgets.test.ts b/tests/vitest/widgets.test.ts index 53e4ccbdb..ef370673e 100644 --- a/tests/vitest/widgets.test.ts +++ b/tests/vitest/widgets.test.ts @@ -1534,6 +1534,7 @@ describe( 'widgets/layer', () => { } finally { ( await mounted ).layer.disposeAll(); vi.unstubAllGlobals(); + delete ( document.body as { getBoundingClientRect?: unknown } ).getBoundingClientRect; } } ); @@ -1559,6 +1560,24 @@ describe( 'widgets/layer', () => { 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. @@ -1579,6 +1598,7 @@ describe( 'widgets/layer', () => { } finally { ( await mounted ).layer.disposeAll(); vi.unstubAllGlobals(); + delete ( document.body as { getBoundingClientRect?: unknown } ).getBoundingClientRect; } } ); From e8dd5d5944a1a582440f8f0fe0ed37a0878e7f07 Mon Sep 17 00:00:00 2001 From: Juan Lentino <74268474+juanlentino@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:43:37 -0400 Subject: [PATCH 3/3] Widgets: save a card's content-box size, not its border box The card is box-sizing: content-box with a 1px border, so offsetWidth, offsetHeight and getBoundingClientRect() measure it 2px larger than the inline width and height applyGeometry() writes. currentGeometry() saved the border-box size, so every drag end and resize end stored the size plus 2px, and the next apply drew it that much larger. A resize also started from the border box, adding 2px more before any movement. The liberate snapshot and a docked height resize had the same offset. All four now read the content box (the existing contentBox() helper), so save, apply, save is stable. Test added: two drags and a resize that ends where it started keep the stored size; it fails without the fix (242 x 122 saved for a 240 x 120 card). Co-Authored-By: Claude Opus 5.5 --- src/widgets/frame.ts | 28 ++++++++++++---- tests/vitest/widgets.test.ts | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/widgets/frame.ts b/src/widgets/frame.ts index c26bd1fff..531db670c 100644 --- a/src/widgets/frame.ts +++ b/src/widgets/frame.ts @@ -20,6 +20,7 @@ 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'; @@ -440,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 === @@ -456,10 +458,10 @@ function attachDrag( const initial: WidgetGeometry = { x: rect.left - parentRect.left, y: rect.top - parentRect.top, - width: rect.width || def.defaultWidth || DEFAULT_WIDTH, + width: rect.width ? box.width : def.defaultWidth || DEFAULT_WIDTH, height: fit?.floor() ?? - ( rect.height || def.defaultHeight || DEFAULT_HEIGHT ), + ( rect.height ? box.height : def.defaultHeight || DEFAULT_HEIGHT ), }; fit?.setFloor( initial.height ); applyGeometry( card, initial ); @@ -591,8 +593,11 @@ 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 ); @@ -656,7 +661,10 @@ function attachResize( if ( isFloating() ) { handlers.onGeometryChanged( currentGeometry( card, fit ) ); } else { - handlers.onDockedHeightChanged( fit?.floor() ?? card.offsetHeight ); + handlers.onDockedHeightChanged( + fit?.floor() ?? + ( parseFloat( card.style.height ) || contentBox( card ).height ), + ); } }; @@ -699,16 +707,22 @@ export function applyGeometry( * 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: fit?.floor() ?? card.offsetHeight, + width: box.width, + height: fit?.floor() ?? box.height, }; } diff --git a/tests/vitest/widgets.test.ts b/tests/vitest/widgets.test.ts index 7c53d64bc..cd379d2fa 100644 --- a/tests/vitest/widgets.test.ts +++ b/tests/vitest/widgets.test.ts @@ -1645,6 +1645,68 @@ describe( 'widgets/layer', () => { } } ); + 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. *