@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 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
- return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: filtersGrouped.map((group, idx) => {
715
- const isLastGroup = idx === filtersGrouped.length - 1;
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
- // Chrome-only shell (not `MenuContent`): filter rows are wrapped (`GroupedFiltersList` →
738
- // `FilterComponent` `Filter` `MenuItem`), so `MenuContent`'s direct-child registration would
739
- // never attach keyboard nav leaving a `role="menu"` with no owned menuitems. Shadow/layout come
740
- // from `cvaMenu`/`cvaMenuList`; keyboard ownership returns when MenuItem self-registers (GLU-1644).
741
- return (jsxRuntime.jsx("div", { className: reactComponents.cvaMenu({
742
- className: tailwindMerge.twMerge("max-h-[min(600px,calc(100dvh-32px))] grid-rows-[minmax(0,1fr)] overflow-y-hidden p-0", className),
743
- limitWidth: true,
744
- }), "data-testid": dataTestId, ref: ref, style: style, children: jsxRuntime.jsxs("div", {
745
- // `gap-y-0` cancels `cvaMenuList`'s default inter-item gap, which here would otherwise land
746
- // between the header block (search/count + separator) and the scrollable list below it,
747
- // showing as a sliver of whitespace above the list's own scrollbar.
748
- className: reactComponents.cvaMenuList({
749
- className: "grid-rows-min-fr !max-h-none min-h-0 gap-y-0 !overflow-y-hidden p-0",
750
- }), children: [jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("div", { className: "flex flex-col gap-1 p-1", children: [filterBarDefinitionCount > 5 && (jsxRuntime.jsx(reactFormComponents.Search, { autoFocus: true, "data-testid": "starred-filters-menu-search", fieldSize: "small", id: "search-filters-list", onChange: e => setSearchText(e.currentTarget.value), onClear: () => setSearchText(""), placeholder: t("filtersBar.searchFiltersPlaceholder"), value: searchText })), 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(Separator, {})] }), jsxRuntime.jsxs("div", { className: "flex min-h-0 flex-col gap-1 overflow-auto p-1", children: [jsxRuntime.jsx(GroupedFiltersList, { className: "flex flex-col gap-1", filterBarConfig: filterBarConfig, filtersGrouped: searchText
751
- ? searchResultsGrouped
752
- : showCustomFilters
753
- ? filtersToShowGrouped
754
- : removeCustomFieldsGroup(filtersToShowGrouped) }), hasCustomFields && !showCustomFilters && !searchText ? (jsxRuntime.jsx(CustomFieldsHiddenGroup, { appliedCustomFields: appliedCustomFields, filterBarConfig: filterBarConfig, onClickShow: () => {
755
- setShowCustomFilters(true);
756
- } })) : null] })] }) }));
757
- };
758
- const Separator = () => jsxRuntime.jsx("hr", { className: "border-neutral-200", role: "separator" });
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(Separator, {}), jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx(reactComponents.Button, { "aria-controls": "filters-list", 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("ul", { "aria-label": "Visible custom fields", "data-testid": "applied-custom-fields-list", children: jsxRuntime.jsx(FiltersRenderer, { filterBarConfig: filterBarConfig, filters: appliedCustomFields, visualStyle: "list-item" }) })) : null] })] }));
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("div", { className: reactComponents.cvaMenu({ limitWidth: true }), children: jsxRuntime.jsx("div", { className: reactComponents.cvaMenuList(), children: filters.map((value, index) => value && (jsxRuntime.jsx(FilterComponent, { filter: value, filterBarActions: filterBarConfig, filterState: filterBarConfig, visualStyle: "list-item" }, index))) }) }) })] }));
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, useRef } from 'react';
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, cvaMenuList, cvaMenu, ZStack, IconButton, Badge, useViewportBreakpoints, MenuTree, Popover, PopoverTrigger, Tooltip, PopoverContent, useCustomEncoding, useSearchParamSync, useStorageKey, useWatch } from '@trackunit/react-components';
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
- return (jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: filtersGrouped.map((group, idx) => {
713
- const isLastGroup = idx === filtersGrouped.length - 1;
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
- // Chrome-only shell (not `MenuContent`): filter rows are wrapped (`GroupedFiltersList` →
736
- // `FilterComponent` `Filter` `MenuItem`), so `MenuContent`'s direct-child registration would
737
- // never attach keyboard nav leaving a `role="menu"` with no owned menuitems. Shadow/layout come
738
- // from `cvaMenu`/`cvaMenuList`; keyboard ownership returns when MenuItem self-registers (GLU-1644).
739
- return (jsx("div", { className: cvaMenu({
740
- className: twMerge("max-h-[min(600px,calc(100dvh-32px))] grid-rows-[minmax(0,1fr)] overflow-y-hidden p-0", className),
741
- limitWidth: true,
742
- }), "data-testid": dataTestId, ref: ref, style: style, children: jsxs("div", {
743
- // `gap-y-0` cancels `cvaMenuList`'s default inter-item gap, which here would otherwise land
744
- // between the header block (search/count + separator) and the scrollable list below it,
745
- // showing as a sliver of whitespace above the list's own scrollbar.
746
- className: cvaMenuList({
747
- className: "grid-rows-min-fr !max-h-none min-h-0 gap-y-0 !overflow-y-hidden p-0",
748
- }), children: [jsxs("div", { children: [jsxs("div", { className: "flex flex-col gap-1 p-1", children: [filterBarDefinitionCount > 5 && (jsx(Search, { autoFocus: true, "data-testid": "starred-filters-menu-search", fieldSize: "small", id: "search-filters-list", onChange: e => setSearchText(e.currentTarget.value), onClear: () => setSearchText(""), placeholder: t("filtersBar.searchFiltersPlaceholder"), value: searchText })), 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(Separator, {})] }), jsxs("div", { className: "flex min-h-0 flex-col gap-1 overflow-auto p-1", children: [jsx(GroupedFiltersList, { className: "flex flex-col gap-1", filterBarConfig: filterBarConfig, filtersGrouped: searchText
749
- ? searchResultsGrouped
750
- : showCustomFilters
751
- ? filtersToShowGrouped
752
- : removeCustomFieldsGroup(filtersToShowGrouped) }), hasCustomFields && !showCustomFilters && !searchText ? (jsx(CustomFieldsHiddenGroup, { appliedCustomFields: appliedCustomFields, filterBarConfig: filterBarConfig, onClickShow: () => {
753
- setShowCustomFilters(true);
754
- } })) : null] })] }) }));
755
- };
756
- const Separator = () => jsx("hr", { className: "border-neutral-200", role: "separator" });
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(Separator, {}), jsxs("div", { children: [jsx(Button, { "aria-controls": "filters-list", 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("ul", { "aria-label": "Visible custom fields", "data-testid": "applied-custom-fields-list", children: jsx(FiltersRenderer, { filterBarConfig: filterBarConfig, filters: appliedCustomFields, visualStyle: "list-item" }) })) : null] })] }));
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("div", { className: cvaMenu({ limitWidth: true }), children: jsx("div", { className: cvaMenuList(), children: filters.map((value, index) => value && (jsx(FilterComponent, { filter: value, filterBarActions: filterBarConfig, filterState: filterBarConfig, visualStyle: "list-item" }, index))) }) }) })] }));
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.33",
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.22",
15
- "@trackunit/react-core-hooks": "1.21.30",
16
- "@trackunit/react-filter-components": "2.6.32",
17
- "@trackunit/react-date-and-time-components": "2.6.32",
18
- "@trackunit/shared-utils": "1.16.25",
19
- "@trackunit/react-form-components": "2.6.32",
20
- "@trackunit/iris-app-runtime-core-api": "1.17.31",
21
- "@trackunit/geo-json-utils": "1.15.24",
22
- "@trackunit/i18n-library-translation": "2.4.30",
23
- "@trackunit/css-class-variance-utilities": "1.14.22",
24
- "@trackunit/react-components": "2.10.25"
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;