@gooddata/sdk-ui-filters 11.41.0-alpha.2 → 11.41.0-alpha.4

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.
Files changed (36) hide show
  1. package/esm/AttributeFilter/hooks/types.d.ts +8 -1
  2. package/esm/AttributeFilter/hooks/useAttributeFilterController.js +40 -3
  3. package/esm/AttributeFilter/hooks/useAttributeFilterHandlerMethods.d.ts +1 -1
  4. package/esm/AttributeFilter/hooks/useElementsFilterController.js +48 -10
  5. package/esm/AttributeFilterHandler/internal/bridge.d.ts +1 -1
  6. package/esm/AttributeFilterHandler/internal/bridge.js +2 -1
  7. package/esm/AttributeFilterHandler/internal/loader.d.ts +1 -1
  8. package/esm/AttributeFilterHandler/internal/loader.js +2 -2
  9. package/esm/AttributeFilterHandler/internal/redux/init/initReducers.d.ts +1 -0
  10. package/esm/AttributeFilterHandler/internal/redux/init/initSaga.js +9 -2
  11. package/esm/AttributeFilterHandler/internal/redux/store/rootReducers.d.ts +1 -0
  12. package/esm/AttributeFilterHandler/internal/redux/store/slice.d.ts +1 -0
  13. package/esm/AttributeFilterHandler/types/attributeFilterLoader.d.ts +3 -1
  14. package/esm/FilterGroup/FilterGroup.js +27 -11
  15. package/esm/MeasureValueFilter/ConditionInputSection.d.ts +0 -1
  16. package/esm/MeasureValueFilter/ConditionInputSection.js +4 -4
  17. package/esm/MeasureValueFilter/Dropdown.js +1 -1
  18. package/esm/MeasureValueFilter/DropdownBody.d.ts +0 -1
  19. package/esm/MeasureValueFilter/DropdownBody.js +60 -15
  20. package/esm/MeasureValueFilter/MeasureValueFilterDropdownActions.js +10 -3
  21. package/esm/MeasureValueFilter/OperatorDropdownBody.js +4 -1
  22. package/esm/MeasureValueFilter/RangeInput.js +2 -2
  23. package/esm/sdk-ui-filters.d.ts +10 -1
  24. package/esm/tsdoc-metadata.json +1 -1
  25. package/package.json +13 -13
  26. package/styles/css/attributeFilter.css +57 -0
  27. package/styles/css/attributeFilter.css.map +1 -1
  28. package/styles/css/attributeFilterNext/attributeFilterDropdownButton.css +57 -0
  29. package/styles/css/attributeFilterNext/attributeFilterDropdownButton.css.map +1 -1
  30. package/styles/css/attributeFilterNext.css +57 -0
  31. package/styles/css/attributeFilterNext.css.map +1 -1
  32. package/styles/css/filterGroup.css +12 -2
  33. package/styles/css/filterGroup.css.map +1 -1
  34. package/styles/css/main.css +69 -2
  35. package/styles/css/main.css.map +1 -1
  36. package/styles/scss/filterGroup.scss +20 -2
@@ -375,6 +375,13 @@ export type SelectionTypeControllerCallbacks = {
375
375
  * @internal
376
376
  */
377
377
  resetForModeSwitch?: (newFilter: IAttributeFilter, newDisplayAsLabel?: ObjRef) => void;
378
+ /**
379
+ * Imperatively read the current working (not yet applied) elements selection as a filter.
380
+ * Used to snapshot the List selection before a mode switch so it can be restored later.
381
+ *
382
+ * @internal
383
+ */
384
+ getWorkingElementsFilter?: () => IAttributeFilter | undefined;
378
385
  };
379
386
  /**
380
387
  * AttributeFilter controller callbacks.
@@ -393,7 +400,7 @@ export type AttributeFilterController = AttributeFilterControllerData & Attribut
393
400
  *
394
401
  * @internal
395
402
  */
