diff --git a/packages/libs/components/package.json b/packages/libs/components/package.json index c8d6c7cdf3..7dd2e9b92d 100755 --- a/packages/libs/components/package.json +++ b/packages/libs/components/package.json @@ -46,7 +46,7 @@ "react-spring": "^9.7.1", "react-transition-group": "^4.4.1", "shape2geohash": "^1.2.5", - "tidytree": "github:d-callan/TidyTree" + "tidytree": "https://github.com/d-callan/TidyTree.git#commit=9063e2df3d93c72743702a6d8f43169a1461e5b0" }, "files": [ "lib", diff --git a/packages/libs/components/src/components/tidytree/HorizontalDendrogram.tsx b/packages/libs/components/src/components/tidytree/HorizontalDendrogram.tsx index 54ccefc3fc..f5ffb2e646 100644 --- a/packages/libs/components/src/components/tidytree/HorizontalDendrogram.tsx +++ b/packages/libs/components/src/components/tidytree/HorizontalDendrogram.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef } from 'react'; +import { CSSProperties, useEffect, useLayoutEffect, useRef } from 'react'; import { TidyTree as TidyTreeJS } from 'tidytree'; export interface HorizontalDendrogramProps { @@ -19,6 +19,7 @@ export interface HorizontalDendrogramProps { for now just default to all zero margins (left-most edges */ margin?: [number, number, number, number]; + interactive?: boolean; }; /// The remaining props are handled with a redraw: /// @@ -31,10 +32,21 @@ export interface HorizontalDendrogramProps { * width of tree in pixels */ width: number; + /** + * hopefully temporary prop that we can get rid of when we understand the + * horizontal layout behaviour of the tree (with respect to number of nodes) + * which will come with testing with more examples. Defaults to 1.0 + * update: possibly wasn't needed in the end! + */ + hStretch?: number; /** * number of pixels height taken per leaf */ rowHeight: number; + /** + * CSS styles for the container div + */ + containerStyles?: CSSProperties; /** * which leaf nodes to highlight */ @@ -43,6 +55,10 @@ export interface HorizontalDendrogramProps { * highlight whole subtrees ('monophyletic') or just leaves ('none') */ highlightMode?: 'monophyletic' | 'none'; + /** + * highlight color (optional - default is tidytree's yellow/orange) + */ + highlightColor?: string; } /** @@ -57,9 +73,12 @@ export function HorizontalDendrogram({ leafCount, rowHeight, width, - options: { ruler = false, margin = [0, 0, 0, 0] }, + options: { ruler = false, margin = [0, 0, 0, 0], interactive = true }, highlightedNodeIds, highlightMode, + highlightColor, + hStretch = 1.0, + containerStyles, }: HorizontalDendrogramProps) { const containerRef = useRef(null); const tidyTreeRef = useRef(); @@ -80,13 +99,15 @@ export function HorizontalDendrogram({ equidistantLeaves: true, ruler, margin, + hStretch, animation: 0, // it's naff and it reveals edge lengths/weights momentarily + interactive, }); tidyTreeRef.current = instance; return function cleanup() { instance.destroy(); }; - }, [data, ruler, margin]); + }, [data, ruler, margin, hStretch, interactive, containerRef]); // redraw when the container size changes // useLayoutEffect ensures that the redraw is not called for brand new TidyTreeJS objects @@ -106,12 +127,15 @@ export function HorizontalDendrogram({ tidyTreeRef.current.setColorOptions({ nodeColorMode: 'predicate', branchColorMode: highlightMode ?? 'none', + highlightColor: highlightColor, leavesOnly: true, predicate: (node) => highlightedNodeIds.includes(node.__data__.data.id), + defaultBranchColor: '#333', }); // no redraw needed, setColorOptions does it } - }, [highlightedNodeIds, highlightMode, tidyTreeRef]); + }, [highlightedNodeIds, highlightMode, tidyTreeRef, data]); + // `data` not used in effect but needed to trigger recoloring const containerHeight = leafCount * rowHeight; return ( @@ -119,6 +143,7 @@ export function HorizontalDendrogram({ style={{ width: width + 'px', height: containerHeight + 'px', + ...containerStyles, }} ref={containerRef} /> diff --git a/packages/libs/components/src/components/tidytree/TreeTable.scss b/packages/libs/components/src/components/tidytree/TreeTable.scss new file mode 100644 index 0000000000..a0db3683bf --- /dev/null +++ b/packages/libs/components/src/components/tidytree/TreeTable.scss @@ -0,0 +1,6 @@ +.TreeTable { + --tree-table-row-height: 1em; + tr { + height: var(--tree-table-row-height); + } +} diff --git a/packages/libs/components/src/components/tidytree/TreeTable.tsx b/packages/libs/components/src/components/tidytree/TreeTable.tsx index 35eb2fc55f..86aaece058 100644 --- a/packages/libs/components/src/components/tidytree/TreeTable.tsx +++ b/packages/libs/components/src/components/tidytree/TreeTable.tsx @@ -5,15 +5,20 @@ import { } from '../../components/tidytree/HorizontalDendrogram'; import Mesa from '@veupathdb/coreui/lib/components/Mesa'; import { MesaStateProps } from '../../../../coreui/lib/components/Mesa/types'; -import { css as classNameStyle, cx } from '@emotion/css'; -import { css as globalStyle, Global } from '@emotion/react'; + +import './TreeTable.scss'; export interface TreeTableProps { /** * number of pixels vertical space for each row of the table and tree * (for the table this is a minimum height, so make sure table content doesn't wrap) + * required; no default; minimum seems to be 42; suggested value: 45 */ rowHeight: number; + /** + * number of pixels max width for table columns; defaults to 200 + */ + maxColumnWidth?: number; /** * data and options for the tree */ @@ -25,8 +30,18 @@ export interface TreeTableProps { * data and options for the table */ tableProps: MesaStateProps; + /** + * hide the tree (but keep its horizontal space); default = false + */ + hideTree?: boolean; + /** + * Passed as children to the `Mesa` component + */ + children?: React.ReactNode; } +const margin: [number, number, number, number] = [0, 10, 0, 10]; + /** * main props are * data: string; // Newick format tree @@ -42,50 +57,52 @@ export interface TreeTableProps { * - allow additional Mesa props and options to be passed */ export default function TreeTable(props: TreeTableProps) { - const { rowHeight } = props; - const { rows } = props.tableProps; - - const rowStyleClassName = useMemo( - () => - cx( - classNameStyle({ - height: rowHeight + 'px', - background: 'yellow', - }) - ), - [rowHeight] - ); + const { rowHeight, maxColumnWidth = 200, hideTree = false, children } = props; + const { rows, filteredRows } = props.tableProps; // tableState is just the tableProps with an extra CSS class // to make sure the height is consistent with the tree - const tableState: MesaStateProps = { - ...props.tableProps, - options: { - ...props.tableProps.options, - deriveRowClassName: (_) => rowStyleClassName, - }, - }; - - return ( -
+ const tableState: MesaStateProps = useMemo(() => { + const tree = hideTree ? null : ( - <> - - - -
- ); + ); + + return { + ...props.tableProps, + options: { + ...props.tableProps.options, + className: 'TreeTable', + style: { + '--tree-table-row-height': rowHeight + 'px', + } as React.CSSProperties, + inline: true, + // TO DO: explore event delegation to avoid each tooltip having handlers + // replace inline mode's inline styling with emotion classes + inlineUseTooltips: true, + inlineMaxHeight: `${rowHeight}px`, + inlineMaxWidth: `${maxColumnWidth}px`, + marginContent: tree, + }, + }; + }, [ + filteredRows?.length, + hideTree, + maxColumnWidth, + props.tableProps, + props.treeProps, + rowHeight, + rows.length, + ]); + + // if `hideTree` is used more dynamically than at present + // (for example if the user sorts the table) + // then the table container styling will need + // { marginLeft: hideTree ? props.treeProps.width : 0 } + // to stop the table jumping around horizontally + return ; } diff --git a/packages/libs/components/src/components/tidytree/tidytree.d.ts b/packages/libs/components/src/components/tidytree/tidytree.d.ts index 254f32e428..c2be3d971f 100644 --- a/packages/libs/components/src/components/tidytree/tidytree.d.ts +++ b/packages/libs/components/src/components/tidytree/tidytree.d.ts @@ -15,6 +15,7 @@ interface ColorOptions { branchColorMode: 'monophyletic' | 'none'; highlightColor?: string; defaultNodeColor?: string; + defaultBranchColor?: string; } declare module 'tidytree' { diff --git a/packages/libs/components/src/components/widgets/RadioButtonGroup.tsx b/packages/libs/components/src/components/widgets/RadioButtonGroup.tsx index 7dec8ecb84..3b0fe98827 100755 --- a/packages/libs/components/src/components/widgets/RadioButtonGroup.tsx +++ b/packages/libs/components/src/components/widgets/RadioButtonGroup.tsx @@ -40,6 +40,8 @@ export type RadioButtonGroupProps = { * if a Map is used, then the values are used in Tooltips to explain why each option is disabled */ disabledList?: string[] | Map; + /** capitalize of the labels; default: true */ + capitalizeLabels?: boolean; }; /** @@ -62,6 +64,7 @@ export default function RadioButtonGroup({ margins, itemMarginRight, disabledList, + capitalizeLabels = true, }: RadioButtonGroupProps) { const isDisabled = (option: string) => { if (!disabledList) return false; @@ -141,7 +144,7 @@ export default function RadioButtonGroup({ marginRight: itemMarginRight, fontSize: '0.75em', fontWeight: 400, - textTransform: 'capitalize', + textTransform: capitalizeLabels ? 'capitalize' : undefined, minWidth: minWidth, }} /> diff --git a/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx b/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx index ca5656a597..aa1b948790 100644 --- a/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx +++ b/packages/libs/coreui/src/components/Mesa/Components/MesaTooltip.tsx @@ -63,6 +63,7 @@ const MesaTooltip = ({ enterDelay={showDelay} className={(className ?? '') + (corner ? ` ${corner}` : '')} style={finalStyle} + tabIndex={0} > {children} diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx index 62fa58732d..fa88237b2a 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import Templates from '../Templates'; import { makeClassifier } from '../Utils/Utils'; +import { Tooltip } from '../../../components/info/Tooltip'; + const dataCellClass = makeClassifier('DataCell'); class DataCell extends React.PureComponent { @@ -47,8 +49,14 @@ class DataCell extends React.PureComponent { } } + setTitle(el) { + if (el == null) return; + el.title = el.scrollWidth <= el.clientWidth ? '' : el.innerText; + } + render() { - let { column, inline, options, isChildRow, childRowColSpan } = this.props; + let { column, inline, options, isChildRow, childRowColSpan, rowIndex } = + this.props; let { style, width, className, key } = column; let whiteSpace = !inline @@ -64,15 +72,24 @@ class DataCell extends React.PureComponent { width = width ? { width, maxWidth: width, minWidth: width } : {}; style = Object.assign({}, style, width, whiteSpace); className = dataCellClass() + (className ? ' ' + className : ''); - const children = this.renderContent(); + + const content = this.renderContent(); + const props = { style, - children, + children: content, className, ...(isChildRow ? { colSpan: childRowColSpan } : null), }; - return column.hidden ? null : ; + return column.hidden ? null : ( + this.setTitle(e.target)} + onMouseLeave={() => this.setTitle()} + key={key + '_' + rowIndex} + {...props} + /> + ); } } diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx index d53ec90a5d..6382798461 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataRow.jsx @@ -30,22 +30,22 @@ class DataRow extends React.PureComponent { expandRow() { const { options } = this.props; - if (!options.inline) return; + if (!options.inline || options.inlineUseTooltips) return; this.setState({ expanded: true }); } collapseRow() { const { options } = this.props; - if (!options.inline) return; + if (!options.inline || options.inlineUseTooltips) return; this.setState({ expanded: false }); } handleRowClick() { const { row, rowIndex, options } = this.props; - const { inline, onRowClick } = options; + const { inline, onRowClick, inlineUseTooltips } = options; if (!inline && !onRowClick) return; - - if (inline) this.setState({ expanded: !this.state.expanded }); + if (inline && !inlineUseTooltips) + this.setState({ expanded: !this.state.expanded }); if (typeof onRowClick === 'function') onRowClick(row, rowIndex); } diff --git a/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx b/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx index 95123e029a..799682ad59 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/DataTable.jsx @@ -16,7 +16,6 @@ class DataTable extends React.Component { super(props); this.widthCache = {}; this.state = { dynamicWidths: null, tableWrapperWidth: null }; - this.renderPlainTable = this.renderPlainTable.bind(this); this.renderStickyTable = this.renderStickyTable.bind(this); this.componentDidMount = this.componentDidMount.bind(this); this.getInnerCellWidth = this.getInnerCellWidth.bind(this); @@ -201,10 +200,8 @@ class DataTable extends React.Component { columns: stickyColumns, dynamicWidths, }); - const bodyWrapperStyle = { - maxHeight: options ? options.tableBodyMaxHeight : null, - }; const wrapperStyle = { + maxHeight: options ? options.tableBodyMaxHeight : null, minWidth: dynamicWidths ? combineWidths(columns.map(({ width }) => width)) : null, @@ -225,66 +222,49 @@ class DataTable extends React.Component { }; return (
(this.mainRef = node)} className="MesaComponent"> -
-
-
(this.headerNode = node)} - className={dataTableClass('Header')} - > - - -
-
-
(this.bodyNode = node)} - className={dataTableClass('Body')} - onScroll={this.handleTableBodyScroll} +
+
(this.headerNode = node)} + className={dataTableClass('Header')} + > + + +
+
+
(this.bodyNode = node)} + className={dataTableClass('Body')} + onScroll={this.handleTableBodyScroll} + > + (this.contentTable = node)} > -
(this.contentTable = node)} - > - {dynamicWidths == null ? : null} - -
-
+ {dynamicWidths == null ? : null} + +
-
-
- ); - } - - renderPlainTable() { - const { props } = this; - const { options, columns } = props; - - const stickyColumns = options.useStickyFirstNColumns - ? this.makeFirstNColumnsSticky(columns, options.useStickyFirstNColumns) - : columns; - const newProps = { - ...props, - columns: stickyColumns, - }; - - return ( -
-
- - - -
+ {this.props.options.marginContent && ( +
+ {this.props.options.marginContent} +
+ )}
); } render() { - const { shouldUseStickyHeader, renderStickyTable, renderPlainTable } = this; - return shouldUseStickyHeader() ? renderStickyTable() : renderPlainTable(); + return this.renderStickyTable(); } } diff --git a/packages/libs/coreui/src/components/Mesa/Ui/MesaController.jsx b/packages/libs/coreui/src/components/Mesa/Ui/MesaController.jsx index 93d490ae02..cf4f3d44ff 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/MesaController.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/MesaController.jsx @@ -97,8 +97,10 @@ class MesaController extends React.Component { const PageNav = this.renderPaginationMenu; const Empty = this.renderEmptyState; + const className = (options.className ?? '') + ' Mesa MesaComponent'; + return ( -
+
diff --git a/packages/libs/coreui/src/components/Mesa/Ui/RowCounter.jsx b/packages/libs/coreui/src/components/Mesa/Ui/RowCounter.jsx index ce66c5789d..4657e08294 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/RowCounter.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/RowCounter.jsx @@ -31,7 +31,10 @@ class RowCounter extends React.PureComponent { : start - 1 + rowsPerPage; let filterString = !filteredRowCount ? null : ( - (filtered from a total of {count}) + + {' '} + (filtered from a total of {count.toLocaleString()}) + ); const remainingRowCount = !filteredRowCount ? count @@ -39,7 +42,7 @@ class RowCounter extends React.PureComponent { let countString = ( - {remainingRowCount} {noun} + {remainingRowCount.toLocaleString()} {noun} ); let allResultsShown = diff --git a/packages/libs/coreui/src/components/Mesa/Ui/TableSearch.jsx b/packages/libs/coreui/src/components/Mesa/Ui/TableSearch.jsx index 4febc2197e..64fabbf22a 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/TableSearch.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/TableSearch.jsx @@ -18,7 +18,7 @@ class TableSearch extends React.PureComponent { clearSearchQuery() { const query = null; const { onSearch } = this.props; - if (onSearch) onSearch(query); + if (onSearch) onSearch(''); } render() { diff --git a/packages/libs/coreui/src/components/Mesa/Ui/TableToolbar.jsx b/packages/libs/coreui/src/components/Mesa/Ui/TableToolbar.jsx index 522d87282a..3853b92e66 100644 --- a/packages/libs/coreui/src/components/Mesa/Ui/TableToolbar.jsx +++ b/packages/libs/coreui/src/components/Mesa/Ui/TableToolbar.jsx @@ -23,12 +23,18 @@ class TableToolbar extends React.PureComponent { } renderSearch() { - const { uiState, eventHandlers } = this.props; + const { uiState, eventHandlers, options } = this.props; const { onSearch } = eventHandlers; const { searchQuery } = uiState; if (!onSearch) return null; - return ; + return ( + + ); } renderCounter() { diff --git a/packages/libs/coreui/src/components/Mesa/style/Ui/DataTable.scss b/packages/libs/coreui/src/components/Mesa/style/Ui/DataTable.scss index e87f3bc71f..bb4d37c72d 100644 --- a/packages/libs/coreui/src/components/Mesa/style/Ui/DataTable.scss +++ b/packages/libs/coreui/src/components/Mesa/style/Ui/DataTable.scss @@ -2,8 +2,34 @@ .DataTable { font-size: $fontSize; width: 100%; - display: block; margin-bottom: 1.5em; + display: grid; + grid-template-columns: auto 1fr; + grid-template-areas: + '. header' + 'margin body'; + z-index: 0; + + &--Sticky { + overflow: auto; + .DataTable-Header { + position: sticky; + top: 0; + z-index: 1; + } + } + + .DataTable-Header { + grid-area: header; + } + + .DataTable-Body { + grid-area: body; + } + + .DataTable-Margin { + grid-area: margin; + } table { width: 100%; diff --git a/packages/libs/coreui/src/components/Mesa/style/Ui/TableToolbar.scss b/packages/libs/coreui/src/components/Mesa/style/Ui/TableToolbar.scss index f2a8283559..67cc35d9bd 100644 --- a/packages/libs/coreui/src/components/Mesa/style/Ui/TableToolbar.scss +++ b/packages/libs/coreui/src/components/Mesa/style/Ui/TableToolbar.scss @@ -18,6 +18,14 @@ .TableToolbar-Info { font-size: 80%; padding: 0px 20px 0px 10px; + + .RowCounter { + display: flex; + justify-content: flex-start; + flex-direction: row; + flex-wrap: wrap; + column-gap: 1em; + } } .TableToolbar-Children { diff --git a/packages/libs/coreui/src/components/Mesa/types.ts b/packages/libs/coreui/src/components/Mesa/types.ts index 54e792ae5a..7b4b70ff12 100644 --- a/packages/libs/coreui/src/components/Mesa/types.ts +++ b/packages/libs/coreui/src/components/Mesa/types.ts @@ -36,7 +36,9 @@ export interface MesaStateProps< inline?: boolean; inlineMaxWidth?: string; inlineMaxHeight?: string; + inlineUseTooltips?: boolean; // don't use onClick to show the full contents, use an onMouseOver tooltip instead className?: string; + style?: React.CSSProperties; errOnOverflow?: boolean; editableColumns?: boolean; overflowHeight?: string; @@ -67,6 +69,12 @@ export interface MesaStateProps< */ childRow?: (props: ChildRowProps) => ReactElement>; getRowId?: (row: Row) => string | number; + /** + * Renders the node in the left margin of the table. + * This can be useful for rendering a graphic that + * aligns with table rows, etc. + */ + marginContent?: React.ReactNode; }; actions?: MesaAction[]; eventHandlers?: { diff --git a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx index 59a1fd2a3f..bf018fefd4 100644 --- a/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx +++ b/packages/libs/coreui/src/components/buttons/PopoverButton/PopoverButton.tsx @@ -1,4 +1,12 @@ -import { ReactNode, useState, useEffect, useMemo } from 'react'; +import { + ReactNode, + useState, + useEffect, + useMemo, + forwardRef, + useImperativeHandle, + useCallback, +} from 'react'; import { Popover } from '@material-ui/core'; import SwissArmyButton from '../SwissArmyButton'; import { gray } from '../../../definitions/colors'; @@ -66,6 +74,11 @@ const defaultStyle: ButtonStyleSpec = { }, }; +export interface PopoverButtonHandle { + /** Closes the popover */ + close: () => void; +} + export interface PopoverButtonProps { /** Contents of the menu when opened */ children: ReactNode; @@ -87,87 +100,104 @@ export interface PopoverButtonProps { /** * Renders a button that display `children` in a popover widget. */ -export default function PopoverButton(props: PopoverButtonProps) { - const { - children, - buttonDisplayContent, - onClose, - setIsPopoverOpen, - isDisabled = false, - styleOverrides = {}, - } = props; - const [anchorEl, setAnchorEl] = useState(null); - - const finalStyle = useMemo( - () => merge({}, defaultStyle, styleOverrides), - [styleOverrides] - ); - - const onCloseHandler = () => { - setAnchorEl(null); - onClose && onClose(); - }; - - useEffect(() => { - if (!setIsPopoverOpen) return; - if (anchorEl) { - setIsPopoverOpen(true); - } else { - setIsPopoverOpen(false); - } - }, [anchorEl, setIsPopoverOpen]); - - const menu = ( - - {children} - - ); - - const button = ( - setAnchorEl(event.currentTarget)} - disabled={isDisabled} - styleSpec={finalStyle} - icon={ArrowDown} - iconPosition="right" - additionalAriaProperties={{ - 'aria-controls': 'dropdown', - 'aria-haspopup': 'true', - type: 'button', - }} - /> - ); - - return ( -
{ - // prevent click event from propagating to ancestor nodes - event.stopPropagation(); - }} - > - {button} - {menu} -
- ); -} + +const PopoverButton = forwardRef( + function PopoverButton(props, ref) { + const { + children, + buttonDisplayContent, + onClose, + setIsPopoverOpen, + isDisabled = false, + styleOverrides = {}, + } = props; + const [anchorEl, setAnchorEl] = useState(null); + + const finalStyle = useMemo( + () => merge({}, defaultStyle, styleOverrides), + [styleOverrides] + ); + + const onCloseHandler = useCallback(() => { + setTimeout(() => { + anchorEl?.focus(); // return focus to button + }); + setAnchorEl(null); + onClose && onClose(); + }, [anchorEl, onClose]); + + // Expose the `close()` method to external components via ref + useImperativeHandle( + ref, + () => ({ + close: onCloseHandler, + }), + [onCloseHandler] + ); + + useEffect(() => { + if (!setIsPopoverOpen) return; + if (anchorEl) { + setIsPopoverOpen(true); + } else { + setIsPopoverOpen(false); + } + }, [anchorEl, setIsPopoverOpen]); + + const menu = ( + + {children} + + ); + + const button = ( + setAnchorEl(event.currentTarget)} + disabled={isDisabled} + styleSpec={finalStyle} + icon={ArrowDown} + iconPosition="right" + additionalAriaProperties={{ + 'aria-controls': 'dropdown', + 'aria-haspopup': 'true', + type: 'button', + }} + /> + ); + + return ( +
{ + // prevent click event from propagating to ancestor nodes + event.stopPropagation(); + }} + > + {button} + {menu} +
+ ); + } +); + +export default PopoverButton; diff --git a/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx b/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx index c38b106cc2..7fe308af0c 100644 --- a/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx +++ b/packages/libs/coreui/src/components/buttons/SwissArmyButton/index.tsx @@ -110,7 +110,7 @@ export default function SwissArmyButton({ styleSpec[styleState].color ?? 'transparent' } !important`, borderRadius: styleSpec[styleState].border?.radius ?? 5, - outlineStyle: styleSpec[styleState].border?.style ?? 'none', + outlineStyle: styleSpec[styleState].border?.style, outlineColor: styleSpec[styleState].border?.color, outlineWidth: styleSpec[styleState].border?.width, outlineOffset: styleSpec[styleState].border?.width diff --git a/packages/libs/coreui/src/components/inputs/SelectList.tsx b/packages/libs/coreui/src/components/inputs/SelectList.tsx index 1de1366795..8c3fd0a1ff 100644 --- a/packages/libs/coreui/src/components/inputs/SelectList.tsx +++ b/packages/libs/coreui/src/components/inputs/SelectList.tsx @@ -1,17 +1,25 @@ -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useState } from 'react'; import PopoverButton from '../buttons/PopoverButton/PopoverButton'; -import CheckboxList, { CheckboxListProps } from './checkboxes/CheckboxList'; +import CheckboxList, { + CheckboxListProps, + Item, +} from './checkboxes/CheckboxList'; -export interface SelectListProps extends CheckboxListProps { +export interface SelectListProps + extends CheckboxListProps { children?: ReactNode; /** A button's content if/when no values are currently selected */ defaultButtonDisplayContent: ReactNode; isDisabled?: boolean; /** Are contents loading? */ isLoading?: boolean; + /** If true, don't wait for component to close before calling `onChange` + * with latest selection. + */ + instantUpdate?: boolean; } -export default function SelectList({ +export default function SelectList({ name, items, value, @@ -21,30 +29,45 @@ export default function SelectList({ defaultButtonDisplayContent, isDisabled = false, isLoading = false, + instantUpdate = false, ...props }: SelectListProps) { const [selected, setSelected] = useState['value']>(value); const [buttonDisplayContent, setButtonDisplayContent] = useState( - value.length ? value.join(', ') : defaultButtonDisplayContent + getDisplayContent(value, items, defaultButtonDisplayContent) ); const onClose = () => { onChange(selected); setButtonDisplayContent( - selected.length ? selected.join(', ') : defaultButtonDisplayContent + getDisplayContent(selected, items, defaultButtonDisplayContent) ); }; + /** + * Keep caller up to date with any selection changes, if required by `instantUpdate` + */ + const handleCheckboxListUpdate = useCallback( + (newSelection: SelectListProps['value']) => { + setSelected(newSelection); + if (instantUpdate) { + onChange(newSelection); + } + }, + [instantUpdate, setSelected, onChange] + ); + /** * Need to ensure that the state syncs with parent component in the event of an external * clearSelection button, as is the case in EDA's line plot controls */ useEffect(() => { setSelected(value); + if (instantUpdate) return; // we don't want the button text changing on every click setButtonDisplayContent( - value.length ? value.join(', ') : defaultButtonDisplayContent + getDisplayContent(value, items, defaultButtonDisplayContent) ); - }, [value, defaultButtonDisplayContent]); + }, [value, items, defaultButtonDisplayContent]); const buttonLabel = ( ({ name={name} items={items} value={selected} - onChange={setSelected} + onChange={handleCheckboxListUpdate} linksPosition={linksPosition} {...props} /> @@ -84,3 +107,19 @@ export default function SelectList({ ); } + +// Returns button display content based on `value` array, mapping to display names from `items` when available. +// If no matching display name is found, uses the value itself. Returns `defaultContent` if `value` is empty. +function getDisplayContent( + value: T[], + items: Item[], + defaultContent: ReactNode +): ReactNode { + return value.length + ? value + .map( + (v) => items.find((item) => item.value === v)?.display ?? v + ) + .reduce((accum, elem) => (accum ? [accum, ',', elem] : elem), null) + : defaultContent; +} diff --git a/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx b/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx index 7d7655073e..567f82ee28 100644 --- a/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx +++ b/packages/libs/coreui/src/components/inputs/SelectTree/SelectTree.tsx @@ -8,8 +8,11 @@ import CheckboxTree, { export interface SelectTreeProps extends CheckboxTreeProps { buttonDisplayContent: ReactNode; shouldCloseOnSelection?: boolean; + hasPopoverButton?: boolean; // default=true wrapPopover?: (checkboxTree: ReactNode) => ReactNode; isDisabled?: boolean; + /** update `selectedList` state instantly when a selection is made (default: true) */ + instantUpdate?: boolean; } function SelectTree(props: SelectTreeProps) { @@ -18,7 +21,19 @@ function SelectTree(props: SelectTreeProps) { ? props.currentList.join(', ') : props.buttonDisplayContent ); - const { selectedList, shouldCloseOnSelection, wrapPopover } = props; + const { + selectedList, + onSelectionChange, + shouldCloseOnSelection, + hasPopoverButton = true, + instantUpdate = true, + wrapPopover, + } = props; + + // This local state is updated whenever a checkbox is clicked in the species tree. + // When `instantUpdate` is false, pass the final value to `onSelectionChange` when the popover closes. + // When it is true we call `onSelectionChange` whenever `localSelectedList` changes + const [localSelectedList, setLocalSelectedList] = useState(selectedList); /** Used as a hack to "auto close" the popover when shouldCloseOnSelection is true */ const [key, setKey] = useState(''); @@ -27,12 +42,37 @@ function SelectTree(props: SelectTreeProps) { if (!shouldCloseOnSelection) return; setKey(selectedList.join(', ')); onClose(); - }, [shouldCloseOnSelection, selectedList]); + }, [shouldCloseOnSelection, localSelectedList]); + + // live updates to caller when needed + useEffect(() => { + if (!instantUpdate) return; + onSelectionChange(localSelectedList); + }, [onSelectionChange, localSelectedList]); + + function truncatedButtonContent(selectedList: string[]) { + return ( + + {selectedList.join(', ')} + + ); + } const onClose = () => { setButtonDisplayContent( - selectedList.length ? selectedList.join(', ') : props.buttonDisplayContent + localSelectedList.length + ? truncatedButtonContent(localSelectedList) + : props.buttonDisplayContent ); + if (!instantUpdate) onSelectionChange(localSelectedList); }; const checkboxTree = ( @@ -49,12 +89,12 @@ function SelectTree(props: SelectTreeProps) { renderNode={props.renderNode} expandedList={props.expandedList} isSelectable={props.isSelectable} - selectedList={selectedList} + selectedList={localSelectedList} filteredList={props.filteredList} customCheckboxes={props.customCheckboxes} isMultiPick={props.isMultiPick} name={props.name} - onSelectionChange={props.onSelectionChange} + onSelectionChange={setLocalSelectedList} currentList={props.currentList} defaultList={props.defaultList} isSearchable={props.isSearchable} @@ -77,7 +117,7 @@ function SelectTree(props: SelectTreeProps) { /> ); - return ( + return hasPopoverButton ? ( (props: SelectTreeProps) { {wrapPopover ? wrapPopover(checkboxTree) : checkboxTree}
+ ) : ( + <>{wrapPopover ? wrapPopover(checkboxTree) : checkboxTree} ); } @@ -114,6 +156,7 @@ const defaultProps = { searchPredicate: () => true, linksPosition: LinksPosition.Both, isDisabled: false, + instantUpdate: true, // Set default value to true }; SelectTree.defaultProps = defaultProps; diff --git a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx index 9dc1c29a4a..218dae85ee 100644 --- a/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx +++ b/packages/libs/coreui/src/components/inputs/checkboxes/CheckboxList.tsx @@ -76,7 +76,7 @@ export type Item = { disabled?: boolean; }; -export type CheckboxListProps = { +export type CheckboxListProps = { /** Optional name attribute for the native input element */ name?: string; @@ -99,7 +99,7 @@ export type CheckboxListProps = { disabledCheckboxTooltipContent?: ReactNode; }; -export default function CheckboxList({ +export default function CheckboxList({ name, items, value, @@ -205,7 +205,7 @@ export default function CheckboxList({ ) : ( -