feat(ListBox): break out header and add unlock button (#1466)

* fix: error on esc in search

* fix: reset zoom on iphone on nebula level

* fix: show toolbar when icons demand

* feat: unlock cover btn and break out header

* fix: move option

* feat: move showLock option to public options

* fix: centered svg unlock icon

* fix: listen to lock changes

* fix: search icon show fixes

* fix: hide header correctly

* fix: make text overflow great again

* test: updated expected snapshots

* fix: tweak icon sizes more

* fix: apply header style color

* test: update snapshots

* fix: override text explicitly to make it work

* fix: rtl actions keyboard nav inverted

* fix: keyboard nav for unlock button

* fix: align numerical search results correctly

* fix: recreate header on layout or isLocked change

* fix: give layout to can-funcs to stay up to date

* Update apis/nucleus/src/components/listbox/ListBoxInline.jsx

Co-authored-by: Daniel Sjöstrand <99665802+DanielS-Qlik@users.noreply.github.com>

* refactor: add loading icon and block interaction

* fix: justify center when loading

* fix: adjust spinner timeout

* chore: update specs

* feat: moved translation strings

---------

Co-authored-by: Daniel Sjöstrand <99665802+DanielS-Qlik@users.noreply.github.com>
This commit is contained in:
Johan Lahti
2024-02-01 16:51:05 +01:00
committed by GitHub
parent 7d6db2d931
commit 5e35fb61fd
43 changed files with 769 additions and 218 deletions

View File

@@ -258,5 +258,10 @@
"value": "Die Berechnungsbedingung ist nicht erfüllt",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Klicken zum Sperren",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -179,6 +179,10 @@
"value": "Click to unlock",
"comment": "Unlock a selection from listbox"
},
"SelectionToolbar.ClickToLock": {
"value": "Click to lock",
"comment": "Lock a selection in selections toolbar"
},
"Visualization.LayoutError": {
"value": "Error",
"comment": "Status text shown when a visualization has layout errors"

View File

@@ -258,5 +258,10 @@
"value": "La condición de cálculo no se cumple",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Haga clic para bloquearla",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "Condition de calcul non remplie",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Cliquez pour verrouiller",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "La condizione di calcolo non è soddisfatta",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Fare clic per bloccare",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "演算実行条件が満たされていません",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "クリックしてロック",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "계산 조건이 충족되지 않았습니다.",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "잠그려면 클릭하십시오.",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "Er is niet aan de berekeningsvoorwaarde voldaan",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Klik om te vergrendelen",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "Warunek obliczenia nie jest spełniony",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Aby zablokować, kliknij",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "A condição de cálculo não foi atendida",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Clique para travar",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "Условие вычисления не выполнено",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Щелкните, чтобы заблокировать",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "Beräkningsvillkoret uppfylls inte",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Klicka för att låsa",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "Hesaplama koşulu yerine getirilmedi",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "Kilitlemek için tıklayın",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "不满足计算条件",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "单击以锁定",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -258,5 +258,10 @@
"value": "不符計算條件",
"comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "按一下鎖定",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
}
}

View File

