| @@ -79,8 +79,9 @@ | ||
| 79 | 79 | /** |
| 80 | 80 | * @var array<string,mixed> |
| 81 | 81 | */ |
| 82 | 82 | private $nav = array( |
| 83 | + 'admin' => 'site', | |
| 83 | 84 | 'placement' => 'dock', |
| 84 | 85 | 'nav_kind' => 'app', |
| 85 | 86 | 'dock_order' => 0, |
| 86 | 87 | 'placeable' => false, |
| @@ -112,8 +113,16 @@ | ||
| 112 | 113 | */ |
| 113 | 114 | private $defaults = array(); |
| 114 | 115 | |
| 115 | 116 | /** |
| 117 | + * The admin menu this window answers for, and the tabs that ARE | |
| 118 | + * that menu's pages. See {@see App::menu()}. | |
| 119 | + * | |
| 120 | + * @var array<string,mixed>|null | |
| 121 | + */ | |
| 122 | + private $menu = null; | |
| 123 | + | |
| 124 | + /** | |
| 116 | 125 | * @var callable|null |
| 117 | 126 | */ |
| 118 | 127 | private $mount = null; |
| 119 | 128 | |
| @@ -301,8 +310,23 @@ | ||
| 301 | 310 | return $this; |
| 302 | 311 | } |
| 303 | 312 | |
| 304 | 313 | /** |
| 314 | + * Which admin's shell offers the window. `site` (the default) is | |
| 315 | + * every site's shell and never the network admin's — right for an | |
| 316 | + * app that reads the current site; `network` is the network admin's | |
| 317 | + * shell only; `any` is both. On a single-site install the network | |
| 318 | + * admin does not exist, so `network` there offers the app nowhere. | |
| 319 | + * | |
| 320 | + * @param string $admin `site` | `network` | `any`. | |
| 321 | + * @return self | |
| 322 | + */ | |
| 323 | + public function admin( $admin ) { | |
| 324 | + $this->nav['admin'] = in_array( $admin, array( 'site', 'network', 'any' ), true ) ? $admin : 'site'; | |
| 325 | + return $this; | |
| 326 | + } | |
| 327 | + | |
| 328 | + /** | |
| 305 | 329 | * What the window is to the navigation model. |
| 306 | 330 | * |
| 307 | 331 | * @param string $kind `app` | `control`. |
| 308 | 332 | * @return self |
| @@ -487,8 +511,110 @@ | ||
| 487 | 511 | return $this; |
| 488 | 512 | } |
| 489 | 513 | |
| 490 | 514 | /** |
| 515 | + * Declare the admin menu this window answers for, and the tabs | |
| 516 | + * that ARE that menu's pages. | |
| 517 | + * | |
| 518 | + * A window that replaces an admin screen replaces its menu too: | |
| 519 | + * whatever the window offers as a tab, the dock offers as a row, | |
| 520 | + * with the same label and in the same order, and picking a row | |
| 521 | + * opens the window on that tab. One declaration drives all three | |
| 522 | + * halves of that: | |
| 523 | + * | |
| 524 | + * - the dock's submenu for `$slug` becomes these tabs (the first | |
| 525 | + * is the menu's own page, so it becomes the tile's label); | |
| 526 | + * - each row's URL is the menu's own tagged `os_tab=<id>`, which | |
| 527 | + * the shell's remap reads back as the tab to open on; | |
| 528 | + * - `tab` becomes declared state, written on mount and reopen; | |
| 529 | + * - the tabs reach the client view as `menuTabs`, which its strip | |
| 530 | + * renders from, so the two lists cannot drift. | |
| 531 | + * | |
| 532 | + * A tab's value is its label, or `array( 'label' => …, 'page' => … )` | |
| 533 | + * naming the submenu slug it replaces (`user-new.php`). That page | |
| 534 | + * is which of wp-admin's own rows this window answers for — the | |
| 535 | + * rest are kept, so a plugin's page under this menu stays | |
| 536 | + * reachable — and what the shell routes here from anywhere else. | |
| 537 | + * | |
| 538 | + * @param string $slug Admin menu slug, e.g. `users.php`. | |
| 539 | + * @param array<string,mixed>|callable $tabs Ordered `id => label|array`, or a | |
| 540 | + * callable returning one (for tabs | |
| 541 | + * that depend on capabilities). | |
| 542 | + * @param callable|null $enabled Optional gate — the opt-in that | |
| 543 | + * decides whether this window answers | |
| 544 | + * for the menu at all. Default: always. | |
| 545 | + * @return self | |
| 546 | + */ | |
| 547 | + public function menu( $slug, $tabs, $enabled = null ) { | |
| 548 | + $this->menu = array( | |
| 549 | + 'slug' => (string) $slug, | |
| 550 | + 'tabs' => $tabs, | |
| 551 | + 'enabled' => $enabled, | |
| 552 | + ); | |
| 553 | + return $this; | |
| 554 | + } | |
| 555 | + | |
| 556 | + /** | |
| 557 | + * The declared menu's tabs for the CURRENT user, as an ordered | |
| 558 | + * list of `array( 'id', 'label' )`. Empty when no menu is declared. | |
| 559 | + * | |
| 560 | + * Not gated: these are the window's tabs whoever opened it and | |
| 561 | + * whatever the opt-in says, and the client view renders its strip | |
| 562 | + * from them. The gate decides only whether the DOCK's submenu | |
| 563 | + * becomes this list — see {@see App::menu_owns_dock()}. | |
| 564 | + * | |
| 565 | + * @return array<int,array<string,string>> | |
| 566 | + */ | |
| 567 | + public function menu_tabs() { | |
| 568 | + if ( ! $this->menu ) { | |
| 569 | + return array(); | |
| 570 | + } | |
| 571 | + $tabs = is_callable( $this->menu['tabs'] ) | |
| 572 | + ? (array) call_user_func( $this->menu['tabs'] ) | |
| 573 | + : (array) $this->menu['tabs']; | |
| 574 | + $out = array(); | |
| 575 | + foreach ( $tabs as $id => $tab ) { | |
| 576 | + $id = strtolower( (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $id ) ); | |
| 577 | + $label = is_array( $tab ) ? (string) ( $tab['label'] ?? '' ) : (string) $tab; | |
| 578 | + if ( '' === $id || '' === $label ) { | |
| 579 | + continue; | |
| 580 | + } | |
| 581 | + $out[] = array( | |
| 582 | + 'id' => $id, | |
| 583 | + 'label' => $label, | |
| 584 | + // The wp-admin submenu slug this tab stands in for, | |
| 585 | + // '' for a tab wp-admin has no page for. | |
| 586 | + 'page' => is_array( $tab ) ? (string) ( $tab['page'] ?? '' ) : '', | |
| 587 | + ); | |
| 588 | + } | |
| 589 | + return $out; | |
| 590 | + } | |
| 591 | + | |
| 592 | + /** | |
| 593 | + * The admin menu slug this window answers for, `''` when none. | |
| 594 | + * | |
| 595 | + * @return string | |
| 596 | + */ | |
| 597 | + public function menu_slug() { | |
| 598 | + return $this->menu ? (string) $this->menu['slug'] : ''; | |
| 599 | + } | |
| 600 | + | |
| 601 | + /** | |
| 602 | + * Whether this window is the one answering for its menu right | |
| 603 | + * now — the per-user opt-in that chooses between it and the | |
| 604 | + * classic screen. Only then does the dock's submenu become the | |
| 605 | + * window's tabs. | |
| 606 | + * | |
| 607 | + * @return bool | |
| 608 | + */ | |
| 609 | + public function menu_owns_dock() { | |
| 610 | + if ( ! $this->menu ) { | |
| 611 | + return false; | |
| 612 | + } | |
| 613 | + return ! is_callable( $this->menu['enabled'] ) || (bool) call_user_func( $this->menu['enabled'] ); | |
| 614 | + } | |
| 615 | + | |
| 616 | + /** | |
| 491 | 617 | * Runs once, before the first render, with the fresh state. |
| 492 | 618 | * |
| 493 | 619 | * @param callable $mount `function ( State $state, Os $os )`. |
| 494 | 620 | * @return self |
| @@ -765,8 +891,15 @@ | ||
| 765 | 891 | $config = $this->config; |
| 766 | 892 | foreach ( $this->config_lazy as $callable ) { |
| 767 | 893 | $config = array_merge( $config, (array) call_user_func( $callable, $this ) ); |
| 768 | 894 | } |
| 895 | + // The declared menu's tabs, for a client view that renders its | |
| 896 | + // strip from them — the only way the strip and the dock's | |
| 897 | + // submenu cannot drift, since both read this list. | |
| 898 | + $tabs = $this->menu_tabs(); | |
| 899 | + if ( $tabs ) { | |
| 900 | + $config['menuTabs'] = $tabs; | |
| 901 | + } | |
| 769 | 902 | return $config; |
| 770 | 903 | } |
| 771 | 904 | |
| 772 | 905 | // ----------------------------------------------------------- readers |
| @@ -785,9 +918,19 @@ | ||
| 785 | 918 | * |
| 786 | 919 | * @return array<string,mixed> |
| 787 | 920 | */ |
| 788 | 921 | public function defaults() { |
| 789 | - return $this->defaults; | |
| 922 | + if ( ! $this->menu || array_key_exists( 'tab', $this->defaults ) ) { | |
| 923 | + return $this->defaults; | |
| 924 | + } | |
| 925 | + // Declared here rather than by every app that declares a menu: | |
| 926 | + // the runtime writes this key from the `tab` open-time param, | |
| 927 | + // and a key the schema does not carry would be dropped. | |
| 928 | + $tabs = $this->menu_tabs(); | |
| 929 | + return array_merge( | |
| 930 | + array( 'tab' => $tabs ? $tabs[0]['id'] : '' ), | |
| 931 | + $this->defaults | |
| 932 | + ); | |
| 790 | 933 | } |
| 791 | 934 | |
| 792 | 935 | /** |
| 793 | 936 | * Whether an action is declared. |
| @@ -795,8 +938,9 @@ | ||
| 795 | 938 | * @param string $name Action name. |
| 796 | 939 | * @return bool |
| 797 | 940 | */ |
| 798 | 941 | public function has_action( $name ) { |
| 942 | + $this->ensure_menu_reopen(); | |
| 799 | 943 | return isset( $this->actions[ (string) $name ] ); |
| 800 | 944 | } |
| 801 | 945 | |
| 802 | 946 | /** |
| @@ -804,12 +948,32 @@ | ||
| 804 | 948 | * |
| 805 | 949 | * @return string[] |
| 806 | 950 | */ |
| 807 | 951 | public function action_names() { |
| 952 | + $this->ensure_menu_reopen(); | |
| 808 | 953 | return array_keys( $this->actions ); |
| 809 | 954 | } |
| 810 | 955 | |
| 811 | 956 | /** |
| 957 | + * A window that declares a menu answers `reopen`, whether or not | |
| 958 | + * it wrote a handler for one. | |
| 959 | + * | |
| 960 | + * The client dispatches a lifecycle action only when the manifest | |
| 961 | + * says the app declared it, so without this the runtime's own | |
| 962 | + * "land on the tab the opener named" never runs on a window that | |
| 963 | + * is already open — the case a dock row for another tab IS. | |
| 964 | + * Registered here rather than in {@see App::menu()} so an app's | |
| 965 | + * own actions keep the order they were declared in. | |
| 966 | + * | |
| 967 | + * @return void | |
| 968 | + */ | |
| 969 | + private function ensure_menu_reopen() { | |
| 970 | + if ( $this->menu && ! isset( $this->actions['reopen'] ) ) { | |
| 971 | + $this->actions['reopen'] = static function () {}; | |
| 972 | + } | |
| 973 | + } | |
| 974 | + | |
| 975 | + /** | |
| 812 | 976 | * Run the mount hook, if any. |
| 813 | 977 | * |
| 814 | 978 | * @param State $state State. |
| 815 | 979 | * @param Os $os Host handle. |
| @@ -986,8 +1150,9 @@ | ||
| 986 | 1150 | 'width' => $this->size['width'], |
| 987 | 1151 | 'height' => $this->size['height'], |
| 988 | 1152 | 'min_width' => $this->size['min_width'], |
| 989 | 1153 | 'min_height' => $this->size['min_height'], |
| 1154 | + 'admin' => $this->nav['admin'], | |
| 990 | 1155 | 'placement' => $this->nav['placement'], |
| 991 | 1156 | 'nav_kind' => $this->nav['nav_kind'], |
| 992 | 1157 | 'dock_order' => $this->nav['dock_order'], |
| 993 | 1158 | 'placeable' => $this->nav['placeable'], |
| @@ -1000,8 +1165,10 @@ | ||
| 1000 | 1165 | 'title_bar_buttons' => $this->title_bar_buttons, |
| 1001 | 1166 | 'window_actions' => $this->window_actions, |
| 1002 | 1167 | 'appearance' => $this->appearance, |
| 1003 | 1168 | 'config' => $this->resolved_config(), |
| 1169 | + 'menu' => $this->menu_slug(), | |
| 1170 | + 'menu_tabs' => $this->menu_tabs(), | |
| 1004 | 1171 | 'tabs' => $this->tabs(), |
| 1005 | 1172 | 'channels' => $this->channels, |
| 1006 | 1173 | 'watch' => $this->watch, |
| 1007 | 1174 | 'client' => $this->client_path(), |