Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bottom-sheet-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Attach files and media from a bottom sheet on the message input.
207 changes: 207 additions & 0 deletions src/app/components/attachment-sheet/AttachmentSheet.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';

export const Backdrop = style({
position: 'absolute',
inset: 0,
zIndex: 1000,
background: 'rgba(0, 0, 0, 0.42)',
touchAction: 'none',
});

export const Sheet = style({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
zIndex: 1001,
display: 'flex',
flexDirection: 'column',
width: '100%',
minWidth: 0,
boxSizing: 'border-box',
background: color.Surface.Container,
borderTopLeftRadius: toRem(20),
borderTopRightRadius: toRem(20),
paddingBottom: `calc(${toRem(12)} + env(safe-area-inset-bottom, 0px))`,
maxHeight: '100%',
overflowX: 'hidden',
overflowY: 'auto',
boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.15)',
});

export const SheetHeader = style({
position: 'relative',
flexShrink: 0,
padding: `${toRem(22)} ${toRem(16)} ${toRem(8)}`,
});

export const DragHandle = style({
position: 'absolute',
top: toRem(8),
left: '50%',
width: toRem(40),
height: toRem(4),
borderRadius: toRem(4),
background: color.Surface.OnContainer,
opacity: 0.3,
transform: 'translateX(-50%)',
});

export const Heading = style({
margin: 0,
color: color.Surface.OnContainer,
fontSize: toRem(16),
fontWeight: 700,
lineHeight: toRem(22),
});

export const ActionsRow = style({
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
padding: `${toRem(4)} ${toRem(12)} 0`,
gap: toRem(8),
flexShrink: 0,
minWidth: 0,
});

export const ActionButton = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: toRem(7),
minWidth: 0,
minHeight: toRem(76),
padding: `${toRem(8)} ${toRem(4)}`,
border: 'none',
background: 'transparent',
cursor: 'pointer',
borderRadius: toRem(12),
transition: 'background-color 0.15s ease',
color: color.Surface.OnContainer,

':hover': {
background: color.Surface.ContainerHover,
},

':focus-visible': {
outline: `${toRem(2)} solid ${color.Primary.Main}`,
outlineOffset: toRem(-2),
},

':active': {
background: color.Surface.ContainerActive,
},

'@media': {
'(prefers-reduced-motion: reduce)': {
transition: 'none',
},
},
});

export const ActionIcon = style({
display: 'grid',
placeItems: 'center',
width: toRem(44),
height: toRem(44),
flexShrink: 0,
borderRadius: '50%',
color: color.SurfaceVariant.OnContainer,
background: color.SurfaceVariant.Container,
});

export const ActionLabel = style({
fontSize: toRem(12),
lineHeight: toRem(16),
textAlign: 'center',
color: color.Surface.OnContainer,
overflowWrap: 'anywhere',
});

export const GallerySection = style({
padding: `${toRem(4)} ${toRem(16)} ${toRem(8)}`,
flexShrink: 0,
minWidth: 0,
});

export const GalleryButton = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: toRem(14),
width: '100%',
minWidth: 0,
minHeight: toRem(72),
boxSizing: 'border-box',
padding: toRem(10),
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(16),
background: color.Surface.ContainerHover,
color: color.Surface.OnContainer,
cursor: 'pointer',
textAlign: 'left',
transition: 'background-color 0.15s ease, border-color 0.15s ease',

':hover': {
background: color.Surface.ContainerActive,
},

':focus-visible': {
outline: `${toRem(2)} solid ${color.Primary.Main}`,
outlineOffset: toRem(2),
},

':active': {
background: color.Surface.ContainerActive,
},

'@media': {
'(prefers-reduced-motion: reduce)': {
transition: 'none',
},
},
});

export const GalleryIcon = style({
display: 'grid',
placeItems: 'center',
width: toRem(52),
height: toRem(52),
flexShrink: 0,
borderRadius: toRem(13),
color: color.Primary.OnContainer,
background: color.Primary.Container,
});

export const GalleryCopy = style({
display: 'flex',
flexDirection: 'column',
minWidth: 0,
flexGrow: 1,
});

