diff --git a/src/index.scss b/src/index.scss index 6d3f16cb..c5db83a0 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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 diff --git a/src/licenses/LicenseDialog.tsx b/src/licenses/LicenseDialog.tsx index da14fd34..ca680551 100644 --- a/src/licenses/LicenseDialog.tsx +++ b/src/licenses/LicenseDialog.tsx @@ -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; +type ListItemProps = { + item: Node; + state: ListState; +}; + +/** + * A list item component using react-aria. + * + * Style uses blueprints tree styles since there is no list style. + */ +const ListItem: React.VoidFunctionComponent = ({ item, state }) => { + const ref = React.useRef(null); + const { optionProps, isSelected } = useOption({ key: item.key }, state, ref); + + const { isFocusVisible, focusProps } = useFocusRing(); + + return ( +
  • +
    + + {item.rendered} + +
    +
  • + ); +}; + +/** + * 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; + +/** + * A list component using react-aria. + * + * Style uses blueprints tree styles since there is no list style. + */ +const ListBox: React.VoidFunctionComponent = (props) => { + // Create state based on the incoming props + const state = useListState(props); + + // Get props for the listbox element + const ref = React.useRef(null); + const { listBoxProps } = useListBox(props, state, ref); + + return ( +
    +
      + {[...state.collection].map((item) => ( + + ))} +
    +
    + ); +}; + type LicenseListPanelProps = { - /** Called when item is clicked. */ - onItemClick(info?: LicenseInfo): void; + /** Called when item is selected. */ + onItemSelected(info?: LicenseInfo): void; }; const LicenseListPanel: React.VoidFunctionComponent = ({ - onItemClick, + onItemSelected, }) => { const i18n = useI18n(); const { data, error } = useFetch('static/oss-licenses.json'); - const [focusedItem, setFocusedItem] = useState(); - const [activeItem, setActiveItem] = useState(); - 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>, - ); - }, [data]); - - const handlePrimaryAction = useCallback( - (item: TreeItem) => { - setActiveItem(item.index); - onItemClick(item.data); + onItemSelected(data.find((item) => keys.has(item.name))); }, - [onItemClick], - ); - - const viewState = useMemo>( - () => ({ - '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 (
    - {contents === undefined ? ( + {data === undefined ? ( {error ? i18n.translate(I18nId.ErrorFetchFailed) : } ) : ( - - {...renderers} - items={contents} - getItemTitle={(item) => item.data.name} - viewState={viewState} - canRename={false} - showLiveDescription={false} - onFocusItem={(item) => setFocusedItem(item.index)} - onPrimaryAction={handlePrimaryAction} + - - + {(item) => {item.name}} + )}
    ); @@ -196,7 +252,7 @@ const LicenseDialog: React.VoidFunctionComponent = ({ { + onItemSelected={(info) => { infoDiv.current?.scrollTo(0, 0); setLicenseInfo(info); }} diff --git a/src/licenses/i18n.ts b/src/licenses/i18n.ts index 5b0d8f4b..40bdd053 100644 --- a/src/licenses/i18n.ts +++ b/src/licenses/i18n.ts @@ -14,6 +14,7 @@ export function useI18n(): I18n { export enum I18nId { Title = 'title', Description = 'description', + PackageListLabel = 'packageList.label', PackageLabel = 'packageLabel', AuthorLabel = 'authorLabel', LicenseLabel = 'licenseLabel', diff --git a/src/licenses/license.scss b/src/licenses/license.scss index e398e016..6cb2d4e1 100644 --- a/src/licenses/license.scss +++ b/src/licenses/license.scss @@ -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; } diff --git a/src/licenses/translations/en.json b/src/licenses/translations/en.json index 24629b37..e5c57952 100644 --- a/src/licenses/translations/en.json +++ b/src/licenses/translations/en.json @@ -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:",