Commit 83e5973d authored by Robert Knight's avatar Robert Knight

Convert remaining sidebar components to TypeScript

This converts the remaining sidebar components to TypeScript.
parent d8250f9f
......@@ -2,6 +2,8 @@ import { PlusIcon } from '@hypothesis/frontend-shared/lib/next';
import classnames from 'classnames';
import { useMemo, useState } from 'preact/hooks';
import type { Group } from '../../../types/api';
import type { SidebarSettings } from '../../../types/config';
import { serviceConfig } from '../../config/service-config';
import { isThirdPartyUser } from '../../helpers/account-id';
import { orgName } from '../../helpers/group-list-item-common';
......@@ -13,34 +15,24 @@ import Menu from '../Menu';
import MenuItem from '../MenuItem';
import GroupListSection from './GroupListSection';
/**
* @typedef {import('../../../types/config').SidebarSettings} SidebarSettings
* @typedef {import('../../../types/api').Group} Group
*/
/**
* Return the custom icon for the top bar configured by the publisher in
* the Hypothesis client configuration.
*
* @param {SidebarSettings} settings
*/
function publisherProvidedIcon(settings) {
function publisherProvidedIcon(settings: SidebarSettings) {
const svc = serviceConfig(settings);
return svc && svc.icon ? svc.icon : null;
}
/**
* @typedef GroupListProps
* @prop {SidebarSettings} settings
*/
export type GroupListProps = {
settings: SidebarSettings;
};
/**
* Menu allowing the user to select which group to show and also access
* additional actions related to groups.
*
* @param {GroupListProps} props
*/
function GroupList({ settings }) {
function GroupList({ settings }: GroupListProps) {
const store = useSidebarStore();
const currentGroups = store.getCurrentlyViewingGroups();
const featuredGroups = store.getFeaturedGroups();
......@@ -73,9 +65,7 @@ function GroupList({ settings }) {
//
// nb. If we create other menus that behave similarly in future, we may want
// to move this state to the `Menu` component.
const [expandedGroup, setExpandedGroup] = useState(
/** @type {Group|null} */ (null)
);
const [expandedGroup, setExpandedGroup] = useState<Group | null>(null);
let label;
if (focusedGroup) {
......
......@@ -6,33 +6,33 @@ import {
import classnames from 'classnames';
import { confirm } from '../../../shared/prompts';
import type { Group } from '../../../types/api';
import { orgName } from '../../helpers/group-list-item-common';
import { withServices } from '../../service-context';
import type { GroupsService } from '../../services/groups';
import type { ToastMessengerService } from '../../services/toast-messenger';
import { useSidebarStore } from '../../store';
import { copyText } from '../../util/copy-to-clipboard';
import MenuItem from '../MenuItem';
/**
* @typedef {import('../../../types/api').Group} Group
*/
export type GroupListItemProps = {
group: Group;
/**
* @typedef GroupListItemProps
* @prop {Group} group
* @prop {boolean} [isExpanded] - Whether the submenu for this group is expanded
* @prop {(expand: boolean) => void} onExpand -
* Callback invoked to expand or collapse the current group
* @prop {import('../../services/groups').GroupsService} groups
* @prop {import('../../services/toast-messenger').ToastMessengerService} toastMessenger
*/
/** Whether the submenu for this group is expanded. */
isExpanded?: boolean;
/** Callback invoked to expand or collapse the current group. */
onExpand: (expand: boolean) => void;
groups: GroupsService;
toastMessenger: ToastMessengerService;
};
/**
* An item in the groups selection menu.
*
* The item has a primary action which selects the group, along with a set of
* secondary actions accessible via a toggle menu.
*
* @param {GroupListItemProps} props
*/
function GroupListItem({
isExpanded,
......@@ -40,7 +40,7 @@ function GroupListItem({
groups: groupsService,
onExpand,
toastMessenger,
}) {
}: GroupListItemProps) {
const activityUrl = group.links.html;
const hasActionMenu = activityUrl || group.canLeave;
const isSelectable =
......@@ -69,12 +69,7 @@ function GroupListItem({
}
};
/**
* Opens or closes the submenu.
*
* @param {Event} event
*/
const toggleSubmenu = event => {
const toggleSubmenu = (event: Event) => {
event.stopPropagation();
// Prevents group items opening a new window when clicked.
......@@ -84,10 +79,7 @@ function GroupListItem({
onExpand(!isExpanded);
};
/**
* @param {string} url
*/
const copyLink = url => {
const copyLink = (url: string) => {
try {
copyText(url);
toastMessenger.success(`Copied link for "${group.name}"`);
......
import type { Group } from '../../../types/api';
import MenuSection from '../MenuSection';
import GroupListItem from './GroupListItem';
/**
* @typedef {import('../../../types/api').Group} Group
*/
export type GroupListSectionProps = {
/** The group whose submenu is currently expanded. */
expandedGroup?: Group | null;
/**
* @typedef GroupListSectionProps
* @prop {Group|null} [expandedGroup]
* - The `Group` whose submenu is currently expanded, or `null` if no group is currently expanded
* @prop {Group[]} groups - The list of groups to be displayed in the group list section
* @prop {string} [heading] - The string name of the group list section
* @prop {(group: Group|null) => void} onExpandGroup -
* Callback invoked when a group is expanded or collapsed. The argument is the group being
* expanded, or `null` if the expanded group is being collapsed.
*/
/** List of groups to be displayed in the section. */
groups: Group[];
/** Heading displayed at top of section. */
heading?: string;
/**
* Callback invoked when a group is expanded or collapsed.
*
* Set to either the group that is being expanded or `null` if the group given
* by {@link GroupListSectionProps.expandedGroup} is being collapsed.
*/
onExpandGroup: (group: Group | null) => void;
};
/**
* A labeled section of the groups list.
*
* @param {GroupListSectionProps} props
*/
export default function GroupListSection({
expandedGroup,
onExpandGroup,
groups,
heading,
}) {
}: GroupListSectionProps) {
return (
<MenuSection heading={heading}>
{groups.map(group => (
......
......@@ -4,29 +4,30 @@ import { replaceLinksWithEmbeds } from '../media-embedder';
import { renderMathAndMarkdown } from '../render-markdown';
import StyledText from './StyledText';
/**
* @typedef MarkdownViewProps
* @prop {string} markdown - The string of markdown to display
* @prop {string} [classes]
* @prop {Record<string,string>} [style]
*/
export type MarkdownViewProps = {
/** The string of markdown to display as HTML. */
markdown: string;
classes?: string;
style?: Record<string, string>;
};
/**
* A component which renders markdown as HTML and replaces recognized links
* with embedded video/audio.
*
* @param {MarkdownViewProps} props
*/
export default function MarkdownView({ markdown, classes, style }) {
export default function MarkdownView({
markdown,
classes,
style,
}: MarkdownViewProps) {
const html = useMemo(
() => (markdown ? renderMathAndMarkdown(markdown) : ''),
[markdown]
);
const content = /** @type {{ current: HTMLDivElement }} */ (useRef());
const content = useRef<HTMLDivElement | null>(null);
useEffect(() => {
replaceLinksWithEmbeds(content.current, {
replaceLinksWithEmbeds(content.current!, {
// Make embeds the full width of the sidebar, unless the sidebar has been
// made wider than the `md` breakpoint. In that case, restrict width
// to 380px.
......
import type { ComponentChildren } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
/** @param {HTMLElement} element */
function isElementVisible(element) {
function isElementVisible(element: HTMLElement) {
return element.offsetParent !== null;
}
/**
* @typedef MenuKeyboardNavigationProps
* @prop {string} [className]
* @prop {(e: KeyboardEvent) => void} [closeMenu] - Callback when the menu is closed via keyboard input
* @prop {boolean} [visible] - When true`, sets focus on the first item in the list
* @prop {import('preact').ComponentChildren} children - Array of nodes which may contain <MenuItems> or any nodes
*/
export type MenuKeyboardNavigationProps = {
className?: string;
/** Callback invoked when the menu is closed via a keyboard command. */
closeMenu?: (e: KeyboardEvent) => void;
/**
* If true, the first element in children with `role=menuitem` is focused when
* this component is mounted.
*/
visible?: boolean;
/** Content to display, which is typically a list of `<MenuItem>` elements. */
children: ComponentChildren;
};
/**
* Helper component used by Menu and MenuItem to facilitate keyboard navigation of a
......@@ -19,26 +27,25 @@ function isElementVisible(element) {
*
* Note that `ArrowRight` shall be handled by the parent <MenuItem> directly and
* all other focus() related navigation is handled here.
*
* @param {MenuKeyboardNavigationProps} props
*/
export default function MenuKeyboardNavigation({
className,
closeMenu,
children,
visible,
}) {
const menuRef = /** @type {{ current: HTMLDivElement }} */ (useRef());
}: MenuKeyboardNavigationProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
/** @type {number|undefined} */
let focusTimer;
let focusTimer: number | undefined;
if (visible) {
focusTimer = setTimeout(() => {
// The focus won't work without delaying rendering.
const firstItem = menuRef.current.querySelector('[role^="menuitem"]');
const firstItem = menuRef.current!.querySelector(
'[role^="menuitem"]'
) as HTMLElement;
if (firstItem) {
/** @type {HTMLElement} */ (firstItem).focus();
firstItem.focus();
}
});
}
......@@ -48,11 +55,11 @@ export default function MenuKeyboardNavigation({
};
}, [visible]);
/** @param {KeyboardEvent} event */
const onKeyDown = event => {
const onKeyDown = (event: KeyboardEvent) => {
const menuItems = Array.from(
/** @type {NodeListOf<HTMLElement>} */
(menuRef.current.querySelectorAll('[role^="menuitem"]'))
menuRef.current!.querySelectorAll(
'[role^="menuitem"]'
) as NodeListOf<HTMLElement>
).filter(isElementVisible);
let focusedIndex = menuItems.findIndex(el =>
......
import { toChildArray } from 'preact';
import type { ComponentChildren, VNode } from 'preact';
/** @typedef {import("preact").JSX.Element} JSXElement */
export type MenuSectionProps = {
/** Heading displayed at the top of the menu. */
heading?: string;
/**
* @typedef MenuSectionProps
* @prop {string} [heading] - Heading displayed at the top of the menu.
* @prop {object} children - Menu items to display in this section.
*/
/** Menu items to display in this section. */
children: ComponentChildren;
};
/**
* Group a set of menu items together visually, with an optional header.
......@@ -25,7 +26,7 @@ import { toChildArray } from 'preact';
*
* @param {MenuSectionProps} props
*/
export default function MenuSection({ heading, children }) {
export default function MenuSection({ heading, children }: MenuSectionProps) {
return (
<>
{heading && (
......@@ -35,7 +36,7 @@ export default function MenuSection({ heading, children }) {
)}
<ul className="border-b">
{toChildArray(children).map(child => (
<li key={/** @type {JSXElement} **/ (child).key}>{child}</li>
<li key={(child as VNode).key}>{child}</li>
))}
</ul>
</>
......
import { useMemo } from 'preact/hooks';
import type { Thread } from '../helpers/build-thread';
import { countVisible } from '../helpers/thread';
import PaginationNavigation from './PaginationNavigation';
import ThreadList from './ThreadList';
/** @typedef {import('../helpers/build-thread').Thread} Thread */
/**
* @typedef PaginatedThreadListProps
* @prop {number} currentPage
* @prop {boolean} isLoading
* @prop {(page: number) => void} onChangePage
* @prop {Thread[]} threads
* @prop {number} [pageSize]
*/
export type PaginatedThreadListProps = {
currentPage: number;
isLoading: boolean;
onChangePage: (page: number) => void;
threads: Thread[];
pageSize?: number;
};
/**
* Determine which subset of all current `threads` to show on the current
* page of results, and how many pages of results there are total.
*
* Render the threads for the current page of results, and pagination controls.
*
* @param {PaginatedThreadListProps} props
*/
function PaginatedThreadList({
currentPage,
......@@ -29,7 +25,7 @@ function PaginatedThreadList({
onChangePage,
threads,
pageSize = 25,
}) {
}: PaginatedThreadListProps) {
const { paginatedThreads, totalPages } = useMemo(() => {
const visibleThreads = threads.filter(thread => countVisible(thread) > 0);
const startIndex = (currentPage - 1) * pageSize;
......
import type { ComponentChildren } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
/**
* @typedef SliderProps
* @prop {object} [children] - The slideable content to hide or reveal.
* @prop {boolean} visible - Whether the content should be visible or not.
*/
export type SliderProps = {
/** The content to hide or reveal. */
children?: ComponentChildren;
/** Whether the content should be visible or not. */
visible: boolean;
};
/**
* A container which reveals its content when `visible` is `true` using
......@@ -15,11 +18,9 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
* order.
*
* Currently the only reveal/expand direction supported is top-down.
*
* @param {SliderProps} props
*/
export default function Slider({ children, visible }) {
const containerRef = /** @type {{ current: HTMLDivElement }} */ (useRef());
export default function Slider({ children, visible }: SliderProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerHeight, setContainerHeight] = useState(visible ? 'auto' : 0);
// Whether the content is currently partially or wholly visible. This is
......@@ -35,7 +36,7 @@ export default function Slider({ children, visible }) {
return;
}
const el = containerRef.current;
const el = containerRef.current!;
if (visible) {
// Show the content synchronously so that we can measure it here.
el.style.display = '';
......
import { withServices } from '../service-context';
import type { RouterService } from '../services/router';
import { useSidebarStore } from '../store';
import SearchInput from './SearchInput';
/**
* @typedef StreamSearchInputProps
* @prop {import('../services/router').RouterService} router
*/
export type StreamSearchInputProps = {
router: RouterService;
};
/**
* Search input for the single annotation view and stream.
*
* This displays and updates the "q" query param in the URL.
*
* @param {StreamSearchInputProps} props
*/
function StreamSearchInput({ router }) {
function StreamSearchInput({ router }: StreamSearchInputProps) {
const store = useSidebarStore();
const query = store.routeParams().q;
/** @param {string} query */
const setQuery = query => {
const setQuery = (query: string) => {
// Re-route the user to `/stream` if they are on `/a/:id` and then set
// the search query.
router.navigate('stream', { q: query });
......
import classnames from 'classnames';
import type { ComponentChildren, JSX } from 'preact';
/**
* @typedef StyledTextProps
* @prop {import('preact').ComponentChildren} children
* @prop {string} [classes]
*/
export type StyledTextProps = JSX.HTMLAttributes<HTMLDivElement> & {
children: ComponentChildren;
classes?: string;
};
/**
* Render children as styled text: basic prose styling for HTML
*
* @param {StyledTextProps & import('preact').JSX.HTMLAttributes<HTMLDivElement>} props
*/
export default function StyledText({ children, classes, ...restProps }) {
export default function StyledText({
children,
classes,
...restProps
}: StyledTextProps) {
// The language for the quote may be different than the client's UI (set by
// `<html lang="...">`).
//
......
/**
* @typedef TagListProps
* @prop {import("preact").ComponentChildren} children
*/
import type { ComponentChildren } from 'preact';
export type TagListProps = {
children: ComponentChildren;
};
/**
* Render a list container for a list of annotation tags.
*
* @param {TagListProps} props
*/
function TagList({ children }) {
export default function TagList({ children }: TagListProps) {
return (
<ul
className="flex flex-wrap gap-2 leading-none"
......@@ -18,5 +17,3 @@ function TagList({ children }) {
</ul>
);
}
export default TagList;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment