229 lines
6.6 KiB
TypeScript
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>;
|
|
}
|