diff --git a/apps/test-app/src/frontend/appui/frontstages/MainFrontstage.tsx b/apps/test-app/src/frontend/appui/frontstages/MainFrontstage.tsx index 7e1a3887b62..e5a44d3f5bf 100644 --- a/apps/test-app/src/frontend/appui/frontstages/MainFrontstage.tsx +++ b/apps/test-app/src/frontend/appui/frontstages/MainFrontstage.tsx @@ -7,9 +7,13 @@ import { BackstageAppButton, BackstageItemUtilities, FrontstageUtilities, + RestoreFrontstageLayoutTool, SettingsModalFrontstage, StageUsage, StandardContentLayouts, + ToolbarItemUtilities, + ToolbarOrientation, + ToolbarUsage, UiItemsProvider, } from "@itwin/appui-react"; import { @@ -46,7 +50,17 @@ createMainFrontstage.stageId = "main"; export function createMainFrontstageProvider() { return { id: "appui-test-app:backstageItemsProvider", - getToolbarItems: () => [getCustomViewSelectorPopupItem()], + getToolbarItems: () => [ + getCustomViewSelectorPopupItem(), + ToolbarItemUtilities.createForTool(RestoreFrontstageLayoutTool, { + layouts: { + standard: { + orientation: ToolbarOrientation.Horizontal, + usage: ToolbarUsage.ContentManipulation, + }, + }, + }), + ], getBackstageItems: () => [ BackstageItemUtilities.createStageLauncher({ stageId: createMainFrontstage.stageId, diff --git a/common/api/appui-react.api.md b/common/api/appui-react.api.md index bb1155703a0..6c0cf508e61 100644 --- a/common/api/appui-react.api.md +++ b/common/api/appui-react.api.md @@ -3665,30 +3665,14 @@ export class ReducerRegistry { export const ReducerRegistryInstance: ReducerRegistry; // @public -export class RestoreAllFrontstagesTool extends Tool { - // (undocumented) - static iconSpec: string; - // (undocumented) - run(): Promise; - // (undocumented) - static toolId: string; -} +export const RestoreAllFrontstagesTool: typeof RestoreAllFrontstagesCoreTool & { + iconElement: React_2.ReactElement; +}; // @public -export class RestoreFrontstageLayoutTool extends Tool { - // (undocumented) - static iconSpec: string; - // (undocumented) - static get maxArgs(): number; - // (undocumented) - static get minArgs(): number; - // (undocumented) - parseAndRun(...args: string[]): Promise; - // (undocumented) - run(frontstageId?: string): Promise; - // (undocumented) - static toolId: string; -} +export const RestoreFrontstageLayoutTool: typeof RestoreFrontstageLayoutCoreTool & { + iconElement: React_2.ReactElement; +}; // @public export const SafeAreaContext: React_2.Context; diff --git a/common/api/imodel-components-react.api.md b/common/api/imodel-components-react.api.md index b226f051a7b..f0fb75dd575 100644 --- a/common/api/imodel-components-react.api.md +++ b/common/api/imodel-components-react.api.md @@ -27,6 +27,7 @@ import { ScreenViewport } from '@itwin/core-frontend'; import type { Slider } from '@itwin/itwinui-react'; import type { StandardViewId } from '@itwin/core-frontend'; import type { TentativePoint } from '@itwin/core-frontend'; +import type { ToolType } from '@itwin/core-frontend'; import type { TypeEditor } from '@itwin/components-react'; import { UiEvent } from '@itwin/appui-abstract'; import type { UnitProps } from '@itwin/core-quantity'; @@ -748,6 +749,12 @@ export enum TimelineScale { Years = 0 } +// @public +export namespace ToolUtilities { + export function defineIcon(toolType: T, iconElement: React_2.ReactElement): ToolWithIcon; + export function isWithIcon(toolType: T): toolType is ToolWithIcon; +} + // @public export class UiIModelComponents { static initialize(): Promise; diff --git a/common/api/summary/appui-react.exports.csv b/common/api/summary/appui-react.exports.csv index 7b8efd0ff10..25d339bf969 100644 --- a/common/api/summary/appui-react.exports.csv +++ b/common/api/summary/appui-react.exports.csv @@ -523,8 +523,8 @@ public;class;ReducerRegistry deprecated;class;ReducerRegistry beta;const;ReducerRegistryInstance deprecated;const;ReducerRegistryInstance -public;class;RestoreAllFrontstagesTool -public;class;RestoreFrontstageLayoutTool +public;const;RestoreAllFrontstagesTool +public;const;RestoreFrontstageLayoutTool public;const;SafeAreaContext public;enum;SafeAreaInsets public;class;ScheduleAnimationTimelineDataProvider diff --git a/common/api/summary/imodel-components-react.exports.csv b/common/api/summary/imodel-components-react.exports.csv index e9a2a3aea55..7a58a0dbef4 100644 --- a/common/api/summary/imodel-components-react.exports.csv +++ b/common/api/summary/imodel-components-react.exports.csv @@ -101,6 +101,7 @@ public;interface;TimelineMenuItemProps public;enum;TimelinePausePlayAction public;interface;TimelinePausePlayArgs public;enum;TimelineScale +public;namespace;ToolUtilities public;class;UiIModelComponents public;class;ViewClassFullNameChangedEvent deprecated;class;ViewClassFullNameChangedEvent diff --git a/common/changes/@itwin/appui-react/tool-icons_2024-12-11-13-21.json b/common/changes/@itwin/appui-react/tool-icons_2024-12-11-13-21.json new file mode 100644 index 00000000000..3393c35368c --- /dev/null +++ b/common/changes/@itwin/appui-react/tool-icons_2024-12-11-13-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/appui-react", + "comment": "", + "type": "none" + } + ], + "packageName": "@itwin/appui-react" +} \ No newline at end of file diff --git a/common/changes/@itwin/imodel-components-react/tool-icons_2024-12-11-13-21.json b/common/changes/@itwin/imodel-components-react/tool-icons_2024-12-11-13-21.json new file mode 100644 index 00000000000..abd87cf9bfa --- /dev/null +++ b/common/changes/@itwin/imodel-components-react/tool-icons_2024-12-11-13-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-components-react", + "comment": "Add `ToolUtilities` to define a Tool icon as a React element.", + "type": "none" + } + ], + "packageName": "@itwin/imodel-components-react" +} \ No newline at end of file diff --git a/docs/changehistory/NextVersion.md b/docs/changehistory/NextVersion.md index 7783ba5ecda..2ced82a8a94 100644 --- a/docs/changehistory/NextVersion.md +++ b/docs/changehistory/NextVersion.md @@ -2,9 +2,55 @@ - [@itwin/components-react](#itwincomponents-react) - [Additions](#additions) +- [@itwin/imodel-components-react](#itwinimodel-components-react) + - [Additions](#additions-1) ## @itwin/components-react ### Additions - Added a callback to `VirtualizedPropertyGrid` which determines which editors should always be visible. [#1090](https://github.com/iTwin/appui/pull/1090) + +## @itwin/imodel-components-react + +### Additions + +- Added `ToolUtilities` namespace that contains utilities for working with iTwin.js core `Tool` class. [1150](https://github.com/iTwin/appui/pull/1150) + + - `ToolUtilities.defineIcon` function allows defining an icon for a tool type using a React element. This is a supplement for an existing `Tool.iconSpec` property that adds additional `iconElement` property to the tool type. + + ```tsx + // Before + export class MyTool extends Tool { + public static iconSpec = "icon-placeholder"; + } + + // After + class MyCoreTool extends Tool { + public static iconSpec = "icon-placeholder"; + } + export const MyTool = ToolUtilities.defineIcon( + MyCoreTool, + + ); + ``` + + Alternatively, consumers can simply add an `iconElement` property of `ReactElement` type to the tool class. + + ```tsx + export class MyTool extends Tool { + public static iconSpec = "icon-placeholder"; + public static iconElement = (); + } + ``` + + > [!NOTE] + > Newly defined `iconElement` property needs to be read by the consumers to display the icon in a toolbar, unless the `ToolbarItemUtilities.createForTool` helper is used when creating toolbar items. + + - `ToolUtilities.isWithIcon` function is a type guard that checks if a tool has a React icon element defined. Which is useful to read the icon element from the tool type. + + ```tsx + if (ToolUtilities.isWithIcon(MyTool)) { + MyTool.iconElement; // ReactElement + } + ``` diff --git a/e2e-tests/tests/toolbar/toolbar-composer.test.ts-snapshots/toolbar-composer-test-1-chromium-linux.png b/e2e-tests/tests/toolbar/toolbar-composer.test.ts-snapshots/toolbar-composer-test-1-chromium-linux.png index 94360cae7d1..ffcc57dafed 100644 Binary files a/e2e-tests/tests/toolbar/toolbar-composer.test.ts-snapshots/toolbar-composer-test-1-chromium-linux.png and b/e2e-tests/tests/toolbar/toolbar-composer.test.ts-snapshots/toolbar-composer-test-1-chromium-linux.png differ diff --git a/ui/appui-react/src/appui-react/icons/SvgViewLayouts.tsx b/ui/appui-react/src/appui-react/icons/SvgViewLayouts.tsx new file mode 100644 index 00000000000..7ec3365e040 --- /dev/null +++ b/ui/appui-react/src/appui-react/icons/SvgViewLayouts.tsx @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Utilities + */ + +import * as React from "react"; + +/** @internal */ +export function SvgViewLayouts() { + return ( + + + + ); +} diff --git a/ui/appui-react/src/appui-react/toolbar/ToolbarItemUtilities.tsx b/ui/appui-react/src/appui-react/toolbar/ToolbarItemUtilities.tsx index da3b57fb54e..1ae15f2395b 100644 --- a/ui/appui-react/src/appui-react/toolbar/ToolbarItemUtilities.tsx +++ b/ui/appui-react/src/appui-react/toolbar/ToolbarItemUtilities.tsx @@ -14,6 +14,7 @@ import type { ToolbarGroupItem, } from "./ToolbarItem.js"; import { isArgsUtil } from "../backstage/BackstageItemUtilities.js"; +import { ToolUtilities } from "@itwin/imodel-components-react"; /** Helper namespace to create toolbar items. * @public @@ -221,6 +222,10 @@ export namespace ToolbarItemUtilities { toolType: ToolType, overrides?: Partial ): ToolbarActionItem { + const iconNode = ToolUtilities.isWithIcon(toolType) + ? toolType.iconElement + : undefined; + // eslint-disable-next-line @typescript-eslint/no-deprecated return ToolbarItemUtilities.createActionItem( toolType.toolId, @@ -232,6 +237,7 @@ export namespace ToolbarItemUtilities { }, { description: toolType.description, + iconNode, ...overrides, } ); diff --git a/ui/appui-react/src/appui-react/tools/KeyinPaletteTools.ts b/ui/appui-react/src/appui-react/tools/KeyinPaletteTools.tsx similarity index 70% rename from ui/appui-react/src/appui-react/tools/KeyinPaletteTools.ts rename to ui/appui-react/src/appui-react/tools/KeyinPaletteTools.tsx index 5fd1681412b..75960801de8 100644 --- a/ui/appui-react/src/appui-react/tools/KeyinPaletteTools.ts +++ b/ui/appui-react/src/appui-react/tools/KeyinPaletteTools.tsx @@ -5,19 +5,15 @@ /** @packageDocumentation * @module Tools */ - +import * as React from "react"; import { clearKeyinPaletteHistory } from "../popup/KeyinPalettePanel.js"; import { Tool } from "@itwin/core-frontend"; -import svgRemove from "@bentley/icons-generic/icons/remove.svg"; +import { ToolUtilities } from "@itwin/imodel-components-react"; +import { SvgRemove } from "@itwin/itwinui-icons-react"; -/** - * Immediate tool that will clear the recent history of command/tool keyins shown in - * the command palette. - * @alpha - */ -export class ClearKeyinPaletteHistoryTool extends Tool { +class ClearKeyinPaletteHistoryCoreTool extends Tool { public static override toolId = "ClearKeyinPaletteHistory"; - public static override iconSpec = svgRemove; + public static override iconSpec = "icon-remove"; public static override get minArgs() { return 0; @@ -31,3 +27,13 @@ export class ClearKeyinPaletteHistoryTool extends Tool { return true; } } + +/** + * Immediate tool that will clear the recent history of command/tool keyins shown in + * the command palette. + * @alpha + */ +export const ClearKeyinPaletteHistoryTool = ToolUtilities.defineIcon( + ClearKeyinPaletteHistoryCoreTool, + +); diff --git a/ui/appui-react/src/appui-react/tools/OpenSettingsTool.ts b/ui/appui-react/src/appui-react/tools/OpenSettingsTool.tsx similarity index 74% rename from ui/appui-react/src/appui-react/tools/OpenSettingsTool.ts rename to ui/appui-react/src/appui-react/tools/OpenSettingsTool.tsx index 7632df28e11..a419e8a5569 100644 --- a/ui/appui-react/src/appui-react/tools/OpenSettingsTool.ts +++ b/ui/appui-react/src/appui-react/tools/OpenSettingsTool.tsx @@ -5,17 +5,15 @@ /** @packageDocumentation * @module Tools */ +import * as React from "react"; import { Tool } from "@itwin/core-frontend"; +import { ToolUtilities } from "@itwin/imodel-components-react"; +import { SvgSettings } from "@itwin/itwinui-icons-react"; import { SettingsModalFrontstage } from "../frontstage/ModalSettingsStage.js"; -import svgSettings from "@bentley/icons-generic/icons/settings.svg"; -/** - * Immediate tool that will open the Settings modal stage. - * @alpha - */ -export class OpenSettingsTool extends Tool { +class OpenSettingsCoreTool extends Tool { public static override toolId = "OpenSettings"; - public static override iconSpec = svgSettings; + public static override iconSpec = "icon-settings"; public static override get minArgs() { return 0; @@ -33,3 +31,12 @@ export class OpenSettingsTool extends Tool { return this.run(args[0]); } } + +/** + * Immediate tool that will open the Settings modal stage. + * @alpha + */ +export const OpenSettingsTool = ToolUtilities.defineIcon( + OpenSettingsCoreTool, + +); diff --git a/ui/appui-react/src/appui-react/tools/RestoreLayoutTool.ts b/ui/appui-react/src/appui-react/tools/RestoreLayoutTool.tsx similarity index 76% rename from ui/appui-react/src/appui-react/tools/RestoreLayoutTool.ts rename to ui/appui-react/src/appui-react/tools/RestoreLayoutTool.tsx index 7617584eef9..f51d2963ba5 100644 --- a/ui/appui-react/src/appui-react/tools/RestoreLayoutTool.ts +++ b/ui/appui-react/src/appui-react/tools/RestoreLayoutTool.tsx @@ -5,6 +5,7 @@ /** @packageDocumentation * @module Tools */ +import * as React from "react"; import { IModelApp, NotifyMessageDetails, @@ -14,16 +15,12 @@ import { import type { FrontstageDef } from "../frontstage/FrontstageDef.js"; import { InternalFrontstageManager } from "../frontstage/InternalFrontstageManager.js"; import { UiFramework } from "../UiFramework.js"; -import svgViewLayouts from "@bentley/icons-generic/icons/view-layouts.svg"; +import { SvgViewLayouts } from "../icons/SvgViewLayouts.js"; +import { ToolUtilities } from "@itwin/imodel-components-react"; -/** - * Immediate tool that will reset the layout to that specified in the stage definition. A stage Id - * may be passed in, if not the active stage is used. The stage Id is case sensitive. - * @public - */ -export class RestoreFrontstageLayoutTool extends Tool { +class RestoreFrontstageLayoutCoreTool extends Tool { public static override toolId = "RestoreFrontstageLayout"; - public static override iconSpec = svgViewLayouts; + public static override iconSpec = "icon-view-layouts"; public static override get minArgs() { return 0; @@ -57,15 +54,25 @@ export class RestoreFrontstageLayoutTool extends Tool { public override async parseAndRun(...args: string[]): Promise { return this.run(args[0]); } + + public getIconNode() { + return ; + } } /** - * Immediate tool that will reset the layout of all frontstages to that specified in the stage definition. + * Immediate tool that will reset the layout to that specified in the stage definition. A stage Id + * may be passed in, if not the active stage is used. The stage Id is case sensitive. * @public */ -export class RestoreAllFrontstagesTool extends Tool { +export const RestoreFrontstageLayoutTool = ToolUtilities.defineIcon( + RestoreFrontstageLayoutCoreTool, + +); + +class RestoreAllFrontstagesCoreTool extends Tool { public static override toolId = "RestoreAllFrontstages"; - public static override iconSpec = svgViewLayouts; + public static override iconSpec = "icon-view-layouts"; public override async run() { const frontstages = InternalFrontstageManager.frontstageDefs; @@ -75,3 +82,12 @@ export class RestoreAllFrontstagesTool extends Tool { return true; } } + +/** + * Immediate tool that will reset the layout of all frontstages to that specified in the stage definition. + * @public + */ +export const RestoreAllFrontstagesTool = ToolUtilities.defineIcon( + RestoreAllFrontstagesCoreTool, + +); diff --git a/ui/appui-react/src/test/toolbar/ToolbarItemUtilities.test.tsx b/ui/appui-react/src/test/toolbar/ToolbarItemUtilities.test.tsx new file mode 100644 index 00000000000..cec63013617 --- /dev/null +++ b/ui/appui-react/src/test/toolbar/ToolbarItemUtilities.test.tsx @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Tool } from "@itwin/core-frontend"; +import { ToolbarItemUtilities } from "../../appui-react.js"; +import { ToolUtilities } from "@itwin/imodel-components-react"; +import { render } from "@testing-library/react"; + +describe("ToolbarItemUtilities.createForTool", () => { + it("should read `iconElement` property", () => { + class MyTool extends Tool { + public static override iconSpec = "icon-placeholder"; + } + ToolUtilities.defineIcon(MyTool, My SVG); + + const item = ToolbarItemUtilities.createForTool(MyTool); + expect(item.icon).toEqual("icon-placeholder"); + + const { getByText } = render(item.iconNode); + getByText("My SVG"); + }); +}); diff --git a/ui/imodel-components-react/src/imodel-components-react.ts b/ui/imodel-components-react/src/imodel-components-react.ts index de93474b14e..527e16d0c9b 100644 --- a/ui/imodel-components-react/src/imodel-components-react.ts +++ b/ui/imodel-components-react/src/imodel-components-react.ts @@ -157,6 +157,8 @@ export { ViewRotationChangeEventArgs, } from "./imodel-components-react/viewport/ViewportComponentEvents.js"; +export { ToolUtilities } from "./imodel-components-react/ToolUtilities.js"; + // #region "SideEffects" import { StandardEditorNames, StandardTypeNames } from "@itwin/appui-abstract"; diff --git a/ui/imodel-components-react/src/imodel-components-react/ToolUtilities.ts b/ui/imodel-components-react/src/imodel-components-react/ToolUtilities.ts new file mode 100644 index 00000000000..f7464b28a2c --- /dev/null +++ b/ui/imodel-components-react/src/imodel-components-react/ToolUtilities.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Common + */ + +import * as React from "react"; +import type { ToolType } from "@itwin/core-frontend"; + +/** A {@link @itwin/core-frontend#ToolType} with an icon element specified as a React element. */ +type ToolWithIcon = T & { + iconElement: React.ReactElement; +}; + +/** Utilities related to {@link @itwin/core-frontend#Tool} class. + * @public + */ +export namespace ToolUtilities { + /** + * Defines an icon property for a specified {@link @itwin/core-frontend#ToolType}. + * + * ```tsx + * ToolUtilities.defineIcon(MyTool, ); + * ``` + * + * Alternatively, consumers can define the `iconElement` property directly on the tool class. + * ```tsx + * class MyTool extends Tool { + * public static iconElement = ; + * } + * ``` + */ + export function defineIcon( + toolType: T, + iconElement: React.ReactElement + ): ToolWithIcon { + const withIcon = toolType as unknown as ToolWithIcon; + withIcon.iconElement = iconElement; + return withIcon; + } + + /** Type guard for a {@link @itwin/core-frontend#ToolType} with an `iconElement` property. */ + export function isWithIcon( + toolType: T + ): toolType is ToolWithIcon { + return ( + "iconElement" in toolType && React.isValidElement(toolType.iconElement) + ); + } +} diff --git a/ui/imodel-components-react/src/test/ToolUtilities.test.tsx b/ui/imodel-components-react/src/test/ToolUtilities.test.tsx new file mode 100644 index 00000000000..5eb727a64a8 --- /dev/null +++ b/ui/imodel-components-react/src/test/ToolUtilities.test.tsx @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Tool } from "@itwin/core-frontend"; +import { render } from "@testing-library/react"; +import { ToolUtilities } from "../imodel-components-react.js"; + +describe("ToolUtilities.defineIcon", () => { + it("should define an icon property for a specified ToolType", () => { + class MyTool extends Tool {} + + const ToolWithIcon = ToolUtilities.defineIcon(MyTool, My SVG); + expect(ToolWithIcon.iconElement).toBeDefined(); + expect(MyTool).toHaveProperty("iconElement"); + + const { getByText } = render(ToolWithIcon.iconElement); + getByText("My SVG"); + }); + + it("should override `iconElement`", () => { + class MyTool extends Tool {} + + const ToolWithIcon = ToolUtilities.defineIcon(MyTool, My SVG); + + class MyNewTool extends ToolWithIcon { + public static override iconElement = (My new SVG); + } + + const { getByText } = render(MyNewTool.iconElement); + getByText("My new SVG"); + }); + + it("should create an instance from a newly returned type", () => { + class MyTool extends Tool { + public test() {} + } + const spy = vi.spyOn(MyTool.prototype, "test"); + + const ToolWithIcon = ToolUtilities.defineIcon(MyTool, My SVG); + new ToolWithIcon().test(); + expect(spy).toHaveBeenCalledOnce(); + }); +}); + +describe("ToolUtilities.isWithIcon", () => { + it("static `iconElement` property", () => { + expect(ToolUtilities.isWithIcon(Tool)).toBe(false); + + class MyTool extends Tool { + public static iconElement = (My SVG); + } + expect(ToolUtilities.isWithIcon(MyTool)).toBe(true); + + class ToolWithIncorrectType extends Tool { + public static iconElement = "icon-placeholder"; + } + expect(ToolUtilities.isWithIcon(ToolWithIncorrectType)).toBe(false); + }); + + it("`defineIcon` helper", () => { + class MyTool extends Tool {} + expect(ToolUtilities.isWithIcon(MyTool)).toBe(false); + + const ToolWithIcon = ToolUtilities.defineIcon(MyTool, My SVG); + expect(ToolUtilities.isWithIcon(MyTool)).toBe(true); + expect(ToolUtilities.isWithIcon(ToolWithIcon)).toBe(true); + }); +});