396
- export type ElementsFilterController = CommonFilterControllerData & ElementsFilterControllerData & CommonFilterControllerCallbacks & ElementsFilterControllerCallbacks & Pick<SelectionTypeControllerCallbacks, "setDisplayForm" | "resetForModeSwitch">;
403
+ export type ElementsFilterController = CommonFilterControllerData & ElementsFilterControllerData & CommonFilterControllerCallbacks & ElementsFilterControllerCallbacks & Pick<SelectionTypeControllerCallbacks, "setDisplayForm" | "resetForModeSwitch" | "getWorkingElementsFilter">;
397
404
  /**
398
405
  * Text mode controller return type.
399
406
  * Only text-specific data and callbacks; root controller merges with common data and elements stubs.
@@ -40,10 +40,28 @@ export const useAttributeFilterController = (props) => {
40
40
  const supportsSingleSelectDependentFilters = backend.capabilities.supportsSingleSelectDependentFilters;
41
41
  const { filter: resolvedFilter, setConnectedPlaceholderValue } = useResolveFilterInput(filterInput ?? workingFilter, connectToPlaceholder);
42
42
  const [selectionType, setSelectionType] = useState(() => getSelectionTypeFromFilter(resolvedFilter) ?? "elements");
43
+ // Only re-sync the selection type when the resolved (applied) filter's TYPE actually changes.
44
+ // Re-syncing on every resolvedFilter reference change would clobber an in-progress mode switch
45
+ // whenever the consumer re-renders with a new (but same-type) filter object — e.g. an app that
46
+ // recomputes the filter from its own state each render. That reverted the just-clicked mode and
47
+ // forced the user to switch List <-> Text twice.
48
+ const lastResolvedSelectionTypeRef = useRef(getSelectionTypeFromFilter(resolvedFilter) ?? "elements");
43
49
  useEffect(() => {
44
- setSelectionType(getSelectionTypeFromFilter(resolvedFilter) ?? "elements");
50
+ const nextSelectionType = getSelectionTypeFromFilter(resolvedFilter) ?? "elements";
51
+ if (nextSelectionType !== lastResolvedSelectionTypeRef.current) {
52
+ lastResolvedSelectionTypeRef.current = nextSelectionType;
53
+ setSelectionType(nextSelectionType);
54
+ }
45
55
  }, [resolvedFilter]);
46
56
  const isTextSelectionType = selectionType === "text";
57
+ // Remembers the List (elements) selection captured when leaving List mode, so switching
58
+ // List -> Text -> List can restore it (keys and inverted flag) instead of resetting to "All".
59
+ // Seeded from the initial filter; updated with the live working selection on each switch to Text.
60
+ const lastElementsFilterRef = useRef(resolvedFilter &&
61
+ !isArbitraryAttributeFilter(resolvedFilter) &&
62
+ !isMatchAttributeFilter(resolvedFilter)
63
+ ? resolvedFilter
64
+ : undefined);
47
65
  const originalSelectionType = getSelectionTypeFromFilter(resolvedFilter);
48
66
  const selectionTypeChanged = originalSelectionType !== selectionType;
49
67
  // The display form from the filter definition itself. For elements filters this is
@@ -253,7 +271,7 @@ export const useAttributeFilterController = (props) => {
253
271
  elementsResetForModeSwitch,
254
272
  textResetForModeSwitch,
255
273
  ]);
256
- const { currentDisplayFormRef: elementsCurrentDisplayFormRef } = elementsFilterController;
274
+ const { currentDisplayFormRef: elementsCurrentDisplayFormRef, getWorkingElementsFilter } = elementsFilterController;
257
275
  const onSelectionTypeChangeForControllers = useCallback((newMode) => {
258
276
  if (!resolvedDisplayFormRef) {
259
277
  return;
@@ -263,6 +281,12 @@ export const useAttributeFilterController = (props) => {
263
281
  return;
264
282
  }
265
283
  if (newMode === "text") {
284
+ // Leaving List: snapshot the current working element selection (including changes made
285
+ // since the last Apply) so switching back to List restores exactly what was on screen.
286
+ const workingElementsFilter = getWorkingElementsFilter?.();
287
+ if (workingElementsFilter) {
288
+ lastElementsFilterRef.current = workingElementsFilter;
289
+ }
266
290
  if (availableTextSelectionTypes.includes("arbitrary")) {
267
291
  nextAvailableMode = "arbitrary";
268
292
  }
@@ -275,7 +299,12 @@ export const useAttributeFilterController = (props) => {
275
299
  ? (userSelectedDisplayForm ?? elementsCurrentDisplayFormRef)
276
300
  : elementsCurrentDisplayFormRef;
277
301
  const displayAsDisplayFormForNewFilter = newMode === "text" ? undefined : userSelectedDisplayForm;
278
- const newFilter = createEmptyFilterForAvailableSelectionType(nextAvailableMode, displayFormForNewFilter, localId);
302
+ // Switching back to List: restore the previously applied element selection (keys + inverted
303
+ // flag) when we have it, so a List -> Text -> List round-trip preserves the user's selection
304
+ // instead of resetting to "All" (and, with stale committed elements, inverting it).
305
+ const restoredElementsFilter = newMode === "text" ? undefined : lastElementsFilterRef.current;
306
+ const newFilter = restoredElementsFilter ??
307
+ createEmptyFilterForAvailableSelectionType(nextAvailableMode, displayFormForNewFilter, localId);
279
308
  handleSelectionTypeChange(newFilter, displayAsDisplayFormForNewFilter, selectionType, newMode);
280
309
  }, [
281
310
  availableTextSelectionTypes,
@@ -285,6 +314,7 @@ export const useAttributeFilterController = (props) => {
285
314
  userSelectedDisplayForm,
286
315
  elementsCurrentDisplayFormRef,
287
316
  resolvedDisplayFormRef,
317
+ getWorkingElementsFilter,
288
318
  ]);
289
319
  const { textFilterOperator, textFilterValues, textFilterLiteral, textFilterCaseSensitive, onCommitTextFilter, isApplyDisabled: isTextApplyDisabled, } = textFilterController;
290
320
  const onApplyTextFilter = useCallback((applyRegardlessWithoutApplySetting = false, _applyToWorkingOnly = false) => {
@@ -364,11 +394,18 @@ export const useAttributeFilterController = (props) => {
364
394
  onResetText?.();
365
395
  onResetElements();
366
396
  setSelectionType(getSelectionTypeFromFilter(resolvedFilter) ?? "elements");
397
+ // The remembered List selection is only meaningful within a single open session (List -> Text
398
+ // -> List). Clear it on close so a snapshot captured before a Text filter was applied is not
399
+ // reused on the next open, which would push the stale elements selection and undo the applied
400
+ // text state. Leaving List re-captures the working selection, so this is safe.
401
+ lastElementsFilterRef.current = undefined;
367
402
  }, [onResetText, onResetElements, resolvedFilter]);
368
403
  // Wrap elements onReset to also revert selectionType on dropdown close without Apply.
369
404
  const onResetElementsMode = useCallback(() => {
370
405
  onResetElements();
371
406
  setSelectionType(getSelectionTypeFromFilter(resolvedFilter) ?? "elements");
407
+ // See onResetTextMode: the remembered List selection is session-scoped, clear it on close.
408
+ lastElementsFilterRef.current = undefined;
372
409
  }, [onResetElements, resolvedFilter]);
373
410
  if (isTextSelectionType) {
374
411
  return {
@@ -3,7 +3,7 @@ import { type IMultiSelectAttributeFilterHandler } from "../../AttributeFilterHa
3
3
  * @internal
4
4
  */
5
5
  export declare const useAttributeFilterHandlerMethods: (handler: IMultiSelectAttributeFilterHandler) => {
6
- init: (correlation?: string | undefined, skipElementsLoading?: boolean | undefined) => void;
6
+ init: (correlation?: string | undefined, skipElementsLoading?: boolean | undefined, preserveWorkingSelection?: boolean | undefined) => void;
7
7
  onInitStart: import("../../index.js").CallbackRegistration<{
8
8
  correlation: string;
9
9
  }>;
@@ -3,7 +3,7 @@
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { debounce, difference, differenceBy, isEmpty, isEqual } from "lodash-es";
5
5
  import { invariant } from "ts-invariant";
6
- import { areObjRefsEqual, filterAttributeElements, filterObjRef, isArbitraryAttributeFilter, isAttributeElementsByRef, isAttributeElementsByValue, isAttributeFilterWithSelection, isMatchAttributeFilter, isNegativeAttributeFilter, isPositiveAttributeFilter, objRefToString, } from "@gooddata/sdk-model";
6
+ import { areObjRefsEqual, filterAttributeElements, filterLocalIdentifier, filterObjRef, isArbitraryAttributeFilter, isAttributeElementsByRef, isAttributeElementsByValue, isAttributeFilterWithSelection, isMatchAttributeFilter, isNegativeAttributeFilter, isPositiveAttributeFilter, newNegativeAttributeFilter, newPositiveAttributeFilter, objRefToString, } from "@gooddata/sdk-model";
7
7
  import { UnexpectedSdkError } from "@gooddata/sdk-ui";
8
8
  import { isValidSingleSelectionFilter } from "../utils.js";
9
9
  import { DISPLAY_FORM_CHANGED_CORRELATION, IRRELEVANT_SELECTION, MAX_SELECTION_SIZE, PARENT_FILTERS_CORRELATION, RESET_CORRELATION, SEARCH_CORRELATION, SHOW_FILTERED_ELEMENTS_CORRELATION, } from "./constants.js";
@@ -158,6 +158,11 @@ function refreshByType(handler, change, supportsKeepingDependentFiltersSelection
158
158
  if (change === "init-self") {
159
159
  handler.init();
160
160
  }
161
+ if (change === "init-self-preserve-selection") {
162
+ // Load the element options without re-deriving (and clobbering) the working selection
163
+ // that resetForModeSwitch just restored.
164
+ handler.init(undefined, false, true);
165
+ }
161
166
  }
162
167
  function isPrimaryLabelUsed(filter, displayForms) {
163
168
  const primaryDisplayForm = displayForms.find((df) => df.isPrimary);
@@ -278,9 +283,10 @@ function useInitOrReload(handler, props, supportsKeepingDependentFiltersSelectio
278
283
  return (!areFiltersEqual(filter, handler.getFilter()) &&
279
284
  !areFiltersEqual(filter, handler.getFilterToDisplay()));
280
285
  };
281
- // When text→elements mode switch happens, resetForModeSwitch already committed "All" to the handler.
282
- // Suppress filterChanged so we don't override that reset with the stale filter prop (parent's
283
- // onChange may not have propagated yet, so filter can still hold the old list selection).
286
+ // When text→elements mode switch happens, resetForModeSwitch already committed the target
287
+ // selection (restored List selection or "All") to the handler. Suppress filterChanged so we
288
+ // don't override that reset with the stale filter prop (parent's onChange may not have
289
+ // propagated yet, so filter can still hold the previous mode's selection).
284
290
  const justSwitchedFromText = prevIsTextModeRef.current && !isTextMode;
285
291
  prevIsTextModeRef.current = isTextMode;
286
292
  const filterChanged = justSwitchedFromText || selectionTypeChanged ? false : getFilterChanged(filter, handler);
@@ -307,10 +313,11 @@ function useInitOrReload(handler, props, supportsKeepingDependentFiltersSelectio
307
313
  let change = resetOnParentFilterChange
308
314
  ? updateAutomaticResettingFilter(handler, updateProps, supportsCircularDependencyInFilters)
309
315
  : updateNonResettingFilter(handler, updateProps, supportsKeepingDependentFiltersSelection);
310
- // Safety net: if elements were not loaded yet (e.g., backend init was interrupted),
311
- // trigger a full init when switching to elements mode so the list is populated.
316
+ // Switching back to elements mode from text mode: elements were not loaded in text mode, so
317
+ // trigger an init to populate the list but preserve the working selection that
318
+ // resetForModeSwitch restored instead of re-deriving it from the stale filter prop.
312
319
  if (justSwitchedFromText) {
313
- change = "init-self";
320
+ change = "init-self-preserve-selection";
314
321
  }
315
322
  refreshByType(handler, change, supportsKeepingDependentFiltersSelection, shouldReloadElements, setShouldReloadElements);
316
323
  }, [
@@ -642,8 +649,7 @@ export function useElementsFilterController(props) {
642
649
  handler.init(DISPLAY_FORM_CHANGED_CORRELATION, isTextMode);
643
650
  }, [handler, isTextMode]);
644
651
  const resetForModeSwitch = useCallback((newFilter, newDisplayAsLabel) => {
645
- // we are always resetting to ALL filter
646
- if (!handler || !isNegativeAttributeFilter(newFilter)) {
652
+ if (!handler) {
647
653
  return;
648
654
  }
649
655
  const displayFormRef = filterObjRef(newFilter);
@@ -652,9 +658,40 @@ export function useElementsFilterController(props) {
652
658
  handler.setDisplayAsLabel(newDisplayAsLabel);
653
659
  }
654
660
  handler.setSearch("");
655
- handler.changeSelection({ keys: [], isInverted: true });
661
+ // Apply the target filter's actual selection (keys + inverted flag) to the WORKING
662
+ // selection. For an empty negative filter this resets to "All"; for a restored List filter
663
+ // it reinstates the previously displayed element selection.
664
+ const elements = filterAttributeElements(newFilter);
665
+ const keys = elements
666
+ ? isAttributeElementsByValue(elements)
667
+ ? elements.values
668
+ : elements.uris
669
+ : [];
670
+ handler.changeSelection({ keys, isInverted: isNegativeAttributeFilter(newFilter) });
671
+ // Only commit in the withoutApply flow. With a deferred Apply the committed selection must
672
+ // stay equal to the applied filter prop — committing here would make the filter-prop
673
+ // re-sync (updateFilter) see a difference and clobber the just-restored working selection.
656
674
  withoutApply && handler.commitSelection();
657
675
  }, [handler, withoutApply]);
676
+ // Builds an elements filter from the current working (not yet applied) selection. Used to snapshot
677
+ // the List selection before switching to Text so it can be restored on switch-back, including any
678
+ // changes the user made after the last Apply.
679
+ const getWorkingElementsFilter = useCallback(() => {
680
+ if (!handler) {
681
+ return undefined;
682
+ }
683
+ const committedFilter = handler.getFilter();
684
+ const committedElements = filterAttributeElements(committedFilter);
685
+ const { keys, isInverted } = handler.getWorkingSelection();
686
+ const elements = isAttributeElementsByValue(committedElements)
687
+ ? { values: keys }
688
+ : { uris: keys };
689
+ const displayForm = filterObjRef(committedFilter);
690
+ const localIdentifier = filterLocalIdentifier(committedFilter);
691
+ return isInverted
692
+ ? newNegativeAttributeFilter(displayForm, elements, localIdentifier)
693
+ : newPositiveAttributeFilter(displayForm, elements, localIdentifier);
694
+ }, [handler]);
658
695
  const callbacks = useCallbacks(handler, {
659
696
  onApply: onApply ?? (() => { }),
660
697
  onChange: onChange ?? (() => { }),
@@ -688,5 +725,6 @@ export function useElementsFilterController(props) {
688
725
  ...forcedInitErrorProp,
689
726
  setDisplayForm,
690
727
  resetForModeSwitch,
728
+ getWorkingElementsFilter,
691
729
  };
692
730
  }
@@ -17,7 +17,7 @@ export declare class AttributeFilterReduxBridge {
17
17
  private callbacks;
18
18
  constructor(config: AttributeFilterHandlerConfig);
19
19
  private initializeBridge;
20
- init: (correlation: string, skipElementsLoading?: boolean | undefined) => void;
20
+ init: (correlation: string, skipElementsLoading?: boolean | undefined, preserveWorkingSelection?: boolean | undefined) => void;
21
21
  getInitStatus: () => AsyncOperationStatus;
22
22
  getInitError: () => GoodDataSdkError | undefined;
23
23
  onInitStart: CallbackRegistration<OnInitStartCallbackPayload>;
@@ -33,10 +33,11 @@ export class AttributeFilterReduxBridge {
33
33
  //
34
34
  // Init
35
35
  //
36
- init = (correlation, skipElementsLoading) => {
36
+ init = (correlation, skipElementsLoading, preserveWorkingSelection) => {
37
37
  this.redux.dispatch(actions.init({
38
38
  correlation,
39
39
  skipElementsLoading,
40
+ preserveWorkingSelection,
40
41
  }));
41
42
  };
42
43
  getInitStatus = () => {
@@ -15,7 +15,7 @@ export declare class AttributeFilterLoader implements IAttributeFilterLoader {
15
15
  protected config: AttributeFilterHandlerConfig;
16
16
  protected constructor(config: AttributeFilterHandlerConfig);
17
17
  private validateStaticElementsLoad;
18
- init: (correlation?: string, skipElementsLoading?: boolean | undefined) => void;
18
+ init: (correlation?: string, skipElementsLoading?: boolean | undefined, preserveWorkingSelection?: boolean | undefined) => void;
19
19
  onInitStart: CallbackRegistration<OnInitStartCallbackPayload>;
20
20
  onInitSuccess: CallbackRegistration<OnInitSuccessCallbackPayload>;
21
21
  onInitError: CallbackRegistration<OnInitErrorCallbackPayload>;
@@ -21,9 +21,9 @@ export class AttributeFilterLoader {
21
21
  //
22
22
  // Init
23
23
  //
24
- init = (correlation = uuid(), skipElementsLoading) => {
24
+ init = (correlation = uuid(), skipElementsLoading, preserveWorkingSelection) => {
25
25
  this.validateStaticElementsLoad();
26
- this.bridge.init(correlation, skipElementsLoading);
26
+ this.bridge.init(correlation, skipElementsLoading, preserveWorkingSelection);
27
27
  };
28
28
  onInitStart = (cb) => {
29
29
  return this.bridge.onInitStart(cb);
@@ -8,6 +8,7 @@ export declare const initReducers: {
8
8
  payload: {
9
9
  correlation: string;
10
10
  skipElementsLoading?: boolean | undefined;
11
+ preserveWorkingSelection?: boolean | undefined;
11
12
  };
12
13
  type: string;
13
14
  }>;
@@ -18,7 +18,7 @@ import { initTotalCountSaga } from "./initTotalCount.js";
18
18
  export function* initWorker() {
19
19
  yield takeLatest(actions.init.match, initSaga);
20
20
  }
21
- function* initSaga({ payload: { correlation, skipElementsLoading = false }, }) {
21
+ function* initSaga({ payload: { correlation, skipElementsLoading = false, preserveWorkingSelection = false }, }) {
22
22
  try {
23
23
  yield put(actions.initStart({ correlation }));
24
24
  if (skipElementsLoading) {
@@ -36,7 +36,14 @@ function* initSaga({ payload: { correlation, skipElementsLoading = false }, }) {
36
36
  const loadTotal = !isLimitingAttributeFiltersEmpty(limitingFilters) ||
37
37
  limitingValidationItems.length > 0 ||
38
38
  limitingDateFilters.length > 0;
39
- const sagas = [initSelectionSaga, initAttributeElementsPageSaga];
39
+ // When switching back to elements mode from text mode, the working/committed selection has
40
+ // already been restored by resetForModeSwitch and its elements are still cached. Re-running
41
+ // initSelectionSaga would re-derive the selection from the (stale, last-applied) filter prop
42
+ // and clobber a pending working selection, so skip it. The element options list is
43
+ // still populated by initAttributeElementsPageSaga.
44
+ const sagas = preserveWorkingSelection
45
+ ? [initAttributeElementsPageSaga]
46
+ : [initSelectionSaga, initAttributeElementsPageSaga];
40
47
  if (hiddenElements?.length > 0) {
41
48
  // the rest need the attribute already loaded for the hiddenElements to work
42
49
  yield call(initAttributeSaga, correlation);
@@ -131,6 +131,7 @@ export declare const rootReducers: {
131
131
  payload: {
132
132
  correlation: string;
133
133
  skipElementsLoading?: boolean | undefined;
134
+ preserveWorkingSelection?: boolean | undefined;
134
135
  };
135
136
  type: string;
136
137
  }>;
@@ -136,6 +136,7 @@ export declare const actions: import("@reduxjs/toolkit").CaseReducerActions<{
136
136
  payload: {
137
137
  correlation: string;
138
138
  skipElementsLoading?: boolean | undefined;
139
+ preserveWorkingSelection?: boolean | undefined;
139
140
  };
140
141
  type: string;
141
142
  }>;
@@ -36,8 +36,10 @@ export interface IAttributeFilterLoader extends IAttributeLoader, IAttributeElem
36
36
  *
37
37
  * @param correlation - correlation that will be included in all callbacks fired by this method
38
38
  * @param skipElementsLoading - when true, only loads attribute metadata (skips elements, selection, total count)
39
+ * @param preserveWorkingSelection - when true, keeps the current working/committed selection instead of
40
+ * re-deriving it from the filter (used when switching back to elements mode from text mode)
39
41
  */
40
- init(correlation?: Correlation, skipElementsLoading?: boolean): void;
42
+ init(correlation?: Correlation, skipElementsLoading?: boolean, preserveWorkingSelection?: boolean): void;
41
43
  /**
42
44
  * Returns the current status of the initialization.
43
45
  */
@@ -100,6 +100,11 @@ export function FilterGroup(props) {
100
100
  return;
101
101
  }
102
102
  function AttributeFilterComponent(attributeFilterProps) {
103
+ const isMobile = useMediaQuery("mobileDevice");
104
+ // On mobile, render filters inside the group same as standard mobile attribute filter row.
105
+ const InnerDropdownButton = isMobile
106
+ ? AttributeFilterDropdownButton
107
+ : FilterGroupItem;
103
108
  // When the filter swaps between Loading / Error / DropdownButton renderings,
104
109
  // the focused DOM node is unmounted. Capture focus on ref-cleanup and
105
110
  // re-apply it when the replacement mounts, so the user doesn't visually lose focus.
@@ -125,14 +130,17 @@ export function FilterGroup(props) {
125
130
  }, []);
126
131
  const DropdownButtonComponent = useCallback(function DropdownButtonComponent({ buttonRef, ...props }) {
127
132
  const titleExtension = getTitleExtension?.(filterIdentifier, props.title);
128
- const CustomDropdownButtonComponent = attributeFilterProps.DropdownButtonComponent ??
129
- FilterGroupItem;
133
+ const CustomDropdownButtonComponent = attributeFilterProps.DropdownButtonComponent ?? InnerDropdownButton;
130
134
  const handleButtonRef = useMergeRefs(buttonRef, setFilterItemRef);
131
- return (_jsx(CustomDropdownButtonComponent, { ...props, titleExtension: _jsxs(_Fragment, { children: [props.titleExtension, titleExtension, _jsx(FilterButtonCustomIcon, { customIcon: props.customIcon, disabled: props.disabled })
132
- ] }), buttonRef: handleButtonRef }));
133
- }, [attributeFilterProps.DropdownButtonComponent, setFilterItemRef]);
134
- const LoadingComponent = useCallback(() => _jsx(FilterGroupItem, { isLoading: true, buttonRef: setFilterItemRef }), [setFilterItemRef]);
135
- const ErrorComponent = useCallback(() => _jsx(FilterGroupItem, { isError: true, buttonRef: setFilterItemRef }), [setFilterItemRef]);
135
+ return (_jsx(CustomDropdownButtonComponent, { ...props, titleExtension: _jsxs(_Fragment, { children: [props.titleExtension, titleExtension, isMobile ? null : (_jsx(FilterButtonCustomIcon, { customIcon: props.customIcon, disabled: props.disabled }))] }), buttonRef: handleButtonRef, isOpen: isMobile ? true : props.isOpen }));
136
+ }, [
137
+ attributeFilterProps.DropdownButtonComponent,
138
+ InnerDropdownButton,
139
+ setFilterItemRef,
140
+ isMobile,
141
+ ]);
142
+ const LoadingComponent = useCallback(() => (_jsx(InnerDropdownButton, { isLoading: true, buttonRef: setFilterItemRef, isOpen: isMobile ? true : undefined })), [setFilterItemRef, InnerDropdownButton, isMobile]);
143
+ const ErrorComponent = useCallback(() => (_jsx(InnerDropdownButton, { isError: true, buttonRef: setFilterItemRef, isOpen: isMobile ? true : undefined })), [setFilterItemRef, InnerDropdownButton, isMobile]);
136
144
  const ElementsSearchBarComponent = useCallback((props) => (_jsx(AttributeFilterElementsSearchBar, { ...props, onKeyDown: (e) => {
137
145
  // allow space key to be handled by filter search bar
138
146
  // and not stolen by filter group dropdown keyboard navigation
@@ -153,14 +161,20 @@ export function FilterGroup(props) {
153
161
  return;
154
162
  }
155
163
  function MeasureValueFilterComponent(measureValueFilterProps) {
164
+ const isMobile = useMediaQuery("mobileDevice");
156
165
  const setFilterItemRef = useCallback((element) => {
157
166
  filterItemRefs.current.set(filterIdentifier, element);
158
167
  }, []);
159
- const DropdownButtonComponent = useCallback(function DropdownButtonComponent({ buttonTitle, buttonSubtitle, buttonTitleExtension, disabled, isActive, onClick, }) {
168
+ const FilterGroupItemDropdownButton = useCallback(function DropdownButtonComponent({ buttonTitle, buttonSubtitle, buttonTitleExtension, disabled, isActive, onClick, }) {
160
169
  const titleExtension = getTitleExtension?.(filterIdentifier, buttonTitle);
161
170
  return (_jsx(FilterGroupItem, { title: buttonTitle, subtitle: buttonSubtitle ?? undefined, isOpen: isActive, isLoaded: true, disabled: disabled, titleExtension: _jsxs(_Fragment, { children: [buttonTitleExtension, titleExtension] }), onClick: onClick, buttonRef: setFilterItemRef }));
162
171
  }, [setFilterItemRef]);
163
- return (_jsx(MeasureValueFilter, { ...measureValueFilterProps, alignPoints: ITEM_ALIGN_POINTS, DropdownButtonComponent: measureValueFilterProps.DropdownButtonComponent ?? DropdownButtonComponent }));
172
+ // On mobile, fall back to MeasureValueFilter's own default dropdown button (a
173
+ // standard mobile filter row) instead of FilterGroupItem.
174
+ const DropdownButtonComponent = isMobile
175
+ ? measureValueFilterProps.DropdownButtonComponent
176
+ : (measureValueFilterProps.DropdownButtonComponent ?? FilterGroupItemDropdownButton);
177
+ return (_jsx(MeasureValueFilter, { ...measureValueFilterProps, alignPoints: ITEM_ALIGN_POINTS, DropdownButtonComponent: DropdownButtonComponent }));
164
178
  }
165
179
  result.set(filterIdentifier, MeasureValueFilterComponent);
166
180
  });
@@ -198,12 +212,14 @@ export function FilterGroup(props) {
198
212
  }, [getFilterIdentifier]);
199
213
  const groupAriaLabel = intl.formatMessage({ id: "filterGroup.aria.label" }, { title });
200
214
  const groupAriaLabelWithState = intl.formatMessage({ id: "filterGroup.aria.label.withState" }, { title, state: subtitle });
201
- const renderBody = useCallback(({ isMobile, closeDropdown }) => (_jsx("div", { role: "dialog", "aria-label": groupAriaLabel, onKeyDownCapture: handleKeyDownCapture, onKeyDown: handleKeyDown, children: _jsx(DropdownList, { className: "gd-filter-group-body", items: filters, maxHeight: 450, itemHeight: 53, accessibilityConfig: {
215
+ const renderBody = useCallback(({ isMobile, closeDropdown }) => (_jsx("div", { role: "dialog", "aria-label": groupAriaLabel, onKeyDownCapture: handleKeyDownCapture, onKeyDown: handleKeyDown, children: _jsx(DropdownList, { className: cx("gd-filter-group-body", { "gd-is-mobile": isMobile }), items: filters, maxHeight: 450, itemHeight: isMobile ? 47 : 53, accessibilityConfig: {
202
216
  role: "list",
203
217
  ariaLabel: groupAriaLabel,
204
218
  }, renderItem: renderItem, onKeyDownSelect: handleItemKeyboardAction, closeDropdown: closeDropdown, isMobile: isMobile }) })), [filters, renderItem, handleKeyDown, handleKeyDownCapture, handleItemKeyboardAction, groupAriaLabel]);
205
219
  const isMobile = useMediaQuery("mobileDevice");
206
- const renderButton = useCallback(({ toggleDropdown, isOpen, buttonRef, dropdownId }) => (_jsx("div", { className: cx({ "gd-is-mobile": isMobile && isOpen }), children: _jsx(AttributeFilterDropdownButton, { title: title, subtitle: subtitle, isLoaded: !isAnyFilterError, isOpen: isOpen, selectedItemsCount: selectedItemsCount, totalItemsCount: totalItemsCount, showSelectionCount: selectedItemsCount !== undefined || totalItemsCount !== undefined, icon: _jsx(UiIcon, { type: "folderSmall", size: 12, color: "complementary-6" }), dropdownId: dropdownId, buttonRef: buttonRef, onClick: toggleDropdown, isError: isAnyFilterError, ariaLabel: groupAriaLabelWithState }) })), [
220
+ const renderButton = useCallback(({ toggleDropdown, isOpen, buttonRef, dropdownId }) => (_jsx("div", { className: cx({
221
+ "gd-attribute-filter-mobile-button-wrapper gd-is-mobile gd-is-mobile--with-menu": isMobile && isOpen,
222
+ }), children: _jsx(AttributeFilterDropdownButton, { title: title, subtitle: subtitle, isLoaded: !isAnyFilterError, isOpen: isOpen, selectedItemsCount: selectedItemsCount, totalItemsCount: totalItemsCount, showSelectionCount: selectedItemsCount !== undefined || totalItemsCount !== undefined, icon: _jsx(UiIcon, { type: "folderSmall", size: 12, color: "complementary-6" }), dropdownId: dropdownId, buttonRef: buttonRef, onClick: toggleDropdown, isError: isAnyFilterError, ariaLabel: groupAriaLabelWithState }) })), [
207
223
  title,
208
224
  subtitle,
209
225
  isAnyFilterError,
@@ -17,7 +17,6 @@ export interface IConditionInputSectionProps {
17
17
  };
18
18
  } | undefined;
19
19
  usePercentage: boolean;
20
- baseDisableAutofocus?: boolean;
21
20
  separators?: ISeparators;
22
21
  onValueChange: (index: number, value: number) => void;
23
22
  onFromChange: (index: number, from: number) => void;
@@ -7,12 +7,12 @@ import { ComparisonInput } from "./ComparisonInput.js";
7
7
  import { RangeInput } from "./RangeInput.js";
8
8
  export const ConditionInputSection = memo(function ConditionInputSection(props) {
9
9
  const intl = useIntl();
10
- const { index, conditionNumber, condition, usePercentage, baseDisableAutofocus, separators, onValueChange, onFromChange, onToChange, onValueBlur, onFromBlur, onToBlur, onApply, } = props;
10
+ const { index, conditionNumber, condition, usePercentage, separators, onValueChange, onFromChange, onToChange, onValueBlur, onFromBlur, onToBlur, onApply, } = props;
11
11
  if (!condition || condition.operator === "ALL") {
12
12
  return null;
13
13
  }
14
- // Only the first condition can autofocus inputs. All others explicitly disable autofocus.
15
- const disableAutofocus = baseDisableAutofocus === true || index !== 0;
14
+ // Never autofocus the value inputs. Initial focus must stay on the operator dropdown button.
15
+ const disableAutofocus = true;
16
16
  const errorId = `mvf-validation-error-${index}`;
17
17
  if (isComparisonConditionOperator(condition.operator)) {
18
18
  const v = condition.value.value;
@@ -25,7 +25,7 @@ export const ConditionInputSection = memo(function ConditionInputSection(props)
25
25
  })
26
26
  : undefined;
27
27
  return (_jsxs(_Fragment, { children: [
28
- _jsx(ComparisonInput, { value: condition.value.value, usePercentage: usePercentage, onValueChange: (v) => onValueChange(index, v), onEnterKeyPress: onApply, onBlur: () => onValueBlur(index), hasError: shouldShowError, ariaDescribedBy: shouldShowError ? errorId : undefined, disableAutofocus: disableAutofocus, separators: separators, conditionNumber: conditionNumber }), shouldShowError ? (_jsx("div", { id: errorId, className: "gd-mvf-input-error s-mvf-input-error", "data-testid": errorId, role: "alert", children: validationErrorText })) : null] }));
28
+ _jsx(ComparisonInput, { value: condition.value.value, usePercentage: usePercentage, onValueChange: (v) => onValueChange(index, v), onEnterKeyPress: onApply, onBlur: () => onValueBlur(index), hasError: shouldShowError, ariaDescribedBy: shouldShowError ? errorId : undefined, disableAutofocus: disableAutofocus, separators: separators, conditionNumber: conditionNumber }), shouldShowError ? (_jsx("div", { id: errorId, className: "gd-mvf-input-error s-mvf-input-error", "data-testid": errorId, children: validationErrorText })) : null] }));
29
29
  }
30
30
  if (isRangeConditionOperator(condition.operator)) {
31
31
  const { from = null, to = null } = condition.value;
@@ -25,7 +25,7 @@ const DropdownWithIntl = memo(function DropdownWithIntl(props) {
25
25
  return (_jsx(FullScreenOverlay, { alignTo: "body", alignPoints: MOBILE_DROPDOWN_ALIGN_POINTS, onClose: onCancel, children: _jsxs("div", { className: "gd-mobile-dropdown-overlay overlay gd-flex-row-container gd-mvf-mobile-dropdown", children: [mobileHeader ? (_jsx("div", { className: "gd-mobile-dropdown-header gd-flex-item gd-mvf-mobile-dropdown-header gd-is-mobile", children: mobileHeader })) : null, _jsx("div", { className: "gd-mobile-dropdown-content gd-flex-item-stretch gd-flex-row-container gd-mvf-mobile-dropdown-content", children: body })
26
26
  ] }) }));
27
27
  }
28
- return (_jsx(Overlay, { alignTo: anchorEl, alignPoints: alignPoints ?? DROPDOWN_ALIGNMENTS, closeOnOutsideClick: true, closeOnParentScroll: true, closeOnMouseDrag: true, onClose: onCancel, children: body }));
28
+ return (_jsx(Overlay, { alignTo: anchorEl, alignPoints: alignPoints ?? DROPDOWN_ALIGNMENTS, closeOnOutsideClick: true, closeOnParentScroll: true, closeOnMouseDrag: true, closeOnEscape: true, onClose: onCancel, children: body }));
29
29
  });
30
30
  export function Dropdown(props) {
31
31
  return (_jsx(IntlWrapper, { locale: props.locale, children: _jsx(DropdownWithIntl, { ...props }) }));
@@ -10,7 +10,6 @@ interface IDropdownBodyProps extends IMeasureValueFilterCustomComponentProps {
10
10
  usePercentage?: boolean;
11
11
  warningMessage?: WarningMessage;
12
12
  locale?: string;
13
- disableAutofocus?: boolean;
14
13
  onCancel?: () => void;
15
14
  measureTitle?: string;
16
15
  onApply: IMeasureValueFilterDropdownCallback;