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