feat(ui): add situational tips system (#6648)
This commit is contained in:
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";
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user