export const GalleryTitle = style({
color: color.Surface.OnContainer,
fontSize: toRem(14),
fontWeight: 700,
lineHeight: toRem(20),
});

export const GalleryGrid = style({
color: color.Surface.OnContainer,
opacity: 0.55,
flexShrink: 0,
marginRight: toRem(4),
});

export const GalleryLabel = style({
fontSize: toRem(12),
lineHeight: toRem(17),
color: color.Surface.OnContainer,
opacity: 0.68,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
127 changes: 127 additions & 0 deletions src/app/components/attachment-sheet/AttachmentSheet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { type PointerEventHandler, useRef, useState } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AttachmentSheet } from './AttachmentSheet';

vi.mock('$state/hooks/settings', () => ({
useSetting: () => [false, vi.fn<() => void>()],
}));

vi.mock('$utils/user-agent', () => ({
mobileOrTablet: () => false,
}));

const callbacks = () => ({
onClose: vi.fn<() => void>(),
onPickPhotos: vi.fn<() => void>(),
onPickFile: vi.fn<() => void>(),
onPickPoll: vi.fn<() => void>(),
onPickLocation: vi.fn<() => void>(),
});

type Handlers = ReturnType<typeof callbacks>;

function AttachmentSheetHarness({
handlers,
onPointerDown,
}: {
handlers: Handlers;
onPointerDown?: PointerEventHandler<HTMLDivElement>;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLElement>(null);

return (
<div onPointerDown={onPointerDown}>
<button type="button" onClick={() => setOpen(true)}>
Open attachments
</button>
<section ref={containerRef} data-testid="chat-pane" />
<AttachmentSheet
open={open}
containerRef={containerRef}
{...handlers}
onClose={() => {
handlers.onClose();
setOpen(false);
}}
/>
</div>
);
}

afterEach(() => {
vi.clearAllMocks();
});

describe('AttachmentSheet', () => {
it('portals into a host whose ref was attached while the sheet was closed', () => {
const handlers = callbacks();
render(<AttachmentSheetHarness handlers={handlers} />);

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open attachments' }));

const chatPane = screen.getByTestId('chat-pane');
const dialog = screen.getByRole('dialog', { name: 'Share' });
const backdrop = dialog.previousElementSibling;

expect(chatPane).toContainElement(dialog);
expect(dialog.parentElement).toBe(chatPane);
expect(backdrop).not.toBeNull();
expect(backdrop?.parentElement).toBe(chatPane);
expect(dialog.parentElement).not.toBe(document.body);
});

it('does not render the dialog without a portal target', () => {
const { container } = render(
<AttachmentSheet open containerRef={{ current: null }} {...callbacks()} />
);

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});

it('delegates Photos selection without directly closing', () => {
const handlers = callbacks();
render(<AttachmentSheetHarness handlers={handlers} />);

fireEvent.click(screen.getByRole('button', { name: 'Open attachments' }));
fireEvent.click(screen.getByRole('button', { name: 'Open photo gallery' }));

expect(handlers.onPickPhotos).toHaveBeenCalledOnce();
expect(handlers.onClose).not.toHaveBeenCalled();
});

it('shields room pointer gestures from icon descendants', () => {
const handlers = callbacks();
const onPointerDown = vi.fn<PointerEventHandler<HTMLDivElement>>();
render(<AttachmentSheetHarness handlers={handlers} onPointerDown={onPointerDown} />);

fireEvent.click(screen.getByRole('button', { name: 'Open attachments' }));
const iconPath = screen
.getByRole('button', { name: 'Open photo gallery' })
.querySelector('path');

expect(iconPath).not.toBeNull();
fireEvent.pointerDown(iconPath!);
expect(onPointerDown).not.toHaveBeenCalled();
});

it('closes once on Escape and returns focus to the opener', async () => {
const handlers = callbacks();
render(<AttachmentSheetHarness handlers={handlers} />);
const opener = screen.getByRole('button', { name: 'Open attachments' });

opener.focus();
fireEvent.click(opener);
await waitFor(() => expect(screen.getByRole('dialog', { name: 'Share' })).toHaveFocus());
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });

expect(handlers.onClose).toHaveBeenCalledOnce();
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(opener).toHaveFocus();
});
});
});
Loading
Loading