diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb88118..e361e5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to the Reactodia will be documented in this document. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +#### 🚀 New Features +- Make tree- and list-like components more accessible: + * Change `ClassTree`, `InstancesSearch`, `ConnectionsMenu` and `SearchResults` to be "focus group" components with support for keyboard interaction (arrow keys to move focus or toggle tree items, space to select); + * Add proper `aria-*` attributes for "focus group" containers and children e.g. `tree` and `treeitem` roles; + +#### 🐛 Fixed +- Fix partially or fully hidden outlines for `WorkspaceLayoutItem` headers and `Navigator` toggle button. + +#### 💅 Polish +- Allow to configure `SearchResults` utility component with `isItemDisabled` and `multiSelection` props: + * Remove `singleSelectOnClick` mode from `SearchResults` as it mostly superseded by `multiSelection`. +- Extend `ListElementView` utility component to accept any other additional HTML props. +- Always display ungroup buttons on `StandardGroup` when the element is single-selected. ## [0.34.1] - 2026-03-30 #### 🐛 Fixed diff --git a/src/coreUtils/dom.ts b/src/coreUtils/dom.ts new file mode 100644 index 00000000..c3fe774b --- /dev/null +++ b/src/coreUtils/dom.ts @@ -0,0 +1,77 @@ +export function findNextWithin( + from: Element, + parent: Element, + condition: (element: Element) => boolean +): Element | undefined { + let current: Element = from; + let allowDescent = true; + do { + while (true) { + if (allowDescent && current.firstElementChild) { + current = current.firstElementChild; + break; + } else if (current.nextElementSibling) { + current = current.nextElementSibling; + allowDescent = true; + break; + } else if (current.parentElement === parent) { + if (parent.firstElementChild) { + current = parent.firstElementChild; + allowDescent = true; + break; + } else { + return undefined; + } + } else if (current.parentElement) { + current = current.parentElement; + allowDescent = false; + } else { + return undefined; + } + } + + if (condition(current)) { + return current; + } + } while (current !== from); +} + +export function findPreviousWithin( + from: Element, + parent: Element, + condition: (element: Element) => boolean +): Element | undefined { + let current: Element = from; + let descent = false; + do { + while (true) { + if (descent) { + if (current.lastElementChild) { + current = current.lastElementChild; + } else { + descent = false; + break; + } + } else if (current.previousElementSibling) { + current = current.previousElementSibling; + descent = true; + } else if (current.parentElement === parent) { + if (parent.lastElementChild) { + current = parent.lastElementChild; + descent = true; + } else { + return undefined; + } + } else if (current.parentElement) { + current = current.parentElement; + break; + } else { + return undefined; + } + } + + if (condition(current)) { + return current; + } + } while (current !== from); +} diff --git a/src/widgets/classTree/classTree.tsx b/src/widgets/classTree/classTree.tsx index 465e0886..d478c889 100644 --- a/src/widgets/classTree/classTree.tsx +++ b/src/widgets/classTree/classTree.tsx @@ -28,8 +28,7 @@ import { } from '../../workspace/commandBusTopic'; import { WorkspaceContext, useWorkspace } from '../../workspace/workspaceContext'; -import { TreeNode } from './treeModel'; -import { ClassTreeContext, Forest } from './leaf'; +import { ClassTreeResults, type ClassTreeSelection, TreeNode } from './classTreeResults'; /** * Props for {@link ClassTree} component. @@ -132,7 +131,7 @@ interface State { roots: ReadonlyArray; filteredRoots: ReadonlyArray; appliedSearchText?: string; - selectedNode?: TreeNode; + selection?: ClassTreeSelection; constructibleClasses: ReadonlyMap; showOnlyConstructible: boolean; } @@ -177,7 +176,7 @@ class ClassTreeInner extends React.Component { draggableItems = true, workspace: {editor}, translation: t, } = this.props; const { - fetchedGraph, refreshingState, appliedSearchText, roots, filteredRoots, selectedNode, + fetchedGraph, refreshingState, appliedSearchText, roots, filteredRoots, selection, constructibleClasses, showOnlyConstructible } = this.state; // highlight search term only if actual tree is already filtered by current or previous term: @@ -217,35 +216,30 @@ class ClassTreeInner extends React.Component { title={t.text('search_element_types.refresh_progress.title')} /> {fetchedGraph?.classTree ? ( - - - ) : null +
+ - + {filteredRoots.length === 0 ? ( + + ) : null} +
) : (
{ )); }; - private onSelectNode = (node: TreeNode) => { + private onSelectNode = (selection: ClassTreeSelection) => { const {workspace: {getCommandBus}} = this.props; - this.setState({selectedNode: node}, () => { + this.setState({selection}, () => { getCommandBus(InstancesSearchTopic) .trigger('setCriteria', { - criteria: {elementType: node.iri}, + criteria: {elementType: selection.node.iri}, }); }); }; diff --git a/src/widgets/classTree/classTreeResults.tsx b/src/widgets/classTree/classTreeResults.tsx new file mode 100644 index 00000000..d2d85a6a --- /dev/null +++ b/src/widgets/classTree/classTreeResults.tsx @@ -0,0 +1,257 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import { useTranslation } from '../../coreUtils/i18n'; + +import { ElementTypeIri, ElementTypeModel } from '../../data/model'; +import { useWorkspace } from '../../workspace/workspaceContext'; + +import { highlightSubstring } from '../utility/listElementView'; +import { + TreeList, type TreeListModel, type TreeListRenderItem, type TreeListFocusProps, + TreeListState, type TreeListUpPath, treeListPathToDown, +} from '../utility/treeList'; + +export interface ClassTreeResultsProps extends ClassTreeProvidedContext { + nodes: ReadonlyArray; + selection: ClassTreeSelection | undefined; + onSelect: (selection: ClassTreeSelection) => void; +} + +export interface ClassTreeProvidedContext { + readonly searchText?: string; + readonly creatableClasses: ReadonlyMap; + readonly onClickCreate: (node: TreeNode) => void; + readonly onDragCreate: (node: TreeNode) => void; + readonly draggableItems: boolean; +} + +export interface ClassTreeSelection { + readonly node: TreeNode; + readonly selection: TreeListState; +} + +interface ClassTreeContext extends ClassTreeProvidedContext { + readonly onExpand: (path: TreeListUpPath) => void; + readonly onSelect: (node: TreeNode, path: TreeListUpPath) => void; +} + +const ClassTreeContext = React.createContext(null); + +const CLASS_NAME = 'reactodia-class-tree-item'; + +export function ClassTreeResults(props: ClassTreeResultsProps) { + const { + nodes, selection, onSelect, searchText, creatableClasses, + onClickCreate, onDragCreate, draggableItems, + } = props; + + const renderItem = React.useCallback>( + ({item, path, focusProps, expanded, selected}) => ( + + ), + [] + ); + const rootProps = React.useMemo((): React.HTMLProps => ({ + className: `${CLASS_NAME}__root`, + role: 'tree', + }), []); + const forestProps = React.useMemo((): React.HTMLProps => ({ + className: `${CLASS_NAME}__children`, + role: 'none', + }), []); + const itemProps = React.useMemo((): React.HTMLProps => ({ + className: CLASS_NAME, + role: 'treeitem', + }), []); + + const defaultExpanded = Boolean(searchText); + const [expanded, setExpanded] = React.useState>(); + const onExpand = React.useCallback((path: TreeListUpPath) => { + setExpanded(previous => + (previous ?? new TreeListState()) + .setAt(treeListPathToDown(path), itemExpanded => !(itemExpanded ?? defaultExpanded)) + ); + }, [defaultExpanded]); + React.useEffect(() => setExpanded(undefined), [searchText]); + + const classTreeContext = React.useMemo( + (): ClassTreeContext => ({ + searchText, + creatableClasses, + onClickCreate, + onDragCreate, + draggableItems, + onExpand, + onSelect: (node, path) => onSelect({ + node, + selection: new TreeListState().setAt( + treeListPathToDown(path), + () => node + ), + }), + }), + [ + searchText, + creatableClasses, + onClickCreate, + onDragCreate, + draggableItems, + onExpand, + onSelect, + ] + ); + + return ( + + setExpanded(previous => ( + (previous ?? new TreeListState()).setAt(path, () => expand) + ))} + selected={selection?.selection} + rootProps={rootProps} + forestProps={forestProps} + itemProps={itemProps} + /> + + ); +} + +export interface TreeNode { + readonly iri: ElementTypeIri + readonly data: ElementTypeModel | undefined; + readonly label: string; + readonly derived: ReadonlyArray; +} + +export const TreeNode = { + setDerived: (node: TreeNode, derived: ReadonlyArray): TreeNode => ({...node, derived}), +}; + +const ClassTreeModel: TreeListModel = { + getKey: item => item.iri, + getChildren: item => item.derived, + getDefaultSelected: (item, selected) => undefined, + isActive: item => true, +}; + +function Leaf(props: { + node: TreeNode; + path: TreeListUpPath; + focusProps: TreeListFocusProps; + expanded: boolean; + selected?: TreeNode; +}) { + const {node, path, focusProps, expanded, selected} = props; + const { + searchText, creatableClasses, onClickCreate, onDragCreate, draggableItems, onExpand, onSelect, + } = useClassTreeContext(); + + const {getElementTypeStyle} = useWorkspace(); + const t = useTranslation(); + + const toggleClass = ( + node.derived.length === 0 ? `${CLASS_NAME}__toggle` : + expanded ? `${CLASS_NAME}__toggle-expanded` : + `${CLASS_NAME}__toggle-collapsed` + ); + + const typeStyle = getElementTypeStyle([node.iri]); + const providedStyle = { + '--reactodia-element-style-color': typeStyle.color, + } as React.CSSProperties; + + const bodyClass = cx( + `${CLASS_NAME}__body`, + selected ? `${CLASS_NAME}__body--selected` : undefined + ); + + const label = highlightSubstring( + node.label, searchText, {className: `${CLASS_NAME}__highlighted-term`} + ); + + const onDragStart = (e: React.DragEvent) => { + // sets the drag data to support drag-n-drop in Firefox + // see https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations for more details + // IE supports only 'text' and 'URL' formats, see https://msdn.microsoft.com/en-us/ie/ms536744(v=vs.94) + e.dataTransfer.setData('text', ''); + onDragCreate(node); + }; + + return ( +
+
onExpand(path)} + /> + { + e.preventDefault(); + onSelect(node, path); + }} + onKeyDown={e => { + if (e.key === ' ') { + e.preventDefault(); + onSelect(node, path); + } + }} + draggable={draggableItems}> +
+ {typeStyle.icon ? ( + + ) : ( +
+ )} +
+ {label} + {node.data?.count ? ( + + {node.data.count} + + ) : null} +
+ {creatableClasses.get(node.iri) ? ( +
+ ); +} + +function useClassTreeContext(): ClassTreeContext { + const context = React.useContext(ClassTreeContext); + if (!context) { + throw new Error('Reactodia: missing class tree context'); + } + return context; +} diff --git a/src/widgets/classTree/leaf.tsx b/src/widgets/classTree/leaf.tsx deleted file mode 100644 index 0c2eaf0b..00000000 --- a/src/widgets/classTree/leaf.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import cx from 'clsx'; -import * as React from 'react'; - -import { useTranslation } from '../../coreUtils/i18n'; - -import { ElementTypeIri } from '../../data/model'; -import { useWorkspace } from '../../workspace/workspaceContext'; - -import { highlightSubstring } from '../utility/listElementView'; - -import { TreeNode } from './treeModel'; - -export interface ClassTreeContext { - searchText?: string; - selectedNode?: TreeNode; - onSelect: (node: TreeNode) => void; - creatableClasses: ReadonlyMap; - onClickCreate: (node: TreeNode) => void; - onDragCreate: (node: TreeNode) => void; - draggableItems: boolean; -} - -export const ClassTreeContext = React.createContext(null); - -const CLASS_NAME = 'reactodia-class-tree-item'; - -export function Leaf(props: { - node: TreeNode; -}) { - const {node, ...otherProps} = props; - const { - selectedNode, searchText, onSelect, creatableClasses, onClickCreate, onDragCreate, - draggableItems, - } = useClassTreeContext(); - - const {getElementTypeStyle} = useWorkspace(); - const t = useTranslation(); - - const [expanded, setExpanded] = React.useState(Boolean(searchText)); - React.useEffect(() => { - setExpanded(Boolean(searchText)); - }, [searchText]); - - const toggleClass = ( - node.derived.length === 0 ? `${CLASS_NAME}__toggle` : - expanded ? `${CLASS_NAME}__toggle-expanded` : - `${CLASS_NAME}__toggle-collapsed` - ); - - const typeStyle = getElementTypeStyle([node.iri]); - const providedStyle = { - '--reactodia-element-style-color': typeStyle.color, - } as React.CSSProperties; - - const selected = Boolean(selectedNode && selectedNode.iri === node.iri); - const bodyClass = cx( - `${CLASS_NAME}__body`, - selected ? `${CLASS_NAME}__body--selected` : undefined - ); - - const label = highlightSubstring( - node.label, searchText, {className: `${CLASS_NAME}__highlighted-term`} - ); - - const onDragStart = (e: React.DragEvent) => { - // sets the drag data to support drag-n-drop in Firefox - // see https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations for more details - // IE supports only 'text' and 'URL' formats, see https://msdn.microsoft.com/en-us/ie/ms536744(v=vs.94) - e.dataTransfer.setData('text', ''); - onDragCreate(node); - }; - - return ( -
-
-
setExpanded(previous => !previous)} - role='button' - /> - { - e.preventDefault(); - onSelect(node); - }} - draggable={draggableItems}> -
- {typeStyle.icon ? ( - - ) : ( -
- )} -
- {label} - {node.data?.count ? ( - - {node.data.count} - - ) : null} -
- {creatableClasses.get(node.iri) ? ( -
onClickCreate(node)} - onDragStart={onDragStart} - /> - ) : null} -
- {expanded && node.derived.length > 0 ? ( - - ) : null} -
- ); -} - -export function Forest(props: { - className?: string; - nodes: ReadonlyArray; - root?: boolean; - footer?: React.ReactNode; -}) { - const {className, nodes, root, footer} = props; - return ( -
- {nodes.map(node => ( - - ))} - {footer} -
- ); -} - -function useClassTreeContext(): ClassTreeContext { - const context = React.useContext(ClassTreeContext); - if (!context) { - throw new Error('Reactodia: missing class tree context'); - } - return context; -} diff --git a/src/widgets/classTree/treeModel.ts b/src/widgets/classTree/treeModel.ts deleted file mode 100644 index a6a9a7f2..00000000 --- a/src/widgets/classTree/treeModel.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ElementTypeIri, ElementTypeModel } from '../../data/model'; - -export interface TreeNode { - readonly iri: ElementTypeIri - readonly data: ElementTypeModel | undefined; - readonly label: string; - readonly derived: ReadonlyArray; -} - -export const TreeNode = { - setDerived: (node: TreeNode, derived: ReadonlyArray): TreeNode => ({...node, derived}), -}; diff --git a/src/widgets/connectionsMenu/connectionList.tsx b/src/widgets/connectionsMenu/connectionList.tsx new file mode 100644 index 00000000..1af003be --- /dev/null +++ b/src/widgets/connectionsMenu/connectionList.tsx @@ -0,0 +1,339 @@ +import * as React from 'react'; +import cx from 'clsx'; + +import { type Translation, useTranslation } from '../../coreUtils/i18n'; + +import type { LinkTypeModel } from '../../data/model'; +import { generate128BitID, makeCaseInsensitiveFilter } from '../../data/utils'; +import { WithFetchStatus } from '../../editor/withFetchStatus'; + +import { highlightSubstring } from '../utility/listElementView'; +import { + TreeList, type TreeListModel, type TreeListRenderItem, type TreeListFocusProps, +} from '../utility/treeList'; + +import { useWorkspace } from '../../workspace/workspaceContext'; + +import { + SortMode, ConnectionsData, ConnectionSuggestions, LinkDataChunk, + CLASS_NAME, LINK_COUNT_PER_PAGE, +} from './menuCommon'; + +export function ConnectionsList(props: { + data: ConnectionsData; + filterKey: string; + sortMode: SortMode; + suggestions: ConnectionSuggestions; + + allRelatedLink: LinkTypeModel; + onExpandLink: (chunk: LinkDataChunk) => void; + onMoveToFilter: ((chunk: LinkDataChunk) => void) | undefined; + + scrolledListRef: React.RefObject; +}) { + const { + data, filterKey, sortMode, suggestions, + allRelatedLink, onExpandLink, onMoveToFilter, scrolledListRef, + } = props; + const {model} = useWorkspace(); + const t = useTranslation(); + + const isSmartMode = sortMode === 'smart' && !filterKey; + + const textFilter = filterKey ? makeCaseInsensitiveFilter(filterKey) : undefined; + const links = isSmartMode ? [] : (data.links || []) + .map(link => model.getLinkType(link.id)?.data ?? link) + .filter(link => { + const text = t.formatLabel(link.label, link.id, model.language); + return !textFilter || textFilter(text); + }) + .sort(makeLinkTypeComparer(t, model.language)); + + const probableLinks = (data.links ?? []) + .map(link => model.getLinkType(link.id)?.data ?? link) + .filter(link => { + const score = suggestions.scores.get(link.id); + return !links.includes(link) && score !== undefined && (score.score > 0 || isSmartMode); + }) + .sort(makeLinkTypeComparer(t, model.language, suggestions)); + + const regularEntries = getConnectionLinks(links, { + counts: data.counts, + scores: suggestions.scores, + }); + const probableEntries = getConnectionLinks(probableLinks, { + counts: data.counts, + scores: suggestions.scores, + probable: true, + }); + + const entries: ConnectionEntry[] = []; + if (regularEntries.length > 1 || (isSmartMode && probableEntries.length > 1)) { + const countMap = data.counts; + const {inCount, outCount, inexact} = countMap.get(allRelatedLink.id)!; + const totalCount = inCount + outCount; + entries.push({ + type: 'link', + key: allRelatedLink.id, + linkType: allRelatedLink, + count: inexact && totalCount > 0 ? 'some' : totalCount, + }); + entries.push({type: 'separator'}); + for (const entry of regularEntries) { + entries.push(entry); + } + } + + if (probableEntries.length > 0) { + if (isSmartMode) { + entries.push({type: 'probable-hint'}); + for (const entry of probableEntries) { + entries.push(entry); + } + } + } + + const renderItem = React.useCallback>( + ({item, focusProps}) => { + if (item.type === 'link') { + return ( + + ); + } else if (item.type === 'separator') { + return
; + } else if (item.type === 'probable-hint') { + return ( +
  • + {t.text('connections_menu.links.suggest_similar')} +
  • + ); + } + return null; + }, + [t, filterKey, onExpandLink, onMoveToFilter] + ); + + const rootProps = React.useMemo((): React.HTMLProps => ({ + /* For compatibility with React 19 typings */ + ref: scrolledListRef as React.RefObject, + className: `${CLASS_NAME}__links-root`, + role: 'list', + }), []); + const forestProps = React.useMemo((): React.HTMLProps => ({}), []); + const itemProps = React.useMemo((): React.HTMLProps => ({ + className: `${CLASS_NAME}__links-item`, + role: 'listitem', + }), []); + + return ( +
    + + {entries.length === 0 ? ( + + ) : null} +
    + ); +} + +const ConnectionListModel: TreeListModel = { + getKey: item => item.type === 'link' ? item.key : item.type, + getChildren: item => undefined, + getDefaultSelected: (item, selected) => undefined, + isActive: item => item.type === 'link', +}; + +type ConnectionEntry = ConnectionEntryLink | ConnectionEntrySeparator; + +interface ConnectionEntrySeparator { + readonly type: 'separator' | 'probable-hint'; +} + +interface ConnectionEntryLink { + readonly type: 'link'; + readonly key: string; + readonly linkType: LinkTypeModel; + readonly direction?: 'in' | 'out'; + readonly count: number | 'some'; + readonly probable?: boolean; + readonly probability?: number; +} + +function getConnectionLinks(links: LinkTypeModel[], options: { + counts: ConnectionsData['counts']; + scores: ConnectionSuggestions['scores']; + probable?: boolean; +}): ConnectionEntryLink[] { + const {counts, scores, probable} = options; + const entries: ConnectionEntryLink[] = []; + const addView = (link: LinkTypeModel, direction: 'in' | 'out') => { + const {inCount, outCount, inexact} = counts.get(link.id) ?? { + inCount: 0, + outCount: 0, + inexact: false, + }; + const count = direction === 'in' ? inCount : outCount; + if (count === 0) { + return; + } + + const postfix = probable ? '-probable' : ''; + const score = scores.get(link.id); + entries.push({ + type: 'link', + key: `${direction}-${link.id}-${postfix}`, + linkType: link, + direction, + count: inexact && count > 0 ? 'some' : count, + probable, + probability: probable && score !== undefined ? score.score : 0, + }); + }; + + for (const link of links) { + addView(link, 'in'); + addView(link, 'out'); + } + + return entries; +} + +function makeLinkTypeComparer( + t: Translation, + language: string, + suggestions?: ConnectionSuggestions +): (a: LinkTypeModel, b: LinkTypeModel) => number { + return (a, b) => { + if (suggestions) { + const {scores} = suggestions; + const aWeight = scores.has(a.id) ? scores.get(a.id)!.score : 0; + const bWeight = scores.has(b.id) ? scores.get(b.id)!.score : 0; + if (aWeight > bWeight) { + return -1; + } else if (aWeight < bWeight) { + return 1; + } + } + + const aText = t.formatLabel(a.label, a.id, language); + const bText = t.formatLabel(b.label, b.id, language); + return aText.localeCompare(bText); + }; +} + +function ConnectionLink(props: { + link: LinkTypeModel; + count: number | 'some'; + direction?: 'in' | 'out'; + filterKey?: string; + onExpandLink: (linkDataChunk: LinkDataChunk) => void; + onMoveToFilter: ((linkDataChunk: LinkDataChunk) => void) | undefined; + probability?: number; + focusProps?: TreeListFocusProps; +}) { + const { + link, filterKey, direction, count, onExpandLink, onMoveToFilter, probability = 0, focusProps, + } = props; + const {model} = useWorkspace(); + const t = useTranslation(); + + const relation = t.formatLabel(link.label, link.id, model.language); + const relationIri = model.locale.formatIri(link.id); + const probabilityPercent = Math.round(probability * 100); + const textLine = highlightSubstring( + relation + (probabilityPercent > 0 ? ` (${probabilityPercent}%)` : ''), + filterKey + ); + const title = ( + direction === 'in' ? t.text('connections_menu.link.source_title', {relation, relationIri}) : + direction === 'out' ? t.text('connections_menu.link.target_title', {relation, relationIri}) : + t.text('connections_menu.link.both_title', {relation, relationIri}) + ); + const navigateTitle = ( + direction === 'in' ? t.text('connections_menu.link.source_navigate_title', {relation, relationIri}) : + direction === 'out' ? t.text('connections_menu.link.target_navigate_title', {relation, relationIri}) : + t.text('connections_menu.link.both_navigate_title', {relation, relationIri}) + ); + + const onExpandLinkClick = () => { + onExpandLink({ + chunkId: generate128BitID(), + linkType: link, + direction, + expectedCount: count, + pageCount: 1, + }); + }; + + const onMoveToFilterClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onMoveToFilter?.({ + chunkId: generate128BitID(), + linkType: link, + direction, + expectedCount: count, + pageCount: 1, + }); + }; + + return ( +
    + + {onMoveToFilter ? ( +
    + ); +} diff --git a/src/widgets/connectionsMenu.tsx b/src/widgets/connectionsMenu/connectionsMenu.tsx similarity index 53% rename from src/widgets/connectionsMenu.tsx rename to src/widgets/connectionsMenu/connectionsMenu.tsx index db91f134..9acc3246 100644 --- a/src/widgets/connectionsMenu.tsx +++ b/src/widgets/connectionsMenu/connectionsMenu.tsx @@ -1,35 +1,38 @@ -import cx from 'clsx'; import * as React from 'react'; +import cx from 'clsx'; -import { EventObserver } from '../coreUtils/events'; -import { useTranslation, type Translation, TranslatedText } from '../coreUtils/i18n'; +import { EventObserver } from '../../coreUtils/events'; +import { useTranslation, type Translation, TranslatedText } from '../../coreUtils/i18n'; -import { ElementModel, ElementIri, LinkTypeIri, LinkTypeModel } from '../data/model'; -import { generate128BitID, makeCaseInsensitiveFilter } from '../data/utils'; +import type { ElementModel, ElementIri, LinkTypeIri, LinkTypeModel } from '../../data/model'; -import { CanvasApi, useCanvas } from '../diagram/canvasApi'; -import { changeLinkTypeVisibility, placeElementsAroundTarget } from '../diagram/commands'; -import { Element, VoidElement } from '../diagram/elements'; -import { getContentFittingBox } from '../diagram/geometry'; -import { DiagramModel } from '../diagram/model'; -import { RenderingLayer } from '../diagram/renderingState'; -import { HtmlSpinner } from '../diagram/spinner'; +import { CanvasApi, useCanvas } from '../../diagram/canvasApi'; +import { changeLinkTypeVisibility, placeElementsAroundTarget } from '../../diagram/commands'; +import { Element, VoidElement } from '../../diagram/elements'; +import { getContentFittingBox } from '../../diagram/geometry'; +import { DiagramModel } from '../../diagram/model'; +import { RenderingLayer } from '../../diagram/renderingState'; -import { BuiltinDialogType } from '../editor/builtinDialogType'; -import { - requestElementData, restoreLinksBetweenElements, getAllPresentEntities, -} from '../editor/dataDiagramModel'; -import { EntityElement, EntityGroup, iterateEntitiesOf } from '../editor/dataElements'; -import { WithFetchStatus } from '../editor/withFetchStatus'; +import { BuiltinDialogType } from '../../editor/builtinDialogType'; +import { requestElementData, restoreLinksBetweenElements } from '../../editor/dataDiagramModel'; +import { EntityElement, EntityGroup, iterateEntitiesOf } from '../../editor/dataElements'; -import { SearchInput, SearchInputStore, useSearchInputStore } from '../widgets/utility/searchInput'; -import type { InstancesSearchCommands } from '../widgets/instancesSearch'; +import { SearchInput, SearchInputStore, useSearchInputStore } from '../utility/searchInput'; -import { type WorkspaceContext, WorkspaceEventKey, useWorkspace } from '../workspace/workspaceContext'; -import { ConnectionsMenuTopic, InstancesSearchTopic } from '../workspace/commandBusTopic'; +import type { InstancesSearchCommands } from '../instancesSearch'; -import { highlightSubstring } from './utility/listElementView'; -import { SearchResults } from './utility/searchResults'; +import { ConnectionsMenuTopic, InstancesSearchTopic } from '../../workspace/commandBusTopic'; +import { + type WorkspaceContext, WorkspaceEventKey, useWorkspace, +} from '../../workspace/workspaceContext'; + +import { + PropertySuggestionHandler, PropertyScore, SortMode, ConnectionsData, ConnectionSuggestions, + ConnectionCount, ObjectsData, LinkDataChunk, ObjectPlacingMode, + CLASS_NAME, LINK_COUNT_PER_PAGE, LoadingSpinner, +} from './menuCommon'; +import { ConnectionsList } from './connectionList'; +import { EntityList } from './entityList'; /** * Props for {@link ConnectionsMenu} component. @@ -82,57 +85,6 @@ export interface ConnectionsMenuCommands { }; } -/** - * Provides smart suggestions when searching by the link type label. - * - * @see {@link ConnectionsMenuProps.suggestProperties} - */ -export type PropertySuggestionHandler = (params: PropertySuggestionParams) => Promise; - -/** - * Parameters for the smart link type suggestion handler. - * - * @see {@link PropertySuggestionHandler} - */ -export interface PropertySuggestionParams { - /** - * Target connected entity IRI. - */ - elementId: string; - /** - * Link type label search token. - */ - token: string; - /** - * A collection of possible link type IRIs. - */ - properties: readonly string[]; - /** - * Current diagram model data language. - */ - lang: string; - /** - * Cancellation signal. - */ - signal: AbortSignal | undefined; -} - -/** - * Result entry for the smart link type suggestion handler. - * - * @see {@link PropertySuggestionHandler} - */ -export interface PropertyScore { - /** - * Link type IRI. - */ - propertyIri: string; - /** - * Suggestion score (higher is more suggested for the top positions). - */ - score: number; -} - /** * Canvas widget component to explore and navigate the graph by adding * connected entities to the diagram. @@ -298,39 +250,6 @@ interface MenuState { readonly objects?: ObjectsData; } -type SortMode = 'alphabet' | 'smart'; - -interface ConnectionsData { - readonly links: ReadonlyArray; - readonly counts: ReadonlyMap; -} - -interface ConnectionCount { - readonly inexact: boolean; - readonly inCount: number; - readonly outCount: number; -} - -interface ObjectsData { - readonly chunk: LinkDataChunk; - readonly elements: ReadonlyArray; -} - -interface LinkDataChunk { - /** - * Random key to check if chunk is different from another - * (i.e. should be re-rendered). - */ - readonly chunkId: string; - readonly linkType: LinkTypeModel; - readonly direction?: 'in' | 'out'; - readonly expectedCount: number | 'some'; - readonly pageCount: number; -} - -const CLASS_NAME = 'reactodia-connections-menu'; -const LINK_COUNT_PER_PAGE = 100; - class ConnectionsMenuInner extends React.Component { private readonly ALL_RELATED_ELEMENTS_LINK: LinkTypeModel; @@ -607,8 +526,10 @@ class ConnectionsMenuInner extends React.Component - {t.text('connections_menu.breadcrumbs_root.label')} + {'\u00A0' + '/' + '\u00A0'} {localizedText} {direction ? `(${direction})` : null} ; @@ -643,7 +564,7 @@ class ConnectionsMenuInner extends React.Component; } else if (objects && panel === 'objects') { return ( - ); } else if (connections && panel === 'connections') { @@ -673,13 +592,12 @@ class ConnectionsMenuInner extends React.Component; } - const commands = workspace.getCommandBus(InstancesSearchTopic); + const commands = getCommandBus(InstancesSearchTopic); const event: InstancesSearchCommands['findCapabilities'] = {capabilities: []}; commands.trigger('findCapabilities', event); return ( ); } else { @@ -847,466 +763,3 @@ class ConnectionsMenuInner extends React.Component; - data: ConnectionsData; - filterKey: string; - sortMode: SortMode; - suggestions: ConnectionSuggestions; - workspace: WorkspaceContext; - translation: Translation; - - allRelatedLink: LinkTypeModel; - onExpandLink: (chunk: LinkDataChunk) => void; - onMoveToFilter: ((chunk: LinkDataChunk) => void) | undefined; - - scrolledListRef: React.RefObject; -} - -interface ConnectionSuggestions { - readonly filterKey: string | null; - readonly scores: ReadonlyMap; -} - -class ConnectionsList extends React.Component { - private isSmartMode(): boolean { - return this.props.sortMode === 'smart' && !this.props.filterKey; - } - - private compareLinks = (a: LinkTypeModel, b: LinkTypeModel) => { - const {workspace: {model}, translation: t} = this.props; - const aText = t.formatLabel(a.label, a.id, model.language); - const bText = t.formatLabel(b.label, b.id, model.language); - return aText.localeCompare(bText); - }; - - private compareLinksByWeight = (a: LinkTypeModel, b: LinkTypeModel) => { - const {workspace: {model}, translation: t, suggestions} = this.props; - const {scores} = suggestions; - const aText = t.formatLabel(a.label, a.id, model.language); - const bText = t.formatLabel(b.label, b.id, model.language); - - const aWeight = scores.has(a.id) ? scores.get(a.id)!.score : 0; - const bWeight = scores.has(b.id) ? scores.get(b.id)!.score : 0; - - return ( - aWeight > bWeight ? -1 : - aWeight < bWeight ? 1 : - aText.localeCompare(bText) - ); - }; - - private getLinks() { - const {workspace: {model}, translation: t, data, filterKey} = this.props; - const textFilter = filterKey ? makeCaseInsensitiveFilter(filterKey) : undefined; - return (data.links || []) - .map(link => model.getLinkType(link.id)?.data ?? link) - .filter(link => { - const text = t.formatLabel(link.label, link.id, model.language); - return !textFilter || textFilter(text); - }) - .sort(this.compareLinks); - } - - private getProbableLinks() { - const {workspace: {model}, data, suggestions} = this.props; - const {scores} = suggestions; - const isSmartMode = this.isSmartMode(); - return (data.links ?? []) - .map(link => model.getLinkType(link.id)?.data ?? link) - .filter(link => { - return scores.has(link.id) && (scores.get(link.id)!.score > 0 || isSmartMode); - }) - .sort(this.compareLinksByWeight); - } - - private getViews = (links: LinkTypeModel[], notSure?: boolean) => { - const {workspace, data, suggestions} = this.props; - const {scores} = suggestions; - - const views: React.ReactElement[] = []; - const addView = (link: LinkTypeModel, direction: 'in' | 'out') => { - const {inCount, outCount, inexact} = data.counts.get(link.id) ?? { - inCount: 0, - outCount: 0, - inexact: false, - }; - const count = direction === 'in' ? inCount : outCount; - if (count === 0) { - return; - } - - const postfix = notSure ? '-probable' : ''; - views.push( - 0 ? 'some' : count} - direction={direction} - filterKey={notSure ? '' : this.props.filterKey} - onMoveToFilter={this.props.onMoveToFilter} - probability={ - scores.has(link.id) && notSure ? scores.get(link.id)!.score : 0 - } - />, - ); - }; - - for (const link of links) { - addView(link, 'in'); - addView(link, 'out'); - } - - return views; - }; - - render() { - const {workspace, translation: t, allRelatedLink, scrolledListRef} = this.props; - const isSmartMode = this.isSmartMode(); - - const links = isSmartMode ? [] : this.getLinks(); - const probableLinks = this.getProbableLinks().filter(link => links.indexOf(link) === -1); - const views = this.getViews(links); - const probableViews = this.getViews(probableLinks, true); - - let viewList: React.ReactElement | React.ReactElement[]; - if (views.length === 0 && probableViews.length === 0) { - viewList = ( - - ); - } else { - viewList = views; - if (views.length > 1 || (isSmartMode && probableViews.length > 1)) { - const countMap = this.props.data.counts; - const {inCount, outCount, inexact} = countMap.get(allRelatedLink.id)!; - const totalCount = inCount + outCount; - viewList = [ - 0 ? 'some' : totalCount} - onMoveToFilter={this.props.onMoveToFilter} - />, -
    , - ].concat(viewList); - } - } - let probablePart = null; - if (probableViews.length !== 0) { - probablePart = [ - isSmartMode ? null : ( -
  • - {t.text('connections_menu.links.suggest_similar')} -
  • - ), - probableViews, - ]; - } - return ( -
      - } - className={cx( - 'reactodia-scrollable', - `${CLASS_NAME}__links-list`, - views.length === 0 && probableViews.length === 0 - ? `${CLASS_NAME}__links-list-empty` : undefined - )}> - {viewList}{probablePart} -
    - ); - } -} - -interface LinkInPopupMenuProps { - link: LinkTypeModel; - count: number | 'some'; - direction?: 'in' | 'out'; - filterKey?: string; - onExpandLink: (linkDataChunk: LinkDataChunk) => void; - onMoveToFilter: ((linkDataChunk: LinkDataChunk) => void) | undefined; - probability?: number; -} - -function LinkInPopupMenu(props: LinkInPopupMenuProps) { - const {link, filterKey, direction, count, onExpandLink, onMoveToFilter, probability = 0} = props; - const {model} = useWorkspace(); - const t = useTranslation(); - - const relation = t.formatLabel(link.label, link.id, model.language); - const relationIri = model.locale.formatIri(link.id); - const probabilityPercent = Math.round(probability * 100); - const textLine = highlightSubstring( - relation + (probabilityPercent > 0 ? ` (${probabilityPercent}%)` : ''), - filterKey - ); - const title = ( - direction === 'in' ? t.text('connections_menu.link.source_title', {relation, relationIri}) : - direction === 'out' ? t.text('connections_menu.link.target_title', {relation, relationIri}) : - t.text('connections_menu.link.both_title', {relation, relationIri}) - ); - const navigateTitle = ( - direction === 'in' ? t.text('connections_menu.link.source_navigate_title', {relation, relationIri}) : - direction === 'out' ? t.text('connections_menu.link.target_navigate_title', {relation, relationIri}) : - t.text('connections_menu.link.both_navigate_title', {relation, relationIri}) - ); - - const onExpandLinkClick = () => { - onExpandLink({ - chunkId: generate128BitID(), - linkType: link, - direction, - expectedCount: count, - pageCount: 1, - }); - }; - - const onMoveToFilterClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onMoveToFilter?.({ - chunkId: generate128BitID(), - linkType: link, - direction, - expectedCount: count, - pageCount: 1, - }); - }; - - return ( -
  • - {direction === 'in' || direction === 'out' ? ( -
    - {direction === 'in' &&
    } - {direction === 'out' &&
    } -
    - ) : null} - -
    {textLine}
    -
    - {count === 'some' ? null : ( - - {count <= LINK_COUNT_PER_PAGE ? count : `${LINK_COUNT_PER_PAGE}+`} - - )} - {onMoveToFilter ? ( -
    - ) : null} -
    -
  • - ); -} - -interface ObjectsPanelProps { - data: ObjectsData; - loading?: boolean; - filterKey?: string; - onPressAddSelected: (selected: ElementModel[], mode: ObjectPlacingMode) => void; - onMoveToFilter: ((linkDataChunk: LinkDataChunk) => void) | undefined; - workspace: WorkspaceContext; - translation: Translation; -} - -type ObjectPlacingMode = 'separately' | 'grouped'; - -interface ObjectsPanelState { - chunkId: string; - selection: ReadonlySet; -} - -class ObjectsPanel extends React.Component { - constructor(props: ObjectsPanelProps) { - super(props); - this.state = ObjectsPanel.makeStateFromProps(props); - } - - static getDerivedStateFromProps( - props: ObjectsPanelProps, - state: ObjectsPanelState | undefined - ): ObjectsPanelState | null { - if (state && state.chunkId === props.data.chunk.chunkId) { - return null; - } - return ObjectsPanel.makeStateFromProps(props); - } - - static makeStateFromProps(props: ObjectsPanelProps): ObjectsPanelState { - return { - chunkId: props.data.chunk.chunkId, - selection: new Set(), - }; - } - - private getFilteredObjects(): ReadonlyArray { - const {workspace: {model}, data, filterKey} = this.props; - if (!filterKey) { - return data.elements; - } - - const textFilter = makeCaseInsensitiveFilter(filterKey); - return data.elements.filter(element => { - const text = model.locale.formatEntityLabel(element, model.language); - return textFilter(text); - }); - } - - private updateSelection = (newSelection: ReadonlySet) => { - this.setState({selection: newSelection}); - }; - - private renderCounter(activeObjCount: number) { - const {data: {chunk, elements}, translation: t} = this.props; - const countString = t.text('connections_menu.entities.counter_label', { - count: activeObjCount, - total: elements.length, - }); - - let extraCountInfo: React.ReactElement | null = null; - if (chunk.expectedCount !== 'some') { - const extraCount = elements.length - Math.min(LINK_COUNT_PER_PAGE, chunk.expectedCount); - const extra = Math.abs(extraCount) > LINK_COUNT_PER_PAGE ? - `${LINK_COUNT_PER_PAGE}+` : Math.abs(extraCount).toString(); - extraCountInfo = ( - 0 - ? t.text('connections_menu.entities.extra_title', {value: extra}) - : t.text('connections_menu.entities.missing_title', {value: extra}) - )}> - {extraCount === 0 ? null : ( - extraCount > 0 - ? t.text('connections_menu.entities.extra_label', {value: extra}) - : t.text('connections_menu.entities.missing_label', {value: extra}) - )} - - ); - } - - return ( -
    - {countString} - {extraCountInfo} -
    - ); - } - - render() { - const { - data, filterKey, onPressAddSelected, onMoveToFilter, - workspace: {model}, - translation: t, - } = this.props; - const {selection} = this.state; - const objects = this.getFilteredObjects(); - - const presentEntities = getAllPresentEntities(model); - const isAllSelected = objects.every(item => - presentEntities.has(item.id) || selection.has(item.id) - ); - - const nonPresented = objects.filter(item => !presentEntities.has(item.id)); - const active = nonPresented.filter(item => selection.has(item.id)); - const selectedItems = active.length > 0 ? active : nonPresented; - - return
    -
    - - - {this.props.loading ? ( -
    - -
    - ) : objects.length === 0 ? ( -
    - {t.text('connections_menu.entities.no_results')} -
    - ) : ( -
    - - {data.chunk.expectedCount !== 'some' && data.chunk.expectedCount > LINK_COUNT_PER_PAGE ? ( - onMoveToFilter ? ( -
    onMoveToFilter(data.chunk)}> - {t.text('connections_menu.entities.truncated_results_expand', { - limit: LINK_COUNT_PER_PAGE, - })} -
    - ) : ( -
    - {t.text('connections_menu.entities.truncated_results', { - limit: LINK_COUNT_PER_PAGE, - })} -
    - ) - ) : null} -
    - )} -
    - - -
    -
    ; - } -} - -function LoadingSpinner(props: { error?: boolean }) { - return ( -
    - -
    - ); -} diff --git a/src/widgets/connectionsMenu/entityList.tsx b/src/widgets/connectionsMenu/entityList.tsx new file mode 100644 index 00000000..c7c9cf4e --- /dev/null +++ b/src/widgets/connectionsMenu/entityList.tsx @@ -0,0 +1,167 @@ +import * as React from 'react'; +import cx from 'clsx'; + +import { useObservedProperty } from '../../coreUtils/hooks'; +import { useTranslation } from '../../coreUtils/i18n'; + +import type { ElementIri, ElementModel } from '../../data/model'; +import { makeCaseInsensitiveFilter } from '../../data/utils'; +import { getAllPresentEntities } from '../../editor/dataDiagramModel'; + +import { SearchResults } from '../utility/searchResults'; + +import { useWorkspace } from '../../workspace/workspaceContext'; + +import { + ObjectsData, LinkDataChunk, ObjectPlacingMode, + CLASS_NAME, LINK_COUNT_PER_PAGE, LoadingSpinner, +} from './menuCommon'; + +export function EntityList(props: { + data: ObjectsData; + isLoading?: boolean; + filterKey?: string; + onPressAddSelected: (selected: ElementModel[], mode: ObjectPlacingMode) => void; + onMoveToFilter: ((linkDataChunk: LinkDataChunk) => void) | undefined; +}) { + const {data, isLoading, filterKey, onPressAddSelected, onMoveToFilter} = props; + const {model} = useWorkspace(); + const t = useTranslation(); + const language = useObservedProperty(model.events, 'changeLanguage', () => model.language); + + const [selection, setSelection] = React.useState>( + () => new Set() + ); + + const objects = React.useMemo(() => { + if (!filterKey) { + return data.elements; + } + const textFilter = makeCaseInsensitiveFilter(filterKey); + return data.elements.filter(element => { + const text = model.locale.formatEntityLabel(element, language); + return textFilter(text); + }); + }, [data.elements, filterKey, language]); + + const presentEntities = getAllPresentEntities(model); + const isAllSelected = objects.every(item => + presentEntities.has(item.id) || selection.has(item.id) + ); + + const nonPresented = objects.filter(item => !presentEntities.has(item.id)); + const active = nonPresented.filter(item => selection.has(item.id)); + const selectedItems = active.length > 0 ? active : nonPresented; + + const countString = t.text('connections_menu.entities.counter_label', { + count: active.length, + total: data.elements.length, + }); + + let extraCountInfo: React.ReactElement | null = null; + if (data.chunk.expectedCount !== 'some') { + const extraCount = + data.elements.length - Math.min(LINK_COUNT_PER_PAGE, data.chunk.expectedCount); + const extra = Math.abs(extraCount) > LINK_COUNT_PER_PAGE ? + `${LINK_COUNT_PER_PAGE}+` : Math.abs(extraCount).toString(); + extraCountInfo = ( + 0 + ? t.text('connections_menu.entities.extra_title', {value: extra}) + : t.text('connections_menu.entities.missing_title', {value: extra}) + )}> + {extraCount === 0 ? null : ( + extraCount > 0 + ? t.text('connections_menu.entities.extra_label', {value: extra}) + : t.text('connections_menu.entities.missing_label', {value: extra}) + )} + + ); + } + + const counter = ( +
    + {countString} + {extraCountInfo} +
    + ); + + return ( +
    +
    + + + {isLoading ? ( +
    + +
    + ) : objects.length === 0 ? ( +
    + {t.text('connections_menu.entities.no_results')} +
    + ) : ( +
    + + {data.chunk.expectedCount !== 'some' && data.chunk.expectedCount > LINK_COUNT_PER_PAGE ? ( + onMoveToFilter ? ( +
    onMoveToFilter(data.chunk)}> + {t.text('connections_menu.entities.truncated_results_expand', { + limit: LINK_COUNT_PER_PAGE, + })} +
    + ) : ( +
    + {t.text('connections_menu.entities.truncated_results', { + limit: LINK_COUNT_PER_PAGE, + })} +
    + ) + ) : null} +
    + )} +
    + + +
    +
    + ); +} diff --git a/src/widgets/connectionsMenu/index.ts b/src/widgets/connectionsMenu/index.ts new file mode 100644 index 00000000..6413107e --- /dev/null +++ b/src/widgets/connectionsMenu/index.ts @@ -0,0 +1,6 @@ +export { + ConnectionsMenu, type ConnectionsMenuProps, type ConnectionsMenuCommands, +} from './connectionsMenu'; +export type { + PropertySuggestionHandler, PropertySuggestionParams, PropertyScore, +} from './menuCommon'; diff --git a/src/widgets/connectionsMenu/menuCommon.tsx b/src/widgets/connectionsMenu/menuCommon.tsx new file mode 100644 index 00000000..d7eea6f8 --- /dev/null +++ b/src/widgets/connectionsMenu/menuCommon.tsx @@ -0,0 +1,104 @@ +import type { ElementModel, LinkTypeIri, LinkTypeModel } from '../../data/model'; + +import { HtmlSpinner } from '../../diagram/spinner'; + +/** + * Provides smart suggestions when searching by the link type label. + * + * @see {@link ConnectionsMenuProps.suggestProperties} + */ +export type PropertySuggestionHandler = (params: PropertySuggestionParams) => Promise; + +/** + * Parameters for the smart link type suggestion handler. + * + * @see {@link PropertySuggestionHandler} + */ +export interface PropertySuggestionParams { + /** + * Target connected entity IRI. + */ + elementId: string; + /** + * Link type label search token. + */ + token: string; + /** + * A collection of possible link type IRIs. + */ + properties: readonly string[]; + /** + * Current diagram model data language. + */ + lang: string; + /** + * Cancellation signal. + */ + signal: AbortSignal | undefined; +} + +/** + * Result entry for the smart link type suggestion handler. + * + * @see {@link PropertySuggestionHandler} + */ +export interface PropertyScore { + /** + * Link type IRI. + */ + propertyIri: string; + /** + * Suggestion score (higher is more suggested for the top positions). + */ + score: number; +} + +export type SortMode = 'alphabet' | 'smart'; + +export interface ConnectionsData { + readonly links: ReadonlyArray; + readonly counts: ReadonlyMap; +} + +export interface ConnectionSuggestions { + readonly filterKey: string | null; + readonly scores: ReadonlyMap; +} + +export interface ConnectionCount { + readonly inexact: boolean; + readonly inCount: number; + readonly outCount: number; +} + +export interface ObjectsData { + readonly chunk: LinkDataChunk; + readonly elements: ReadonlyArray; +} + +export interface LinkDataChunk { + /** + * Random key to check if chunk is different from another + * (i.e. should be re-rendered). + */ + readonly chunkId: string; + readonly linkType: LinkTypeModel; + readonly direction?: 'in' | 'out'; + readonly expectedCount: number | 'some'; + readonly pageCount: number; +} + +export type ObjectPlacingMode = 'separately' | 'grouped'; + +export const CLASS_NAME = 'reactodia-connections-menu'; +export const LINK_COUNT_PER_PAGE = 100; + +export function LoadingSpinner(props: { error?: boolean }) { + return ( +
    + +
    + ); +} diff --git a/src/widgets/editorForms/elementTypeSelector.tsx b/src/widgets/editorForms/elementTypeSelector.tsx index cfd19bdc..64ea3f43 100644 --- a/src/widgets/editorForms/elementTypeSelector.tsx +++ b/src/widgets/editorForms/elementTypeSelector.tsx @@ -3,7 +3,9 @@ import * as React from 'react'; import { mapAbortedToNull } from '../../coreUtils/async'; import { EventObserver } from '../../coreUtils/events'; -import { useObservedProperty } from '../../coreUtils/hooks'; +import { + useEventStore, useFrameDebouncedStore, useObservedProperty, useSyncStore, +} from '../../coreUtils/hooks'; import { useTranslation, type Translation } from '../../coreUtils/i18n'; import { useKeyedSyncStore } from '../../coreUtils/keyedObserver'; @@ -14,13 +16,14 @@ import type { MetadataCreatedEntity } from '../../data/metadataProvider'; import { HtmlSpinner } from '../../diagram/spinner'; -import type { DataDiagramModel } from '../../editor/dataDiagramModel'; +import { type DataDiagramModel, getAllPresentEntities } from '../../editor/dataDiagramModel'; import { EntityElement } from '../../editor/dataElements'; import { subscribeElementTypes } from '../../editor/observedElement'; import { ListElementView } from '../utility/listElementView'; import { NoSearchResults } from '../utility/noSearchResults'; import { SearchInput, SearchInputStore, useSearchInputStore } from '../utility/searchInput'; +import { SearchResults } from '../utility/searchResults'; import { createRequest } from '../instancesSearch'; import { type WorkspaceContext, useWorkspace } from '../../workspace/workspaceContext'; @@ -248,7 +251,7 @@ export class ElementTypeSelectorInner extends React.Component ); } - if (existingElements.length > 0) { - return existingElements.map(element => { - const isAlreadyOnDiagram = !editor.temporaryState.elements.has(element.id) && Boolean( - model.elements.find((el) => el instanceof EntityElement && el.iri === element.id) - ); - const hasAppropriateType = Boolean( - elementTypes && elementTypes.find(type => element.types.indexOf(type) >= 0) - ); - return ( - void this.onSelectExistingItem(model)} - /> - ); - }); - } - return ; + + return ( + void this.onSelectExistingItem(item)} + highlightText={searchStore.value} + /> + ); } private async onSelectExistingItem(data: ElementModel) { @@ -318,7 +312,8 @@ export class ElementTypeSelectorInner extends React.Component 0 ? (
    + aria-label={t.text('visual_authoring.select_entity.results.aria_label')} + tabIndex={-1}> {this.renderExistingElementsList()}
    ) : ( @@ -337,6 +332,72 @@ export class ElementTypeSelectorInner extends React.Component void; + highlightText?: string; +}) { + const {items, requiredElementTypes, selected, onSelect, highlightText} = props; + const {model, editor} = useWorkspace(); + + const selectedIri = selected?.id; + const selection = React.useMemo( + () => new Set(selectedIri === undefined ? undefined : [selectedIri]), + [selectedIri] + ); + + const cellsVersion = useSyncStore( + useFrameDebouncedStore( + useEventStore(model.events, 'changeCells') + ), + () => model.cellsVersion + ); + const isEntityOnDiagram = React.useMemo(() => { + const presentEntities = getAllPresentEntities(model); + return (item: ElementModel) => presentEntities.has(item.id); + }, [cellsVersion]); + + const temporaryState = useObservedProperty( + editor.events, + 'changeTemporaryState', + () => editor.temporaryState + ); + + const isItemDisabled = React.useCallback((item: ElementModel) => { + const hasAppropriateType = + requiredElementTypes && requiredElementTypes.some(type => item.types.includes(type)); + return ( + (isEntityOnDiagram(item) && !temporaryState.elements.has(item.id)) || + !hasAppropriateType + ); + }, [isEntityOnDiagram, temporaryState, requiredElementTypes]); + + return ( + { + if (nextSelection.size === 1) { + const [nextIri] = nextSelection; + const nextItem = items.find(item => item.id === nextIri); + if (nextItem) { + onSelect(nextItem); + } + } + }} + isItemDisabled={isItemDisabled} + highlightText={highlightText} + useDragAndDrop={false} + multiSelection={false} + footer={ + items.length === 0 ? : undefined + } + /> + ); +} + function ElementTypeOptions(props: { elementTypes: readonly ElementTypeIri[]; }) { diff --git a/src/widgets/instancesSearch.tsx b/src/widgets/instancesSearch.tsx index 5a441f6a..f880d6d9 100644 --- a/src/widgets/instancesSearch.tsx +++ b/src/widgets/instancesSearch.tsx @@ -346,7 +346,8 @@ class InstancesSearchInner extends React.Component {/* specify resultId as key to reset scroll position when loaded new search results */}
    + className={`${CLASS_NAME}__rest reactodia-scrollable`} + tabIndex={-1}> , 'onClick'> { /** * Entity data to display. */ @@ -57,6 +57,7 @@ const CLASS_NAME = 'reactodia-list-element-view'; export function ListElementView(props: ListElementViewProps) { const { element, className, highlightText, disabled, selected, onClick, onDragStart, + ref, ...otherProps } = props; const {model, getElementTypeStyle} = useWorkspace(); @@ -88,18 +89,20 @@ export function ListElementView(props: ListElementViewProps) { }; return ( -
  • } + className={combinedClass} + role={otherProps.role ?? 'option'} draggable={!disabled && Boolean(onDragStart)} - title={formatEntityTitle(element, model, t)} - style={providedStyle} + title={otherProps.title ?? formatEntityTitle(element, model, t)} + style={{...otherProps.style, ...providedStyle}} onClick={onItemClick} onDragStart={onDragStart}>
    {highlightSubstring(localizedText, highlightText)}
    -
  • +
    ); } diff --git a/src/widgets/utility/searchResults.tsx b/src/widgets/utility/searchResults.tsx index fc6bc608..164a6043 100644 --- a/src/widgets/utility/searchResults.tsx +++ b/src/widgets/utility/searchResults.tsx @@ -1,16 +1,18 @@ import * as React from 'react'; -import { EventObserver } from '../../coreUtils/events'; -import { Debouncer } from '../../coreUtils/scheduler'; +import { + neverSyncStore, useEventStore, useFrameDebouncedStore, useSyncStore, +} from '../../coreUtils/hooks'; import { ElementModel, ElementIri } from '../../data/model'; - import { getAllPresentEntities } from '../../editor/dataDiagramModel'; -import { EntityElement } from '../../editor/dataElements'; - -import { WorkspaceContext, useWorkspace } from '../../workspace/workspaceContext'; +import { useWorkspace } from '../../workspace/workspaceContext'; import { ListElementView, startDragElements } from './listElementView'; +import { + TreeList, TreeListState, type TreeListModel, type TreeListRenderItem, type TreeListFocusProps, + type TreeListUpPath, +} from './treeList'; const CLASS_NAME = 'reactodia-search-results'; @@ -32,6 +34,12 @@ export interface SearchResultsProps { * Handler to change a selected set of entities. */ onSelectionChanged: (newSelection: ReadonlySet) => void; + /** + * Whether to allow to select an entity from the list. + * + * **Default** is to disable an entity if it has been already placed on the canvas. + */ + isItemDisabled?: (item: ElementModel) => boolean; /** * Text sub-string to highlight in the displayed entities. */ @@ -43,15 +51,14 @@ export interface SearchResultsProps { */ useDragAndDrop?: boolean; /** - * Whether to unselect previously selected item on click and require - * to click with Control/Meta key to select multiple items. + * Whether to allow to select multiple items at the same time. * - * It is also always possible to select a range of items by holding Shift - * when selecting a second item to select all other items in between as well. + * It is possible to select a range of items by holding `Shift` when + * selecting another item to select all other items in-between as well. * - * @default false + * @default true */ - singleSelectOnClick?: boolean; + multiSelection?: boolean; /** * Additional components to render after the result items. */ @@ -64,245 +71,210 @@ export interface SearchResultsProps { * @category Components */ export function SearchResults(props: SearchResultsProps) { - const workspace = useWorkspace(); - return ( - + const { + items, selection, onSelectionChanged, isItemDisabled, highlightText, + useDragAndDrop = true, multiSelection = true, footer, + } = props; + + const renderItem = React.useCallback>( + ({item, path, focusProps, selected}) => ( + + ), + [] ); -} - -interface SearchResultsInnerProps extends SearchResultsProps { - workspace: WorkspaceContext; -} - -const DEFAULT_USE_DRAG_AND_DROP = true; - -const enum Direction { Up, Down } - -class SearchResultsInner extends React.Component { - private readonly listener = new EventObserver(); - private readonly delayedChangeCells = new Debouncer(); - - private root: HTMLElement | undefined | null; - - private startSelection = 0; - private endSelection = 0; - - render(): React.ReactElement { - const {items, footer, workspace: {model}} = this.props; - const presentOnDiagram = getAllPresentEntities(model); - return ( -
      - {items.map(item => this.renderResultItem(item, presentOnDiagram))} - {footer} -
    - ); - } - - private onRootMount = (root: HTMLElement | null) => { - this.root = root; - }; - - private renderResultItem(model: ElementModel, presentOnDiagram: ReadonlySet) { - const {useDragAndDrop = DEFAULT_USE_DRAG_AND_DROP} = this.props; - const canBeSelected = this.canBeSelected(model, presentOnDiagram); - return ( - { - const {selection} = this.props; - const iris: ElementIri[] = []; - selection.forEach(iri => iris.push(iri)); - if (!selection.has(model.id)) { - iris.push(model.id); + const rootProps = React.useMemo((): React.HTMLProps => ({ + className: `${CLASS_NAME}__root`, + role: 'list', + 'aria-multiselectable': true, + }), []); + const forestProps = React.useMemo((): React.HTMLProps => ({}), []); + const itemProps = React.useMemo((): React.HTMLProps => ({ + className: `${CLASS_NAME}__item`, + role: 'listitem', + }), []); + + const computeIsItemDisabled = useIsItemDisabledWithDefault(isItemDisabled); + const extendedItems = React.useMemo(() => items.map((data): ElementItem => ({ + data, + active: !computeIsItemDisabled(data), + })), [items, computeIsItemDisabled]); + + const latestItems = React.useRef(items); + React.useEffect(() => { + latestItems.current = items; + }); + const lastSelected = React.useRef(); + + const searchResultsContext = React.useMemo( + (): SearchResultsContext => ({ + highlightText, + useDragAndDrop, + selection, + onSetSelected: (item, select, e) => { + if (select) { + if (multiSelection && e.shiftKey && lastSelected.current) { + const lastIri = lastSelected.current.id; + const lastIndex = latestItems.current + .findIndex(entity => entity.id === lastIri); + const currentIndex = latestItems.current + .findIndex(entity => entity.id === item.data.id); + if (lastIndex >= 0 && currentIndex >= 0) { + const nextSelection = new Set(selection); + const endIndex = Math.max(lastIndex, currentIndex); + for (let i = Math.min(lastIndex, currentIndex); i <= endIndex; i++) { + nextSelection.add(latestItems.current[i].id); + } + onSelectionChanged(nextSelection); + } + } else if (!selection.has(item.data.id)) { + const nextSelection = new Set(multiSelection ? selection : undefined); + nextSelection.add(item.data.id); + onSelectionChanged(nextSelection); } - return startDragElements(e, iris); - } : undefined} - /> - ); - } - - componentDidMount() { - const {workspace: {model}} = this.props; - this.listener.listen(model.events, 'changeCells', () => { - this.delayedChangeCells.call(this.onChangeCells); - }); - } - - private onChangeCells = () => { - const {items, selection, workspace: {model}} = this.props; - if (selection.size === 0) { - if (items.length > 0) { - // redraw "already on diagram" state - this.forceUpdate(); - } - } else { - const newSelection = new Set(selection); - for (const element of model.elements) { - if (element instanceof EntityElement && selection.has(element.iri)) { - newSelection.delete(element.iri); - } - } - this.updateSelection(newSelection); - } - }; - - componentWillUnmount() { - this.removeKeyListener(); - this.listener.stopListening(); - this.delayedChangeCells.dispose(); - } - - private updateSelection(selection: ReadonlySet) { - const {onSelectionChanged} = this.props; - onSelectionChanged(selection); - } - - private addKeyListener = () => { - document.addEventListener('keydown', this.onKeyDown); - }; - - private removeKeyListener = () => { - document.removeEventListener('keydown', this.onKeyDown); - }; - - private onKeyDown = (event: KeyboardEvent) => { - const {items} = this.props; - const isPressedUp = event.keyCode === 38 || event.which === 38; - const isPressDown = event.keyCode === 40 || event.which === 40; - - if (isPressedUp || isPressDown) { - if (event.shiftKey) { // select range - if (isPressedUp) { - this.endSelection = this.getNextIndex(this.endSelection, Direction.Up); - } else if (isPressDown) { - this.endSelection = this.getNextIndex(this.endSelection, Direction.Down); - } - const startIndex = Math.min(this.startSelection, this.endSelection); - const finishIndex = Math.max(this.startSelection, this.endSelection); - const selection = this.selectRange(startIndex, finishIndex); - - this.updateSelection(selection); - this.focusOn(this.endSelection); - } else { // change focus - const startIndex = Math.min(this.startSelection, this.endSelection); - const finishIndex = Math.max(this.startSelection, this.endSelection); - - if (isPressedUp) { - this.startSelection = this.getNextIndex(startIndex, Direction.Up); - } else if (isPressDown) { - this.startSelection = this.getNextIndex(finishIndex, Direction.Down); - } - this.endSelection = this.startSelection; - - const focusElement = items[this.startSelection]; - const newSelection = new Set(); - newSelection.add(focusElement.id); - - this.updateSelection(newSelection); - this.focusOn(this.startSelection); - } - } - event.preventDefault(); - }; - - private onItemClick = (event: React.MouseEvent, model: ElementModel) => { - event.preventDefault(); - - const {items, selection, onSelectionChanged, singleSelectOnClick} = this.props; - const modelIndex = items.indexOf(model); - - let newSelection: Set; - - if (event.shiftKey && this.startSelection !== -1) { // select range - const start = Math.min(this.startSelection, modelIndex); - const end = Math.max(this.startSelection, modelIndex); - newSelection = this.selectRange(start, end); - } else { - this.endSelection = this.startSelection = modelIndex; - const ctrlKey = event.ctrlKey || event.metaKey; - - if (!singleSelectOnClick || ctrlKey) { - // select/deselect - newSelection = new Set(selection); - if (selection.has(model.id)) { - newSelection.delete(model.id); + lastSelected.current = item.data; } else { - newSelection.add(model.id); + if (selection.has(item.data.id)) { + const nextSelection = new Set(selection); + nextSelection.delete(item.data.id); + onSelectionChanged(nextSelection); + } } - } else { - // single click - newSelection = new Set(); - newSelection.add(model.id); } - } - - onSelectionChanged(newSelection); - }; + }), + [ + highlightText, + useDragAndDrop, + selection, + onSelectionChanged, + multiSelection, + ] + ); - private selectRange(start: number, end: number): Set { - const {items, workspace: {model}} = this.props; - const presentOnDiagram = getAllPresentEntities(model); - const selection = new Set(); - for (let i = start; i <= end; i++) { - const selectedModel = items[i]; - if (this.canBeSelected(selectedModel, presentOnDiagram)) { - selection.add(selectedModel.id); - } + const selected = React.useMemo((): TreeListState | undefined => { + if (selection.size === 0) { + return undefined; } - return selection; - } + return new TreeListState( + new Map(Array.from(selection, iri => [iri, {value: true}])) + ); + }, [selection]); - private getNextIndex(startIndex: number, direction: Direction) { - const {items, workspace: {model}} = this.props; - if (items.length === 0) { - return startIndex; - } - const presentOnDiagram = getAllPresentEntities(model); - const indexDelta = direction === Direction.Up ? -1 : 1; - for (let step = 1; step < items.length; step++) { - let nextIndex = startIndex + step * indexDelta; - if (nextIndex < 0) { nextIndex += items.length; } - if (nextIndex >= items.length) { nextIndex -= items.length; } - if (this.canBeSelected(items[nextIndex], presentOnDiagram)) { - return nextIndex; + React.useEffect(() => { + const leftovers = new Set(selection); + for (const item of extendedItems) { + if (item.active) { + leftovers.delete(item.data.id); } } - return startIndex; - } + if (leftovers.size > 0) { + onSelectionChanged(new Set( + Array.from(selection).filter(iri => !leftovers.has(iri)) + )); + } + }, [computeIsItemDisabled]); - private canBeSelected(item: ElementModel, presentOnDiagram: ReadonlySet) { - const {useDragAndDrop = DEFAULT_USE_DRAG_AND_DROP} = this.props; - return !useDragAndDrop || !presentOnDiagram.has(item.id); - } + return ( + +
    + + {footer} +
    +
    + ); +} - private focusOn(index: number) { - const scrollableContainer = this.root!.parentElement!; +function useIsItemDisabledWithDefault( + isItemDisabled: ((item: ElementModel) => boolean) | undefined +): (item: ElementModel) => boolean { + const {model} = useWorkspace(); + const changeCellsStore = useFrameDebouncedStore( + useEventStore(model.events, 'changeCells') + ); + const cellsVersion = useSyncStore( + isItemDisabled ? neverSyncStore() : changeCellsStore, + () => model.cellsVersion + ); + return React.useMemo(() => { + if (isItemDisabled) { + return isItemDisabled; + } + const presentEntities = getAllPresentEntities(model); + return (item: ElementModel) => presentEntities.has(item.id); + }, [isItemDisabled, cellsVersion]); +} - const containerBounds = scrollableContainer.getBoundingClientRect(); +interface ElementItem { + readonly data: ElementModel; + readonly active: boolean; +} - const item = this.root!.children.item(index) as HTMLElement; - const itemBounds = item.getBoundingClientRect(); - const itemTop = itemBounds.top - containerBounds.top; - const itemBottom = itemBounds.bottom - containerBounds.top; +const SearchResultsModel: TreeListModel = { + getKey: item => item.data.id, + getChildren: item => undefined, + getDefaultSelected: (item, selected) => undefined, + isActive: item => item.active, +}; + +interface SearchResultsContext { + readonly highlightText: string | undefined; + readonly useDragAndDrop: boolean; + readonly selection: ReadonlySet; + readonly onSetSelected: ( + item: ElementItem, + select: boolean, + e: React.MouseEvent | React.KeyboardEvent + ) => void; +} - if (itemTop < 0) { - scrollableContainer.scrollTop += itemTop; - } else if (itemBottom > containerBounds.height) { - scrollableContainer.scrollTop += (itemBottom - containerBounds.height); - } +const SearchResultsContext = React.createContext(null); - item.focus(); +function useSearchResultsContext(): SearchResultsContext { + const context = React.useContext(SearchResultsContext); + if (!context) { + throw new Error('Reactodia: missing search results context'); } + return context; +} + +function ResultItem(props: { + item: ElementItem; + path: TreeListUpPath; + focusProps: TreeListFocusProps; + selected: boolean | undefined; +}) { + const {item, focusProps, selected} = props; + const { + highlightText, useDragAndDrop, selection, onSetSelected, + } = useSearchResultsContext(); + return ( + onSetSelected(item, !selected, e) : undefined} + onKeyDown={e => { + if (item.active && e.key === ' ') { + e.preventDefault(); + onSetSelected(item, !selected, e); + } + }} + onDragStart={useDragAndDrop ? e => { + const iris: ElementIri[] = []; + selection.forEach(iri => iris.push(iri)); + if (!selection.has(item.data.id)) { + iris.push(item.data.id); + } + return startDragElements(e, iris); + } : undefined} + /> + ); } diff --git a/src/widgets/utility/treeList.tsx b/src/widgets/utility/treeList.tsx new file mode 100644 index 00000000..d04f6eab --- /dev/null +++ b/src/widgets/utility/treeList.tsx @@ -0,0 +1,460 @@ +import * as React from 'react'; + +import { findNextWithin, findPreviousWithin } from '../../coreUtils/dom'; + +export interface TreeListProps { + model: TreeListModel; + items: readonly T[]; + renderItem: TreeListRenderItem; + expanded?: TreeListState; + /** + * @default false + */ + defaultExpanded?: boolean; + onSetExpanded?: (item: T, path: TreeListDownPath, expand: boolean) => void; + selected?: TreeListState; + /** + * @default undefined + */ + defaultSelected?: S; + rootProps: React.HTMLProps; + forestProps: React.HTMLProps; + itemProps: React.HTMLProps; +} + +export interface TreeListModel { + readonly getKey: (item: T) => string; + readonly getChildren: (item: T) => readonly T[] | undefined; + readonly getDefaultSelected: (item: T, selected: S | undefined) => S | undefined; + readonly isActive: (item: T) => boolean; +} + +export type TreeListRenderItem = (props: { + item: T; + path: TreeListUpPath; + focusProps: TreeListFocusProps; + expanded: boolean; + selected: S | undefined; +}) => React.ReactElement | null; + +export interface TreeListFocusProps { + tabIndex: number; + 'data-tree-focusable': true; +}; + +export function TreeList(props: TreeListProps) { + const { + model, items, renderItem, expanded, defaultExpanded, onSetExpanded, + selected, defaultSelected, rootProps, forestProps, itemProps, + } = props; + + const getTotalChildCount = React.useMemo(() => makeGetTotalChildCount(model), [model]); + const treeContext = React.useMemo((): TreeListContext => ({ + model, + getTotalChildCount, + renderItem, + forestProps, + itemProps, + }), [model, getTotalChildCount, renderItem, forestProps, itemProps]); + + const rootRef = React.useRef(null); + const [focusIndex, setFocusIndex] = React.useState(0); + const tryFocusOnItemElement = (target: Element | undefined) => { + if (target) { + const focusable = target.querySelector('[data-tree-focusable]'); + if (focusable instanceof HTMLElement) { + focusable.focus(); + } + const targetIndex = Number(target.getAttribute('data-tree-index')); + if (Number.isFinite(targetIndex)) { + setFocusIndex(targetIndex); + } + } + }; + + React.useEffect(() => { + setFocusIndex(previousIndex => { + const previousItem = findItemAtIndex( + model, getTotalChildCount, items, 0, previousIndex + ); + if (!(previousItem && model.isActive(previousItem[0]))) { + const found = findItem(model, items, item => model.isActive(item)); + if (found) { + const [nextItem, nextIndex] = found; + return nextIndex; + } + } + return previousIndex; + }); + }, [model, getTotalChildCount, items]); + + return ( + { + const current = findTreeIndexedAt(e.target, e.currentTarget); + if (current) { + const currentIndex = Number(current.getAttribute('data-tree-index')); + if (Number.isFinite(currentIndex)) { + setFocusIndex(currentIndex); + } + } + }, + onKeyDown: e => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const current = findTreeIndexedAt(e.target, e.currentTarget); + if (current) { + const next = e.key === 'ArrowUp' + ? findPreviousWithin(current, e.currentTarget, isActiveIndexedElement) + : findNextWithin(current, e.currentTarget, isActiveIndexedElement); + tryFocusOnItemElement(next); + } + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + if (onSetExpanded) { + e.preventDefault(); + const current = findTreeIndexedAt(e.target, e.currentTarget); + if (!current) { + return; + } + const currentIndex = Number(current.getAttribute('data-tree-index')); + if (Number.isFinite(currentIndex)) { + if (e.key === 'ArrowLeft' && current.getAttribute('aria-expanded') === 'false') { + // Focus on parent item + const parent = current.parentElement + ? findTreeIndexedAt(current.parentElement, e.currentTarget) + : undefined; + tryFocusOnItemElement(parent); + } else { + // Expand or collapse current item + const found = findItemAtIndex( + model, getTotalChildCount, items, 0, currentIndex + ); + if (found) { + const [target, path] = found; + const expand = e.key === 'ArrowRight'; + onSetExpanded(target, path, expand); + } + } + } + } + } + }, + }} + /> + ); +} + +interface TreeListContext { + readonly model: TreeListModel; + readonly getTotalChildCount: (item: T) => number; + readonly renderItem: TreeListRenderItem; + readonly forestProps: React.HTMLProps; + readonly itemProps: React.HTMLProps; +} + +function Forest(props: { + treeContext: TreeListContext; + items: readonly T[]; + index: number; + parentPath?: TreeListUpPath; + focusIndex: number; + expanded: TreeListState | undefined; + defaultExpanded: boolean; + selected: TreeListState | undefined; + defaultSelected: S | undefined; + rootProps?: React.HTMLProps; +}) { + const { + treeContext, items, index, parentPath, focusIndex, expanded, defaultExpanded, + selected, defaultSelected, rootProps, + } = props; + const {model, getTotalChildCount, forestProps} = treeContext; + const targetProps = rootProps ?? forestProps; + let nextIndex = index; + return ( +
      + {items.map(item => { + const itemIndex = nextIndex; + nextIndex += 1 + getTotalChildCount(item); + const path: TreeListUpPath = { + parent: parentPath, + key: model.getKey(item), + }; + return ( + + ); + })} +
    + ); +} + +function Item(props: { + treeContext: TreeListContext; + item: T; + index: number; + path: TreeListUpPath; + focusIndex: number; + expanded: TreeListItemState | undefined; + defaultExpanded: boolean; + selected: TreeListItemState | undefined; + defaultSelected: S | undefined; +}) { + const { + treeContext, item, index, path, focusIndex, expanded, defaultExpanded, + selected, defaultSelected, + } = props; + const {model, renderItem, itemProps} = treeContext; + + const leafExpanded = expanded?.value ?? defaultExpanded; + const leafSelected = selected?.value ?? defaultSelected; + const children = model.getChildren(item); + + return ( +
  • + {renderItem({ + item, + path, + focusProps: { + tabIndex: index === focusIndex ? 0 : -1, + 'data-tree-focusable': true, + }, + expanded: leafExpanded, + selected: leafSelected, + })} + {leafExpanded && children && children.length > 0 ? ( + + ) : null} +
  • + ); +} + +export interface TreeListUpPath { + readonly parent: TreeListUpPath | undefined; + readonly key: string; +} + +export interface TreeListDownPath { + readonly child: TreeListDownPath | undefined; + readonly key: string; +} + +export class TreeListState { + constructor( + private readonly states = new Map>() + ) {} + + get(key: string): TreeListItemState | undefined { + return this.states.get(key); + } + + setAt( + path: TreeListDownPath, + updater: (previous: S | undefined) => S | undefined + ): TreeListState { + const itemState = this.states.get(path.key); + const nextState = TreeListState.setAtItem( + itemState, path.child, updater + ); + if (nextState === itemState) { + return this; + } + const nextStates = new Map(this.states); + if (nextState) { + nextStates.set(path.key, nextState); + } else { + nextStates.delete(path.key); + } + return new TreeListState(nextStates); + } + + private static setAtItem( + itemState: TreeListItemState | undefined, + path: TreeListDownPath | undefined, + updater: (previous: S | undefined) => S | undefined + ): TreeListItemState | undefined { + if (path) { + if (itemState && itemState.level) { + const nextLevel = itemState.level.setAt(path, updater); + return nextLevel === itemState.level + ? itemState : {...itemState, level: nextLevel}; + } else { + const nextItem = TreeListState.setAtItem( + undefined, path.child, updater + ); + if (!nextItem || nextItem === itemState) { + return nextItem; + } + return { + value: itemState?.value, + level: new TreeListState(new Map([[path.key, nextItem]])), + }; + } + } + + const nextValue = updater(itemState?.value); + if (nextValue === itemState?.value) { + return itemState; + } + + return nextValue === undefined && itemState?.level === undefined ? undefined : { + ...itemState, + value: nextValue, + }; + } +} + +export interface TreeListItemState { + readonly value: S | undefined; + readonly level?: TreeListState | undefined; +} + +export function treeListPathToDown(upPath: TreeListUpPath): TreeListDownPath { + let current = upPath.parent; + let downward: TreeListDownPath = { + key: upPath.key, + child: undefined, + }; + while (current) { + downward = { + key: current.key, + child: downward, + }; + current = current.parent; + } + return downward; +} + +function makeGetTotalChildCount( + model: TreeListModel +): (item: T) => number { + const totalChildCount = new WeakMap(); + const getTotalChildCount = (item: T): number => { + const children = model.getChildren(item); + if (!children || children.length === 0) { + return 0; + } + let count = totalChildCount.get(item); + if (count === undefined) { + count = children.reduce( + (acc, child) => acc + 1 + getTotalChildCount(child), + 0 + ); + totalChildCount.set(item, count); + } + return count; + }; + return getTotalChildCount; +} + +function findItem( + model: TreeListModel, + items: readonly T[], + isMatch: (item: T) => boolean +): [T, number] | undefined { + const stack: Array<[readonly T[], number]> = [[items, 0]]; + let nextIndex = 0; + while (true) { + const frame = stack.pop(); + if (!frame) { + break; + } + const [children, startAt] = frame; + for (let i = startAt; i < children.length; i++) { + const child = children[i]; + if (isMatch(child)) { + return [child, nextIndex]; + } + nextIndex++; + const nested = model.getChildren(child); + if (nested && nested.length > 0) { + frame[1] = i + 1; + stack.push(frame); + stack.push([nested, 0]); + break; + } + } + } + return undefined; +} + +function findItemAtIndex( + model: TreeListModel, + getTotalChildCount: (item: T) => number, + items: readonly T[], + firstIndex: number, + targetIndex: number +): [T, TreeListDownPath] | undefined { + let current = firstIndex; + for (const item of items) { + if (current === targetIndex) { + return [item, {key: model.getKey(item), child: undefined}]; + } + current++; + const count = getTotalChildCount(item); + if (targetIndex < current + count) { + const children = model.getChildren(item); + if (!children) { + return undefined; + } + const found = findItemAtIndex(model, getTotalChildCount, children, current, targetIndex); + if (found) { + const [target, child] = found; + return [target, {key: model.getKey(item), child}]; + } + return undefined; + } + current += count; + } + return undefined; +} + +function findTreeIndexedAt(target: EventTarget, parent: HTMLElement): HTMLElement | undefined { + if (!(target instanceof HTMLElement)) { + return undefined; + } + let current: HTMLElement | null = target; + while (current && current !== parent) { + if (current.hasAttribute('data-tree-index')) { + return current; + } + current = current.parentElement; + } + return undefined; +} + +function isActiveIndexedElement(element: Element): boolean { + return element.hasAttribute('data-tree-index') && element.hasAttribute('data-tree-active'); +} diff --git a/styles/templates/_standardEntity.scss b/styles/templates/_standardEntity.scss index c8f6fe64..151c3e47 100644 --- a/styles/templates/_standardEntity.scss +++ b/styles/templates/_standardEntity.scss @@ -284,8 +284,10 @@ $standard-new-entity-stripe: theme.$element-background-color; transition: opacity 200ms 0ms; } + &:hover &__ungroup-one-button, - &:focus &__ungroup-one-button { + &:focus &__ungroup-one-button, + .reactodia-overlaid-element--only-selected &__ungroup-one-button { opacity: 1; } diff --git a/styles/utility/_searchResults.scss b/styles/utility/_searchResults.scss index 3cd5186b..d871506b 100644 --- a/styles/utility/_searchResults.scss +++ b/styles/utility/_searchResults.scss @@ -1,5 +1,11 @@ .reactodia-search-results { - margin: 0; - padding: 0; - outline: none; + &__root { + margin: 0; + padding: 0; + } + + &__item { + display: contents; + list-style: none; + } } diff --git a/styles/widgets/_classTree.scss b/styles/widgets/_classTree.scss index 6bc9a596..8973d21f 100644 --- a/styles/widgets/_classTree.scss +++ b/styles/widgets/_classTree.scss @@ -41,6 +41,7 @@ } .reactodia-class-tree-item { + list-style: none; margin: 1px 0; &__row { @@ -108,8 +109,14 @@ margin-left: 5px; } + &__root { + margin: 0; + padding: 0; + } + &__children { - margin-left: 20px; + margin: 0 0 0 20px; + padding: 0; } &__toggle, diff --git a/styles/widgets/_connectionsMenu.scss b/styles/widgets/_connectionsMenu.scss index 96653dcb..1af11276 100644 --- a/styles/widgets/_connectionsMenu.scss +++ b/styles/widgets/_connectionsMenu.scss @@ -22,6 +22,12 @@ $no-results-color: theme.$color-emphasis-400; color: theme.$color-primary; cursor: pointer; text-decoration: none; + border: none; + background: none; + font-family: unset; + font-size: unset; + padding: 0; + font-weight: unset; &:hover { text-decoration: underline; @@ -83,13 +89,22 @@ $no-results-color: theme.$color-emphasis-400; overflow-y: auto; border-top: 1px solid theme.$border-color-base; flex-grow: 1; - margin: 0; } &__links-list-empty { display: flex; align-items: center; } + + &__links-root { + list-style-type: none; + margin: 0; + padding: 0; + } + + &__links-item { + display: contents; + } &__links-no-results { width: 100%; @@ -113,10 +128,8 @@ $no-results-color: theme.$color-emphasis-400; } &__link { + position: relative; display: flex; - overflow: hidden; - padding: 0 5px 0 0; - align-items: center; margin-bottom: 4px; background: theme.$color-emphasis-200; border-radius: theme.$border-radius-s; @@ -126,6 +139,22 @@ $no-results-color: theme.$color-emphasis-400; } } + &__link-button { + /* Unset