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", "value": "Die Berechnungsbedingung ist nicht erfüllt",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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", "value": "Click to unlock",
"comment": "Unlock a selection from listbox" "comment": "Unlock a selection from listbox"
}, },
"SelectionToolbar.ClickToLock": {
"value": "Click to lock",
"comment": "Lock a selection in selections toolbar"
},
"Visualization.LayoutError": { "Visualization.LayoutError": {
"value": "Error", "value": "Error",
"comment": "Status text shown when a visualization has layout errors" "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", "value": "La condición de cálculo no se cumple",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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", "value": "Condition de calcul non remplie",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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", "value": "La condizione di calcolo non è soddisfatta",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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": "演算実行条件が満たされていません", "value": "演算実行条件が満たされていません",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "クリックしてロック",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
} }
} }

View File

@@ -258,5 +258,10 @@
"value": "계산 조건이 충족되지 않았습니다.", "value": "계산 조건이 충족되지 않았습니다.",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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", "value": "Er is niet aan de berekeningsvoorwaarde voldaan",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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", "value": "Warunek obliczenia nie jest spełniony",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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", "value": "A condição de cálculo não foi atendida",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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": "Условие вычисления не выполнено", "value": "Условие вычисления не выполнено",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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", "value": "Beräkningsvillkoret uppfylls inte",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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", "value": "Hesaplama koşulu yerine getirilmedi",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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": "不满足计算条件", "value": "不满足计算条件",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "version": "Hj3EiDijTWZ+MhDaqSx/uQ=="
},
"SelectionToolbar.ClickToLock": {
"value": "单击以锁定",
"comment": "Lock a selection in selection toolbar",
"version": "yOnFI2n2tK+87jTmGHU5vw=="
} }
} }

View File

