From c521837a3a15132817cef47b0c8bd657f0d5f532 Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Sun, 14 Dec 2025 00:13:32 +0200 Subject: [PATCH 01/12] WIP: DynamicPanel --- .../src/frontstage/Panels.stories.tsx | 82 ++++++++++ docs/storybook/src/frontstage/Panels.tsx | 34 +++++ ui/appui-react/src/appui-react.ts | 3 + .../appui-react/frontstage/FrontstageDef.tsx | 91 ++++++++++- .../appui-react/hooks/useConditionalValue.tsx | 1 + .../appui-react/layout/StandardLayout.scss | 2 + .../layout/widget-panels/Panel.tsx | 2 + .../src/appui-react/panel/DynamicPanel.scss | 36 +++++ .../src/appui-react/panel/DynamicPanel.tsx | 68 +++++++++ ui/appui-react/src/appui-react/panel/Panel.ts | 41 +++++ .../src/appui-react/panel/PanelsState.tsx | 141 ++++++++++++++++++ .../ui-items-provider/UiItemsManager.ts | 22 +++ .../ui-items-provider/UiItemsProvider.ts | 3 + .../appui-react/widget-panels/Frontstage.tsx | 43 +++--- 14 files changed, 548 insertions(+), 21 deletions(-) create mode 100644 docs/storybook/src/frontstage/Panels.stories.tsx create mode 100644 docs/storybook/src/frontstage/Panels.tsx create mode 100644 ui/appui-react/src/appui-react/panel/DynamicPanel.scss create mode 100644 ui/appui-react/src/appui-react/panel/DynamicPanel.tsx create mode 100644 ui/appui-react/src/appui-react/panel/Panel.ts create mode 100644 ui/appui-react/src/appui-react/panel/PanelsState.tsx diff --git a/docs/storybook/src/frontstage/Panels.stories.tsx b/docs/storybook/src/frontstage/Panels.stories.tsx new file mode 100644 index 00000000000..79370090846 --- /dev/null +++ b/docs/storybook/src/frontstage/Panels.stories.tsx @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Page } from "../AppUiStory"; +import { PanelsStory } from "./Panels"; +import { createWidget, removeProperty } from "../Utils"; +import { Button } from "@itwin/itwinui-react"; +import { useActiveFrontstageDef, usePanelsStore } from "@itwin/appui-react"; + +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: <>Hello world, + type: "dynamic", + placement: "left", + label: "Dynamic panel 1", + }, + ], + getWidgets: () => [ + createWidget(1, { + content: , + }), + ], + }; + }, + }, +}; + +function Widget() { + const frontstageDef = useActiveFrontstageDef(); + const state = usePanelsStore((state) => state); + return ( +
+ Widget 1 Content + +
+ ); +} 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 0a89f917d62..2f720a1092b 100644 --- a/ui/appui-react/src/appui-react.ts +++ b/ui/appui-react/src/appui-react.ts @@ -390,6 +390,9 @@ export { StandardRotationNavigationAidControl, } from "./appui-react/navigationaids/StandardRotationNavigationAid.js"; +export { Panel } from "./appui-react/panel/Panel.js"; +export { usePanelsStore } from "./appui-react/panel/PanelsState.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..a2de1d84398 100644 --- a/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx +++ b/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx @@ -55,7 +55,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 +63,7 @@ import { FRONTSTAGE_SETTINGS_NAMESPACE, getFrontstageStateSettingName, } from "../widget-panels/Frontstage.js"; +import type { createPanelsStore } from "../panel/PanelsState.js"; /** FrontstageDef class provides an API for a Frontstage. * @public @@ -96,6 +97,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 +154,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 +308,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 +1159,72 @@ export function useSpecificWidgetDef(widgetId: string) { }, [frontstageDef, widgetId]); return widgetDef; } + +interface OpenPanelArgs { + id: string; +} + +interface OpenDynamicPanelArgs { + id: string; + placement: "left" | "right"; +} + +interface ClosePanelArgs { + id: string; +} + +interface CloseDynamicPanelArgs { + placement: "left" | "right"; + id?: string; +} + +interface FrontstagePanels { + open: (args: OpenPanelArgs) => void; + close: (args: ClosePanelArgs) => void; + openDynamic: (args: OpenDynamicPanelArgs) => void; + closeDynamic: (args: CloseDynamicPanelArgs) => void; +} + +function createFrontstagePanels( + frontstageDef: FrontstageDef +): FrontstagePanels { + return { + open: (args) => { + const panelsStore = frontstageDef.getPanelsStore(); + if (!panelsStore) return; + const state = panelsStore.getState(); + state.open(args.id); + }, + close: (args) => { + const panelsStore = frontstageDef.getPanelsStore(); + if (!panelsStore) return; + const state = panelsStore.getState(); + + if (!args.id) return; + state.close(args.id); + }, + openDynamic: (args) => { + const { id, placement } = args; + const panelsStore = frontstageDef.getPanelsStore(); + if (!panelsStore) return; + const state = panelsStore.getState(); + state.dynamic[placement].open(id); + }, + closeDynamic: (args) => { + const { id, placement } = args; + const panelsStore = frontstageDef.getPanelsStore(); + if (!panelsStore) return; + const state = panelsStore.getState(); + + const panelSlice = state.dynamic[placement]; + if (!id) { + panelSlice.close(); + return; + } + + if (!panelSlice.activePanel) return; + if (panelSlice.activePanel.id !== id) return; + panelSlice.close(); + }, + }; +} 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..1f7ed62cfc5 100644 --- a/ui/appui-react/src/appui-react/layout/StandardLayout.scss +++ b/ui/appui-react/src/appui-react/layout/StandardLayout.scss @@ -74,6 +74,8 @@ .nz-standardLayout_leftPanel { grid-area: lp; + display: flex; + flex-direction: row; } .nz-standardLayout_rightPanel { 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..dc7833d3194 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 { DynamicPanel } from "../../panel/DynamicPanel.js"; /** Properties of [[WidgetPanelProvider]] component. * @internal @@ -53,6 +54,7 @@ export function WidgetPanelProvider({ side }: WidgetPanelProviderProps) { + {side === "left" && } ); } 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..a29c86108ce --- /dev/null +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.scss @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * 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-right: 1px solid var(--iui-color-border); + padding: $padding; + min-width: 200px; + max-width: 350px; + + display: flex; + flex-direction: column; + gap: $padding; + } + + .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..3d35349e1ee --- /dev/null +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Divider, IconButton } from "@itwin/itwinui-react"; +import { SvgCloseSmall } from "@itwin/itwinui-icons-react"; +import { useConditionalValue } from "../hooks/useConditionalValue.js"; +import type { ConditionalValue } from "../shared/ConditionalValue.js"; +import { usePanelsStore } from "./PanelsState.js"; + +interface DynamicPanelProps { + side: "left" | "right"; +} + +/** @internal */ +export function DynamicPanel(props: DynamicPanelProps) { + const { side } = props; + const panelSlice = usePanelsStore((state) => { + if (side === "left") return state.dynamic.left; + return state.dynamic.right; + }); + const { activePanel, close } = panelSlice; + const label = useConditionalValue( + () => { + if (!activePanel) return undefined; + if (isConditionalValue(activePanel.label)) { + return activePanel.label.getValue(); + } + return activePanel.label; + }, + isConditionalValue(activePanel?.label) ? activePanel.label.eventIds : [] + ); + if (!activePanel) return null; + return ( +
+
+ {label} + + + +
+ +
{activePanel.content}
+
+ ); +} + +function isConditionalValue( + value: T | ConditionalValue +): value is ConditionalValue { + return ( + typeof value === "object" && + value !== null && + "eventIds" in value && + "getValue" in value + ); +} 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..408b2513aa1 --- /dev/null +++ b/ui/appui-react/src/appui-react/panel/Panel.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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"; +} + +/** @internal */ +export 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..408bf2344ab --- /dev/null +++ b/ui/appui-react/src/appui-react/panel/PanelsState.tsx @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { useSafeContext } from "../hooks/useSafeContext.js"; +import type { DynamicPanel } from "./Panel.js"; +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"; + +interface PanelsState { + panels: Panel[]; + setPanels: (panels: Panel[]) => void; + dynamic: { + left: DynamicPanelSlice; + right: DynamicPanelSlice; + }; + open: (id: Panel["id"]) => void; + close: (id: Panel["id"]) => void; +} + +interface DynamicPanelSlice { + activePanel: DynamicPanel | undefined; + open: (id: Panel["id"]) => void; + close: () => void; +} + +const createDynamicPanelSlice = + ( + side: "left" | "right" + ): StateCreator => + (set) => ({ + activePanel: 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].activePanel = panel; + } + }) + ); + }, + close: () => { + set((state) => + produce(state, (draft) => { + draft.dynamic[side].activePanel = undefined; + }) + ); + }, + }); + +/** @internal */ +export function createPanelsStore(stateOverrides?: Partial) { + return createStore((set, get, store) => { + return { + panels: [], + setPanels: (panels: Panel[]) => set({ panels }), + open: (id: Panel["id"]) => { + set((state) => + produce(state, (draft) => { + const panel = state.panels.find((p) => p.id === id); + if (!panel) return; + + if (isDynamicPanel(panel)) { + const placement = panel.placement; + if (placement === "left") { + draft.dynamic.left.activePanel = panel; + } + } + }) + ); + }, + close: (id: Panel["id"]) => { + set((state) => + produce(state, (draft) => { + if (draft.dynamic.left.activePanel?.id === id) { + draft.dynamic.left.activePanel = undefined; + } + }) + ); + }, + dynamic: { + left: createDynamicPanelSlice("left")(set, get, store), + right: createDynamicPanelSlice("right")(set, get, store), + }, + ...stateOverrides, + }; + }); +} + +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]); + return ( + + {props.children} + + ); +} + +/** @internal */ +export function usePanelsStore( + selector: (state: PanelsState) => SelectorOutput +) { + const store = useSafeContext(PanelsStoreContext); + return useStore(store, selector); +} 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={} + > + + + + + + From 1c73df775ff2fc39538c512baeae71d32c88b8c1 Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:42:46 +0200 Subject: [PATCH 02/12] WIP: panels API --- .../src/frontstage/Panels.stories.tsx | 27 +++++-- ui/appui-react/src/appui-react.ts | 1 - .../appui-react/frontstage/FrontstageDef.tsx | 67 +++++------------ .../src/appui-react/panel/DynamicPanel.tsx | 16 ++-- ui/appui-react/src/appui-react/panel/Panel.ts | 2 +- .../src/appui-react/panel/PanelsState.tsx | 75 +++++++++++++------ 6 files changed, 102 insertions(+), 86 deletions(-) diff --git a/docs/storybook/src/frontstage/Panels.stories.tsx b/docs/storybook/src/frontstage/Panels.stories.tsx index 79370090846..3ca2047d1fb 100644 --- a/docs/storybook/src/frontstage/Panels.stories.tsx +++ b/docs/storybook/src/frontstage/Panels.stories.tsx @@ -7,7 +7,9 @@ import { Page } from "../AppUiStory"; import { PanelsStory } from "./Panels"; import { createWidget, removeProperty } from "../Utils"; import { Button } from "@itwin/itwinui-react"; -import { useActiveFrontstageDef, usePanelsStore } from "@itwin/appui-react"; +import { useActiveFrontstageDef } from "@itwin/appui-react"; +import React from "react"; +import { action } from "storybook/internal/actions"; const meta = { title: "Frontstage/Panels", @@ -60,18 +62,31 @@ export const DynamicPanel: Story = { function Widget() { const frontstageDef = useActiveFrontstageDef(); - const state = usePanelsStore((state) => state); + const [isActive, setIsActive] = React.useState(() => { + if (!frontstageDef) return false; + return frontstageDef?.panels.getOpenPanels().includes("panel1"); + }); + React.useEffect(() => { + if (!frontstageDef) return; + return frontstageDef.panels.onPanelOpenChanged.addListener((args) => { + action("onPanelOpenChanged")(args); + if (args.id !== "panel1") return; + setIsActive(args.open); + }); + }, [frontstageDef]); return (
Widget 1 Content +
); diff --git a/ui/appui-react/src/appui-react/layout/StandardLayout.scss b/ui/appui-react/src/appui-react/layout/StandardLayout.scss index 1f7ed62cfc5..441525ba636 100644 --- a/ui/appui-react/src/appui-react/layout/StandardLayout.scss +++ b/ui/appui-react/src/appui-react/layout/StandardLayout.scss @@ -80,6 +80,8 @@ .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 dc7833d3194..20564a4f9c1 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 @@ -32,6 +32,10 @@ import { useAnimatePanel } from "./useAnimatePanel.js"; import { useMaximizedPanel } from "../../preview/enable-maximized-widget/useMaximizedWidget.js"; import type { RectangleProps } from "../../utils/RectangleProps.js"; import { DynamicPanel } from "../../panel/DynamicPanel.js"; +import { + type DynamicPanelPlacement, + usePanelsStore, +} from "../../panel/PanelsState.js"; /** Properties of [[WidgetPanelProvider]] component. * @internal @@ -47,6 +51,11 @@ export function WidgetPanelProvider({ side }: WidgetPanelProviderProps) { const hasWidgets = useLayout( (state) => state.panels[side].widgets.length > 0 ); + const slice = usePanelsStore((state) => { + const placement = toDynamicPanelPlacement(side); + if (!placement) return undefined; + return state.dynamic[placement]; + }); return (
@@ -54,11 +63,19 @@ export function WidgetPanelProvider({ side }: WidgetPanelProviderProps) {
- {side === "left" && } + {slice?.active && }
); } +function toDynamicPanelPlacement( + side: PanelSide +): DynamicPanelPlacement | undefined { + if (side === "left") return "left"; + if (side === "right") return "right"; + return undefined; +} + /** @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 index a29c86108ce..d3c0726cb3c 100644 --- a/ui/appui-react/src/appui-react/panel/DynamicPanel.scss +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.scss @@ -8,7 +8,9 @@ .uifw-panel-dynamicPanel { position: relative; background-color: var(--iui-color-background); - border-right: 1px solid var(--iui-color-border); + border-width: 0; + border-style: solid; + border-color: var(--iui-color-border); padding: $padding; min-width: 200px; max-width: 350px; @@ -16,6 +18,14 @@ display: flex; flex-direction: column; gap: $padding; + + &:where([data-_appui-panel-side="left"]) { + border-right-width: 1px; + } + + &:where([data-_appui-panel-side="right"]) { + border-left-width: 1px; + } } .uifw-panel-dynamicPanel_header { diff --git a/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx index 577c2630612..40be11573be 100644 --- a/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx @@ -12,40 +12,28 @@ import { Divider, IconButton } from "@itwin/itwinui-react"; import { SvgCloseSmall } from "@itwin/itwinui-icons-react"; import { useConditionalValue } from "../hooks/useConditionalValue.js"; import type { ConditionalValue } from "../shared/ConditionalValue.js"; -import { usePanelsStore } from "./PanelsState.js"; +import type { DynamicPanel, DynamicPanelSlice } from "./PanelsState.js"; +import { PanelSideContext } from "../layout/widget-panels/Panel.js"; interface DynamicPanelProps { - side: "left" | "right"; + panel: DynamicPanel; + slice: DynamicPanelSlice; } /** @internal */ -export function DynamicPanel(props: DynamicPanelProps) { - const { side } = props; - const panelSlice = usePanelsStore((state) => { - if (side === "left") return state.dynamic.left; - return state.dynamic.right; - }); - const { active: panel, close } = panelSlice; - const label = useConditionalValue( - () => { - if (!panel) return undefined; - if (isConditionalValue(panel.label)) { - return panel.label.getValue(); - } - return panel.label; - }, - isConditionalValue(panel?.label) ? panel.label.eventIds : [] - ); - if (!panel) return null; +function DynamicPanelComponent(props: DynamicPanelProps) { + const side = React.useContext(PanelSideContext); + const { panel, slice } = props; + const label = useConditionalValueProp(panel.label); return ( -
+
{label} @@ -56,9 +44,9 @@ export function DynamicPanel(props: DynamicPanelProps) { ); } -function isConditionalValue( - value: T | ConditionalValue -): value is ConditionalValue { +function isConditionalValue( + value: T | ConditionalValue +): value is ConditionalValue { return ( typeof value === "object" && value !== null && @@ -66,3 +54,17 @@ function isConditionalValue( "getValue" in value ); } + +function useConditionalValueProp(prop: T | ConditionalValue): T { + return useConditionalValue( + () => { + if (isConditionalValue(prop)) { + return prop.getValue(); + } + return prop; + }, + isConditionalValue(prop) ? prop.eventIds : [] + ); +} + +export { DynamicPanelComponent as DynamicPanel }; diff --git a/ui/appui-react/src/appui-react/panel/PanelsState.tsx b/ui/appui-react/src/appui-react/panel/PanelsState.tsx index bf18f064d54..9fe45b7f775 100644 --- a/ui/appui-react/src/appui-react/panel/PanelsState.tsx +++ b/ui/appui-react/src/appui-react/panel/PanelsState.tsx @@ -11,7 +11,14 @@ import { UiItemsManager } from "../ui-items-provider/UiItemsManager.js"; import { useActiveFrontstageDef } from "../frontstage/FrontstageDef.js"; import { produce } from "immer"; -type DynamicPanel = Extract; +/** @internal */ +export type DynamicPanel = Extract; + +/** @internal */ +export type DynamicPanelPlacement = Extract< + DynamicPanel["placement"], + "left" | "right" +>; interface OpenPanelArgs { id: string; @@ -38,7 +45,8 @@ export interface PanelsState { close: (args: ClosePanelArgs) => void; } -interface DynamicPanelSlice { +/** @internal */ +export interface DynamicPanelSlice { active: DynamicPanel | undefined; open: (id: Panel["id"]) => void; close: () => void; @@ -87,7 +95,12 @@ export function createPanelsStore(stateOverrides?: Partial) { const panel = draft.panels.find((p) => p.id === args.id); if (!panel) return; if (!isDynamicPanel(panel)) return; - draft.dynamic.left.active = panel; + const placement = (() => { + if (panel.placement === "left") return "left"; + if (panel.placement === "right") return "right"; + return "left"; + })(); + draft.dynamic[placement].active = panel; }) ); }, @@ -98,7 +111,16 @@ export function createPanelsStore(stateOverrides?: Partial) { draft.dynamic[args.placement].active = undefined; return; } - draft.dynamic.left.active = undefined; + + const placements = ["left", "right"] as const; + for (const placement of placements) { + const slice = draft.dynamic[placement]; + const panel = slice.active; + if (!panel) continue; + if (panel.id !== args.id) continue; + + slice.active = undefined; + } }) ); }, @@ -140,19 +162,22 @@ export function PanelsProvider(props: React.PropsWithChildren) { React.useEffect(() => { if (!frontstageDef) return; return store.subscribe((state, prevState) => { - const prevOpen = prevState.dynamic.left.active?.id; - const currOpen = state.dynamic.left.active?.id; - if (prevOpen === currOpen) return; - prevOpen && - frontstageDef.panels.onPanelOpenChanged.raiseEvent({ - id: prevOpen, - open: false, - }); - currOpen && - frontstageDef.panels.onPanelOpenChanged.raiseEvent({ - id: currOpen, - open: true, - }); + 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 ( From 69667e9a06f6255ebbae40a961d6d26a13366cde Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:28:58 +0200 Subject: [PATCH 05/12] fix sb --- docs/storybook/src/frontstage/Panels.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/storybook/src/frontstage/Panels.stories.tsx b/docs/storybook/src/frontstage/Panels.stories.tsx index 9131158584b..0d551e930e8 100644 --- a/docs/storybook/src/frontstage/Panels.stories.tsx +++ b/docs/storybook/src/frontstage/Panels.stories.tsx @@ -9,7 +9,7 @@ import { createWidget, removeProperty } from "../Utils"; import { Button } from "@itwin/itwinui-react"; import { useActiveFrontstageDef } from "@itwin/appui-react"; import React from "react"; -import { action } from "storybook/internal/actions"; +import { action } from "storybook/actions"; const meta = { title: "Frontstage/Panels", From 4fb744cd24c39abb6d47ee9d214e0c55dd265067 Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:43:07 +0200 Subject: [PATCH 06/12] useSyncExternalStore --- .../src/frontstage/Panels.stories.tsx | 42 +++++++++++-------- .../appui-react/frontstage/FrontstageDef.tsx | 24 +++++++---- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/docs/storybook/src/frontstage/Panels.stories.tsx b/docs/storybook/src/frontstage/Panels.stories.tsx index 0d551e930e8..a7df6310299 100644 --- a/docs/storybook/src/frontstage/Panels.stories.tsx +++ b/docs/storybook/src/frontstage/Panels.stories.tsx @@ -2,14 +2,14 @@ * 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"; -import { Button } from "@itwin/itwinui-react"; -import { useActiveFrontstageDef } from "@itwin/appui-react"; -import React from "react"; -import { action } from "storybook/actions"; const meta = { title: "Frontstage/Panels", @@ -68,19 +68,27 @@ export const DynamicPanel: Story = { }, }; -function Widget() { +function useOpenPanels() { const frontstageDef = useActiveFrontstageDef(); - const [openPanels, setOpenPanels] = React.useState(() => { - if (!frontstageDef) return []; - return frontstageDef.panels.getOpenPanels(); - }); - React.useEffect(() => { - if (!frontstageDef) return; - return frontstageDef.panels.onPanelOpenChanged.addListener((args) => { - action("onPanelOpenChanged")(args); - setOpenPanels(frontstageDef.panels.getOpenPanels()); - }); + 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 (
{ const id = "panel1"; - const isActive = openPanels.some((p) => p === id); + const isActive = openPanels?.some((p) => p === id); if (isActive) { frontstageDef?.panels.close({ id, @@ -108,7 +116,7 @@ function Widget() { +
); } From db84b7c2645393ae15044df1608398e79e77c176 Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:19:02 +0200 Subject: [PATCH 09/12] Refactor --- .../appui-react/frontstage/FrontstageDef.tsx | 16 +++-- .../layout/widget-panels/Panel.tsx | 22 +------ .../src/appui-react/panel/DynamicPanel.scss | 4 +- .../src/appui-react/panel/DynamicPanel.tsx | 62 +++++++++++++++---- .../src/appui-react/panel/PanelsState.tsx | 13 ++-- 5 files changed, 74 insertions(+), 43 deletions(-) diff --git a/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx b/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx index b0147f69750..d69cbf6f099 100644 --- a/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx +++ b/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx @@ -64,7 +64,11 @@ import { FRONTSTAGE_SETTINGS_NAMESPACE, getFrontstageStateSettingName, } from "../widget-panels/Frontstage.js"; -import type { createPanelsStore, PanelsState } from "../panel/PanelsState.js"; +import { + type createPanelsStore, + dynamicPanelPlacements, + type PanelsState, +} from "../panel/PanelsState.js"; import type { Panel } from "../panel/Panel.js"; import { shallow } from "zustand/shallow"; @@ -1195,10 +1199,12 @@ function createFrontstagePanels( if (!panelsStore) return []; const state = panelsStore.getState(); const panels: Panel["id"][] = []; - if (state.dynamic.left.active) - panels.push(state.dynamic.left.active.id); - if (state.dynamic.right.active) - panels.push(state.dynamic.right.active.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; 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 20564a4f9c1..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,11 +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 { DynamicPanel } from "../../panel/DynamicPanel.js"; -import { - type DynamicPanelPlacement, - usePanelsStore, -} from "../../panel/PanelsState.js"; +import { DynamicPanelRenderer } from "../../panel/DynamicPanel.js"; /** Properties of [[WidgetPanelProvider]] component. * @internal @@ -51,11 +47,6 @@ export function WidgetPanelProvider({ side }: WidgetPanelProviderProps) { const hasWidgets = useLayout( (state) => state.panels[side].widgets.length > 0 ); - const slice = usePanelsStore((state) => { - const placement = toDynamicPanelPlacement(side); - if (!placement) return undefined; - return state.dynamic[placement]; - }); return (
@@ -63,19 +54,10 @@ export function WidgetPanelProvider({ side }: WidgetPanelProviderProps) {
- {slice?.active && } +
); } - -function toDynamicPanelPlacement( - side: PanelSide -): DynamicPanelPlacement | undefined { - if (side === "left") return "left"; - if (side === "right") return "right"; - return undefined; -} - /** @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 index d3c0726cb3c..9e5e43981ea 100644 --- a/ui/appui-react/src/appui-react/panel/DynamicPanel.scss +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.scss @@ -19,11 +19,11 @@ flex-direction: column; gap: $padding; - &:where([data-_appui-panel-side="left"]) { + &:where([data-_appui-placement="left"]) { border-right-width: 1px; } - &:where([data-_appui-panel-side="right"]) { + &:where([data-_appui-placement="right"]) { border-left-width: 1px; } } diff --git a/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx index d00af3fa9c8..8c52049a794 100644 --- a/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx @@ -10,37 +10,75 @@ import "./DynamicPanel.scss"; import * as React from "react"; import { Divider, IconButton } from "@itwin/itwinui-react"; import { SvgCloseSmall } from "@itwin/itwinui-icons-react"; -import type { DynamicPanel, DynamicPanelSlice } from "./PanelsState.js"; -import { PanelSideContext } from "../layout/widget-panels/Panel.js"; +import { type DynamicPanelPlacement, usePanelsStore } from "./PanelsState.js"; import { useConditionalValueProp } from "../shared/ConditionalValue.js"; +import { PanelSideContext } from "../layout/widget-panels/Panel.js"; +import type { PanelSide } from "../layout/widget-panels/PanelTypes.js"; +import { useSafeContext } from "../hooks/useSafeContext.js"; interface DynamicPanelProps { - panel: DynamicPanel; - slice: DynamicPanelSlice; + placement: DynamicPanelPlacement; + label: string | undefined; + onClose?: () => void; + content: React.ReactNode; } -/** @internal */ function DynamicPanelComponent(props: DynamicPanelProps) { - const side = React.useContext(PanelSideContext); - const { panel, slice } = props; - const label = useConditionalValueProp(panel.label); + const { placement, label, onClose, content } = props; return ( -
+
{label}
-
{panel.content}
+
{content}
); } -export { DynamicPanelComponent as DynamicPanel }; +interface FrameworkDynamicPanelProps { + placement: DynamicPanelPlacement; +} + +function FrameworkDynamicPanel(props: FrameworkDynamicPanelProps) { + const { placement } = props; + const slice = usePanelsStore((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 = useSafeContext(PanelSideContext); + const placement = toDynamicPanelPlacement(side); + if (!placement) return; + return ; +} + +function toDynamicPanelPlacement( + side: PanelSide +): DynamicPanelPlacement | undefined { + if (side === "left") return "left"; + if (side === "right") return "right"; + return undefined; +} diff --git a/ui/appui-react/src/appui-react/panel/PanelsState.tsx b/ui/appui-react/src/appui-react/panel/PanelsState.tsx index 9fe45b7f775..9bf5b516c0f 100644 --- a/ui/appui-react/src/appui-react/panel/PanelsState.tsx +++ b/ui/appui-react/src/appui-react/panel/PanelsState.tsx @@ -20,17 +20,23 @@ export type DynamicPanelPlacement = Extract< "left" | "right" >; +/** @internal */ +export const dynamicPanelPlacements = [ + "left", + "right", +] as const satisfies readonly DynamicPanelPlacement[]; + interface OpenPanelArgs { id: string; } type ClosePanelArgs = | { - id: string; + id: Panel["id"]; } | { type: "dynamic"; - placement: "left" | "right"; + placement: DynamicPanelPlacement; }; /** @internal */ @@ -112,8 +118,7 @@ export function createPanelsStore(stateOverrides?: Partial) { return; } - const placements = ["left", "right"] as const; - for (const placement of placements) { + for (const placement of dynamicPanelPlacements) { const slice = draft.dynamic[placement]; const panel = slice.active; if (!panel) continue; From d60170f7ad01e8a72153b31fdfcba404c78e7bbc Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:42:34 +0200 Subject: [PATCH 10/12] Extract API --- common/api/appui-react.api.md | 13 +++++++++++++ common/api/summary/appui-react.exports.csv | 1 + 2 files changed, 14 insertions(+) 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 From 47cff125dbd04aaab1ded98a646fa242e7d28623 Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:42:43 +0200 Subject: [PATCH 11/12] pnpm changeset --- .changeset/free-shoes-chew.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/free-shoes-chew.md 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" }); +``` From c1ace7bb65db97142b09915eebbfd96e6f28fafc Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:22:54 +0200 Subject: [PATCH 12/12] Fix tests --- .../src/appui-react/panel/DynamicPanel.tsx | 20 +++++++++++-------- .../src/appui-react/panel/PanelsState.tsx | 12 ++--------- .../test/hooks/useConditionalValue.test.tsx | 4 +++- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx index 8c52049a794..cdf2f5a8a0f 100644 --- a/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx +++ b/ui/appui-react/src/appui-react/panel/DynamicPanel.tsx @@ -8,13 +8,15 @@ 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 { type DynamicPanelPlacement, usePanelsStore } from "./PanelsState.js"; +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"; -import { useSafeContext } from "../hooks/useSafeContext.js"; interface DynamicPanelProps { placement: DynamicPanelPlacement; @@ -46,11 +48,12 @@ function DynamicPanelComponent(props: DynamicPanelProps) { interface FrameworkDynamicPanelProps { placement: DynamicPanelPlacement; + store: ReturnType; } function FrameworkDynamicPanel(props: FrameworkDynamicPanelProps) { - const { placement } = props; - const slice = usePanelsStore((state) => { + const { placement, store } = props; + const slice = useStore(store, (state) => { if (!placement) return undefined; return state.dynamic[placement]; }); @@ -69,14 +72,15 @@ function FrameworkDynamicPanel(props: FrameworkDynamicPanelProps) { /** @internal */ export function DynamicPanelRenderer() { - const side = useSafeContext(PanelSideContext); + const side = React.useContext(PanelSideContext); + const store = React.useContext(PanelsStoreContext); const placement = toDynamicPanelPlacement(side); - if (!placement) return; - return ; + if (!placement || !store) return; + return ; } function toDynamicPanelPlacement( - side: PanelSide + side: PanelSide | undefined ): DynamicPanelPlacement | undefined { if (side === "left") return "left"; if (side === "right") return "right"; diff --git a/ui/appui-react/src/appui-react/panel/PanelsState.tsx b/ui/appui-react/src/appui-react/panel/PanelsState.tsx index 9bf5b516c0f..054ff5d3772 100644 --- a/ui/appui-react/src/appui-react/panel/PanelsState.tsx +++ b/ui/appui-react/src/appui-react/panel/PanelsState.tsx @@ -5,7 +5,6 @@ import * as React from "react"; import type { StateCreator } from "zustand"; import { createStore, useStore } from "zustand"; -import { useSafeContext } from "../hooks/useSafeContext.js"; import { isDynamicPanel, type Panel } from "./Panel.js"; import { UiItemsManager } from "../ui-items-provider/UiItemsManager.js"; import { useActiveFrontstageDef } from "../frontstage/FrontstageDef.js"; @@ -134,7 +133,8 @@ export function createPanelsStore(stateOverrides?: Partial) { }); } -const PanelsStoreContext = React.createContext< +/** @internal */ +export const PanelsStoreContext = React.createContext< ReturnType | undefined >(undefined); @@ -191,11 +191,3 @@ export function PanelsProvider(props: React.PropsWithChildren) { ); } - -/** @internal */ -export function usePanelsStore( - selector: (state: PanelsState) => SelectorOutput -) { - const store = useSafeContext(PanelsStoreContext); - return useStore(store, selector); -} 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); });