signal-desktop/ts/components/NavSidebar.tsx

229 lines
6.6 KiB
TypeScript

// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { useMove } from 'react-aria';
import { NavTabsToggle } from './NavTabs';
import type { LocalizerType } from '../types/I18N';
import {
MAX_WIDTH,
MIN_FULL_WIDTH,
MIN_WIDTH,
getWidthFromPreferredWidth,
} from '../util/leftPaneWidth';
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util';
import type { UnreadStats } from '../util/countUnreadStats';
export function NavSidebarActionButton({
icon,
label,
onClick,
onKeyDown,
}: {
icon: ReactNode;
label: ReactNode;
onClick: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}): JSX.Element {
return (
<button
type="button"
className="NavSidebar__ActionButton"
onClick={onClick}
onKeyDown={onKeyDown}
>
{icon}
<span className="NavSidebar__ActionButtonLabel">{label}</span>
</button>
);
}
export type NavSidebarProps = Readonly<{
actions?: ReactNode;
children: ReactNode;
i18n: LocalizerType;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
hideHeader?: boolean;
navTabsCollapsed: boolean;
onBack?: (() => void) | null;
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
preferredLeftPaneWidth: number;
requiresFullWidth: boolean;
savePreferredLeftPaneWidth: (width: number) => void;
title: string;
appUnreadStats: UnreadStats;
}>;
enum DragState {
INITIAL,
DRAGGING,
DRAGEND,
}
export function NavSidebar({
actions,
children,
hideHeader,
i18n,
hasFailedStorySends,
hasPendingUpdate,
navTabsCollapsed,
onBack,
onToggleNavTabsCollapse,
preferredLeftPaneWidth,
requiresFullWidth,
savePreferredLeftPaneWidth,
title,
appUnreadStats,
}: NavSidebarProps): JSX.Element {
const [dragState, setDragState] = useState(DragState.INITIAL);
const [preferredWidth, setPreferredWidth] = useState(() => {
return getWidthFromPreferredWidth(preferredLeftPaneWidth, {
requiresFullWidth,
});
});
const width = getWidthFromPreferredWidth(preferredWidth, {
requiresFullWidth,
});
const widthBreakpoint = getNavSidebarWidthBreakpoint(width);
// `useMove` gives us keyboard and mouse dragging support.
const { moveProps } = useMove({
onMoveStart() {
setDragState(DragState.DRAGGING);
},
onMoveEnd() {
setDragState(DragState.DRAGEND);
},
onMove(event) {
const { deltaX, shiftKey, pointerType } = event;
const isKeyboard = pointerType === 'keyboard';
const increment = isKeyboard && shiftKey ? 10 : 1;
setPreferredWidth(prevWidth => {
// Jump minimize for keyboard users
if (isKeyboard && prevWidth === MIN_FULL_WIDTH && deltaX < 0) {
return MIN_WIDTH;
}
// Jump maximize for keyboard users
if (isKeyboard && prevWidth === MIN_WIDTH && deltaX > 0) {
return MIN_FULL_WIDTH;
}
return prevWidth + deltaX * increment;
});
},
});
useEffect(() => {
// Save the preferred width when the drag ends. We can't do this in onMoveEnd
// because the width is not updated yet.
if (dragState === DragState.DRAGEND) {
setPreferredWidth(width);
savePreferredLeftPaneWidth(width);
setDragState(DragState.INITIAL);
}
}, [
dragState,
preferredLeftPaneWidth,
preferredWidth,
savePreferredLeftPaneWidth,
width,
]);
useEffect(() => {
// This effect helps keep the pointer `col-resize` even when you drag past the handle.
const className = 'NavSidebar__document--draggingHandle';
if (dragState === DragState.DRAGGING) {
document.body.classList.add(className);
return () => {
document.body.classList.remove(className);
};
}
return undefined;
}, [dragState]);
return (
<div
role="navigation"
className={classNames('NavSidebar', {
'NavSidebar--narrow': widthBreakpoint === WidthBreakpoint.Narrow,
})}
style={{ width }}
>
{!hideHeader && (
<div className="NavSidebar__Header">
{onBack == null && navTabsCollapsed && (
<NavTabsToggle
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
appUnreadStats={appUnreadStats}
/>
)}
<div
className={classNames('NavSidebar__HeaderContent', {
'NavSidebar__HeaderContent--navTabsCollapsed': navTabsCollapsed,
'NavSidebar__HeaderContent--withBackButton': onBack != null,
})}
>
{onBack != null && (
<button
type="button"
role="link"
onClick={onBack}
className="NavSidebar__BackButton"
>
<span className="NavSidebar__BackButtonLabel">
{i18n('icu:NavSidebar__BackButtonLabel')}
</span>
</button>
)}
<h1
className={classNames('NavSidebar__HeaderTitle', {
'NavSidebar__HeaderTitle--withBackButton': onBack != null,
})}
aria-live="assertive"
>
{title}
</h1>
{actions && (
<div className="NavSidebar__HeaderActions">{actions}</div>
)}
</div>
</div>
)}
<div className="NavSidebar__Content">{children}</div>
{/* eslint-disable-next-line jsx-a11y/role-supports-aria-props -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator */}
<div
className={classNames('NavSidebar__DragHandle', {
'NavSidebar__DragHandle--dragging': dragState === DragState.DRAGGING,
})}
role="separator"
aria-orientation="vertical"
aria-valuemin={MIN_WIDTH}
aria-valuemax={preferredLeftPaneWidth}
aria-valuenow={MAX_WIDTH}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator
tabIndex={0}
{...moveProps}
/>
</div>
);
}
export function NavSidebarSearchHeader({
children,
}: {
children: ReactNode;
}): JSX.Element {
return <div className="NavSidebarSearchHeader">{children}</div>;
}