diff --git a/app/javascript/mastodon/components/bottom_sheet/index.tsx b/app/javascript/mastodon/components/bottom_sheet/index.tsx new file mode 100644 index 00000000000..1ca0e1a517e --- /dev/null +++ b/app/javascript/mastodon/components/bottom_sheet/index.tsx @@ -0,0 +1,63 @@ +import type { ComponentPropsWithRef } from 'react'; +import { useLayoutEffect, useRef } from 'react'; + +import classNames from 'classnames'; + +import { useMergedRefs } from '@/mastodon/hooks/useMergedRefs'; +import { useOnClickOutside } from '@/mastodon/hooks/useOnClickOutside'; +import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor'; + +import classes from './styles.module.scss'; + +interface BottomSheetProps extends Omit< + ComponentPropsWithRef<'dialog'>, + 'onClose' +> { + onClose: (e: Event) => void; +} + +export const BottomSheet: React.FC = ({ + children, + className, + onClose, + ...props +}) => { + const dialogRef = useRef(null); + const contentRef = useRef(null); + + useLayoutEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + dialog.showModal(); + + return () => { + dialog.requestClose(); + }; + }, []); + + useOnClickOutside(contentRef, (e) => { + onClose(e); + }); + + const { sensor, isInViewport } = useScrollSensor({ + placement: 'bottom', + tolerance: 10, + }); + + return ( + +
+ {children} +
+ {sensor} +
+ ); +}; diff --git a/app/javascript/mastodon/components/bottom_sheet/styles.module.scss b/app/javascript/mastodon/components/bottom_sheet/styles.module.scss new file mode 100644 index 00000000000..288f8bf5fff --- /dev/null +++ b/app/javascript/mastodon/components/bottom_sheet/styles.module.scss @@ -0,0 +1,44 @@ +@use '@/styles/mastodon/mixins'; + +.dialog { + width: 100%; + max-width: unset; + max-height: 100dvh; + margin: initial; + margin-top: auto; + padding: 0; + border: none; + background: none; + overscroll-behavior: contain; + + &::backdrop { + opacity: 0.9; + background-color: var(--color-bg-overlay); + } + + &::after { + @include mixins.scroll-gradient(bottom); + + position: fixed; + bottom: 0; + opacity: 0; + transition: opacity ease 250ms; + } +} + +.withGradient::after { + opacity: 1; +} + +.sheet { + padding: var(--space-md); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-bg-primary); + overflow: hidden; + text-align: start; + + @include mixins.elevation-1; + + border-inline: none; + border-bottom: none; +} diff --git a/app/javascript/mastodon/components/menu/card.tsx b/app/javascript/mastodon/components/menu/card.tsx index 90e90beb3f3..d0a11f22a37 100644 --- a/app/javascript/mastodon/components/menu/card.tsx +++ b/app/javascript/mastodon/components/menu/card.tsx @@ -1,7 +1,9 @@ import classNames from 'classnames'; +import { useBreakpoint } from '@/mastodon/features/ui/hooks/useBreakpoint'; import type { PolymorphicProps } from '@/types/polymorphic'; +import { BottomSheet } from '../bottom_sheet'; import { Popover } from '../popover'; import type { PopoverProps } from '../popover'; @@ -65,6 +67,16 @@ export const PopoverMenuCard = ({ className, ...props }: PopoverMenuCardProps) => { + const isMobile = useBreakpoint('openable'); + + if (isMobile && isOpen) { + return ( + + {children} + + ); + } + return (