Skip to content

Commit 9512363

Browse files
committed
menu filter and group improvements
1 parent 953f663 commit 9512363

File tree

3 files changed

+77
-34
lines changed

3 files changed

+77
-34
lines changed

src/controls/menu.tsx

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { Menu, MenuDivider, MenuGroup, MenuGroupHeader, MenuItem, MenuList, MenuListProps, MenuPopover, menuPopoverClassNames, MenuPopoverProps, MenuProps, MenuTrigger } from '@fluentui/react-components';
2-
import { ChevronLeftRegular, ChevronRightRegular } from '@fluentui/react-icons';
32
import { IDictionary, isNotEmptyArray, isNotEmptyString, isNullOrEmptyString, isNullOrUndefined, isNumber, isString, isUndefined, jsonClone, stopEvent } from '@kwiz/common';
43
import React from 'react';
5-
import { useStateEX } from '../helpers';
4+
import { useClickableDiv, useStateEX } from '../helpers';
65
import { useKWIZFluentContext } from '../helpers/context-internal';
76
import { ButtonEX, ButtonEXProps } from './button';
7+
import { DividerEX } from './divider';
88
import { Horizontal } from './horizontal';
99
import { Search } from './search';
10-
import { Section } from './section';
1110

1211
interface iMenuItemEXItem {
1312
type?: "item";
@@ -24,7 +23,8 @@ interface iMenuItemEXSeparator {
2423
interface iMenuItemEXGroup {
2524
type: "group";
2625
title: string;
27-
items: iMenuItemEX[];
26+
//can't nest groups
27+
items: (iMenuItemEX & { type?: "separator" | "item" })[];
2828
}
2929
export type iMenuItemEX = iMenuItemEXItem | iMenuItemEXSeparator | iMenuItemEXGroup;
3030

@@ -50,6 +50,8 @@ export const MenuEx: React.FunctionComponent<React.PropsWithChildren<IProps>> =
5050
const [keepOpen, setKeepOpen] = useStateEX<IDictionary<boolean>>({});
5151
const [opened, setOpened] = useStateEX<IDictionary<boolean>>({});
5252

53+
const clickableDiv = useClickableDiv();
54+
5355
React.useEffect(() => {
5456
window.setTimeout(() => {
5557
var menus = document.querySelectorAll(`.${menuPopoverClassNames.root}`);
@@ -65,18 +67,34 @@ export const MenuEx: React.FunctionComponent<React.PropsWithChildren<IProps>> =
6567

6668
function renderItems(items: iMenuItemEX[], level: number) {
6769
const myLevelFilter = filterPerLevel[level];
68-
//get rid of empty/null items
69-
items = items.filter(i => !isNullOrUndefined(i) && (isNotEmptyString(i.type) || isNotEmptyString((i as iMenuItemEXItem).title)))
70-
if (isNotEmptyString(myLevelFilter)) {
71-
items = items.filter(i => i.type !== "separator" && i.title.toLowerCase().indexOf(myLevelFilter) >= 0);
70+
71+
const showItem = (i: iMenuItemEX) => {
72+
//get rid of empty/null items
73+
let show = !isNullOrUndefined(i) && (isNotEmptyString(i.type) || isNotEmptyString((i as iMenuItemEXItem).title));
74+
if (show && isNotEmptyString(myLevelFilter)) {
75+
if (i.type === "separator") show = false;
76+
else if (i.type === "group") {
77+
//only show group if 1 or more results are in it
78+
return i.items.filter(sub => showItem(sub)).length > 0;
79+
}
80+
else
81+
show = i.title.toLowerCase().indexOf(myLevelFilter) >= 0;
82+
}
83+
return show;
7284
}
7385

86+
//inject group items into this level - so we share the filter/next functionality. it looks wierd if filter/paging is done per group if they are displayed inline.
87+
items = items.map(i => i.type === "group" && isNotEmptyArray(i.items) ? [i, ...i.items] : i)
88+
.flat()
89+
//filter empty item or based on text filter
90+
.filter(i => showItem(i));
91+
7492
let menuItems = items.map((item, index) => {
7593
switch (item.type) {
7694
case "group":
95+
//todo: technically group items should be nested inside the group for better screen reder support
7796
return <MenuGroup key={index}>
7897
<MenuGroupHeader>{item.title}</MenuGroupHeader>
79-
{renderItems(item.items, level + 1)}
8098
</MenuGroup>;
8199
case "separator":
82100
return <MenuDivider key={index} />;
@@ -113,36 +131,40 @@ export const MenuEx: React.FunctionComponent<React.PropsWithChildren<IProps>> =
113131

114132
const paged = menuItems.length > pageSize;
115133
const filtered = menuItems.length > filterThreshold || !isNullOrEmptyString(myLevelFilter);
116-
const filterControl = filtered && <Search defaultValue={myLevelFilter || ""} onChangeDeferred={(newValue) => {
117-
const s = jsonClone(filterPerLevel);
118-
s[level] = newValue ? newValue.toLowerCase() : "";
119-
setFilterPerLevel(s);
120-
}} />;
134+
121135
if (paged) {
122136
let start = startIndexPerLevel[level];
123137
if (isNullOrUndefined(start)) start = 0;
124138
let hasMore = menuItems.length > start + pageSize;
125139
menuItems = menuItems.slice(start, start + pageSize);
126-
if (start > 0 || hasMore) menuItems.splice(0, 0, <Horizontal key='$next'>
127-
<ButtonEX disabled={start < 1} icon={<ChevronLeftRegular />} title='previous' onClick={() => {
128-
const s = jsonClone(startIndexPerLevel);
129-
s[level] = start - pageSize;
130-
setStartIndexPerLevel(s);
131-
}} />
132-
<Section main>
133-
{filterControl}
134-
</Section>
135-
<ButtonEX disabled={!hasMore} icon={<ChevronRightRegular />} title='next' onClick={() => {
136-
const s = jsonClone(startIndexPerLevel);
137-
s[level] = start + pageSize;
138-
setStartIndexPerLevel(s);
139-
}} />
140-
</Horizontal>);
140+
if (start > 0) {
141+
menuItems.splice(0, 0, <DividerEX key="$prev" title='Previous'
142+
{...clickableDiv}
143+
onClick={() => {
144+
const s = jsonClone(startIndexPerLevel);
145+
s[level] = start - pageSize;
146+
setStartIndexPerLevel(s);
147+
}}
148+
>previous</DividerEX>);
149+
}
150+
if (hasMore)
151+
menuItems.push(<DividerEX key="$next" title='Next'
152+
{...clickableDiv}
153+
onClick={() => {
154+
const s = jsonClone(startIndexPerLevel);
155+
s[level] = start + pageSize;
156+
setStartIndexPerLevel(s);
157+
}}
158+
>next</DividerEX>);
141159
}
142-
else if (filtered) {
160+
if (filtered) {
143161
//just filter - no paging
144-
menuItems.splice(0, 0, <Horizontal key='$next'>
145-
{filterControl}
162+
menuItems.splice(0, 0, <Horizontal key='$search'>
163+
<Search defaultValue={myLevelFilter || ""} onChangeDeferred={(newValue) => {
164+
const s = jsonClone(filterPerLevel);
165+
s[level] = newValue ? newValue.toLowerCase() : "";
166+
setFilterPerLevel(s);
167+
}} />
146168
</Horizontal>);
147169
}
148170
return menuItems;

src/controls/search.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const useStyles = makeStyles({
1414
},
1515
searchIcon: {
1616
},
17-
})
17+
});
1818

1919
interface IProps extends Omit<InputProps, "onChange"> {
2020
main?: boolean;

src/helpers/hooks.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { makeStyles } from "@fluentui/react-components";
12
import { isFunction, isNotEmptyArray, isNullOrEmptyString, isPrimitiveValue, jsonClone, jsonStringify, LoggerLevel, objectsEqual, wrapFunction } from "@kwiz/common";
2-
import { MutableRefObject, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
3+
import { HTMLAttributes, MutableRefObject, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
34
import { GetLogger } from "../_modules/config";
5+
import { mixins } from "../styles/styles";
46

57
/** Empty array ensures that effect is only run on mount */
68
export const useEffectOnlyOnMount = [];
@@ -139,4 +141,23 @@ export function useRefWithState<T>(initialValue?: T, stateOptions: stateExOption
139141
/** for setting on element: ref={e.set} */
140142
set: setRef
141143
};
144+
}
145+
146+
const useStyles = makeStyles({
147+
clickable: mixins.clickable,
148+
});
149+
150+
/** return props to make div appear as clickable, and accept enter key as click */
151+
export function useClickableDiv() {
152+
const cssNames = useStyles();
153+
154+
const props: HTMLAttributes<HTMLDivElement> = {
155+
className: cssNames.clickable,
156+
tabIndex: 0,
157+
onKeyDown: e => {
158+
if (e.key === "Enter") (e.target as HTMLDivElement).click();
159+
}
160+
};
161+
162+
return props;
142163
}

0 commit comments

Comments
 (0)