Skip to content

chore: new Dialog with floating-ui#3246

Merged
talkor merged 8 commits intomasterfrom
feat/dialog/next
Feb 16, 2026
Merged

chore: new Dialog with floating-ui#3246
talkor merged 8 commits intomasterfrom
feat/dialog/next

Conversation

@talkor
Copy link
Copy Markdown
Member

@talkor talkor commented Feb 4, 2026

User description

https://monday.monday.com/boards/3532714909/pulses/5319093293


PR Type

Enhancement


Description

  • Migrate Dialog component to next folder with floating-ui

  • Replace Popper.js with Floating UI for positioning

  • Add comprehensive Dialog types and props interface

  • Implement scroll disable hook and DialogContent component

  • Add extensive test coverage for Dialog functionality


Diagram Walkthrough

flowchart LR
  A["Old Dialog<br/>with Popper.js"] -->|"Refactor"| B["New Dialog<br/>with Floating UI"]
  B -->|"Uses"| C["Floating UI<br/>Middleware"]
  B -->|"Renders"| D["DialogContent<br/>Component"]
  B -->|"Manages"| E["Scroll Disable<br/>Hook"]
  B -->|"Wraps refs"| F["Refable<br/>Component"]
  G["Dialog.types.ts"] -->|"Defines"| B
  H["Dialog.module.scss"] -->|"Styles"| D
Loading

File Walkthrough

Relevant files
Enhancement
10 files
Dialog.types.ts
Define comprehensive Dialog types and props                           
+247/-0 
Dialog.tsx
Implement Dialog component with Floating UI                           
+541/-0 
Dialog.module.scss
Add Dialog arrow and positioning styles                                   
+42/-0   
index.ts
Export Dialog component and types                                               
+2/-0     
useDisableScroll.ts
Implement scroll disable hook for dialogs                               
+37/-0   
DialogContent.tsx
Implement DialogContent with animations                                   
+248/-0 
DialogContent.module.scss
Add DialogContent animation and padding styles                     
+173/-0 
Refable.tsx
Create Refable utility for ref forwarding                               
+63/-0   
index.ts
Export Dialog from next components                                             
+1/-0     
Dialog.stories.tsx
Update Dialog stories to use Floating UI                                 
+24/-74 
Tests
4 files
useDisableScroll.test.ts
Add tests for useDisableScroll hook                                           
+67/-0   
useDisableScroll.test.ts
Update useDisableScroll test import path                                 
+2/-2     
Refable.test.tsx
Add comprehensive tests for Refable component                       
+88/-0   
Dialog.test.tsx
Add extensive Dialog component tests                                         
+472/-0 
Dependencies
1 files
package.json
Add floating-ui dependency to core                                             
+1/-0     

@talkor talkor marked this pull request as ready for review February 4, 2026 08:52
@talkor talkor requested a review from a team as a code owner February 4, 2026 08:52
@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects bot commented Feb 4, 2026

PR Reviewer Guide 🔍

(Review updated until commit 7bcf81b)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Breaking Behavior

Refable drops non-ReactElement children by returning null for anything that is not a valid element. This conflicts with the public DialogProps type that allows children to be a string, and can lead to triggers disappearing if a consumer passes text (or other primitives) as children.

export const Refable = React.forwardRef<HTMLElement, RefableProps>(
  ({ children, wrapperElement = "span", ...rest }, ref) => {
    return React.Children.map(children, child => {
      if (!React.isValidElement(child)) return null;

      // For React components, wrap in a native element to attach the ref
      if (typeof child.type !== "string") {
        const WrapperElement = wrapperElement;
        return (
          // @ts-expect-error - TypeScript can't infer the correct ref type when using a variable as JSX tag
          <WrapperElement ref={ref} {...rest}>
            {child}
          </WrapperElement>
        );
      }

      // For native HTML elements, clone and merge props/refs
      const childProps = child.props as React.HTMLProps<HTMLElement>;

      return React.cloneElement(child, {
        ...rest,
        ...childProps,
        // Chain all event handlers to preserve both parent and child handlers
        onClick: getChainedFunction("onClick", childProps, rest),
        onBlur: getChainedFunction("onBlur", childProps, rest),
        onFocus: getChainedFunction("onFocus", childProps, rest),
        onMouseEnter: getChainedFunction("onMouseEnter", childProps, rest),
        onMouseLeave: getChainedFunction("onMouseLeave", childProps, rest),
        onMouseDown: getChainedFunction("onMouseDown", childProps, rest),
        onKeyDown: getChainedFunction("onKeyDown", childProps, rest),
        onContextMenu: getChainedFunction("onContextMenu", childProps, rest),
        ref: chainRefFunctions([(child as any).ref, ref])
      } as any);
    }) as any;
  }
Controlled Mode

In controlled usage (open true), hide/show triggers still call hideDialog/showDialog which updates internal state and fires onDialogDidHide/onDialogDidShow, but the rendered visibility is ultimately governed by open. This can cause callbacks and internal state to diverge from actual visibility (e.g., hide callbacks firing while the dialog remains visible because open is still true).

// Derived state
const isOpenInternal = useDerivedStateFromProps ? isOpenProp : isOpenState;
const isShown = isOpenInternal || open;

// Build middleware array for Floating UI
const floatingMiddleware = useMemo<Middleware[]>(() => {
  const middlewareList: Middleware[] = [];

  // Get user-provided middleware (filter out invalid ones)
  const validMiddleware = middlewareProp.filter(
    (m): m is Middleware => m != null && typeof m === "object" && typeof m.fn === "function"
  );

  // Check if user provided their own middleware to override defaults
  const hasCustomOffset = validMiddleware.some(m => m.name === "offset");
  const hasCustomFlip = validMiddleware.some(m => m.name === "flip");
  const hasCustomShift = validMiddleware.some(m => m.name === "shift");

  // Offset middleware - skip if user provided their own
  if (!hasCustomOffset && (moveBy.main !== 0 || moveBy.secondary !== 0)) {
    middlewareList.push(offset({ mainAxis: moveBy.main || 0, crossAxis: moveBy.secondary || 0 }));
  }

  // Core positioning middleware - skip if user provided their own
  if (!hasCustomFlip) {
    middlewareList.push(flip());
  }
  if (!hasCustomShift) {
    middlewareList.push(shift());
  }

  // Add user-provided middleware
  middlewareList.push(...validMiddleware);

  // Arrow middleware - pass ref directly, Floating UI handles null refs
  if (tooltip) {
    middlewareList.push(arrowMiddleware({ element: arrowRef }));
  }

  // Hide middleware for detecting when reference is hidden
  if (hideWhenReferenceHidden) {
    middlewareList.push(hide());
  }

  return middlewareList;
}, [moveBy.main, moveBy.secondary, tooltip, hideWhenReferenceHidden, middlewareProp]);

// Configure autoUpdate for position tracking
const whileElementsMounted = useCallback(
  (reference: HTMLElement, floating: HTMLElement, update: () => void) => {
    return autoUpdate(reference, floating, update, {
      elementResize: observeContentResize,
      layoutShift: false
    });
  },
  [observeContentResize]
);

// Use Floating UI hook
const { refs, floatingStyles, placement, middlewareData } = useFloating({
  placement: position as Placement,
  middleware: floatingMiddleware,
  whileElementsMounted,
  elements: {
    reference: referenceElement
  }
});

// Check if reference is hidden (from hide middleware)
const isReferenceHidden = middlewareData.hide?.referenceHidden ?? false;

const isShowTrigger = useCallback(
  (eventName: DialogTriggerEvent) => {
    const showTriggersArray = convertToArray(showTrigger);
    if (addKeyboardHideShowTriggersByDefault && eventName === "focus" && showTriggersArray.includes("mouseenter")) {
      return true;
    }
    return showTriggersArray.includes(eventName);
  },
  [showTrigger, addKeyboardHideShowTriggersByDefault]
);

const isHideTrigger = useCallback(
  (eventName: DialogTriggerEvent) => {
    const hideTriggersArray = convertToArray(hideTrigger);
    if (addKeyboardHideShowTriggersByDefault && eventName === "blur" && hideTriggersArray.includes("mouseleave")) {
      return true;
    }
    return hideTriggersArray.includes(eventName);
  },
  [hideTrigger, addKeyboardHideShowTriggersByDefault]
);

const showDialog = useCallback(
  (event: DialogEvent, eventName: DialogTriggerEvent | string, options: { preventAnimation?: boolean } = {}) => {
    let finalShowDelay = showDelay;
    let preventAnimationValue = options.preventAnimation;
    if (getDynamicShowDelay) {
      const dynamicDelayObj = getDynamicShowDelay();
      finalShowDelay = dynamicDelayObj.showDelay || 0;
      preventAnimationValue = preventAnimationValue ?? dynamicDelayObj.preventAnimation;
    }

    if (instantShowAndHide) {
      onDialogDidShow(event, eventName);
      setIsOpenState(true);
      setPreventAnimation(!!preventAnimationValue);
      showTimeoutRef.current = null;
    } else {
      showTimeoutRef.current = setTimeout(() => {
        onDialogDidShow(event, eventName);
        showTimeoutRef.current = null;
        setIsOpenState(true);
        setPreventAnimation(!!preventAnimationValue);
      }, finalShowDelay);
    }
  },
  [showDelay, getDynamicShowDelay, instantShowAndHide, onDialogDidShow]
);

const hideDialog = useCallback(
  (event: DialogEvent, eventName: DialogTriggerEvent | string) => {
    if (instantShowAndHide) {
      onDialogDidHide(event, eventName);
      setIsOpenState(false);
      hideTimeoutRef.current = null;
    } else {
      hideTimeoutRef.current = setTimeout(() => {
        onDialogDidHide(event, eventName);
        setIsOpenState(false);
        hideTimeoutRef.current = null;
      }, hideDelay);
    }
  },
  [hideDelay, instantShowAndHide, onDialogDidHide]
);

const showDialogIfNeeded = useCallback(
  (event: DialogEvent, eventName: DialogTriggerEvent | string, options = {}) => {
    if (disable) {
      return;
    }

    if (hideTimeoutRef.current) {
      clearTimeout(hideTimeoutRef.current);
      hideTimeoutRef.current = null;
    }

    if (!showTimeoutRef.current) {
      showDialog(event, eventName, options);
    }
  },
  [disable, showDialog]
);

const hideDialogIfNeeded = useCallback(
  (event: DialogEvent, eventName: DialogTriggerEvent | string) => {
    if (showTimeoutRef.current) {
      clearTimeout(showTimeoutRef.current);
      showTimeoutRef.current = null;
    }

    if (!hideTimeoutRef.current) {
      hideDialog(event, eventName);
    }
  },
  [hideDialog]
);

// Event handling
const handleEvent = useCallback(
  (eventName: DialogTriggerEvent, target: EventTarget | null, event: DialogEvent) => {
    if (!target) return; // Guard against null targets (e.g., when focus leaves the document)
    if (isShowTrigger(eventName) && !isShown && !isInsideClass(target as HTMLElement, showTriggerIgnoreClass)) {
      return showDialogIfNeeded(event, eventName);
    }

    if (isHideTrigger(eventName) && !isInsideClass(target as HTMLElement, hideTriggerIgnoreClass)) {
      return hideDialogIfNeeded(event, eventName);
    }
  },
Transition Timeout

CSSTransition receives timeout={showDelay} where showDelay is optional. If it is ever undefined, react-transition-group can behave unexpectedly or warn. Consider defaulting showDelay to a number at the DialogContent level (or mapping undefined to 0) to ensure stable transition timing.

const DialogContent = forwardRef<HTMLElement, DialogContentProps>(
  (
    {
      onEsc = NOOP,
      children,
      position,
      wrapperClassName,
      isOpen = false,
      startingEdge,
      animationType = "expand",
      onMouseEnter = NOOP,
      onMouseLeave = NOOP,
      onClickOutside = NOOP,
      onClick = NOOP,
      onContextMenu = NOOP,
      showDelay,
      styleObject = EMPTY_OBJECT,
      isReferenceHidden,
      hasTooltip = false,
      containerSelector,
      disableContainerScroll = false,
      "data-testid": dataTestId
    },
    forwardedRef
  ) => {
    const contentRef = useRef<HTMLDivElement>(null);
    const wrapperRef = useRef<HTMLSpanElement>(null);
    const mergedWrapperRef = useMergeRef(forwardedRef, wrapperRef);

    const onOutsideClick = useCallback(
      (event: React.MouseEvent) => {
        if (isOpen) {
          return onClickOutside(event, "clickoutside");
        }
      },
      [isOpen, onClickOutside]
    );
    const overrideOnContextMenu = useCallback(
      (event: React.MouseEvent) => {
        if (isOpen) {
          onContextMenu(event);
        }
      },
      [isOpen, onContextMenu]
    );

    // Wrap escape callback to ensure useKeyEvent always receives a valid function
    const escapeCallback = useMemo(
      () => (event: KeyboardEvent) => {
        if (isOpen && onEsc !== NOOP) {
          onEsc(event as unknown as React.KeyboardEvent);
        }
      },
      [isOpen, onEsc]
    );
    useKeyEvent({ keys: ESCAPE_KEYS, callback: escapeCallback });

    // Watch the wrapper ref to include padding area, tooltip arrows, and nested Dialogs
    useClickOutside({ callback: onOutsideClick, ref: wrapperRef });
    useClickOutside({ eventName: "contextmenu", callback: overrideOnContextMenu, ref: wrapperRef });
    const selectorToDisable = typeof disableContainerScroll === "string" ? disableContainerScroll : containerSelector;
    const { disableScroll, enableScroll } = useDisableScroll(selectorToDisable);

    useEffect(() => {
      if (disableContainerScroll) {
        if (isOpen) {
          disableScroll();
        } else {
          enableScroll();
        }
      }
    }, [disableContainerScroll, disableScroll, enableScroll, isOpen]);

    const transitionOptions: Partial<CSSTransitionProps> = { classNames: undefined };

    switch (animationType) {
      case "expand":
        transitionOptions.classNames = {
          appear: styles.expandAppear,
          appearActive: styles.expandAppearActive,
          exit: styles.expandExit
        };
        break;
      case "opacity-and-slide":
        transitionOptions.classNames = {
          appear: styles.opacitySlideAppear,
          appearActive: styles.opacitySlideAppearActive
        };
        break;
    }

    // Clone children to attach mouse event handlers, with proper type checking
    const childrenWithHandlers = React.Children.map(children, child => {
      // Only clone valid React elements, pass through primitives unchanged
      if (!React.isValidElement(child)) {
        return child;
      }

      return cloneElement(child as ReactElement, {
        onMouseEnter: chainFunctions([(child as ReactElement).props.onMouseEnter, onMouseEnter]),
        onMouseLeave: chainFunctions([(child as ReactElement).props.onMouseLeave, onMouseLeave])
      });
    });

    return (
      <span
        // Legacy class name preserved for Monolith overrides
        className={cx("monday-style-dialog-content-wrapper", styles.contentWrapper, wrapperClassName)}
        ref={mergedWrapperRef}
        data-testid={dataTestId}
        style={styleObject}
        onClickCapture={onClick}
        data-dialog-reference-hidden={isReferenceHidden}
      >
        <CSSTransition
          classNames={transitionOptions.classNames}
          nodeRef={contentRef}
          in={isOpen}
          appear={!!animationType}
          timeout={showDelay}
        >
          <div
            className={cx(styles.contentComponent, getStyle(styles, camelCase(position)), {
              [getStyle(styles, camelCase("edge-" + startingEdge))]: startingEdge,
              [styles.hasTooltip]: hasTooltip
            })}
            ref={contentRef}
          >
            {childrenWithHandlers}
          </div>
        </CSSTransition>
      </span>
    );

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 4, 2026

📦 Bundle Size Analysis

✅ No bundle size changes detected.

Unchanged Components
Component Base PR Diff
@vibe/button 17.74KB 17.79KB +57B 🔺
@vibe/clickable 6.07KB 6.05KB -21B 🟢
@vibe/dialog 53.85KB 53.8KB -48B 🟢
@vibe/icon-button 68.09KB 68.07KB -18B 🟢
@vibe/icon 13.01KB 13.01KB +3B 🔺
@vibe/layer 2.96KB 2.96KB 0B ➖
@vibe/layout 10.56KB 10.54KB -24B 🟢
@vibe/loader 5.8KB 5.82KB +16B 🔺
@vibe/tooltip 62.98KB 62.88KB -107B 🟢
@vibe/typography 65.4KB 65.41KB +2B 🔺
Accordion 6.35KB 6.34KB -16B 🟢
AccordionItem 68.13KB 68.16KB +30B 🔺
AlertBanner 72.93KB 72.9KB -38B 🟢
AlertBannerButton 19.23KB 19.25KB +26B 🔺
AlertBannerLink 15.56KB 15.56KB -1B 🟢
AlertBannerText 65.53KB 65.48KB -44B 🟢
AttentionBox 74.53KB 74.39KB -138B 🟢
AttentionBoxLink 15.41KB 15.37KB -44B 🟢
Avatar 68.36KB 68.3KB -61B 🟢
AvatarGroup 96.04KB 95.98KB -68B 🟢
Badge 43.53KB 43.56KB +26B 🔺
BreadcrumbItem 66.26KB 66.12KB -145B 🟢
BreadcrumbMenu 70.3KB 70.34KB +36B 🔺
BreadcrumbMenuItem 79.45KB 79.36KB -99B 🟢
BreadcrumbsBar 5.81KB 5.78KB -31B 🟢
ButtonGroup 70.25KB 70.28KB +28B 🔺
Checkbox 68.43KB 68.43KB -1B 🟢
Chips 77.18KB 77.11KB -72B 🟢
ColorPicker 76.35KB 76.42KB +74B 🔺
ColorPickerContent 75.57KB 75.52KB -43B 🟢
Combobox 86.37KB 86.28KB -87B 🟢
Counter 42.49KB 42.42KB -72B 🟢
DatePicker 134.59KB 134.56KB -32B 🟢
Divider 5.56KB 5.58KB +22B 🔺
Dropdown 125.94KB 125.85KB -88B 🟢
menu 59.95KB 59.94KB -13B 🟢
option 93.15KB 93.2KB +54B 🔺
singleValue 93.09KB 93.01KB -79B 🟢
EditableHeading 68.35KB 68.31KB -38B 🟢
EditableText 68.26KB 68.22KB -36B 🟢
EmptyState 72.75KB 72.64KB -108B 🟢
ExpandCollapse 68KB 68KB +3B 🔺
FormattedNumber 5.91KB 5.94KB +33B 🔺
GridKeyboardNavigationContext 4.66KB 4.65KB -5B 🟢
HiddenText 5.45KB 5.45KB -4B 🟢
Info 74.34KB 74.32KB -25B 🟢
Label 70.43KB 70.38KB -54B 🟢
LegacyModal 76.89KB 76.86KB -37B 🟢
LegacyModalContent 66.82KB 66.84KB +23B 🔺
LegacyModalFooter 3.45KB 3.45KB 0B ➖
LegacyModalFooterButtons 20.68KB 20.66KB -22B 🟢
LegacyModalHeader 72.97KB 72.96KB -8B 🟢
Link 15.23KB 15.18KB -50B 🟢
List 74.99KB 74.97KB -21B 🟢
ListItem 67.34KB 67.29KB -61B 🟢
ListItemAvatar 68.57KB 68.53KB -37B 🟢
ListItemIcon 14.21KB 14.23KB +19B 🔺
ListTitle 66.75KB 66.71KB -43B 🟢
Menu 8.71KB 8.72KB +10B 🔺
MenuDivider 5.65KB 5.65KB +5B 🔺
MenuGridItem 7.24KB 7.21KB -30B 🟢
MenuItem 79.22KB 79.27KB +53B 🔺
MenuItemButton 72.2KB 72.22KB +15B 🔺
MenuTitle 67.21KB 67.15KB -61B 🟢
MenuButton 67.8KB 67.82KB +24B 🔺
Modal 111.93KB 111.88KB -60B 🟢
ModalContent 4.77KB 4.77KB +2B 🔺
ModalHeader 67.64KB 67.51KB -125B 🟢
ModalMedia 7.77KB 7.76KB -5B 🟢
ModalFooter 69.48KB 69.58KB +109B 🔺
ModalFooterWizard 70.48KB 70.5KB +26B 🔺
ModalBasicLayout 9.25KB 9.25KB -3B 🟢
ModalMediaLayout 8.32KB 8.33KB +7B 🔺
ModalSideBySideLayout 6.36KB 6.38KB +26B 🔺
MultiStepIndicator 53.31KB 53.35KB +45B 🔺
NumberField 74.87KB 74.91KB +45B 🔺
LinearProgressBar 7.49KB 7.48KB -6B 🟢
RadioButton 67.59KB 67.59KB -5B 🟢
Search 72.45KB 72.41KB -37B 🟢
Skeleton 6.18KB 6.22KB +41B 🔺
Slider 75.97KB 75.93KB -40B 🟢
SplitButton 68.83KB 68.86KB +27B 🔺
SplitButtonMenu 8.89KB 8.88KB -7B 🟢
Steps 73.52KB 73.56KB +45B 🔺
Table 7.33KB 7.32KB -10B 🟢
TableBody 68.67KB 68.66KB -2B 🟢
TableCell 66.9KB 66.98KB +82B 🔺
TableContainer 5.36KB 5.37KB +14B 🔺
TableHeader 5.69KB 5.71KB +17B 🔺
TableHeaderCell 74.24KB 74.16KB -89B 🟢
TableRow 5.63KB 5.63KB +3B 🔺
TableRowMenu 70.62KB 70.61KB -18B 🟢
TableVirtualizedBody 73.42KB 73.29KB -128B 🟢
Tab 65.54KB 65.55KB +12B 🔺
TabList 8.97KB 8.94KB -28B 🟢
TabPanel 5.34KB 5.34KB -3B 🟢
TabPanels 5.97KB 5.96KB -12B 🟢
TabsContext 5.55KB 5.56KB +18B 🔺
TextArea 68.08KB 68.05KB -33B 🟢
TextField 71.36KB 71.38KB +21B 🔺
TextWithHighlight 65.86KB 65.93KB +69B 🔺
ThemeProvider 4.68KB 4.69KB +7B 🔺
Tipseen 73.16KB 73.23KB +67B 🔺
TipseenContent 73.65KB 73.66KB +11B 🔺
TipseenImage 73.49KB 73.5KB +2B 🔺
TipseenMedia 73.39KB 73.46KB +81B 🔺
TipseenWizard 76KB 75.99KB -14B 🟢
Toast 76.19KB 76.26KB +71B 🔺
ToastButton 19.07KB 19.04KB -32B 🟢
ToastLink 15.37KB 15.35KB -26B 🟢
Toggle 68.3KB 68.32KB +28B 🔺
TransitionView 37.69KB 37.73KB +41B 🔺
VirtualizedGrid 12.63KB 12.67KB +39B 🔺
VirtualizedList 12.42KB 12.4KB -14B 🟢
AttentionBox (Next) 76.41KB 76.45KB +38B 🔺
DatePicker (Next) 114.46KB 114.4KB -66B 🟢
Dialog (Next) 52.25KB 52.7KB +468B 🔺
Dropdown (Next) 97.57KB 97.45KB -122B 🟢
List (Next) 8.2KB 8.2KB +3B 🔺
ListItem (Next) 71.66KB 71.64KB -23B 🟢
ListTitle (Next) 67.08KB 67.05KB -30B 🟢

📊 Summary:

  • Total Base Size: 5.88MB
  • Total PR Size: 5.88MB
  • Total Difference: 944B

Copy link
Copy Markdown
Contributor

@rivka-ungar rivka-ungar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👑 👑 👑

Should it be in the separate dialog package?

const EMPTY_OBJECT: CSSProperties = {};
const ESCAPE_KEYS = [keyCodes.ESCAPE];

export interface DialogContentProps extends VibeComponentProps {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the types be in a separate types file?

@talkor
Copy link
Copy Markdown
Member Author

talkor commented Feb 16, 2026

👑 👑 👑

Should it be in the separate dialog package?

In v4 yes, I just didn't want to move it to @vibe/dialog as next, so it will replace the existing @vibe/dialog in the next major

@talkor talkor merged commit 6b1cc1e into master Feb 16, 2026
17 checks passed
@talkor talkor deleted the feat/dialog/next branch February 16, 2026 12:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants