Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/examples/register-widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/javascript-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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( {
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions includes/registries/widgets.php
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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 );

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions includes/widgets/widget-starter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' )
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
151 changes: 151 additions & 0 deletions src/widgets/fit-content.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
Loading
Loading