Modal refactor attempts.

This commit is contained in:
Half-Shot
2024-11-07 12:08:13 +00:00
parent 60300c2f84
commit 54b495c79d
4 changed files with 209 additions and 1128 deletions

View File

@@ -27,6 +27,11 @@ import { useMediaQuery } from "./useMediaQuery";
export interface Props {
title: string;
/**
* Hide the modal header. Used for smaller popups where the context is readily apparent.
* A title should still be specified for users using assistive technology.
*/
hideHeader?: boolean;
children: ReactNode;
className?: string;
/**
@@ -54,6 +59,7 @@ export interface Props {
*/
export const Modal: FC<Props> = ({
title,
hideHeader,
children,
className,
open,
@@ -108,6 +114,28 @@ export const Modal: FC<Props> = ({
</Drawer.Root>
);
} else {
const titleNode = (
<DialogTitle asChild>
<Heading as="h2" weight="semibold" size="md">
{title}
</Heading>
</DialogTitle>
);
const header = (
<div className={styles.header}>
{titleNode}
{onDismiss !== undefined && (
<DialogClose
className={styles.close}
data-testid="modal_close"
aria-label={t("action.close")}
>
<CloseIcon width={20} height={20} />
</DialogClose>
)}
</div>
);
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal>
@@ -119,31 +147,19 @@ export const Modal: FC<Props> = ({
<DialogContent asChild aria-describedby={undefined} {...rest}>
<Glass
className={classNames(
className,
overlayStyles.overlay,
overlayStyles.animate,
styles.modal,
styles.dialog,
{ [styles.tabbed]: tabbed },
className,
)}
>
<div className={styles.content}>
<div className={styles.header}>
<DialogTitle asChild>
<Heading as="h2" weight="semibold" size="md">
{title}
</Heading>
</DialogTitle>
{onDismiss !== undefined && (
<DialogClose
className={styles.close}
data-testid="modal_close"
aria-label={t("action.close")}
>
<CloseIcon width={20} height={20} />
</DialogClose>
)}
</div>
{!hideHeader ? header : null}
{hideHeader ? (
<VisuallyHidden asChild>{titleNode}</VisuallyHidden>
) : null}
<div className={styles.body}>{children}</div>
</div>
</Glass>

View File

@@ -3,13 +3,20 @@
}
.reactionPopupMenu {
background: var(--cpd-color-bg-canvas-default);
border-radius: var(--cpd-space-4x);
width: fit-content;
top: 70vh;
padding: 1em;
position: absolute;
border: none;
display: flex;
}
/* These styles override the Modal styles to ensure our emoji
picker appears near the toolbar */
.reactionPopupMenuModal {
width: fit-content !important;
top: 82vh !important;
}
.reactionPopupMenuModal > div > div {
padding-inline: var(--cpd-space-6x) !important;
padding-block: var(--cpd-space-6x) var(--cpd-space-8x) !important;
}
.reactionPopupMenu menu {
@@ -35,6 +42,8 @@
}
.reactionButton {
padding: 1em;
font-size: 2em;
width: 2em;
height: 2em;
border-radius: 2em;

View File

@@ -22,14 +22,10 @@ import {
ChangeEventHandler,
ComponentPropsWithoutRef,
FC,
forwardRef,
KeyboardEventHandler,
PropsWithRef,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
@@ -47,6 +43,7 @@ import {
ReactionSet,
ElementCallReactionEventType,
} from "../reactions";
import { Modal } from "../Modal";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
@@ -70,17 +67,17 @@ const InnerButton: FC<InnerButtonProps> = ({ raised, open, ...props }) => {
);
};
interface ReactionsPopupMenuProps {
export function ReactionPopupMenu({
sendReaction,
toggleRaisedHand,
isHandRaised,
canReact,
}: {
sendReaction: (reaction: ReactionOption) => void;
toggleRaisedHand: () => void;
isHandRaised: boolean;
canReact: boolean;
}
export const ReactionPopupMenu = forwardRef<
HTMLDialogElement,
ReactionsPopupMenuProps
>(({ sendReaction, toggleRaisedHand, isHandRaised, canReact }, ref) => {
}): ReactNode {
const { t } = useTranslation();
const [searchText, setSearchText] = useState("");
const [isSearching, setIsSearching] = useState(false);
@@ -122,84 +119,82 @@ export const ReactionPopupMenu = forwardRef<
);
return (
<dialog ref={ref} className={styles.reactionPopupMenu} open={false}>
<div style={{ display: "flex" }}>
<section className={styles.handRaiseSection}>
<Tooltip label={t("common.raise_hand")}>
<CpdButton
kind={isHandRaised ? "primary" : "secondary"}
aria-pressed={isHandRaised}
aria-label="Toggle hand raised"
className={styles.reactionButton}
key="raise-hand"
onClick={() => toggleRaisedHand()}
>
🖐
</CpdButton>
</Tooltip>
</section>
<div className={styles.verticalSeperator} />
<section>
{isSearching ? (
<>
<Form.Root className={styles.searchForm}>
<Search
required
value={searchText}
name="reactionSearch"
placeholder="Search reactions…"
onChange={onSearch}
onKeyDown={onSearchKeyDown}
// This is a reasonable use of autofocus, we are focusing when
// the search button is clicked (which matches the Element Web reaction picker)
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<div className={styles.reactionPopupMenu}>
<section className={styles.handRaiseSection}>
<Tooltip label={t("common.raise_hand")}>
<CpdButton
kind={isHandRaised ? "primary" : "secondary"}
aria-pressed={isHandRaised}
aria-label="Toggle hand raised"
className={styles.reactionButton}
key="raise-hand"
onClick={() => toggleRaisedHand()}
>
🖐
</CpdButton>
</Tooltip>
</section>
<div className={styles.verticalSeperator} />
<section>
{isSearching ? (
<>
<Form.Root className={styles.searchForm}>
<Search
required
value={searchText}
name="reactionSearch"
placeholder="Search reactions…"
onChange={onSearch}
onKeyDown={onSearchKeyDown}
// This is a reasonable use of autofocus, we are focusing when
// the search button is clicked (which matches the Element Web reaction picker)
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<CpdButton
Icon={CloseIcon}
aria-label="close search"
size="sm"
kind="destructive"
onClick={() => setIsSearching(false)}
/>
</Form.Root>
<Separator />
</>
) : null}
<menu>
{filteredReactionSet.map((reaction) => (
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
<Tooltip label={reaction.name}>
<CpdButton
Icon={CloseIcon}
aria-label="close search"
size="sm"
kind="destructive"
onClick={() => setIsSearching(false)}
kind="secondary"
className={styles.reactionButton}
disabled={!canReact}
onClick={() => sendReaction(reaction)}
>
{reaction.emoji}
</CpdButton>
</Tooltip>
</li>
))}
{!isSearching ? (
<li key="search" className={styles.reactionPopupMenuItem}>
<Tooltip label="Search">
<CpdButton
iconOnly
aria-label="Open reactions search"
Icon={SearchIcon}
kind="tertiary"
onClick={() => setIsSearching(true)}
/>
</Form.Root>
<Separator />
</>
</Tooltip>
</li>
) : null}
<menu>
{filteredReactionSet.map((reaction) => (
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
<Tooltip label={reaction.name}>
<CpdButton
kind="secondary"
className={styles.reactionButton}
disabled={!canReact}
onClick={() => sendReaction(reaction)}
>
{reaction.emoji}
</CpdButton>
</Tooltip>
</li>
))}
{!isSearching ? (
<li key="search" className={styles.reactionPopupMenuItem}>
<Tooltip label="Search">
<CpdButton
iconOnly
aria-label="Open reactions search"
Icon={SearchIcon}
kind="tertiary"
onClick={() => setIsSearching(true)}
/>
</Tooltip>
</li>
) : null}
</menu>
</section>
</div>
</dialog>
</menu>
</section>
</div>
);
});
}
interface ReactionToggleButtonProps {
rtcSession: MatrixRTCSession;
@@ -215,37 +210,10 @@ export function ReactionToggleButton({
const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const ref = useRef<HTMLDialogElement>(null);
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const canReact = !reactions[userId];
const showReactionsMenu = useCallback(() => {
if (ref.current) {
ref.current.showModal();
}
}, [ref]);
const hideReactionsMenu = useCallback(() => {
if (ref.current) {
ref.current.close();
}
}, [ref]);
useEffect(() => {
if (!ref.current) {
return;
}
function onClick(evt: MouseEvent) {
if (evt.target === ref.current) {
hideReactionsMenu();
}
}
ref.current.addEventListener("click", onClick);
return () => {
ref.current?.removeEventListener("click", onClick);
};
}, [ref]);
const sendRelation = useCallback(
async (reaction: ReactionOption) => {
const myMembership = memberships.find((m) => m.sender === userId);
@@ -273,7 +241,7 @@ export function ReactionToggleButton({
logger.error("Failed to send reaction", ex);
} finally {
setBusy(false);
hideReactionsMenu();
setShowReactionsMenu(false);
}
},
[memberships, client, userId, rtcSession],
@@ -285,6 +253,7 @@ export function ReactionToggleButton({
try {
setBusy(true);
await lowerHand();
setShowReactionsMenu(false);
} finally {
setBusy(false);
}
@@ -313,7 +282,7 @@ export function ReactionToggleButton({
logger.error("Failed to send reaction event", ex);
} finally {
setBusy(false);
hideReactionsMenu();
setShowReactionsMenu(false);
}
}
};
@@ -332,17 +301,24 @@ export function ReactionToggleButton({
<>
<InnerButton
disabled={busy}
onClick={showReactionsMenu}
raised={isHandRaised}
open={!!ref.current?.open}
/>
<ReactionPopupMenu
ref={ref}
isHandRaised={isHandRaised}
canReact={canReact}
sendReaction={(reaction) => void sendRelation(reaction)}
toggleRaisedHand={toggleRaisedHand}
onClick={() => setShowReactionsMenu((show) => !show)}
raised={isHandRaised || showReactionsMenu}
open={showReactionsMenu}
/>
<Modal
open={showReactionsMenu}
title="Pick reaction"
hideHeader
className={styles.reactionPopupMenuModal}
onDismiss={() => setShowReactionsMenu(false)}
>
<ReactionPopupMenu
isHandRaised={isHandRaised}
canReact={canReact}
sendReaction={(reaction) => void sendRelation(reaction)}
toggleRaisedHand={toggleRaisedHand}
/>
</Modal>
</>
);
}

File diff suppressed because it is too large Load Diff