licenses: convert tree to list

This migrates the list of packages from using react-complex-tree to
react-aria since it is a list rather than a tree.
This commit is contained in:
David Lechner
2022-06-15 12:03:29 -05:00
committed by David Lechner
parent 9104b70840
commit d891d349f6
5 changed files with 155 additions and 78 deletions
+1 -1
View File
@@ -31,7 +31,7 @@
}
// show focus for tree nodes even if mouse click it
.#{bp.$ns}-tree-node:focus,
.#{bp.$ns}-tree-node:focus:not(.pb-focus-managed),
// react-aria managed focus visibility
.pb-focus-ring,
// broswer managed focus visibility
+126 -70
View File
@@ -3,6 +3,7 @@
// The license dialog
import './license.scss';
import {
Callout,
Card,
@@ -10,23 +11,19 @@ import {
Dialog,
NonIdealState,
Spinner,
Text,
} from '@blueprintjs/core';
import React, { useCallback, useMemo, useState } from 'react';
import {
ControlledTreeEnvironment,
Tree,
TreeItem,
TreeItemIndex,
TreeViewState,
} from 'react-complex-tree';
import { Item } from '@react-stately/collections';
import { ListProps, ListState, useListState } from '@react-stately/list';
import type { Node, Selection } from '@react-types/shared';
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { mergeProps, useFocusRing, useListBox, useOption } from 'react-aria';
import { useFetch } from 'usehooks-ts';
import { appName } from '../app/constants';
import { TreeItemData, renderers } from '../utils/tree-renderer';
import { I18nId, useI18n } from './i18n';
import './license.scss';
interface LicenseInfo extends TreeItemData {
interface LicenseInfo {
readonly name: string;
readonly version: string;
readonly author: string | undefined;
@@ -36,83 +33,142 @@ interface LicenseInfo extends TreeItemData {
type LicenseList = ReadonlyArray<LicenseInfo>;
type ListItemProps = {
item: Node<LicenseInfo>;
state: ListState<LicenseInfo>;
};
/**
* A list item component using react-aria.
*
* Style uses blueprints tree styles since there is no list style.
*/
const ListItem: React.VoidFunctionComponent<ListItemProps> = ({ item, state }) => {
const ref = React.useRef<HTMLLIElement>(null);
const { optionProps, isSelected } = useOption({ key: item.key }, state, ref);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<li
className={classNames(
Classes.TREE_NODE,
isSelected && Classes.TREE_NODE_SELECTED,
'pb-focus-managed',
isFocusVisible && 'pb-focus-ring',
)}
{...mergeProps(optionProps, focusProps)}
ref={ref}
>
<div className={Classes.TREE_NODE_CONTENT}>
<Text className={Classes.TREE_NODE_LABEL} ellipsize>
{item.rendered}
</Text>
</div>
</li>
);
};
/**
* Memoized version of list items.
*
* This saves us from having to rerender all items in the list each time one
* item changes.
*/
const MemoizedListItem = React.memo(ListItem, (prev, next) => {
// selection and focus are the only thing that can change currently
if (
prev.state.selectionManager.focusedKey === prev.item.key ||
next.state.selectionManager.focusedKey === next.item.key
) {
return false;
}
if (
prev.state.selectionManager.selectedKeys.has(prev.item.key) ||
next.state.selectionManager.selectedKeys.has(next.item.key)
) {
return false;
}
return true;
});
MemoizedListItem.displayName = 'MemoizedListItem';
type ListBoxProps = ListProps<LicenseInfo>;
/**
* A list component using react-aria.
*
* Style uses blueprints tree styles since there is no list style.
*/
const ListBox: React.VoidFunctionComponent<ListBoxProps> = (props) => {
// Create state based on the incoming props
const state = useListState(props);
// Get props for the listbox element
const ref = React.useRef<HTMLUListElement>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<div className={Classes.TREE}>
<ul
className={classNames(Classes.TREE_NODE_LIST, Classes.TREE_ROOT)}
{...listBoxProps}
ref={ref}
>
{[...state.collection].map((item) => (
<MemoizedListItem key={item.key} item={item} state={state} />
))}
</ul>
</div>
);
};
type LicenseListPanelProps = {
/** Called when item is clicked. */
onItemClick(info?: LicenseInfo): void;
/** Called when item is selected. */
onItemSelected(info?: LicenseInfo): void;
};
const LicenseListPanel: React.VoidFunctionComponent<LicenseListPanelProps> = ({
onItemClick,
onItemSelected,
}) => {
const i18n = useI18n();
const { data, error } = useFetch<LicenseList>('static/oss-licenses.json');
const [focusedItem, setFocusedItem] = useState<TreeItemIndex>();
const [activeItem, setActiveItem] = useState<TreeItemIndex>();
const contents = useMemo(() => {
if (!data) {
return undefined;
}
const handleSelectionChanged = useCallback(
(keys: Selection) => {
if (!data) {
return;
}
return data.reduce(
(obj, info, i) => {
obj[i] = {
index: i,
data: info,
};
// istanbul ignore if: not reachable since list uses single selection
if (keys === 'all') {
return;
}
return obj;
},
{
root: {
index: 'root',
data: {} as LicenseInfo,
hasChildren: true,
children: data.map((_info, i) => i),
},
} as Record<TreeItemIndex, TreeItem<LicenseInfo>>,
);
}, [data]);
const handlePrimaryAction = useCallback(
(item: TreeItem<LicenseInfo>) => {
setActiveItem(item.index);
onItemClick(item.data);
onItemSelected(data.find((item) => keys.has(item.name)));
},
[onItemClick],
);
const viewState = useMemo<TreeViewState<never>>(
() => ({
'pb-license-list': {
focusedItem,
// REVISIT: it would be nice if there was an active item separate
// from using selected items.
selectedItems: activeItem === undefined ? undefined : [activeItem],
},
}),
[focusedItem, activeItem],
[data, onItemSelected],
);
return (
<div className="pb-license-list">
{contents === undefined ? (
{data === undefined ? (
<NonIdealState>
{error ? i18n.translate(I18nId.ErrorFetchFailed) : <Spinner />}
</NonIdealState>
) : (
<ControlledTreeEnvironment<LicenseInfo>
{...renderers}
items={contents}
getItemTitle={(item) => item.data.name}
viewState={viewState}
canRename={false}
showLiveDescription={false}
onFocusItem={(item) => setFocusedItem(item.index)}
onPrimaryAction={handlePrimaryAction}
<ListBox
aria-label={i18n.translate(I18nId.PackageListLabel)}
selectionMode="single"
onSelectionChange={handleSelectionChanged}
items={data}
>
<Tree treeId="pb-license-list" rootItem="root" />
</ControlledTreeEnvironment>
{(item) => <Item key={item.name}>{item.name}</Item>}
</ListBox>
)}
</div>
);
@@ -196,7 +252,7 @@ const LicenseDialog: React.VoidFunctionComponent<LicenseDialogProps> = ({
</Callout>
<Callout className="pb-license-browser">
<LicenseListPanel
onItemClick={(info) => {
onItemSelected={(info) => {
infoDiv.current?.scrollTo(0, 0);
setLicenseInfo(info);
}}
+1
View File
@@ -14,6 +14,7 @@ export function useI18n(): I18n {
export enum I18nId {
Title = 'title',
Description = 'description',
PackageListLabel = 'packageList.label',
PackageLabel = 'packageLabel',
AuthorLabel = 'authorLabel',
LicenseLabel = 'licenseLabel',
+24 -7
View File
@@ -12,6 +12,7 @@
.pb-license-browser {
margin-top: 16px;
display: flex;
gap: 2px;
height: 600px;
}
@@ -22,16 +23,32 @@
}
.pb-license-list {
width: 25%;
flex-flow: column;
overflow: auto;
// to allow for focus outline
padding: 2px;
width: 20%;
display: flex;
flex-direction: column;
& > .#{bp.$ns}-tree {
flex-grow: 1;
min-height: 0;
display: flex;
& > .#{bp.$ns}-tree-root {
flex-grow: 1;
min-height: 0;
// to allow for focus outline
padding: 2px;
overflow: auto;
& .#{bp.$ns}-tree-node-content {
padding-left: bp.$pt-grid-size * 0.5;
}
}
}
}
.pb-license-info {
width: 75%;
flex-flow: column;
width: 80%;
padding: 0px 16px;
overflow: auto;
}
+3
View File
@@ -1,6 +1,9 @@
{
"title": "Open Source Software Licenses",
"description": "{name} is built on open source software. By using {name} you are agreeing to the terms and conditions of all of the included software licenses.",
"packageList": {
"label": "Packages"
},
"packageLabel": "Package:",
"authorLabel": "Author:",
"licenseLabel": "License:",