import { addResizeObserver } from '@wix/bex-core'; import { action, computed, makeObservable, observable, runInAction, } from 'mobx'; import { ToolbarResponsivePriorityState } from './ToolbarResponsivePriorityState'; import { ToolbarState } from '../../components/CollectionToolbars/ToolbarState'; import { ToolbarLeftGroupState } from './ToolbarLeftGroupState'; import { computeResponsiveTargetState } from './computeResponsiveTargetState'; import { OVERFLOW_BUTTON_WIDTH, SEARCH_COLLAPSED_WIDTH, SEARCH_EXPANDED_WIDTH, } from './toolbarResponsiveConstants'; export interface ToolbarResponsiveStateParams { readonly toolbar: ToolbarState; } // Buffer (in px) added to the expand threshold to prevent flickering near the // expand/collapse boundary. Tuned to ~2-3x the inter-element gap (SP2 = 12px) -- // large enough to absorb measurement rounding and flex reflow, small enough that // the dead zone per group isn't visually noticeable during gradual resizing. const EXPAND_THRESHOLD_BUFFER = 30; export class ToolbarResponsiveState { readonly actions: ToolbarResponsivePriorityState; readonly search: ToolbarResponsivePriorityState; readonly filters: ToolbarResponsivePriorityState; private readonly _groups: ToolbarResponsivePriorityState[]; _shouldOverflow = false; _measurementElement: Element | null = null; _contentElement: Element | null = null; // Width of non-group content that is removed from the DOM during overflow // (e.g., tableGridSwitchButton). Must be accounted for in the exit-overflow // calculation to prevent flicker. _overflowHiddenContentWidth = 0; readonly toolbar: ToolbarState; leftGroupState: ToolbarLeftGroupState | null = null; get container() { return this.toolbar.toolbar.container; } constructor(params: ToolbarResponsiveStateParams) { this.toolbar = params.toolbar; this.actions = new ToolbarResponsivePriorityState({ responsive: this, }); this.filters = new ToolbarResponsivePriorityState({ responsive: this, }); this.search = new ToolbarResponsivePriorityState({ responsive: this, expandedWidth: SEARCH_EXPANDED_WIDTH, collapsedWidth: SEARCH_COLLAPSED_WIDTH, }); this._groups = [this.actions, this.filters, this.search]; makeObservable(this, { _shouldOverflow: observable.ref, _overflowHiddenContentWidth: observable.ref, responsiveDisabled: computed, _applyTargetState: action, }); } get responsiveDisabled() { const { toolbar: { toolbar: { _syncedProps }, }, container: { internalExperiments }, } = this; return ( !_syncedProps?.useResponsiveLayout || !internalExperiments?.enabled('specs.cairo.EnableResponsiveToolbar11420') ); } _scheduleRecompute() { if (this.responsiveDisabled) { return; } this._recomputeThrottled?.(); } private _computeResponsiveInput() { const el = this._measurementElement; const contentEl = this._contentElement; if (!el || !contentEl) { return null; } // The left group (title/tabs/views) has flex:1 and absorbs freed space when // right-side items shrink. Account for its excess width so the algorithm sees // the true available space and can expand items back when the container grows. const leftGroupExcess = this.leftGroupState?.getExcessWidth() ?? 0; const containerWidth = el.clientWidth + leftGroupExcess; const contentWidth = contentEl.scrollWidth; const groups = this._groups.map((g) => ({ expandedWidth: g.expandedWidth, collapsedWidth: g.collapsedWidth, currentlyShrunk: g._shouldShrink, })); // When overflowing, the overflowable groups are removed from the DOM and // replaced by the overflow button. Compute groupsTotal to reflect what the // algorithm expects (all groups at their current widths) so that // availableWidth = containerWidth - nonGroupContent is accurate. let groupsTotal: number; if (this._shouldOverflow) { // contentWidth includes the overflow button but not the overflowable groups. // groupsTotal must match: only non-overflowed groups + overflow button. const overflowableIndices = ToolbarResponsiveState._overflowableGroupIndices; const nonOverflowedTotal = groups.reduce( (sum, g, i) => overflowableIndices.includes(i) ? sum : sum + (g.currentlyShrunk ? g.collapsedWidth : g.expandedWidth), 0, ); groupsTotal = nonOverflowedTotal + OVERFLOW_BUTTON_WIDTH; } else { groupsTotal = groups.reduce( (sum, g) => sum + (g.currentlyShrunk ? g.collapsedWidth : g.expandedWidth), 0, ); } // nonGroupContent that is hidden during overflow but would reappear on exit. // Must be added back so the algorithm doesn't overestimate available space. const hiddenContentAdjustment = this._shouldOverflow ? this._overflowHiddenContentWidth : 0; return { availableWidth: containerWidth - (contentWidth - groupsTotal) - hiddenContentAdjustment, groups, }; } // Indices of groups that can be absorbed into the overflow menu. // actions = 0, filters = 1 (search = 2 stays visible). private static readonly _overflowableGroupIndices = [0, 1]; _applyTargetState() { const input = this._computeResponsiveInput(); if (!input) { return; } const result = computeResponsiveTargetState({ ...input, expandThresholdBuffer: EXPAND_THRESHOLD_BUFFER, currentlyOverflowing: this._shouldOverflow, overflowButtonWidth: OVERFLOW_BUTTON_WIDTH, overflowableGroupIndices: ToolbarResponsiveState._overflowableGroupIndices, }); this._groups.forEach((g, i) => { g._shouldShrink = result.groups[i].shouldShrink; }); this._shouldOverflow = result.shouldOverflow; } private _recomputeThrottled?: ReturnType< typeof this.container.lodash.throttle >; private _rafId = 0; init() { const { responsiveDisabled } = this; if (responsiveDisabled) { return () => {}; } this._recomputeThrottled = this.container.lodash.throttle(() => { this._rafId = window.requestAnimationFrame(() => { runInAction(() => this._applyTargetState()); }); }, 200); const containerEl = this.toolbar.toolbar.toolbarRect.element; if (!containerEl) { return () => { this._recomputeThrottled?.cancel(); }; } const disposers = [ addResizeObserver(containerEl, this._recomputeThrottled), ]; return () => { disposers.forEach((d) => d?.()); this._recomputeThrottled?.cancel(); cancelAnimationFrame(this._rafId); }; } }