PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / registries / native-windows.php

native-windows.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.8.7, at includes/registries/native-windows.php

798 lines 29.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Native windows registry.
4 *
5 * The largest of the five components.php registries — owns:
6 *
7 * - `desktop_mode_register_window()` — plugin-author API
8 * - `desktop_mode_native_window_registry()` — internal store
9 * - `desktop_mode_native_window_allowed_html()` — wp_kses
10 * allowlist for `<template>` payloads
11 * - `desktop_mode_build_native_window_template_html()` —
12 * wraps the registered template callback in tabs markup
13 * when the window has multiple registered tabs
14 * - `desktop_mode_enqueue_native_window_scripts()` — enqueue
15 * hook that ships every registered window's script handle
16 * - `desktop_mode_render_native_window_templates()` — renders
17 * the `<template>` elements the shell clones
18 *
19 * Extracted from `components.php` during the architecture-0.8.1
20 * PHP slicing (phase 6). The window-tabs registry that builds on
21 * top of this lives in `includes/registries/window-tabs.php`.
22 *
23 * @package Desktop_Mode
24 * @since 0.8.1
25 */
26
27 defined( 'ABSPATH' ) || exit;
28
29 /**
30 * Register a PHP-owned native desktop window with one call.
31 *
32 * Under the hood this:
33 *
34 * 1. Captures the $args and stores them on a module-level
35 * registry so the relevant admin_footer + enqueue hooks fire
36 * only for the current user's desktop-mode shell.
37 * 2. On `admin_footer` (shell-side only), emits
38 * `<template id="desktop-mode-native-window-<id>">` wrapping the
39 * output of the `template` callback. Each registered window
40 * gets its own template element.
41 * 3. On `admin_enqueue_scripts` (shell-side), enqueues the
42 * caller's `script` handle if one was provided. The script
43 * registers a render callback at
44 * `window.desktopModeNativeWindows[<id>]`. On every window open
45 * the shell clones the registered template into the body and
46 * then invokes the callback — render is enhancement: query
47 * the body for mount points your template declared, light
48 * them up. Without a `script` the cloned template IS the
49 * window; declarative-only plugins need zero JS.
50 * 4. Passes a localized config blob to the script
51 * (`desktopModeNativeWindow_<id>`) carrying the window's
52 * `id`, `title`, `icon`, dimensions, and `placement`. The
53 * script then calls `wp.desktop.registerSystemTile()` +
54 * `wp.desktop.registerWindow()` to wire up the dock tile
55 * and the open-on-click behaviour.
56 *
57 * Plugins write the template callback + the render callback on
58 * the JS side; everything else is shell plumbing. Capability gate
59 * honours WP admin conventions: any `capabilities` entries must
60 * ALL match for the window to register.
61 *
62 * Note on scope: the shell doesn't auto-open windows server-side
63 * — `registerWindow` declares availability, not presence. Users
64 * click the registered tile (or your plugin calls
65 * `wp.desktop.windowManager.open()` programmatically) to surface
66 * the window.
67 *
68 * @since 0.10.0
69 * @since 0.11.0 Returns `WP_Error` on validation failure instead of
70 * silent `false`. Legacy `if ( $result )` callers remain
71 * correct because `WP_Error` is truthy; new code should
72 * prefer `is_wp_error( $result )` for diagnostics.
73 *
74 * @param string $id Doubles as window id + dock-tile id. Must
75 * be a kebab-case-ish slug.
76 * @param array $args {
77 * Window registration options.
78 *
79 * @type string $title Window + tooltip title. Required.
80 * @type string $icon Dashicons class or URL. Required.
81 * @type callable $template Echoes the window body markup.
82 * Wrapped on `admin_footer` in a
83 * `<template id="desktop-mode-native-window-
84 * <id>">`; cloned into the window
85 * body on every open. The render
86 * callback runs against the cloned
87 * body, so mount points declared in
88 * the template are guaranteed to be
89 * present.
90 * @type string $script Registered script handle that
91 * owns the JS render callback.
92 * Optional — omit for a purely
93 * declarative window whose body is
94 * exactly the cloned template.
95 * @type int $width Initial width (px). Default 520.
96 * @type int $height Initial height (px). Default 400.
97 * @type int $min_width Minimum width (px). Default 280.
98 * @type int $min_height Minimum height (px). Default 220.
99 * @type string $placement 'dock' | 'none'. Default 'dock'.
100 * 'none' skips the tile (plugin
101 * opens the window programmatically).
102 * @type string[] $capabilities User capabilities that gate the
103 * registration. ANY miss returns
104 * `WP_Error desktop_mode_capability_denied`.
105 * @type bool|string $autofocus Passed verbatim to
106 * `NativeWindowDef.autofocus`.
107 * @type string $main_tab_label Label for the "main" tab that
108 * displays the window's own
109 * `template` output. Only rendered
110 * when at least one additional
111 * tab is registered via
112 * {@see desktop_mode_register_window_tab()}.
113 * Defaults to the window's `title`.
114 * @type int $main_tab_padding Padding (in px) applied to the
115 * auto-generated tab-wrap around
116 * the window body. Only applies
117 * when additional tabs are
118 * registered. Default 16. Pass 0
119 * for edge-to-edge content.
120 * Filterable at runtime via
121 * `desktop_mode_native_window_tab_wrap_padding`.
122 * @type array $config Arbitrary serializable data to ship
123 * to the bundle alongside the script
124 * tag. Read in JS via
125 * `wp.desktop.getWindowConfig( $id )`
126 * (or directly at
127 * `window.desktopModeWindowConfig[ $id ]`).
128 * Recommended over `wp_localize_script`
129 * for native-window scripts because
130 * the lazy-load path bypasses
131 * `wp_print_scripts` — passing config
132 * through this arg guarantees delivery
133 * on both eager AND lazy paths
134 * (mid-session activation). Use this
135 * for REST URLs, nonces, capability
136 * flags, anything session-bound. Empty
137 * array (default) ships nothing.
138 * }
139 * @return true|WP_Error `true` on success; `WP_Error` when any
140 * required arg is missing/invalid or a
141 * declared capability is unmet.
142 */
143 function desktop_mode_register_window( $id, $args = array() ) {
144 $id = sanitize_key( (string) $id );
145 if ( '' === $id ) {
146 return desktop_mode_registration_error(
147 'desktop_mode_missing_id',
148 __( 'Native window id is required and must be a valid slug.', 'desktop-mode' )
149 );
150 }
151
152 $defaults = array(
153 'title' => '',
154 'icon' => 'dashicons-admin-generic',
155 'template' => null,
156 'script' => '',
157 // Optional WP style handle (registered with `wp_register_style()`).
158 // Resolved at payload-build time so the shell can lazy-inject a
159 // `<link rel="stylesheet">` when a peer plugin is activated
160 // mid-session — without this, the parent shell page already
161 // finished `wp_print_styles` and the plugin's CSS is missing
162 // until F5. @since 0.18.1
163 'style' => '',
164 'width' => 520,
165 'height' => 400,
166 'min_width' => 280,
167 'min_height' => 220,
168 'placement' => 'dock',
169 'capabilities' => array(),
170 'autofocus' => false,
171 'main_tab_label' => '',
172 'main_tab_padding' => '',
173 'config' => array(),
174 );
175 $args = wp_parse_args( $args, $defaults );
176
177 // Capability gate — ALL listed caps must match. Fail closed.
178 foreach ( (array) $args['capabilities'] as $cap ) {
179 if ( ! current_user_can( (string) $cap ) ) {
180 return desktop_mode_registration_error(
181 'desktop_mode_capability_denied',
182 sprintf(
183 /* translators: %s: capability slug. */
184 __( 'Current user lacks the %s capability required to register this native window.', 'desktop-mode' ),
185 (string) $cap
186 ),
187 array( 'capability' => (string) $cap, 'id' => $id )
188 );
189 }
190 }
191
192 // Required fields.
193 if ( '' === (string) $args['title'] ) {
194 return desktop_mode_registration_error(
195 'desktop_mode_missing_title',
196 __( 'Native window registration requires a non-empty `title`.', 'desktop-mode' ),
197 array( 'id' => $id )
198 );
199 }
200 if ( ! is_callable( $args['template'] ) ) {
201 return desktop_mode_registration_error(
202 'desktop_mode_invalid_template',
203 __( 'Native window registration requires a callable `template` that echoes the template body.', 'desktop-mode' ),
204 array( 'id' => $id )
205 );
206 }
207
208 $placement = in_array( $args['placement'], array( 'dock', 'none' ), true )
209 ? $args['placement']
210 : 'dock';
211
212 $entry = array(
213 'id' => $id,
214 'title' => (string) $args['title'],
215 'icon' => (string) $args['icon'],
216 'template' => $args['template'],
217 'script' => (string) $args['script'],
218 'style' => (string) $args['style'],
219 'width' => (int) $args['width'],
220 'height' => (int) $args['height'],
221 'min_width' => (int) $args['min_width'],
222 'min_height' => (int) $args['min_height'],
223 'placement' => $placement,
224 'autofocus' => $args['autofocus'],
225 'main_tab_label' => (string) $args['main_tab_label'],
226 // Stored as-is (string or int). `desktop_mode_build_native_window_template_html`
227 // coerces to int and falls back to 16 when absent.
228 'main_tab_padding' => $args['main_tab_padding'],
229 // Bundle-bound config delivered through the same path as
230 // `wp_localize_script` `extra['data']` — see the `config` doc
231 // in this function's `$args` block and `desktop_mode_resolve_script_payload()`
232 // for how it lands on the wire.
233 'config' => is_array( $args['config'] ) ? $args['config'] : array(),
234 );
235 desktop_mode_native_window_registry( $id, $entry );
236
237 /**
238 * Fires after a native desktop window is successfully registered.
239 *
240 * Lets plugins react to registrations made by other plugins —
241 * e.g. a widget that auto-opens when a given window registers,
242 * or analytics tracking of which windows the current install
243 * exposes. Does NOT fire when `desktop_mode_register_window()`
244 * returns a `WP_Error`.
245 *
246 * @since 0.11.0
247 *
248 * @param string $id The window id.
249 * @param array $entry The stored registry entry (id, title,
250 * icon, template callback, script handle,
251 * size defaults, placement, autofocus).
252 */
253 do_action( 'desktop_mode_native_window_registered', $id, $entry );
254
255 return true;
256 }
257
258 /**
259 * Internal module-level registry for native windows registered
260 * via {@see desktop_mode_register_window()}. Passing a second
261 * argument stores the entry; passing only the id returns the
262 * stored value (or null). Kept small and side-effect-free so
263 * tests can introspect.
264 *
265 * @since 0.10.0
266 * @internal
267 *
268 * @param string $id Window id.
269 * @param array|null $entry Entry to store, or null to just read.
270 * @return array|null Either the stored entry or the full registry
271 * (when id is empty).
272 */
273 function desktop_mode_native_window_registry( $id = '', $entry = null ) {
274 static $store = array();
275
276 if ( '' === (string) $id ) {
277 return $store;
278 }
279 if ( null !== $entry ) {
280 $store[ $id ] = $entry;
281 }
282 return isset( $store[ $id ] ) ? $store[ $id ] : null;
283 }
284
285
286 /**
287 * Render a native window's template HTML to a string, wrapping
288 * with tabs when the window has at least one additional tab
289 * registered. Shared by `desktop_mode_render_native_window_templates()`
290 * (which emits the live `<template>` element) and
291 * `desktop_mode_build_native_windows_payload()` (which captures the same
292 * string for the shell config so mid-session activation can inject
293 * the template without a reload).
294 *
295 * Single-tab windows (no additional tabs registered) render the
296 * same flat body they always did — backwards-compatible with
297 * every existing caller.
298 *
299 * @since 0.11.0
300 *
301 * @param array $entry Window registry entry.
302 * @return string Template body HTML (no outer `<template>` tag).
303 */
304 /**
305 * Returns the `wp_kses`-shaped allowlist used to escape native-window
306 * `<template>` payloads (and the recycle-bin template) before they're
307 * emitted into the page.
308 *
309 * Templates are inert until JS clones them out of the `<template>`
310 * tag — but Plugin Check still requires escape-on-output. The list
311 * extends `wp_kses_allowed_html( 'post' )` with form controls,
312 * `<wpd-*>` web components, and dashicon spans, plus permissive
313 * `data-*`, `aria-*`, and component-specific attributes. Plugins
314 * registering their own native windows can extend the list via the
315 * `desktop_mode_native_window_allowed_html` filter below.
316 *
317 * @since 0.6.2
318 *
319 * @return array<string,array<string,bool>>
320 */
321 function desktop_mode_native_window_allowed_html() {
322 $base = wp_kses_allowed_html( 'post' );
323
324 $global_attrs = array(
325 'id' => true,
326 'class' => true,
327 'style' => true,
328 'title' => true,
329 'role' => true,
330 'tabindex' => true,
331 'hidden' => true,
332 'slot' => true,
333 'part' => true,
334 'lang' => true,
335 'dir' => true,
336 'draggable' => true,
337 'contenteditable' => true,
338 'data-*' => true,
339 'aria-*' => true,
340 // `full-width` is a layout-level flag honoured by
341 // `<wpd-form>` (and any future wpd-* container that opts in
342 // to row-spanning slotted children). Lives in the global
343 // allowlist so a plain `<div full-width>` wrapper isn't
344 // stripped by kses on its way through the template.
345 'full-width' => true,
346 );
347
348 $form_attrs = array_merge(
349 $global_attrs,
350 array(
351 'name' => true,
352 'value' => true,
353 'placeholder' => true,
354 'required' => true,
355 'disabled' => true,
356 'readonly' => true,
357 'checked' => true,
358 'selected' => true,
359 'min' => true,
360 'max' => true,
361 'step' => true,
362 'minlength' => true,
363 'maxlength' => true,
364 'pattern' => true,
365 'autocomplete' => true,
366 'autofocus' => true,
367 'multiple' => true,
368 'rows' => true,
369 'cols' => true,
370 'wrap' => true,
371 'size' => true,
372 'for' => true,
373 'form' => true,
374 'type' => true,
375 'accept' => true,
376 'list' => true,
377 'src' => true,
378 'href' => true,
379 'target' => true,
380 'rel' => true,
381 'open' => true,
382 'variant' => true,
383 )
384 );
385
386 $wpd_attrs = array_merge(
387 $form_attrs,
388 array(
389 'gap' => true,
390 'padding' => true,
391 'align' => true,
392 'justify' => true,
393 'direction' => true,
394 'wrap' => true,
395 'inset' => true,
396 'icon' => true,
397 'tone' => true,
398 'size' => true,
399 'shape' => true,
400 'badge' => true,
401 'selectable' => true,
402 'sticky-header' => true,
403 'sticky-columns' => true,
404 'hover' => true,
405 'striped' => true,
406 'bordered' => true,
407 'compact' => true,
408 'loading' => true,
409 'loading-rows' => true,
410 'columns' => true,
411 'rows' => true,
412 'sortable' => true,
413 'expandable' => true,
414 'preset' => true,
415 'label' => true,
416 'description' => true,
417 'orientation' => true,
418 'level' => true,
419 'collapsed' => true,
420 // `<wpd-form>` props + the `full-width` row span flag
421 // honoured by the form's slotted-child layout rule.
422 'submit-label' => true,
423 'reset-label' => true,
424 'busy' => true,
425 'error' => true,
426 'min-column' => true,
427 'show-reset' => true,
428 'reveal' => true,
429 'full-width' => true,
430 )
431 );
432
433 // Built-in HTML elements the templates rely on.
434 $extra = array(
435 'form' => $form_attrs,
436 'fieldset' => $form_attrs,
437 'legend' => $global_attrs,
438 'label' => $form_attrs,
439 'input' => $form_attrs,
440 'select' => $form_attrs,
441 'option' => $form_attrs,
442 'optgroup' => $form_attrs,
443 'textarea' => $form_attrs,
444 'button' => $form_attrs,
445 'output' => $form_attrs,
446 'datalist' => $global_attrs,
447 'progress' => $form_attrs,
448 'meter' => $form_attrs,
449 'details' => $global_attrs,
450 'summary' => $global_attrs,
451 'dialog' => $global_attrs,
452 'header' => $global_attrs,
453 'footer' => $global_attrs,
454 'main' => $global_attrs,
455 'nav' => $global_attrs,
456 'section' => $global_attrs,
457 'article' => $global_attrs,
458 'aside' => $global_attrs,
459 'figure' => $global_attrs,
460 'figcaption' => $global_attrs,
461 'time' => array_merge( $global_attrs, array( 'datetime' => true ) ),
462 'mark' => $global_attrs,
463 'small' => $global_attrs,
464 'svg' => array_merge( $global_attrs, array( 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'stroke' => true, 'xmlns' => true ) ),
465 'path' => array( 'd' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'class' => true ),
466 'g' => array( 'class' => true, 'transform' => true, 'fill' => true ),
467 'circle' => array( 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'class' => true ),
468 'rect' => array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'class' => true ),
469 'line' => array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'class' => true ),
470 'polyline' => array( 'points' => true, 'fill' => true, 'stroke' => true, 'class' => true ),
471 'polygon' => array( 'points' => true, 'fill' => true, 'stroke' => true, 'class' => true ),
472 'use' => array( 'href' => true, 'class' => true ),
473 );
474
475 // `<wpd-*>` web components — every shipped tag plus a permissive
476 // open door for new ones added by plugin templates.
477 $wpd_tags = array(
478 'wpd-stack', 'wpd-cluster', 'wpd-grid', 'wpd-spacer', 'wpd-divider',
479 'wpd-tabs', 'wpd-tab', 'wpd-tabpanel',
480 'wpd-segmented', 'wpd-segment',
481 'wpd-button', 'wpd-icon-button', 'wpd-button-group',
482 'wpd-text-field', 'wpd-textarea', 'wpd-search-field',
483 'wpd-select', 'wpd-option', 'wpd-checkbox', 'wpd-checkbox-label',
484 'wpd-radio', 'wpd-radio-group', 'wpd-form',
485 'wpd-switch', 'wpd-slider',
486 'wpd-table', 'wpd-table-column', 'wpd-table-row', 'wpd-table-cell',
487 'wpd-card', 'wpd-list', 'wpd-list-item',
488 'wpd-badge', 'wpd-pill', 'wpd-tag', 'wpd-chip',
489 'wpd-spinner', 'wpd-skeleton', 'wpd-empty-state',
490 'wpd-tooltip', 'wpd-popover', 'wpd-menu', 'wpd-menu-item',
491 'wpd-modal', 'wpd-drawer', 'wpd-toast',
492 'wpd-icon', 'wpd-avatar', 'wpd-heading', 'wpd-text', 'wpd-link',
493 'wpd-banner', 'wpd-alert', 'wpd-callout',
494 'wpd-form-row', 'wpd-form-section', 'wpd-help-text',
495 'wpd-toolbar', 'wpd-toolbar-group',
496 );
497 foreach ( $wpd_tags as $tag ) {
498 $extra[ $tag ] = $wpd_attrs;
499 }
500
501 $allowed = array_merge( $base, $extra );
502
503 // Promote the framework's global attrs (`slot`, `part`,
504 // `full-width`, `data-*`, `aria-*`, …) to EVERY allowed tag —
505 // otherwise plain wrappers like `<div slot="header">` lose
506 // their `slot` attribute on the way through kses and get
507 // projected into the default slot instead of the named one.
508 // Caught by inspection when the Add User form's header
509 // rendered as a fields-grid cell instead of a banner above
510 // the fields. `array_merge( + )` with a kses-true value
511 // (boolean `true`) is harmless for tags whose entries are
512 // just `true` rather than an attrs map — array_merge skips
513 // non-array values.
514 foreach ( $allowed as $tag => $attrs ) {
515 if ( is_array( $attrs ) ) {
516 $allowed[ $tag ] = array_merge( $attrs, $global_attrs );
517 }
518 }
519
520 /**
521 * Filters the kses allowlist used when escaping native-window
522 * `<template>` payloads.
523 *
524 * Plugins registering their own native windows can extend the
525 * list with custom tags or attributes if their templates need
526 * markup not covered here.
527 *
528 * @since 0.6.2
529 *
530 * @param array $allowed wp_kses-shaped allowlist.
531 */
532 return (array) apply_filters( 'desktop_mode_native_window_allowed_html', $allowed );
533 }
534
535 /**
536 * Run `wp_kses` on a native-window template body with the framework
537 * allowlist, **auto-extending the allowlist with every `<wpd-*>` tag
538 * the template actually uses.**
539 *
540 * The pain this fixes: each shipped `<wpd-*>` component had to be
541 * manually added to the `$wpd_tags` list above, and the failure mode
542 * of forgetting it was silent — kses would strip the tag, the
543 * template would render as a sea of unparented children, and you'd
544 * spend an afternoon working out why "the form has no buttons."
545 *
546 * Plugin authors registering a new component now only need to
547 * `defineComponent('wpd-foo', WpdFoo)` on the JS side and use
548 * `<wpd-foo>` in their template — this helper finds the tag at
549 * render time, tags it onto the allowlist with the standard
550 * permissive attrs, and runs kses with the extended list.
551 *
552 * Every callsite in the framework that previously did the
553 * `wp_kses( $html, desktop_mode_native_window_allowed_html() )`
554 * dance can call this instead and get tag-discovery for free.
555 *
556 * @since 0.18.0
557 *
558 * @param string $html Template HTML to sanitize.
559 * @return string Sanitized HTML.
560 */
561 function desktop_mode_kses_native_window_template( $html ) {
562 $allowed = desktop_mode_native_window_allowed_html();
563
564 if ( preg_match_all( '/<(wpd-[a-z][a-z0-9-]*)\b/i', (string) $html, $matches ) ) {
565 $unique = array_unique( array_map( 'strtolower', $matches[1] ) );
566 $wpd_attrs = isset( $allowed['wpd-button'] )
567 ? $allowed['wpd-button']
568 : array();
569 foreach ( $unique as $tag ) {
570 if ( ! isset( $allowed[ $tag ] ) ) {
571 $allowed[ $tag ] = $wpd_attrs;
572 }
573 }
574 }
575
576 return wp_kses( (string) $html, $allowed );
577 }
578
579 function desktop_mode_build_native_window_template_html( $entry ) {
580 if ( ! is_array( $entry ) || ! is_callable( $entry['template'] ) ) {
581 return '';
582 }
583
584 $tabs = desktop_mode_get_native_window_tabs( $entry['id'] );
585 $has_extras = count( $tabs ) > 1;
586
587 // Fast path — single-pane window, no wrapping.
588 if ( ! $has_extras ) {
589 ob_start();
590 call_user_func( $entry['template'] );
591 return (string) ob_get_clean();
592 }
593
594 // Multi-tab window — wrap in <wpd-stack> + <wpd-tabs> + one
595 // <wpd-tabpanel> per tab. The default active tab is the main
596 // one (the window's own template). Plugin authors still get to
597 // declare their own tab-change side effects via the
598 // `wpd-tab-change` event bubbled by <wpd-tabs>.
599 //
600 // The wrap's padding is plugin-controllable two ways:
601 // 1. `main_tab_padding` arg on `desktop_mode_register_window` —
602 // a per-window override. `0` opts into edge-to-edge
603 // content.
604 // 2. `desktop_mode_native_window_tab_wrap_padding` filter for
605 // late-bound overrides (e.g. a theme that wants every
606 // tabbed window to adopt a narrower inset).
607 // Default stays 16px so existing plugins don't shift.
608 $default_padding = isset( $entry['main_tab_padding'] )
609 && '' !== (string) $entry['main_tab_padding']
610 ? (int) $entry['main_tab_padding']
611 : 16;
612 /**
613 * Filters the padding (in px) applied to the auto-generated
614 * tab wrap around a native window's template body. The shell
615 * emits the wrap as `<wpd-stack padding="N">`; the CSS-as-
616 * attribute pipeline at the client translates that to
617 * `style.padding`.
618 *
619 * Return `0` for edge-to-edge content. Negative values are
620 * clamped to 0.
621 *
622 * @since 0.13.0
623 *
624 * @param int $padding Default padding in px.
625 * @param string $window_id The native window id.
626 */
627 $padding = (int) apply_filters(
628 'desktop_mode_native_window_tab_wrap_padding',
629 $default_padding,
630 (string) $entry['id']
631 );
632 if ( $padding < 0 ) {
633 $padding = 0;
634 }
635
636 $buffer = sprintf(
637 '<wpd-stack gap="12" padding="%d">',
638 $padding
639 );
640 $buffer .= '<wpd-tabs value="' . esc_attr( DESKTOP_MODE_NATIVE_WINDOW_MAIN_TAB ) . '">';
641 foreach ( $tabs as $tab ) {
642 $buffer .= sprintf(
643 '<wpd-tab value="%s">%s</wpd-tab>',
644 esc_attr( $tab['value'] ),
645 esc_html( $tab['label'] )
646 );
647 }
648 $buffer .= '</wpd-tabs>';
649
650 // Stamp `hidden` on every non-active panel directly in the
651 // emitted HTML. The client-side `<wpd-tabs>` syncs panel
652 // visibility on `value` changes, but its initial sync runs
653 // inside a microtask — and panel siblings may not have upgraded
654 // in time on first paint. Setting the attribute server-side
655 // makes first paint correct regardless of upgrade order; the JS
656 // keeps owning subsequent transitions.
657 foreach ( $tabs as $tab ) {
658 if ( ! is_callable( $tab['template'] ) ) {
659 continue;
660 }
661 $is_active = DESKTOP_MODE_NATIVE_WINDOW_MAIN_TAB === $tab['value'];
662 $buffer .= sprintf(
663 '<wpd-tabpanel for="%s"%s>',
664 esc_attr( $tab['value'] ),
665 $is_active ? '' : ' hidden'
666 );
667 ob_start();
668 call_user_func( $tab['template'] );
669 $buffer .= (string) ob_get_clean();
670 $buffer .= '</wpd-tabpanel>';
671 }
672
673 $buffer .= '</wpd-stack>';
674 return $buffer;
675 }
676
677 /**
678 * Enqueue every registered native window's script when the shell
679 * is active. Runs on `admin_enqueue_scripts` alongside the main
680 * shell enqueue so ordering (shell → plugin scripts) is
681 * deterministic.
682 *
683 * @since 0.10.0
684 */
685 function desktop_mode_enqueue_native_window_scripts() {
686 if ( ! desktop_mode_is_enabled() || desktop_mode_is_chromeless_request() || desktop_mode_is_classic_request() ) {
687 return;
688 }
689 $registry = desktop_mode_native_window_registry();
690 if ( ! is_array( $registry ) ) {
691 return;
692 }
693 foreach ( $registry as $entry ) {
694 // Enqueue per-tab scripts — each tab registration can carry
695 // its own script handle so a tab's JS module stays scoped to
696 // that tab. Main tab uses the window's own `script`; it's
697 // enqueued below alongside the localize call.
698 $tabs = desktop_mode_get_native_window_tabs( $entry['id'] );
699 foreach ( $tabs as $tab ) {
700 if ( $tab['is_main'] || empty( $tab['script'] ) ) {
701 continue;
702 }
703 wp_enqueue_script( $tab['script'] );
704 }
705
706 if ( empty( $entry['script'] ) ) {
707 continue;
708 }
709 wp_enqueue_script( $entry['script'] );
710 // Localize the config the JS side reads to register itself.
711 wp_localize_script(
712 $entry['script'],
713 'desktopModeNativeWindow_' . str_replace( '-', '_', $entry['id'] ),
714 array(
715 'id' => $entry['id'],
716 'title' => $entry['title'],
717 'icon' => $entry['icon'],
718 'width' => $entry['width'],
719 'height' => $entry['height'],
720 'minWidth' => $entry['min_width'],
721 'minHeight' => $entry['min_height'],
722 'placement' => $entry['placement'],
723 'autofocus' => $entry['autofocus'],
724 'templateId' => 'desktop-mode-native-window-' . $entry['id'],
725 'tabs' => array_map(
726 static function ( $tab ) {
727 return array(
728 'value' => $tab['value'],
729 'label' => $tab['label'],
730 'isMain' => $tab['is_main'],
731 );
732 },
733 $tabs
734 ),
735 )
736 );
737
738 // Bundle-bound `config` (since 0.6.0). Ships through
739 // `wp_add_inline_script` `'before'` so it lands on the eager
740 // path the same way `wp_localize_script` does, AND through
741 // the lazy-load payload (see `desktop_mode_resolve_script_payload`)
742 // so the same data is available even when the script is
743 // dynamically injected mid-session. The bundle reads it via
744 // `wp.desktop.getWindowConfig( id )` or directly at
745 // `window.desktopModeWindowConfig[ id ]`.
746 if ( ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) {
747 wp_add_inline_script(
748 $entry['script'],
749 sprintf(
750 'window.desktopModeWindowConfig=window.desktopModeWindowConfig||{};window.desktopModeWindowConfig[%s]=%s;',
751 wp_json_encode( $entry['id'] ),
752 wp_json_encode( $entry['config'] )
753 ),
754 'before'
755 );
756 }
757 }
758 }
759 add_action( 'admin_enqueue_scripts', 'desktop_mode_enqueue_native_window_scripts', 20 );
760
761 /**
762 * Emit a `<template>` tag for every registered native window on
763 * `admin_footer` when the shell is active. The JS side resolves
764 * these via `document.getElementById( `desktop-mode-native-window-${id}` )`
765 * and clones them into each opened window's body.
766 *
767 * @since 0.10.0
768 */
769 function desktop_mode_render_native_window_templates() {
770 if ( ! desktop_mode_is_enabled() || desktop_mode_is_chromeless_request() || desktop_mode_is_classic_request() ) {
771 return;
772 }
773 $registry = desktop_mode_native_window_registry();
774 if ( ! is_array( $registry ) ) {
775 return;
776 }
777 foreach ( $registry as $entry ) {
778 if ( ! is_callable( $entry['template'] ) ) {
779 continue;
780 }
781 $html = desktop_mode_build_native_window_template_html( $entry );
782 if ( '' === $html ) {
783 continue;
784 }
785 printf(
786 '<template id="desktop-mode-native-window-%s">',
787 esc_attr( $entry['id'] )
788 );
789 // `desktop_mode_kses_native_window_template()` auto-extends
790 // the allowlist with any `<wpd-*>` tag the template carries
791 // — so plugin authors never have to remember to register
792 // their custom component tags in the kses list.
793 echo desktop_mode_kses_native_window_template( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper kses-escapes.
794 echo '</template>';
795 }
796 }
797 add_action( 'admin_footer', 'desktop_mode_render_native_window_templates', 20 );
798