@@ -65,6 +65,7 @@ function ActionsToolbar({
onConfirm: () => {},
onCancel: () => {},
},
extraItems,
more = {
enabled: false,
actions: [],
@@ -78,10 +79,11 @@ function ActionsToolbar({
},
focusHandler = null,
actionsRefMock = null, // for testing
direction = 'ltr',
isRtl,
autoConfirm = false,
layout,
}) {
const defaultSelectionActions = useDefaultSelectionActions(selections);
const defaultSelectionActions = useDefaultSelectionActions({ ...selections, layout });
const { translator, keyboardNavigation } = useContext(InstanceContext);
const [showMoreItems, setShowMoreItems] = useState(false);
@@ -104,8 +106,8 @@ function ActionsToolbar({
};
const handleActionsKeyDown = useMemo(
() => getActionsKeyDownHandler({ keyboardNavigation, focusHandler, getEnabledButton, selections }),
[keyboardNavigation, focusHandler, getEnabledButton, selections]
() => getActionsKeyDownHandler({ keyboardNavigation, focusHandler, getEnabledButton, selections, isRtl }),
[keyboardNavigation, focusHandler, getEnabledButton, selections, isRtl]
);
useEffect(
@@ -120,11 +122,11 @@ function ActionsToolbar({
const focusFirst = () => {
const enabledButton = getEnabledButton(false);
enabledButton && enabledButton.focus();
enabledButton?.focus();
};
const focusLast = () => {
const enabledButton = getEnabledButton(true);
enabledButton && enabledButton.focus();
enabledButton?.focus();
};
focusHandler.on('focus_toolbar_first', focusFirst);
focusHandler.on('focus_toolbar_last', focusLast);
@@ -163,7 +165,6 @@ function ActionsToolbar({
const showActions = newActions.length > 0;
const showMore = moreActions.length > 0;
const showDivider = (showActions && selections.show) || (showMore && selections.show);
const isRtl = direction === 'rtl';
const Actions = (
<Grid
@@ -176,6 +177,9 @@ function ActionsToolbar({
data-testid="actions-toolbar"
sx={{ flexDirection: isRtl ? 'row-reverse' : 'row' }}
>
{extraItems?.length && (
<ActionsGroup className="actions-toolbar-extra-actions" actions={extraItems} isRtl={isRtl} />
)}
{showActions && <ActionsGroup actions={newActions} />}
{showMore && (
<ActionsGroup

View File

@@ -89,6 +89,7 @@ function Header({ layout, sn, anchorEl, hovering, focusHandler, titleStyles = {}
actions={actions}
popover={{ show: showPopoverToolbar, anchorEl }}
focusHandler={focusHandler}
layout={layout}
/>
);

View File

@@ -17,7 +17,13 @@ const focusButton = (index) => {
btn.focus();
};
export default function getActionsKeyDownHandler({ keyboardNavigation, focusHandler, getEnabledButton, selections }) {
export default function getActionsKeyDownHandler({
keyboardNavigation,
focusHandler,
getEnabledButton,
selections,
isRtl,
}) {
const handleActionsKeyDown = (evt) => {
const { target, nativeEvent } = evt;
const { keyCode } = nativeEvent;
@@ -29,7 +35,8 @@ export default function getActionsKeyDownHandler({ keyboardNavigation, focusHand
const isActionButton = target.classList.contains('njs-cell-action');
if (isActionButton) {
const index = getActionButtonIndex(target);
const pressedLeft = [KEYS.ARROW_LEFT, KEYS.ARROW_DOWN].includes(keyCode);
let pressedLeft = [KEYS.ARROW_LEFT, KEYS.ARROW_DOWN].includes(keyCode);
pressedLeft = isRtl ? !pressedLeft : pressedLeft; // invert direction when using RTL
focusButton(pressedLeft ? index - 1 : index + 1);
}
evt.stopPropagation();

View File

@@ -2,37 +2,23 @@
import React, { useContext, useCallback, useRef, useEffect, useState, useMemo } from 'react';
import { styled } from '@mui/material/styles';
import AutoSizer from 'react-virtualized-auto-sizer';
import Lock from '@nebula.js/ui/icons/lock';
import { IconButton, Grid, Typography } from '@mui/material';
import { Grid } from '@mui/material';
import { useTheme } from '@nebula.js/ui/theme';
import SearchIcon from '@nebula.js/ui/icons/search';
import DrillDownIcon from '@nebula.js/ui/icons/drill-down';
import useLayout from '../../hooks/useLayout';
import ListBox from './ListBox';
import createListboxSelectionToolbar from './interactions/listbox-selection-toolbar';
import ActionsToolbar from '../ActionsToolbar';
import InstanceContext from '../../contexts/InstanceContext';
import ListBoxSearch from './components/ListBoxSearch';
import getListboxContainerKeyboardNavigation from './interactions/keyboard-navigation/keyboard-nav-container';
import getStyles from './assets/styling';
import useAppSelections from '../../hooks/useAppSelections';
import showToolbarDetached from './interactions/listbox-show-toolbar-detached';
import getListboxActionProps from './interactions/listbox-get-action-props';
import createSelectionState from './hooks/selections/selectionState';
import {
CELL_PADDING_LEFT,
ICON_WIDTH,
ICON_PADDING,
BUTTON_ICON_WIDTH,
HEADER_PADDING_RIGHT,
DENSE_ROW_HEIGHT,
SCROLL_BAR_WIDTH,
} from './constants';
import { DENSE_ROW_HEIGHT, SCROLL_BAR_WIDTH } from './constants';
import useTempKeyboard from './components/useTempKeyboard';
import ListBoxError from './components/ListBoxError';
import useRect from '../../hooks/useRect';
import isDirectQueryEnabled from './utils/is-direct-query';
import getContainerPadding from './assets/list-sizes/container-padding';
import ListBoxHeader from './components/ListBoxHeader';
const PREFIX = 'ListBoxInline';
const classes = {
@@ -69,25 +55,6 @@ const StyledGrid = styled(Grid, {
},
}));
const StyledGridHeader = styled(Grid, { shouldForwardProp: (p) => !['styles', 'isRtl'].includes(p) })(
({ styles, isRtl }) => ({
flexDirection: isRtl ? 'row-reverse' : 'row',
wrap: 'nowrap',
minHeight: 32,
alignContent: 'center',
...styles.header,
'& *': {
// Assign the styles color as defaul color for all elements in the header
color: styles.header.color,
},
})
);
const Title = styled(Typography)(({ styles }) => ({
...styles.header,
paddingRight: '1px', // make place for italic font style
}));
const isModal = ({ app, appSelections }) => app.isInModalSelection?.() ?? appSelections.isInModal();
function ListBoxInline({ options, layout }) {
@@ -111,6 +78,7 @@ function ListBoxInline({ options, layout }) {
renderedCallback,
toolbar = true,
isPopover = false,
showLock = false,
components,
} = options;
@@ -130,10 +98,6 @@ function ListBoxInline({ options, layout }) {
const theme = useTheme();
const unlock = useCallback(() => {
model.unlock('/qListObjectDef');
}, [model]);
const { translator, keyboardNavigation, themeApi, constraints } = useContext(InstanceContext);
const { checkboxes = checkboxesOption } = layout || {};
@@ -152,13 +116,11 @@ function ListBoxInline({ options, layout }) {
const updateKeyScroll = (newState) => setKeyScroll((current) => ({ ...current, ...newState }));
const [currentScrollIndex, setCurrentScrollIndex] = useState({ start: 0, stop: 0 });
const [appSelections] = useAppSelections(app);
const titleRef = useRef(null);
const [selectionState] = useState(() => createSelectionState());
const keyboard = useTempKeyboard({ containerRef, enabled: keyboardNavigation });
const isModalMode = useCallback(() => isModal({ app, appSelections }), [app, appSelections]);
const isInvalid = layout?.qListObject.qDimensionInfo.qError;
const errorText = isInvalid && constraints.active ? 'Visualization.Invalid.Dimension' : 'Visualization.Incomplete';
const [isToolbarDetached, setIsToolbarDetached] = useState(false);
const { handleKeyDown, handleOnMouseEnter, handleOnMouseLeave, globalKeyDown } = useMemo(
() =>
@@ -186,9 +148,6 @@ function ListBoxInline({ options, layout }) {
]
);
const showDetachedToolbarOnly = toolbar && (layout?.title === '' || layout?.showTitle === false) && !isPopover;
const showToolbarWithTitle = (toolbar && layout?.title !== '' && layout?.showTitle !== false) || isPopover;
useEffect(() => {
document.addEventListener('keydown', globalKeyDown);
return () => {
@@ -197,6 +156,9 @@ function ListBoxInline({ options, layout }) {
}, [globalKeyDown]);
useEffect(() => {
if (search === true) {
setShowSearch(true);
}
const show = () => {
setShowToolbar(true);
};
@@ -207,6 +169,7 @@ function ListBoxInline({ options, layout }) {
}
};
if (isPopover) {
// When isPopover, toolbar == false will be ignored.
if (!selections.isActive()) {
selections.begin('/qListObjectDef');
selections.on('activated', show);
@@ -214,7 +177,7 @@ function ListBoxInline({ options, layout }) {
}
setShowToolbar(isPopover);
}
if (selections) {
if (toolbar && selections) {
if (!selections.isModal()) {
selections.on('activated', show);
selections.on('deactivated', hide);
@@ -228,7 +191,7 @@ function ListBoxInline({ options, layout }) {
selections.removeListener('deactivated', hide);
}
};
}, [selections, isPopover]);
}, [toolbar, selections, isPopover]);
useEffect(() => {
if (!searchContainer || !searchContainer.current) {
@@ -242,75 +205,38 @@ function ListBoxInline({ options, layout }) {
}, [searchContainer && searchContainer.current, showSearch, search, focusSearch]);
const { wildCardSearch, searchEnabled, autoConfirm = false, layoutOptions = {} } = layout ?? {};
const showSearchIcon = searchEnabled !== false && search === 'toggle';
const isLocked = layout?.qListObject?.qDimensionInfo?.qLocked === true;
const showSearchOrLockIcon = isLocked || showSearchIcon;
const isLocked = layout?.qListObject?.qDimensionInfo?.qLocked;
const showSearchIcon = searchEnabled !== false && search === 'toggle' && !isLocked;
const isDrillDown = layout?.qListObject?.qDimensionInfo?.qGrouping === 'H';
const showIcons = showSearchOrLockIcon || isDrillDown;
const iconsWidth = (showSearchOrLockIcon ? BUTTON_ICON_WIDTH : 0) + (isDrillDown ? ICON_WIDTH + ICON_PADDING : 0); // Drill-down icon needs padding right so there is space between the icon and the title
const canShowTitle = layout?.title?.length && layout?.showTitle !== false;
const showDetachedToolbarOnly = toolbar && !canShowTitle && !isPopover;
const showAttachedToolbar = (toolbar && canShowTitle) || isPopover;
const isRtl = direction === 'rtl';
const headerPaddingLeft = CELL_PADDING_LEFT - (showSearchOrLockIcon ? ICON_PADDING : 0);
const headerPaddingRight = isRtl ? CELL_PADDING_LEFT - (showIcons ? ICON_PADDING : 0) : HEADER_PADDING_RIGHT;
useEffect(() => {
if (!titleRef.current || !containerRect) {
return;
}
const isDetached = showToolbarDetached({
containerRect,
titleRef,
iconsWidth,
headerPaddingLeft,
headerPaddingRight,
});
setIsToolbarDetached(isDetached);
}, [titleRef.current, containerRect]);
if (!model || !layout || !translator) {
return null;
}
const listboxSelectionToolbarItems = createListboxSelectionToolbar({
layout,
model,
translator,
selectionState,
isDirectQuery,
});
const showTitle = true;
const showSearchToggle = search === 'toggle' && showSearch;
const searchVisible = (search === true || showSearchToggle) && !selectDisabled() && searchEnabled !== false;
const searchVisible = search === true || (showSearchToggle && !selectDisabled() && searchEnabled !== false);
const dense = layoutOptions.dense ?? false;
const onShowSearch = () => {
const handleShowSearch = () => {
const newValue = !showSearch;
setShowSearch(newValue);
};
const onCtrlF = () => {
if (search === 'toggle') {
onShowSearch();
handleShowSearch();
} else {
const input = searchContainer.current.querySelector('input');
input?.focus();
}
};
const getActionToolbarProps = (isDetached) => ({
...getListboxActionProps({
isDetached: isPopover ? false : isDetached,
showToolbar,
containerRef,
isLocked,
listboxSelectionToolbarItems,
selections,
keyboard,
}),
autoConfirm,
});
const shouldAutoFocus = searchVisible && search === 'toggle';
// Add a container padding for grid mode to harmonize with the grid item margins (should sum to 8px).
@@ -323,38 +249,42 @@ function ListBoxInline({ options, layout }) {
layoutOrder: layoutOptions.layoutOrder,
});
const searchIconComp = constraints?.active ? (
<SearchIcon title={translator.get('Listbox.Search')} size="large" style={{ fontSize: '12px', padding: '7px' }} />
) : (
<IconButton
onClick={onShowSearch}
tabIndex={-1}
title={translator.get('Listbox.Search')}
size="large"
disableRipple
data-testid="search-toggle-btn"
>
<SearchIcon style={{ fontSize: '12px' }} />
</IconButton>
);
const lockIconComp = selectDisabled() ? (
<Lock size="large" style={{ fontSize: '12px', padding: '7px' }} />
) : (
<IconButton title={translator.get('SelectionToolbar.ClickToUnlock')} tabIndex={-1} onClick={unlock} size="large">
<Lock disableRipple style={{ fontSize: '12px' }} />
</IconButton>
);
if (isInvalid) {
renderedCallback?.();
}
const listBoxMinHeight = showToolbarWithTitle ? DENSE_ROW_HEIGHT + SCROLL_BAR_WIDTH : 0;
const listBoxMinHeight = showAttachedToolbar ? DENSE_ROW_HEIGHT + SCROLL_BAR_WIDTH : 0;
const listBoxHeader = (
<ListBoxHeader
selectDisabled={selectDisabled}
showSearchIcon={showSearchIcon}
isDrillDown={isDrillDown}
onShowSearch={handleShowSearch}
isPopover={isPopover}
showToolbar={showToolbar}
isDirectQuery={isDirectQuery}
autoConfirm={autoConfirm}
showDetachedToolbarOnly={showDetachedToolbarOnly}
layout={layout}
translator={translator}
styles={styles}
isRtl={isRtl}
showLock={showLock}
constraints={constraints}
classes={classes}
containerRect={containerRect}
containerRef={containerRef}
model={model}
selectionState={selectionState}
selections={selections}
keyboard={keyboard}
/>
);
return (
<>
{showDetachedToolbarOnly && <ActionsToolbar direction={direction} {...getActionToolbarProps(true)} />}
{showDetachedToolbarOnly && listBoxHeader}
<StyledGrid
className="listbox-container"
container
@@ -374,47 +304,7 @@ function ListBoxInline({ options, layout }) {
isGridMode={isGridMode}
aria-label={keyboard.active ? translator.get('Listbox.ScreenReaderInstructions') : ''}
>
{showToolbarWithTitle && (
<StyledGridHeader
item
container
styles={styles}
isRtl={isRtl}
marginY={1}
paddingLeft={`${headerPaddingLeft}px`}
paddingRight={`${headerPaddingRight}px`}
>
{showIcons && (
<Grid item container alignItems="center" width={iconsWidth}>
{isLocked ? lockIconComp : showSearchIcon && searchIconComp}
{isDrillDown && (
<DrillDownIcon
tabIndex={-1}
title={translator.get('Listbox.DrillDown')}
size="large"
style={{ fontSize: '12px' }}
/>
)}
</Grid>
)}
<Grid
item
xs
minWidth={0} // needed to text-overflow see: https://css-tricks.com/flexbox-truncated-text/
justifyContent={isRtl ? 'flex-end' : 'flex-start'}
className={classes.listBoxHeader}
>
{showTitle && (
<Title variant="h6" noWrap ref={titleRef} title={layout.title} styles={styles}>
{layout.title}
</Title>
)}
</Grid>
<Grid item display="flex">
<ActionsToolbar direction={direction} {...getActionToolbarProps(isToolbarDetached)} />
</Grid>
</StyledGridHeader>
)}
{showAttachedToolbar && listBoxHeader}
<Grid
item
container
@@ -437,7 +327,7 @@ function ListBoxInline({ options, layout }) {
wildCardSearch={wildCardSearch}
searchEnabled={searchEnabled}
direction={direction}
hide={showSearchIcon && onShowSearch}
hide={showSearchIcon && handleShowSearch}
styles={styles}
/>
</Grid>

View File

@@ -140,8 +140,12 @@ export default function ListBoxPopover({
anchorEl={alignTo.current}
anchorOrigin={anchorOrigin}
transformOrigin={transformOrigin}
PaperProps={{
style: { minWidth: '250px' },
slotProps={{
paper: {
style: {
minWidth: '250px',
},
},
}}
>
<Grid container direction="column" gap={0} ref={containerRef}>
@@ -160,6 +164,7 @@ export default function ListBoxPopover({
<Grid item xs />
<Grid item>
<ActionsToolbar
layout={layout}
more={{
enabled: !isLocked,
actions: listboxSelectionToolbarItems,

View File

@@ -34,7 +34,6 @@ jest.mock('../interactions/keyboard-navigation/keyboard-nav-container', () => ({
describe('<ListboxInline />', () => {
const app = { key: 'app' };
let options;
let useState;
let useEffect;
let useCallback;
@@ -49,6 +48,7 @@ describe('<ListboxInline />', () => {
let render;
let getListboxInlineKeyboardNavigation;
let InstanceContext;
let defaultOptions;
beforeEach(() => {
useState = jest.fn();
@@ -111,7 +111,7 @@ describe('<ListboxInline />', () => {
removeListener: jest.fn(),
};
options = {
defaultOptions = {
app,
title: 'title',
direction: 'vertical',
@@ -161,12 +161,13 @@ describe('<ListboxInline />', () => {
beforeEach(() => {
const theme = createTheme('dark');
render = async () => {
render = async (options = {}) => {
const mergedOptions = { ...defaultOptions, ...options };
await act(async () => {
renderer = create(
<ThemeProvider theme={theme}>
<InstanceContext.Provider value={{ translator: { get: (s) => s, language: () => 'sv' } }}>
<ListBoxInline options={options} />
<ListBoxInline options={mergedOptions} />
</InstanceContext.Provider>
</ThemeProvider>
);
@@ -201,8 +202,8 @@ describe('<ListboxInline />', () => {
});
test('should render properly with search toggle option', async () => {
options.search = 'toggle';
await render();
const options = { search: 'toggle' };
await render(options);
const searchToggleBtns = renderer.root
.findAllByProps({ 'data-testid': 'search-toggle-btn' })
@@ -215,8 +216,8 @@ describe('<ListboxInline />', () => {
});
test('should render without toolbar', async () => {
options.toolbar = false;
await render();
const options = { toolbar: false };
await render(options);
const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars).toHaveLength(0);
@@ -228,8 +229,8 @@ describe('<ListboxInline />', () => {
});
test('should render without toolbar', async () => {
options.search = 'toggle';
await render();
const options = { search: 'toggle' };
await render(options);
expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({
visible: false,
@@ -241,8 +242,8 @@ describe('<ListboxInline />', () => {
});
test('should render without search and show search button', async () => {
options.search = false;
await render();
const options = { search: false };
await render(options);
const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars).toHaveLength(1);
@@ -255,8 +256,8 @@ describe('<ListboxInline />', () => {
});
test('should render with NOT autoFocus when search is true', async () => {
options.search = true;
await render();
const options = { search: true };
await render(options);
expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({
visible: true,
@@ -265,9 +266,8 @@ describe('<ListboxInline />', () => {
});
test('should show toolbar when opened in a popover', async () => {
options.search = false;
options.isPopover = true;
await render();
const options = { search: false, isPopover: true };
await render(options);
const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars).toHaveLength(1);

View File

@@ -0,0 +1,232 @@
/* eslint-disable react/jsx-props-no-spreading */
import React, { useEffect, useRef, useState } from 'react';
import { Grid, IconButton } from '@mui/material';
import Lock from '@nebula.js/ui/icons/lock';
import { unlock } from '@nebula.js/ui/icons/unlock';
import SearchIcon from '@nebula.js/ui/icons/search';
import DrillDownIcon from '@nebula.js/ui/icons/drill-down';
import ActionsToolbar from '../../../ActionsToolbar';
import showToolbarDetached from '../../interactions/listbox-show-toolbar-detached';
import getListboxActionProps from '../../interactions/listbox-action-props';
import createListboxSelectionToolbar from '../../interactions/listbox-selection-toolbar';
import { BUTTON_ICON_WIDTH, CELL_PADDING_LEFT, HEADER_PADDING_RIGHT, ICON_PADDING } from '../../constants';
import hasSelections from '../../assets/has-selections';
import { HeaderTitle, StyledGridHeader, UnlockCoverButton, iconStyle } from './ListBoxHeaderComponents';
// ms that needs to pass before the lock button can be toggled again
const lockTimeFrameMs = 500;
let lastTime = 0;
function getToggleLock({ isLocked, setLocked, settingLockedState, model, setSettingLockedState }) {
return () => {
const now = new Date();
const curTime = now - lastTime;
if (curTime < lockTimeFrameMs) {
return Promise.resolve();
}
if (settingLockedState) {
return () => {};
}
setSettingLockedState(true);
lastTime = now;
const func = isLocked ? model.unlock : model.lock;
setLocked(!isLocked);
return func
.call(model, '/qListObjectDef')
.catch(() => {
setLocked(isLocked); // revert to the layout value
})
.finally(() => {
setTimeout(() => {
setSettingLockedState(false);
}, 0);
});
};
}
export default function ListBoxHeader({
layout,
translator,
styles,
isRtl,
showLock,
selectDisabled,
showSearchIcon,
isDrillDown,
constraints,
onShowSearch,
classes,
containerRect,
isPopover,
showToolbar,
showDetachedToolbarOnly,
containerRef,
model,
selectionState,
isDirectQuery,
selections,
keyboard,
autoConfirm,
}) {
const [isToolbarDetached, setIsToolbarDetached] = useState(showDetachedToolbarOnly);
const [isLocked, setLocked] = useState(layout?.qListObject?.qDimensionInfo?.qLocked);
const [settingLockedState, setSettingLockedState] = useState(false);
useEffect(() => {
setLocked(layout?.qListObject?.qDimensionInfo?.qLocked);
}, [layout?.qListObject?.qDimensionInfo?.qLocked]);
const titleRef = useRef(null);
const showUnlock = showLock && isLocked;
const showLockIcon = !showLock && isLocked; // shows instead of the cover button when field/dim is locked.
const showLeftIcon = showSearchIcon || showLockIcon || isDrillDown; // the left-most icon outside of the actions/selections toolbar.
const paddingLeft = CELL_PADDING_LEFT - (showLeftIcon ? ICON_PADDING : 0);
const paddingRight = isRtl ? CELL_PADDING_LEFT - (showLeftIcon ? ICON_PADDING : 0) : HEADER_PADDING_RIGHT;
// Calculate explicit width for icons container. Search and lock cannot exist combined.
const iconsWidth =
(showSearchIcon ? BUTTON_ICON_WIDTH : 0) +
(showLockIcon ? BUTTON_ICON_WIDTH : 0) +
(isDrillDown ? BUTTON_ICON_WIDTH : 0);
const toggleLock = getToggleLock({ isLocked, setLocked, settingLockedState, setSettingLockedState, model });
const listboxSelectionToolbarItems = createListboxSelectionToolbar({
layout,
model,
translator,
selectionState,
isDirectQuery,
});
const extraItems =
isLocked || !showLock
? undefined
: [
{
key: 'lock',
type: undefined,
label: translator.get('SelectionToolbar.ClickToLock'),
getSvgIconShape: unlock,
enabled: () => !settingLockedState && !selectDisabled() && hasSelections(layout),
action: toggleLock,
},
];
const searchIconComp = constraints?.active ? (
<SearchIcon
title={translator.get('Listbox.Search')}
size="large"
style={{ ...iconStyle, padding: `${ICON_PADDING}px` }}
/>
) : (
<IconButton
onClick={onShowSearch}
tabIndex={-1}
title={translator.get('Listbox.Search')}
size="large"
disableRipple
data-testid="search-toggle-btn"
>
<SearchIcon style={iconStyle} />
</IconButton>
);
useEffect(() => {
if (!titleRef.current || !containerRect) {
return;
}
const mustShowDetached = showToolbarDetached({
containerRect,
titleRef,
iconsWidth,
paddingLeft,
paddingRight,
});
const isDetached = showDetachedToolbarOnly || mustShowDetached;
setIsToolbarDetached(isDetached);
}, [
iconsWidth,
paddingLeft,
paddingRight,
titleRef.current,
showDetachedToolbarOnly,
Object.entries(containerRect || {})
.sort()
.join(','),
]);
const toolbarProps = getListboxActionProps({
isDetached: isPopover ? false : isToolbarDetached,
showToolbar,
containerRef,
isLocked,
extraItems,
listboxSelectionToolbarItems,
selections,
keyboard,
autoConfirm,
});
const actionsToolbar = <ActionsToolbar isRtl={isRtl} layout={layout} {...toolbarProps} />;
if (showDetachedToolbarOnly) {
return actionsToolbar;
}
// Always show a lock symbol when locked and showLock is false
const lockedIconComp = showLockIcon ? (
<Lock size="large" style={{ ...iconStyle, padding: `${ICON_PADDING}px` }} />
) : undefined;
return (
<StyledGridHeader
item
container
styles={styles}
isRtl={isRtl}
marginY={1}
paddingLeft={`${paddingLeft}px`}
paddingRight={`${paddingRight}px`}
className="header-container"
>
{showUnlock && (
<UnlockCoverButton
isLoading={settingLockedState}
translator={translator}
toggleLock={toggleLock}
keyboard={keyboard}
/>
)}
{showLeftIcon && (
<Grid item container alignItems="center" width={iconsWidth} className="header-action-container">
{lockedIconComp || (showSearchIcon && searchIconComp)}
{isDrillDown && (
<DrillDownIcon
tabIndex={-1}
title={translator.get('Listbox.DrillDown')}
style={{ ...iconStyle, padding: `${ICON_PADDING}px` }}
/>
)}
</Grid>
)}
<Grid
item
xs
minWidth={0} // needed to text-overflow see: https://css-tricks.com/flexbox-truncated-text/
justifyContent={isRtl ? 'flex-end' : 'flex-start'}
className={classes.listBoxHeader}
>
<HeaderTitle variant="h6" noWrap ref={titleRef} title={layout.title} styles={styles}>
{layout.title}
</HeaderTitle>
</Grid>
<Grid item display="flex">
{actionsToolbar}
</Grid>
</StyledGridHeader>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import { ButtonBase, CircularProgress, Grid, Typography } from '@mui/material';
import Lock from '@nebula.js/ui/icons/lock';
import { ICON_PADDING } from '../../constants';
export const iconStyle = {
fontSize: '12px',
};
export const UnlockButton = styled(ButtonBase)(({ theme, isLoading }) => ({
position: 'absolute',
top: 0,
left: 0,
height: 48,
zIndex: 2, // so it goes on top of action buttons
background: theme.palette.custom.disabledBackground,
opacity: 1,
color: theme.palette.custom.disabledContrastText,
width: '100%',
display: 'flex',
justifyContent: isLoading ? 'center' : 'flex-start',
paddingLeft: 16,
paddingRight: 16,
borderRadius: 0,
'& *, & p': {
color: theme.palette.custom.disabledContrastText,
},
'& i': {
padding: `${ICON_PADDING}px`,
},
}));
export const StyledGridHeader = styled(Grid, { shouldForwardProp: (p) => !['styles', 'isRtl'].includes(p) })(
({ styles, isRtl }) => ({
flexDirection: isRtl ? 'row-reverse' : 'row',
wrap: 'nowrap',
minHeight: 32,
alignContent: 'center',
...styles.header,
'& *': {
color: styles.header.color,
},
})
);
export const HeaderTitle = styled(Typography)(({ styles }) => ({
...styles.header,
display: 'block', // needed for text-overflow to work
alignItems: 'center',
paddingRight: '1px', // make place for italic font style
}));
export function UnlockCoverButton({ translator, toggleLock, keyboard, isLoading }) {
const fontSize = '14px';
const unLockText = translator.get('SelectionToolbar.ClickToUnlock');
const component = (
<UnlockButton
title={unLockText}
tabIndex={keyboard.enabled ? 0 : -1}
onClick={toggleLock}
data-testid="listbox-unlock-button"
id="listbox-unlock-button"
isLoading={isLoading}
>
{isLoading ? (
<CircularProgress size={16} variant="indeterminate" color="primary" />
) : (
<Lock disableRipple style={iconStyle} />
)}
{!isLoading && <Typography fontSize={fontSize}>{unLockText}</Typography>}
</UnlockButton>
);
return component;
}

View File

@@ -0,0 +1,3 @@
import ListBoxHeader from './ListBoxHeader';
export default ListBoxHeader;

View File

@@ -172,13 +172,14 @@ function RowColumn({ index, rowIndex, columnIndex, style, data }) {
paddingLeft: isRtl ? 8 : checkboxes ? 0 : undefined,
paddingRight: checkboxes ? 0 : isRtl ? 8 : 0,
justifyContent: valueTextAlign,
textAlign: valueTextAlign,
};
const isFirstElement = index === 0;
const showLock = isSelected && isLocked;
const showTick = !checkboxes && isSelected && !isLocked;
const showIcon = !checkboxes && sizePermitsTickOrLock;
const showLockIcon = isSelected && isLocked;
const showTickIcon = !checkboxes && isSelected && !isLocked;
const showAnyIcon = !checkboxes && sizePermitsTickOrLock;
const cellPaddingRight = checkboxes || !sizePermitsTickOrLock;
const ariaLabel = getValueLabel({
@@ -288,10 +289,10 @@ function RowColumn({ index, rowIndex, columnIndex, style, data }) {
{freqIsAllowed && <Frequency cell={cell} checkboxes={checkboxes} dense={dense} showGray={showGray} />}
{showIcon && (
{showAnyIcon && (
<Grid item className={classes.icon}>
{showLock && <Lock style={iconStyles} size="small" />}
{showTick && <Tick style={iconStyles} size="small" />}
{showLockIcon && <Lock style={iconStyles} size="small" />}
{showTickIcon && <Tick style={iconStyles} size="small" />}
</Grid>
)}
</ItemGrid>

View File

@@ -154,7 +154,7 @@ export default function ListBoxSearch({
function focusRow(container) {
const row = container?.querySelector('.last-focused') || container?.querySelector('[role="row"]:first-child');
row.setAttribute('tabIndex', 0);
row?.setAttribute('tabIndex', 0);
row?.focus();
}

View File

@@ -0,0 +1,233 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/jsx-no-constructed-context-values */
/* eslint-disable no-import-assign */
import React from 'react';
import renderer, { act } from 'react-test-renderer';
import DrillDownIcon from '@nebula.js/ui/icons/drill-down';
import { createTheme, ThemeProvider } from '@nebula.js/ui/theme';
import Lock from '@nebula.js/ui/icons/lock';
import { unlock } from '@nebula.js/ui/icons/unlock';
import ListBoxHeader from '../ListBoxHeader';
import * as InstanceContextModule from '../../../../contexts/InstanceContext';
import * as ActionsToolbarModule from '../../../ActionsToolbar';
import * as HeaderComponents from '../ListBoxHeader/ListBoxHeaderComponents';
import * as hasSelectionsModule from '../../assets/has-selections';
const { StyledGridHeader, UnlockButton } = HeaderComponents;
const selections = {
canClear: () => true,
canConfirm: () => true,
canCancel: () => true,
};
const styles = { content: {}, header: { color: 'red' }, selections: {}, search: {}, background: {} };
let rendererInst;
const translator = { get: jest.fn().mockImplementation((v) => v) };
const theme = createTheme('dark');
const model = {
lock: jest.fn().mockImplementation(() => Promise.resolve()),
unlock: jest.fn().mockImplementation(() => Promise.resolve()),
selectListObjectAll: jest.fn(),
selectListObjectPossible: jest.fn(),
selectListObjectAlternative: jest.fn(),
selectListObjectExcluded: jest.fn(),
};
function getDefaultProps() {
const containerRef = React.createRef();
const defaultProps = {
layout: { title: 'The title', qListObject: { qDimensionInfo: { qLocked: false } } },
translator,
styles,
isRtl: false,
showLock: true,
selectDisabled: () => false,
showSearchIcon: 'toggle',
isDrillDown: false,
constraints: { active: false },
onShowSearch: () => {},
classes: { listBoxHeader: 'listBoxHeader' },
containerRect: { width: 200 },
isPopover: false,
showToolbar: true,
showDetachedToolbarOnly: false,
containerRef,
model,
selectionState: {
clearItemStates: jest.fn(),
},
isDirectQuery: false,
selections,
keyboard: { enabled: true },
autoConfirm: false,
};
return defaultProps;
}
const InstanceContext = React.createContext();
let component;
const render = async (overrideProps = {}) => {
const defaultProps = getDefaultProps();
const mergedProps = { ...defaultProps, ...overrideProps };
component = (
<ThemeProvider theme={theme}>
<InstanceContext.Provider value={{ translator }}>
<ListBoxHeader {...mergedProps} />
</InstanceContext.Provider>
</ThemeProvider>
);
await act(() => {
rendererInst = renderer.create(component);
});
return rendererInst;
};
let ActionsToolbar;
// Mock the useRef module
jest.mock('react', () => ({
...jest.requireActual('react'), // Use the actual implementation of React
useRef: jest.fn(),
useCallback: (func) => func,
}));
let HeaderTitle;
let hasSelections;
function HeaderTitleMock() {
return <div />;
}
describe('<ListBoxHeader />', () => {
beforeEach(() => {
hasSelections = jest.spyOn(hasSelectionsModule, 'default').mockReturnValue(true);
HeaderComponents.HeaderTitle = HeaderTitleMock;
HeaderTitle = HeaderComponents.HeaderTitle;
InstanceContextModule.default = InstanceContext;
const ActionsToolbarElement = <div id="test-actions-toolbar" />;
ActionsToolbar = jest.spyOn(ActionsToolbarModule, 'default').mockImplementation(() => ActionsToolbarElement);
jest.spyOn(React, 'useRef').mockReturnValue({ current: { clientWidth: 100, scrollWidth: 100, offsetWidth: 100 } });
});
afterEach(() => {
jest.resetAllMocks();
rendererInst.unmount();
jest.restoreAllMocks();
});
test('should render the header with title and attached actions toolbar', async () => {
const testRenderer = await render();
const testInstance = testRenderer.root;
// Find by type.
const titles = testInstance.findAllByType(HeaderTitle);
const drillDowns = testInstance.findAllByType(DrillDownIcon);
const actionsToolbars = testInstance.findAllByType(ActionsToolbar);
const locks = testInstance.findAllByType(Lock);
const unlocks = testInstance.findAllByType(unlock);
// Check existence.
expect(titles).toHaveLength(1);
expect(drillDowns).toHaveLength(0);
expect(actionsToolbars).toHaveLength(1);
expect(locks).toHaveLength(0);
expect(unlocks).toHaveLength(0);
// Dig in to Title.
const titleProps = titles[0].props;
expect(titleProps.title).toEqual('The title');
expect(titleProps.children).toEqual('The title');
expect(titleProps.styles.header.color).toEqual('red');
});
test('should render a detached toolbar when told to do so', async () => {
const testRenderer = await render({ showDetachedToolbarOnly: true });
const testInstance = testRenderer.root;
const titles = testInstance.findAllByType(HeaderTitle);
const actionsToolbars = testInstance.findAllByType(ActionsToolbar);
const styledGridHeaders = testInstance.findAllByType(StyledGridHeader);
// Check existence.
expect(titles).toHaveLength(0);
expect(actionsToolbars).toHaveLength(1);
expect(styledGridHeaders).toHaveLength(0);
// Dig in to const actionsToolbar props
expect(actionsToolbars[0].props.isDetached).toEqual(true);
});
test('should render a detached toolbar when space is limited', async () => {
const containerRect = { width: 20 };
const testRenderer = await render({ showDetachedToolbarOnly: false, containerRect });
const testInstance = testRenderer.root;
const titles = testInstance.findAllByType(HeaderTitle);
const actionsToolbars = testInstance.findAllByType(ActionsToolbar);
const styledGridHeaders = testInstance.findAllByType(StyledGridHeader);
// Check existence.
expect(titles).toHaveLength(1);
expect(actionsToolbars).toHaveLength(1);
expect(styledGridHeaders).toHaveLength(1);
// Verify it is detached.
expect(actionsToolbars[0].props.isDetached).toEqual(true);
});
test('trigger toggle search field when pressing search icon in toggle mode', async () => {
const onShowSearch = jest.fn();
const testRenderer = await render({ search: 'toggle', onShowSearch });
const testInstance = testRenderer.root;
const searchButton = testInstance.findByProps({ 'data-testid': 'search-toggle-btn' });
// Check existence.
expect(searchButton).toBeTruthy();
expect(onShowSearch).toHaveBeenCalledTimes(0);
// Trigger toggle search and verify it was called.
await act(() => {
searchButton.props.onClick();
});
expect(onShowSearch).toHaveBeenCalledTimes(1);
});
test('There should be a lock button inside the actions toolbar', async () => {
hasSelections.mockReturnValue(true);
const testRenderer = await render({ showSearchIcon: false, showLock: true, isPopover: true });
const testInstance = testRenderer.root;
const [actionsToolbar] = testInstance.findAllByType(ActionsToolbar);
const unlockCoverButtons = testInstance.findAllByType(UnlockButton);
// Check existence.
const btns = actionsToolbar.props.extraItems;
expect(btns).toHaveLength(1);
expect(btns[0].key).toEqual('lock');
expect(btns[0].enabled()).toEqual(true);
// Ensure unlock is not visible
expect(unlockCoverButtons).toHaveLength(0);
});
test('There should be an unlock cover button on top of the actions toolbar', async () => {
const layout = { title: 'The title', qListObject: { qDimensionInfo: { qLocked: true } } };
hasSelections.mockReturnValue(false);
const testRenderer = await render({
layout,
showSearchIcon: false,
showLock: true,
isPopover: true,
});
const testInstance = testRenderer.root;
const unlockCoverButtons = testInstance.findAllByType(UnlockButton);
// Ensure unlock is visible
expect(unlockCoverButtons).toHaveLength(1);
});
});

View File

@@ -52,14 +52,17 @@ export default function useTempKeyboard({ containerRef, enabled }) {
const lastSelectedRow = c?.querySelector('.value.last-focused');
const firstRowElement = c?.querySelector('.value.selector, .value');
const confirmButton = c?.querySelector('.actions-toolbar-default-actions .actions-toolbar-confirm');
const elementToFocus = searchField || lastSelectedRow || firstRowElement || confirmButton;
const unlockCoverButton = c?.querySelector('#listbox-unlock-button');
const elementToFocus = searchField || lastSelectedRow || firstRowElement || unlockCoverButton || confirmButton;
elementToFocus?.setAttribute('tabIndex', 0);
elementToFocus?.focus();
},
focusSelection() {
const unlockCoverButton = document.querySelector('#listbox-unlock-button');
const confirmButton = document.querySelector('.actions-toolbar-default-actions .actions-toolbar-confirm');
confirmButton?.setAttribute('tabIndex', 0);
confirmButton?.focus();
const btnToFocus = unlockCoverButton || confirmButton;
btnToFocus?.setAttribute('tabIndex', 0);
btnToFocus?.focus();
},
};

View File

@@ -1,22 +1,20 @@
import showToolbarDetached from '../listbox-show-toolbar-detached';
const iconsWidth = 28;
const headerPaddingLeft = 9;
const headerPaddingRight = 4;
const paddingLeft = 9;
const paddingRight = 4;
describe('show listbox toolbar detached', () => {
it('should return true if there is not enough space for toolbar', () => {
const containerRect = { width: 100 };
const titleRef = { current: { clientWidth: 50, scrollWidth: 80, offsetWidth: 81 } };
expect(
showToolbarDetached({ containerRect, titleRef, iconsWidth, headerPaddingLeft, headerPaddingRight })
).toStrictEqual(true);
expect(showToolbarDetached({ containerRect, titleRef, iconsWidth, paddingLeft, paddingRight })).toStrictEqual(true);
});
it('should return true if title is truncated', () => {
const containerRect = { width: 300 };
const titleRef = {
current: { clientWidth: 60, scrollWidth: 200, offsetWidth: 199, headerPaddingLeft, headerPaddingRight },
current: { clientWidth: 60, scrollWidth: 200, offsetWidth: 199, paddingLeft, paddingRight },
};
expect(showToolbarDetached({ containerRect, titleRef, iconsWidth })).toStrictEqual(true);
});
@@ -24,7 +22,7 @@ describe('show listbox toolbar detached', () => {
it('should return false if there is enough space for title and toolbar', () => {
const containerRect = { width: 300 };
const titleRef = {
current: { clientWidth: 60, scrollWidth: 200, offsetWidth: 201, headerPaddingLeft, headerPaddingRight },
current: { clientWidth: 60, scrollWidth: 200, offsetWidth: 201, paddingLeft, paddingRight },
};
expect(showToolbarDetached({ containerRect, titleRef, iconsWidth })).toStrictEqual(false);
});

View File

@@ -4,15 +4,18 @@ export default function getListboxActionProps({
containerRef,
isLocked,
listboxSelectionToolbarItems,
extraItems,
selections,
keyboard,
}) {
return {
isDetached,
show: showToolbar && !isDetached,
popover: {
show: showToolbar && isDetached,
anchorEl: containerRef.current,
},
extraItems,
more: {
enabled: !isLocked,
actions: listboxSelectionToolbarItems,

View File

@@ -1,18 +1,11 @@
export default function showToolbarDetached({
containerRect,
titleRef,
iconsWidth,
headerPaddingLeft,
headerPaddingRight,
}) {
export default function showToolbarDetached({ containerRect, titleRef, iconsWidth, paddingLeft, paddingRight }) {
const containerWidth = containerRect.width;
const preventTruncation = 2;
const padding = headerPaddingLeft + headerPaddingRight;
const padding = paddingLeft + paddingRight;
const contentWidth = (titleRef?.current?.clientWidth ?? 0) + iconsWidth + padding + preventTruncation;
const actionToolbarWidth = 128;
const notSufficientSpace = containerWidth < contentWidth + actionToolbarWidth;
const isTruncated = titleRef?.current?.scrollWidth > titleRef?.current?.offsetWidth;
const isDetached = !!(notSufficientSpace || isTruncated);
return isDetached;
}

View File

@@ -7,6 +7,7 @@ import InstanceContext from '../contexts/InstanceContext';
export default function useDefaultSelectionActions({
api,
layout,
onConfirm = () => {},
onCancel = () => {},
onKeyDeactivate = () => {},
@@ -17,7 +18,7 @@ export default function useDefaultSelectionActions({
key: 'clear',
type: 'icon-button',
label: translator.get('Selection.Clear'),
enabled: () => api.canClear(),
enabled: () => api.canClear(layout),
action: () => api.clear(),
getSvgIconShape: clearSelections,
},
@@ -25,7 +26,7 @@ export default function useDefaultSelectionActions({
key: 'cancel',
type: 'icon-button',
label: translator.get('Selection.Cancel'),
enabled: () => api.canCancel(),
enabled: () => api.canCancel(layout),
action: () => {
onCancel();
api.cancel();
@@ -41,7 +42,7 @@ export default function useDefaultSelectionActions({
key: 'confirm',
type: 'icon-button',
label: translator.get('Selection.Confirm'),
enabled: () => api.canConfirm(),
enabled: () => api.canConfirm(layout),
action: () => {
onConfirm();
api.confirm();

View File

@@ -513,6 +513,7 @@ function nuked(configuration = {}) {
* @param {FrequencyMode=} [options.frequencyMode=none] Show frequency none|value|percent|relative
* @param {boolean=} [options.histogram=false] Show histogram bar (not applicable for existing objects)
* @param {SearchMode=} [options.search=true] Show the search bar permanently, using the toggle button or when in selection: false|true|toggle
* @param {boolean=} [options.showLock=false] Show the button for toggling locked state.
* @param {boolean=} [options.toolbar=true] Show the toolbar
* @param {boolean=} [options.checkboxes=false] Show values as checkboxes instead of as fields (not applicable for existing objects)
* @param {boolean=} [options.dense=false] Reduces padding and text size (not applicable for existing objects)

View File

@@ -1111,6 +1111,12 @@
"defaultValue": true,
"type": "#/definitions/SearchMode"
},
"showLock": {
"description": "Show the button for toggling locked state.",
"optional": true,
"defaultValue": false,
"type": "boolean"
},
"toolbar": {
"description": "Show the toolbar",
"optional": true,

View File

@@ -339,6 +339,7 @@ declare namespace stardust {
frequencyMode?: stardust.FrequencyMode;
histogram?: boolean;
search?: stardust.SearchMode;
showLock?: boolean;
toolbar?: boolean;
checkboxes?: boolean;
dense?: boolean;

View File

@@ -20,6 +20,18 @@ const StyledGrid = styled(Grid)(({ theme }) => ({
},
}));
/**
* Run this on small devices to reset the zoom. Required when focusing
* an input field and the browser auto zooms the page. Browsers do not
* expose any API for handling this currently.
*/
function resetZoom() {
const viewportMetaTag = document.querySelector('meta[name="viewport"]');
if (viewportMetaTag instanceof HTMLMetaElement) {
viewportMetaTag.content = 'width=device-width, minimum-scale=1.0, maximum-scale=1.0, initial-scale=1.0';
}
}
export default function Search({ onChange = () => {}, onEnter = () => {}, onEscape = () => {} }) {
const [value, setValue] = useState('');
const handleChange = (e) => {
@@ -54,6 +66,7 @@ export default function Search({ onChange = () => {}, onEnter = () => {}, onEsca
placeholder={placeholder}
value={value}
onChange={handleChange}
onFocus={resetZoom}
onKeyDown={onKeyDown}
/>
</Grid>

View File

@@ -2,6 +2,7 @@ import SvgIcon from './SvgIcon';
const unlock = (props) => ({
...props,
viewBox: '0 0 12 16',
shapes: [
{
type: 'path',
@@ -12,3 +13,4 @@ const unlock = (props) => ({
],
});
export default (props) => SvgIcon(unlock(props));
export { unlock };

View File

@@ -15,6 +15,7 @@ const colors = {
grey55: '#8C8C8C',
grey45: '#737373',
grey30: '#4D4D4D',
grey25: '#404040',
grey20: '#333333',

View File

@@ -43,6 +43,8 @@ const dark = {
focusBorder: colors.blue,
focusOutline: 'rgba(70, 157, 205, 0.3)',
inputBackground: 'rgba(0, 0, 0, 0.2)',
disabledBackground: colors.grey45,
disabledContrastText: colors.grey100,
},
selected: {
main: colors.green,

View File

@@ -41,6 +41,8 @@ const light = {
focusBorder: colors.blue,
focusOutline: 'rgba(70, 157, 205, 0.3)',
inputBackground: 'rgba(255, 255, 255, 1)',
disabledBackground: colors.grey45,
disabledContrastText: colors.grey100,
},
selected: {
main: colors.green,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB