@trackunit/filters-filter-bar 2.6.33 → 2.6.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.cjs.js +103 -29
- package/index.esm.js +105 -31
- package/package.json +12 -12
- package/src/lib/components/useFiltersMenuSearchNavigation.d.ts +16 -0
package/index.cjs.js
CHANGED
|
@@ -55,6 +55,7 @@ var defaultTranslations = {
|
|
|
55
55
|
"filtersBar.searchPlaceholder": "Search...",
|
|
56
56
|
"filtersBar.selectAll": "Select All",
|
|
57
57
|
"filtersBar.showAll": "Show all",
|
|
58
|
+
"filtersBar.visibleCustomFields": "Visible custom fields",
|
|
58
59
|
"filtersMenu.appliedFiltersLabel.plural": "{{count}} filters applied",
|
|
59
60
|
"filtersMenu.appliedFiltersLabel.singular": "1 filter applied"
|
|
60
61
|
};
|
|
@@ -705,16 +706,18 @@ const useFiltersMenu = ({ filterBarDefinition, filterBarConfig, hiddenFilters =
|
|
|
705
706
|
]);
|
|
706
707
|
};
|
|
707
708
|
|
|
709
|
+
const FilterGroupSection = ({ filterBarConfig, group, showSeparator, showTitle, }) => {
|
|
710
|
+
const titleId = react.useId();
|
|
711
|
+
return (jsxRuntime.jsxs("div", { className: "flex flex-col gap-1", children: [jsxRuntime.jsxs("div", { children: [showTitle ? (jsxRuntime.jsx(reactComponents.Text, { className: "h-7 p-2 text-neutral-400", "data-testid": `${group.key}-group-title`, id: titleId, size: "small", uppercase: true, weight: "bold", children: group.title })) : null, jsxRuntime.jsx("div", { "aria-labelledby": showTitle ? titleId : undefined, className: "grid", "data-testid": `${group.key}-group-list`, role: "group", children: jsxRuntime.jsx(FiltersRenderer, { filterBarConfig: filterBarConfig, filters: group.filters, visualStyle: "list-item" }) })] }), showSeparator ? jsxRuntime.jsx(reactComponents.MenuDivider, {}) : null] }));
|
|
712
|
+
};
|
|
708
713
|
/**
|
|
709
714
|
* FiltersList is a React component that displays a list of filters within a filter bar.
|
|
710
715
|
*
|
|
711
716
|
* @returns {ReactElement} - Returns the FiltersList component.
|
|
712
717
|
*/
|
|
713
718
|
const GroupedFiltersList = ({ filterBarConfig, filtersGrouped, className, ref, style, "data-testid": dataTestId = "grouped-filters-list", }) => {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
return (jsxRuntime.jsxs("div", { className: "flex flex-col gap-1", children: [jsxRuntime.jsxs("div", { children: [filtersGrouped.length > 1 && (jsxRuntime.jsx(reactComponents.Text, { className: "h-7 p-2 text-neutral-400", "data-testid": `${group.key}-group-title`, size: "small", uppercase: true, weight: "bold", children: group.title })), jsxRuntime.jsx("ul", { "aria-labelledby": `${group.key}-group-title`, className: "grid", "data-testid": `${group.key}-group-list`, children: jsxRuntime.jsx(FiltersRenderer, { filterBarConfig: filterBarConfig, filters: group.filters, visualStyle: "list-item" }) })] }), isLastGroup ? null : jsxRuntime.jsx("div", { className: "h-[1px] w-full bg-neutral-200", role: "separator" })] }, group.key));
|
|
717
|
-
}) }));
|
|
719
|
+
const showTitle = filtersGrouped.length > 1;
|
|
720
|
+
return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: filtersGrouped.map((group, idx) => (jsxRuntime.jsx(FilterGroupSection, { filterBarConfig: filterBarConfig, group: group, showSeparator: idx !== filtersGrouped.length - 1, showTitle: showTitle }, group.key))) }));
|
|
718
721
|
};
|
|
719
722
|
|
|
720
723
|
/**
|
|
@@ -729,33 +732,104 @@ const ResetFiltersButton = ({ resetFiltersToInitialState, "data-testid": dataTes
|
|
|
729
732
|
}, ref: ref, size: "small", style: style, variant: "ghost", children: [t("filtersBar.resetFilters"), jsxRuntime.jsx("span", { className: "sr-only", children: t("filtersBar.resetFiltersSR") })] }));
|
|
730
733
|
};
|
|
731
734
|
|
|
735
|
+
const firstEnabledMenuItem = (root) => root?.querySelector('[role="menuitem"]:not([aria-disabled="true"])') ?? null;
|
|
736
|
+
/**
|
|
737
|
+
* Bridges Search and the registered FilterBar rows: Down from Search enters the first matching
|
|
738
|
+
* item; Up from that item returns to Search. Initial focus lands on Search when it is shown,
|
|
739
|
+
* otherwise on the first enabled row.
|
|
740
|
+
*
|
|
741
|
+
* @returns {UseFiltersMenuSearchNavigationResult} Search ref, panel-node callback, and Search ArrowDown handler
|
|
742
|
+
*/
|
|
743
|
+
const useFiltersMenuSearchNavigation = ({ showSearch, }) => {
|
|
744
|
+
const searchInputRef = react.useRef(null);
|
|
745
|
+
const panelRef = react.useRef(null);
|
|
746
|
+
const onPanelArrowUp = react.useCallback((event) => {
|
|
747
|
+
if (event.key !== "ArrowUp") {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const firstItem = firstEnabledMenuItem(panelRef.current);
|
|
751
|
+
const active = document.activeElement;
|
|
752
|
+
if (firstItem && active && (active === firstItem || firstItem.contains(active))) {
|
|
753
|
+
// Capture on the menu root so this wins over useListNavigation's
|
|
754
|
+
// loop:true wrap (React onKeyDown on the floating element).
|
|
755
|
+
event.preventDefault();
|
|
756
|
+
event.stopImmediatePropagation();
|
|
757
|
+
searchInputRef.current?.focus();
|
|
758
|
+
}
|
|
759
|
+
}, []);
|
|
760
|
+
const setPanelNode = react.useCallback((node) => {
|
|
761
|
+
if (panelRef.current) {
|
|
762
|
+
panelRef.current.removeEventListener("keydown", onPanelArrowUp, true);
|
|
763
|
+
}
|
|
764
|
+
panelRef.current = node;
|
|
765
|
+
if (node && showSearch) {
|
|
766
|
+
// Attach here, not in an effect: MenuContent's ref is set after the
|
|
767
|
+
// first paint, and a [showSearch]-only effect can snapshot a null panel.
|
|
768
|
+
node.addEventListener("keydown", onPanelArrowUp, true);
|
|
769
|
+
}
|
|
770
|
+
}, [onPanelArrowUp, showSearch]);
|
|
771
|
+
react.useEffect(() => {
|
|
772
|
+
const focusInitialTarget = () => {
|
|
773
|
+
if (showSearch) {
|
|
774
|
+
searchInputRef.current?.focus();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
firstEnabledMenuItem(panelRef.current)?.focus();
|
|
778
|
+
};
|
|
779
|
+
// Floating UI's default initialFocus is 0 (the trigger). FilterBar cannot
|
|
780
|
+
// use initialFocus={1}: that would land on Reset when Search is hidden.
|
|
781
|
+
const frame = requestAnimationFrame(focusInitialTarget);
|
|
782
|
+
return () => cancelAnimationFrame(frame);
|
|
783
|
+
}, [showSearch]);
|
|
784
|
+
const handleSearchKeyDown = react.useCallback((event) => {
|
|
785
|
+
if (event.key !== "ArrowDown") {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const firstItem = firstEnabledMenuItem(panelRef.current);
|
|
789
|
+
if (firstItem) {
|
|
790
|
+
// Stop list-navigation from owning this ArrowDown so activeIndex stays
|
|
791
|
+
// unset and ArrowUp from the first row can return to Search.
|
|
792
|
+
event.preventDefault();
|
|
793
|
+
event.stopPropagation();
|
|
794
|
+
firstItem.focus();
|
|
795
|
+
}
|
|
796
|
+
}, []);
|
|
797
|
+
return react.useMemo(() => ({
|
|
798
|
+
searchInputRef,
|
|
799
|
+
setPanelNode,
|
|
800
|
+
handleSearchKeyDown,
|
|
801
|
+
}), [setPanelNode, handleSearchKeyDown]);
|
|
802
|
+
};
|
|
803
|
+
|
|
732
804
|
/**
|
|
733
805
|
*
|
|
734
806
|
*/
|
|
735
807
|
const FiltersMenuContent = ({ filterBarConfig, setShowCustomFilters, setSearchText, searchText, searchResultsGrouped, filtersToShowGrouped, removeCustomFieldsGroup, hasCustomFields, appliedCustomFields, showCustomFilters, filterBarDefinitionCount, className, "data-testid": dataTestId = "starred-filters-menu-popover", ref, style, }) => {
|
|
736
808
|
const [t] = useTranslation();
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
758
|
-
|
|
809
|
+
const filtersListId = react.useId();
|
|
810
|
+
const showSearch = filterBarDefinitionCount > 5;
|
|
811
|
+
const { searchInputRef, setPanelNode, handleSearchKeyDown } = useFiltersMenuSearchNavigation({
|
|
812
|
+
showSearch,
|
|
813
|
+
});
|
|
814
|
+
const setPanelRef = react.useCallback((node) => {
|
|
815
|
+
setPanelNode(node);
|
|
816
|
+
if (typeof ref === "function") {
|
|
817
|
+
ref(node);
|
|
818
|
+
}
|
|
819
|
+
else if (ref) {
|
|
820
|
+
ref.current = node;
|
|
821
|
+
}
|
|
822
|
+
}, [setPanelNode, ref]);
|
|
823
|
+
const visibleGroups = searchText
|
|
824
|
+
? searchResultsGrouped
|
|
825
|
+
: showCustomFilters
|
|
826
|
+
? filtersToShowGrouped
|
|
827
|
+
: removeCustomFieldsGroup(filtersToShowGrouped);
|
|
828
|
+
const hasVisibleFilters = visibleGroups.some(group => group.filters.length > 0);
|
|
829
|
+
return (jsxRuntime.jsxs(reactComponents.MenuContent, { "aria-label": t("filtersBar.filtersHeading"), className: tailwindMerge.twMerge("max-h-[min(600px,calc(100dvh-32px))] grid-rows-[minmax(0,1fr)] overflow-y-hidden p-0", className), "data-testid": dataTestId, listClassName: "grid-rows-min-fr !max-h-none min-h-0 gap-y-0 !overflow-y-hidden p-0", ref: setPanelRef, style: style, children: [jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("div", { className: "flex flex-col gap-1 p-1", children: [showSearch ? (jsxRuntime.jsx(reactFormComponents.Search, { "data-testid": "starred-filters-menu-search", fieldSize: "small", id: "search-filters-list", onChange: e => setSearchText(e.currentTarget.value), onClear: () => setSearchText(""), onKeyDown: handleSearchKeyDown, placeholder: t("filtersBar.searchFiltersPlaceholder"), ref: searchInputRef, value: searchText })) : null, jsxRuntime.jsxs("div", { className: "flex h-7 items-center justify-between gap-1 pl-3", children: [jsxRuntime.jsx(reactComponents.Text, { className: "text-neutral-400", size: "small", children: jsxRuntime.jsx(FiltersAppliedCountLabel, { filterBarConfig: filterBarConfig }) }), filterBarConfig.appliedFilterKeys().length > 0 ? (jsxRuntime.jsx(ResetFiltersButton, { resetFiltersToInitialState: filterBarConfig.resetFiltersToInitialState })) : null] })] }), jsxRuntime.jsx(reactComponents.MenuDivider, {})] }), jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-col gap-1 overflow-auto p-1", id: filtersListId, children: [searchText && !hasVisibleFilters ? (jsxRuntime.jsx("div", { "aria-live": "polite", role: "status", children: jsxRuntime.jsx(reactComponents.Text, { className: "p-2 text-neutral-400", size: "small", children: t("filtersBar.emptyResults") }) })) : (jsxRuntime.jsx(GroupedFiltersList, { className: "flex flex-col gap-1", filterBarConfig: filterBarConfig, filtersGrouped: visibleGroups })), hasCustomFields && !showCustomFilters && !searchText ? (jsxRuntime.jsx(CustomFieldsHiddenGroup, { appliedCustomFields: appliedCustomFields, filterBarConfig: filterBarConfig, filtersListId: filtersListId, onClickShow: () => {
|
|
830
|
+
setShowCustomFilters(true);
|
|
831
|
+
} })) : null] })] }));
|
|
832
|
+
};
|
|
759
833
|
const FiltersAppliedCountLabel = ({ filterBarConfig, }) => {
|
|
760
834
|
const [t] = useTranslation();
|
|
761
835
|
// Count actual applied values, not just filter keys - same logic as badge
|
|
@@ -777,9 +851,9 @@ const FiltersAppliedCountLabel = ({ filterBarConfig, }) => {
|
|
|
777
851
|
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: t("filtersMenu.appliedFiltersLabel.plural", { count: appliedValuesCount }) });
|
|
778
852
|
}
|
|
779
853
|
};
|
|
780
|
-
const CustomFieldsHiddenGroup = ({ appliedCustomFields, filterBarConfig, onClickShow, }) => {
|
|
854
|
+
const CustomFieldsHiddenGroup = ({ appliedCustomFields, filterBarConfig, filtersListId, onClickShow, }) => {
|
|
781
855
|
const [t] = useTranslation();
|
|
782
|
-
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(
|
|
856
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactComponents.MenuDivider, {}), jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx(reactComponents.Button, { "aria-controls": filtersListId, className: "text-primary-600 w-full justify-between px-3", onClick: onClickShow, prefix: jsxRuntime.jsx(reactComponents.Text, { className: "text-neutral-400", size: "small", uppercase: true, weight: "bold", children: t("filtersBar.groups.CUSTOM_FIELDS") }), size: "small", variant: "ghost-neutral", children: t("filtersBar.showAll") }), appliedCustomFields.length > 0 ? (jsxRuntime.jsx("div", { "aria-label": t("filtersBar.visibleCustomFields"), "data-testid": "applied-custom-fields-list", role: "group", children: jsxRuntime.jsx(FiltersRenderer, { filterBarConfig: filterBarConfig, filters: appliedCustomFields, visualStyle: "list-item" }) })) : null] })] }));
|
|
783
857
|
};
|
|
784
858
|
|
|
785
859
|
const sharedButtonProps = {
|
|
@@ -972,7 +1046,7 @@ const FilterTableComponent = ({ filterKey, filterBarDefinition, filterBarConfig,
|
|
|
972
1046
|
const multipleFiltersAppliedCount = getManuallyAppliedFilterCount(activeFilterKeys);
|
|
973
1047
|
const multipleFiltersIsActive = multipleFiltersAppliedCount > 0;
|
|
974
1048
|
return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: jsxRuntime.jsx(reactComponents.MenuTree, { children: jsxRuntime.jsx(reactComponents.Popover, { placement: "bottom-start", children: modalState => {
|
|
975
|
-
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx("div", { className: "relative", children: jsxRuntime.jsx(reactComponents.Tooltip, { disabled: modalState.isOpen || !multipleFiltersIsActive, label: jsxRuntime.jsx(MultipleFilterTooltipLabel, { filterBarConfig: filterBarConfig, filterKeys: filterKey, filters: filters }), placement: "bottom", children: jsxRuntime.jsx(reactComponents.IconButton, { ariaLabel: t("filtersBar.filtersHeading"), className: "h-8 w-5 p-0", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Funnel", size: "small" }), variant: multipleFiltersIsActive ? "ghost" : "ghost-neutral" }) }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { children: jsxRuntime.jsx(
|
|
1049
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx("div", { className: "relative", children: jsxRuntime.jsx(reactComponents.Tooltip, { disabled: modalState.isOpen || !multipleFiltersIsActive, label: jsxRuntime.jsx(MultipleFilterTooltipLabel, { filterBarConfig: filterBarConfig, filterKeys: filterKey, filters: filters }), placement: "bottom", children: jsxRuntime.jsx(reactComponents.IconButton, { ariaLabel: t("filtersBar.filtersHeading"), className: "h-8 w-5 p-0", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Funnel", size: "small" }), variant: multipleFiltersIsActive ? "ghost" : "ghost-neutral" }) }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { initialFocus: 1, children: jsxRuntime.jsx(reactComponents.MenuContent, { "aria-label": t("filtersBar.filtersHeading"), "data-testid": "filter-table-menu", children: filters.map((value, index) => value && (jsxRuntime.jsx(FilterComponent, { filter: value, filterBarActions: filterBarConfig, filterState: filterBarConfig, visualStyle: "list-item" }, index))) }) })] }));
|
|
976
1050
|
} }) }) }));
|
|
977
1051
|
}
|
|
978
1052
|
const filter = filterBarDefinition[ensureFilterKey(filterKey)];
|
package/index.esm.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
|
|
2
2
|
import { registerTranslations, useNamespaceTranslation } from '@trackunit/i18n-library-translation';
|
|
3
|
-
import { useMemo, useState, useEffect, useCallback, Fragment as Fragment$1
|
|
3
|
+
import { useMemo, useState, useEffect, useCallback, useId, useRef, Fragment as Fragment$1 } from 'react';
|
|
4
4
|
import { twMerge } from 'tailwind-merge';
|
|
5
5
|
import { Filter, FilterBody, RadioFilterItem, CheckBoxFilterItem, FilterHeader as FilterHeader$1, FilterFooter } from '@trackunit/react-filter-components';
|
|
6
|
-
import { Button, Icon, useList, List, Text, useTextSearch,
|
|
6
|
+
import { Button, Icon, useList, List, Text, useTextSearch, MenuDivider, MenuContent, ZStack, IconButton, Badge, useViewportBreakpoints, MenuTree, Popover, PopoverTrigger, Tooltip, PopoverContent, useCustomEncoding, useSearchParamSync, useStorageKey, useWatch } from '@trackunit/react-components';
|
|
7
7
|
import { useAnalytics, useCurrentUser } from '@trackunit/react-core-hooks';
|
|
8
8
|
import { capitalize } from 'string-ts';
|
|
9
9
|
import { createEvent } from '@trackunit/iris-app-runtime-core-api';
|
|
@@ -53,6 +53,7 @@ var defaultTranslations = {
|
|
|
53
53
|
"filtersBar.searchPlaceholder": "Search...",
|
|
54
54
|
"filtersBar.selectAll": "Select All",
|
|
55
55
|
"filtersBar.showAll": "Show all",
|
|
56
|
+
"filtersBar.visibleCustomFields": "Visible custom fields",
|
|
56
57
|
"filtersMenu.appliedFiltersLabel.plural": "{{count}} filters applied",
|
|
57
58
|
"filtersMenu.appliedFiltersLabel.singular": "1 filter applied"
|
|
58
59
|
};
|
|
@@ -703,16 +704,18 @@ const useFiltersMenu = ({ filterBarDefinition, filterBarConfig, hiddenFilters =
|
|
|
703
704
|
]);
|
|
704
705
|
};
|
|
705
706
|
|
|
707
|
+
const FilterGroupSection = ({ filterBarConfig, group, showSeparator, showTitle, }) => {
|
|
708
|
+
const titleId = useId();
|
|
709
|
+
return (jsxs("div", { className: "flex flex-col gap-1", children: [jsxs("div", { children: [showTitle ? (jsx(Text, { className: "h-7 p-2 text-neutral-400", "data-testid": `${group.key}-group-title`, id: titleId, size: "small", uppercase: true, weight: "bold", children: group.title })) : null, jsx("div", { "aria-labelledby": showTitle ? titleId : undefined, className: "grid", "data-testid": `${group.key}-group-list`, role: "group", children: jsx(FiltersRenderer, { filterBarConfig: filterBarConfig, filters: group.filters, visualStyle: "list-item" }) })] }), showSeparator ? jsx(MenuDivider, {}) : null] }));
|
|
710
|
+
};
|
|
706
711
|
/**
|
|
707
712
|
* FiltersList is a React component that displays a list of filters within a filter bar.
|
|
708
713
|
*
|
|
709
714
|
* @returns {ReactElement} - Returns the FiltersList component.
|
|
710
715
|
*/
|
|
711
716
|
const GroupedFiltersList = ({ filterBarConfig, filtersGrouped, className, ref, style, "data-testid": dataTestId = "grouped-filters-list", }) => {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
return (jsxs("div", { className: "flex flex-col gap-1", children: [jsxs("div", { children: [filtersGrouped.length > 1 && (jsx(Text, { className: "h-7 p-2 text-neutral-400", "data-testid": `${group.key}-group-title`, size: "small", uppercase: true, weight: "bold", children: group.title })), jsx("ul", { "aria-labelledby": `${group.key}-group-title`, className: "grid", "data-testid": `${group.key}-group-list`, children: jsx(FiltersRenderer, { filterBarConfig: filterBarConfig, filters: group.filters, visualStyle: "list-item" }) })] }), isLastGroup ? null : jsx("div", { className: "h-[1px] w-full bg-neutral-200", role: "separator" })] }, group.key));
|
|
715
|
-
}) }));
|
|
717
|
+
const showTitle = filtersGrouped.length > 1;
|
|
718
|
+
return (jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: filtersGrouped.map((group, idx) => (jsx(FilterGroupSection, { filterBarConfig: filterBarConfig, group: group, showSeparator: idx !== filtersGrouped.length - 1, showTitle: showTitle }, group.key))) }));
|
|
716
719
|
};
|
|
717
720
|
|
|
718
721
|
/**
|
|
@@ -727,33 +730,104 @@ const ResetFiltersButton = ({ resetFiltersToInitialState, "data-testid": dataTes
|
|
|
727
730
|
}, ref: ref, size: "small", style: style, variant: "ghost", children: [t("filtersBar.resetFilters"), jsx("span", { className: "sr-only", children: t("filtersBar.resetFiltersSR") })] }));
|
|
728
731
|
};
|
|
729
732
|
|
|
733
|
+
const firstEnabledMenuItem = (root) => root?.querySelector('[role="menuitem"]:not([aria-disabled="true"])') ?? null;
|
|
734
|
+
/**
|
|
735
|
+
* Bridges Search and the registered FilterBar rows: Down from Search enters the first matching
|
|
736
|
+
* item; Up from that item returns to Search. Initial focus lands on Search when it is shown,
|
|
737
|
+
* otherwise on the first enabled row.
|
|
738
|
+
*
|
|
739
|
+
* @returns {UseFiltersMenuSearchNavigationResult} Search ref, panel-node callback, and Search ArrowDown handler
|
|
740
|
+
*/
|
|
741
|
+
const useFiltersMenuSearchNavigation = ({ showSearch, }) => {
|
|
742
|
+
const searchInputRef = useRef(null);
|
|
743
|
+
const panelRef = useRef(null);
|
|
744
|
+
const onPanelArrowUp = useCallback((event) => {
|
|
745
|
+
if (event.key !== "ArrowUp") {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const firstItem = firstEnabledMenuItem(panelRef.current);
|
|
749
|
+
const active = document.activeElement;
|
|
750
|
+
if (firstItem && active && (active === firstItem || firstItem.contains(active))) {
|
|
751
|
+
// Capture on the menu root so this wins over useListNavigation's
|
|
752
|
+
// loop:true wrap (React onKeyDown on the floating element).
|
|
753
|
+
event.preventDefault();
|
|
754
|
+
event.stopImmediatePropagation();
|
|
755
|
+
searchInputRef.current?.focus();
|
|
756
|
+
}
|
|
757
|
+
}, []);
|
|
758
|
+
const setPanelNode = useCallback((node) => {
|
|
759
|
+
if (panelRef.current) {
|
|
760
|
+
panelRef.current.removeEventListener("keydown", onPanelArrowUp, true);
|
|
761
|
+
}
|
|
762
|
+
panelRef.current = node;
|
|
763
|
+
if (node && showSearch) {
|
|
764
|
+
// Attach here, not in an effect: MenuContent's ref is set after the
|
|
765
|
+
// first paint, and a [showSearch]-only effect can snapshot a null panel.
|
|
766
|
+
node.addEventListener("keydown", onPanelArrowUp, true);
|
|
767
|
+
}
|
|
768
|
+
}, [onPanelArrowUp, showSearch]);
|
|
769
|
+
useEffect(() => {
|
|
770
|
+
const focusInitialTarget = () => {
|
|
771
|
+
if (showSearch) {
|
|
772
|
+
searchInputRef.current?.focus();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
firstEnabledMenuItem(panelRef.current)?.focus();
|
|
776
|
+
};
|
|
777
|
+
// Floating UI's default initialFocus is 0 (the trigger). FilterBar cannot
|
|
778
|
+
// use initialFocus={1}: that would land on Reset when Search is hidden.
|
|
779
|
+
const frame = requestAnimationFrame(focusInitialTarget);
|
|
780
|
+
return () => cancelAnimationFrame(frame);
|
|
781
|
+
}, [showSearch]);
|
|
782
|
+
const handleSearchKeyDown = useCallback((event) => {
|
|
783
|
+
if (event.key !== "ArrowDown") {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const firstItem = firstEnabledMenuItem(panelRef.current);
|
|
787
|
+
if (firstItem) {
|
|
788
|
+
// Stop list-navigation from owning this ArrowDown so activeIndex stays
|
|
789
|
+
// unset and ArrowUp from the first row can return to Search.
|
|
790
|
+
event.preventDefault();
|
|
791
|
+
event.stopPropagation();
|
|
792
|
+
firstItem.focus();
|
|
793
|
+
}
|
|
794
|
+
}, []);
|
|
795
|
+
return useMemo(() => ({
|
|
796
|
+
searchInputRef,
|
|
797
|
+
setPanelNode,
|
|
798
|
+
handleSearchKeyDown,
|
|
799
|
+
}), [setPanelNode, handleSearchKeyDown]);
|
|
800
|
+
};
|
|
801
|
+
|
|
730
802
|
/**
|
|
731
803
|
*
|
|
732
804
|
*/
|
|
733
805
|
const FiltersMenuContent = ({ filterBarConfig, setShowCustomFilters, setSearchText, searchText, searchResultsGrouped, filtersToShowGrouped, removeCustomFieldsGroup, hasCustomFields, appliedCustomFields, showCustomFilters, filterBarDefinitionCount, className, "data-testid": dataTestId = "starred-filters-menu-popover", ref, style, }) => {
|
|
734
806
|
const [t] = useTranslation();
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
}
|
|
756
|
-
|
|
807
|
+
const filtersListId = useId();
|
|
808
|
+
const showSearch = filterBarDefinitionCount > 5;
|
|
809
|
+
const { searchInputRef, setPanelNode, handleSearchKeyDown } = useFiltersMenuSearchNavigation({
|
|
810
|
+
showSearch,
|
|
811
|
+
});
|
|
812
|
+
const setPanelRef = useCallback((node) => {
|
|
813
|
+
setPanelNode(node);
|
|
814
|
+
if (typeof ref === "function") {
|
|
815
|
+
ref(node);
|
|
816
|
+
}
|
|
817
|
+
else if (ref) {
|
|
818
|
+
ref.current = node;
|
|
819
|
+
}
|
|
820
|
+
}, [setPanelNode, ref]);
|
|
821
|
+
const visibleGroups = searchText
|
|
822
|
+
? searchResultsGrouped
|
|
823
|
+
: showCustomFilters
|
|
824
|
+
? filtersToShowGrouped
|
|
825
|
+
: removeCustomFieldsGroup(filtersToShowGrouped);
|
|
826
|
+
const hasVisibleFilters = visibleGroups.some(group => group.filters.length > 0);
|
|
827
|
+
return (jsxs(MenuContent, { "aria-label": t("filtersBar.filtersHeading"), className: twMerge("max-h-[min(600px,calc(100dvh-32px))] grid-rows-[minmax(0,1fr)] overflow-y-hidden p-0", className), "data-testid": dataTestId, listClassName: "grid-rows-min-fr !max-h-none min-h-0 gap-y-0 !overflow-y-hidden p-0", ref: setPanelRef, style: style, children: [jsxs("div", { children: [jsxs("div", { className: "flex flex-col gap-1 p-1", children: [showSearch ? (jsx(Search, { "data-testid": "starred-filters-menu-search", fieldSize: "small", id: "search-filters-list", onChange: e => setSearchText(e.currentTarget.value), onClear: () => setSearchText(""), onKeyDown: handleSearchKeyDown, placeholder: t("filtersBar.searchFiltersPlaceholder"), ref: searchInputRef, value: searchText })) : null, jsxs("div", { className: "flex h-7 items-center justify-between gap-1 pl-3", children: [jsx(Text, { className: "text-neutral-400", size: "small", children: jsx(FiltersAppliedCountLabel, { filterBarConfig: filterBarConfig }) }), filterBarConfig.appliedFilterKeys().length > 0 ? (jsx(ResetFiltersButton, { resetFiltersToInitialState: filterBarConfig.resetFiltersToInitialState })) : null] })] }), jsx(MenuDivider, {})] }), jsxs("div", { className: "flex min-h-0 flex-col gap-1 overflow-auto p-1", id: filtersListId, children: [searchText && !hasVisibleFilters ? (jsx("div", { "aria-live": "polite", role: "status", children: jsx(Text, { className: "p-2 text-neutral-400", size: "small", children: t("filtersBar.emptyResults") }) })) : (jsx(GroupedFiltersList, { className: "flex flex-col gap-1", filterBarConfig: filterBarConfig, filtersGrouped: visibleGroups })), hasCustomFields && !showCustomFilters && !searchText ? (jsx(CustomFieldsHiddenGroup, { appliedCustomFields: appliedCustomFields, filterBarConfig: filterBarConfig, filtersListId: filtersListId, onClickShow: () => {
|
|
828
|
+
setShowCustomFilters(true);
|
|
829
|
+
} })) : null] })] }));
|
|
830
|
+
};
|
|
757
831
|
const FiltersAppliedCountLabel = ({ filterBarConfig, }) => {
|
|
758
832
|
const [t] = useTranslation();
|
|
759
833
|
// Count actual applied values, not just filter keys - same logic as badge
|
|
@@ -775,9 +849,9 @@ const FiltersAppliedCountLabel = ({ filterBarConfig, }) => {
|
|
|
775
849
|
return jsx(Fragment, { children: t("filtersMenu.appliedFiltersLabel.plural", { count: appliedValuesCount }) });
|
|
776
850
|
}
|
|
777
851
|
};
|
|
778
|
-
const CustomFieldsHiddenGroup = ({ appliedCustomFields, filterBarConfig, onClickShow, }) => {
|
|
852
|
+
const CustomFieldsHiddenGroup = ({ appliedCustomFields, filterBarConfig, filtersListId, onClickShow, }) => {
|
|
779
853
|
const [t] = useTranslation();
|
|
780
|
-
return (jsxs(Fragment, { children: [jsx(
|
|
854
|
+
return (jsxs(Fragment, { children: [jsx(MenuDivider, {}), jsxs("div", { children: [jsx(Button, { "aria-controls": filtersListId, className: "text-primary-600 w-full justify-between px-3", onClick: onClickShow, prefix: jsx(Text, { className: "text-neutral-400", size: "small", uppercase: true, weight: "bold", children: t("filtersBar.groups.CUSTOM_FIELDS") }), size: "small", variant: "ghost-neutral", children: t("filtersBar.showAll") }), appliedCustomFields.length > 0 ? (jsx("div", { "aria-label": t("filtersBar.visibleCustomFields"), "data-testid": "applied-custom-fields-list", role: "group", children: jsx(FiltersRenderer, { filterBarConfig: filterBarConfig, filters: appliedCustomFields, visualStyle: "list-item" }) })) : null] })] }));
|
|
781
855
|
};
|
|
782
856
|
|
|
783
857
|
const sharedButtonProps = {
|
|
@@ -970,7 +1044,7 @@ const FilterTableComponent = ({ filterKey, filterBarDefinition, filterBarConfig,
|
|
|
970
1044
|
const multipleFiltersAppliedCount = getManuallyAppliedFilterCount(activeFilterKeys);
|
|
971
1045
|
const multipleFiltersIsActive = multipleFiltersAppliedCount > 0;
|
|
972
1046
|
return (jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: jsx(MenuTree, { children: jsx(Popover, { placement: "bottom-start", children: modalState => {
|
|
973
|
-
return (jsxs(Fragment, { children: [jsx(PopoverTrigger, { children: jsx("div", { className: "relative", children: jsx(Tooltip, { disabled: modalState.isOpen || !multipleFiltersIsActive, label: jsx(MultipleFilterTooltipLabel, { filterBarConfig: filterBarConfig, filterKeys: filterKey, filters: filters }), placement: "bottom", children: jsx(IconButton, { ariaLabel: t("filtersBar.filtersHeading"), className: "h-8 w-5 p-0", icon: jsx(Icon, { name: "Funnel", size: "small" }), variant: multipleFiltersIsActive ? "ghost" : "ghost-neutral" }) }) }) }), jsx(PopoverContent, { children: jsx(
|
|
1047
|
+
return (jsxs(Fragment, { children: [jsx(PopoverTrigger, { children: jsx("div", { className: "relative", children: jsx(Tooltip, { disabled: modalState.isOpen || !multipleFiltersIsActive, label: jsx(MultipleFilterTooltipLabel, { filterBarConfig: filterBarConfig, filterKeys: filterKey, filters: filters }), placement: "bottom", children: jsx(IconButton, { ariaLabel: t("filtersBar.filtersHeading"), className: "h-8 w-5 p-0", icon: jsx(Icon, { name: "Funnel", size: "small" }), variant: multipleFiltersIsActive ? "ghost" : "ghost-neutral" }) }) }) }), jsx(PopoverContent, { initialFocus: 1, children: jsx(MenuContent, { "aria-label": t("filtersBar.filtersHeading"), "data-testid": "filter-table-menu", children: filters.map((value, index) => value && (jsx(FilterComponent, { filter: value, filterBarActions: filterBarConfig, filterState: filterBarConfig, visualStyle: "list-item" }, index))) }) })] }));
|
|
974
1048
|
} }) }) }));
|
|
975
1049
|
}
|
|
976
1050
|
const filter = filterBarDefinition[ensureFilterKey(filterKey)];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/filters-filter-bar",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.35",
|
|
4
4
|
"repository": "https://github.com/Trackunit/manager",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"engines": {
|
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
"tailwind-merge": "^2.0.0",
|
|
12
12
|
"string-ts": "^2.0.0",
|
|
13
13
|
"zod": "^3.25.76",
|
|
14
|
-
"@trackunit/iris-app-api": "2.4.
|
|
15
|
-
"@trackunit/react-core-hooks": "1.21.
|
|
16
|
-
"@trackunit/react-filter-components": "2.6.
|
|
17
|
-
"@trackunit/react-date-and-time-components": "2.6.
|
|
18
|
-
"@trackunit/shared-utils": "1.16.
|
|
19
|
-
"@trackunit/react-form-components": "2.6.
|
|
20
|
-
"@trackunit/iris-app-runtime-core-api": "1.17.
|
|
21
|
-
"@trackunit/geo-json-utils": "1.15.
|
|
22
|
-
"@trackunit/i18n-library-translation": "2.4.
|
|
23
|
-
"@trackunit/css-class-variance-utilities": "1.14.
|
|
24
|
-
"@trackunit/react-components": "2.10.
|
|
14
|
+
"@trackunit/iris-app-api": "2.4.24",
|
|
15
|
+
"@trackunit/react-core-hooks": "1.21.32",
|
|
16
|
+
"@trackunit/react-filter-components": "2.6.34",
|
|
17
|
+
"@trackunit/react-date-and-time-components": "2.6.34",
|
|
18
|
+
"@trackunit/shared-utils": "1.16.26",
|
|
19
|
+
"@trackunit/react-form-components": "2.6.34",
|
|
20
|
+
"@trackunit/iris-app-runtime-core-api": "1.17.33",
|
|
21
|
+
"@trackunit/geo-json-utils": "1.15.25",
|
|
22
|
+
"@trackunit/i18n-library-translation": "2.4.32",
|
|
23
|
+
"@trackunit/css-class-variance-utilities": "1.14.23",
|
|
24
|
+
"@trackunit/react-components": "2.10.27"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"@apollo/client": "^3.13.8",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { KeyboardEvent, Ref, RefCallback } from "react";
|
|
2
|
+
export interface UseFiltersMenuSearchNavigationResult {
|
|
3
|
+
searchInputRef: Ref<HTMLInputElement>;
|
|
4
|
+
setPanelNode: RefCallback<HTMLDivElement>;
|
|
5
|
+
handleSearchKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Bridges Search and the registered FilterBar rows: Down from Search enters the first matching
|
|
9
|
+
* item; Up from that item returns to Search. Initial focus lands on Search when it is shown,
|
|
10
|
+
* otherwise on the first enabled row.
|
|
11
|
+
*
|
|
12
|
+
* @returns {UseFiltersMenuSearchNavigationResult} Search ref, panel-node callback, and Search ArrowDown handler
|
|
13
|
+
*/
|
|
14
|
+
export declare const useFiltersMenuSearchNavigation: ({ showSearch, }: {
|
|
15
|
+
showSearch: boolean;
|
|
16
|
+
}) => UseFiltersMenuSearchNavigationResult;
|