From 0d6dc0b52a1ce507eaad4303fbe3fc5960ccee41 Mon Sep 17 00:00:00 2001 From: GerardasB <10091419+GerardasB@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:25:23 +0200 Subject: [PATCH] Add `promptAtContent` to `ToolAssistanceField` components (#1211) * Update docs * Add story * Group status bar fields * Use ConfigurableUiContent * Add indefinite cursorPromptTimeout * Fix an issue with CursorPopup where a fading popup is not re-opened. * Refactor cursor prompt. * Close the prompt if toggled off (after toggle on tool still needs to be activated). * Add promptAtContent prop. * Add promptAtContent story * rush change * Extract API * NextVersion * Mark componentDidUpdate as internal to fix docs CI issue --- common/api/appui-react.api.md | 10 +- .../issue-1201_2025-02-10-11-01.json | 10 ++ docs/changehistory/NextVersion.md | 9 ++ .../components/MessageCenterField.stories.tsx | 2 +- .../ToolAssistanceField.stories.tsx | 128 ++++++++++++++++ .../configurableui/ConfigurableUiContent.tsx | 32 +++- .../appui-react/cursor/CursorInformation.ts | 19 +++ .../cursor/cursorpopup/CursorPopup.tsx | 2 +- .../cursor/cursorpopup/CursorPopupManager.tsx | 80 ++++++---- .../cursor/cursorprompt/CursorPrompt.tsx | 137 ++++++++++-------- .../src/appui-react/layout/StandardLayout.tsx | 6 +- .../toolassistance/ToolAssistanceField.tsx | 57 +++++--- .../cursorpopup/CursorPopupManager.test.tsx | 51 +++++++ .../cursor/cursorprompt/CursorPrompt.test.tsx | 114 --------------- 14 files changed, 419 insertions(+), 238 deletions(-) create mode 100644 common/changes/@itwin/appui-react/issue-1201_2025-02-10-11-01.json create mode 100644 docs/storybook/src/components/ToolAssistanceField.stories.tsx create mode 100644 ui/appui-react/src/test/cursor/cursorpopup/CursorPopupManager.test.tsx delete mode 100644 ui/appui-react/src/test/cursor/cursorprompt/CursorPrompt.test.tsx diff --git a/common/api/appui-react.api.md b/common/api/appui-react.api.md index 9ce39fa62c9..fb1fa2595a2 100644 --- a/common/api/appui-react.api.md +++ b/common/api/appui-react.api.md @@ -1451,6 +1451,7 @@ export class CursorPopupManager { // @internal (undocumented) static readonly onCursorPopupFadeOutEvent: BeUiEvent<{ id: string; + show?: CursorPopupShow; }>; // @internal (undocumented) static readonly onCursorPopupsChangedEvent: BeUiEvent; @@ -2069,7 +2070,7 @@ export interface FrameworkKeyboardShortcuts { export const FrameworkReducer: Reducer_2< { configurableUiState: ConfigurableUiState; sessionState: DeepReadonlyObject_2; -}, SessionStateActionsUnion_2 | ConfigurableUiActionsUnion_2, Partial<{ +}, ConfigurableUiActionsUnion_2 | SessionStateActionsUnion_2, Partial<{ configurableUiState: never; sessionState: never; }>>; @@ -3838,7 +3839,7 @@ export const SessionStateActions: { setNumItemsSelected: (numSelected: number) => ActionWithPayload_2; setIModelConnection: (iModelConnection: any) => ActionWithPayload_2; setSelectionScope: (activeSelectionScope: string) => ActionWithPayload_2; - updateCursorMenu: (cursorMenuData: CursorMenuData | CursorMenuPayload) => ActionWithPayload_2 | DeepReadonlyObject_2>; + updateCursorMenu: (cursorMenuData: CursorMenuData | CursorMenuPayload) => ActionWithPayload_2 | DeepReadonlyObject_2>; }; // @beta @deprecated @@ -3876,7 +3877,7 @@ export const sessionStateMapDispatchToProps: { setNumItemsSelected: (numSelected: number) => ActionWithPayload_2; setIModelConnection: (iModelConnection: any) => ActionWithPayload_2; setSelectionScope: (activeSelectionScope: string) => ActionWithPayload_2; - updateCursorMenu: (cursorMenuData: CursorMenuData | CursorMenuPayload) => ActionWithPayload_2 | DeepReadonlyObject_2>; + updateCursorMenu: (cursorMenuData: CursorMenuData | CursorMenuPayload) => ActionWithPayload_2 | DeepReadonlyObject_2>; }; // @public @deprecated @@ -4654,6 +4655,8 @@ export class ToolAssistanceField extends React_2.Component; + // @internal (undocumented) + componentDidUpdate(): void; // (undocumented) componentWillUnmount(): void; // @internal (undocumented) @@ -4674,6 +4677,7 @@ export interface ToolAssistanceFieldProps extends CommonProps { defaultPromptAtCursor: boolean; fadeOutCursorPrompt: boolean; includePromptAtCursor: boolean; + promptAtContent?: boolean; uiStateStorage?: UiStateStorage; } diff --git a/common/changes/@itwin/appui-react/issue-1201_2025-02-10-11-01.json b/common/changes/@itwin/appui-react/issue-1201_2025-02-10-11-01.json new file mode 100644 index 00000000000..862c35902bf --- /dev/null +++ b/common/changes/@itwin/appui-react/issue-1201_2025-02-10-11-01.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/appui-react", + "comment": "Add `promptAtContent` prop to `ToolAssistanceField` component.", + "type": "none" + } + ], + "packageName": "@itwin/appui-react" +} \ No newline at end of file diff --git a/docs/changehistory/NextVersion.md b/docs/changehistory/NextVersion.md index 21d0c18dc23..99df152603c 100644 --- a/docs/changehistory/NextVersion.md +++ b/docs/changehistory/NextVersion.md @@ -1,7 +1,16 @@ # NextVersion +- [@itwin/appui-react](#itwinappui-react) + - [Additions](#additions) + - [Changes](#changes) + ## @itwin/appui-react ### Additions - Added `useSavedState` property to `Widget` interface. By default widgets with `defaultState=Hidden` are always hidden when the layout is restored (i.e. page is reloaded). When `useSavedState` is set to `true` it will override the default behavior and force the widget to use its saved layout state instead. This is useful for widgets that are hidden by default but should be shown when the layout is restored. [#1210](https://github.com/iTwin/appui/pull/1210) +- Added `promptAtContent` prop to `ToolAssistanceField` component. When set to `true` the prompt will be displayed only when the content area (i.e. viewport) is hovered. [#1211](https://github.com/iTwin/appui/pull/1211) + +### Changes + +- Updated `cursorPromptTimeout` prop of `ToolAssistanceField` component to handle `Number.POSITIVE_INFINITY`, which when enabled will display the cursor prompt indefinitely. [#1211](https://github.com/iTwin/appui/pull/1211) diff --git a/docs/storybook/src/components/MessageCenterField.stories.tsx b/docs/storybook/src/components/MessageCenterField.stories.tsx index b8d70692434..5ab7d5dc344 100644 --- a/docs/storybook/src/components/MessageCenterField.stories.tsx +++ b/docs/storybook/src/components/MessageCenterField.stories.tsx @@ -30,7 +30,7 @@ const AlignComponent: Decorator = (Story) => { }; const meta = { - title: "Components/MessageCenterField", + title: "Components/Status fields/MessageCenterField", component: MessageCenterField, tags: ["autodocs"], decorators: [AlignComponent, InitializerDecorator, AppUiDecorator], diff --git a/docs/storybook/src/components/ToolAssistanceField.stories.tsx b/docs/storybook/src/components/ToolAssistanceField.stories.tsx new file mode 100644 index 00000000000..c42d240484a --- /dev/null +++ b/docs/storybook/src/components/ToolAssistanceField.stories.tsx @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Decorator, Meta, StoryObj } from "@storybook/react"; +import { + MessageManager, + StatusBarItemUtilities, + ToolAssistanceField, + UiFramework, +} from "@itwin/appui-react"; +import { + Tool, + ToolAssistance, + ToolAssistanceImage, +} from "@itwin/core-frontend"; +import { AppUiStory } from "src/AppUiStory"; +import { createFrontstage } from "src/Utils"; + +const StoryDecorator: Decorator = (Story) => { + return ( + [ + StatusBarItemUtilities.createCustomItem({ + id: "tool-assistance", + content: ( + <> + + + + ), + }), + ], + }, + ]} + /> + ); +}; + +function Setup() { + React.useEffect(() => { + const mainInstruction = ToolAssistance.createInstruction( + ToolAssistanceImage.CursorClick, + "Main instruction of a tool" + ); + + const cursorSection = ToolAssistance.createSection( + [ + ToolAssistance.createInstruction( + ToolAssistanceImage.LeftClick, + "Left click to select a point" + ), + ToolAssistance.createInstruction( + ToolAssistanceImage.RightClick, + "Right click to cancel" + ), + ], + ToolAssistance.inputsLabel + ); + + const touchSection = ToolAssistance.createSection( + [ + ToolAssistance.createInstruction( + ToolAssistanceImage.OneTouchTap, + "Touch to select a point" + ), + ], + ToolAssistance.inputsLabel + ); + + const instructions = ToolAssistance.createInstructions(mainInstruction, [ + cursorSection, + touchSection, + ]); + // Tool assistance information + MessageManager.setToolAssistance(instructions); + + // Icon for the tool assistance + UiFramework.frontstages.setActiveTool( + new (class extends Tool { + get iconSpec(): string { + return "icon-placeholder"; + } + })() + ); + }, []); + return null; +} + +const meta = { + title: "Components/Status fields/ToolAssistanceField", + component: ToolAssistanceField, + tags: ["autodocs"], + decorators: [StoryDecorator], + args: { + includePromptAtCursor: true, + cursorPromptTimeout: 5000, + fadeOutCursorPrompt: true, + defaultPromptAtCursor: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const AlwaysVisible: Story = { + args: { + cursorPromptTimeout: Number.POSITIVE_INFINITY, + }, +}; + +export const PromptAtContent: Story = { + args: { + cursorPromptTimeout: Number.POSITIVE_INFINITY, + promptAtContent: true, + }, +}; diff --git a/ui/appui-react/src/appui-react/configurableui/ConfigurableUiContent.tsx b/ui/appui-react/src/appui-react/configurableui/ConfigurableUiContent.tsx index 95f635e9b32..da79ce8e81c 100644 --- a/ui/appui-react/src/appui-react/configurableui/ConfigurableUiContent.tsx +++ b/ui/appui-react/src/appui-react/configurableui/ConfigurableUiContent.tsx @@ -11,7 +11,10 @@ import * as React from "react"; import type { CommonProps } from "@itwin/core-react"; import { Point } from "@itwin/core-react/internal"; import { ThemeProvider } from "@itwin/itwinui-react"; -import { CursorInformation } from "../cursor/CursorInformation.js"; +import { + CursorInformation, + useCursorInformationStore, +} from "../cursor/CursorInformation.js"; import { CursorPopupMenu } from "../cursor/cursormenu/CursorMenu.js"; import { CursorPopupRenderer } from "../cursor/cursorpopup/CursorPopupManager.js"; import { ModalDialogRenderer } from "../dialog/ModalDialogManager.js"; @@ -46,7 +49,9 @@ export const ConfigurableUiContext = React.createContext< | "animateToolSettings" | "toolAsToolSettingsLabel" | "childWindow" - > + > & { + contentElementRef?: React.RefObject; + } >({}); /** Properties for [[ConfigurableUiContent]] @@ -94,6 +99,8 @@ export const WrapperContext = React.createContext(document.body); */ // eslint-disable-next-line @typescript-eslint/no-deprecated export function ConfigurableUiContent(props: ConfigurableUiContentProps) { + const contentElementRef = React.useRef(null); + useWidgetOpacity(props.widgetOpacity); useToolbarOpacity(props.toolbarOpacity); const [mainElement, setMainElement] = React.useState(); @@ -112,11 +119,9 @@ export function ConfigurableUiContent(props: ConfigurableUiContentProps) { }; }, [props.idleTimeout, props.intervalTimeout]); - const handleMouseMove = React.useCallback((e: React.MouseEvent) => { - const point = new Point(e.pageX, e.pageY); - CursorInformation.handleMouseMove(point); - }, []); - + const setContentHovered = useCursorInformationStore( + (state) => state.setContentHovered + ); return (
{ + const point = new Point(e.pageX, e.pageY); + CursorInformation.handleMouseMove(point); + + if (!(e.target instanceof Node)) return; + const contentHovered = contentElementRef.current?.contains(e.target); + setContentHovered(contentHovered ?? false); + }} + onMouseLeave={() => { + setContentHovered(false); + }} ref={(el) => setMainElement(el ?? undefined)} > diff --git a/ui/appui-react/src/appui-react/cursor/CursorInformation.ts b/ui/appui-react/src/appui-react/cursor/CursorInformation.ts index 35f54b95828..64155a5a26e 100644 --- a/ui/appui-react/src/appui-react/cursor/CursorInformation.ts +++ b/ui/appui-react/src/appui-react/cursor/CursorInformation.ts @@ -6,6 +6,8 @@ * @module Cursor */ +import { create } from "zustand"; +import { produce } from "immer"; import type { XAndY } from "@itwin/core-geometry"; import { RelativePosition, UiEvent } from "@itwin/appui-abstract"; import { Point } from "@itwin/core-react/internal"; @@ -195,3 +197,20 @@ export class CursorInformation { this._cursorDirections.length = 0; } } + +/** Returns additional information about cursor. + * @internal + */ +export const useCursorInformationStore = create<{ + contentHovered: boolean; + setContentHovered: (hovered: boolean) => void; +}>((set) => ({ + contentHovered: false, + setContentHovered: (hovered: boolean) => { + set((state) => + produce(state, (draft) => { + draft.contentHovered = hovered; + }) + ); + }, +})); diff --git a/ui/appui-react/src/appui-react/cursor/cursorpopup/CursorPopup.tsx b/ui/appui-react/src/appui-react/cursor/cursorpopup/CursorPopup.tsx index 3b98c2511fc..c12f4e93a8a 100644 --- a/ui/appui-react/src/appui-react/cursor/cursorpopup/CursorPopup.tsx +++ b/ui/appui-react/src/appui-react/cursor/cursorpopup/CursorPopup.tsx @@ -96,7 +96,7 @@ export class CursorPopup extends React.Component< > = (args) => { if (this.props.id === args.id) { if (this._isMounted) - this.setState({ showPopup: CursorPopupShow.FadeOut }); + this.setState({ showPopup: args.show ?? CursorPopupShow.FadeOut }); } }; diff --git a/ui/appui-react/src/appui-react/cursor/cursorpopup/CursorPopupManager.tsx b/ui/appui-react/src/appui-react/cursor/cursorpopup/CursorPopupManager.tsx index 4d432bd554c..93bbb2a25be 100644 --- a/ui/appui-react/src/appui-react/cursor/cursorpopup/CursorPopupManager.tsx +++ b/ui/appui-react/src/appui-react/cursor/cursorpopup/CursorPopupManager.tsx @@ -13,6 +13,7 @@ import { RelativePosition } from "@itwin/appui-abstract"; import type { ListenerType } from "@itwin/core-react/internal"; import { Point } from "@itwin/core-react/internal"; import { UiFramework } from "../../UiFramework.js"; +import { CursorPopupShow } from "./CursorPopup.js"; import { CursorPopup } from "./CursorPopup.js"; import type { SizeProps } from "../../utils/SizeProps.js"; import type { RectangleProps } from "../../utils/RectangleProps.js"; @@ -42,6 +43,8 @@ interface CursorPopupInfo { renderRelativePosition: RelativePosition; popupSize?: SizeProps; + + cancelFadeOut?: () => void; } /** CursorPopup component @@ -57,6 +60,7 @@ export class CursorPopupManager { /** @internal */ public static readonly onCursorPopupFadeOutEvent = new BeUiEvent<{ id: string; + show?: CursorPopupShow; }>(); /** @internal */ public static readonly onCursorPopupsChangedEvent = new BeUiEvent(); @@ -98,25 +102,36 @@ export class CursorPopupManager { (info: CursorPopupInfo) => id === info.id ); if (popupInfo) { + CursorPopupManager.onCursorPopupFadeOutEvent.emit({ + id, + show: CursorPopupShow.Open, + }); + popupInfo.content = content; popupInfo.offset = Point.create(offset); popupInfo.relativePosition = relativePosition; popupInfo.renderRelativePosition = relativePosition; popupInfo.priority = priority; popupInfo.options = options; - } else { - const newPopupInfo: CursorPopupInfo = { - id, - content, - offset: Point.create(offset), - relativePosition, - options, - renderRelativePosition: relativePosition, - priority, - }; - CursorPopupManager.pushPopup(newPopupInfo); + CursorPopupManager.updatePosition(pt); + + if (popupInfo.cancelFadeOut) { + popupInfo.cancelFadeOut(); + popupInfo.cancelFadeOut = undefined; + } + return; } + const newPopupInfo: CursorPopupInfo = { + id, + content, + offset: Point.create(offset), + relativePosition, + options, + renderRelativePosition: relativePosition, + priority, + }; + CursorPopupManager.pushPopup(newPopupInfo); CursorPopupManager.updatePosition(pt); } @@ -162,28 +177,37 @@ export class CursorPopupManager { const popupInfo = CursorPopupManager._popups.find( (info: CursorPopupInfo) => id === info.id ); - - if (popupInfo) { - if (popupInfo.options) { - if (apply && popupInfo.options.onApply) popupInfo.options.onApply(); - - if (popupInfo.options.onClose) popupInfo.options.onClose(); - } - - if (fadeOut) { - CursorPopupManager.onCursorPopupFadeOutEvent.emit({ id }); - setTimeout(() => { - CursorPopupManager.removePopup(id); - }, CursorPopup.fadeOutTime); - } else { - CursorPopupManager.removePopup(id); - } - } else { + if (!popupInfo) { Logger.logError( UiFramework.loggerCategory("CursorPopupManager"), `close: Could not find popup with id of '${id}'` ); + return; + } + + if (apply) popupInfo.options?.onApply?.(); + popupInfo.options?.onClose?.(); + + if (fadeOut) { + // Already closing + if (popupInfo.cancelFadeOut) return; + + let cancelled = false; + popupInfo.cancelFadeOut = () => { + cancelled = true; + }; + CursorPopupManager.onCursorPopupFadeOutEvent.emit({ id }); + + setTimeout(() => { + if (cancelled) return; + CursorPopupManager.removePopup(id); + }, CursorPopup.fadeOutTime); + return; } + + popupInfo.cancelFadeOut?.(); + popupInfo.cancelFadeOut = undefined; + CursorPopupManager.removePopup(id); } private static removePopup(id: string): void { diff --git a/ui/appui-react/src/appui-react/cursor/cursorprompt/CursorPrompt.tsx b/ui/appui-react/src/appui-react/cursor/cursorprompt/CursorPrompt.tsx index 81cc6a0b561..e975bb99eb8 100644 --- a/ui/appui-react/src/appui-react/cursor/cursorprompt/CursorPrompt.tsx +++ b/ui/appui-react/src/appui-react/cursor/cursorprompt/CursorPrompt.tsx @@ -8,101 +8,112 @@ import "./CursorPrompt.scss"; import * as React from "react"; -import type { ToolAssistanceInstruction } from "@itwin/core-frontend"; -import type { XAndY } from "@itwin/core-geometry"; import { RelativePosition } from "@itwin/appui-abstract"; -import type { ListenerType } from "@itwin/core-react/internal"; import { Icon, Timer } from "@itwin/core-react"; import { Point } from "@itwin/core-react/internal"; import { Text } from "@itwin/itwinui-react"; -import { CursorInformation } from "../CursorInformation.js"; +import { + CursorInformation, + useCursorInformationStore, +} from "../CursorInformation.js"; import { CursorPopupManager } from "../cursorpopup/CursorPopupManager.js"; /** @internal */ export class CursorPrompt { - private _timeOut: number; - private _fadeOut: boolean; - // eslint-disable-next-line @typescript-eslint/no-deprecated - private _timer: Timer; - private _relativePosition = RelativePosition.BottomRight; - private _offset: Point = new Point(20, 20); private _popupId = "cursor-prompt"; + // eslint-disable-next-line @typescript-eslint/no-deprecated + private _timer: Timer | undefined; + private _removeListeners: (() => void) | undefined; + + public open({ + timeout, + fadeout, + iconSpec, + instruction, + promptAtContent, + }: { + timeout: number; + fadeout: boolean; + iconSpec: string; + instruction: string; + promptAtContent: boolean; + }) { + if (!this._removeListeners) { + const listeners = [ + CursorInformation.onCursorUpdatedEvent.addListener((args) => { + CursorPopupManager.updatePosition(args.newPt); + }), + useCursorInformationStore.subscribe((state) => { + if (!promptAtContent) return; + if (state.contentHovered) { + this.show({ + iconSpec, + instruction, + }); + return; + } + + this.hide(fadeout); + }), + ]; + this._removeListeners = () => { + listeners.forEach((remove) => remove()); + }; + } + + this.show({ iconSpec, instruction }); + + if (timeout === Number.POSITIVE_INFINITY) return; - constructor(timeOut: number, fadeOut: boolean) { - this._timeOut = timeOut; - this._fadeOut = fadeOut; // eslint-disable-next-line @typescript-eslint/no-deprecated - this._timer = new Timer(timeOut); + const timer = new Timer(timeout); + timer.setOnExecute(() => this.close(fadeout)); + timer.start(); + this._timer = timer; } - public display( - toolIconSpec: string, - instruction: ToolAssistanceInstruction, - offset: XAndY = { x: 20, y: 20 }, - relativePosition: RelativePosition = RelativePosition.BottomRight - ) { - if (!instruction.text) { - if (this._timer.isRunning) this.close(false); - return; - } + public close(fadeout: boolean) { + this._timer?.stop(); + this._timer = undefined; + this._removeListeners?.(); + this._removeListeners = undefined; - this._relativePosition = relativePosition; - this._offset = Point.create(offset); + this.hide(fadeout); + } + private show({ + iconSpec, + instruction, + }: { + iconSpec: string; + instruction: string; + }) { const promptElement = (
- {toolIconSpec && ( + {iconSpec && ( {/* eslint-disable-next-line @typescript-eslint/no-deprecated */} - + )} - {instruction.text} + {instruction}
); - this._startCursorPopup(promptElement); - - this._timer.setOnExecute(() => this._endCursorPopup(this._fadeOut)); - this._timer.delay = this._timeOut; - this._timer.start(); - } - - /** @internal - unit testing */ - public close(fadeOut: boolean) { - this._timer.stop(); - this._endCursorPopup(fadeOut); - } - - private _startCursorPopup = (promptElement: React.ReactElement) => { CursorPopupManager.open( this._popupId, promptElement, CursorInformation.cursorPosition, - this._offset, - this._relativePosition, + new Point(20, 20), + RelativePosition.BottomRight, 0, { shadow: true } ); + } - if (!CursorInformation.onCursorUpdatedEvent.has(this._handleCursorUpdated)) - CursorInformation.onCursorUpdatedEvent.addListener( - this._handleCursorUpdated - ); - }; - - private _endCursorPopup = (fadeOut?: boolean) => { - CursorPopupManager.close(this._popupId, false, fadeOut); - CursorInformation.onCursorUpdatedEvent.removeListener( - this._handleCursorUpdated - ); - }; - - private _handleCursorUpdated: ListenerType< - typeof CursorInformation.onCursorUpdatedEvent - > = (args) => { - CursorPopupManager.updatePosition(args.newPt); - }; + private hide(fadeout: boolean) { + CursorPopupManager.close(this._popupId, false, fadeout); + } } diff --git a/ui/appui-react/src/appui-react/layout/StandardLayout.tsx b/ui/appui-react/src/appui-react/layout/StandardLayout.tsx index b4210458f98..51baa813f73 100644 --- a/ui/appui-react/src/appui-react/layout/StandardLayout.tsx +++ b/ui/appui-react/src/appui-react/layout/StandardLayout.tsx @@ -16,6 +16,8 @@ import { useMaximizedPanelLayout } from "../preview/enable-maximized-widget/useM import { useHorizontalPanelAlignment } from "../preview/horizontal-panel-alignment/useHorizontalPanelAlignment.js"; import { usePanelsAutoCollapse } from "./widget-panels/usePanelsAutoCollapse.js"; import type { PanelSide } from "./widget-panels/PanelTypes.js"; +import { ConfigurableUiContext } from "../configurableui/ConfigurableUiContent.js"; +import { useRefs } from "@itwin/core-react/internal"; // eslint-disable-next-line @typescript-eslint/no-deprecated interface StandardLayoutProps extends CommonProps { @@ -35,15 +37,17 @@ interface StandardLayoutProps extends CommonProps { * @internal */ export function StandardLayout(props: StandardLayoutProps) { + const { contentElementRef } = React.useContext(ConfigurableUiContext); const pinned = usePinnedPanels(); const appContentRef = usePanelsAutoCollapse(); + const refs = useRefs(contentElementRef, appContentRef); const className = classnames("nz-standardLayout", pinned, props.className); const contentAlwaysMaxSize = useContentAlwaysMaxSize(); return (
{props.children}
diff --git a/ui/appui-react/src/appui-react/statusfields/toolassistance/ToolAssistanceField.tsx b/ui/appui-react/src/appui-react/statusfields/toolassistance/ToolAssistanceField.tsx index 7c8a9d6b075..aa83e45e733 100644 --- a/ui/appui-react/src/appui-react/statusfields/toolassistance/ToolAssistanceField.tsx +++ b/ui/appui-react/src/appui-react/statusfields/toolassistance/ToolAssistanceField.tsx @@ -65,20 +65,22 @@ import { LocalStateStorage } from "../../uistate/LocalStateStorage.js"; */ // eslint-disable-next-line @typescript-eslint/no-deprecated export interface ToolAssistanceFieldProps extends CommonProps { - /** Indicates whether to include promptAtCursor Checkbox. Defaults to true. */ + /** Indicates whether to include promptAtCursor Checkbox. Defaults to `true`. */ includePromptAtCursor: boolean; - /** Optional parameter for persistent UI settings. Defaults to UiStateStorageContext. - */ + /** Optional parameter for persistent UI settings. Defaults to `UiStateStorageContext`. */ uiStateStorage?: UiStateStorage; - /** Cursor Prompt Timeout period. Defaults to 5000. */ + /** Cursor prompt timeout period. Defaults to `5000`. + * @note Specify `Number.POSITIVE_INFINITY` to keep the cursor prompt open indefinitely. + */ cursorPromptTimeout: number; - /** Fade Out the Cursor Prompt when closed. */ + /** Fade out the cursor prompt when closed. */ fadeOutCursorPrompt: boolean; - /** Indicates whether to show promptAtCursor by default. Defaults to false. */ + /** Indicates whether to show promptAtCursor by default. Defaults to `false`. */ defaultPromptAtCursor: boolean; + /** When set to `true` will show prompt at cursor only when the content area is hovered. */ + promptAtContent?: boolean; } -/** @internal */ interface ToolAssistanceFieldState { instructions: ToolAssistanceInstructions | undefined; toolIconSpec: string; @@ -152,10 +154,7 @@ export class ToolAssistanceField extends React.Component< }; this._uiSettingsStorage = new LocalStateStorage(); - this._cursorPrompt = new CursorPrompt( - this.props.cursorPromptTimeout, - this.props.fadeOutCursorPrompt - ); + this._cursorPrompt = new CursorPrompt(); // eslint-disable-next-line @typescript-eslint/no-deprecated this._showPromptAtCursorSetting = new UiStateEntry( ToolAssistanceField._toolAssistanceKey, @@ -186,8 +185,15 @@ export class ToolAssistanceField extends React.Component< await this.restoreSettings(); } + /** @internal */ + public override componentDidUpdate() { + if (!this.state.showPromptAtCursor) + this._cursorPrompt.close(this.props.fadeOutCursorPrompt); + } + public override componentWillUnmount() { this._isMounted = false; + this._cursorPrompt.close(this.props.fadeOutCursorPrompt); MessageManager.onToolAssistanceChangedEvent.removeListener( this._handleToolAssistanceChangedEvent ); @@ -296,17 +302,30 @@ export class ToolAssistanceField extends React.Component< typeof UiFramework.frontstages.onToolIconChangedEvent > = (args) => { if (this._isMounted) - this.setState({ toolIconSpec: args.iconSpec }, () => { - this._showCursorPrompt(); - }); + this.setState( + { + toolIconSpec: args.iconSpec, + }, + () => { + this._showCursorPrompt(); + } + ); }; private _showCursorPrompt() { - if (this.state.showPromptAtCursor && this.state.instructions) - this._cursorPrompt.display( - this.state.toolIconSpec, - this.state.instructions.mainInstruction - ); + const instruction = this.state.instructions?.mainInstruction.text; + if (!this.state.showPromptAtCursor || !instruction) { + this._cursorPrompt.close(this.props.fadeOutCursorPrompt); + return; + } + + this._cursorPrompt.open({ + timeout: this.props.cursorPromptTimeout, + fadeout: this.props.fadeOutCursorPrompt, + iconSpec: this.state.toolIconSpec, + instruction, + promptAtContent: this.props.promptAtContent ?? false, + }); } private _sectionHasDisplayableInstructions( diff --git a/ui/appui-react/src/test/cursor/cursorpopup/CursorPopupManager.test.tsx b/ui/appui-react/src/test/cursor/cursorpopup/CursorPopupManager.test.tsx new file mode 100644 index 00000000000..3418eb8ee0e --- /dev/null +++ b/ui/appui-react/src/test/cursor/cursorpopup/CursorPopupManager.test.tsx @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { RelativePosition } from "@itwin/appui-abstract"; +import { Point } from "@itwin/core-react/internal"; +import { + CursorInformation, + CursorPopup, + CursorPopupManager, +} from "../../../appui-react.js"; + +describe("CursorPopupManager", () => { + beforeEach(() => { + CursorPopupManager.clearPopups(); + }); + + function open() { + CursorPopupManager.open( + "test", +
Hello
, + CursorInformation.cursorPosition, + new Point(20, 20), + RelativePosition.Left + ); + } + + it("should fade out a popup", async () => { + vi.useFakeTimers(); + + open(); + expect(CursorPopupManager.popupCount).toEqual(1); + + CursorPopupManager.close("test", true, true); + expect(CursorPopupManager.popupCount).toEqual(1); + + vi.advanceTimersByTime(CursorPopup.fadeOutTime); + expect(CursorPopupManager.popupCount).toEqual(0); + }); + + it("should stop fade out of a popup", async () => { + vi.useFakeTimers(); + + open(); + CursorPopupManager.close("test", true, true); + open(); + vi.advanceTimersByTime(CursorPopup.fadeOutTime); + expect(CursorPopupManager.popupCount).toEqual(1); + }); +}); diff --git a/ui/appui-react/src/test/cursor/cursorprompt/CursorPrompt.test.tsx b/ui/appui-react/src/test/cursor/cursorprompt/CursorPrompt.test.tsx deleted file mode 100644 index 712a55efad2..00000000000 --- a/ui/appui-react/src/test/cursor/cursorprompt/CursorPrompt.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { ToolAssistance } from "@itwin/core-frontend"; -import { RelativePosition } from "@itwin/appui-abstract"; -import { Point } from "@itwin/core-react/internal"; -import { CursorInformation } from "../../../appui-react/cursor/CursorInformation.js"; -import { CursorPopup } from "../../../appui-react/cursor/cursorpopup/CursorPopup.js"; -import { - CursorPopupManager, - CursorPopupRenderer, -} from "../../../appui-react/cursor/cursorpopup/CursorPopupManager.js"; -import { CursorPrompt } from "../../../appui-react/cursor/cursorprompt/CursorPrompt.js"; -import { selectorMatches } from "../../TestUtils.js"; -import { render, screen, waitFor } from "@testing-library/react"; - -describe("CursorPrompt", () => { - beforeEach(() => { - CursorPopupManager.clearPopups(); - }); - - it("should display", async () => { - render(); - expect(CursorPopupManager.popupCount).toEqual(0); - - const cursorPrompt = new CursorPrompt(20, false); - cursorPrompt.display( - "icon-placeholder", - ToolAssistance.createInstruction("icon-placeholder", "Prompt string") - ); - - expect(CursorPopupManager.popupCount).toEqual(1); - expect(await screen.findByText("Prompt string")).to.satisfy( - selectorMatches(".uifw-cursor-prompt *") - ); - - cursorPrompt.close(false); - }); - - // TODO: react 18 upgrade - it.skip("should display, update and close", async () => { - const offset = new Point(20, 20); - const cursor = { x: 6, y: 6 }; - CursorInformation.cursorPosition = cursor; - vi.useFakeTimers({ shouldAdvanceTime: true }); - const { container } = render(); - expect(CursorPopupManager.popupCount).toEqual(0); - CursorPopup.fadeOutTime = 50; - - const cursorPrompt = new CursorPrompt(20, true); - cursorPrompt.display( - "icon-placeholder", - ToolAssistance.createInstruction("icon-placeholder", "Prompt string"), - offset, - RelativePosition.BottomRight - ); - - expect(CursorPopupManager.popupCount).toEqual(1); - expect(await screen.findByText("Prompt string")).to.satisfy( - selectorMatches(".uifw-cursor-prompt *") - ); - - const styleForOffset = { - top: `${offset.y + cursor.y}px`, - left: `${offset.x + cursor.x}px`, - }; - expect( - container.querySelector(".uifw-cursorpopup")?.style - ).to.include(styleForOffset); - - const move = new Point(50, 60); - CursorInformation.handleMouseMove(move); - vi.advanceTimersByTime(0); - - const moved = move.offset(offset); - const styleForMoved = { top: `${moved.y}px`, left: `${moved.x}px` }; - await waitFor(() => { - expect( - container.querySelector(".uifw-cursorpopup")?.style - ).to.include(styleForMoved); - }); - - vi.advanceTimersByTime(40); - expect(CursorPopupManager.popupCount).toEqual(1); - - vi.advanceTimersByTime(1000); - expect(CursorPopupManager.popupCount).toEqual(0); - }); - - it("should close if passed a blank instruction", async () => { - render(); - expect(CursorPopupManager.popupCount).toEqual(0); - - const cursorPrompt = new CursorPrompt(20, false); - cursorPrompt.display( - "icon-placeholder", - ToolAssistance.createInstruction("icon-placeholder", "Prompt string") - ); - - expect(CursorPopupManager.popupCount).toEqual(1); - expect(await screen.findByText("Prompt string")).to.satisfy( - selectorMatches(".uifw-cursor-prompt *") - ); - - cursorPrompt.display( - "icon-placeholder", - ToolAssistance.createInstruction("icon-placeholder", "") - ); - - expect(CursorPopupManager.popupCount).toEqual(0); - }); -});