mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-08 20:09:19 +00:00
Modal refactor attempts.
This commit is contained in:
@@ -27,6 +27,11 @@ import { useMediaQuery } from "./useMediaQuery";
|
|||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title: string;
|
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;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +59,7 @@ export interface Props {
|
|||||||
*/
|
*/
|
||||||
export const Modal: FC<Props> = ({
|
export const Modal: FC<Props> = ({
|
||||||
title,
|
title,
|
||||||
|
hideHeader,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
open,
|
open,
|
||||||
@@ -108,6 +114,28 @@ export const Modal: FC<Props> = ({
|
|||||||
</Drawer.Root>
|
</Drawer.Root>
|
||||||
);
|
);
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
@@ -119,31 +147,19 @@ export const Modal: FC<Props> = ({
|
|||||||
<DialogContent asChild aria-describedby={undefined} {...rest}>
|
<DialogContent asChild aria-describedby={undefined} {...rest}>
|
||||||
<Glass
|
<Glass
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
|
||||||
overlayStyles.overlay,
|
overlayStyles.overlay,
|
||||||
overlayStyles.animate,
|
overlayStyles.animate,
|
||||||
styles.modal,
|
styles.modal,
|
||||||
styles.dialog,
|
styles.dialog,
|
||||||
{ [styles.tabbed]: tabbed },
|
{ [styles.tabbed]: tabbed },
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.header}>
|
{!hideHeader ? header : null}
|
||||||
<DialogTitle asChild>
|
{hideHeader ? (
|
||||||
<Heading as="h2" weight="semibold" size="md">
|
<VisuallyHidden asChild>{titleNode}</VisuallyHidden>
|
||||||
{title}
|
) : null}
|
||||||
</Heading>
|
|
||||||
</DialogTitle>
|
|
||||||
{onDismiss !== undefined && (
|
|
||||||
<DialogClose
|
|
||||||
className={styles.close}
|
|
||||||
data-testid="modal_close"
|
|
||||||
aria-label={t("action.close")}
|
|
||||||
>
|
|
||||||
<CloseIcon width={20} height={20} />
|
|
||||||
</DialogClose>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.body}>{children}</div>
|
<div className={styles.body}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</Glass>
|
</Glass>
|
||||||
|
|||||||
@@ -3,13 +3,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reactionPopupMenu {
|
.reactionPopupMenu {
|
||||||
background: var(--cpd-color-bg-canvas-default);
|
display: flex;
|
||||||
border-radius: var(--cpd-space-4x);
|
}
|
||||||
width: fit-content;
|
|
||||||
top: 70vh;
|
/* These styles override the Modal styles to ensure our emoji
|
||||||
padding: 1em;
|
picker appears near the toolbar */
|
||||||
position: absolute;
|
|
||||||
border: none;
|
.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 {
|
.reactionPopupMenu menu {
|
||||||
@@ -35,6 +42,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reactionButton {
|
.reactionButton {
|
||||||
|
padding: 1em;
|
||||||
|
font-size: 2em;
|
||||||
width: 2em;
|
width: 2em;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
border-radius: 2em;
|
border-radius: 2em;
|
||||||
|
|||||||
@@ -22,14 +22,10 @@ import {
|
|||||||
ChangeEventHandler,
|
ChangeEventHandler,
|
||||||
ComponentPropsWithoutRef,
|
ComponentPropsWithoutRef,
|
||||||
FC,
|
FC,
|
||||||
forwardRef,
|
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
PropsWithRef,
|
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -47,6 +43,7 @@ import {
|
|||||||
ReactionSet,
|
ReactionSet,
|
||||||
ElementCallReactionEventType,
|
ElementCallReactionEventType,
|
||||||
} from "../reactions";
|
} from "../reactions";
|
||||||
|
import { Modal } from "../Modal";
|
||||||
|
|
||||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
raised: boolean;
|
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;
|
sendReaction: (reaction: ReactionOption) => void;
|
||||||
toggleRaisedHand: () => void;
|
toggleRaisedHand: () => void;
|
||||||
isHandRaised: boolean;
|
isHandRaised: boolean;
|
||||||
canReact: boolean;
|
canReact: boolean;
|
||||||
}
|
}): ReactNode {
|
||||||
|
|
||||||
export const ReactionPopupMenu = forwardRef<
|
|
||||||
HTMLDialogElement,
|
|
||||||
ReactionsPopupMenuProps
|
|
||||||
>(({ sendReaction, toggleRaisedHand, isHandRaised, canReact }, ref) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -122,84 +119,82 @@ export const ReactionPopupMenu = forwardRef<
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog ref={ref} className={styles.reactionPopupMenu} open={false}>
|
<div className={styles.reactionPopupMenu}>
|
||||||
<div style={{ display: "flex" }}>
|
<section className={styles.handRaiseSection}>
|
||||||
<section className={styles.handRaiseSection}>
|
<Tooltip label={t("common.raise_hand")}>
|
||||||
<Tooltip label={t("common.raise_hand")}>
|
<CpdButton
|
||||||
<CpdButton
|
kind={isHandRaised ? "primary" : "secondary"}
|
||||||
kind={isHandRaised ? "primary" : "secondary"}
|
aria-pressed={isHandRaised}
|
||||||
aria-pressed={isHandRaised}
|
aria-label="Toggle hand raised"
|
||||||
aria-label="Toggle hand raised"
|
className={styles.reactionButton}
|
||||||
className={styles.reactionButton}
|
key="raise-hand"
|
||||||
key="raise-hand"
|
onClick={() => toggleRaisedHand()}
|
||||||
onClick={() => toggleRaisedHand()}
|
>
|
||||||
>
|
🖐️
|
||||||
🖐️
|
</CpdButton>
|
||||||
</CpdButton>
|
</Tooltip>
|
||||||
</Tooltip>
|
</section>
|
||||||
</section>
|
<div className={styles.verticalSeperator} />
|
||||||
<div className={styles.verticalSeperator} />
|
<section>
|
||||||
<section>
|
{isSearching ? (
|
||||||
{isSearching ? (
|
<>
|
||||||
<>
|
<Form.Root className={styles.searchForm}>
|
||||||
<Form.Root className={styles.searchForm}>
|
<Search
|
||||||
<Search
|
required
|
||||||
required
|
value={searchText}
|
||||||
value={searchText}
|
name="reactionSearch"
|
||||||
name="reactionSearch"
|
placeholder="Search reactions…"
|
||||||
placeholder="Search reactions…"
|
onChange={onSearch}
|
||||||
onChange={onSearch}
|
onKeyDown={onSearchKeyDown}
|
||||||
onKeyDown={onSearchKeyDown}
|
// This is a reasonable use of autofocus, we are focusing when
|
||||||
// This is a reasonable use of autofocus, we are focusing when
|
// the search button is clicked (which matches the Element Web reaction picker)
|
||||||
// the search button is clicked (which matches the Element Web reaction picker)
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
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
|
<CpdButton
|
||||||
Icon={CloseIcon}
|
kind="secondary"
|
||||||
aria-label="close search"
|
className={styles.reactionButton}
|
||||||
size="sm"
|
disabled={!canReact}
|
||||||
kind="destructive"
|
onClick={() => sendReaction(reaction)}
|
||||||
onClick={() => setIsSearching(false)}
|
>
|
||||||
|
{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>
|
</Tooltip>
|
||||||
<Separator />
|
</li>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<menu>
|
</menu>
|
||||||
{filteredReactionSet.map((reaction) => (
|
</section>
|
||||||
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
interface ReactionToggleButtonProps {
|
interface ReactionToggleButtonProps {
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
@@ -215,37 +210,10 @@ export function ReactionToggleButton({
|
|||||||
const userId = client.getUserId()!;
|
const userId = client.getUserId()!;
|
||||||
const isHandRaised = !!raisedHands[userId];
|
const isHandRaised = !!raisedHands[userId];
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||||
|
|
||||||
const canReact = !reactions[userId];
|
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(
|
const sendRelation = useCallback(
|
||||||
async (reaction: ReactionOption) => {
|
async (reaction: ReactionOption) => {
|
||||||
const myMembership = memberships.find((m) => m.sender === userId);
|
const myMembership = memberships.find((m) => m.sender === userId);
|
||||||
@@ -273,7 +241,7 @@ export function ReactionToggleButton({
|
|||||||
logger.error("Failed to send reaction", ex);
|
logger.error("Failed to send reaction", ex);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
hideReactionsMenu();
|
setShowReactionsMenu(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[memberships, client, userId, rtcSession],
|
[memberships, client, userId, rtcSession],
|
||||||
@@ -285,6 +253,7 @@ export function ReactionToggleButton({
|
|||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
await lowerHand();
|
await lowerHand();
|
||||||
|
setShowReactionsMenu(false);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -313,7 +282,7 @@ export function ReactionToggleButton({
|
|||||||
logger.error("Failed to send reaction event", ex);
|
logger.error("Failed to send reaction event", ex);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
hideReactionsMenu();
|
setShowReactionsMenu(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -332,17 +301,24 @@ export function ReactionToggleButton({
|
|||||||
<>
|
<>
|
||||||
<InnerButton
|
<InnerButton
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={showReactionsMenu}
|
onClick={() => setShowReactionsMenu((show) => !show)}
|
||||||
raised={isHandRaised}
|
raised={isHandRaised || showReactionsMenu}
|
||||||
open={!!ref.current?.open}
|
open={showReactionsMenu}
|
||||||
/>
|
|
||||||
<ReactionPopupMenu
|
|
||||||
ref={ref}
|
|
||||||
isHandRaised={isHandRaised}
|
|
||||||
canReact={canReact}
|
|
||||||
sendReaction={(reaction) => void sendRelation(reaction)}
|
|
||||||
toggleRaisedHand={toggleRaisedHand}
|
|
||||||
/>
|
/>
|
||||||
|
<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
Reference in New Issue
Block a user