@12-apps/ui 6.41.0 → 6.41.2

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 (73) hide show
  1. package/dist/_internal/components/data-display/DataViews/DataViewsGrid.js +8 -8
  2. package/dist/_internal/components/data-display/DataViews/DataViewsTableBase.js +9 -9
  3. package/dist/_internal/components/data-display/DataViews/data-views-category-pill.js +1 -1
  4. package/dist/_internal/components/data-display/DataViews/data-views-filter-panel.js +2 -2
  5. package/dist/_internal/components/data-display/DataViews/data-views-grid-parts.js +7 -7
  6. package/dist/_internal/components/data-display/DataViews/data-views-inline-bar.js +4 -4
  7. package/dist/_internal/components/data-display/DataViews/data-views-more-fields.js +24 -1
  8. package/dist/_internal/components/data-display/DataViews/data-views-more-filters.js +23 -2
  9. package/dist/_internal/components/data-display/DataViews/data-views-overflow.js +1 -1
  10. package/dist/_internal/components/form/Autocomplete/Autocomplete.js +2 -2
  11. package/dist/_internal/components/form/Autocomplete/useAutocomplete.js +1 -1
  12. package/dist/_internal/components/form/index.js +2 -2
  13. package/dist/_internal/components/index.js +2 -2
  14. package/dist/_internal/components/layout/SettingsLayout/SettingsLayout.js +4 -4
  15. package/dist/_internal/components/layout/SettingsLayout/SettingsPanel.js +3 -3
  16. package/dist/_internal/components/layout/SettingsLayout/SettingsSectionChips.js +2 -2
  17. package/dist/_internal/components/layout/SettingsLayout/SettingsSectionChips.scroll.js +7 -1
  18. package/dist/{chunk-ODW2D5PN.js → chunk-6QYKZMEE.js} +5 -2
  19. package/dist/{chunk-ODW2D5PN.js.map → chunk-6QYKZMEE.js.map} +1 -1
  20. package/dist/{chunk-Y2UXX5IQ.js → chunk-BKZERHNC.js} +2 -2
  21. package/dist/{chunk-M4P7NS2L.js → chunk-BR2T72JI.js} +23 -15
  22. package/dist/chunk-BR2T72JI.js.map +1 -0
  23. package/dist/{chunk-UGPRBJWB.js → chunk-DH77PLFF.js} +5 -5
  24. package/dist/chunk-GT2RCOHM.js +184 -0
  25. package/dist/chunk-GT2RCOHM.js.map +1 -0
  26. package/dist/{chunk-EIII4QU4.js → chunk-HFVYWHJT.js} +4 -4
  27. package/dist/{chunk-VEEG4UXZ.js → chunk-IUD6N52Q.js} +2 -2
  28. package/dist/{chunk-RZ2VZ7SL.js → chunk-JMEPGXJG.js} +2 -2
  29. package/dist/{chunk-J6KLT7HP.js → chunk-MEBQKYH4.js} +2 -2
  30. package/dist/{chunk-XVZG52CM.js → chunk-NOLSQK4J.js} +23 -25
  31. package/dist/chunk-NOLSQK4J.js.map +1 -0
  32. package/dist/{chunk-VWJEM2ER.js → chunk-NPPJ3IJP.js} +2 -2
  33. package/dist/chunk-NPPJ3IJP.js.map +1 -0
  34. package/dist/{chunk-CKHFYTIX.js → chunk-TBMGYQJF.js} +15 -5
  35. package/dist/chunk-TBMGYQJF.js.map +1 -0
  36. package/dist/{chunk-TCNTIGMY.js → chunk-TDXEO2UI.js} +2 -2
  37. package/dist/{chunk-FTQBVZ7V.js → chunk-TJ3334AJ.js} +2 -2
  38. package/dist/{chunk-VOJBLBXE.js → chunk-UT73NGRS.js} +2 -2
  39. package/dist/data-display/DataViews.js +9 -9
  40. package/dist/form/Autocomplete.js +2 -2
  41. package/dist/layout/SettingsLayout.js +4 -4
  42. package/dist/types/components/data-display/DataViews/data-views-category-pill.d.ts +6 -1
  43. package/dist/types/components/data-display/DataViews/data-views-category-pill.d.ts.map +1 -1
  44. package/dist/types/components/data-display/DataViews/data-views-more-fields.d.ts +0 -11
  45. package/dist/types/components/data-display/DataViews/data-views-more-fields.d.ts.map +1 -1
  46. package/dist/types/components/data-display/DataViews/data-views-overflow.d.ts.map +1 -1
  47. package/dist/types/components/form/Autocomplete/useAutocomplete.d.ts +9 -0
  48. package/dist/types/components/form/Autocomplete/useAutocomplete.d.ts.map +1 -1
  49. package/dist/types/components/layout/SettingsLayout/SettingsSectionChips.d.ts.map +1 -1
  50. package/dist/types/components/layout/SettingsLayout/SettingsSectionChips.scroll.d.ts +20 -2
  51. package/dist/types/components/layout/SettingsLayout/SettingsSectionChips.scroll.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/components/data-display/DataViews/data-views-category-pill.tsx +6 -1
  54. package/src/components/data-display/DataViews/data-views-more-fields.tsx +43 -37
  55. package/src/components/data-display/DataViews/data-views-overflow.ts +12 -1
  56. package/src/components/form/Autocomplete/useAutocomplete.ts +46 -16
  57. package/src/components/layout/SettingsLayout/SettingsSectionChips.scroll.ts +212 -28
  58. package/src/components/layout/SettingsLayout/SettingsSectionChips.tsx +24 -4
  59. package/dist/chunk-CKHFYTIX.js.map +0 -1
  60. package/dist/chunk-M4P7NS2L.js.map +0 -1
  61. package/dist/chunk-SLJAQA2X.js +0 -82
  62. package/dist/chunk-SLJAQA2X.js.map +0 -1
  63. package/dist/chunk-VWJEM2ER.js.map +0 -1
  64. package/dist/chunk-XVZG52CM.js.map +0 -1
  65. /package/dist/{chunk-Y2UXX5IQ.js.map → chunk-BKZERHNC.js.map} +0 -0
  66. /package/dist/{chunk-UGPRBJWB.js.map → chunk-DH77PLFF.js.map} +0 -0
  67. /package/dist/{chunk-EIII4QU4.js.map → chunk-HFVYWHJT.js.map} +0 -0
  68. /package/dist/{chunk-VEEG4UXZ.js.map → chunk-IUD6N52Q.js.map} +0 -0
  69. /package/dist/{chunk-RZ2VZ7SL.js.map → chunk-JMEPGXJG.js.map} +0 -0
  70. /package/dist/{chunk-J6KLT7HP.js.map → chunk-MEBQKYH4.js.map} +0 -0
  71. /package/dist/{chunk-TCNTIGMY.js.map → chunk-TDXEO2UI.js.map} +0 -0
  72. /package/dist/{chunk-FTQBVZ7V.js.map → chunk-TJ3334AJ.js.map} +0 -0
  73. /package/dist/{chunk-VOJBLBXE.js.map → chunk-UT73NGRS.js.map} +0 -0
@@ -21,6 +21,24 @@ const LANDED_SLACK = 1;
21
21
  */
22
22
  const QUIET_BEFORE_SETTLED_MS = 250;
23
23
 
