mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
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:
committed by
David Lechner
parent
9104b70840
commit
d891d349f6
+1
-1
@@ -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
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function useI18n(): I18n {
|
||||
export enum I18nId {
|
||||
Title = 'title',
|
||||
Description = 'description',
|
||||
PackageListLabel = 'packageList.label',
|
||||
PackageLabel = 'packageLabel',
|
||||
AuthorLabel = 'authorLabel',
|
||||
LicenseLabel = 'licenseLabel',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user