diff --git a/CHANGELOG.md b/CHANGELOG.md index 757bf45a25..1b5a1a3e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,35 @@ ## 87.0.0-SNAPSHOT - unreleased +### 💥 Breaking Changes (upgrade difficulty: 🟠 MEDIUM - React 19 upgrade.) +* Hoist v87 updates to React 19. Applications may require minor adjustments and should be + carefully tested. + * Apply any type adjustments needed to meet React 19's stricter typing. See + https://react.dev/blog/2024/04/25/react-19-upgrade-guide#typescript-changes for more info. + * Both desktop and mobile `Popover` implementations now render on Floating UI, rather than + Popper.js, which is not React-19 compatible. This changes the underlying DOM and CSS classes + for popovers. Test popover-based UI (menus, selects, date inputs, filter choosers) and adjust + any custom styling that targeted Blueprint or Popper css classes (e.g. `bp6-minimal`). + * The `popperOptions` `popper.js` escape-hatch prop has been removed from the mobile `Popover`. + +### ⚙️ Technical +* Moved both desktop and mobile popover implementations off the deprecated, React-18-capped Popper.js + onto Floating UI for React 19 compatibility. The hoist `Popover` components (mobile and desktop) + have been updated so no app call-site changes are required. +* Applied type adjustments to meet React 19's stricter `@types/react` typing. + +### 📚 Libraries +* react `18.2 → 19.2` + ## 86.4.0 - 2026-07-15 ### 🎁 New Features - * Added `PrefService.isSet()` to report whether the current user has an explicit value on file for a preference vs. receiving its server-side default - a distinction that cannot be reliably inferred by comparing the value to the default. Requires a hoist-core version that emits the backing `isSet` flag; against older servers all prefs report as unset. ### 🐞 Bug Fixes - * `PrefService.unset()` now performs a true server-side unset, clearing the user's stored value so the preference reverts to its (possibly changing) default and `isSet()` reports `false`. Previously it persisted the current default as an explicit user value. Falls back to the legacy diff --git a/cmp/grid/columns/Column.ts b/cmp/grid/columns/Column.ts index 62ac1df5ec..0ce9f27b3e 100644 --- a/cmp/grid/columns/Column.ts +++ b/cmp/grid/columns/Column.ts @@ -41,6 +41,7 @@ import { forwardRef, FunctionComponent, isValidElement, + ReactElement, ReactNode, useImperativeHandle, useLayoutEffect, @@ -1062,7 +1063,7 @@ export class Column { }; // Can be a component or elem factory/ ad-hoc render function. if ((editor as any).isHoistComponent) return createElement(editor, props); - if (isFunction(editor)) return editor(props); + if (isFunction(editor)) return editor(props) as ReactElement; throw XH.exception('Column editor must be a HoistComponent or a render function'); }); ret.cellEditorPopup = this.editorIsPopup; diff --git a/cmp/input/HoistInputModel.ts b/cmp/input/HoistInputModel.ts index 535739ee73..947e3db5e2 100644 --- a/cmp/input/HoistInputModel.ts +++ b/cmp/input/HoistInputModel.ts @@ -11,8 +11,7 @@ import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {createObservableRef} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import {isEqual} from 'lodash'; -import {FocusEvent, ForwardedRef, ReactElement, ReactInstance, useImperativeHandle} from 'react'; -import {findDOMNode} from 'react-dom'; +import {FocusEvent, ForwardedRef, ReactElement, useImperativeHandle} from 'react'; import './HoistInput.scss'; /** @@ -72,10 +71,8 @@ export class HoistInputModel extends HoistModel { * root of the rendered component sub-tree. */ get domEl(): HTMLElement { - const current = this.domRef.current as ReactInstance; - return ( - !current || current instanceof Element ? current : findDOMNode(current) - ) as HTMLElement; + const {current} = this.domRef; + return current instanceof Element ? (current as HTMLElement) : null; } /** @@ -103,7 +100,7 @@ export class HoistInputModel extends HoistModel { //------------------------ @observable.ref internalValue: any = null; // Cached internal value inputRef = createObservableRef(); // ref to internal element, if any - domRef = createObservableRef(); // ref to outermost element, or class Component. + domRef = createObservableRef(); // ref to outermost rendered DOM element. isDirty: boolean = false; constructor() { diff --git a/desktop/cmp/dash/canvas/widgetchooser/DashCanvasWidgetChooser.ts b/desktop/cmp/dash/canvas/widgetchooser/DashCanvasWidgetChooser.ts index d6d75eda87..d6a827a5b9 100644 --- a/desktop/cmp/dash/canvas/widgetchooser/DashCanvasWidgetChooser.ts +++ b/desktop/cmp/dash/canvas/widgetchooser/DashCanvasWidgetChooser.ts @@ -99,7 +99,12 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel): ReactNode[] { firstIcon = entries[0]?.icon, icon = firstIcon && - every(entries, it => it.icon?.props.iconName === firstIcon.props.iconName) + every( + entries, + it => + (it.icon?.props as {iconName?: string})?.iconName === + (firstIcon.props as {iconName?: string}).iconName + ) ? firstIcon : null; diff --git a/desktop/cmp/filter/FilterChooser.scss b/desktop/cmp/filter/FilterChooser.scss index 9026689fcd..cf01cef898 100644 --- a/desktop/cmp/filter/FilterChooser.scss +++ b/desktop/cmp/filter/FilterChooser.scss @@ -28,8 +28,8 @@ } } - // Extra class names required to override the default styles of the popover. - &__popover.bp6-popover.bp6-minimal { + // Override the default styles of the popover, scoped by our own `popoverClassName`. + &__popover.bp6-popover { box-shadow: none; // Overlay the expanded input onto the collapsed 30px trigger. Offset follows Blueprint's diff --git a/desktop/cmp/input/CodeInput.ts b/desktop/cmp/input/CodeInput.ts index 7507c0855d..5afd424376 100644 --- a/desktop/cmp/input/CodeInput.ts +++ b/desktop/cmp/input/CodeInput.ts @@ -606,7 +606,10 @@ const inputCmp = hoistCmp.factory(({model, ...props}, ref) => div({ className: 'xh-code-input__inner-wrapper', // We pass the container via ref to createCodeEditor, which initializes the editor inside it. - ref: model.createCodeEditor + // Wrapped to return void — React 19 treats a ref callback's return value as a cleanup fn. + ref: el => { + model.createCodeEditor(el); + } }), model.showToolbar ? toolbarCmp() : actionButtonsCmp() ], diff --git a/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts b/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts index 2f56308c04..cc674bc1af 100644 --- a/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +++ b/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts @@ -122,7 +122,7 @@ const tabCmp = hoistCmp.factory(({tab, index, localModel, model}) => { isCloseable = tab.disabled || model.enabledVisibleTabs.filter(it => it instanceof TabModel).length > 1, - tabRef = useRef(), + tabRef = useRef(null), scrollerModel = useContextModel(ScrollerModel), {showScrollButtons} = scrollerModel, {disabled, icon, tooltip} = tab, diff --git a/desktop/cmp/tab/dynamic/scroller/Scroller.ts b/desktop/cmp/tab/dynamic/scroller/Scroller.ts index 4b9b79eb3d..6a40e87749 100644 --- a/desktop/cmp/tab/dynamic/scroller/Scroller.ts +++ b/desktop/cmp/tab/dynamic/scroller/Scroller.ts @@ -4,7 +4,7 @@ import {button} from '@xh/hoist/desktop/cmp/button'; import {ScrollerModel} from '@xh/hoist/desktop/cmp/tab/dynamic/scroller/ScrollerModel'; import {Icon} from '@xh/hoist/icon'; import {composeRefs, useOnResize} from '@xh/hoist/utils/react'; -import React, {Ref} from 'react'; +import React, {ReactElement, Ref} from 'react'; /** * A scroller component that displays a content component with directional scroll buttons when the @@ -38,7 +38,7 @@ export const [Scroller, scroller] = hoistCmp.withFactory({ contentRef, useOnResize(() => model.onViewportEvent()) ) - }), + }) as ReactElement, scrollButton({direction: 'forward', model}) ] }); diff --git a/desktop/hooks/UseContextMenu.ts b/desktop/hooks/UseContextMenu.ts index d98cb1e74c..bad353c920 100644 --- a/desktop/hooks/UseContextMenu.ts +++ b/desktop/hooks/UseContextMenu.ts @@ -19,7 +19,7 @@ import {cloneElement, isValidElement, MouseEvent, ReactElement} from 'react'; * @param spec - spec the menu to be shown. If null, or the number of items is empty, no menu will * be rendered and the event will be consumed. */ -export function useContextMenu(child?: ReactElement, spec?: ContextMenuSpec): ReactElement { +export function useContextMenu(child?: ReactElement, spec?: ContextMenuSpec): ReactElement { if (!child || isUndefined(spec)) return child; const onContextMenu = (e: MouseEvent | PointerEvent) => { diff --git a/desktop/hooks/UseHotkeys.ts b/desktop/hooks/UseHotkeys.ts index fd446bfcb7..022919f2bc 100644 --- a/desktop/hooks/UseHotkeys.ts +++ b/desktop/hooks/UseHotkeys.ts @@ -22,7 +22,7 @@ import {HotkeyConfig} from '@blueprintjs/core/src/hooks/hotkeys/hotkeyConfig'; * @param hotkeys - An array of hotkeys, or configs for hotkeys, * as prescribed by blueprint. A Hotkeys element may also be provided. */ -export function useHotkeys(child?: ReactElement, hotkeys?: HotkeyConfig[]) { +export function useHotkeys(child?: ReactElement, hotkeys?: HotkeyConfig[]) { if (!child || isEmpty(hotkeys)) return child; const memoHotkeys = useMemo(() => hotkeys, []), diff --git a/docs/planning/react-19-upgrade.md b/docs/planning/react-19-upgrade.md new file mode 100644 index 0000000000..7c99b7fc1b --- /dev/null +++ b/docs/planning/react-19-upgrade.md @@ -0,0 +1,108 @@ +# React 19 upgrade + +**Branch:** `react-19-upgrade` (based on `develop`) — see [#4205](https://github.com/xh/hoist-react/issues/4205). + +**Outcome:** hoist-react now type-checks and runs against React 19. This is a clean cut — the +`react` / `react-dom` peer dependency moves to `^19.2.0` and React 18 is no longer supported. +Consuming apps upgrade to React 19 alongside this Hoist major. + +## Context + +A scan of hoist-react for React-19-affected internal APIs found the surface small, and most of it +was already handled before this branch: + +- `ReactDOM.render` / `hydrate` / `unmountComponentAtNode` — **none** (already on `createRoot`, in + `appcontainer/AppContainerModel.ts` and `desktop/cmp/dash/container/DashContainerModel.ts`). +- legacy Blueprint `Overlay` — already migrated to `Overlay2` in `kit/blueprint/Wrappers.ts`. +- `defaultProps` on function components — none. +- Legacy context (`childContextTypes` / `getChildContext`) — none. +- String refs / `element.ref` access / `props.ref` reads — none. +- `cloneElement` is used in several places but never to forward a `ref`, so the React 19 + `cloneElement` ref-handling change does not apply. + +That left the work below: two removed/broken APIs (`findDOMNode`, Popper.js-based popovers), the +custom-element boolean-prop change that affects the Onsen kit, the types/peer bump, and the +type-only fallout from `@types/react@19`. + +## Types / peer bump + +- `package.json`: `peerDependencies.react` / `react-dom` → `^19.2.0` (React 18 dropped); + dev `react` / `react-dom` → `^19.2.0`; `@types/react` / `@types/react-dom` and their + `resolutions` pins → `19.x`. +- Removed `react-popper` as a direct dependency; added `@floating-ui/react` (see below). + +## `findDOMNode` removal + +`findDOMNode` is removed in React 19. `HoistInputModel.domEl` (`cmp/input/HoistInputModel.ts`) +used it as a fallback for when `domRef.current` resolved to a class instance rather than a DOM +element. A trace of all desktop + mobile HoistInput implementations confirmed every one roots +`domRef` on a DOM element, so the fallback was dead defensive code. `domEl` now resolves the +element directly from the ref; the `findDOMNode` / `ReactInstance` imports are dropped. + +## Popovers off Popper.js → Floating UI + +Both popover implementations relied on Popper.js, which is React-18-capped and not React 19 +compatible. + +- **Desktop kit wrapper — `kit/blueprint/Wrappers.ts`.** Blueprint's legacy `Popover` relies on + the removed `findDOMNode`; its Floating UI-based `PopoverNext` (same Blueprint build) is the + replacement — mirroring the existing `Overlay2 as Overlay` pattern. The wrapper now renders + `PopoverNext` and runs incoming props through Blueprint's `popoverPropsToNextProps()` helper, + which maps `position` / `modifiers` / `minimal` / `boundary` and preserves the legacy + `shouldReturnFocusOnClose` default. Because the helper handles the mapping, the `popover` + factory keeps its existing `PopoverProps` API and **no call sites needed edits**. +- **Mobile — `mobile/cmp/popover/Popover.ts`** (the sole `react-popper` consumer). Swapped + `usePopper` for `@floating-ui/react`'s `useFloating`, reusing the Floating UI copy Blueprint's + `PopoverNext` already pulls in rather than adding a separate library. Positioning uses + `middleware: [autoPlacement() | flip(), shift({padding: 10})]` with `whileElementsMounted: + autoUpdate`; placement names are shared with Popper.js so `menuPositionToPlacement` is + unchanged. Element wiring uses Floating UI's own `refs.setReference` / `refs.setFloating` + callback refs rather than the previous observable refs — the MobX-observer re-render the old + approach depended on did not reliably fire under React 19 for the portaled content. The obsolete + Popper.js-specific `popperOptions` escape-hatch prop was removed (breaking change; unused in + Hoist). + +## Onsen custom-element boolean props — `kit/onsen/index.ts` + +react-onsenui encodes boolean props as the string `''` (or `null`). Under React 18 these were +assigned as DOM *attributes*; React 19 assigns them as DOM *properties* on the underlying custom +element, and Onsen's boolean property setters treat `''` as falsy — so props like `checked`, +`disabled`, and `visible` silently failed to apply. The kit wrapper now strips boolean props from +the rendered props and applies the real booleans imperatively via a ref in `useLayoutEffect` +(accounting for Onsen's deprecated aliases `isOpen`/`isCancelable`/`isDisabled`), routing through +Onsen's own setters. + +## `@types/react@19` type adjustments + +Type-only fallout, no runtime behavior change: + +- `useRef` now requires an initial arg — `desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts` + (`useRef(null)`). +- Ref-callback return values are treated as cleanup functions — `desktop/cmp/input/CodeInput.ts` + wraps its `createCodeEditor` ref callback to return `void` (the method is `async`, so it would + otherwise return a Promise that React invokes as a destructor). +- `ReactElement.props` is typed `unknown` — `desktop/cmp/dash/canvas/widgetchooser/DashCanvasWidgetChooser.ts` + narrows icon `props` before reading `iconName`. +- Widened FC return type / stricter element typing — casts in `cmp/grid/columns/Column.ts` and + `desktop/cmp/tab/dynamic/scroller/Scroller.ts` (`as ReactElement`), and `ReactElement` in + `desktop/hooks/UseContextMenu.ts` and `desktop/hooks/UseHotkeys.ts`. + +## Not done (still valid on React 19) + +- **`forwardRef` → `ref`-as-prop.** `forwardRef` is *deprecated* in React 19, not removed — it + still works, so it never blocked this upgrade. Left in place as future modernization cleanup; + sites include `core/HoistComponent.ts` (the framework-wide `cfg.isForwardRef` ref wrap — the + delicate one), `cmp/grid/columns/Column.ts`, and `kit/onsen/index.ts`. +- **`propTypes`** — React 19 ignores them (not an error). No runtime `propTypes` definitions exist + in the codebase; only a stale comment reference in `desktop/cmp/button/index.ts`. + +## Verification + +- `tsc --noEmit` clean against `@types/react@19`. +- `yarn lint`. +- Smoke in toolbox via `yarn startWithHoist`: + - inputs and anything reading `HoistInputModel.domEl` (focus, autofocus, sizing/measurement); + - desktop popovers: Select, DateInput, combos, column chooser, ViewManager menu, mobile + MenuButton — placement, dismissal, and focus-return behavior; + - mobile Popover positioning, flip/shift near viewport edges, scroll/resize updates; + - Onsen-backed mobile controls with boolean state (checkboxes, switches, dialog visibility). diff --git a/kit/blueprint/Wrappers.ts b/kit/blueprint/Wrappers.ts index bbeb64b7ef..fc20a90668 100644 --- a/kit/blueprint/Wrappers.ts +++ b/kit/blueprint/Wrappers.ts @@ -35,7 +35,8 @@ import { NumericInput, OverflowList, Overlay2 as Overlay, - Popover as BpPopover, + PopoverNext as BpPopover, + popoverPropsToNextProps, type PopoverProps, Radio, RadioGroup, @@ -61,8 +62,11 @@ import React, {createElement as reactCreateElement} from 'react'; const Dialog: React.FC = props => reactCreateElement(BpDialog, {transitionDuration: 0, transitionName: 'none', ...props}); +// `Popover` renders Blueprint's `PopoverNext` (Floating UI based, React 19 compatible) while +// preserving our legacy `PopoverProps` API: `popoverPropsToNextProps` maps `position`/`modifiers`/ +// `minimal`/`boundary` and retains the legacy `shouldReturnFocusOnClose` default of `false`. const Popover: React.FC = props => - reactCreateElement(BpPopover, {transitionDuration: 0, ...props}); + reactCreateElement(BpPopover, {transitionDuration: 0, ...popoverPropsToNextProps(props)}); //--------------------- // Re-exports diff --git a/kit/onsen/index.ts b/kit/onsen/index.ts index c562722a73..1235b0084b 100644 --- a/kit/onsen/index.ts +++ b/kit/onsen/index.ts @@ -8,9 +8,10 @@ import {ElementFactory, elementFactory, HoistModel} from '@xh/hoist/core'; import onsen from 'onsenui'; import 'onsenui/css/onsen-css-components.css'; import 'onsenui/css/onsenui.css'; -import {createElement, forwardRef, FunctionComponent} from 'react'; +import {composeRefs} from '@xh/hoist/utils/react'; +import {createElement, forwardRef, FunctionComponent, useLayoutEffect, useRef} from 'react'; import * as ons from 'react-onsenui'; -import {omitBy} from 'lodash'; +import {mapKeys, omitBy, pickBy} from 'lodash'; import './styles.scss'; import './theme.scss'; @@ -42,23 +43,41 @@ export const [dialog, Dialog] = wrappedCmp(ons.Dialog), //----------------- // Implementation //----------------- +// Onsen's deprecated boolean prop aliases - react-onsenui remaps these to the real custom-element +// property names before assigning. We replicate it here since we bypass that step for booleans. +const ONSEN_BOOL_ALIASES = {isOpen: 'visible', isCancelable: 'cancelable', isDisabled: 'disabled'}; + /** - * Wrappers around ElementFactory and ContainerElementFactory that strip - * HoistModel props before passing onto the Onsen component. + * Wrapper around ElementFactory that adapts an Onsen component for use within Hoist. * - * Onsen component props are internally serialized to JSON. If it receives a HoistModel as a prop, - * it can easily cause a circular structure error due to the complexity of the model. For example, - * any HoistModel that implements LoadSupport will create a 'target' reference to itself. - * Apps can readily introduce other structures incompatible with JSON serialization. + * Strips HoistModel props before passing them on. Onsen serializes props to JSON internally, and a + * an Onsen component never needs a HoistModel prop. * - * There is no reason for an Onsen Component to ever receive a HoistModel prop, so we can safely - * strip them out here. + * Applies boolean props imperatively as real booleans via a ref. react-onsenui encodes boolean + * props as the string `''` (or `null`), which React 19 assigns as a *property* on the underlying + * custom element rather than as an *attribute* (as React 18 and earlier did). Onsen's boolean property + * setters treat `''` as falsy, so props such as `checked`, `disabled`, and `visible` silently fail + * to apply. Setting the real boolean on the element after commit routes through Onsen's own setters. */ function wrappedCmp(rawCmp): [ElementFactory, FunctionComponent] { const cmp = forwardRef((props, ref) => { - const safeProps = omitBy(props, it => it instanceof HoistModel); - if (ref) safeProps.ref = ref; - return createElement(rawCmp, safeProps); + // 1) Gather the boolean props, accounting for aliased keys. + // We'll apply these values directly on underlying onsen component after render. + const elemRef = useRef(null); + const boolProps = mapKeys( + pickBy(props, it => typeof it === 'boolean'), + (_v, key) => ONSEN_BOOL_ALIASES[key] ?? key + ); + useLayoutEffect(() => { + if (elemRef.current) Object.assign(elemRef.current, boolProps); + }); + + // 2) Set remaining props on the underlying component, including our ref. + const childProps = { + ...omitBy(props, it => it instanceof HoistModel || typeof it === 'boolean'), + ref: composeRefs(elemRef, ref) + }; + return createElement(rawCmp, childProps); }); return [elementFactory(cmp), cmp]; } diff --git a/mobile/cmp/popover/Popover.ts b/mobile/cmp/popover/Popover.ts index d4d40c370e..08b971b085 100644 --- a/mobile/cmp/popover/Popover.ts +++ b/mobile/cmp/popover/Popover.ts @@ -4,24 +4,23 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {div, fragment} from '@xh/hoist/cmp/layout'; import { - Content, - hoistCmp, - HoistModel, - HoistProps, - PlainObject, - useLocalModel, - XH -} from '@xh/hoist/core'; + autoPlacement, + autoUpdate, + flip, + type Placement, + shift, + useFloating +} from '@floating-ui/react'; +import {div, fragment} from '@xh/hoist/cmp/layout'; +import {Content, hoistCmp, HoistModel, HoistProps, useLocalModel, XH} from '@xh/hoist/core'; import '@xh/hoist/mobile/register'; import {action, makeObservable, observable} from '@xh/hoist/mobx'; -import {createObservableRef, elementFromContent} from '@xh/hoist/utils/react'; +import {elementFromContent} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import {isFunction, isNil} from 'lodash'; import {ReactPortal} from 'react'; import ReactDom from 'react-dom'; -import {usePopper} from 'react-popper'; import './Popover.scss'; @@ -62,18 +61,15 @@ export interface PopoverProps extends HoistProps { /** Optional className applied to the popover content wrapper. */ popoverClassName?: string; - - /** Escape hatch to provide additional options to the PopperJS implementation */ - popperOptions?: PlainObject; } /** * Popovers display floating content next to a target element. * * The API is based on a stripped-down version of Blueprint's Popover component - * that is used on Desktop. Popover is built on top of the Popper.js library. + * that is used on Desktop. Popover is built on top of the Floating UI library. * - * @see https://popper.js.org/ + * @see https://floating-ui.com/ */ export const [Popover, popover] = hoistCmp.withFactory({ displayName: 'Popover', @@ -86,30 +82,28 @@ export const [Popover, popover] = hoistCmp.withFactory({ disabled = false, backdrop = false, position = 'auto', - popoverClassName, - popperOptions + popoverClassName }) { const impl = useLocalModel(PopoverModel), - popper = usePopper(impl.targetEl, impl.contentEl, { - placement: impl.menuPositionToPlacement(position), + isAuto = position === 'auto', + placement = isAuto ? undefined : impl.menuPositionToPlacement(position), + // Use Floating UI's own `refs.setReference`/`setFloating` callback refs rather than + // the controlled `elements` option. This lets Floating UI manage the element state + // (and trigger its own re-renders/repositioning) internally, instead of relying on a + // MobX observer re-render when an observable ref is set during the commit phase - a + // dependency that does not reliably fire under React 19 for the portaled content. + {refs, floatingStyles} = useFloating({ + placement, strategy: 'fixed', - modifiers: [ - { - name: 'preventOverflow', - options: { - padding: 10, - boundary: 'viewport' - } as any - } - ], - ...popperOptions + middleware: [isAuto ? autoPlacement() : flip(), shift({padding: 10})], + whileElementsMounted: autoUpdate }); return div({ className, items: [ div({ - ref: impl.targetRef, + ref: refs.setReference, className: 'xh-popover__target-wrapper', items: children, onClick: () => { @@ -122,8 +116,8 @@ export const [Popover, popover] = hoistCmp.withFactory({ omit: !impl.isOpen, items: [ div({ - ref: impl.contentRef, - style: popper?.styles?.popper, + ref: refs.setFloating, + style: floatingStyles, className: classNames( 'xh-popover__content-wrapper', popoverClassName @@ -149,21 +143,11 @@ export const [Popover, popover] = hoistCmp.withFactory({ class PopoverModel extends HoistModel { override xhImpl = true; - targetRef = createObservableRef(); - contentRef = createObservableRef(); @observable isOpen; _onInteraction; _controlledMode = false; - get targetEl() { - return this.targetRef.current; - } - - get contentEl() { - return this.contentRef.current; - } - constructor() { super(); makeObservable(this); @@ -224,12 +208,12 @@ class PopoverModel extends HoistModel { } /** - * Convert a menu position to a Popper.js placement. - * This allows us to the same position names as desktop, and is inspired + * Convert a menu position to a Floating UI placement (the vocabulary is shared with + * Popper.js). This allows us to use the same position names as desktop, and is inspired * by Blueprint's similar implementation: * https://github.com/palantir/blueprint/blob/develop/packages/core/src/components/popover/popoverMigrationUtils.ts */ - menuPositionToPlacement(position) { + menuPositionToPlacement(position: string): Placement { switch (position) { case 'top-left': return 'top-start'; @@ -248,7 +232,7 @@ class PopoverModel extends HoistModel { case 'left-bottom': return 'left-end'; default: - return position; + return position as Placement; } } } diff --git a/package.json b/package.json index 1764172917..87d97a2df1 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@codemirror/lint": "^6.9.6", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.43.0", + "@floating-ui/react": "^0.27.13", "@fortawesome/fontawesome-pro": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/pro-light-svg-icons": "^7.2.0", @@ -79,7 +80,6 @@ "react-grid-layout": "~2.2.3", "react-markdown": "~10.1.0", "react-onsenui": "~1.13.2", - "react-popper": "~2.3.0", "react-select": "~5.10.2", "react-window": "~2.2.7", "react-windowed-select": "~5.2.0", @@ -97,12 +97,12 @@ "zod": "^4.3.6" }, "peerDependencies": { - "react": "~18.2.0", - "react-dom": "~18.2.0" + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { - "@types/react": "18.x", - "@types/react-dom": "18.x", + "@types/react": "19.x", + "@types/react-dom": "19.x", "@xh/hoist-dev-utils": "13.x", "ag-grid-community": "35.x", "ag-grid-react": "35.x", @@ -114,16 +114,16 @@ "lint-staged": "17.x", "postcss": "8.x", "prettier": "3.x", - "react": "~18.2.0", - "react-dom": "~18.2.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", "stylelint": "17.x", "stylelint-config-standard-scss": "17.x", "type-fest": "5.x", "typescript": "~5.9.3" }, "resolutions": { - "@types/react": "18.x", - "@types/react-dom": "18.x", + "@types/react": "19.x", + "@types/react-dom": "19.x", "core-js": "3.x" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/yarn.lock b/yarn.lock index 3e96ee8fe0..524e4e937d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,9 +3,9 @@ "@auth0/auth0-auth-js@^1.10.0": - version "1.11.0" - resolved "https://registry.yarnpkg.com/@auth0/auth0-auth-js/-/auth0-auth-js-1.11.0.tgz#94458721551d5135d13d2fd2b0e44cdf1f7848b7" - integrity sha512-hy5knYIPKECTLCPCZKolHsdOkjZhCAc59pga4Y3dp8q7E1uVSKYI7BUhzukNVykUshJRmcgPwOiBtsn2JjUo6w== + version "1.12.0" + resolved "https://registry.yarnpkg.com/@auth0/auth0-auth-js/-/auth0-auth-js-1.12.0.tgz#ca46ae6fcd23f6dcd62b8678693f41b957d9b63f" + integrity sha512-94r29UxcFEdroO74TUqu5KYy5hSKa6mCXY8lu4hADiyES8qthlSCwaCPEW1adpM5hdheltQ7s1o9TsK8Btyjvg== dependencies: jose "^6.0.8" openid-client "^6.8.0" @@ -1167,9 +1167,9 @@ "@lezer/lr" "^1.3.1" "@codemirror/lang-markdown@^6.0.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz#29df87310a555b007beba8e12893363956a26e8e" - integrity sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw== + version "6.5.1" + resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz#eb53c488e368b8fa581409a06531dfedd573d88c" + integrity sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA== dependencies: "@codemirror/autocomplete" "^6.7.1" "@codemirror/lang-html" "^6.0.0" @@ -2157,9 +2157,9 @@ "@lezer/common" "^1.0.0" "@lezer/markdown@^1.0.0": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.7.1.tgz#92bf8a4128308a2d18d13483b48ff189c9fc2f73" - integrity sha512-MEBZeFSBxgteUjEC3Wxg2Dwld5/JxRKG267L3bMFdibm8KjqSdiJYBeFw1Nt1CM8+zKMpSIEHblY8FD9z38sJQ== + version "1.7.2" + resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.7.2.tgz#dfe0249813dc8faa60b4659a4ca2b8da6dcaf753" + integrity sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ== dependencies: "@lezer/common" "^1.5.0" "@lezer/highlight" "^1.0.0" @@ -2693,11 +2693,6 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== -"@types/prop-types@*": - version "15.7.15" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" - integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== - "@types/qs@*": version "6.15.1" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.15.1.tgz#8606884272c63f0db96986bd3548650d8a9388bf" @@ -2708,22 +2703,21 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@18.x": - version "18.3.7" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" - integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== +"@types/react-dom@18.x", "@types/react-dom@19.x": + version "19.2.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== "@types/react-transition-group@^4.4.0": version "4.4.12" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@18.x": - version "18.3.31" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.31.tgz#b5e95e28ffcceab8d982f33f2eb076e17653c2a4" - integrity sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw== +"@types/react@18.x", "@types/react@19.x": + version "19.2.17" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.17.tgz#dccac365baa0f1734ec270ff4b51c89465e8dc7f" + integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw== dependencies: - "@types/prop-types" "*" csstype "^3.2.2" "@types/retry@0.12.2": @@ -3436,12 +3430,12 @@ attr-accept@^2.2.4: integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== autoprefixer@~10.5.0: - version "10.5.2" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.5.2.tgz#5d7dcaab1c294038fe51e0fa3738d28e559caac0" - integrity sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q== + version "10.5.3" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.5.3.tgz#cd6f4c611cef98055c998b5de8e6b1759f024447" + integrity sha512-bJRzflk8GgE4JX+iZNEwz9f9p460NCHnU7bd+CZ9vIjIlZuTkt6F3WSl2oNO8StZBFx17nLEsiQ6H2wcZiY7nA== dependencies: - browserslist "^4.28.4" - caniuse-lite "^1.0.30001799" + browserslist "^4.28.6" + caniuse-lite "^1.0.30001805" fraction.js "^5.3.4" picocolors "^1.1.1" postcss-value-parser "^4.2.0" @@ -3611,7 +3605,7 @@ browser-tabs-lock@^1.3.0: dependencies: lodash ">=4.17.21" -browserslist@^4.24.0, browserslist@^4.28.1, browserslist@^4.28.4: +browserslist@^4.24.0, browserslist@^4.28.1, browserslist@^4.28.6: version "4.28.6" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.6.tgz#7cf83afcd69c55fde6fb2dcc5039ff0f4ba42610" integrity sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw== @@ -3694,7 +3688,7 @@ camel-case@^4.1.2: pascal-case "^3.1.2" tslib "^2.0.3" -caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001803: +caniuse-lite@^1.0.30001803, caniuse-lite@^1.0.30001805: version "1.0.30001805" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz#78d5d5968a69b7ff81af87a96d7ddc7ea6670b1e" integrity sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA== @@ -4356,9 +4350,9 @@ ee-first@1.1.1: integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== electron-to-chromium@^1.5.389: - version "1.5.389" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz#538be9ebec78026d4daba6be321ab854dfac2a8f" - integrity sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg== + version "1.5.392" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz#31e9f9af0d8df3d1489468a4242b173605617482" + integrity sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g== emoji-regex@^10.3.0: version "10.6.0" @@ -6246,7 +6240,7 @@ longest-streak@^3.0.0: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -7544,13 +7538,12 @@ react-day-picker@^8.10.0: resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.2.tgz#12b7893d5b1bd6ba7f055498614c1c5aaf50accd" integrity sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ== -react-dom@~18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" - integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== +react-dom@^19.2.0: + version "19.2.7" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.7.tgz#0450dc9ae9ddbff76ef196401cd8b8c7fb466ccc" + integrity sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ== dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.0" + scheduler "^0.27.0" react-draggable@^4.4.6, react-draggable@^4.5.0: version "4.7.0" @@ -7620,7 +7613,7 @@ react-onsenui@~1.13.2: dependencies: prop-types "^15.6.0" -react-popper@^2.3.0, react-popper@~2.3.0: +react-popper@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba" integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q== @@ -7690,12 +7683,10 @@ react-windowed-select@~5.2.0: react-select "^5.2.2" react-window "^1.8.6" -react@~18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" - integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== - dependencies: - loose-envify "^1.1.0" +react@^19.2.0: + version "19.2.7" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b" + integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== readable-stream@^2.0.1: version "2.3.8" @@ -8206,12 +8197,10 @@ sass@1.100.0: optionalDependencies: "@parcel/watcher" "^2.4.1" -scheduler@^0.23.0: - version "0.23.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" - integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== - dependencies: - loose-envify "^1.1.0" +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== schema-utils@^4.0.0, schema-utils@^4.2.0, schema-utils@^4.3.0, schema-utils@^4.3.3: version "4.3.3" @@ -9602,9 +9591,9 @@ write-file-atomic@^7.0.1: signal-exit "^4.0.1" ws@^8.18.0, ws@^8.19.0: - version "8.21.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951" - integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g== + version "8.21.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586" + integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== wsl-utils@^0.1.0: version "0.1.0"