diff --git a/.changeset/bottom-toolbar-positions.md b/.changeset/bottom-toolbar-positions.md new file mode 100644 index 00000000000..7e95b7a28c6 --- /dev/null +++ b/.changeset/bottom-toolbar-positions.md @@ -0,0 +1,5 @@ +--- +"@itwin/appui-react": minor +--- + +Added `BottomContentManipulation` and `BottomViewNavigation` values to the `ToolbarUsage` enum. The standard layout now automatically renders bottom toolbar positions when a `UiItemsProvider` returns items with these usages — no custom overlay component is needed. diff --git a/common/api/appui-react.api.md b/common/api/appui-react.api.md index cb5f1e03ded..7e49f77cf58 100644 --- a/common/api/appui-react.api.md +++ b/common/api/appui-react.api.md @@ -655,6 +655,21 @@ export interface BasicToolWidgetProps { showCategoryAndModelsContextTools?: boolean; } +// @public +export function BottomContentToolWidgetComposer(): React_2.JSX.Element; + +// @public +export function BottomToolWidgetComposer(props: BottomToolWidgetComposerProps): React_2.JSX.Element | null; + +// @public +export interface BottomToolWidgetComposerProps { + horizontalToolbar?: React_2.ReactNode; + verticalToolbar?: React_2.ReactNode; +} + +// @public +export function BottomViewToolWidgetComposer(): React_2.JSX.Element; + // @alpha export class BumpToolSetting extends Tool { // (undocumented) @@ -2276,7 +2291,9 @@ export interface FrontstageActivatedEventArgs { // @public @deprecated export interface FrontstageConfig extends CommonProps { + readonly bottomContentManipulation?: WidgetConfig; readonly bottomPanel?: StagePanelConfig; + readonly bottomViewNavigation?: WidgetConfig; readonly contentGroup: ContentGroup | ContentGroupProvider; readonly contentManipulation?: WidgetConfig; readonly defaultTool?: string; @@ -2317,7 +2334,11 @@ export class FrontstageDef { // @internal batch(fn: () => void): void; // (undocumented) + get bottomContentManipulation(): WidgetDef | undefined; + // (undocumented) get bottomPanel(): StagePanelDef | undefined; + // (undocumented) + get bottomViewNavigation(): WidgetDef | undefined; // @deprecated get contentControls(): ContentControl[]; // (undocumented) @@ -4958,6 +4979,8 @@ export interface ToolbarProps extends CommonProps, NoChildrenProps { // @public export enum ToolbarUsage { + BottomContentManipulation = 2, + BottomViewNavigation = 3, ContentManipulation = 0, ViewNavigation = 1 } @@ -5427,7 +5450,7 @@ export function useUiStateStorageHandler(): UiStateStorage; // @public export function useWidget(): { state: WidgetState; - widgetLocation: "popout" | "docked" | "floating"; + widgetLocation: "docked" | "floating" | "popout"; setState: (widgetState: Omit) => void; }; diff --git a/common/api/summary/appui-react.exports.csv b/common/api/summary/appui-react.exports.csv index 49290859902..908bbb2bb62 100644 --- a/common/api/summary/appui-react.exports.csv +++ b/common/api/summary/appui-react.exports.csv @@ -96,6 +96,10 @@ deprecated;interface;BasicNavigationWidgetProps public;function;BasicToolWidget public;interface;BasicToolWidgetProps deprecated;interface;BasicToolWidgetProps +public;function;BottomContentToolWidgetComposer +public;function;BottomToolWidgetComposer +public;interface;BottomToolWidgetComposerProps +public;function;BottomViewToolWidgetComposer alpha;class;BumpToolSetting public;class;Calculator public;class;CalculatorPopup diff --git a/ui/appui-react/src/appui-react.ts b/ui/appui-react/src/appui-react.ts index 0581df9b541..9dacf21e798 100644 --- a/ui/appui-react/src/appui-react.ts +++ b/ui/appui-react/src/appui-react.ts @@ -812,6 +812,12 @@ export { ContentToolWidgetComposer, ContentToolWidgetComposerProps, } from "./appui-react/widgets/ContentToolWidgetComposer.js"; +export { BottomContentToolWidgetComposer } from "./appui-react/widgets/BottomContentToolWidgetComposer.js"; +export { + BottomToolWidgetComposer, + BottomToolWidgetComposerProps, +} from "./appui-react/widgets/BottomToolWidgetComposer.js"; +export { BottomViewToolWidgetComposer } from "./appui-react/widgets/BottomViewToolWidgetComposer.js"; export { NavigationAidHost, NavigationWidgetComposer, diff --git a/ui/appui-react/src/appui-react/frontstage/FrontstageConfig.ts b/ui/appui-react/src/appui-react/frontstage/FrontstageConfig.ts index 30bae1af019..40e16d0983a 100644 --- a/ui/appui-react/src/appui-react/frontstage/FrontstageConfig.ts +++ b/ui/appui-react/src/appui-react/frontstage/FrontstageConfig.ts @@ -50,6 +50,10 @@ export interface FrontstageConfig extends CommonProps { }; /** The top-right corner that shows view navigation tools. */ readonly viewNavigation?: WidgetConfig; + /** The bottom-left corner that shows tools typically used to query and modify content. */ + readonly bottomContentManipulation?: WidgetConfig; + /** The bottom-right corner that shows view navigation tools. */ + readonly bottomViewNavigation?: WidgetConfig; /** The status bar of the application. */ readonly statusBar?: WidgetConfig; diff --git a/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx b/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx index 3e00e605d94..e59c6fc4d29 100644 --- a/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx +++ b/ui/appui-react/src/appui-react/frontstage/FrontstageDef.tsx @@ -80,6 +80,8 @@ export class FrontstageDef { private _statusBar?: WidgetDef; private _contentManipulation?: WidgetDef; private _viewNavigation?: WidgetDef; + private _bottomContentManipulation?: WidgetDef; + private _bottomViewNavigation?: WidgetDef; private _topPanel?: StagePanelDef; private _leftPanel?: StagePanelDef; private _rightPanel?: StagePanelDef; @@ -130,6 +132,12 @@ export class FrontstageDef { public get viewNavigation(): WidgetDef | undefined { return this._viewNavigation; } + public get bottomContentManipulation(): WidgetDef | undefined { + return this._bottomContentManipulation; + } + public get bottomViewNavigation(): WidgetDef | undefined { + return this._bottomViewNavigation; + } public get topPanel(): StagePanelDef | undefined { return this._topPanel; @@ -600,6 +608,14 @@ export class FrontstageDef { config.viewNavigation, WidgetType.Navigation ); + this._bottomContentManipulation = createWidgetDef( + config.bottomContentManipulation, + WidgetType.Tool + ); + this._bottomViewNavigation = createWidgetDef( + config.bottomViewNavigation, + WidgetType.Navigation + ); this._topPanel = createStagePanelDef(config, StagePanelLocation.Top); this._leftPanel = createStagePanelDef(config, StagePanelLocation.Left); this._rightPanel = createStagePanelDef(config, StagePanelLocation.Right); diff --git a/ui/appui-react/src/appui-react/toolbar/ToolbarComposer.tsx b/ui/appui-react/src/appui-react/toolbar/ToolbarComposer.tsx index 568b72ad53a..4aa3bd639cc 100644 --- a/ui/appui-react/src/appui-react/toolbar/ToolbarComposer.tsx +++ b/ui/appui-react/src/appui-react/toolbar/ToolbarComposer.tsx @@ -257,10 +257,20 @@ export function ToolbarComposer(props: ExtensibleToolbarProps) { function toExpandsTo(orientation: ToolbarOrientation, usage: ToolbarUsage) { if (orientation === ToolbarOrientation.Vertical) { - if (usage === ToolbarUsage.ViewNavigation) return Direction.Left; + if ( + usage === ToolbarUsage.ViewNavigation || + usage === ToolbarUsage.BottomViewNavigation + ) + return Direction.Left; return Direction.Right; } + if ( + usage === ToolbarUsage.BottomContentManipulation || + usage === ToolbarUsage.BottomViewNavigation + ) + return Direction.Top; + return Direction.Bottom; } @@ -270,7 +280,8 @@ function toPanelAlignment( ) { if ( orientation === ToolbarOrientation.Horizontal && - usage === ToolbarUsage.ViewNavigation + (usage === ToolbarUsage.ViewNavigation || + usage === ToolbarUsage.BottomViewNavigation) ) return ToolbarPanelAlignment.End; diff --git a/ui/appui-react/src/appui-react/toolbar/ToolbarItem.ts b/ui/appui-react/src/appui-react/toolbar/ToolbarItem.ts index 9d3859efa2d..0ed90bf43f9 100644 --- a/ui/appui-react/src/appui-react/toolbar/ToolbarItem.ts +++ b/ui/appui-react/src/appui-react/toolbar/ToolbarItem.ts @@ -22,6 +22,10 @@ export enum ToolbarUsage { ContentManipulation = 0, /** Manipulate view/camera - in AppUI this is in top right of content area. */ ViewNavigation = 1, + /** Contains tools to Create Update and Delete content - rendered in bottom left of content area. */ + BottomContentManipulation = 2, + /** Manipulate view/camera - rendered in bottom right of content area. */ + BottomViewNavigation = 3, } /** Used to specify the orientation of the toolbar. diff --git a/ui/appui-react/src/appui-react/widget-panels/Toolbars.scss b/ui/appui-react/src/appui-react/widget-panels/Toolbars.scss index 13b80312987..37a3751924f 100644 --- a/ui/appui-react/src/appui-react/widget-panels/Toolbars.scss +++ b/ui/appui-react/src/appui-react/widget-panels/Toolbars.scss @@ -11,12 +11,59 @@ ); // TODO: Change this to an AppUI global CSS variable. box-sizing: border-box; grid-template-columns: 1fr 1fr; + grid-template-rows: auto 1fr auto; .nz-tools-widget { grid-column: 1; + grid-row: 1; &.nz-widget-navigationArea { grid-column: 2; + grid-row: 1; + } + } + + .uifw-widgetPanels-bottomToolbars { + grid-column: 1 / -1; + grid-row: 3; + display: flex; + justify-content: space-between; + align-items: flex-end; + pointer-events: none; + + > * { + pointer-events: auto; + } + } + + .uifw-bottom-toolArea { + display: grid; + grid-gap: 6px; + grid-template-areas: + "vtools ." + "vtools htools"; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + align-items: end; + justify-items: start; + } + + .uifw-bottom-toolArea_vertical { + grid-area: vtools; + display: inline-flex; + flex-direction: column; + } + + .uifw-bottom-toolArea_horizontal { + grid-area: htools; + min-width: 0; + } + + .uifw-bottom-toolArea_right { + margin-left: auto; + + .uifw-bottom-toolArea { + justify-items: end; } } } diff --git a/ui/appui-react/src/appui-react/widget-panels/Toolbars.tsx b/ui/appui-react/src/appui-react/widget-panels/Toolbars.tsx index 00cb40bf9b8..8db634a28d3 100644 --- a/ui/appui-react/src/appui-react/widget-panels/Toolbars.tsx +++ b/ui/appui-react/src/appui-react/widget-panels/Toolbars.tsx @@ -16,10 +16,22 @@ export function WidgetPanelsToolbars() { const frontstageDef = useActiveFrontstageDef(); const tools = frontstageDef?.contentManipulation?.reactNode; const navigation = frontstageDef?.viewNavigation?.reactNode; + const bottomTools = frontstageDef?.bottomContentManipulation?.reactNode; + const bottomNavigation = frontstageDef?.bottomViewNavigation?.reactNode; return (
{tools} {navigation} + {(bottomTools || bottomNavigation) && ( +
+ {bottomTools} + {bottomNavigation && ( +
+ {bottomNavigation} +
+ )} +
+ )}
); } diff --git a/ui/appui-react/src/appui-react/widgets/BottomContentToolWidgetComposer.tsx b/ui/appui-react/src/appui-react/widgets/BottomContentToolWidgetComposer.tsx new file mode 100644 index 00000000000..7f502437894 --- /dev/null +++ b/ui/appui-react/src/appui-react/widgets/BottomContentToolWidgetComposer.tsx @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Widget + */ + +import * as React from "react"; +import { ToolbarComposer } from "../toolbar/ToolbarComposer.js"; +import { BottomToolWidgetComposer } from "./BottomToolWidgetComposer.js"; +import { ToolbarOrientation, ToolbarUsage } from "../toolbar/ToolbarItem.js"; + +/** + * BottomContentToolWidgetComposer composes a bottom-left Tool Widget with no tools defined by default. + * UiItemsProviders are used to populate the toolbars by providing items with + * `ToolbarUsage.BottomContentManipulation`. + * @public + */ +export function BottomContentToolWidgetComposer() { + return ( + + } + verticalToolbar={ + + } + /> + ); +} diff --git a/ui/appui-react/src/appui-react/widgets/BottomToolWidgetComposer.tsx b/ui/appui-react/src/appui-react/widgets/BottomToolWidgetComposer.tsx new file mode 100644 index 00000000000..7ea0ac0de03 --- /dev/null +++ b/ui/appui-react/src/appui-react/widgets/BottomToolWidgetComposer.tsx @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Widget + */ + +import "../widget-panels/Toolbars.scss"; +import * as React from "react"; +import { + useProximityToMouse, + WidgetElementSet, + WidgetOpacityContext, +} from "@itwin/core-react/internal"; +import { UiFramework } from "../UiFramework.js"; +import { useUiVisibility } from "../hooks/useUiVisibility.js"; + +/** Properties for {@link BottomToolWidgetComposer}. + * @public + */ +export interface BottomToolWidgetComposerProps { + /** Optional Horizontal Toolbar */ + horizontalToolbar?: React.ReactNode; + /** Optional Vertical Toolbar */ + verticalToolbar?: React.ReactNode; +} + +/** + * BottomToolWidgetComposer renders an L-shaped toolbar area anchored to the bottom of the content area. + * The vertical toolbar grows upward and the horizontal toolbar is positioned at the bottom, + * offset by the vertical toolbar's width. + * @public + */ +export function BottomToolWidgetComposer( + props: BottomToolWidgetComposerProps +) { + const { horizontalToolbar, verticalToolbar } = props; + const [elementSet] = React.useState(new WidgetElementSet()); + const proximityScale = useProximityToMouse( + elementSet, + UiFramework.visibility.snapWidgetOpacity + ); + const uiIsVisible = useUiVisibility(); + + const addRef = React.useCallback< + React.ContextType["addRef"] + >( + (ref) => { + elementSet.add(ref); + }, + [elementSet] + ); + const removeRef = React.useCallback< + React.ContextType["removeRef"] + >( + (ref) => { + elementSet.delete(ref); + }, + [elementSet] + ); + + if (!uiIsVisible) return null; + + return ( + +
+
+ {verticalToolbar} +
+
+ {horizontalToolbar} +
+
+
+ ); +} diff --git a/ui/appui-react/src/appui-react/widgets/BottomViewToolWidgetComposer.tsx b/ui/appui-react/src/appui-react/widgets/BottomViewToolWidgetComposer.tsx new file mode 100644 index 00000000000..1573a93c3d1 --- /dev/null +++ b/ui/appui-react/src/appui-react/widgets/BottomViewToolWidgetComposer.tsx @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Widget + */ + +import * as React from "react"; +import { ToolbarComposer } from "../toolbar/ToolbarComposer.js"; +import { BottomToolWidgetComposer } from "./BottomToolWidgetComposer.js"; +import { ToolbarOrientation, ToolbarUsage } from "../toolbar/ToolbarItem.js"; + +/** + * BottomViewToolWidgetComposer composes a bottom-right Tool Widget with no tools defined by default. + * UiItemsProviders are used to populate the toolbars by providing items with + * `ToolbarUsage.BottomViewNavigation`. + * @public + */ +export function BottomViewToolWidgetComposer() { + return ( + + } + verticalToolbar={ + + } + /> + ); +} diff --git a/ui/appui-react/src/test/widget-panels/Toolbars.test.tsx b/ui/appui-react/src/test/widget-panels/Toolbars.test.tsx index ffc19330793..5acdb8651ab 100644 --- a/ui/appui-react/src/test/widget-panels/Toolbars.test.tsx +++ b/ui/appui-react/src/test/widget-panels/Toolbars.test.tsx @@ -4,7 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { render, screen } from "@testing-library/react"; import * as React from "react"; -import { FrontstageDef, UiFramework, WidgetDef } from "../../appui-react.js"; +import { + FrontstageDef, + UiFramework, + WidgetDef, +} from "../../appui-react.js"; import { TestNineZoneProvider } from "../layout/Providers.js"; import { WidgetPanelsToolbars } from "../../appui-react/widget-panels/Toolbars.js"; @@ -36,4 +40,72 @@ describe("WidgetPanelsToolbars", () => { screen.getByText("tools"); screen.getByText("navigation"); }); + + it("should not render bottom toolbars when no widget defs are provided", () => { + const frontstageDef = new FrontstageDef(); + vi.spyOn( + UiFramework.frontstages, + "activeFrontstageDef", + "get" + ).mockImplementation(() => frontstageDef); + const { container } = render(, { + wrapper: (props: any) => , + }); + expect( + container.querySelector(".uifw-widgetPanels-bottomToolbars") + ).toEqual(null); + }); + + it("should render bottom-right toolbar when bottomViewNavigation widget is provided", () => { + const frontstageDef = new FrontstageDef(); + const bottomViewNavigationWidget = WidgetDef.create({ + id: "bottomViewNavigationWidget", + content: <>bottom-nav, + }); + vi.spyOn( + UiFramework.frontstages, + "activeFrontstageDef", + "get" + ).mockImplementation(() => frontstageDef); + vi.spyOn( + frontstageDef, + "bottomViewNavigation", + "get" + ).mockImplementation(() => bottomViewNavigationWidget); + const { container } = render(, { + wrapper: (props: any) => , + }); + expect( + container.querySelector(".uifw-widgetPanels-bottomToolbars") + ).toBeTruthy(); + expect( + container.querySelector(".uifw-bottom-toolArea_right") + ).toBeTruthy(); + screen.getByText("bottom-nav"); + }); + + it("should render bottom-left toolbar when bottomContentManipulation widget is provided", () => { + const frontstageDef = new FrontstageDef(); + const bottomContentWidget = WidgetDef.create({ + id: "bottomContentManipulationWidget", + content: <>bottom-tools, + }); + vi.spyOn( + UiFramework.frontstages, + "activeFrontstageDef", + "get" + ).mockImplementation(() => frontstageDef); + vi.spyOn( + frontstageDef, + "bottomContentManipulation", + "get" + ).mockImplementation(() => bottomContentWidget); + const { container } = render(, { + wrapper: (props: any) => , + }); + expect( + container.querySelector(".uifw-widgetPanels-bottomToolbars") + ).toBeTruthy(); + screen.getByText("bottom-tools"); + }); }); diff --git a/ui/appui-react/src/test/widgets/BottomToolWidgetComposer.test.tsx b/ui/appui-react/src/test/widgets/BottomToolWidgetComposer.test.tsx new file mode 100644 index 00000000000..ce88de75310 --- /dev/null +++ b/ui/appui-react/src/test/widgets/BottomToolWidgetComposer.test.tsx @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import { render } from "@testing-library/react"; +import * as React from "react"; +import { BottomToolWidgetComposer } from "../../appui-react/widgets/BottomToolWidgetComposer.js"; +import { BottomContentToolWidgetComposer } from "../../appui-react/widgets/BottomContentToolWidgetComposer.js"; +import { BottomViewToolWidgetComposer } from "../../appui-react/widgets/BottomViewToolWidgetComposer.js"; +import { childStructure } from "../TestUtils.js"; + +describe("BottomToolWidgetComposer", () => { + it("should render with vertical and horizontal toolbars", () => { + const { container } = render( + vertical} + horizontalToolbar={
horizontal
} + /> + ); + + expect(container).to.satisfy( + childStructure([ + ".uifw-bottom-toolArea .uifw-bottom-toolArea_vertical", + ".uifw-bottom-toolArea .uifw-bottom-toolArea_horizontal", + ]) + ); + }); + + it("should render without toolbars", () => { + const { container } = render(); + + expect(container).to.satisfy( + childStructure([".uifw-bottom-toolArea"]) + ); + }); +}); + +describe("BottomContentToolWidgetComposer", () => { + it("should render", () => { + const { container } = render(); + + expect(container).to.satisfy( + childStructure([".uifw-bottom-toolArea"]) + ); + }); +}); + +describe("BottomViewToolWidgetComposer", () => { + it("should render", () => { + const { container } = render(); + + expect(container).to.satisfy( + childStructure([".uifw-bottom-toolArea"]) + ); + }); +});