24
+ /**
25
+ * How much `scrollLeft` has to move, tick to tick, before a `scroll` event is
26
+ * even a CANDIDATE for arming (FUT-2775). Below this, a browser's own rounding
27
+ * or a repeated event carrying no real motion cannot arm anything by itself.
28
+ *
29
+ * This alone does not stop the false latch the ticket describes: the strip's
30
+ * own `scrollTo` produces real per-tick deltas well past this line, same as a
31
+ * drag would. It only rules out a scroll event that moved nothing.
32
+ */
33
+ const SCROLL_DELTA_EPSILON = 2;
34
+
35
+ /**
36
+ * How far a pointer or touch has to travel from where it went down before it
37
+ * counts as having MOVED, rather than merely resting (FUT-2775). Below this,
38
+ * an held-still finger's sub-pixel tremor does not count as a drag.
39
+ */
40
+ const POINTER_MOVE_EPSILON = 2;
41
+
24
42
  interface VisitorScrollWatch {
25
43
  /** True once the visitor has moved the strip themselves. */
26
44
  hasScrolled: () => boolean;
@@ -29,10 +47,14 @@ interface VisitorScrollWatch {
29
47
  * strip must not scroll itself under it: that would yank a drag in progress.
30
48
  */
31
49
  isUndecided: () => boolean;
32
- /** Where the strip last aimed itself, in its own pixels; `NaN` before it has. */
50
+ /**
51
+ * Where the strip last aimed itself, PHYSICAL pixels (see {@link physicalScrollLeft}) —
52
+ * direction-blind, like every other geometry read in this file. `NaN` before it has.
53
+ */
33
54
  aimedAt: () => number;
34
55
  /**
35
- * The strip is about to scroll itself to `left`. Records the aim and NOTHING
56
+ * The strip is about to scroll itself to `left` (physical pixels). Records the
57
+ * aim and NOTHING
36
58
  * else: it used to disarm too, and a re-centre landing between a visitor's key
37
59
  * press (or wheel, or pan) and the first tick of the scroll it causes wiped
38
60
  * the arming — their scroll went unclaimed, read as the strip's own. Left
@@ -62,13 +84,172 @@ interface WatchState {
62
84
  aimed: number;
63
85
  quiet: ReturnType<typeof setTimeout> | undefined;
64
86
  settled: () => void;
87
+ /** `scrollLeft` as of the last `scroll` tick, updated every tick regardless of arming. */
88
+ lastScrollLeft: number;
89
+ /** Where a `pointerdown` on the strip landed, in viewport `clientX`. `NaN` while up. */
90
+ pointerDownAt: number;
91
+ /** True once that pointer has travelled past {@link POINTER_MOVE_EPSILON} from `pointerDownAt`. */
92
+ pointerMoved: boolean;
93
+ /** Same pair, for `touchstart`/`touchmove` — a distinct event stream on some engines. */
94
+ touchDownAt: number;
95
+ touchMoved: boolean;
96
+ }
97
+
98
+ type RtlScrollType = 'default' | 'negative' | 'reverse';
99
+
100
+ let cachedRtlScrollType: RtlScrollType | undefined;
101
+
102
+ /**
103
+ * Which of the three incompatible conventions this engine reports an RTL
104
+ * scroller's `scrollLeft` in, probed once and cached (FUT-2775).
105
+ *
106
+ * Per the CSSOM View spec, an RTL scroller's `scrollLeft` is `0` at its
107
+ * (right-hand) start and goes NEGATIVE toward the end — `'negative'` below,
108
+ * what every current evergreen engine implements. Older WebKit mirrored that
109
+ * (`'reverse'`: `0` at the start, growing toward `+max`), and older
110
+ * Chromium/Safari ignored `dir` altogether and kept the plain left-to-right
111
+ * numbering (`'default'`). A hidden probe, not a version sniff, tells them
112
+ * apart — the same technique `@mui/utils`'s `detectScrollType` uses for
113
+ * `Tabs`; reimplemented here rather than taking a new dependency for one
114
+ * probe.
115
+ */
116
+ function detectRtlScrollType(doc: Document): RtlScrollType {
117
+ if (cachedRtlScrollType) return cachedRtlScrollType;
118
+ // The exact pixel values below are the probe's mechanism, not a design size:
119
+ // what matters is only that the INNER box is wider than the OUTER one, so the
120
+ // outer one has something to scroll — same technique `@mui/utils` uses,
121
+ // detached and off-screen either way.
122
+ const probe = doc.createElement('div');
123
+ const inner = doc.createElement('div');
124
+ inner.style.width = '10px';
125
+ probe.appendChild(inner);
126
+ probe.dir = 'rtl';
127
+ probe.style.width = '4px';
128
+ probe.style.position = 'absolute';
129
+ probe.style.top = '-1000px';
130
+ probe.style.overflow = 'scroll';
131
+ doc.body.appendChild(probe);
132
+ let type: RtlScrollType = 'reverse';
133
+ if (probe.scrollLeft > 0) {
134
+ type = 'default';
135
+ } else {
136
+ probe.scrollLeft = 1;
137
+ if (probe.scrollLeft === 0) type = 'negative';
138
+ }
139
+ doc.body.removeChild(probe);
140
+ cachedRtlScrollType = type;
141
+ return type;
142
+ }
143
+
144
+ /** True when the strip's own computed direction is right-to-left. */
145
+ export function isRtlStrip(strip: HTMLElement): boolean {
146
+ const view = strip.ownerDocument.defaultView;
147
+ const getComputedStyle = view?.getComputedStyle ?? globalThis.getComputedStyle;
148
+ return getComputedStyle(strip).direction === 'rtl';
149
+ }
150
+
151
+ /**
152
+ * `strip.scrollLeft`, renumbered so `0` is always the strip's physical LEFT
153
+ * edge and larger is always further physically right — whatever `direction`
154
+ * and whatever convention this engine uses to report it raw (FUT-2775).
155
+ *
156
+ * Every geometry read in this file (`getBoundingClientRect`) is already in
157
+ * that same physical, direction-blind coordinate space, so this is what makes
158
+ * `scrollLeft` combinable with it at all.
159
+ */
160
+ export function physicalScrollLeft(strip: HTMLElement): number {
161
+ const raw = strip.scrollLeft;
162
+ if (!isRtlStrip(strip)) return raw;
163
+ const max = strip.scrollWidth - strip.clientWidth;
164
+ const type = detectRtlScrollType(strip.ownerDocument);
165
+ if (type === 'negative') return raw + max;
166
+ if (type === 'reverse') return max - raw;
167
+ return raw;
168
+ }
169
+
170
+ /** The inverse of {@link physicalScrollLeft}: a physical offset back to this engine's raw `scrollLeft`. */
171
+ export function toRawScrollLeft(strip: HTMLElement, physical: number): number {
172
+ if (!isRtlStrip(strip)) return physical;
173
+ const max = strip.scrollWidth - strip.clientWidth;
174
+ const type = detectRtlScrollType(strip.ownerDocument);
175
+ if (type === 'negative') return physical - max;
176
+ if (type === 'reverse') return max - physical;
177
+ return physical;
65
178
  }
66
179
 
67
180
  /** Whether the strip came to rest where it last aimed itself — clamped as the browser would. */
68
181
  function restsOnAim(strip: HTMLElement, aimed: number): boolean {
69
182
  const reachable = Math.min(aimed, strip.scrollWidth - strip.clientWidth);
70
183
  // Written so that `NaN` — the strip never aimed — reads as "not on aim".
71
- return Math.abs(strip.scrollLeft - reachable) <= LANDED_SLACK;
184
+ return Math.abs(physicalScrollLeft(strip) - reachable) <= LANDED_SLACK;
185
+ }
186
+
187
+ /** The pointer/touch/keydown/wheel listeners that arm `state`, split out of `watchVisitorScroll` (line budget). */
188
+ function createGestureListeners(state: WatchState): { onStrip: Listeners; onView: Listeners } {
189
+ // `MouseEvent`, not `PointerEvent`: a real `PointerEvent` IS one (it extends
190
+ // `MouseEvent`), so this reads `clientX` off it just the same in a browser —
191
+ // but jsdom (this file's own test environment) has never implemented
192
+ // `PointerEvent` at all, and the bare identifier throws a `ReferenceError`
193
+ // the moment anything evaluates `instanceof PointerEvent`, pointer event or
194
+ // not. Same reasoning as feature-checking `scrollTo` elsewhere in this file:
195
+ // nothing in here is worth a crash over which constructor a probe prefers.
196
+ const onPointerDown = (event: Event): void => {
197
+ state.pointer = true;
198
+ state.pointerMoved = false;
199
+ state.pointerDownAt = event instanceof MouseEvent ? event.clientX : Number.NaN;
200
+ };
201
+ const onPointerMove = (event: Event): void => {
202
+ if (!(event instanceof MouseEvent) || Number.isNaN(state.pointerDownAt)) return;
203
+ if (Math.abs(event.clientX - state.pointerDownAt) > POINTER_MOVE_EPSILON) state.pointerMoved = true;
204
+ };
205
+ const onPointerUp = (): void => {
206
+ state.pointer = false;
207
+ state.pointerDownAt = Number.NaN;
208
+ };
209
+ const onTouchStart = (event: Event): void => {
210
+ state.touch = true;
211
+ state.touchMoved = false;
212
+ state.touchDownAt = event instanceof TouchEvent ? (event.touches[0]?.clientX ?? Number.NaN) : Number.NaN;
213
+ };
214
+ const onTouchMove = (event: Event): void => {
215
+ if (!(event instanceof TouchEvent) || Number.isNaN(state.touchDownAt)) return;
216
+ const clientX = event.touches[0]?.clientX;
217
+ if (clientX !== undefined && Math.abs(clientX - state.touchDownAt) > POINTER_MOVE_EPSILON) {
218
+ state.touchMoved = true;
219
+ }
220
+ };
221
+ const onTouchEnd = (): void => {
222
+ state.touch = false;
223
+ state.touchDownAt = Number.NaN;
224
+ };
225
+ const onPointerCancel = (): void => {
226
+ state.nudged = true;
227
+ onPointerUp();
228
+ };
229
+ const onKeydown = (): void => {
230
+ state.nudged = true;
231
+ };
232
+ const onWheel = (event: Event): void => {
233
+ if (horizontalDelta(event) !== 0) state.nudged = true;
234
+ };
235
+ return {
236
+ onStrip: [
237
+ ['pointerdown', onPointerDown],
238
+ ['pointermove', onPointerMove],
239
+ ['touchstart', onTouchStart],
240
+ ['touchmove', onTouchMove],
241
+ ['pointercancel', onPointerCancel],
242
+ ['keydown', onKeydown],
243
+ ['wheel', onWheel],
244
+ ],
245
+ // A gesture can END off the strip, so its end is heard on the window.
246
+ onView: [
247
+ ['pointerup', onPointerUp],
248
+ ['pointercancel', onPointerUp],
249
+ ['touchend', onTouchEnd],
250
+ ['touchcancel', onTouchEnd],
251
+ ],
252
+ };
72
253
  }
73
254
 
74
255
  /**
@@ -115,12 +296,12 @@ export function watchVisitorScroll(strip: HTMLElement): VisitorScrollWatch {
115
296
  aimed: Number.NaN,
116
297
  quiet: undefined,
117
298
  settled: () => undefined,
299
+ lastScrollLeft: physicalScrollLeft(strip),
300
+ pointerDownAt: Number.NaN,
301
+ pointerMoved: false,
302
+ touchDownAt: Number.NaN,
303
+ touchMoved: false,
118
304
  };
119
- const set =
120
- (key: 'pointer' | 'touch' | 'nudged', value: boolean) =>
121
- (): void => {
122
- state[key] = value;
123
- };
124
305
  const settle = (): void => {
125
306
  // Only a scroll that was UNDER JUDGEMENT disarms. Chromium can end the
126
307
  // strip's own smooth scroll with a `scrollend` when another scroll cuts it
@@ -132,33 +313,36 @@ export function watchVisitorScroll(strip: HTMLElement): VisitorScrollWatch {
132
313
  if (!restsOnAim(strip, state.aimed)) state.scrolled = true;
133
314
  state.settled();
134
315
  };
135
- const onWheel = (event: Event): void => {
136
- if (horizontalDelta(event) !== 0) state.nudged = true;
137
- };
138
316
  const onScroll = (): void => {
139
- if (state.pointer || state.touch || state.nudged) state.undecided = true;
317
+ const now = physicalScrollLeft(strip);
318
+ const movedFarEnough = Math.abs(now - state.lastScrollLeft) > SCROLL_DELTA_EPSILON;
319
+ state.lastScrollLeft = now;
320
+ // Pointer/touch need BOTH signals (FUT-2775): the pointer/finger must have
321
+ // itself MOVED since going down, AND this tick's delta must be real. Either
322
+ // alone still latches on the other's shape — a pointer merely resting on
323
+ // the strip while its OWN `scrollTo` produces real per-tick deltas would
324
+ // arm under a delta-only check (`pointerMoved`/`touchMoved` rules that out);
325
+ // a pointer that drifts a sub-epsilon amount while resting would arm under
326
+ // a moved-only check with no delta floor (`movedFarEnough` rules that out).
327
+ const pointerDrag = state.pointer && state.pointerMoved && movedFarEnough;
328
+ const touchDrag = state.touch && state.touchMoved && movedFarEnough;
329
+ // `nudged` (a keypress, a sideways wheel, the browser taking the pointer
330
+ // over to pan) is already a discrete, one-shot gesture with no "resting
331
+ // pointer coincides with an unrelated animation" shape to rule out — unlike
332
+ // pointer/touch, requiring a delta on the SAME tick that arms it would only
333
+ // race the strip's own scroll ending before a qualifying tick arrives (seen:
334
+ // it broke the existing "keeps a wheel/drag scroll" stories against a real
335
+ // browser's `scrollend` timing). So it arms as it always has.
336
+ if (pointerDrag || touchDrag || state.nudged) state.undecided = true;
140
337
  if (!state.undecided || hasScrollEnd) return;
141
338
  // Every tick while undecided, armed or not: a quiet spell that began while
142
339
  // the strip was still moving would judge it mid-flight, away from its aim.
143
340
  clearTimeout(state.quiet);
144
341
  state.quiet = setTimeout(settle, QUIET_BEFORE_SETTLED_MS);
145
342
  };
146
- const onStrip: Listeners = [
147
- ['pointerdown', set('pointer', true)],
148
- ['touchstart', set('touch', true)],
149
- ['pointercancel', set('nudged', true)],
150
- ['keydown', set('nudged', true)],
151
- ['wheel', onWheel],
152
- ['scroll', onScroll],
153
- ['scrollend', settle],
154
- ];
155
- // A gesture can END off the strip, so its end is heard on the window.
156
- const onView: Listeners = [
157
- ['pointerup', set('pointer', false)],
158
- ['pointercancel', set('pointer', false)],
159
- ['touchend', set('touch', false)],
160
- ['touchcancel', set('touch', false)],
161
- ];
343
+ const gestures = createGestureListeners(state);
344
+ const onStrip: Listeners = [...gestures.onStrip, ['scroll', onScroll], ['scrollend', settle]];
345
+ const onView = gestures.onView;
162
346
  for (const [type, listener] of onStrip) strip.addEventListener(type, listener, { passive: true });
163
347
  for (const [type, listener] of onView) view?.addEventListener(type, listener, { passive: true });
164
348
  return {
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box/index.js';
4
4
  import { alpha, useTheme, type Theme } from '@mui/material/styles/index.js';
5
5
  import React, { useEffect, useRef, type RefObject } from 'react';
6
6
 
7
- import { watchVisitorScroll } from './SettingsSectionChips.scroll';
7
+ import { physicalScrollLeft, toRawScrollLeft, watchVisitorScroll } from './SettingsSectionChips.scroll';
8
8
  import { SettingsStatusMarker } from './SettingsStatusMarker';
9
9
  import { TOUCH_TARGET } from './SettingsLayout.styles';
10
10
  import { rem, sxRem } from '../../../tokens/relative';
@@ -48,11 +48,20 @@ function prefersReducedMotion(): boolean {
48
48
  * it. The clamp hid that for the last chip; a middle one was simply off-centre.
49
49
  * The boxes are both in viewport coordinates, so their difference is the strip's
50
50
  * own, whatever the host wraps it in.
51
+ *
52
+ * Read and returned in PHYSICAL pixels (`physicalScrollLeft`, FUT-2775): the
53
+ * bounding-rect arithmetic above is already direction-blind, and an RTL
54
+ * scroller's raw `scrollLeft` is not — the CSSOM View spec has it `0` at the
55
+ * strip's right-hand start and NEGATIVE past it, not `0`-to-`scrollWidth` like
56
+ * an LTR one. `Math.max(0, …)` alone, against that raw value, would clamp every
57
+ * legitimate RTL target straight back to the start. Physical throughout keeps
58
+ * this comparable to {@link VisitorScrollWatch.aimedAt}; the caller converts to
59
+ * this engine's raw `scrollLeft` (`toRawScrollLeft`) only at the `scrollTo` call.
51
60
  */
52
61
  function centredScrollLeft(strip: HTMLElement, chip: HTMLElement): number {
53
62
  const chipBox = chip.getBoundingClientRect();
54
63
  const visibleStart = strip.getBoundingClientRect().left + strip.clientLeft;
55
- const chipCentre = chipBox.left - visibleStart + strip.scrollLeft + chipBox.width / 2;
64
+ const chipCentre = chipBox.left - visibleStart + physicalScrollLeft(strip) + chipBox.width / 2;
56
65
  const target = chipCentre - strip.clientWidth / 2;
57
66
  return Math.max(0, Math.min(target, strip.scrollWidth - strip.clientWidth));
58
67
  }
@@ -122,13 +131,16 @@ function useCentreActiveChip(
122
131
  // Not while a scroll that may be the visitor's is still moving: a resize
123
132
  // mid-drag must not yank the strip. It is re-tried once that scroll rests.
124
133
  if (!chip || visitor.hasScrolled() || visitor.isUndecided()) return;
134
+ // Physical pixels throughout (FUT-2775) — comparable to `aimedAt()` and to
135
+ // what `beforeOwnScroll` records; converted to this engine's raw
136
+ // `scrollLeft` only for the actual `scrollTo` call below.
125
137
  const left = centredScrollLeft(strip, chip);
126
138
  // Only when the target MOVED: a resize that leaves it where it was must not
127
139
  // restart a smooth scroll that is already on its way there. The aim is a
128
140
  // runtime-computed scroll offset, like the exempt `left` below — not a size.
129
141
  if (Math.abs(left - visitor.aimedAt()) < 0.5) return;
130
142
  visitor.beforeOwnScroll(left);
131
- strip.scrollTo({ left, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
143
+ strip.scrollTo({ left: toRawScrollLeft(strip, left), behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
132
144
  };
133
145
  visitor.onSettled(centre);
134
146
  centre();
@@ -250,7 +262,15 @@ export function SettingsSectionChips({
250
262
  display: 'flex',
251
263
  alignItems: 'center',
252
264
  gap: 0.75,
253
- px: 0.25,
265
+ // The start side keeps its old, symmetric 0.25 — nothing sits flush
266
+ // against it once scrolled. The END side is where a scrolled-to-end
267
+ // chip's own border used to sit almost against the clip boundary
268
+ // (FUT-2775): sized like the gap BETWEEN chips (`gap` above) rather
269
+ // than invented fresh, so the last chip clears the edge by the same
270
+ // margin any two chips clear each other. Logical, not `pr`: the side
271
+ // that gets clipped is the strip's END, whichever edge that is.
272
+ paddingInlineStart: 0.25,
273
+ paddingInlineEnd: 0.75,
254
274
  py: 0.25,
255
275
  overflowX: 'auto',
256
276
  overflowY: 'hidden',
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/layout/SettingsLayout/SettingsSectionChips.tsx"],"sourcesContent":["'use client';\n\nimport Box from '@mui/material/Box/index.js';\nimport { alpha, useTheme, type Theme } from '@mui/material/styles/index.js';\nimport React, { useEffect, useRef, type RefObject } from 'react';\n\nimport { watchVisitorScroll } from './SettingsSectionChips.scroll';\nimport { SettingsStatusMarker } from './SettingsStatusMarker';\nimport { TOUCH_TARGET } from './SettingsLayout.styles';\nimport { rem, sxRem } from '../../../tokens/relative';\n\n/**\n * The chip's drawn height, against `TOUCH_TARGET`'s 44 for the tappable one.\n *\n * The strip sits between a section's header and its first field on the width\n * with the least room for either, so the pill is sized to its text rather than\n * to the thumb — and the thumb is served by the hit area instead.\n */\nconst CHIP_HEIGHT = sxRem(34);\nimport type { SettingsLayoutProps, SettingsNavItem } from './SettingsLayout.types';\n\nexport interface SettingsSectionChipsProps {\n /** The sibling sections to offer — usually the open item's own group. */\n items: SettingsNavItem[];\n /** Which one is open. */\n activeItemId?: string;\n /** Accessible name of the strip, in the host's language. */\n ariaLabel: string;\n linkComponent?: SettingsLayoutProps['linkComponent'];\n onSelectItem?: (id: string) => void;\n testIdPrefix: string;\n}\n\n/** True when the visitor asked the OS for less movement. Re-read per scroll. */\nfunction prefersReducedMotion(): boolean {\n if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;\n return window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n}\n\n/**\n * Where the strip has to scroll for `chip`'s centre to sit on its own, clamped\n * to what the strip can scroll.\n *\n * Measured from the two boxes rather than from `chip.offsetLeft`. `offsetLeft`\n * counts from the nearest POSITIONED ancestor, and the strip is not one — nor\n * are the panel and layout around it — so it counted from whatever box the host\n * happened to position, and every target overshot by the strip's offset inside\n * it. The clamp hid that for the last chip; a middle one was simply off-centre.\n * The boxes are both in viewport coordinates, so their difference is the strip's\n * own, whatever the host wraps it in.\n */\nfunction centredScrollLeft(strip: HTMLElement, chip: HTMLElement): number {\n const chipBox = chip.getBoundingClientRect();\n const visibleStart = strip.getBoundingClientRect().left + strip.clientLeft;\n const chipCentre = chipBox.left - visibleStart + strip.scrollLeft + chipBox.width / 2;\n const target = chipCentre - strip.clientWidth / 2;\n return Math.max(0, Math.min(target, strip.scrollWidth - strip.clientWidth));\n}\n\n/** The chip for `id`, walked rather than selected. */\nfunction findChip(strip: HTMLElement, id: string): HTMLElement | undefined {\n // An id is host data, and a section id carrying a quote would turn a selector\n // string into a thrown SyntaxError — which here would take the whole strip\n // down to centre one chip.\n return Array.from(strip.children).find(\n (node): node is HTMLElement => node instanceof HTMLElement && node.dataset.chipId === id,\n );\n}\n\n/**\n * Keep the open section's chip in view, however the visitor got here — and\n * however the strip's content settles after it mounted.\n *\n * Keyed on `activeItemId` in an effect rather than done in a click handler:\n * arriving by the desktop rail, by a card, by a pasted link and by the browser's\n * back button all have to land the same way, and only one of those is a click on\n * a chip. Every re-render otherwise starts the strip at scroll 0, which parks\n * the active chip off the right edge on any list longer than a screen — the\n * visitor is told where they are by an element they cannot see.\n *\n * Centring ONCE is not enough (FUT-2606). A host can widen a chip after mount\n * with the ids unchanged — a status marker whose read resolves late, a web font\n * arriving — and a strip that fitted at mount then overflows with its last chip\n * clipped, because the one centring saw nothing to scroll. So the strip and\n * every chip are watched with a `ResizeObserver`, and a resize re-aims at the\n * active chip when its target has moved.\n *\n * Never against the visitor, though: once they have scrolled the strip\n * themselves, it is theirs until the open section changes\n * ({@link watchVisitorScroll}, which also says why the strip's OWN smooth scroll\n * never counts as theirs). A strip that yanks back mid-drag because a\n * marker loaded is worse than one that does not centre.\n *\n * `itemsKey` rather than `items`: a host almost always rebuilds that array per\n * render (`groups.flatMap(...)`), and depending on it would re-run this on every\n * render and reset the visitor's latch with it.\n *\n * The key joins on NUL — as the ESCAPE `'\\u0000'`, which it must stay. A\n * literal NUL byte works identically at runtime and makes git classify this file\n * as BINARY: `git diff` prints \"Binary file not shown\" and the GitHub review UI\n * shows nothing at all. This file shipped that way once, and the cost was not\n * the byte — it was a whole change nobody could read.\n */\nfunction useCentreActiveChip(\n stripRef: RefObject<HTMLDivElement | null>,\n activeItemId: string | undefined,\n itemsKey: string,\n): void {\n useEffect(() => {\n const strip = stripRef.current;\n if (!strip || !activeItemId) return undefined;\n // Feature-checked, not assumed. `Element.scrollTo` is absent in jsdom and in\n // a few embedded webviews, and an unguarded call there THROWS out of an\n // effect — which React escalates to the nearest error boundary. The whole\n // settings area would go down to centre one chip, and the host's own tests\n // could not render the shell at all. Same reasoning as walking the children\n // instead of building a selector: nothing in here is worth a crash.\n if (typeof strip.scrollTo !== 'function') return undefined;\n const visitor = watchVisitorScroll(strip);\n const centre = (): void => {\n const chip = findChip(strip, activeItemId);\n // Not while a scroll that may be the visitor's is still moving: a resize\n // mid-drag must not yank the strip. It is re-tried once that scroll rests.\n if (!chip || visitor.hasScrolled() || visitor.isUndecided()) return;\n const left = centredScrollLeft(strip, chip);\n // Only when the target MOVED: a resize that leaves it where it was must not\n // restart a smooth scroll that is already on its way there. The aim is a\n // runtime-computed scroll offset, like the exempt `left` below — not a size.\n if (Math.abs(left - visitor.aimedAt()) < 0.5) return;\n visitor.beforeOwnScroll(left);\n strip.scrollTo({ left, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });\n };\n visitor.onSettled(centre);\n centre();\n // Also feature-checked: without it the strip still centres once, as before.\n const resizes = typeof ResizeObserver === 'function' ? new ResizeObserver(centre) : undefined;\n resizes?.observe(strip);\n for (const child of Array.from(strip.children)) resizes?.observe(child);\n return () => {\n resizes?.disconnect();\n visitor.detach();\n };\n }, [stripRef, activeItemId, itemsKey]);\n}\n\n/** One chip: a link when the host gave it a target, else a button. */\nfunction SectionChip({\n item,\n active,\n theme,\n linkComponent,\n onSelectItem,\n testIdPrefix,\n}: {\n item: SettingsNavItem;\n active: boolean;\n theme: Theme;\n linkComponent?: SettingsLayoutProps['linkComponent'];\n onSelectItem?: (id: string) => void;\n testIdPrefix: string;\n}): React.JSX.Element {\n const asLink = item.href !== undefined && linkComponent !== undefined;\n const behaviour = asLink\n ? { component: linkComponent, href: item.href }\n : { component: 'button' as const, type: 'button' as const, onClick: () => onSelectItem?.(item.id) };\n\n return (\n <Box\n {...behaviour}\n data-chip-id={item.id}\n data-testid={`${testIdPrefix}-chip-${item.id}`}\n aria-current={active ? 'page' : undefined}\n sx={{\n display: 'inline-flex',\n alignItems: 'center',\n gap: 0.75,\n flex: '0 0 auto',\n position: 'relative',\n // The chip LOOKS this tall; it is still tapped at `TOUCH_TARGET`.\n //\n // Shrinking `minHeight` alone would have bought a slimmer strip with a\n // smaller hit area — on the one width where the strip IS the navigation\n // and the input is a thumb. The `::after` below keeps the tappable\n // region at the full target while the pill itself takes less room, so\n // this is a visual change and not an accessibility trade.\n minHeight: CHIP_HEIGHT,\n px: 1.5,\n borderRadius: 999,\n cursor: 'pointer',\n font: 'inherit',\n fontSize: rem(theme, 13),\n fontWeight: active ? 700 : 500,\n textDecoration: 'none',\n whiteSpace: 'nowrap',\n color: active ? 'primary.main' : 'text.secondary',\n bgcolor: active ? alpha(theme.palette.primary.main, 0.12) : 'transparent',\n border: `1px solid ${active ? alpha(theme.palette.primary.main, 0.4) : theme.palette.divider}`,\n // Spans the chip's width only, so neighbours never overlap: the strip\n // is a row, and the extra reach is vertical.\n '&::after': {\n content: '\"\"',\n position: 'absolute',\n insetInlineStart: 0,\n insetInlineEnd: 0,\n top: '50%',\n transform: 'translateY(-50%)',\n height: rem(theme, TOUCH_TARGET),\n },\n }}\n >\n {item.status ? (\n <SettingsStatusMarker\n status={item.status}\n label={item.statusLabel}\n testId={`${testIdPrefix}-chip-status-${item.id}`}\n />\n ) : null}\n {item.label}\n </Box>\n );\n}\n\n/**\n * The narrow-width section strip: horizontally scrollable chips for the sections\n * beside the open one.\n *\n * It clips its own overflow — `overflow-x: auto` on the strip, never on an\n * ancestor. A strip that pushes the page wide makes every OTHER screen at that\n * width scroll sideways too, and the cause is nowhere near the symptom.\n */\nexport function SettingsSectionChips({\n items,\n activeItemId,\n ariaLabel,\n linkComponent,\n onSelectItem,\n testIdPrefix,\n}: SettingsSectionChipsProps): React.JSX.Element {\n const theme = useTheme();\n const stripRef = useRef<HTMLDivElement | null>(null);\n useCentreActiveChip(stripRef, activeItemId, items.map((item) => item.id).join('\\u0000'));\n\n return (\n <Box\n ref={stripRef}\n component=\"nav\"\n aria-label={ariaLabel}\n data-testid={`${testIdPrefix}-chips`}\n sx={{\n display: 'flex',\n alignItems: 'center',\n gap: 0.75,\n px: 0.25,\n py: 0.25,\n overflowX: 'auto',\n overflowY: 'hidden',\n scrollbarWidth: 'none',\n '&::-webkit-scrollbar': { display: 'none' },\n }}\n >\n {items.map((item) => (\n <SectionChip\n key={item.id}\n item={item}\n active={item.id === activeItemId}\n theme={theme}\n linkComponent={linkComponent}\n onSelectItem={onSelectItem}\n testIdPrefix={testIdPrefix}\n />\n ))}\n </Box>\n );\n}\n\nSettingsSectionChips.displayName = 'SettingsSectionChips';\n"],"mappings":";;;;;;;;;;;;;;;AAEA,OAAO,SAAS;AAChB,SAAS,OAAO,gBAA4B;AAC5C,SAAgB,WAAW,cAA8B;AAmKrD,SA4CI,KA5CJ;AArJJ,IAAM,cAAc,MAAM,EAAE;AAgB5B,SAAS,uBAAgC;AACvC,MAAI,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,WAAY,QAAO;AACrF,SAAO,OAAO,WAAW,kCAAkC,EAAE;AAC/D;AAcA,SAAS,kBAAkB,OAAoB,MAA2B;AACxE,QAAM,UAAU,KAAK,sBAAsB;AAC3C,QAAM,eAAe,MAAM,sBAAsB,EAAE,OAAO,MAAM;AAChE,QAAM,aAAa,QAAQ,OAAO,eAAe,MAAM,aAAa,QAAQ,QAAQ;AACpF,QAAM,SAAS,aAAa,MAAM,cAAc;AAChD,SAAO,KAAK,IAAI,GAAG,KAAK,IAAI,QAAQ,MAAM,cAAc,MAAM,WAAW,CAAC;AAC5E;AAGA,SAAS,SAAS,OAAoB,IAAqC;AAIzE,SAAO,MAAM,KAAK,MAAM,QAAQ,EAAE;AAAA,IAChC,CAAC,SAA8B,gBAAgB,eAAe,KAAK,QAAQ,WAAW;AAAA,EACxF;AACF;AAoCA,SAAS,oBACP,UACA,cACA,UACM;AACN,YAAU,MAAM;AACd,UAAM,QAAQ,SAAS;AACvB,QAAI,CAAC,SAAS,CAAC,aAAc,QAAO;AAOpC,QAAI,OAAO,MAAM,aAAa,WAAY,QAAO;AACjD,UAAM,UAAU,mBAAmB,KAAK;AACxC,UAAM,SAAS,MAAY;AACzB,YAAM,OAAO,SAAS,OAAO,YAAY;AAGzC,UAAI,CAAC,QAAQ,QAAQ,YAAY,KAAK,QAAQ,YAAY,EAAG;AAC7D,YAAM,OAAO,kBAAkB,OAAO,IAAI;AAI1C,UAAI,KAAK,IAAI,OAAO,QAAQ,QAAQ,CAAC,IAAI,IAAK;AAC9C,cAAQ,gBAAgB,IAAI;AAC5B,YAAM,SAAS,EAAE,MAAM,UAAU,qBAAqB,IAAI,SAAS,SAAS,CAAC;AAAA,IAC/E;AACA,YAAQ,UAAU,MAAM;AACxB,WAAO;AAEP,UAAM,UAAU,OAAO,mBAAmB,aAAa,IAAI,eAAe,MAAM,IAAI;AACpF,aAAS,QAAQ,KAAK;AACtB,eAAW,SAAS,MAAM,KAAK,MAAM,QAAQ,EAAG,UAAS,QAAQ,KAAK;AACtE,WAAO,MAAM;AACX,eAAS,WAAW;AACpB,cAAQ,OAAO;AAAA,IACjB;AAAA,EACF,GAAG,CAAC,UAAU,cAAc,QAAQ,CAAC;AACvC;AAGA,SAAS,YAAY;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAOsB;AACpB,QAAM,SAAS,KAAK,SAAS,UAAa,kBAAkB;AAC5D,QAAM,YAAY,SACd,EAAE,WAAW,eAAe,MAAM,KAAK,KAAK,IAC5C,EAAE,WAAW,UAAmB,MAAM,UAAmB,SAAS,MAAM,eAAe,KAAK,EAAE,EAAE;AAEpG,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,gBAAc,KAAK;AAAA,MACnB,eAAa,GAAG,YAAY,SAAS,KAAK,EAAE;AAAA,MAC5C,gBAAc,SAAS,SAAS;AAAA,MAChC,IAAI;AAAA,QACF,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,KAAK;AAAA,QACL,MAAM;AAAA,QACN,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAQV,WAAW;AAAA,QACX,IAAI;AAAA,QACJ,cAAc;AAAA,QACd,QAAQ;AAAA,QACR,MAAM;AAAA,QACN,UAAU,IAAI,OAAO,EAAE;AAAA,QACvB,YAAY,SAAS,MAAM;AAAA,QAC3B,gBAAgB;AAAA,QAChB,YAAY;AAAA,QACZ,OAAO,SAAS,iBAAiB;AAAA,QACjC,SAAS,SAAS,MAAM,MAAM,QAAQ,QAAQ,MAAM,IAAI,IAAI;AAAA,QAC5D,QAAQ,aAAa,SAAS,MAAM,MAAM,QAAQ,QAAQ,MAAM,GAAG,IAAI,MAAM,QAAQ,OAAO;AAAA;AAAA;AAAA,QAG5F,YAAY;AAAA,UACV,SAAS;AAAA,UACT,UAAU;AAAA,UACV,kBAAkB;AAAA,UAClB,gBAAgB;AAAA,UAChB,KAAK;AAAA,UACL,WAAW;AAAA,UACX,QAAQ,IAAI,OAAO,YAAY;AAAA,QACjC;AAAA,MACF;AAAA,MAEC;AAAA,aAAK,SACJ;AAAA,UAAC;AAAA;AAAA,YACC,QAAQ,KAAK;AAAA,YACb,OAAO,KAAK;AAAA,YACZ,QAAQ,GAAG,YAAY,gBAAgB,KAAK,EAAE;AAAA;AAAA,QAChD,IACE;AAAA,QACH,KAAK;AAAA;AAAA;AAAA,EACR;AAEJ;AAUO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAiD;AAC/C,QAAM,QAAQ,SAAS;AACvB,QAAM,WAAW,OAA8B,IAAI;AACnD,sBAAoB,UAAU,cAAc,MAAM,IAAI,CAAC,SAAS,KAAK,EAAE,EAAE,KAAK,IAAQ,CAAC;AAEvF,SACE;AAAA,IAAC;AAAA;AAAA,MACC,KAAK;AAAA,MACL,WAAU;AAAA,MACV,cAAY;AAAA,MACZ,eAAa,GAAG,YAAY;AAAA,MAC5B,IAAI;AAAA,QACF,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,KAAK;AAAA,QACL,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,WAAW;AAAA,QACX,WAAW;AAAA,QACX,gBAAgB;AAAA,QAChB,wBAAwB,EAAE,SAAS,OAAO;AAAA,MAC5C;AAAA,MAEC,gBAAM,IAAI,CAAC,SACV;AAAA,QAAC;AAAA;AAAA,UAEC;AAAA,UACA,QAAQ,KAAK,OAAO;AAAA,UACpB;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA;AAAA,QANK,KAAK;AAAA,MAOZ,CACD;AAAA;AAAA,EACH;AAEJ;AAEA,qBAAqB,cAAc;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/form/Autocomplete/useAutocomplete.ts"],"sourcesContent":["import { useEffect, useId, useRef, useState } from 'react';\n\nimport {\n defaultGetKey,\n defaultGetLabel,\n defaultGetSuggestionType,\n defaultGetUrl,\n defaultOpenLink,\n filterSuggestions,\n firstGhostSuggestion,\n type MatchMode,\n} from './Autocomplete.helpers';\nimport { handleAutocompleteKey, type KeyContext } from './Autocomplete.keys';\nimport type { AutocompleteProps } from './Autocomplete.types';\n\ntype Setter<V> = React.Dispatch<React.SetStateAction<V>>;\n\n/** All mutable state + refs for one Autocomplete instance. */\ninterface AutocompleteState<T> {\n open: boolean;\n setOpen: Setter<boolean>;\n activeIndex: number;\n setActiveIndex: Setter<number>;\n ghost: string;\n setGhost: Setter<string>;\n inputValue: string;\n setInputValue: Setter<string>;\n composition: boolean;\n setComposition: Setter<boolean>;\n filteredSuggestions: T[];\n setFilteredSuggestions: Setter<T[]>;\n isInputFocused: boolean;\n setIsInputFocused: Setter<boolean>;\n justCompletedGhost: boolean;\n setJustCompletedGhost: Setter<boolean>;\n userClosedDropdown: boolean;\n setUserClosedDropdown: Setter<boolean>;\n inputRef: React.RefObject<HTMLInputElement | null>;\n listRef: React.RefObject<HTMLUListElement | null>;\n debounceRef: React.RefObject<number | undefined>;\n}\n\nfunction useAutocompleteState<T>(value: string, suggestions: T[]): AutocompleteState<T> {\n const [open, setOpen] = useState(false);\n const [activeIndex, setActiveIndex] = useState(-1);\n const [ghost, setGhost] = useState('');\n const [inputValue, setInputValue] = useState(value);\n const [composition, setComposition] = useState(false);\n const [filteredSuggestions, setFilteredSuggestions] = useState<T[]>(suggestions);\n const [isInputFocused, setIsInputFocused] = useState(false);\n const [justCompletedGhost, setJustCompletedGhost] = useState(false);\n const [userClosedDropdown, setUserClosedDropdown] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n const listRef = useRef<HTMLUListElement | null>(null);\n const debounceRef = useRef<number | undefined>(undefined);\n return {\n open, setOpen, activeIndex, setActiveIndex, ghost, setGhost, inputValue, setInputValue,\n composition, setComposition, filteredSuggestions, setFilteredSuggestions, isInputFocused,\n setIsInputFocused, justCompletedGhost, setJustCompletedGhost, userClosedDropdown,\n setUserClosedDropdown, inputRef, listRef, debounceRef,\n };\n}\n\n// ---- effects (one tiny hook each; explicit params keep deps complete) --------\n\nfunction useValueSync(value: string, setInputValue: Setter<string>): void {\n useEffect(() => setInputValue(value), [value, setInputValue]);\n}\n\nfunction useDebounceCleanup(debounceRef: React.RefObject<number | undefined>): void {\n useEffect(\n () => () => {\n if (debounceRef.current) window.clearTimeout(debounceRef.current);\n },\n [debounceRef],\n );\n}\n\nfunction useScrollActiveIntoView(\n activeIndex: number,\n optionIdPrefix: string,\n listRef: React.RefObject<HTMLUListElement | null>,\n): void {\n useEffect(() => {\n if (activeIndex < 0 || !listRef.current) return;\n document\n .getElementById(`${optionIdPrefix}-${activeIndex}`)\n ?.scrollIntoView({ block: 'nearest' });\n }, [activeIndex, optionIdPrefix, listRef]);\n}\n\nfunction useGhostFlagReset(justCompletedGhost: boolean, setJustCompletedGhost: Setter<boolean>): void {\n useEffect(() => {\n if (!justCompletedGhost) return;\n const timer = setTimeout(() => setJustCompletedGhost(false), 50);\n return () => clearTimeout(timer);\n }, [justCompletedGhost, setJustCompletedGhost]);\n}\n\nfunction useLoadingOpen(\n isLoading: boolean,\n inputValue: string,\n disabled: boolean,\n userClosedDropdown: boolean,\n setOpen: Setter<boolean>,\n setActiveIndex: Setter<number>,\n): void {\n useEffect(() => {\n if (isLoading && inputValue.trim().length > 0 && !disabled && !userClosedDropdown) setOpen(true);\n if (disabled) {\n setOpen(false);\n setActiveIndex(-1);\n }\n }, [isLoading, inputValue, disabled, userClosedDropdown, setOpen, setActiveIndex]);\n}\n\n/** Inputs to the auto-open check, all primitive/stable so effect deps stay complete. */\ninterface AutoOpenArgs {\n inputValue: string;\n isInputFocused: boolean;\n composition: boolean;\n justCompletedGhost: boolean;\n userClosedDropdown: boolean;\n activeIndex: number;\n setOpen: Setter<boolean>;\n setActiveIndex: Setter<number>;\n}\n\n/** Auto-open the dropdown once local filtering has a fresh result set. */\nfunction maybeAutoOpen(a: AutoOpenArgs, hasResults: boolean): void {\n const canAutoOpen =\n a.inputValue.trim() &&\n a.isInputFocused &&\n !a.composition &&\n !a.justCompletedGhost &&\n !a.userClosedDropdown;\n if (!canAutoOpen) return;\n a.setOpen(true);\n if (hasResults && a.activeIndex === -1) a.setActiveIndex(0);\n}\n\nfunction useLocalFilter<T>(p: AutocompleteProps<T>, s: AutocompleteState<T>): void {\n const propSuggestions = p.suggestions;\n const getLabel = p.getLabel ?? defaultGetLabel;\n const matchMode: MatchMode = p.matchMode ?? 'contains';\n const async = Boolean(p.async);\n const {\n inputValue, isInputFocused, composition, justCompletedGhost, userClosedDropdown, activeIndex,\n setFilteredSuggestions, setOpen, setActiveIndex,\n } = s;\n useEffect(() => {\n const suggestions = propSuggestions ?? [];\n if (async || !inputValue) {\n setFilteredSuggestions(suggestions);\n return;\n }\n const filtered = filterSuggestions(suggestions, inputValue, matchMode, getLabel);\n setFilteredSuggestions(filtered);\n maybeAutoOpen(\n { inputValue, isInputFocused, composition, justCompletedGhost, userClosedDropdown, activeIndex, setOpen, setActiveIndex },\n filtered.length > 0,\n );\n }, [\n propSuggestions, inputValue, async, getLabel, matchMode, isInputFocused, composition,\n justCompletedGhost, userClosedDropdown, activeIndex, setFilteredSuggestions, setOpen, setActiveIndex,\n ]);\n}\n\nfunction useGhostText<T>(p: AutocompleteProps<T>, s: AutocompleteState<T>): void {\n const getLabel = p.getLabel ?? defaultGetLabel;\n const getType = p.getSuggestionType ?? defaultGetSuggestionType;\n const showGhost = p.showGhostText ?? true;\n const { inputValue, filteredSuggestions, isInputFocused, composition, setGhost } = s;\n useEffect(() => {\n if (!inputValue || !isInputFocused || composition || !showGhost) {\n setGhost('');\n return;\n }\n const first = firstGhostSuggestion(filteredSuggestions, inputValue, getLabel, getType);\n setGhost(first ? getLabel(first).slice(inputValue.length) : '');\n }, [inputValue, filteredSuggestions, isInputFocused, composition, showGhost, getLabel, getType, setGhost]);\n}\n\n// ---- selection (split by mode to keep each function under the complexity bar) -\n\nfunction commitMultiSelect<T>(item: T, p: AutocompleteProps<T>, s: AutocompleteState<T>, onChange: (v: string) => void): void {\n const getKey = p.getKey ?? defaultGetKey;\n const current = p.selectedItems ?? [];\n if (!current.some((sel) => getKey(sel) === getKey(item))) p.onSelectedItemsChange?.([...current, item]);\n s.setInputValue('');\n onChange('');\n s.setUserClosedDropdown(false);\n}\n\nfunction commitSingleSelect<T>(item: T, p: AutocompleteProps<T>, s: AutocompleteState<T>, onChange: (v: string) => void): void {\n const label = (p.getLabel ?? defaultGetLabel)(item);\n s.setInputValue(label);\n onChange(label);\n}\n\n/** Apply a pick: open a link suggestion, or commit a search one to the input. */\nfunction commitPick<T>(\n item: T,\n linkUrl: string | null,\n p: AutocompleteProps<T>,\n s: AutocompleteState<T>,\n onChange: (v: string) => void,\n): void {\n if (linkUrl !== null) (p.openLink ?? defaultOpenLink)(linkUrl, item);\n else if (p.multiple) commitMultiSelect(item, p, s, onChange);\n else commitSingleSelect(item, p, s, onChange);\n}\n\nfunction runSelectItem<T>(item: T, p: AutocompleteProps<T>, s: AutocompleteState<T>, onChange: (v: string) => void): void {\n const url = (p.getUrl ?? defaultGetUrl)(item);\n const isLink = (p.getSuggestionType ?? defaultGetSuggestionType)(item) === 'link' && Boolean(url);\n // A single-select pick (search or link) ends the current search: the list\n // stays closed until the user types again. `multiple` mode is unchanged —\n // commitMultiSelect already clears inputValue and resets the flag itself,\n // so the empty-input branch of the filter effect keeps the list closed.\n const isSingleSelectPick = isLink || !p.multiple;\n commitPick(item, isLink ? (url as string) : null, p, s, onChange);\n s.setOpen(false);\n s.setActiveIndex(-1);\n p.onSelect?.(item);\n s.inputRef.current?.focus();\n // Set LAST so a re-fired `onFocus` cannot clear the flag. The close itself\n // relies on the input never blurring during a pick (the option's\n // `onMouseDown` preventDefault, and Enter keeps focus): `onFocus` reopens a\n // non-empty input directly, without reading this flag.\n if (isSingleSelectPick) s.setUserClosedDropdown(true);\n}\n\nfunction buildKeyContext<T>(\n p: AutocompleteProps<T>,\n s: AutocompleteState<T>,\n selectItem: (item: T) => void,\n onChange: (v: string) => void,\n): KeyContext<T> {\n return {\n open: s.open, activeIndex: s.activeIndex, filteredSuggestions: s.filteredSuggestions,\n maxVisibleItems: p.maxVisibleItems ?? 10, allowFreeText: p.allowFreeText ?? true,\n inputValue: s.inputValue, ghost: s.ghost, isInputFocused: s.isInputFocused,\n multiple: Boolean(p.multiple), selectedItems: p.selectedItems ?? [],\n getLabel: p.getLabel ?? defaultGetLabel,\n getSuggestionType: p.getSuggestionType ?? defaultGetSuggestionType,\n setOpen: s.setOpen, setActiveIndex: s.setActiveIndex, setUserClosedDropdown: s.setUserClosedDropdown,\n setInputValue: s.setInputValue, setGhost: s.setGhost, setJustCompletedGhost: s.setJustCompletedGhost,\n debouncedOnChange: onChange, selectItem, onSelectedItemsChange: p.onSelectedItemsChange,\n };\n}\n\n/** The full view-model the presentational component renders. */\nexport interface AutocompleteView<T> {\n id: string;\n listId: string;\n optionIdPrefix: string;\n state: AutocompleteState<T>;\n visibleSuggestions: T[];\n activeDescendant: string | undefined;\n selectItem: (item: T) => void;\n removeSelectedItem: (item: T) => void;\n onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n onFocus: () => void;\n onBlur: () => void;\n onClickAway: () => void;\n}\n\n/** Wire state, effects, and (plain, per-render) handlers for the Autocomplete component. */\nexport function useAutocomplete<T>(p: AutocompleteProps<T>): AutocompleteView<T> {\n const componentId = useId();\n const id = p.id || componentId;\n const optionIdPrefix = `${id}-option`;\n const s = useAutocompleteState<T>(p.value, p.suggestions ?? []);\n\n const debouncedOnChange = (val: string): void => {\n if (s.debounceRef.current) window.clearTimeout(s.debounceRef.current);\n s.debounceRef.current = window.setTimeout(() => p.onChange(val), p.debounceMs ?? 150);\n };\n\n useValueSync(p.value, s.setInputValue);\n useDebounceCleanup(s.debounceRef);\n useScrollActiveIntoView(s.activeIndex, optionIdPrefix, s.listRef);\n useGhostFlagReset(s.justCompletedGhost, s.setJustCompletedGhost);\n useLoadingOpen(\n Boolean(p.isLoading),\n s.inputValue,\n Boolean(p.disabled),\n s.userClosedDropdown,\n s.setOpen,\n s.setActiveIndex,\n );\n useLocalFilter(p, s);\n useGhostText(p, s);\n\n const selectItem = (item: T): void => runSelectItem(item, p, s, debouncedOnChange);\n\n return {\n id,\n listId: `${id}-listbox`,\n optionIdPrefix,\n state: s,\n visibleSuggestions: s.filteredSuggestions.slice(0, p.maxVisibleItems ?? 10),\n activeDescendant: s.activeIndex >= 0 ? `${optionIdPrefix}-${s.activeIndex}` : undefined,\n selectItem,\n removeSelectedItem: (item: T): void => {\n const getKey = p.getKey ?? defaultGetKey;\n p.onSelectedItemsChange?.((p.selectedItems ?? []).filter((sel) => getKey(sel) !== getKey(item)));\n s.inputRef.current?.focus();\n },\n onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): void =>\n handleAutocompleteKey(e, s.composition, buildKeyContext(p, s, selectItem, debouncedOnChange)),\n onInputChange: (e: React.ChangeEvent<HTMLInputElement>): void => {\n s.setInputValue(e.target.value);\n debouncedOnChange(e.target.value);\n if (!s.open || s.filteredSuggestions.length === 0) s.setActiveIndex(-1);\n s.setUserClosedDropdown(false);\n },\n onFocus: (): void => {\n s.setIsInputFocused(true);\n s.setUserClosedDropdown(false);\n if (!s.inputValue.trim()) return;\n s.setOpen(true);\n if (s.filteredSuggestions.length > 0 && s.activeIndex === -1) s.setActiveIndex(0);\n },\n onBlur: (): void => {\n window.setTimeout(() => s.setIsInputFocused(false), 150);\n },\n onClickAway: (): void => {\n s.setOpen(false);\n s.setActiveIndex(-1);\n s.setUserClosedDropdown(true);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,82 +0,0 @@
1
- // src/components/layout/SettingsLayout/SettingsSectionChips.scroll.ts
2
- var LANDED_SLACK = 1;
3
- var QUIET_BEFORE_SETTLED_MS = 250;
4
- function horizontalDelta(event) {
5
- if (!(event instanceof WheelEvent)) return 0;
6
- return event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0;
7
- }
8
- function restsOnAim(strip, aimed) {
9
- const reachable = Math.min(aimed, strip.scrollWidth - strip.clientWidth);
10
- return Math.abs(strip.scrollLeft - reachable) <= LANDED_SLACK;
11
- }
12
- function watchVisitorScroll(strip) {
13
- const view = strip.ownerDocument.defaultView;
14
- const hasScrollEnd = "onscrollend" in strip;
15
- const state = {
16
- pointer: false,
17
- touch: false,
18
- nudged: false,
19
- undecided: false,
20
- scrolled: false,
21
- aimed: Number.NaN,
22
- quiet: void 0,
23
- settled: () => void 0
24
- };
25
- const set = (key, value) => () => {
26
- state[key] = value;
27
- };
28
- const settle = () => {
29
- if (!state.undecided) return;
30
- state.undecided = false;
31
- state.nudged = false;
32
- if (!restsOnAim(strip, state.aimed)) state.scrolled = true;
33
- state.settled();
34
- };
35
- const onWheel = (event) => {
36
- if (horizontalDelta(event) !== 0) state.nudged = true;
37
- };
38
- const onScroll = () => {
39
- if (state.pointer || state.touch || state.nudged) state.undecided = true;
40
- if (!state.undecided || hasScrollEnd) return;
41
- clearTimeout(state.quiet);
42
- state.quiet = setTimeout(settle, QUIET_BEFORE_SETTLED_MS);
43
- };
44
- const onStrip = [
45
- ["pointerdown", set("pointer", true)],
46
- ["touchstart", set("touch", true)],
47
- ["pointercancel", set("nudged", true)],
48
- ["keydown", set("nudged", true)],
49
- ["wheel", onWheel],
50
- ["scroll", onScroll],
51
- ["scrollend", settle]
52
- ];
53
- const onView = [
54
- ["pointerup", set("pointer", false)],
55
- ["pointercancel", set("pointer", false)],
56
- ["touchend", set("touch", false)],
57
- ["touchcancel", set("touch", false)]
58
- ];
59
- for (const [type, listener] of onStrip) strip.addEventListener(type, listener, { passive: true });
60
- for (const [type, listener] of onView) view?.addEventListener(type, listener, { passive: true });
61
- return {
62
- hasScrolled: () => state.scrolled,
63
- isUndecided: () => state.undecided,
64
- aimedAt: () => state.aimed,
65
- beforeOwnScroll: (left) => {
66
- state.aimed = left;
67
- },
68
- onSettled: (listener) => {
69
- state.settled = listener;
70
- },
71
- detach: () => {
72
- clearTimeout(state.quiet);
73
- for (const [type, listener] of onStrip) strip.removeEventListener(type, listener);
74
- for (const [type, listener] of onView) view?.removeEventListener(type, listener);
75
- }
76
- };
77
- }
78
-
79
- export {
80
- watchVisitorScroll
81
- };
82
- //# sourceMappingURL=chunk-SLJAQA2X.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/layout/SettingsLayout/SettingsSectionChips.scroll.ts"],"sourcesContent":["/**\n * Telling the strip's own scrolls from the visitor's (FUT-2606).\n *\n * Split from `SettingsSectionChips.tsx` so the chips stay a component file and\n * the rule for \"who moved the strip\" can be read in one place.\n */\n\n/**\n * How near to where the strip aimed itself it has to come to rest for the scroll\n * to count as the strip's own. A smooth scroll lands on a device pixel, and the\n * target it was given is fractional.\n */\nconst LANDED_SLACK = 1;\n\n/**\n * Where `scrollend` is missing: how long the strip has to go without a `scroll`\n * before it counts as settled. Generous on purpose — a busy main thread can go\n * several frames between ticks of a smooth scroll, and judging one mid-flight\n * would read the strip's own animation as the visitor's. Too long only delays a\n * re-centre; too short can latch the strip for good.\n */\nconst QUIET_BEFORE_SETTLED_MS = 250;\n\ninterface VisitorScrollWatch {\n /** True once the visitor has moved the strip themselves. */\n hasScrolled: () => boolean;\n /**\n * True while a scroll the visitor MAY be making has not come to rest yet. The\n * strip must not scroll itself under it: that would yank a drag in progress.\n */\n isUndecided: () => boolean;\n /** Where the strip last aimed itself, in its own pixels; `NaN` before it has. */\n aimedAt: () => number;\n /**\n * The strip is about to scroll itself to `left`. Records the aim and NOTHING\n * else: it used to disarm too, and a re-centre landing between a visitor's key\n * press (or wheel, or pan) and the first tick of the scroll it causes wiped\n * the arming — their scroll went unclaimed, read as the strip's own. Left\n * armed, the worst a stale arming does is put the strip's OWN next scroll\n * under judgement, which rests on its aim, is not latched, and disarms.\n */\n beforeOwnScroll: (left: number) => void;\n /** Called once an undecided scroll has come to rest and been judged. */\n onSettled: (listener: () => void) => void;\n detach: () => void;\n}\n\ntype Listeners = Array<[string, (event: Event) => void]>;\n\n/** A wheel's sideways travel: `deltaX`, or `deltaY` with Shift held, as browsers read it. */\nfunction horizontalDelta(event: Event): number {\n if (!(event instanceof WheelEvent)) return 0;\n return event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0;\n}\n\ninterface WatchState {\n pointer: boolean;\n touch: boolean;\n nudged: boolean;\n undecided: boolean;\n scrolled: boolean;\n aimed: number;\n quiet: ReturnType<typeof setTimeout> | undefined;\n settled: () => void;\n}\n\n/** Whether the strip came to rest where it last aimed itself — clamped as the browser would. */\nfunction restsOnAim(strip: HTMLElement, aimed: number): boolean {\n const reachable = Math.min(aimed, strip.scrollWidth - strip.clientWidth);\n // Written so that `NaN` — the strip never aimed — reads as \"not on aim\".\n return Math.abs(strip.scrollLeft - reachable) <= LANDED_SLACK;\n}\n\n/**\n * Tell a scroll the VISITOR made from every other one.\n *\n * The `scroll` event cannot say: the strip's own `scrollTo` fires it, and so does\n * the browser clamping a strip whose content shrank. So a scroll is only a\n * CANDIDATE when it arrives during something only a visitor does:\n *\n * - a finger held on the strip (`touchstart` until `touchend`) — a drag;\n * - a pointer held on it (`pointerdown` until `pointerup`);\n * - a key press, a SIDEWAYS wheel, or the browser taking a pointer over to pan\n * it (`pointercancel`), each armed until the next scroll under judgement has\n * been judged — whoever's it turns out to be.\n *\n * and the verdict waits until the strip comes to REST (`scrollend`, or a quiet\n * spell where the browser has no `scrollend`): it is the visitor's only if the\n * strip rests somewhere other than where it last aimed itself.\n *\n * Judged at rest rather than per `scroll` because the strip's own smooth scroll\n * fires `scroll` for a third of a second, and a visitor who presses Tab or puts\n * a finger on a chip to scroll the PAGE in that window is not scrolling the\n * strip. Every tick of that animation used to count as theirs, the strip stopped\n * re-centring for good, and the open chip could be left clipped (FUT-2606\n * again, by another door). A visitor who really drags or wheels the strip\n * mid-animation cancels it, so the strip rests where THEY left it — and that\n * still counts. The one loss: a visitor who happens to leave it within a pixel\n * of the strip's own aim is not told apart from it, and the strip there is\n * already where it would have put itself.\n *\n * A tap is a pointer held with no scroll under it, so it never counts. A\n * vertical wheel scrolls the PAGE — the strip is `overflowY: hidden` — and is\n * not armed at all.\n */\nexport function watchVisitorScroll(strip: HTMLElement): VisitorScrollWatch {\n const view = strip.ownerDocument.defaultView;\n const hasScrollEnd = 'onscrollend' in strip;\n const state: WatchState = {\n pointer: false,\n touch: false,\n nudged: false,\n undecided: false,\n scrolled: false,\n aimed: Number.NaN,\n quiet: undefined,\n settled: () => undefined,\n };\n const set =\n (key: 'pointer' | 'touch' | 'nudged', value: boolean) =>\n (): void => {\n state[key] = value;\n };\n const settle = (): void => {\n // Only a scroll that was UNDER JUDGEMENT disarms. Chromium can end the\n // strip's own smooth scroll with a `scrollend` when another scroll cuts it\n // off, fired BEFORE that scroll's first `scroll` (seen with an instant one);\n // disarming there would hand the visitor's scroll to nobody.\n if (!state.undecided) return;\n state.undecided = false;\n state.nudged = false;\n if (!restsOnAim(strip, state.aimed)) state.scrolled = true;\n state.settled();\n };\n const onWheel = (event: Event): void => {\n if (horizontalDelta(event) !== 0) state.nudged = true;\n };\n const onScroll = (): void => {\n if (state.pointer || state.touch || state.nudged) state.undecided = true;\n if (!state.undecided || hasScrollEnd) return;\n // Every tick while undecided, armed or not: a quiet spell that began while\n // the strip was still moving would judge it mid-flight, away from its aim.\n clearTimeout(state.quiet);\n state.quiet = setTimeout(settle, QUIET_BEFORE_SETTLED_MS);\n };\n const onStrip: Listeners = [\n ['pointerdown', set('pointer', true)],\n ['touchstart', set('touch', true)],\n ['pointercancel', set('nudged', true)],\n ['keydown', set('nudged', true)],\n ['wheel', onWheel],\n ['scroll', onScroll],\n ['scrollend', settle],\n ];\n // A gesture can END off the strip, so its end is heard on the window.\n const onView: Listeners = [\n ['pointerup', set('pointer', false)],\n ['pointercancel', set('pointer', false)],\n ['touchend', set('touch', false)],\n ['touchcancel', set('touch', false)],\n ];\n for (const [type, listener] of onStrip) strip.addEventListener(type, listener, { passive: true });\n for (const [type, listener] of onView) view?.addEventListener(type, listener, { passive: true });\n return {\n hasScrolled: () => state.scrolled,\n isUndecided: () => state.undecided,\n aimedAt: () => state.aimed,\n beforeOwnScroll: (left) => {\n state.aimed = left;\n },\n onSettled: (listener) => {\n state.settled = listener;\n },\n detach: () => {\n clearTimeout(state.quiet);\n for (const [type, listener] of onStrip) strip.removeEventListener(type, listener);\n for (const [type, listener] of onView) view?.removeEventListener(type, listener);\n },\n };\n}\n"],"mappings":";AAYA,IAAM,eAAe;AASrB,IAAM,0BAA0B;AA6BhC,SAAS,gBAAgB,OAAsB;AAC7C,MAAI,EAAE,iBAAiB,YAAa,QAAO;AAC3C,SAAO,MAAM,WAAW,IAAI,MAAM,SAAS,MAAM,WAAW,MAAM,SAAS;AAC7E;AAcA,SAAS,WAAW,OAAoB,OAAwB;AAC9D,QAAM,YAAY,KAAK,IAAI,OAAO,MAAM,cAAc,MAAM,WAAW;AAEvE,SAAO,KAAK,IAAI,MAAM,aAAa,SAAS,KAAK;AACnD;AAkCO,SAAS,mBAAmB,OAAwC;AACzE,QAAM,OAAO,MAAM,cAAc;AACjC,QAAM,eAAe,iBAAiB;AACtC,QAAM,QAAoB;AAAA,IACxB,SAAS;AAAA,IACT,OAAO;AAAA,IACP,QAAQ;AAAA,IACR,WAAW;AAAA,IACX,UAAU;AAAA,IACV,OAAO,OAAO;AAAA,IACd,OAAO;AAAA,IACP,SAAS,MAAM;AAAA,EACjB;AACA,QAAM,MACJ,CAAC,KAAqC,UACtC,MAAY;AACV,UAAM,GAAG,IAAI;AAAA,EACf;AACF,QAAM,SAAS,MAAY;AAKzB,QAAI,CAAC,MAAM,UAAW;AACtB,UAAM,YAAY;AAClB,UAAM,SAAS;AACf,QAAI,CAAC,WAAW,OAAO,MAAM,KAAK,EAAG,OAAM,WAAW;AACtD,UAAM,QAAQ;AAAA,EAChB;AACA,QAAM,UAAU,CAAC,UAAuB;AACtC,QAAI,gBAAgB,KAAK,MAAM,EAAG,OAAM,SAAS;AAAA,EACnD;AACA,QAAM,WAAW,MAAY;AAC3B,QAAI,MAAM,WAAW,MAAM,SAAS,MAAM,OAAQ,OAAM,YAAY;AACpE,QAAI,CAAC,MAAM,aAAa,aAAc;AAGtC,iBAAa,MAAM,KAAK;AACxB,UAAM,QAAQ,WAAW,QAAQ,uBAAuB;AAAA,EAC1D;AACA,QAAM,UAAqB;AAAA,IACzB,CAAC,eAAe,IAAI,WAAW,IAAI,CAAC;AAAA,IACpC,CAAC,cAAc,IAAI,SAAS,IAAI,CAAC;AAAA,IACjC,CAAC,iBAAiB,IAAI,UAAU,IAAI,CAAC;AAAA,IACrC,CAAC,WAAW,IAAI,UAAU,IAAI,CAAC;AAAA,IAC/B,CAAC,SAAS,OAAO;AAAA,IACjB,CAAC,UAAU,QAAQ;AAAA,IACnB,CAAC,aAAa,MAAM;AAAA,EACtB;AAEA,QAAM,SAAoB;AAAA,IACxB,CAAC,aAAa,IAAI,WAAW,KAAK,CAAC;AAAA,IACnC,CAAC,iBAAiB,IAAI,WAAW,KAAK,CAAC;AAAA,IACvC,CAAC,YAAY,IAAI,SAAS,KAAK,CAAC;AAAA,IAChC,CAAC,eAAe,IAAI,SAAS,KAAK,CAAC;AAAA,EACrC;AACA,aAAW,CAAC,MAAM,QAAQ,KAAK,QAAS,OAAM,iBAAiB,MAAM,UAAU,EAAE,SAAS,KAAK,CAAC;AAChG,aAAW,CAAC,MAAM,QAAQ,KAAK,OAAQ,OAAM,iBAAiB,MAAM,UAAU,EAAE,SAAS,KAAK,CAAC;AAC/F,SAAO;AAAA,IACL,aAAa,MAAM,MAAM;AAAA,IACzB,aAAa,MAAM,MAAM;AAAA,IACzB,SAAS,MAAM,MAAM;AAAA,IACrB,iBAAiB,CAAC,SAAS;AACzB,YAAM,QAAQ;AAAA,IAChB;AAAA,IACA,WAAW,CAAC,aAAa;AACvB,YAAM,UAAU;AAAA,IAClB;AAAA,IACA,QAAQ,MAAM;AACZ,mBAAa,MAAM,KAAK;AACxB,iBAAW,CAAC,MAAM,QAAQ,KAAK,QAAS,OAAM,oBAAoB,MAAM,QAAQ;AAChF,iBAAW,CAAC,MAAM,QAAQ,KAAK,OAAQ,OAAM,oBAAoB,MAAM,QAAQ;AAAA,IACjF;AAAA,EACF;AACF;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/data-display/DataViews/data-views-category-pill.tsx"],"sourcesContent":["\"use client\";\n\nimport { useDataViewsCopy } from \"./data-views-copy-context\";\nimport { CategorySelect } from \"../../form/CategorySelect\";\nimport { MultiSelectDropdown } from \"../../layout/ContentToolbar\";\n\nimport type { FilterFieldConfig, FilterOption } from \"./data-views-types\";\n\n/**\n * Diff a whole-array selection back into per-value toggles.\n *\n * The filter state's only channel is `onTogglePill(field, value, checked)`, but\n * a tree hands back the entire selection at once. Diffing here keeps that one\n * channel the single way a pill ever changes, rather than giving the\n * hierarchical control a private path into the state.\n */\nexport function emitPillDiff(\n fieldId: string,\n before: readonly string[],\n after: readonly string[],\n onTogglePill: (fieldId: string, value: string, checked: boolean) => void,\n): void {\n const had = new Set(before);\n const wants = new Set(after);\n after.forEach((value) => {\n if (!had.has(value)) onTogglePill(fieldId, value, true);\n });\n before.forEach((value) => {\n if (!wants.has(value)) onTogglePill(fieldId, value, false);\n });\n}\n\n/** A filter option as the category tree wants it. */\nconst toCategoryOption = (option: FilterOption) => ({\n id: option.value,\n name: option.label,\n parentId: option.parentId ?? null,\n});\n\ninterface PillControlProps<T extends Record<string, unknown>> {\n fieldId: string;\n pill: FilterFieldConfig<T>;\n selected: string[];\n onTogglePill: (fieldId: string, value: string, checked: boolean) => void;\n onClearField: (fieldId: string) => void;\n onOpenChange?: (open: boolean) => void;\n testIdPrefix: string;\n /** `stacked` in the filter panel, `pill` on the inline bar. */\n layout?: \"stacked\" | \"pill\";\n}\n\n/**\n * One filter pill: the hierarchical category tree, or the flat multi-select.\n *\n * A category set nests, and flattening it into one list is what made \"Massas\"\n * (mercearia) indistinguishable from \"Massas\" (pratos principais). `control:\n * \"category\"` opts a field into the tree, which shows each hit under its parent.\n *\n * {@link FilterFieldConfig.allowParentSelection} decides whether a parent row is\n * itself tickable. It is forwarded rather than fixed here because it is a\n * per-SCREEN question: a filter over a catalogue where products sit on leaves\n * wants \"the whole category\" as one click, while one whose parents are pure\n * grouping would only offer a tick that means nothing.\n */\nexport function PillControl<T extends Record<string, unknown>>({\n fieldId,\n pill,\n selected,\n onTogglePill,\n onClearField,\n onOpenChange,\n testIdPrefix,\n layout = \"pill\",\n}: PillControlProps<T>): React.JSX.Element {\n const copy = useDataViewsCopy();\n const testId = `${testIdPrefix}-filter-${fieldId}`;\n if (pill.control === \"category\") {\n return (\n <CategorySelect\n copy={copy.categorySelect}\n placeholder={pill.label}\n label={layout === \"stacked\" ? pill.label : undefined}\n fullWidth={layout === \"stacked\"}\n options={pill.options.map(toCategoryOption)}\n allowParentSelection={pill.allowParentSelection ?? false}\n value={selected}\n onChange={(next) => emitPillDiff(fieldId, selected, next, onTogglePill)}\n dataTestId={testId}\n />\n );\n }\n return (\n <MultiSelectDropdown\n extraOptionsHeading={copy.filters.optionsHeading}\n clearText={copy.filters.clear}\n clearLabel={copy.filters.clearRange(pill.label)}\n label={pill.label}\n options={pill.options}\n selected={new Set(selected)}\n onToggle={(value, checked) => onTogglePill(fieldId, value, checked)}\n onClear={() => onClearField(fieldId)}\n allLabel={copy.filters.allOption}\n searchable={pill.searchEnabled ? true : undefined}\n searchPlaceholder={copy.filters.optionSearchPlaceholder}\n noResultsLabel={copy.filters.optionsEmpty}\n layout={layout}\n onOpenChange={onOpenChange}\n data-testid={testId}\n />\n );\n}\n"],"mappings":";;;;;;;;;;;AA8EM;AA9DC,SAAS,aACd,SACA,QACA,OACA,cACM;AACN,QAAM,MAAM,IAAI,IAAI,MAAM;AAC1B,QAAM,QAAQ,IAAI,IAAI,KAAK;AAC3B,QAAM,QAAQ,CAAC,UAAU;AACvB,QAAI,CAAC,IAAI,IAAI,KAAK,EAAG,cAAa,SAAS,OAAO,IAAI;AAAA,EACxD,CAAC;AACD,SAAO,QAAQ,CAAC,UAAU;AACxB,QAAI,CAAC,MAAM,IAAI,KAAK,EAAG,cAAa,SAAS,OAAO,KAAK;AAAA,EAC3D,CAAC;AACH;AAGA,IAAM,mBAAmB,CAAC,YAA0B;AAAA,EAClD,IAAI,OAAO;AAAA,EACX,MAAM,OAAO;AAAA,EACb,UAAU,OAAO,YAAY;AAC/B;AA2BO,SAAS,YAA+C;AAAA,EAC7D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS;AACX,GAA2C;AACzC,QAAM,OAAO,iBAAiB;AAC9B,QAAM,SAAS,GAAG,YAAY,WAAW,OAAO;AAChD,MAAI,KAAK,YAAY,YAAY;AAC/B,WACE;AAAA,MAAC;AAAA;AAAA,QACC,MAAM,KAAK;AAAA,QACX,aAAa,KAAK;AAAA,QAClB,OAAO,WAAW,YAAY,KAAK,QAAQ;AAAA,QAC3C,WAAW,WAAW;AAAA,QACtB,SAAS,KAAK,QAAQ,IAAI,gBAAgB;AAAA,QAC1C,sBAAsB,KAAK,wBAAwB;AAAA,QACnD,OAAO;AAAA,QACP,UAAU,CAAC,SAAS,aAAa,SAAS,UAAU,MAAM,YAAY;AAAA,QACtE,YAAY;AAAA;AAAA,IACd;AAAA,EAEJ;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,qBAAqB,KAAK,QAAQ;AAAA,MAClC,WAAW,KAAK,QAAQ;AAAA,MACxB,YAAY,KAAK,QAAQ,WAAW,KAAK,KAAK;AAAA,MAC9C,OAAO,KAAK;AAAA,MACZ,SAAS,KAAK;AAAA,MACd,UAAU,IAAI,IAAI,QAAQ;AAAA,MAC1B,UAAU,CAAC,OAAO,YAAY,aAAa,SAAS,OAAO,OAAO;AAAA,MAClE,SAAS,MAAM,aAAa,OAAO;AAAA,MACnC,UAAU,KAAK,QAAQ;AAAA,MACvB,YAAY,KAAK,gBAAgB,OAAO;AAAA,MACxC,mBAAmB,KAAK,QAAQ;AAAA,MAChC,gBAAgB,KAAK,QAAQ;AAAA,MAC7B;AAAA,MACA;AAAA,MACA,eAAa;AAAA;AAAA,EACf;AAEJ;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/data-display/DataViews/data-views-more-fields.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * ONE FIELD, RENDERED INSIDE THE \"MAIS\" PANEL.\n *\n * Split from `data-views-more-filters` at the file-size gate, along the seam\n * that was already there: that module is the trigger, the panel and its\n * footer — the chrome — and this is what goes in it.\n *\n * A field with one or two options renders FLAT: inside a panel there is no\n * space to win back, and two labelled checkboxes are faster than a dropdown\n * needing a second click. Past that it becomes a {@link MultiSelectDropdown} —\n * see `INLINE_OPTION_LIMIT`.\n */\nimport Checkbox from \"@mui/material/Checkbox/index.js\";\n\nimport { MultiSelectDropdown } from \"../../layout/ContentToolbar\";\nimport { Box } from \"../../../mui/Box\";\nimport { Text } from \"../../typography/Text\";\n\nimport type { OverflowField } from \"./data-views-overflow\";\nimport { RangeBounds } from \"./data-views-range-pill\";\nimport { isRangeSet } from \"./data-views-range-values\";\nimport type { RangeValue } from \"./data-views-types\";\nimport { useDataViewsCopy } from \"./data-views-copy-context\";\nimport { fieldRadiusPx } from \"../../../tokens/field-radius\";\nimport { fieldHeight } from \"../../../tokens/field-height\";\nimport { fieldEdge } from \"../../../tokens/field-edge\";\nimport { sxRem } from \"../../../tokens/relative\";\n\n/** What each field's control needs, minus the panel's own chrome. */\nexport interface MoreFieldProps {\n pills: Record<string, string[]>;\n ranges: Record<string, RangeValue>;\n onTogglePill: (fieldId: string, value: string, checked: boolean) => void;\n onChangeRange: (fieldId: string, range: RangeValue) => void;\n testIdPrefix: string;\n}\n\n/**\n * Above this many options a field is a DROPDOWN rather than a flat row of\n * checkboxes. Two options cost two lines and read faster laid out; a \"Cliente\"\n * with a dozen turns the panel into a scrolling wall and buries every field\n * under it, which is exactly what the overflow was meant to avoid.\n */\nconst INLINE_OPTION_LIMIT = 2;\n\n/** One overflowed pill: its options as checkboxes, all visible at once. */\nfunction OverflowPill<T extends Record<string, unknown>>({\n field,\n values,\n onToggle,\n onClear,\n testIdPrefix,\n}: {\n field: OverflowField<T>;\n values: string[];\n onToggle: (value: string, checked: boolean) => void;\n onClear: () => void;\n testIdPrefix: string;\n}): React.JSX.Element {\n const copy = useDataViewsCopy();\n const options = field.pill?.options ?? [];\n if (options.length > INLINE_OPTION_LIMIT) {\n return (\n <MultiSelectDropdown\n extraOptionsHeading={copy.filters.optionsHeading}\n clearText={copy.filters.clear}\n clearLabel={copy.filters.clearRange(field.label)}\n label={field.pill?.label ?? field.label}\n options={options}\n selected={new Set(values)}\n onToggle={onToggle}\n onClear={onClear}\n allLabel={copy.filters.allOption}\n searchable={options.length > 6 ? true : undefined}\n searchPlaceholder={copy.filters.optionSearchPlaceholder}\n noResultsLabel={copy.filters.optionsEmpty}\n layout=\"pill\"\n data-testid={`${testIdPrefix}-more-${field.id}`}\n />\n );\n }\n return (\n <Box sx={{ display: \"flex\", flexWrap: \"wrap\", gap: 0.75 }}>\n {(field.pill?.options ?? []).map((option) => {\n const checked = values.includes(option.value);\n return (\n <Box\n key={option.value}\n component=\"label\"\n data-testid={`${testIdPrefix}-more-${field.id}-${option.value}`}\n sx={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 0.25,\n pr: 1,\n border: 1,\n borderStyle: \"solid\",\n minHeight: fieldHeight,\n borderColor: (theme) => (checked ? theme.palette.primary.main : fieldEdge(theme)),\n bgcolor: checked ? \"action.selected\" : \"transparent\",\n borderRadius: fieldRadiusPx,\n cursor: \"pointer\",\n fontSize: sxRem(13),\n color: checked ? \"primary.main\" : \"text.secondary\",\n }}\n >\n <Checkbox\n size=\"small\"\n checked={checked}\n onChange={(event) => onToggle(option.value, event.target.checked)}\n inputProps={{ \"aria-label\": `${field.label}: ${option.label}` }}\n />\n {option.label}\n </Box>\n );\n })}\n </Box>\n );\n}\n\n\n/**\n * One overflowed range: its presets and its two bounds, as the same controls\n * the pill uses.\n *\n * The whole control DELEGATES to {@link RangeBounds} rather than being built\n * here, which is the point — this panel used to render a raw\n * `<input type=\"date\">`, so the masked `dd/mm/aaaa` field existed only while\n * the filter FITTED on the bar. The moment \"Data\" overflowed it reverted to the\n * native control the mask replaced, and a merchant on a narrow screen never saw\n * the fix at all (FUT-744). `Valor` had the twin bug: a bare `type=\"number\"`\n * plus `Number(raw)` dropped the decimal comma this panel is meant to accept.\n *\n * That also retires the intrinsic-width workaround this pair used to need — a\n * native date input reports a wide minimum (its own mask plus the picker\n * glyph), which is what made two of them refuse to shrink and scrolled the\n * panel sideways. A text input has no such minimum.\n *\n * `RangeBounds` renders the preset chips ITSELF, above the inputs. This panel\n * must NOT render its own row as well: when the delegation above landed, the\n * chips it used to draw by hand stayed behind, and \"Data\" showed Hoje/Ontem/\n * Esta semana/Este mês/Este ano twice — two rows, duplicated test ids, and half\n * the panel's height spent saying the same thing (FUT-751).\n */\nfunction OverflowRange<T extends Record<string, unknown>>({\n field,\n value,\n onChange,\n testIdPrefix,\n}: {\n field: OverflowField<T>;\n value: RangeValue;\n onChange: (range: RangeValue) => void;\n testIdPrefix: string;\n}): React.JSX.Element | null {\n // `group === \"range\"` is what selects this component, so `range` is always\n // set; the guard is for the type, not for a state the caller can reach.\n if (!field.range) return null;\n return (\n <RangeBounds\n field={field.range}\n value={value}\n onChange={onChange}\n testId={`${testIdPrefix}-more-${field.id}`}\n />\n );\n}\n\n/**\n * The trigger, badged with how many fields had no room on the bar — and told\n * apart when some of them are APPLIED.\n *\n * An applied filter can land in here now (it is ranked first, not exempt), so\n * this button is the only thing on screen still saying it exists. A neutral\n * badge reading \"3\" would make \"three filters you have not used\" and \"three\n * filters narrowing this list\" look identical, which is the whole failure the\n * old exemption was written to avoid. So an overflow holding applied filters\n * takes the applied tone and counts them in its own badge.\n\n/**\n * Whether a field has anything applied, and how to unapply it — the two shapes\n * (a pill's selected values, a range's bounds) answered in one place so the\n * group that renders them stays a renderer.\n */\nexport function fieldClearing<T extends Record<string, unknown>>({\n field,\n pills,\n ranges,\n onTogglePill,\n onChangeRange,\n}: {\n field: OverflowField<T>;\n} & Pick<MoreFieldProps, \"pills\" | \"ranges\" | \"onTogglePill\" | \"onChangeRange\">): {\n applied: boolean;\n clear: () => void;\n} {\n if (field.group === \"range\") {\n return {\n applied: isRangeSet(ranges[field.id] ?? {}),\n clear: () => onChangeRange(field.id, {}),\n };\n }\n const values = pills[field.id] ?? [];\n return {\n applied: values.length > 0,\n clear: () => values.forEach((value) => onTogglePill(field.id, value, false)),\n };\n}\n\n\n/** One labelled group in the panel: the field's name, then its control. */\nexport function MoreGroup<T extends Record<string, unknown>>({\n field,\n pills,\n ranges,\n onTogglePill,\n onChangeRange,\n testIdPrefix,\n}: {\n field: OverflowField<T>;\n} & MoreFieldProps): React.JSX.Element {\n const copy = useDataViewsCopy();\n const { applied, clear } = fieldClearing({ field, pills, ranges, onTogglePill, onChangeRange });\n return (\n <Box sx={{ mb: 1.5, \"&:last-of-type\": { mb: 0 } }}>\n <Box sx={{ display: \"flex\", alignItems: \"center\", gap: 1, mb: 0.5 }}>\n <Text variant=\"caption\" as=\"span\">\n <Box component=\"span\" sx={{ color: \"text.secondary\" }}>\n {field.label}\n {field.group === \"range\" && isRangeSet(ranges[field.id] ?? {}) ? \" •\" : \"\"}\n </Box>\n </Text>\n {/* The panel's equivalent of the pill's ✕. Without it a field applied\n in here could only be cleared by finding it again on the bar —\n which is where it goes the moment it becomes active, so the\n operator has to close this panel to undo what they just did. */}\n {applied && (\n <Box\n component=\"button\"\n type=\"button\"\n onClick={clear}\n data-testid={`${testIdPrefix}-more-${field.id}-clear`}\n sx={{\n ml: \"auto\",\n border: 0,\n p: 0,\n bgcolor: \"transparent\",\n cursor: \"pointer\",\n font: \"inherit\",\n fontSize: sxRem(12),\n color: \"primary.main\",\n \"&:hover\": { textDecoration: \"underline\" },\n }}\n >\n {copy.filters.clear}\n </Box>\n )}\n </Box>\n {field.group === \"pill\" ? (\n <OverflowPill\n field={field}\n values={pills[field.id] ?? []}\n onToggle={(value, checked) => onTogglePill(field.id, value, checked)}\n onClear={() => (pills[field.id] ?? []).forEach((v) => onTogglePill(field.id, v, false))}\n testIdPrefix={testIdPrefix}\n />\n ) : (\n <OverflowRange\n field={field}\n value={ranges[field.id] ?? {}}\n onChange={(range) => onChangeRange(field.id, range)}\n testIdPrefix={testIdPrefix}\n />\n )}\n </Box>\n );\n}\n\n"],"mappings":"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","names":["default"]}