@@ -258,5 +258,10 @@
"value": "不符計算條件", "value": "不符計算條件",
"comment": "Message displayed when a calculation condition is not fulfilled", "comment": "Message displayed when a calculation condition is not fulfilled",
"version": "Hj3EiDijTWZ+MhDaqSx/uQ==" "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: () => {}, onConfirm: () => {},
onCancel: () => {}, onCancel: () => {},
}, },
extraItems,
more = { more = {
enabled: false, enabled: false,
actions: [], actions: [],
@@ -78,10 +79,11 @@ function ActionsToolbar({
}, },
focusHandler = null, focusHandler = null,
actionsRefMock = null, // for testing actionsRefMock = null, // for testing
direction = 'ltr', isRtl,
autoConfirm = false, autoConfirm = false,
layout,
}) { }) {
const defaultSelectionActions = useDefaultSelectionActions(selections); const defaultSelectionActions = useDefaultSelectionActions({ ...selections, layout });
const { translator, keyboardNavigation } = useContext(InstanceContext); const { translator, keyboardNavigation } = useContext(InstanceContext);
const [showMoreItems, setShowMoreItems] = useState(false); const [showMoreItems, setShowMoreItems] = useState(false);
@@ -104,8 +106,8 @@ function ActionsToolbar({
}; };
const handleActionsKeyDown = useMemo( const handleActionsKeyDown = useMemo(
() => getActionsKeyDownHandler({ keyboardNavigation, focusHandler, getEnabledButton, selections }), () => getActionsKeyDownHandler({ keyboardNavigation, focusHandler, getEnabledButton, selections, isRtl }),
[keyboardNavigation, focusHandler, getEnabledButton, selections] [keyboardNavigation, focusHandler, getEnabledButton, selections, isRtl]
); );
useEffect( useEffect(
@@ -120,11 +122,11 @@ function ActionsToolbar({
const focusFirst = () => { const focusFirst = () => {
const enabledButton = getEnabledButton(false); const enabledButton = getEnabledButton(false);
enabledButton && enabledButton.focus(); enabledButton?.focus();
}; };
const focusLast = () => { const focusLast = () => {
const enabledButton = getEnabledButton(true); const enabledButton = getEnabledButton(true);
enabledButton && enabledButton.focus(); enabledButton?.focus();
}; };
focusHandler.on('focus_toolbar_first', focusFirst); focusHandler.on('focus_toolbar_first', focusFirst);
focusHandler.on('focus_toolbar_last', focusLast); focusHandler.on('focus_toolbar_last', focusLast);
@@ -163,7 +165,6 @@ function ActionsToolbar({
const showActions = newActions.length > 0; const showActions = newActions.length > 0;
const showMore = moreActions.length > 0; const showMore = moreActions.length > 0;
const showDivider = (showActions && selections.show) || (showMore && selections.show); const showDivider = (showActions && selections.show) || (showMore && selections.show);
const isRtl = direction === 'rtl';
const Actions = ( const Actions = (
<Grid <Grid
@@ -176,6 +177,9 @@ function ActionsToolbar({
data-testid="actions-toolbar" data-testid="actions-toolbar"
sx={{ flexDirection: isRtl ? 'row-reverse' : 'row' }} sx={{ flexDirection: isRtl ? 'row-reverse' : 'row' }}
> >
{extraItems?.length && (
<ActionsGroup className="actions-toolbar-extra-actions" actions={extraItems} isRtl={isRtl} />
)}
{showActions && <ActionsGroup actions={newActions} />} {showActions && <ActionsGroup actions={newActions} />}
{showMore && ( {showMore && (
<ActionsGroup <ActionsGroup

View File

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

View File

@@ -17,7 +17,13 @@ const focusButton = (index) => {
btn.focus(); btn.focus();
}; };
export default function getActionsKeyDownHandler({ keyboardNavigation, focusHandler, getEnabledButton, selections }) { export default function getActionsKeyDownHandler({
keyboardNavigation,
focusHandler,
getEnabledButton,
selections,
isRtl,
}) {
const handleActionsKeyDown = (evt) => { const handleActionsKeyDown = (evt) => {
const { target, nativeEvent } = evt; const { target, nativeEvent } = evt;
const { keyCode } = nativeEvent; const { keyCode } = nativeEvent;
@@ -29,7 +35,8 @@ export default function getActionsKeyDownHandler({ keyboardNavigation, focusHand
const isActionButton = target.classList.contains('njs-cell-action'); const isActionButton = target.classList.contains('njs-cell-action');
if (isActionButton) { if (isActionButton) {
const index = getActionButtonIndex(target); 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); focusButton(pressedLeft ? index - 1 : index + 1);
} }
evt.stopPropagation(); evt.stopPropagation();

View File

@@ -2,37 +2,23 @@
import React, { useContext, useCallback, useRef, useEffect, useState, useMemo } from 'react'; import React, { useContext, useCallback, useRef, useEffect, useState, useMemo } from 'react';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import Lock from '@nebula.js/ui/icons/lock'; import { Grid } from '@mui/material';
import { IconButton, Grid, Typography } from '@mui/material';
import { useTheme } from '@nebula.js/ui/theme'; 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 useLayout from '../../hooks/useLayout';
import ListBox from './ListBox'; import ListBox from './ListBox';
import createListboxSelectionToolbar from './interactions/listbox-selection-toolbar';
import ActionsToolbar from '../ActionsToolbar';
import InstanceContext from '../../contexts/InstanceContext'; import InstanceContext from '../../contexts/InstanceContext';
import ListBoxSearch from './components/ListBoxSearch'; import ListBoxSearch from './components/ListBoxSearch';
import getListboxContainerKeyboardNavigation from './interactions/keyboard-navigation/keyboard-nav-container'; import getListboxContainerKeyboardNavigation from './interactions/keyboard-navigation/keyboard-nav-container';
import getStyles from './assets/styling'; import getStyles from './assets/styling';
import useAppSelections from '../../hooks/useAppSelections'; 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 createSelectionState from './hooks/selections/selectionState';
import { import { DENSE_ROW_HEIGHT, SCROLL_BAR_WIDTH } from './constants';
CELL_PADDING_LEFT,
ICON_WIDTH,
ICON_PADDING,
BUTTON_ICON_WIDTH,
HEADER_PADDING_RIGHT,
DENSE_ROW_HEIGHT,
SCROLL_BAR_WIDTH,
} from './constants';
import useTempKeyboard from './components/useTempKeyboard'; import useTempKeyboard from './components/useTempKeyboard';
import ListBoxError from './components/ListBoxError'; import ListBoxError from './components/ListBoxError';
import useRect from '../../hooks/useRect'; import useRect from '../../hooks/useRect';
import isDirectQueryEnabled from './utils/is-direct-query'; import isDirectQueryEnabled from './utils/is-direct-query';
import getContainerPadding from './assets/list-sizes/container-padding'; import getContainerPadding from './assets/list-sizes/container-padding';
import ListBoxHeader from './components/ListBoxHeader';
const PREFIX = 'ListBoxInline'; const PREFIX = 'ListBoxInline';
const classes = { 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(); const isModal = ({ app, appSelections }) => app.isInModalSelection?.() ?? appSelections.isInModal();
function ListBoxInline({ options, layout }) { function ListBoxInline({ options, layout }) {
@@ -111,6 +78,7 @@ function ListBoxInline({ options, layout }) {
renderedCallback, renderedCallback,
toolbar = true, toolbar = true,
isPopover = false, isPopover = false,
showLock = false,
components, components,
} = options; } = options;
@@ -130,10 +98,6 @@ function ListBoxInline({ options, layout }) {
const theme = useTheme(); const theme = useTheme();
const unlock = useCallback(() => {
model.unlock('/qListObjectDef');
}, [model]);
const { translator, keyboardNavigation, themeApi, constraints } = useContext(InstanceContext); const { translator, keyboardNavigation, themeApi, constraints } = useContext(InstanceContext);
const { checkboxes = checkboxesOption } = layout || {}; const { checkboxes = checkboxesOption } = layout || {};
@@ -152,13 +116,11 @@ function ListBoxInline({ options, layout }) {
const updateKeyScroll = (newState) => setKeyScroll((current) => ({ ...current, ...newState })); const updateKeyScroll = (newState) => setKeyScroll((current) => ({ ...current, ...newState }));
const [currentScrollIndex, setCurrentScrollIndex] = useState({ start: 0, stop: 0 }); const [currentScrollIndex, setCurrentScrollIndex] = useState({ start: 0, stop: 0 });
const [appSelections] = useAppSelections(app); const [appSelections] = useAppSelections(app);
const titleRef = useRef(null);
const [selectionState] = useState(() => createSelectionState()); const [selectionState] = useState(() => createSelectionState());
const keyboard = useTempKeyboard({ containerRef, enabled: keyboardNavigation }); const keyboard = useTempKeyboard({ containerRef, enabled: keyboardNavigation });
const isModalMode = useCallback(() => isModal({ app, appSelections }), [app, appSelections]); const isModalMode = useCallback(() => isModal({ app, appSelections }), [app, appSelections]);
const isInvalid = layout?.qListObject.qDimensionInfo.qError; const isInvalid = layout?.qListObject.qDimensionInfo.qError;
const errorText = isInvalid && constraints.active ? 'Visualization.Invalid.Dimension' : 'Visualization.Incomplete'; const errorText = isInvalid && constraints.active ? 'Visualization.Invalid.Dimension' : 'Visualization.Incomplete';
const [isToolbarDetached, setIsToolbarDetached] = useState(false);
const { handleKeyDown, handleOnMouseEnter, handleOnMouseLeave, globalKeyDown } = useMemo( 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(() => { useEffect(() => {
document.addEventListener('keydown', globalKeyDown); document.addEventListener('keydown', globalKeyDown);
return () => { return () => {
@@ -197,6 +156,9 @@ function ListBoxInline({ options, layout }) {
}, [globalKeyDown]); }, [globalKeyDown]);
useEffect(() => { useEffect(() => {
if (search === true) {
setShowSearch(true);
}
const show = () => { const show = () => {
setShowToolbar(true); setShowToolbar(true);
}; };
@@ -207,6 +169,7 @@ function ListBoxInline({ options, layout }) {
} }
}; };
if (isPopover) { if (isPopover) {
// When isPopover, toolbar == false will be ignored.
if (!selections.isActive()) { if (!selections.isActive()) {
selections.begin('/qListObjectDef'); selections.begin('/qListObjectDef');
selections.on('activated', show); selections.on('activated', show);
@@ -214,7 +177,7 @@ function ListBoxInline({ options, layout }) {
} }
setShowToolbar(isPopover); setShowToolbar(isPopover);
} }
if (selections) { if (toolbar && selections) {
if (!selections.isModal()) { if (!selections.isModal()) {
selections.on('activated', show); selections.on('activated', show);
selections.on('deactivated', hide); selections.on('deactivated', hide);
@@ -228,7 +191,7 @@ function ListBoxInline({ options, layout }) {
selections.removeListener('deactivated', hide); selections.removeListener('deactivated', hide);
} }
}; };
}, [selections, isPopover]); }, [toolbar, selections, isPopover]);
useEffect(() => { useEffect(() => {
if (!searchContainer || !searchContainer.current) { if (!searchContainer || !searchContainer.current) {
@@ -242,75 +205,38 @@ function ListBoxInline({ options, layout }) {
}, [searchContainer && searchContainer.current, showSearch, search, focusSearch]); }, [searchContainer && searchContainer.current, showSearch, search, focusSearch]);
const { wildCardSearch, searchEnabled, autoConfirm = false, layoutOptions = {} } = layout ?? {}; const { wildCardSearch, searchEnabled, autoConfirm = false, layoutOptions = {} } = layout ?? {};
const showSearchIcon = searchEnabled !== false && search === 'toggle'; const isLocked = layout?.qListObject?.qDimensionInfo?.qLocked;
const isLocked = layout?.qListObject?.qDimensionInfo?.qLocked === true; const showSearchIcon = searchEnabled !== false && search === 'toggle' && !isLocked;
const showSearchOrLockIcon = isLocked || showSearchIcon;
const isDrillDown = layout?.qListObject?.qDimensionInfo?.qGrouping === 'H'; 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 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) { if (!model || !layout || !translator) {
return null; return null;
} }
const listboxSelectionToolbarItems = createListboxSelectionToolbar({
layout,
model,
translator,
selectionState,
isDirectQuery,
});
const showTitle = true;
const showSearchToggle = search === 'toggle' && showSearch; 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 dense = layoutOptions.dense ?? false;
const onShowSearch = () => { const handleShowSearch = () => {
const newValue = !showSearch; const newValue = !showSearch;
setShowSearch(newValue); setShowSearch(newValue);
}; };
const onCtrlF = () => { const onCtrlF = () => {
if (search === 'toggle') { if (search === 'toggle') {
onShowSearch(); handleShowSearch();
} else { } else {
const input = searchContainer.current.querySelector('input'); const input = searchContainer.current.querySelector('input');
input?.focus(); input?.focus();
} }
}; };
const getActionToolbarProps = (isDetached) => ({
...getListboxActionProps({
isDetached: isPopover ? false : isDetached,
showToolbar,
containerRef,
isLocked,
listboxSelectionToolbarItems,
selections,
keyboard,
}),
autoConfirm,
});
const shouldAutoFocus = searchVisible && search === 'toggle'; const shouldAutoFocus = searchVisible && search === 'toggle';
// Add a container padding for grid mode to harmonize with the grid item margins (should sum to 8px). // 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, 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) { if (isInvalid) {
renderedCallback?.(); 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 ( return (
<> <>
{showDetachedToolbarOnly && <ActionsToolbar direction={direction} {...getActionToolbarProps(true)} />} {showDetachedToolbarOnly && listBoxHeader}
<StyledGrid <StyledGrid
className="listbox-container" className="listbox-container"
container container
@@ -374,47 +304,7 @@ function ListBoxInline({ options, layout }) {
isGridMode={isGridMode} isGridMode={isGridMode}
aria-label={keyboard.active ? translator.get('Listbox.ScreenReaderInstructions') : ''} aria-label={keyboard.active ? translator.get('Listbox.ScreenReaderInstructions') : ''}
> >
{showToolbarWithTitle && ( {showAttachedToolbar && listBoxHeader}
<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>
)}
<Grid <Grid
item item
container container
@@ -437,7 +327,7 @@ function ListBoxInline({ options, layout }) {
wildCardSearch={wildCardSearch} wildCardSearch={wildCardSearch}
searchEnabled={searchEnabled} searchEnabled={searchEnabled}
direction={direction} direction={direction}
hide={showSearchIcon && onShowSearch} hide={showSearchIcon && handleShowSearch}
styles={styles} styles={styles}
/> />
</Grid> </Grid>

View File

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

View File

@@ -34,7 +34,6 @@ jest.mock('../interactions/keyboard-navigation/keyboard-nav-container', () => ({
describe('<ListboxInline />', () => { describe('<ListboxInline />', () => {
const app = { key: 'app' }; const app = { key: 'app' };
let options;
let useState; let useState;
let useEffect; let useEffect;
let useCallback; let useCallback;
@@ -49,6 +48,7 @@ describe('<ListboxInline />', () => {
let render; let render;
let getListboxInlineKeyboardNavigation; let getListboxInlineKeyboardNavigation;
let InstanceContext; let InstanceContext;
let defaultOptions;
beforeEach(() => { beforeEach(() => {
useState = jest.fn(); useState = jest.fn();
@@ -111,7 +111,7 @@ describe('<ListboxInline />', () => {
removeListener: jest.fn(), removeListener: jest.fn(),
}; };
options = { defaultOptions = {
app, app,
title: 'title', title: 'title',
direction: 'vertical', direction: 'vertical',
@@ -161,12 +161,13 @@ describe('<ListboxInline />', () => {
beforeEach(() => { beforeEach(() => {
const theme = createTheme('dark'); const theme = createTheme('dark');
render = async () => { render = async (options = {}) => {
const mergedOptions = { ...defaultOptions, ...options };
await act(async () => { await act(async () => {
renderer = create( renderer = create(
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<InstanceContext.Provider value={{ translator: { get: (s) => s, language: () => 'sv' } }}> <InstanceContext.Provider value={{ translator: { get: (s) => s, language: () => 'sv' } }}>
<ListBoxInline options={options} /> <ListBoxInline options={mergedOptions} />
</InstanceContext.Provider> </InstanceContext.Provider>
</ThemeProvider> </ThemeProvider>
); );
@@ -201,8 +202,8 @@ describe('<ListboxInline />', () => {
}); });
test('should render properly with search toggle option', async () => { test('should render properly with search toggle option', async () => {
options.search = 'toggle'; const options = { search: 'toggle' };
await render(); await render(options);
const searchToggleBtns = renderer.root const searchToggleBtns = renderer.root
.findAllByProps({ 'data-testid': 'search-toggle-btn' }) .findAllByProps({ 'data-testid': 'search-toggle-btn' })
@@ -215,8 +216,8 @@ describe('<ListboxInline />', () => {
}); });
test('should render without toolbar', async () => { test('should render without toolbar', async () => {
options.toolbar = false; const options = { toolbar: false };
await render(); await render(options);
const actionToolbars = renderer.root.findAllByType(ActionsToolbar); const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars).toHaveLength(0); expect(actionToolbars).toHaveLength(0);
@@ -228,8 +229,8 @@ describe('<ListboxInline />', () => {
}); });
test('should render without toolbar', async () => { test('should render without toolbar', async () => {
options.search = 'toggle'; const options = { search: 'toggle' };
await render(); await render(options);
expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({ expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({
visible: false, visible: false,
@@ -241,8 +242,8 @@ describe('<ListboxInline />', () => {
}); });
test('should render without search and show search button', async () => { test('should render without search and show search button', async () => {
options.search = false; const options = { search: false };
await render(); await render(options);
const actionToolbars = renderer.root.findAllByType(ActionsToolbar); const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars).toHaveLength(1); expect(actionToolbars).toHaveLength(1);
@@ -255,8 +256,8 @@ describe('<ListboxInline />', () => {
}); });
test('should render with NOT autoFocus when search is true', async () => { test('should render with NOT autoFocus when search is true', async () => {
options.search = true; const options = { search: true };
await render(); await render(options);
expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({ expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({
visible: true, visible: true,
@@ -265,9 +266,8 @@ describe('<ListboxInline />', () => {
}); });
test('should show toolbar when opened in a popover', async () => { test('should show toolbar when opened in a popover', async () => {
options.search = false; const options = { search: false, isPopover: true };
options.isPopover = true; await render(options);
await render();
const actionToolbars = renderer.root.findAllByType(ActionsToolbar); const actionToolbars = renderer.root.findAllByType(ActionsToolbar);
expect(actionToolbars).toHaveLength(1); 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, paddingLeft: isRtl ? 8 : checkboxes ? 0 : undefined,
paddingRight: checkboxes ? 0 : isRtl ? 8 : 0, paddingRight: checkboxes ? 0 : isRtl ? 8 : 0,
justifyContent: valueTextAlign, justifyContent: valueTextAlign,
textAlign: valueTextAlign,
}; };
const isFirstElement = index === 0; const isFirstElement = index === 0;
const showLock = isSelected && isLocked; const showLockIcon = isSelected && isLocked;
const showTick = !checkboxes && isSelected && !isLocked; const showTickIcon = !checkboxes && isSelected && !isLocked;
const showIcon = !checkboxes && sizePermitsTickOrLock; const showAnyIcon = !checkboxes && sizePermitsTickOrLock;
const cellPaddingRight = checkboxes || !sizePermitsTickOrLock; const cellPaddingRight = checkboxes || !sizePermitsTickOrLock;
const ariaLabel = getValueLabel({ const ariaLabel = getValueLabel({
@@ -288,10 +289,10 @@ function RowColumn({ index, rowIndex, columnIndex, style, data }) {
{freqIsAllowed && <Frequency cell={cell} checkboxes={checkboxes} dense={dense} showGray={showGray} />} {freqIsAllowed && <Frequency cell={cell} checkboxes={checkboxes} dense={dense} showGray={showGray} />}
{showIcon && ( {showAnyIcon && (
<Grid item className={classes.icon}> <Grid item className={classes.icon}>
{showLock && <Lock style={iconStyles} size="small" />} {showLockIcon && <Lock style={iconStyles} size="small" />}
{showTick && <Tick style={iconStyles} size="small" />} {showTickIcon && <Tick style={iconStyles} size="small" />}
</Grid> </Grid>
)} )}
</ItemGrid> </ItemGrid>

View File

@@ -154,7 +154,7 @@ export default function ListBoxSearch({
function focusRow(container) { function focusRow(container) {
const row = container?.querySelector('.last-focused') || container?.querySelector('[role="row"]:first-child'); const row = container?.querySelector('.last-focused') || container?.querySelector('[role="row"]:first-child');
row.setAttribute('tabIndex', 0); row?.setAttribute('tabIndex', 0);
row?.focus(); 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 lastSelectedRow = c?.querySelector('.value.last-focused');
const firstRowElement = c?.querySelector('.value.selector, .value'); const firstRowElement = c?.querySelector('.value.selector, .value');
const confirmButton = c?.querySelector('.actions-toolbar-default-actions .actions-toolbar-confirm'); 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?.setAttribute('tabIndex', 0);
elementToFocus?.focus(); elementToFocus?.focus();
}, },
focusSelection() { focusSelection() {
const unlockCoverButton = document.querySelector('#listbox-unlock-button');
const confirmButton = document.querySelector('.actions-toolbar-default-actions .actions-toolbar-confirm'); const confirmButton = document.querySelector('.actions-toolbar-default-actions .actions-toolbar-confirm');
confirmButton?.setAttribute('tabIndex', 0); const btnToFocus = unlockCoverButton || confirmButton;
confirmButton?.focus(); btnToFocus?.setAttribute('tabIndex', 0);
btnToFocus?.focus();
}, },
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -513,6 +513,7 @@ function nuked(configuration = {}) {
* @param {FrequencyMode=} [options.frequencyMode=none] Show frequency none|value|percent|relative * @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 {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 {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.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.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) * @param {boolean=} [options.dense=false] Reduces padding and text size (not applicable for existing objects)

View File

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

View File

@@ -339,6 +339,7 @@ declare namespace stardust {
frequencyMode?: stardust.FrequencyMode; frequencyMode?: stardust.FrequencyMode;
histogram?: boolean; histogram?: boolean;
search?: stardust.SearchMode; search?: stardust.SearchMode;
showLock?: boolean;
toolbar?: boolean; toolbar?: boolean;
checkboxes?: boolean; checkboxes?: boolean;
dense?: 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 = () => {} }) { export default function Search({ onChange = () => {}, onEnter = () => {}, onEscape = () => {} }) {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const handleChange = (e) => { const handleChange = (e) => {
@@ -54,6 +66,7 @@ export default function Search({ onChange = () => {}, onEnter = () => {}, onEsca
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
onFocus={resetZoom}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
</Grid> </Grid>

View File

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

View File

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

View File

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

View File

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