feat(ui): add situational tips system (#6648)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-21 18:58:52 +02:00
1 parent 3c2f95d529
commit f0f9539466
20 files changed
+694 -493

No files matched your search

@@ -0,0 +1,17 @@
.root {
position: fixed;
inset-inline: var(--mantine-spacing-md);
bottom: var(--floating-tip-bottom);
z-index: var(--floating-tip-z-index);
display: flex;
justify-content: center;
pointer-events: none;
}
.alert {
width: fit-content;
max-width: min(var(--floating-tip-max-width), calc(100vw - 2 * var(--mantine-spacing-md)));
pointer-events: auto;
box-shadow: var(--mantine-shadow-xl);
backdrop-filter: blur(12px);
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly root: string;
readonly alert: string;
};
export default classes;
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FloatingTip } from "./floating-tip";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.useFakeTimers();
window.localStorage.clear();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
document.body.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
vi.useRealTimers();
});
describe("FloatingTip", () => {
it("shows after the configured delay and auto-dismisses", async () => {
await act(async () =>
root.render(
<MantineProvider>
<FloatingTip opened showDelay={2_000} dismissAfter={3_000} transitionDuration={0}>
Select several apps
</FloatingTip>
</MantineProvider>,
),
);
expect(document.body.textContent).not.toContain("Select several apps");
await act(async () => vi.advanceTimersByTime(2_000));
expect(document.body.textContent).toContain("Select several apps");
await act(async () => vi.advanceTimersByTime(3_000));
expect(document.body.textContent).not.toContain("Select several apps");
});
it("provides an accessible close button and remembers dismissal", async () => {
const onDismiss = vi.fn();
const renderTip = (opened: boolean) =>
root.render(
<MantineProvider>
<FloatingTip
opened={opened}
showDelay={0}
transitionDuration={0}
closable
closeButtonLabel="Dismiss tip"
rememberDismissal
storageKey="selection"
onDismiss={onDismiss}
>
Select several apps
</FloatingTip>
</MantineProvider>,
);
await act(async () => renderTip(true));
await act(async () => vi.advanceTimersByTime(0));
const closeButton = document.body.querySelector<HTMLButtonElement>('button[aria-label="Dismiss tip"]');
expect(closeButton).not.toBeNull();
await act(async () => closeButton?.click());
expect(onDismiss).toHaveBeenCalledOnce();
expect(window.localStorage.getItem("homarr:floating-tip:selection")).toBe("dismissed");
await act(async () => renderTip(false));
await act(async () => renderTip(true));
await act(async () => vi.advanceTimersByTime(0));
expect(document.body.textContent).not.toContain("Select several apps");
});
});
@@ -0,0 +1,205 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Box, createVarsResolver, factory, Portal, rem, Transition, useProps, useStyles } from "@mantine/core";
import type { AlertProps, BoxProps, ElementProps, Factory, MantineTransition, StylesApiProps } from "@mantine/core";
import classes from "./floating-tip.module.css";
export type FloatingTipStylesNames = "root" | "alert";
export type FloatingTipCssVariables = {
root: "--floating-tip-bottom" | "--floating-tip-max-width" | "--floating-tip-z-index";
};
interface FloatingTipBaseProps
extends BoxProps, StylesApiProps<FloatingTipFactory>, Omit<ElementProps<"div">, "title"> {
/** Controls whether this tip cycle should run. */
opened: boolean;
/** Delay before the tip appears, in milliseconds. */
showDelay?: number;
/** Time the tip remains visible, in milliseconds. Set to `null` to keep it open. */
dismissAfter?: number | null;
/** Enter and exit animation duration, in milliseconds. */
transitionDuration?: number;
/** Mantine transition used to show and hide the tip. */
transition?: MantineTransition;
/** Distance from the bottom edge of the viewport. */
bottomOffset?: number | string;
/** Maximum width of the alert. */
maxWidth?: number | string;
/** Stack order of the floating tip. */
zIndex?: number;
/** Props forwarded to the underlying Mantine Alert. */
alertProps?: Omit<
AlertProps,
"children" | "classNames" | "closeButtonLabel" | "onClose" | "styles" | "withCloseButton"
>;
/** Called when the user closes the tip. */
onDismiss?: () => void;
}
type FloatingTipCloseProps =
| { closable: true; closeButtonLabel: string }
| { closable?: false; closeButtonLabel?: never };
type FloatingTipPersistenceProps =
| { rememberDismissal: true; storageKey: string }
| { rememberDismissal?: false; storageKey?: never };
export type FloatingTipProps = FloatingTipBaseProps & FloatingTipCloseProps & FloatingTipPersistenceProps;
export type FloatingTipFactory = Factory<{
props: FloatingTipProps;
ref: HTMLDivElement;
stylesNames: FloatingTipStylesNames;
vars: FloatingTipCssVariables;
}>;
const defaultProps = {
showDelay: 0,
dismissAfter: null,
transitionDuration: 200,
transition: "slide-up",
bottomOffset: 24,
maxWidth: 520,
zIndex: 1000,
closable: false,
rememberDismissal: false,
} satisfies Partial<FloatingTipProps>;
const toCssSize = (value: number | string | undefined) => (typeof value === "number" ? rem(value) : value);
const varsResolver = createVarsResolver<FloatingTipFactory>((_theme, { bottomOffset, maxWidth, zIndex }) => ({
root: {
"--floating-tip-bottom": toCssSize(bottomOffset),
"--floating-tip-max-width": toCssSize(maxWidth),
"--floating-tip-z-index": zIndex?.toString(),
},
}));
const getDismissalStorageKey = (storageKey: string) => `homarr:floating-tip:${storageKey}`;
export const FloatingTip = factory<FloatingTipFactory>((_props) => {
const props = useProps("FloatingTip", defaultProps, _props);
const {
ref,
classNames,
className,
style,
styles,
unstyled,
vars,
attributes,
opened,
showDelay,
dismissAfter,
transitionDuration,
transition,
bottomOffset: _bottomOffset,
maxWidth: _maxWidth,
zIndex: _zIndex,
closable,
closeButtonLabel,
rememberDismissal,
storageKey,
alertProps,
onDismiss,
children,
...others
} = props;
const [visible, setVisible] = useState(false);
const showTimerRef = useRef<number | null>(null);
const dismissTimerRef = useRef<number | null>(null);
const clearTimers = useCallback(() => {
if (showTimerRef.current !== null) window.clearTimeout(showTimerRef.current);
if (dismissTimerRef.current !== null) window.clearTimeout(dismissTimerRef.current);
showTimerRef.current = null;
dismissTimerRef.current = null;
}, []);
useEffect(() => {
clearTimers();
setVisible(false);
if (!opened) return;
if (rememberDismissal) {
try {
if (window.localStorage.getItem(getDismissalStorageKey(storageKey)) === "dismissed") return;
} catch {
// Storage can be unavailable in private browsing or hardened environments.
}
}
showTimerRef.current = window.setTimeout(() => {
setVisible(true);
showTimerRef.current = null;
if (dismissAfter !== null) {
dismissTimerRef.current = window.setTimeout(() => {
setVisible(false);
dismissTimerRef.current = null;
}, dismissAfter);
}
}, showDelay);
return clearTimers;
}, [clearTimers, dismissAfter, opened, rememberDismissal, showDelay, storageKey]);
const handleDismiss = () => {
clearTimers();
setVisible(false);
if (rememberDismissal) {
try {
window.localStorage.setItem(getDismissalStorageKey(storageKey), "dismissed");
} catch {
// The tip still closes for this render even when persistence is unavailable.
}
}
onDismiss?.();
};
const getStyles = useStyles<FloatingTipFactory>({
name: "FloatingTip",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Portal>
<Transition mounted={visible} transition={transition} duration={transitionDuration} timingFunction="ease">
{(transitionStyle) => {
const rootStyles = getStyles("root");
return (
<Box ref={ref} {...others} {...rootStyles} style={[rootStyles.style, transitionStyle]}>
<Alert
{...alertProps}
{...getStyles("alert")}
withCloseButton={closable}
closeButtonLabel={closeButtonLabel}
onClose={handleDismiss}
>
{children}
</Alert>
</Box>
);
}}
</Transition>
</Portal>
);
});
FloatingTip.displayName = "@homarr/ui/FloatingTip";
FloatingTip.classes = classes;
@@ -0,0 +1,7 @@
export { FloatingTip } from "./floating-tip";
export type {
FloatingTipCssVariables,
FloatingTipFactory,
FloatingTipProps,
FloatingTipStylesNames,
} from "./floating-tip";
+1
View File
@@ -20,6 +20,7 @@ export type {
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
export * from "./floating-tip";
export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon";