diff --git a/.changeset/free-shoes-chew.md b/.changeset/free-shoes-chew.md new file mode 100644 index 00000000000..b0c5feed478 --- /dev/null +++ b/.changeset/free-shoes-chew.md @@ -0,0 +1,23 @@ +--- +"@itwin/appui-react": minor +--- + +Added `getPanels` property to `UiItemsProvider` interface which allows panel items to be defined. Once defined the `panels` getter of `FrontstageDef` class can be used to control the visibility and behavior of provided panels. + +```tsx +UiItemsManager.register({ + id: "my-provider", + getPanels: () => [ + { + id: "panel1", + type: "dynamic", + placement: "left", + label: "Panel 1", + content: <>Panel 1 content, + }, + ], +}); + +const frontstageDef = UiFramework.frontstages.activeFrontstageDef; +frontstageDef?.panels.open({ id: "panel1" }); +``` diff --git a/common/api/appui-react.api.md b/common/api/appui-react.api.md index 185387d8aab..28855c4f77d 100644 --- a/common/api/appui-react.api.md +++ b/common/api/appui-react.api.md @@ -94,6 +94,7 @@ import { SnapMode } from '@itwin/core-frontend'; import type { SolarDataProvider } from '@itwin/imodel-components-react'; import { StandardViewId } from '@itwin/core-frontend'; import type { Store } from 'redux'; +import { StoreApi } from 'zustand'; import type { StringGetter } from '@itwin/appui-abstract'; import type { ToggleSwitch } from '@itwin/itwinui-react'; import { Tool } from '@itwin/core-frontend'; @@ -2352,6 +2353,8 @@ export class FrontstageDef { getFloatingWidgetContainerIdByWidgetId(widgetId: string): string | undefined; // (undocumented) getFloatingWidgetContainerIds(): string[]; + // @internal (undocumented) + getPanelsStore(): ReturnType | undefined; // @beta getStagePanelDef(location: StagePanelLocation): StagePanelDef | undefined; // (undocumented) @@ -2386,6 +2389,8 @@ export class FrontstageDef { openPopoutWidgetContainer(widgetContainerId: string, oldState: NineZoneState | undefined): boolean; // @beta get panelDefs(): StagePanelDef[]; + // (undocumented) + get panels(): FrontstagePanels; // @beta popoutWidget(widgetId: string, position?: XAndY, size?: SizeProps): void; // @beta @@ -2404,6 +2409,8 @@ export class FrontstageDef { setFloatingWidgetContainerBounds(floatingWidgetId: string, bounds: RectangleProps): boolean; // @internal (undocumented) setIsApplicationClosing(value: boolean): void; + // @internal (undocumented) + setPanelsStore(panelsStore: ReturnType): void; // (undocumented) get statusBar(): WidgetDef | undefined; // @internal (undocumented) @@ -3450,6 +3457,9 @@ export interface OverflowToolbarOptions { overflowExpandsTo?: Direction; } +// @public +export type Panel = CommonPanel | InformationPanel | DynamicPanel; + // @public @deprecated export interface PanelPinnedChangedEventArgs { // (undocumented) @@ -5246,6 +5256,8 @@ export class UiItemsManager { // @internal static clearAllProviders(): void; static getBackstageItems(): ReadonlyArray>; + // (undocumented) + static getPanels(stageId: string, stageUsage: string): ReadonlyArray>; static getStatusBarItems(stageId: string, stageUsage: string): ReadonlyArray>; static getToolbarButtonItems(stageId: string, stageUsage: string, usage: ToolbarUsage, orientation: ToolbarOrientation): ReadonlyArray>; static getToolbarItems(stageId: string, stageUsage: string): ReadonlyArray>; @@ -5264,6 +5276,7 @@ export class UiItemsManager { // @public export interface UiItemsProvider { readonly getBackstageItems?: () => ReadonlyArray; + readonly getPanels?: () => ReadonlyArray; readonly getStatusBarItems?: () => ReadonlyArray; readonly getToolbarItems?: () => ReadonlyArray; readonly getWidgets?: () => ReadonlyArray; diff --git a/common/api/summary/appui-react.exports.csv b/common/api/summary/appui-react.exports.csv index d9847a78ec8..9fbb6934b65 100644 --- a/common/api/summary/appui-react.exports.csv +++ b/common/api/summary/appui-react.exports.csv @@ -478,6 +478,7 @@ public;interface;OpenChildWindowInfo public;class;OpenMessageCenterEvent deprecated;class;OpenMessageCenterEvent public;interface;OverflowToolbarOptions +public;type;Panel public;interface;PanelPinnedChangedEventArgs deprecated;interface;PanelPinnedChangedEventArgs beta;class;PanelStateChangedEvent diff --git a/docs/storybook/src/frontstage/Panels.stories.tsx b/docs/storybook/src/frontstage/Panels.stories.tsx new file mode 100644 index 00000000000..286075b00fb --- /dev/null +++ b/docs/storybook/src/frontstage/Panels.stories.tsx @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from "react"; +import { useActiveFrontstageDef } from "@itwin/appui-react"; +import { Button } from "@itwin/itwinui-react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { action } from "storybook/actions"; +import { Page } from "../AppUiStory"; +import { PanelsStory } from "./Panels"; +import { createWidget, removeProperty } from "../Utils"; + +const meta = { + title: "Frontstage/Panels", + component: PanelsStory, + tags: ["autodocs"], + parameters: { + docs: { + page: () => , + }, + layout: "fullscreen", + }, + args: { + getItemProvider: ({}) => { + return { + id: "items", + }; + }, + }, + argTypes: { + getItemProvider: removeProperty(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DynamicPanel: Story = { + args: { + getItemProvider: () => { + return { + id: "items", + getPanels: () => [ + { + id: "panel1", + content: <>Panel 1 content, + type: "dynamic", + placement: "left", + label: "Dynamic panel 1", + }, + { + id: "panel2", + content: <>Panel 2 content, + type: "dynamic", + placement: "right", + label: "Dynamic panel 2", + }, + { + id: "panel3", + content: <>Panel 3 content, + type: "dynamic", + placement: "left", + label: "Dynamic panel 3", + }, + ], + getWidgets: () => [ + createWidget(1, { + content: , + }), + createWidget(2), + ], + }; + }, + }, +}; + +function useOpenPanels() { + const frontstageDef = useActiveFrontstageDef(); + const subscribe = React.useCallback( + (onStoreChange: () => void) => { + if (!frontstageDef) return () => {}; + return frontstageDef.panels.onPanelOpenChanged.addListener((args) => { + action("onPanelOpenChanged")(args); + onStoreChange(); + }); + }, + [frontstageDef] + ); + const getSnapshot = React.useCallback(() => { + return frontstageDef?.panels.getOpenPanels(); + }, [frontstageDef]); + return React.useSyncExternalStore(subscribe, getSnapshot); +} + +function Widget() { + const frontstageDef = useActiveFrontstageDef(); + const openPanels = useOpenPanels(); + return ( +
+ + + +
+ ); +} diff --git a/docs/storybook/src/frontstage/Panels.tsx b/docs/storybook/src/frontstage/Panels.tsx new file mode 100644 index 00000000000..dc102223c8b --- /dev/null +++ b/docs/storybook/src/frontstage/Panels.tsx @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import { + StagePanelState, + UiFramework, + UiItemsProvider, +} from "@itwin/appui-react"; +import { AppUiStory } from "../AppUiStory"; +import { createFrontstage } from "../Utils"; + +interface PanelsStoryProps { + getItemProvider: (props: PanelsStoryProps) => UiItemsProvider; +} + +export function PanelsStory(props: PanelsStoryProps) { + const frontstage = createFrontstage({ + leftPanelProps: { + defaultState: StagePanelState.Open, + }, + }); + const provider = props.getItemProvider?.(props); + return ( + { + UiFramework.visibility.autoHideUi = false; + }} + /> + ); +} diff --git a/ui/appui-react/src/appui-react.ts b/ui/appui-react/src/appui-react.ts index 0581df9b541..e6e1992aebd 100644 --- a/ui/appui-react/src/appui-react.ts +++ b/ui/appui-react/src/appui-react.ts @@ -391,6 +391,8 @@ export { StandardRotationNavigationAidControl, } from "./appui-react/navigationaids/StandardRotationNavigationAid.js"; +export { Panel } from "./appui-react/panel/Panel.js"; + export { ExpandableSection, ExpandableSectionProps, diff --git a/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx b/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx index f90c599674a..d69cbf6f099 100644 --- a/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx +++ b/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx @@ -7,6 +7,7 @@ */ import { UiError } from "@itwin/appui-abstract"; +import { BeEvent } from "@itwin/core-bentley"; import { BentleyStatus } from "@itwin/core-bentley"; import type { ScreenViewport } from "@itwin/core-frontend"; import { @@ -55,7 +56,7 @@ import type { FrontstageProvider } from "./FrontstageProvider.js"; import { InternalFrontstageManager } from "./InternalFrontstageManager.js"; import { StageUsage } from "./StageUsage.js"; import type { Frontstage } from "./Frontstage.js"; -import { UiItemsProvider } from "../ui-items-provider/UiItemsProvider.js"; +import type { UiItemsProvider } from "../ui-items-provider/UiItemsProvider.js"; import { FrameworkContent } from "../framework/FrameworkContent.js"; import type { SizeProps } from "../utils/SizeProps.js"; import type { RectangleProps } from "../utils/RectangleProps.js"; @@ -63,6 +64,13 @@ import { FRONTSTAGE_SETTINGS_NAMESPACE, getFrontstageStateSettingName, } from "../widget-panels/Frontstage.js"; +import { + type createPanelsStore, + dynamicPanelPlacements, + type PanelsState, +} from "../panel/PanelsState.js"; +import type { Panel } from "../panel/Panel.js"; +import { shallow } from "zustand/shallow"; /** FrontstageDef class provides an API for a Frontstage. * @public @@ -96,6 +104,8 @@ export class FrontstageDef { private _toolAdminDefaultToolId?: string; private _dispatch?: NineZoneDispatch; private _batching = false; + private _panelsStore?: ReturnType; + private _panels?: FrontstagePanels; public get id(): string { return this._id; @@ -151,6 +161,13 @@ export class FrontstageDef { return this._contentGroup; } + public get panels(): FrontstagePanels { + if (!this._panels) { + this._panels = createFrontstagePanels(this); + } + return this._panels; + } + /** @internal */ public get initialConfig(): Frontstage | undefined { return this._initialConfig; @@ -298,6 +315,16 @@ export class FrontstageDef { this._dispatch = dispatch; } + /** @internal */ + public setPanelsStore(panelsStore: ReturnType) { + this._panelsStore = panelsStore; + } + + /** @internal */ + public getPanelsStore(): ReturnType | undefined { + return this._panelsStore; + } + /** Dispatch multiple actions inside `fn`, but trigger events once. * @internal */ @@ -1139,3 +1166,52 @@ export function useSpecificWidgetDef(widgetId: string) { }, [frontstageDef, widgetId]); return widgetDef; } + +interface FrontstagePanels { + open: PanelsState["open"]; + close: PanelsState["close"]; + getOpenPanels: () => Panel["id"][]; + onPanelOpenChanged: BeEvent< + (args: { id: Panel["id"]; open: boolean }) => void + >; +} + +function createFrontstagePanels( + frontstageDef: FrontstageDef +): FrontstagePanels { + let prevOpenPanels: Panel["id"][] = []; + return { + open: (args) => { + const panelsStore = frontstageDef.getPanelsStore(); + if (!panelsStore) return; + const state = panelsStore.getState(); + state.open(args); + }, + close: (args) => { + const panelsStore = frontstageDef.getPanelsStore(); + if (!panelsStore) return; + const state = panelsStore.getState(); + state.close(args); + }, + getOpenPanels: () => { + const openPanels = (() => { + const panelsStore = frontstageDef.getPanelsStore(); + if (!panelsStore) return []; + const state = panelsStore.getState(); + const panels: Panel["id"][] = []; + for (const placement of dynamicPanelPlacements) { + const slice = state.dynamic[placement]; + const panel = slice.active; + if (!panel) continue; + panels.push(panel.id); + } + return panels; + })(); + if (shallow(openPanels, prevOpenPanels)) return prevOpenPanels; + + prevOpenPanels = openPanels; + return openPanels; + }, + onPanelOpenChanged: new BeEvent(), + }; +} diff --git a/ui/appui-react/src/appui-react/hooks/useConditionalValue.tsx b/ui/appui-react/src/appui-react/hooks/useConditionalValue.tsx index c2e43dbaf52..093e1b1b457 100644 --- a/ui/appui-react/src/appui-react/hooks/useConditionalValue.tsx +++ b/ui/appui-react/src/appui-react/hooks/useConditionalValue.tsx @@ -19,6 +19,7 @@ export function useConditionalValue(getValue: () => T, eventIds: string[]) { const getValueRef = React.useRef(getValue); React.useEffect(() => { getValueRef.current = getValue; + setValue(getValue()); }, [getValue]); const eventIdsRef = React.useRef(eventIds); diff --git a/ui/appui-react/src/appui-react/layout/StandardLayout.scss b/ui/appui-react/src/appui-react/layout/StandardLayout.scss index 503f2dc56d3..441525ba636 100644 --- a/ui/appui-react/src/appui-react/layout/StandardLayout.scss +++ b/ui/appui-react/src/appui-react/layout/StandardLayout.scss @@ -74,10 +74,14 @@ .nz-standardLayout_leftPanel { grid-area: lp; + display: flex; + flex-direction: row; } .nz-standardLayout_rightPanel { grid-area: rp; + display: flex; + flex-direction: row-reverse; } .nz-standardLayout_bottomPanel { diff --git a/ui/appui-react/src/appui-react/layout/widget-panels/Panel.tsx b/ui/appui-react/src/appui-react/layout/widget-panels/Panel.tsx index 938c4092426..32885f53368 100644 --- a/ui/appui-react/src/appui-react/layout/widget-panels/Panel.tsx +++ b/ui/appui-react/src/appui-react/layout/widget-panels/Panel.tsx @@ -31,6 +31,7 @@ import type { import { useAnimatePanel } from "./useAnimatePanel.js"; import { useMaximizedPanel } from "../../preview/enable-maximized-widget/useMaximizedWidget.js"; import type { RectangleProps } from "../../utils/RectangleProps.js"; +import { DynamicPanelRenderer } from "../../panel/DynamicPanel.js"; /** Properties of [[WidgetPanelProvider]] component. * @internal @@ -53,10 +54,10 @@ export function WidgetPanelProvider({ side }: WidgetPanelProviderProps) { + ); } - /** @internal */ export function WidgetPanel() { const side = React.useContext(PanelSideContext); diff --git a/ui/appui-react/src/appui-react/panel/DynamicPanel.scss b/ui/appui-react/src/appui-react/panel/DynamicPanel.scss new file mode 100644 index 00000000000..9e5e43981ea --- /dev/null +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.scss @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +@layer appui.component { + $padding: var(--iui-size-s); + + .uifw-panel-dynamicPanel { + position: relative; + background-color: var(--iui-color-background); + border-width: 0; + border-style: solid; + border-color: var(--iui-color-border); + padding: $padding; + min-width: 200px; + max-width: 350px; + + display: flex; + flex-direction: column; + gap: $padding; + + &:where([data-_appui-placement="left"]) { + border-right-width: 1px; + } + + &:where([data-_appui-placement="right"]) { + border-left-width: 1px; + } + } + + .uifw-panel-dynamicPanel_header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .uifw-panel-dynamicPanel_label { + font-size: var(--iui-font-size-1); + font-weight: var(--iui-font-weight-semibold); + } + + .uifw-panel-dynamicPanel_divider { + margin-left: calc(-1 * $padding); + margin-right: calc(-1 * $padding); + } +} diff --git a/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx new file mode 100644 index 00000000000..cdf2f5a8a0f --- /dev/null +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Frontstage + */ + +import "./DynamicPanel.scss"; +import * as React from "react"; +import { useStore } from "zustand"; +import { Divider, IconButton } from "@itwin/itwinui-react"; +import { SvgCloseSmall } from "@itwin/itwinui-icons-react"; +import { PanelsStoreContext } from "./PanelsState.js"; +import { useConditionalValueProp } from "../shared/ConditionalValue.js"; +import { PanelSideContext } from "../layout/widget-panels/Panel.js"; +import type { createPanelsStore } from "./PanelsState.js"; +import type { DynamicPanelPlacement } from "./PanelsState.js"; +import type { PanelSide } from "../layout/widget-panels/PanelTypes.js"; + +interface DynamicPanelProps { + placement: DynamicPanelPlacement; + label: string | undefined; + onClose?: () => void; + content: React.ReactNode; +} + +function DynamicPanelComponent(props: DynamicPanelProps) { + const { placement, label, onClose, content } = props; + return ( +
+
+ {label} + + + +
+ +
{content}
+
+ ); +} + +interface FrameworkDynamicPanelProps { + placement: DynamicPanelPlacement; + store: ReturnType; +} + +function FrameworkDynamicPanel(props: FrameworkDynamicPanelProps) { + const { placement, store } = props; + const slice = useStore(store, (state) => { + if (!placement) return undefined; + return state.dynamic[placement]; + }); + const panel = slice?.active; + const label = useConditionalValueProp(panel?.label); + if (!panel) return null; + return ( + + ); +} + +/** @internal */ +export function DynamicPanelRenderer() { + const side = React.useContext(PanelSideContext); + const store = React.useContext(PanelsStoreContext); + const placement = toDynamicPanelPlacement(side); + if (!placement || !store) return; + return ; +} + +function toDynamicPanelPlacement( + side: PanelSide | undefined +): DynamicPanelPlacement | undefined { + if (side === "left") return "left"; + if (side === "right") return "right"; + return undefined; +} diff --git a/ui/appui-react/src/appui-react/panel/Panel.ts b/ui/appui-react/src/appui-react/panel/Panel.ts new file mode 100644 index 00000000000..6e8a47c039c --- /dev/null +++ b/ui/appui-react/src/appui-react/panel/Panel.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Frontstage + */ + +import type { ConditionalValue } from "../shared/ConditionalValue.js"; + +type PanelType = "information" | "dynamic" | (string & {}); + +interface CommonPanel { + /** Unique identifier of the panel. */ + readonly id: string; + /** Content of the panel. */ + readonly content: React.ReactNode; + /** Type of the panel. */ + readonly type?: PanelType; + readonly label?: string | ConditionalValue; +} + +interface InformationPanel extends CommonPanel { + readonly type: "information"; +} + +interface DynamicPanel extends CommonPanel { + readonly type: "dynamic"; + readonly placement?: "left" | "right" | (string & {}); +} + +/** Describes the data needed to provide a panel. + * @public + */ +export type Panel = CommonPanel | InformationPanel | DynamicPanel; + +/** @internal */ +export function isDynamicPanel(panel: Panel): panel is DynamicPanel { + return panel.type === "dynamic"; +} diff --git a/ui/appui-react/src/appui-react/panel/PanelsState.tsx b/ui/appui-react/src/appui-react/panel/PanelsState.tsx new file mode 100644 index 00000000000..054ff5d3772 --- /dev/null +++ b/ui/appui-react/src/appui-react/panel/PanelsState.tsx @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from "react"; +import type { StateCreator } from "zustand"; +import { createStore, useStore } from "zustand"; +import { isDynamicPanel, type Panel } from "./Panel.js"; +import { UiItemsManager } from "../ui-items-provider/UiItemsManager.js"; +import { useActiveFrontstageDef } from "../frontstage/FrontstageDef.js"; +import { produce } from "immer"; + +/** @internal */ +export type DynamicPanel = Extract; + +/** @internal */ +export type DynamicPanelPlacement = Extract< + DynamicPanel["placement"], + "left" | "right" +>; + +/** @internal */ +export const dynamicPanelPlacements = [ + "left", + "right", +] as const satisfies readonly DynamicPanelPlacement[]; + +interface OpenPanelArgs { + id: string; +} + +type ClosePanelArgs = + | { + id: Panel["id"]; + } + | { + type: "dynamic"; + placement: DynamicPanelPlacement; + }; + +/** @internal */ +export interface PanelsState { + panels: Panel[]; + setPanels: (panels: Panel[]) => void; + dynamic: { + left: DynamicPanelSlice; + right: DynamicPanelSlice; + }; + open: (args: OpenPanelArgs) => void; + close: (args: ClosePanelArgs) => void; +} + +/** @internal */ +export interface DynamicPanelSlice { + active: DynamicPanel | undefined; + open: (id: Panel["id"]) => void; + close: () => void; +} + +const createDynamicPanelSlice = + ( + side: "left" | "right" + ): StateCreator => + (set) => ({ + active: undefined, + open: (id: Panel["id"]) => { + set((state) => + produce(state, (draft) => { + const panel = draft.panels.find((p) => p.id === id); + if (!panel) return; + + if (isDynamicPanel(panel)) { + draft.dynamic[side].active = panel; + } + }) + ); + }, + close: () => { + set((state) => + produce(state, (draft) => { + draft.dynamic[side].active = undefined; + }) + ); + }, + }); + +/** @internal */ +export function createPanelsStore(stateOverrides?: Partial) { + return createStore((set, get, store) => { + return { + panels: [], + setPanels: (panels: Panel[]) => set({ panels }), + dynamic: { + left: createDynamicPanelSlice("left")(set, get, store), + right: createDynamicPanelSlice("right")(set, get, store), + }, + open: (args) => { + set((state) => + produce(state, (draft) => { + const panel = draft.panels.find((p) => p.id === args.id); + if (!panel) return; + if (!isDynamicPanel(panel)) return; + const placement = (() => { + if (panel.placement === "left") return "left"; + if (panel.placement === "right") return "right"; + return "left"; + })(); + draft.dynamic[placement].active = panel; + }) + ); + }, + close: (args) => { + set((state) => + produce(state, (draft) => { + if ("type" in args) { + draft.dynamic[args.placement].active = undefined; + return; + } + + for (const placement of dynamicPanelPlacements) { + const slice = draft.dynamic[placement]; + const panel = slice.active; + if (!panel) continue; + if (panel.id !== args.id) continue; + + slice.active = undefined; + } + }) + ); + }, + ...stateOverrides, + }; + }); +} + +/** @internal */ +export const PanelsStoreContext = React.createContext< + ReturnType | undefined +>(undefined); + +/** @internal */ +export function PanelsProvider(props: React.PropsWithChildren) { + const frontstageDef = useActiveFrontstageDef(); + const [store] = React.useState(() => { + const panels = frontstageDef + ? [...UiItemsManager.getPanels(frontstageDef.id, frontstageDef.usage)] + : undefined; + return createPanelsStore({ + panels, + }); + }); + const setPanels = useStore(store, (state) => state.setPanels); + React.useEffect(() => { + return UiItemsManager.onUiProviderRegisteredEvent.addListener(() => { + if (!frontstageDef) return; + const panels = UiItemsManager.getPanels( + frontstageDef.id, + frontstageDef.usage + ); + setPanels([...panels]); + }); + }, [frontstageDef, setPanels]); + React.useEffect(() => { + if (!frontstageDef) return; + frontstageDef.setPanelsStore(store); + }, [store, frontstageDef]); + React.useEffect(() => { + if (!frontstageDef) return; + return store.subscribe((state, prevState) => { + const placements = ["left", "right"] as const; + for (const placement of placements) { + const prevOpen = prevState.dynamic[placement].active?.id; + const currOpen = state.dynamic[placement].active?.id; + if (prevOpen === currOpen) continue; + prevOpen && + frontstageDef.panels.onPanelOpenChanged.raiseEvent({ + id: prevOpen, + open: false, + }); + currOpen && + frontstageDef.panels.onPanelOpenChanged.raiseEvent({ + id: currOpen, + open: true, + }); + } + }); + }, [frontstageDef, store]); + return ( + + {props.children} + + ); +} diff --git a/ui/appui-react/src/appui-react/shared/ConditionalValue.ts b/ui/appui-react/src/appui-react/shared/ConditionalValue.tsx similarity index 75% rename from ui/appui-react/src/appui-react/shared/ConditionalValue.ts rename to ui/appui-react/src/appui-react/shared/ConditionalValue.tsx index 055f5f9b449..93e175ea2d7 100644 --- a/ui/appui-react/src/appui-react/shared/ConditionalValue.ts +++ b/ui/appui-react/src/appui-react/shared/ConditionalValue.tsx @@ -10,7 +10,7 @@ import { ConditionalStringValue as _ConditionalStringValue, } from "@itwin/appui-abstract"; import { ConditionalIconItem as _ConditionalIconItem } from "@itwin/core-react"; -import type { useConditionalValue } from "../hooks/useConditionalValue.js"; +import { useConditionalValue } from "../hooks/useConditionalValue.js"; /** Interface used to track the conditional value of a generic type `T`. The `getValue` function should be called when sync event is emitted that matches one of specified `eventIds` values. * @note Use {@link useConditionalValue} hook to get the value. @@ -38,3 +38,27 @@ export type ConditionalStringValue = _ConditionalStringValue; /** @public */ // eslint-disable-next-line @typescript-eslint/no-redeclare export const ConditionalStringValue = _ConditionalStringValue; + +function isConditionalValue( + value: T | ConditionalValue +): value is ConditionalValue { + return ( + typeof value === "object" && + value !== null && + "eventIds" in value && + "getValue" in value + ); +} + +/** @internal */ +export function useConditionalValueProp(prop: T | ConditionalValue): T { + return useConditionalValue( + () => { + if (isConditionalValue(prop)) { + return prop.getValue(); + } + return prop; + }, + isConditionalValue(prop) ? prop.eventIds : [] + ); +} diff --git a/ui/appui-react/src/appui-react/ui-items-provider/UiItemsManager.ts b/ui/appui-react/src/appui-react/ui-items-provider/UiItemsManager.ts index d6cfbd70091..6f29f9d8edc 100644 --- a/ui/appui-react/src/appui-react/ui-items-provider/UiItemsManager.ts +++ b/ui/appui-react/src/appui-react/ui-items-provider/UiItemsManager.ts @@ -25,6 +25,7 @@ import { createAbstractUiItemsManagerAdapter, createGetPropertyAdapter, } from "./AbstractUiItemsManager.js"; +import type { Panel } from "../panel/Panel.js"; /** UiItemsProvider register event args. * @public @@ -282,6 +283,27 @@ export class UiItemsManager { return getUniqueItems(items); } + public static getPanels( + stageId: string, + stageUsage: string + ): ReadonlyArray> { + const items: ProviderItem[] = []; + UiItemsManager._registeredUiItemsProviders.forEach((entry) => { + const uiProvider = entry.provider; + const providerId = entry.overrides?.providerId ?? uiProvider.id; + if (!this.allowItemsFromProvider(entry, stageId, stageUsage)) return; + + const providerItems = + uiProvider.getPanels?.().map((item) => ({ + ...item, + providerId, + })) ?? []; + items.push(...providerItems); + }); + + return getUniqueItems(items); + } + /** Returns registered status bar items that match the specified frontstage id and usage. * @note Items registered in `UiItemsManager` of `@itwin/appui-abstract` are returned by this method. * @note Items returned by {@link UiItemsProvider.provideStatusBarItems} are returned by this method. diff --git a/ui/appui-react/src/appui-react/ui-items-provider/UiItemsProvider.ts b/ui/appui-react/src/appui-react/ui-items-provider/UiItemsProvider.ts index 6e1a1058609..d2076e90c79 100644 --- a/ui/appui-react/src/appui-react/ui-items-provider/UiItemsProvider.ts +++ b/ui/appui-react/src/appui-react/ui-items-provider/UiItemsProvider.ts @@ -7,6 +7,7 @@ */ import type { BackstageItem } from "../backstage/BackstageItem.js"; +import type { Panel } from "../panel/Panel.js"; import type { StagePanelLocation } from "../stagepanels/StagePanelLocation.js"; import type { StagePanelSection } from "../stagepanels/StagePanelSection.js"; import type { StatusBarItem } from "../statusbar/StatusBarItem.js"; @@ -37,6 +38,8 @@ export interface UiItemsProvider { * @note Use {@link Widget.layouts} to map item to location previously specified by `provideWidgets` arguments. */ readonly getWidgets?: () => ReadonlyArray; + /** Provides panels. */ + readonly getPanels?: () => ReadonlyArray; /** Provides toolbar items. * @deprecated in 4.15.0. Use {@link UiItemsProvider.getToolbarItems} instead. To map item to location previously specified by arguments use {@link CommonToolbarItem.layouts}. diff --git a/ui/appui-react/src/appui-react/widget-panels/Frontstage.tsx b/ui/appui-react/src/appui-react/widget-panels/Frontstage.tsx index bae8c0953a6..3f2a187a6d9 100644 --- a/ui/appui-react/src/appui-react/widget-panels/Frontstage.tsx +++ b/ui/appui-react/src/appui-react/widget-panels/Frontstage.tsx @@ -69,6 +69,7 @@ import { useSaveFrontstageSettings } from "./useSaveFrontstageSettings.js"; import type { UiStateStorageResult } from "../uistate/UiStateStorage.js"; import { UiStateStorageStatus } from "../uistate/UiStateStorage.js"; import { useLatestRef } from "../hooks/useLatestRef.js"; +import { PanelsProvider } from "../panel/PanelsState.js"; function WidgetPanelsFrontstageComponent() { const activeModalFrontstageInfo = useActiveModalFrontstageInfo(); @@ -82,26 +83,28 @@ function WidgetPanelsFrontstageComponent() { enabled={previewFeatures.horizontalPanelAlignment} > - - - - - - } - toolSettings={} - statusBar={} - topPanel={} - leftPanel={} - rightPanel={} - bottomPanel={} - > - - - - - + + + + + + + } + toolSettings={} + statusBar={} + topPanel={} + leftPanel={} + rightPanel={} + bottomPanel={} + > + + + + + + diff --git a/ui/appui-react/src/test/hooks/useConditionalValue.test.tsx b/ui/appui-react/src/test/hooks/useConditionalValue.test.tsx index 5ce42e83f46..683e649b9d3 100644 --- a/ui/appui-react/src/test/hooks/useConditionalValue.test.tsx +++ b/ui/appui-react/src/test/hooks/useConditionalValue.test.tsx @@ -21,18 +21,20 @@ describe("useConditionalValue", () => { let counter = 0; const { result } = renderHook(() => useConditionalValue(() => { - return counter++; + return counter; }, ["myEvent1"]) ); expect(result.current).toEqual(0); act(() => { + counter++; SyncUiEventDispatcher.dispatchSyncUiEvent("myEvent1"); vi.advanceTimersByTime(timeToWaitForUiSyncCallback); }); expect(result.current).toEqual(1); act(() => { + counter++; SyncUiEventDispatcher.dispatchSyncUiEvent("myevent1"); vi.advanceTimersByTime(timeToWaitForUiSyncCallback); });