@jsenv/navi 0.27.15 → 0.27.17

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.
@@ -5729,6 +5729,9 @@ const NO_LOCAL_STORAGE = [() => undefined, () => {}, () => {}];
5729
5729
  const localStorageTypeMap = {
5730
5730
  float: "number",
5731
5731
  integer: "number",
5732
+ minute: "number",
5733
+ hour: "number",
5734
+ second: "number",
5732
5735
  ratio: "number",
5733
5736
  longitude: "number",
5734
5737
  latitude: "number",
@@ -12885,11 +12888,27 @@ const definePseudoClass = (pseudoClass, definition) => {
12885
12888
  definePseudoClass(":hover", {
12886
12889
  attribute: "data-hover",
12887
12890
  setup: (el, callback) => {
12888
- let onmouseenter = () => {
12891
+ const recheckProxy = (e) => {
12892
+ const proxy = findControlProxy(el);
12893
+ if (proxy) {
12894
+ requestPseudoStateCheck(proxy, { event: e });
12895
+ }
12896
+ };
12897
+ const recheckProxyTarget = (e) => {
12898
+ const proxyTarget = findControlProxyTarget(el);
12899
+ if (proxyTarget) {
12900
+ requestPseudoStateCheck(proxyTarget, { event: e });
12901
+ }
12902
+ };
12903
+ let onmouseenter = (e) => {
12889
12904
  callback();
12905
+ recheckProxy(e);
12906
+ recheckProxyTarget(e);
12890
12907
  };
12891
- let onmouseleave = () => {
12908
+ let onmouseleave = (e) => {
12892
12909
  callback();
12910
+ recheckProxy(e);
12911
+ recheckProxyTarget(e);
12893
12912
  };
12894
12913
 
12895
12914
  if (el.tagName === "LABEL") {
@@ -12912,13 +12931,15 @@ definePseudoClass(":hover", {
12912
12931
  }
12913
12932
  requestPseudoStateCheck(input, { event: e });
12914
12933
  };
12934
+ const _onmouseenter = onmouseenter;
12915
12935
  onmouseenter = (e) => {
12916
- callback();
12917
12936
  recheckInput(e);
12937
+ _onmouseenter(e);
12918
12938
  };
12939
+ const _onmouseleave = onmouseleave;
12919
12940
  onmouseleave = (e) => {
12920
- callback();
12921
12941
  recheckInput(e);
12942
+ _onmouseleave(e);
12922
12943
  };
12923
12944
  }
12924
12945
 
@@ -12929,7 +12950,16 @@ definePseudoClass(":hover", {
12929
12950
  el.removeEventListener("mouseleave", onmouseleave);
12930
12951
  };
12931
12952
  },
12932
- test: (el) => el.matches(":hover"),
12953
+ test: (el) => {
12954
+ if (el.matches(":hover")) {
12955
+ return true;
12956
+ }
12957
+ const proxy = findControlProxy(el);
12958
+ if (proxy && proxy.matches(":hover")) {
12959
+ return true;
12960
+ }
12961
+ return false;
12962
+ },
12933
12963
  });
12934
12964
  definePseudoClass(":disabled", {
12935
12965
  attribute: "data-disabled",
@@ -17384,6 +17414,7 @@ const css$O = /* css */`
17384
17414
  opacity: 0;
17385
17415
  /* will be positioned with transform: translate */
17386
17416
  transition: opacity 0.2s ease-in-out;
17417
+ pointer-events: auto; /* Must be interactive to be closabled (overrid list item pointer-events none for instance) */
17387
17418
  overflow: visible;
17388
17419
 
17389
17420
  &[data-status="success"] {
@@ -21851,7 +21882,7 @@ const useCustomValidationRef = (
21851
21882
  }
21852
21883
  const element = elementRef.current;
21853
21884
  if (!element) {
21854
- if (!elementRef.nullExpected) {
21885
+ if (!elementRef.nullCanHappen) {
21855
21886
  console.warn(
21856
21887
  `useCustomValidationRef: elementRef.current is null, make sure to pass a ref to an element
21857
21888
  ${callSiteRef.current}`,
@@ -24394,10 +24425,13 @@ const useNextResolver = () => useContext(NextResolverContext);
24394
24425
  * To terminate the chain early (e.g. render a specialized component), render
24395
24426
  * directly without calling Next.
24396
24427
  *
24428
+ * The last entry in the array is the final/target component — it receives null
24429
+ * from useNextResolver() indicating it is terminal.
24430
+ *
24397
24431
  * Usage:
24398
- * const renderButton = createComponentResolver([ResolverA, ResolverB]);
24432
+ * const renderButton = createComponentResolver([ResolverA, ResolverB, ButtonTarget]);
24399
24433
  * // Then inside a component render:
24400
- * renderButton(ButtonTarget, props)
24434
+ * renderButton(props)
24401
24435
  *
24402
24436
  * NextResolverContext exposes a stable Next component so resolvers can continue
24403
24437
  * the chain via useNextResolver().
@@ -24406,22 +24440,21 @@ const useNextResolver = () => useContext(NextResolverContext);
24406
24440
  */
24407
24441
  const createComponentResolver = resolvers => {
24408
24442
  const ResolverIndexContext = createContext(0);
24409
- const TargetComponentContext = createContext(null);
24410
24443
  const ChainRunner = props => {
24411
24444
  const index = useContext(ResolverIndexContext);
24412
- const TargetComponent = useContext(TargetComponentContext);
24413
24445
  if (index >= resolvers.length) {
24414
- return jsx(NextResolverContext.Provider, {
24415
- value: null,
24416
- children: jsx(TargetComponent, {
24417
- ...props
24418
- })
24419
- });
24446
+ return null;
24420
24447
  }
24421
24448
  const Resolver = resolvers[index];
24449
+ const isLast = index === resolvers.length - 1;
24422
24450
  return jsx(ResolverIndexContext.Provider, {
24423
24451
  value: index + 1,
24424
- children: jsx(Resolver, {
24452
+ children: isLast ? jsx(NextResolverContext.Provider, {
24453
+ value: null,
24454
+ children: jsx(Resolver, {
24455
+ ...props
24456
+ })
24457
+ }) : jsx(Resolver, {
24425
24458
  ...props
24426
24459
  })
24427
24460
  });
@@ -24434,11 +24467,11 @@ const createComponentResolver = resolvers => {
24434
24467
  const NextComponent = props => jsx(ChainRunner, {
24435
24468
  ...props
24436
24469
  });
24437
- const renderComponent = (TargetComponent, props) => {
24470
+ const renderComponent = props => {
24438
24471
  return jsx(NextResolverContext.Provider, {
24439
24472
  value: NextComponent,
24440
- children: jsx(TargetComponentContext.Provider, {
24441
- value: TargetComponent,
24473
+ children: jsx(ResolverIndexContext.Provider, {
24474
+ value: 0,
24442
24475
  children: jsx(ChainRunner, {
24443
24476
  ...props
24444
24477
  })
@@ -24448,6 +24481,61 @@ const createComponentResolver = resolvers => {
24448
24481
  return renderComponent;
24449
24482
  };
24450
24483
 
24484
+ const ButtonInsideFormResolver = props => {
24485
+ const Next = useNextResolver();
24486
+ const formContext = useContext(FormContext);
24487
+ if (formContext) {
24488
+ return jsx(ButtonInsideForm, {
24489
+ ...props
24490
+ });
24491
+ }
24492
+ return jsx(Next, {
24493
+ ...props
24494
+ });
24495
+ };
24496
+ const ButtonInsideForm = props => {
24497
+ const Next = useNextResolver();
24498
+ return jsx(Next
24499
+ // The default action for a button inside a form is to request form action
24500
+ , {
24501
+ action: "send",
24502
+ ...props
24503
+ });
24504
+ };
24505
+
24506
+ const ButtonRouteResolver = props => {
24507
+ const Next = useNextResolver();
24508
+ if (props.route) {
24509
+ return jsx(ButtonWithRoute, {
24510
+ ...props
24511
+ });
24512
+ }
24513
+ return jsx(Next, {
24514
+ ...props
24515
+ });
24516
+ };
24517
+ const ButtonWithRoute = props => {
24518
+ const Next = useNextResolver();
24519
+ const {
24520
+ route,
24521
+ routeParams,
24522
+ children,
24523
+ ...rest
24524
+ } = props;
24525
+ const url = route.buildUrl(routeParams);
24526
+ const {
24527
+ matching
24528
+ } = useRouteStatus(route);
24529
+ const paramsAreMatching = route.matchesParams(routeParams);
24530
+ const linkMatching = matching && paramsAreMatching;
24531
+ return jsx(Next, {
24532
+ href: url,
24533
+ "data-href-current": linkMatching ? "" : undefined,
24534
+ ...rest,
24535
+ children: children || route.buildRelativeUrl(routeParams)
24536
+ });
24537
+ };
24538
+
24451
24539
  const LIGHT_ACCENT_ATTRIBUTE = "data-accent-light";
24452
24540
  const VERY_LIGHT_ACCENT_ATTRIBUTE = "data-accent-very-light";
24453
24541
  const DARK_CONTRAST_ATTRIBUTE = "data-accent-needs-dark-fg";
@@ -26520,38 +26608,8 @@ installImportMetaCssBuild(import.meta);const css$I = /* css */`
26520
26608
  }
26521
26609
  }
26522
26610
  `;
26523
- const Button = props => {
26524
- const defaultRef = useRef(null);
26525
- props.ref = props.ref || defaultRef;
26526
- const button = renderButton(ButtonUI, props);
26527
- return button;
26528
- };
26529
- const ButtonRouteResolver = props => {
26530
- const Next = useNextResolver();
26531
- if (props.route) {
26532
- return jsx(ButtonWithRoute, {
26533
- ...props
26534
- });
26535
- }
26536
- return jsx(Next, {
26537
- ...props
26538
- });
26539
- };
26540
- const ButtonInsideFormResolver = props => {
26541
- const Next = useNextResolver();
26542
- const formContext = useContext(FormContext);
26543
- if (formContext) {
26544
- return jsx(ButtonInsideForm, {
26545
- ...props
26546
- });
26547
- }
26548
- return jsx(Next, {
26549
- ...props
26550
- });
26551
- };
26552
- const renderButton = createComponentResolver([ButtonRouteResolver, ButtonInsideFormResolver]);
26553
26611
  const ButtonUI = props => {
26554
- import.meta.css = [css$I, "@jsenv/navi/src/control/button.jsx"];
26612
+ import.meta.css = [css$I, "@jsenv/navi/src/control/input/button_ui.jsx"];
26555
26613
  const {
26556
26614
  ref,
26557
26615
  // href/link
@@ -26701,36 +26759,16 @@ const ButtonShadow = () => {
26701
26759
  });
26702
26760
  };
26703
26761
  markAsOutsideTextFlow(ButtonShadow);
26704
- const ButtonInsideForm = props => {
26705
- const Next = useNextResolver();
26706
- return jsx(Next
26707
- // The default action for a button inside a form is to request form action
26708
- , {
26709
- action: "send",
26710
- ...props
26711
- });
26712
- };
26713
- const ButtonWithRoute = props => {
26762
+
26763
+ const ButtonFirstResolver = props => {
26714
26764
  const Next = useNextResolver();
26715
- const {
26716
- route,
26717
- routeParams,
26718
- children,
26719
- ...rest
26720
- } = props;
26721
- const url = route.buildUrl(routeParams);
26722
- const {
26723
- matching
26724
- } = useRouteStatus(route);
26725
- const paramsAreMatching = route.matchesParams(routeParams);
26726
- const linkMatching = matching && paramsAreMatching;
26765
+ const defaultRef = useRef(null);
26766
+ props.ref = props.ref || defaultRef;
26727
26767
  return jsx(Next, {
26728
- href: url,
26729
- "data-href-current": linkMatching ? "" : undefined,
26730
- ...rest,
26731
- children: children || route.buildRelativeUrl(routeParams)
26768
+ ...props
26732
26769
  });
26733
26770
  };
26771
+ const Button = createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonInsideFormResolver, ButtonUI]);
26734
26772
 
26735
26773
  const CloseSvg = () => {
26736
26774
  return jsx("svg", {
@@ -29841,16 +29879,6 @@ const RangeStyleCSSVars = {
29841
29879
  const RangePseudoClasses = [":hover", ":active", ":-navi-pressed", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
29842
29880
  const RangePseudoElements = ["::-navi-loader"];
29843
29881
 
29844
- const ChevronDownSvg = () => {
29845
- return jsx("svg", {
29846
- viewBox: "0 0 16 16",
29847
- fill: "currentColor",
29848
- children: jsx("path", {
29849
- d: "M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427z"
29850
- })
29851
- });
29852
- };
29853
-
29854
29882
  const SearchSvg = () => jsx("svg", {
29855
29883
  viewBox: "0 0 24 24",
29856
29884
  xmlns: "http://www.w3.org/2000/svg",
@@ -29860,6 +29888,8 @@ const SearchSvg = () => jsx("svg", {
29860
29888
  })
29861
29889
  });
29862
29890
 
29891
+ const InputTextualContext = createContext(null);
29892
+
29863
29893
  installImportMetaCssBuild(import.meta);const css$x = /* css */`
29864
29894
  @layer navi {
29865
29895
  [data-navi-field] {
@@ -30084,6 +30114,91 @@ const Label = props => {
30084
30114
  };
30085
30115
  const LabelPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
30086
30116
 
30117
+ const InputSlot = ({
30118
+ side,
30119
+ onClick,
30120
+ hideWhileEmpty,
30121
+ ...props
30122
+ }) => {
30123
+ const ctx = useContext(InputTextualContext);
30124
+ const {
30125
+ id,
30126
+ readOnly,
30127
+ disabled
30128
+ } = ctx;
30129
+ return jsx(Label, {
30130
+ htmlFor: id,
30131
+ className: "navi_input_slot",
30132
+ disabled: disabled,
30133
+ readOnly: readOnly,
30134
+ "data-readonly": readOnly,
30135
+ "data-disabled": disabled,
30136
+ "data-left": side === "left" ? "" : undefined,
30137
+ "data-right": side === "right" ? "" : undefined,
30138
+ "data-hide-while-empty": hideWhileEmpty ? "" : undefined,
30139
+ inline: true,
30140
+ flex: true,
30141
+ align: "center",
30142
+ onMouseDown: e => {
30143
+ // Only prevent focus from leaving when the input already has focus.
30144
+ // If the input is not focused, let the mousedown proceed normally so
30145
+ // the slot element (e.g. a clear button) can receive focus itself.
30146
+ const inputEl = document.getElementById(id);
30147
+ if (inputEl && inputEl === document.activeElement) {
30148
+ e.preventDefault();
30149
+ }
30150
+ },
30151
+ onClick: e => {
30152
+ onClick?.(e);
30153
+ const input = document.getElementById(id);
30154
+ const allowed = dispatchRequestInteraction(input, e);
30155
+ if (!allowed) {
30156
+ e.preventDefault();
30157
+ }
30158
+ },
30159
+ ...props
30160
+ });
30161
+ };
30162
+ const InputLeftSlot = props => {
30163
+ return jsx(InputSlot, {
30164
+ ...props,
30165
+ side: "left"
30166
+ });
30167
+ };
30168
+ const InputRightSlot = props => {
30169
+ return jsx(InputSlot, {
30170
+ ...props,
30171
+ side: "right"
30172
+ });
30173
+ };
30174
+
30175
+ const InputWithListResolver = props => {
30176
+ const Next = useNextResolver();
30177
+ if (props["navi-list"]) {
30178
+ return jsx(InputWithList, {
30179
+ ...props
30180
+ });
30181
+ }
30182
+ return jsx(Next, {
30183
+ ...props
30184
+ });
30185
+ };
30186
+
30187
+ /**
30188
+ * InputWithList — connects an input to a SelectableList via its id.
30189
+ *
30190
+ * Usage: <Input navi-list="my-list-id" /> next to <SelectableList id="my-list-id" />
30191
+ *
30192
+ * Behavior:
30193
+ * - ArrowDown / ArrowUp move the list's "current item" without moving focus
30194
+ * - Home / End jump to first/last navigable item
30195
+ * - Enter triggers selection of the current item (same as clicking it)
30196
+ * - aria-controls dynamically points at the current item's real input so the
30197
+ * pseudo-styles focus-inheritance kicks in (list item shows :focus /
30198
+ * :focus-visible while the input is focused).
30199
+ * - aria-activedescendant points at the current item's <li> id (standard
30200
+ * combobox/listbox ARIA pattern).
30201
+ */
30087
30202
  const InputWithList = props => {
30088
30203
  const Next = useNextResolver();
30089
30204
  const {
@@ -30156,6 +30271,218 @@ const InputWithList = props => {
30156
30271
  });
30157
30272
  };
30158
30273
 
30274
+ const ChevronDownSvg = () => {
30275
+ return jsx("svg", {
30276
+ viewBox: "0 0 16 16",
30277
+ fill: "currentColor",
30278
+ children: jsx("path", {
30279
+ d: "M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427z"
30280
+ })
30281
+ });
30282
+ };
30283
+
30284
+ const InputWithSuggestionsResolver = props => {
30285
+ const Next = useNextResolver();
30286
+ if (props["navi-suggestions"]) {
30287
+ return jsx(InputTextualWithSuggestions, {
30288
+ ...props
30289
+ });
30290
+ }
30291
+ return jsx(Next, {
30292
+ ...props
30293
+ });
30294
+ };
30295
+ const InputTextualWithSuggestions = props => {
30296
+ const Next = useNextResolver();
30297
+ const {
30298
+ ref,
30299
+ suggestions,
30300
+ onInput,
30301
+ onFocus,
30302
+ onBlur,
30303
+ onKeyDown,
30304
+ children,
30305
+ ...rest
30306
+ } = props;
30307
+ const [expanded, setExpanded] = useState(false);
30308
+ const expandedRef = useRef(expanded);
30309
+ expandedRef.current = expanded;
30310
+ const expand = () => {
30311
+ expandedRef.current = true;
30312
+ setExpanded(true);
30313
+ };
30314
+ const collapse = () => {
30315
+ expandedRef.current = false;
30316
+ setExpanded(false);
30317
+ };
30318
+ const getListEl = () => {
30319
+ return document.getElementById(suggestions);
30320
+ };
30321
+ const showSuggestions = e => {
30322
+ if (expandedRef.current) {
30323
+ return;
30324
+ }
30325
+ const listEl = getListEl();
30326
+ if (listEl) {
30327
+ dispatchCustomEvent(listEl, "navi_request_open", {
30328
+ event: e,
30329
+ anchor: ref.current
30330
+ });
30331
+ expand();
30332
+ }
30333
+ };
30334
+ const hideSuggestions = e => {
30335
+ if (!expandedRef.current) {
30336
+ return;
30337
+ }
30338
+ const listEl = getListEl();
30339
+ if (listEl) {
30340
+ dispatchCustomEvent(listEl, "navi_request_close", {
30341
+ event: e
30342
+ });
30343
+ collapse();
30344
+ }
30345
+ };
30346
+ useEffect(() => {
30347
+ const inputEl = ref.current;
30348
+ const listEl = getListEl();
30349
+ if (!listEl) {
30350
+ return undefined;
30351
+ }
30352
+ const onSelect = e => {
30353
+ const {
30354
+ item
30355
+ } = e.detail;
30356
+ const {
30357
+ value
30358
+ } = item;
30359
+ inputEl.value = value;
30360
+ inputEl.dispatchEvent(new Event("input", {
30361
+ bubbles: true
30362
+ }));
30363
+ hideSuggestions(e);
30364
+ };
30365
+ listEl.addEventListener("navi_list_select", onSelect);
30366
+ return () => {
30367
+ listEl.removeEventListener("navi_list_select", onSelect);
30368
+ };
30369
+ }, [suggestions]);
30370
+ const onKeyDownShortcuts = createOnKeyDownForShortcuts({
30371
+ arrowdown: e => {
30372
+ showSuggestions(e);
30373
+ },
30374
+ arrowup: e => {
30375
+ showSuggestions(e);
30376
+ },
30377
+ escape: e => {
30378
+ if (!expandedRef.current) {
30379
+ return false;
30380
+ }
30381
+ hideSuggestions(e);
30382
+ return true;
30383
+ },
30384
+ home: () => {},
30385
+ end: () => {},
30386
+ enter: () => {}
30387
+ });
30388
+ return jsx(Next, {
30389
+ role: "combobox",
30390
+ "aria-haspopup": "listbox",
30391
+ "aria-expanded": expanded,
30392
+ "aria-autocomplete": "list",
30393
+ autoComplete: "off",
30394
+ basePseudoState: {
30395
+ ":-navi-expanded": expanded
30396
+ },
30397
+ onnavi_callout_open: e => {
30398
+ hideSuggestions(e);
30399
+ },
30400
+ ...rest,
30401
+ ref: ref,
30402
+ onFocus: e => {
30403
+ onFocus?.(e);
30404
+ showSuggestions(e);
30405
+ },
30406
+ onBlur: e => {
30407
+ onBlur?.(e);
30408
+ hideSuggestions(e);
30409
+ },
30410
+ onInput: e => {
30411
+ onInput?.(e);
30412
+ showSuggestions(e);
30413
+ },
30414
+ onKeyDown: e => {
30415
+ onKeyDown?.(e);
30416
+ onKeyDownShortcuts(e);
30417
+ }
30418
+ // arrowdown: (e) => {
30419
+ // const listEl = getListEl();
30420
+ // e.stopPropagation(); // when within a list, prevent list from handling it twice
30421
+ // return requestListNavFromCurrent(listEl, {
30422
+ // event: e,
30423
+ // goal: "down",
30424
+ // });
30425
+ // },
30426
+ // arrowup: (e) => {
30427
+ // const listEl = getListEl();
30428
+ // e.stopPropagation(); // when within a list, prevent list from handling it twice
30429
+ // return requestListNavFromCurrent(listEl, {
30430
+ // event: e,
30431
+ // goal: "up",
30432
+ // });
30433
+ // },
30434
+ // home: (e) => {
30435
+ // const listEl = getListEl();
30436
+ // e.stopPropagation(); // when within a list, prevent list from handling it twice
30437
+ // return requestListNavFromCurrent(listEl, {
30438
+ // event: e,
30439
+ // goal: "first",
30440
+ // });
30441
+ // },
30442
+ // end: (e) => {
30443
+ // const listEl = getListEl();
30444
+ // e.stopPropagation(); // when within a list, prevent list from handling it twice
30445
+ // return requestListNavFromCurrent(listEl, {
30446
+ // event: e,
30447
+ // goal: "last",
30448
+ // });
30449
+ // },
30450
+ // enter: (e) => {
30451
+ // const listEl = getListEl();
30452
+ // e.stopPropagation(); // when within a list, prevent list from handling it twice
30453
+ // return requestListSelectCurrent(listEl, { event: e });
30454
+ // },
30455
+ // escape: (e) => {
30456
+ // // prevent escape from reaching eventual <select> ancestor
30457
+ // // when the escape is meant to clear the search input (otherwise it would close the select too)
30458
+ // if (e.currentTarget.type === "search" && e.currentTarget.value !== "") {
30459
+ // e.stopPropagation();
30460
+ // return true;
30461
+ // }
30462
+ // const listEl = getListEl();
30463
+ // // here we allow propagation of escape up to the <select> to allow closing if within a select
30464
+ // // it also means list might catch escape and reset again but it's ok to reset twice here as it won't cause side effects
30465
+ // // (if we need the same pattern for other events where it could be problematic we would have to mark
30466
+ // // event as handled somehow to prevent list containing input to react to it)
30467
+ // return requestListInteractionStateReset(listEl, { event: e });
30468
+ // },
30469
+ ,
30470
+ children: children || jsx(InputRightSlot, {
30471
+ onClick: e => {
30472
+ if (expanded) {
30473
+ hideSuggestions(e);
30474
+ } else {
30475
+ showSuggestions(e);
30476
+ }
30477
+ },
30478
+ children: jsx(Icon, {
30479
+ color: "rgba(28, 43, 52, 0.5)",
30480
+ children: jsx(ChevronDownSvg, {})
30481
+ })
30482
+ })
30483
+ });
30484
+ };
30485
+
30159
30486
  installImportMetaCssBuild(import.meta);/**
30160
30487
  * Input component for all textual input types.
30161
30488
  *
@@ -30414,28 +30741,6 @@ const css$w = /* css */`
30414
30741
  -webkit-text-fill-color: var(--x-color) !important;
30415
30742
  }
30416
30743
  `;
30417
- const InputTextual = props => {
30418
- const defaultRef = useRef(null);
30419
- props.ref = props.ref || defaultRef;
30420
- const input = renderInput(InputTextualControlInterface, props);
30421
- return input;
30422
- };
30423
- const InputTextualWithListResolver = props => {
30424
- const Next = useNextResolver();
30425
- if (props["navi-list"]) {
30426
- return jsx(InputWithList, {
30427
- ...props
30428
- });
30429
- }
30430
- if (props.suggestions) {
30431
- return jsx(InputTextualWithSuggestions, {
30432
- ...props
30433
- });
30434
- }
30435
- return jsx(Next, {
30436
- ...props
30437
- });
30438
- };
30439
30744
  const InputTypeResolver = props => {
30440
30745
  const Next = useNextResolver();
30441
30746
  if (props.type === "search") {
@@ -30483,7 +30788,6 @@ const InputHeadlessResolver = props => {
30483
30788
  ...props
30484
30789
  });
30485
30790
  };
30486
- const renderInput = createComponentResolver([InputTextualWithListResolver, InputTypeResolver, InputHeadlessResolver]);
30487
30791
  const InputTextualHeadless = props => {
30488
30792
  const [inputProps, remainingProps] = useInputTextualProps(props);
30489
30793
  return jsx(BoxForwardedPropsContext.Provider, {
@@ -30508,8 +30812,7 @@ const useInputTextualProps = props => {
30508
30812
  });
30509
30813
  return [controlProps, remainingProps, ControlChildrenWrapper];
30510
30814
  };
30511
- const InputNativeContext = createContext(null);
30512
- const InputTextualControlInterface = props => {
30815
+ const InputTextualUI = props => {
30513
30816
  import.meta.css = [css$w, "@jsenv/navi/src/control/input/input_textual.jsx"];
30514
30817
  const {
30515
30818
  ui,
@@ -30527,7 +30830,7 @@ const InputTextualControlInterface = props => {
30527
30830
  const readOnly = basePseudoState[":read-only"];
30528
30831
  const loading = basePseudoState[":-navi-loading"];
30529
30832
  const childrenWithContext = jsx(ControlChildrenWrapper, {
30530
- children: jsx(InputNativeContext.Provider, {
30833
+ children: jsx(InputTextualContext.Provider, {
30531
30834
  value: {
30532
30835
  id,
30533
30836
  readOnly,
@@ -30562,6 +30865,15 @@ const InputTextualControlInterface = props => {
30562
30865
  }), childrenWithContext]
30563
30866
  });
30564
30867
  };
30868
+ const InputTextualFirstResolver = props => {
30869
+ const Next = useNextResolver();
30870
+ const defaultRef = useRef(null);
30871
+ props.ref = props.ref || defaultRef;
30872
+ return jsx(Next, {
30873
+ ...props
30874
+ });
30875
+ };
30876
+ const InputTextual = createComponentResolver([InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputHeadlessResolver, InputTextualUI]);
30565
30877
  const RealInput = props => {
30566
30878
  const inputProps = useContext(BoxForwardedPropsContext);
30567
30879
  return jsx(Box, {
@@ -30613,63 +30925,6 @@ const InputStyleCSSVars = {
30613
30925
  };
30614
30926
  const InputPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading", ":-navi-has-value", ":-navi-expanded"];
30615
30927
  const InputPseudoElements = ["::-navi-loader"];
30616
- const InputSlot = ({
30617
- side,
30618
- onClick,
30619
- hideWhileEmpty,
30620
- ...props
30621
- }) => {
30622
- const ctx = useContext(InputNativeContext);
30623
- const {
30624
- id,
30625
- readOnly,
30626
- disabled
30627
- } = ctx;
30628
- return jsx(Label, {
30629
- htmlFor: id,
30630
- className: "navi_input_slot",
30631
- disabled: disabled,
30632
- readOnly: readOnly,
30633
- "data-readonly": readOnly,
30634
- "data-disabled": disabled,
30635
- "data-left": side === "left" ? "" : undefined,
30636
- "data-right": side === "right" ? "" : undefined,
30637
- "data-hide-while-empty": hideWhileEmpty ? "" : undefined,
30638
- inline: true,
30639
- flex: true,
30640
- align: "center",
30641
- onMouseDown: e => {
30642
- // Only prevent focus from leaving when the input already has focus.
30643
- // If the input is not focused, let the mousedown proceed normally so
30644
- // the slot element (e.g. a clear button) can receive focus itself.
30645
- const inputEl = document.getElementById(id);
30646
- if (inputEl && inputEl === document.activeElement) {
30647
- e.preventDefault();
30648
- }
30649
- },
30650
- onClick: e => {
30651
- onClick?.(e);
30652
- const input = document.getElementById(id);
30653
- const allowed = dispatchRequestInteraction(input, e);
30654
- if (!allowed) {
30655
- e.preventDefault();
30656
- }
30657
- },
30658
- ...props
30659
- });
30660
- };
30661
- const InputLeftSlot = props => {
30662
- return jsx(InputSlot, {
30663
- ...props,
30664
- side: "left"
30665
- });
30666
- };
30667
- const InputRightSlot = props => {
30668
- return jsx(InputSlot, {
30669
- ...props,
30670
- side: "right"
30671
- });
30672
- };
30673
30928
  const InputSearch = props => {
30674
30929
  const Next = useNextResolver();
30675
30930
  return jsx(Next, {
@@ -30682,7 +30937,7 @@ const InputSearch = props => {
30682
30937
  const InputSearchUI = ({
30683
30938
  icon
30684
30939
  }) => {
30685
- const ctx = useContext(InputNativeContext);
30940
+ const ctx = useContext(InputTextualContext);
30686
30941
  const {
30687
30942
  id
30688
30943
  } = ctx;
@@ -30771,197 +31026,6 @@ const InputDatetimeLocal = props => {
30771
31026
  ...props
30772
31027
  });
30773
31028
  };
30774
- const InputTextualWithSuggestions = props => {
30775
- const Next = useNextResolver();
30776
- const {
30777
- ref,
30778
- suggestions,
30779
- onInput,
30780
- onFocus,
30781
- onBlur,
30782
- onKeyDown,
30783
- children,
30784
- ...rest
30785
- } = props;
30786
- const [expanded, setExpanded] = useState(false);
30787
- const expandedRef = useRef(expanded);
30788
- expandedRef.current = expanded;
30789
- const expand = () => {
30790
- expandedRef.current = true;
30791
- setExpanded(true);
30792
- };
30793
- const collapse = () => {
30794
- expandedRef.current = false;
30795
- setExpanded(false);
30796
- };
30797
- const getListEl = () => {
30798
- return document.getElementById(suggestions);
30799
- };
30800
- const showSuggestions = e => {
30801
- if (expandedRef.current) {
30802
- return;
30803
- }
30804
- const listEl = getListEl();
30805
- if (listEl) {
30806
- dispatchCustomEvent(listEl, "navi_request_open", {
30807
- event: e,
30808
- anchor: ref.current
30809
- });
30810
- expand();
30811
- }
30812
- };
30813
- const hideSuggestions = e => {
30814
- if (!expandedRef.current) {
30815
- return;
30816
- }
30817
- const listEl = getListEl();
30818
- if (listEl) {
30819
- dispatchCustomEvent(listEl, "navi_request_close", {
30820
- event: e
30821
- });
30822
- collapse();
30823
- }
30824
- };
30825
- useEffect(() => {
30826
- const inputEl = ref.current;
30827
- const listEl = getListEl();
30828
- if (!listEl) {
30829
- return undefined;
30830
- }
30831
- const onSelect = e => {
30832
- const {
30833
- item
30834
- } = e.detail;
30835
- const {
30836
- value
30837
- } = item;
30838
- inputEl.value = value;
30839
- inputEl.dispatchEvent(new Event("input", {
30840
- bubbles: true
30841
- }));
30842
- hideSuggestions(e);
30843
- };
30844
- listEl.addEventListener("navi_list_select", onSelect);
30845
- return () => {
30846
- listEl.removeEventListener("navi_list_select", onSelect);
30847
- };
30848
- }, [suggestions]);
30849
- const onKeyDownShortcuts = createOnKeyDownForShortcuts({
30850
- arrowdown: e => {
30851
- showSuggestions(e);
30852
- },
30853
- arrowup: e => {
30854
- showSuggestions(e);
30855
- },
30856
- escape: e => {
30857
- if (!expandedRef.current) {
30858
- return false;
30859
- }
30860
- hideSuggestions(e);
30861
- return true;
30862
- },
30863
- home: () => {},
30864
- end: () => {},
30865
- enter: () => {}
30866
- });
30867
- return jsx(Next, {
30868
- role: "combobox",
30869
- "aria-haspopup": "listbox",
30870
- "aria-expanded": expanded,
30871
- "aria-autocomplete": "list",
30872
- autoComplete: "off",
30873
- basePseudoState: {
30874
- ":-navi-expanded": expanded
30875
- },
30876
- onnavi_callout_open: e => {
30877
- hideSuggestions(e);
30878
- },
30879
- ...rest,
30880
- ref: ref,
30881
- onFocus: e => {
30882
- onFocus?.(e);
30883
- showSuggestions(e);
30884
- },
30885
- onBlur: e => {
30886
- onBlur?.(e);
30887
- hideSuggestions(e);
30888
- },
30889
- onInput: e => {
30890
- onInput?.(e);
30891
- showSuggestions(e);
30892
- },
30893
- onKeyDown: e => {
30894
- onKeyDown?.(e);
30895
- onKeyDownShortcuts(e);
30896
- }
30897
- // arrowdown: (e) => {
30898
- // const listEl = getListEl();
30899
- // e.stopPropagation(); // when within a list, prevent list from handling it twice
30900
- // return requestListNavFromCurrent(listEl, {
30901
- // event: e,
30902
- // goal: "down",
30903
- // });
30904
- // },
30905
- // arrowup: (e) => {
30906
- // const listEl = getListEl();
30907
- // e.stopPropagation(); // when within a list, prevent list from handling it twice
30908
- // return requestListNavFromCurrent(listEl, {
30909
- // event: e,
30910
- // goal: "up",
30911
- // });
30912
- // },
30913
- // home: (e) => {
30914
- // const listEl = getListEl();
30915
- // e.stopPropagation(); // when within a list, prevent list from handling it twice
30916
- // return requestListNavFromCurrent(listEl, {
30917
- // event: e,
30918
- // goal: "first",
30919
- // });
30920
- // },
30921
- // end: (e) => {
30922
- // const listEl = getListEl();
30923
- // e.stopPropagation(); // when within a list, prevent list from handling it twice
30924
- // return requestListNavFromCurrent(listEl, {
30925
- // event: e,
30926
- // goal: "last",
30927
- // });
30928
- // },
30929
- // enter: (e) => {
30930
- // const listEl = getListEl();
30931
- // e.stopPropagation(); // when within a list, prevent list from handling it twice
30932
- // return requestListSelectCurrent(listEl, { event: e });
30933
- // },
30934
- // escape: (e) => {
30935
- // // prevent escape from reaching eventual <select> ancestor
30936
- // // when the escape is meant to clear the search input (otherwise it would close the select too)
30937
- // if (e.currentTarget.type === "search" && e.currentTarget.value !== "") {
30938
- // e.stopPropagation();
30939
- // return true;
30940
- // }
30941
- // const listEl = getListEl();
30942
- // // here we allow propagation of escape up to the <select> to allow closing if within a select
30943
- // // it also means list might catch escape and reset again but it's ok to reset twice here as it won't cause side effects
30944
- // // (if we need the same pattern for other events where it could be problematic we would have to mark
30945
- // // event as handled somehow to prevent list containing input to react to it)
30946
- // return requestListInteractionStateReset(listEl, { event: e });
30947
- // },
30948
- ,
30949
-
30950
- children: children || jsx(InputRightSlot, {
30951
- onClick: e => {
30952
- if (expanded) {
30953
- hideSuggestions(e);
30954
- } else {
30955
- showSuggestions(e);
30956
- }
30957
- },
30958
- children: jsx(Icon, {
30959
- color: "rgba(28, 43, 52, 0.5)",
30960
- children: jsx(ChevronDownSvg, {})
30961
- })
30962
- })
30963
- });
30964
- };
30965
31029
 
30966
31030
  const Input = props => {
30967
31031
  const {
@@ -30986,6 +31050,10 @@ const Input = props => {
30986
31050
  ...props
30987
31051
  });
30988
31052
  };
31053
+ Input.UI = {
31054
+ LeftSlot: InputLeftSlot,
31055
+ RightSlot: InputRightSlot
31056
+ };
30989
31057
 
30990
31058
  installImportMetaCssBuild(import.meta);/**
30991
31059
  * - We must keep the edited element in the DOM so that
@@ -33739,1943 +33807,2032 @@ const createItemTracker = (onChange) => {
33739
33807
  };
33740
33808
  };
33741
33809
 
33742
- /**
33743
- * CSS Highlight API integration for navi-search-match.
33744
- *
33745
- * Provides a shared `Highlight` instance registered as "navi-search-match"
33746
- * and a `useSearchHighlight` hook that marks matching text ranges on an element.
33747
- *
33748
- * The highlight is not specific to ListItem — any element that wants to mark
33749
- * search match ranges (e.g. Suggestion, custom search results) can use these.
33750
- *
33751
- * CSS to paint the highlight:
33752
- * ::highlight(navi-search-match) {
33753
- * color: var(--search-match-color);
33754
- * background-color: var(--search-match-background-color);
33755
- * }
33756
- *
33757
- * The `highlight` prop can be:
33758
- * - an array of [start, end] pairs — applied to all text nodes under the root element
33759
- * - an object { [domSelector]: [[start, end], …] } — applied to each sub-element
33760
- * matched by the selector (the format produced by createSearch)
33761
- */
33762
-
33763
- // Module-level shared Highlight instance — created lazily once.
33764
- let naviSearchHighlight = null;
33765
- const getNaviSearchHighlight = () => {
33766
- if (!CSS.highlights) {
33767
- return null;
33810
+ const ListItemHeaderOrFooterResolver = props => {
33811
+ const Next = useNextResolver();
33812
+ if (props.header) {
33813
+ return jsx(ListItemHeader, {
33814
+ ...props
33815
+ });
33768
33816
  }
33769
- if (!naviSearchHighlight) {
33770
- naviSearchHighlight = new Highlight();
33771
- CSS.highlights.set("navi-search-match", naviSearchHighlight);
33817
+ if (props.footer) {
33818
+ return jsx(ListItemFooter, {
33819
+ ...props
33820
+ });
33772
33821
  }
33773
- return naviSearchHighlight;
33822
+ return jsx(Next, {
33823
+ ...props
33824
+ });
33774
33825
  };
33775
-
33776
- /**
33777
- * useSearchHighlight — registers/unregisters CSS Highlight API ranges for an element.
33778
- *
33779
- * @param {import("preact").RefObject} ref - ref to the root element
33780
- * @param {Array|Object|null|undefined} highlight - ranges to highlight
33781
- * @param {Array} deps - additional dependencies that should retrigger the effect
33782
- * (typically [children, hidden] from the consuming component)
33783
- */
33784
- const useSearchHighlight = (ref, highlight, deps = []) => {
33785
- useLayoutEffect(() => {
33786
- const el = ref.current;
33787
- if (!el || !highlight) {
33788
- return undefined;
33789
- }
33790
- return applySearchHighlight(el, highlight);
33791
- }, [highlight, ...deps]);
33826
+ const ListItemHeader = props => {
33827
+ const Next = useNextResolver();
33828
+ const {
33829
+ ref
33830
+ } = props;
33831
+ useDisplayedLayoutEffect(ref, headerEl => {
33832
+ const listContainerEl = headerEl.closest(".navi_list_container");
33833
+ const rect = headerEl.getBoundingClientRect();
33834
+ listContainerEl.style.setProperty("--list-header-height", `${rect.height}px`);
33835
+ listContainerEl.style.setProperty("--list-header-width", `${rect.width}px`);
33836
+ }, []);
33837
+ return jsx(Next, {
33838
+ ...props,
33839
+ role: "presentation",
33840
+ baseClassName: "navi_list_item_header"
33841
+ });
33792
33842
  };
33793
-
33794
- const applySearchHighlight = (el, highlight) => {
33795
- const hl = getNaviSearchHighlight();
33796
- if (!hl) {
33797
- return undefined;
33798
- }
33799
-
33800
- // Normalise highlight to { root, ranges }[] entries.
33801
- // Flat array → single entry scoped to the whole element.
33802
- const entries = Array.isArray(highlight)
33803
- ? highlight.length === 0
33804
- ? []
33805
- : [{ root: el, ranges: highlight }]
33806
- : Object.entries(highlight).map(([selector, ranges]) => ({
33807
- root: el.querySelector(selector) ?? el,
33808
- ranges,
33809
- }));
33810
-
33811
- if (entries.length === 0) {
33812
- return undefined;
33813
- }
33814
-
33815
- const ownRanges = [];
33816
- for (const { root, ranges } of entries) {
33817
- // Collect text nodes under root with cumulative character offsets so that
33818
- // [start, end] ranges (positions in the field string) map directly to
33819
- // the correct DOM text node positions without re-searching.
33820
- const textNodes = [];
33821
- let totalLength = 0;
33822
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
33823
- acceptNode: (node) => {
33824
- // Skip text nodes inside aria-hidden elements (icons, decorative content, etc.)
33825
- let parent = node.parentElement;
33826
- while (parent && parent !== root) {
33827
- if (parent.getAttribute("aria-hidden") === "true") {
33828
- return NodeFilter.FILTER_REJECT;
33829
- }
33830
- parent = parent.parentElement;
33831
- }
33832
- return NodeFilter.FILTER_ACCEPT;
33833
- },
33834
- });
33835
- let node;
33836
- while ((node = walker.nextNode())) {
33837
- textNodes.push({ node, offset: totalLength });
33838
- totalLength += node.textContent.length;
33839
- }
33840
- for (const [start, end] of ranges) {
33841
- for (const { node: textNode, offset: nodeOffset } of textNodes) {
33842
- const nodeEnd = nodeOffset + textNode.textContent.length;
33843
- if (nodeEnd <= start || nodeOffset >= end) {
33844
- continue;
33845
- }
33846
- const rangeStart = start - nodeOffset;
33847
- const rangeEnd = end - nodeOffset;
33848
- const range = new Range();
33849
- range.setStart(textNode, rangeStart < 0 ? 0 : rangeStart);
33850
- range.setEnd(
33851
- textNode,
33852
- rangeEnd > textNode.textContent.length
33853
- ? textNode.textContent.length
33854
- : rangeEnd,
33855
- );
33856
- hl.add(range);
33857
- ownRanges.push(range);
33858
- }
33859
- }
33860
- }
33861
- return () => {
33862
- for (const range of ownRanges) {
33863
- hl.delete(range);
33864
- }
33865
- };
33843
+ const ListItemFooter = props => {
33844
+ const Next = useNextResolver();
33845
+ const {
33846
+ ref
33847
+ } = props;
33848
+ useDisplayedLayoutEffect(ref, footerEl => {
33849
+ const listContainerEl = footerEl.closest(".navi_list_container");
33850
+ const rect = footerEl.getBoundingClientRect();
33851
+ listContainerEl.style.setProperty("--list-footer-height", `${rect.height}px`);
33852
+ listContainerEl.style.setProperty("--list-footer-width", `${rect.width}px`);
33853
+ }, []);
33854
+ return jsx(Next, {
33855
+ ...props,
33856
+ role: "presentation",
33857
+ baseClassName: "navi_list_item_footer"
33858
+ });
33866
33859
  };
33867
33860
 
33868
- installImportMetaCssBuild(import.meta);const ListItemTrackerContext = createContext(null);
33869
- const GroupItemTrackerContext = createContext(null);
33870
- const PendingScrollRefContext = createContext(null);
33871
-
33872
- // When total rendered items exceeds renderBudget, a render window [start, end)
33873
- // is activated to cap the number of DOM nodes. Items outside the window return
33874
- // null. The window slides as the user scrolls, using actual DOM positions
33875
- // (getBoundingClientRect) to find the first visible item — no height estimation.
33876
- const RENDER_BUDGET_DEFAULT = 100;
33877
-
33878
- // Attribute used on <li> elements rendered by ListItemReal so the scroll listener
33879
- // and filler-height calculation can find real items without matching presentation ones.
33880
- const REAL_LIST_ITEM_SELECTOR = `[navi-list-item-real]`;
33881
-
33882
- // Carries the render window {start, end} (or null = render all) from
33883
- // List down to each ListItem.
33884
- const RenderWindowContext = createContext(null);
33885
- // Carries the separator element/function down to each ListItem so separators
33886
- // are only rendered between items that actually mount (post-filter, post-window).
33887
- const SeparatorContext = createContext(null);
33888
- const css$m = /* css */`
33861
+ installImportMetaCssBuild(import.meta);const css$m = /* css */`
33889
33862
  @layer navi {
33890
33863
  .navi_list_container {
33891
- --list-outline-width: 1px;
33892
- --list-border-radius: 4px;
33893
- --list-border-width: 1px;
33894
- --list-border-color: light-dark(#ccc, #555);
33895
- --list-background-color: light-dark(#fff, #1e1e1e);
33896
- --list-max-height: 220px;
33864
+ --list-outline-color: var(--navi-focus-outline-color);
33865
+ --list-item-outline-color: var(--navi-focus-outline-color);
33866
+ --list-item-outline-width: 2px;
33867
+ --list-item-outline-offset: calc(-1 * var(--list-item-outline-width));
33868
+ /* Hover (mouse) */
33869
+ --list-item-color-hover: var(--list-item-color);
33870
+ --list-item-background-color-hover: light-dark(#f5f5f5, #2a2a2a);
33871
+ /* Pointed by mouse — subtle, just a shade above background */
33872
+ --list-item-color-mouse-pointed: var(--list-item-color);
33873
+ --list-item-background-color-mouse-pointed: light-dark(#ebebeb, #303030);
33874
+ /* Pointed by keyboard — subtle light blue highlight */
33875
+ --list-item-color-keyboard-pointed: var(--list-item-color);
33876
+ --list-item-background-color-keyboard-pointed: light-dark(
33877
+ #c2dcff,
33878
+ #1c3a6e
33879
+ );
33880
+ /* Pointed by proxy */
33881
+ --list-item-color-pointed: var(--list-item-color);
33882
+ --list-item-background-color-pointed: light-dark(#dbeafe, #1c3a6e);
33883
+ /* Selected — vivid blue accent */
33884
+ --list-item-color-selected: white;
33885
+ --list-item-background-color-selected: rgb(3, 30, 60);
33886
+ --list-item-border-color-selected: var(
33887
+ --list-item-background-color-selected
33888
+ );
33889
+ /* Disabled */
33890
+ --list-item-color-disabled: light-dark(#aaa, #555);
33891
+ --list-item-background-color-disabled: var(--list-item-background-color);
33897
33892
  }
33898
- .navi_list_item {
33899
- --list-item-padding-x: 0px;
33900
- --list-item-padding-y: 0px;
33901
- --list-item-padding: var(--list-item-padding-y) var(--list-item-padding-x);
33902
- --list-item-color: inherit;
33903
- --list-item-font-weight: inherit;
33904
- --list-item-background-color: transparent;
33905
-
33906
- /* Highlight (CSS Highlight API match) */
33907
- --list-item-color-highlight: inherit;
33908
- --list-item-background-color-highlight: #ffe066;
33893
+ }
33909
33894
 
33910
- /* Here to be overridable by box layout props such as flex */
33911
- display: inline-block;
33912
- }
33913
- .navi_list_item_group_label {
33914
- --list-group-label-background-color: var(--list-background-color);
33915
- }
33916
- .navi_list_item_header {
33917
- background: var(--list-background-color);
33918
- }
33919
- .navi_list_item_footer {
33920
- background: var(--list-background-color);
33921
- }
33895
+ fieldset.navi_list_container {
33896
+ margin: 0; /* Reset margin that might come from fieldset */
33897
+ padding: 0; /* Reset padding that might come from fieldset */
33922
33898
  }
33923
33899
 
33924
33900
  .navi_list_container {
33925
- --x-list-border-radius: var(--list-border-radius);
33926
- --x-list-border-width: var(--list-border-width);
33927
- --x-list-border-color: var(--list-border-color);
33928
- --x-list-background-color: var(--list-background-color);
33929
- /* When typing inside an input browser tries to keep caret visible */
33930
- /* For input within a sticky element inside a scrollable container */
33931
- /* Browser will try to scroll that input into view */
33932
- /* When that scrollable container has a scroll padding it causes scroll on each keystroke */
33933
- /* Even putting a scroll margin on the input won't fix */
33934
- /* The only solution is to use scroll-margins on each item that can scroll */
33935
- /* This is why these props are named list-scroll-spacing-top and applied via scroll-margin on items */
33936
- --x-list-scroll-spacing-top: calc(
33937
- var(--list-header-height, 0px) + var(--list-scroll-padding-top, 0px)
33938
- );
33939
- --x-list-scroll-spacing-bottom: calc(
33940
- var(--list-footer-height, 0px) + var(--list-scroll-padding-bottom, 0px)
33901
+ --x-list-outline-width: calc(
33902
+ var(--list-outline-width) + var(--list-border-width)
33941
33903
  );
33904
+ --x-list-outline-offset: calc(-1 * var(--list-border-width));
33942
33905
 
33943
- display: flex;
33944
- min-width: 0;
33945
- /* fit-content by default, but never wider than the parent */
33946
- max-width: 100%;
33947
- flex-direction: column;
33948
- background-color: var(--x-list-background-color);
33949
- border: var(--x-list-border-width) solid var(--x-list-border-color);
33950
- border-radius: var(--x-list-border-radius);
33951
-
33952
- transition: opacity 0.2s ease;
33953
- /* overflow:hidden is required on the container (not the inner scroll element)
33954
- so that border-radius clips the content correctly. Without it, items near
33955
- the corners would visually overflow the rounded corners during scroll. */
33956
- overflow: hidden;
33957
-
33958
- .navi_list_scroll_container {
33959
- width: inherit;
33960
- min-width: inherit;
33961
- max-width: inherit;
33962
- max-height: var(--list-max-height);
33963
- overflow: auto;
33964
- overscroll-behavior: inherit; /* inherit select behavior */
33965
- }
33906
+ outline-width: var(--x-list-outline-width);
33907
+ outline-color: var(--list-outline-color);
33908
+ outline-offset: var(--x-list-outline-offset);
33966
33909
 
33967
- &[data-expand-x] {
33968
- width: 100%;
33910
+ &[data-focus] {
33911
+ /* outline: var(--list-outline-width) solid var(--navi-focus-outline-color);
33912
+ outline-offset: calc(-1 * var(--list-outline-width)); */
33969
33913
  }
33970
- &[popover] {
33971
- position: absolute;
33972
- inset: unset;
33973
- display: none;
33974
- min-width: var(--list-anchor-width, 0px);
33975
- max-width: 95vw;
33976
- margin: 0;
33977
- padding: 0;
33978
-
33979
- &:popover-open {
33980
- display: flex;
33981
- }
33982
- .navi_list {
33983
- width: 100%;
33984
- }
33985
-
33986
- &[data-anchor-hidden] {
33987
- opacity: 0;
33988
- pointer-events: none;
33989
- }
33914
+ &[data-focus-visible] {
33915
+ outline-style: solid;
33990
33916
  }
33991
- }
33992
-
33993
- .navi_list {
33994
- display: flex;
33995
- box-sizing: border-box;
33996
- margin: 0;
33997
- padding: 0;
33998
- flex-direction: column;
33999
- list-style: none;
34000
- outline: none; /* Focus is displayed on the container */
34001
-
34002
- /* Would create scrollbars, for now just hide the loader here */
34003
- .navi_input {
34004
- .navi_loading_rectangle_wrapper {
34005
- display: none;
34006
- }
33917
+ &[data-callout] {
33918
+ --x-list-border-color: var(--callout-color);
34007
33919
  }
34008
33920
  }
34009
33921
 
34010
33922
  .navi_list_item {
34011
- --x-list-item-color: var(--list-item-color);
34012
- --x-list-item-background-color: var(--list-item-background-color);
34013
- --x-list-item-font-weight: var(--list-item-font-weight);
34014
-
34015
- box-sizing: border-box;
34016
- min-width: 0;
34017
- max-width: 100%;
34018
- padding: var(--list-item-padding);
34019
- color: var(--x-list-item-color);
34020
- font-weight: var(--x-list-item-font-weight);
34021
- background-color: var(--x-list-item-background-color);
34022
- /*
34023
- CSS impossible d'obtenir un layout qui ferait en gros:
34024
- width = max(min(max-content, 100%), unbreakable-content)
34025
- Donc 3 options:
34026
- - Laisser le contenu overflow
34027
- - moche, background ne suit pas
34028
- -> NOPE
34029
- - Force overflow hidden + ellipsis
34030
- - casse la lisibilité des mots insécables
34031
- - possible d'optin en utilisant overflowEllipsis sur le ListItem
34032
- -> Bien mais pas par défaut
34033
- - Forcer le retour a la ligne des mot inécables
34034
- - Aucun des inconvénient ci dessus
34035
- -> Comportement par défaut
34036
- */
34037
- overflow-wrap: anywhere;
34038
- /* When list has sticky header/footer, put a scroll padding */
34039
- scroll-margin-top: var(--x-list-scroll-spacing-top);
34040
- scroll-margin-bottom: var(--x-list-scroll-spacing-bottom);
34041
- }
33923
+ --x-list-item-cursor: default;
34042
33924
 
34043
- /* Virtual scroll fillers — must remain invisible.
34044
- The browser may briefly flash them during scroll before the render window
34045
- updates, so giving them a visible background would cause visual glitches. */
34046
- .navi_list_virtual_filler {
34047
- display: inline-block;
34048
- height: 0px;
34049
- list-style: none;
34050
- /* background: pink; */
34051
- }
33925
+ position: relative;
33926
+ outline-width: var(--list-item-outline-width);
33927
+ outline-color: var(--list-item-outline-color);
33928
+ outline-offset: var(--list-item-outline-offset);
33929
+ cursor: var(--x-list-item-cursor);
34052
33930
 
34053
- /* Empty state — hidden by default, shown when no list items are rendered.
34054
- order: 1 pushes fallbacks after all regular items in flex column layout.
34055
- The list children are open-ended (headers, presentation items, real items),
34056
- so we cannot control where the consumer places the fallback nodes in the DOM.
34057
- Using order ensures fallbacks always appear after items regardless of DOM order.
34058
- matchFallback intentionally shares the same order as fallback so it appears
34059
- at the same visual position — after an input if present but before any items
34060
- still displayed (non-matching items remain in DOM with hidden prop):
34061
- 1. Input (sticky header, order: -2)
34062
- 2. matchFallback (order: -1)
34063
- 3. hidden items (regular order, after DOM flow)
34064
- 4. HOT FIX OF THE DEAD for bottom filler + preact issue: order: 1
34065
- 5. sticky footer (order: 2)
34066
- */
34067
- /* order: 0 keeps the header pinned before fallbacks (order: 1) in flex order,
34068
- ensuring the header (e.g. a search input) always appears above them. */
34069
- .navi_list_item_header {
34070
- position: sticky;
34071
- top: 0;
34072
- z-index: 1;
34073
- order: -2;
34074
- }
34075
- .navi_list_fallback,
34076
- .navi_list_no_match_fallback {
34077
- order: -1;
34078
- color: light-dark(#888, #aaa);
34079
- &[navi-default] {
34080
- display: inline;
34081
- padding: var(--list-item-padding);
34082
- text-align: center;
33931
+ &[navi-selectable] {
34083
33932
  user-select: none;
34084
33933
  }
34085
- }
34086
- [navi-virtual-filler="bottom"] {
34087
- /* for some reason preact ends up puttin this element before the list items in some scenarios
34088
- I've noticed that removing the ItemIndexToScrollOnMountRefContext.Provider
34089
- does fix this issue (I suppose it's because it cause on less render of the list which is the problematic one)
34090
- this order ENSURE that even when preact hallucinates we are still correctly putting the bottom filler
34091
- after the list items */
34092
- order: 1;
34093
- }
34094
- /* order: 2 pins the footer after fallbacks (order: 1) and all items. */
34095
- .navi_list_item_footer {
34096
- position: sticky;
34097
- bottom: 0;
34098
- z-index: 1;
34099
- order: 2;
34100
- }
34101
-
34102
- ::highlight(navi-search-match) {
34103
- color: var(--list-item-color-highlight);
34104
- background-color: var(--list-item-background-color-highlight);
34105
- }
33934
+ &[navi-selectable-area-all] {
33935
+ --x-list-item-cursor: pointer;
33936
+ pointer-events: none;
34106
33937
 
34107
- /* Hide groups that have no rendered items. */
34108
- .navi_list_item_group {
34109
- min-width: 100%;
33938
+ [navi-selectable-real-input] {
33939
+ z-index: 0;
33940
+ outline: none;
33941
+ opacity: 0;
33942
+ clip-path: none;
33943
+ cursor: var(--x-list-item-cursor);
33944
+ pointer-events: auto;
33945
+ }
33946
+ }
34110
33947
 
34111
- .navi_list_item_group_label {
34112
- position: sticky;
34113
- top: 0;
34114
- z-index: 1;
34115
- display: block;
34116
- background-color: var(--list-group-label-background-color);
33948
+ &[data-interactive] {
33949
+ cursor: pointer;
34117
33950
  user-select: none;
33951
+ }
33952
+ &[data-hover] {
33953
+ --x-list-item-color: var(--list-item-color-mouse-pointed);
33954
+ --x-list-item-background-color: var(
33955
+ --list-item-background-color-mouse-pointed
33956
+ );
33957
+ }
33958
+ &[data-pointed] {
33959
+ --x-list-item-color: var(--list-item-color-pointed);
33960
+ --x-list-item-background-color: var(--list-item-background-color-pointed);
33961
+ }
33962
+ /* No input proxy: focused,selected */
33963
+ &:not(:has(input[navi-control-proxy-for])) {
33964
+ &:has([data-focus-visible]) {
33965
+ --x-list-item-color: var(--list-item-color-keyboard-pointed);
33966
+ --x-list-item-background-color: var(
33967
+ --list-item-background-color-keyboard-pointed
33968
+ );
33969
+ outline-style: solid;
34118
33970
 
34119
- &[navi-default] {
34120
- padding: 4px 12px 2px;
34121
- color: light-dark(#888, #aaa);
34122
- font-weight: 600;
34123
- font-size: 0.75em;
34124
- text-transform: uppercase;
34125
- letter-spacing: 0.05em;
33971
+ /* Selected must win over keyboard-pointed */
33972
+ &[data-selected] {
33973
+ --x-list-item-background-color: var(
33974
+ --list-item-background-color-selected,
33975
+ var(--list-item-background-color-keyboard-pointed)
33976
+ );
33977
+ }
34126
33978
  }
34127
- }
34128
- .navi_list_item_group_list {
34129
- display: flex;
34130
- width: max-content;
34131
- min-width: 100%;
34132
- margin: 0;
34133
- padding: 0;
34134
- flex-direction: column;
34135
- list-style: none;
34136
33979
 
34137
- /* Items inside a group must account for the sticky group label height
34138
- on top of the list's global header/scroll-padding spacing. */
34139
- .navi_list_item {
34140
- scroll-margin-top: calc(
34141
- var(--x-list-scroll-spacing-top) + var(--list-group-label-height, 0px)
33980
+ &[data-selected] {
33981
+ --x-list-item-border-color: var(--list-item-border-color-selected);
33982
+ --x-list-item-background-color: var(
33983
+ --list-item-background-color-selected
34142
33984
  );
33985
+ --x-list-item-color: var(--list-item-color-selected);
33986
+
33987
+ &[data-hover] {
33988
+ --x-list-item-background-color: var(
33989
+ --list-item-background-color-selected,
33990
+ var(--list-item-background-color-mouse-pointed)
33991
+ ) !important;
33992
+ }
34143
33993
  }
34144
33994
  }
34145
33995
 
34146
- &[data-hidden-while-empty]:not(:has([navi-list-item-real])) {
34147
- display: none;
33996
+ &[data-disabled] {
33997
+ --x-list-item-color: var(--list-item-color-disabled);
33998
+ --x-list-item-background-color: var(
33999
+ --list-item-background-color-disabled
34000
+ );
34001
+ --x-list-item-cursor: default;
34002
+ pointer-events: none;
34003
+ }
34004
+ &[data-readonly] {
34005
+ --x-list-item-color: var(--list-item-color-disabled);
34006
+ --x-list-item-cursor: default;
34148
34007
  }
34149
34008
  }
34150
34009
  `;
34151
-
34152
- /**
34153
- * List generic virtualized scroll container.
34154
- *
34155
- * Renders children inside a scrollable container with an optional render budget
34156
- * for virtual scrolling. Items must use <ListItem> to participate in tracking.
34157
- *
34158
- * Props:
34159
- * keyboardInteractions — when true, attaches arrow/enter/escape keyboard shortcuts
34160
- * that dispatch navi_list_nav / navi_list_confirm / navi_list_clear
34161
- * to the list container. Pair with uiAction for a full keyboard-
34162
- * navigable list.
34163
- * uiAction — called with the selected value on confirm. When provided
34164
- * the list becomes interactive: tracks hover and keyboard-
34165
- * pointed state, handles navi_list_nav / navi_list_clear /
34166
- * navi_list_confirm custom events via ListInteractionContext.
34167
- * popover — when true, renders as a managed popover positioned near
34168
- * an anchor element via navi_list_open / navi_list_close events.
34169
- * renderBudget — max items in DOM at once (default 100, virtual scroll when exceeded)
34170
- * virtualItemHeight — fixed px height per item when all items have the same height.
34171
- * Enables precise virtual-scroll filler sizing without a DOM
34172
- * measurement pass. Required when renderBudget is active and
34173
- * item height is known up-front.
34174
- * fallback — content shown when no items exist at all
34175
- * matchFallback — content shown when items exist but all are hidden (e.g. no search match)
34176
- * separator — element or function(index, { previousItem, currentItem }) inserted between visible items
34177
- * lockSize — when true, captures the container's dimensions on first render
34178
- * (always in unfiltered state). Those values become min-width/
34179
- * min-height so filtering cannot collapse the layout.
34180
- * ...rest — forwarded to the outer scroll container <Box>
34181
- */
34182
- const List = props => {
34183
- const refDefault = useRef(null);
34184
- props.ref = props.ref || refDefault;
34185
- const idDefault = useId();
34186
- props.id = props.id || idDefault;
34187
- const listVnode = jsx(ListUI, {
34010
+ const SelectableListMultipleContext = createContext(false);
34011
+ // Interactive variant: manages hover/keyboard/selection state and handles the
34012
+ // navi event protocol. When an action is provided it binds the action to ui state
34013
+ // and fires it on select. When only uiAction is provided it calls it directly.
34014
+ const ListSelectableResolver = props => {
34015
+ const Next = useNextResolver();
34016
+ if (props.selectable) {
34017
+ return jsx(ListSelectable, {
34018
+ ...props
34019
+ });
34020
+ }
34021
+ return jsx(Next, {
34188
34022
  ...props
34189
34023
  });
34190
- return listVnode;
34191
34024
  };
34192
- const ListUI = props => {
34193
- import.meta.css = [css$m, "@jsenv/navi/src/control/list/list.jsx"];
34025
+ const ListSelectable = props => {
34026
+ const Next = useNextResolver();
34027
+ import.meta.css = [css$m, "@jsenv/navi/src/control/list/list_selectable.jsx"];
34028
+ // we allow ourselves to auto-generate a name
34029
+ const defaultName = useId();
34030
+ props.name = props.name || `listbox_${defaultName}`;
34194
34031
  const {
34195
34032
  ref,
34196
- renderBudget = RENDER_BUDGET_DEFAULT,
34197
- renderBudgetSkipCheck,
34198
- role,
34199
- fallback,
34200
- noMatchFallback,
34201
- separator,
34202
- children,
34203
- popover,
34204
- expandX,
34205
- expand,
34206
- maxHeight,
34207
- onListVisibleItemsChange,
34208
- virtualItemHeight,
34209
- lockSize,
34210
- searchText,
34211
- ...rest
34033
+ multiple,
34034
+ selectedIndicator = "backgroundColor",
34035
+ focusGroupDirection,
34036
+ focusGroupWrap
34212
34037
  } = props;
34213
- if (renderBudget < 30 && !renderBudgetSkipCheck) {
34214
- console.warn(`List: renderBudget=${renderBudget} is too low. A renderBudget below 30 is not supported: on large screens or when the list grows, items outside the window would appear as blank space instead of rendered content. Use a value of at least 30, or omit the prop to use the default (${RENDER_BUDGET_DEFAULT}).`);
34215
- }
34216
-
34217
- // lockSize: capture the container's dimensions on first render so filtering
34218
- // cannot collapse the layout. Measurement happens on the initial (unfiltered)
34219
- // state because the parent controls hidden props before any search is applied.
34220
- const sizeLocked = useRef(false);
34221
- useDisplayedLayoutEffect(ref, listContainerEl => {
34222
- if (!lockSize) {
34223
- return undefined;
34224
- }
34225
- const observer = new ResizeObserver(entries => {
34226
- const entry = entries[0];
34227
- // Use borderBoxSize (outer width) not contentRect (which excludes the
34228
- // scrollbar width). If we used contentRect, min-width would be set to
34229
- // outerWidth − scrollbarWidth, and the container would shrink by exactly
34230
- // the scrollbar width when the scrollbar disappears.
34231
- const borderBoxEntry = entry.borderBoxSize ? entry.borderBoxSize[0] : null;
34232
- const width = borderBoxEntry ? borderBoxEntry.inlineSize : entry.contentRect.width;
34233
- const height = borderBoxEntry ? borderBoxEntry.blockSize : entry.contentRect.height;
34234
- if (width === 0 && height === 0) {
34235
- return;
34038
+ const [listControlProps, remainingProps, childrenWrapperProps, uiGroupStateController] = useControlgroupProps(props, {
34039
+ stateType: multiple ? "array" : "",
34040
+ controlType: multiple ? "checkbox_group" : "radio_group",
34041
+ childControlFilter: multiple ? childUIStateController => {
34042
+ return childUIStateController.controlType === "input" && childUIStateController.props.type === "checkbox";
34043
+ } : childUIStateController => {
34044
+ return childUIStateController.controlType === "input" && childUIStateController.props.type === "radio";
34045
+ },
34046
+ aggregateChildStates: multiple ? childUIStateControllers => {
34047
+ const values = [];
34048
+ for (const childUIStateController of childUIStateControllers) {
34049
+ if (childUIStateController.uiState) {
34050
+ values.push(childUIStateController.uiState);
34051
+ }
34236
34052
  }
34237
- listContainerEl.style.minWidth = `${width}px`;
34238
- listContainerEl.style.minHeight = `${height}px`;
34239
- sizeLocked.current = true;
34240
- observer.disconnect();
34241
- });
34242
- observer.observe(listContainerEl);
34243
- return () => {
34244
- observer.disconnect();
34245
- };
34246
- }, [lockSize]);
34247
- const tracker = useItemTracker({
34248
- onChange: () => {
34249
- onListVisibleItemsChange?.(tracker.visibleItemsSignal.peek());
34250
- }
34251
- });
34252
- const {
34253
- virtualItemHeightSignal,
34254
- renderWindow,
34255
- scrollToItem,
34256
- pendingScrollRef
34257
- } = useListScrollSync({
34258
- ref,
34259
- tracker,
34260
- renderBudget,
34261
- virtualItemHeight,
34262
- searchText
34263
- });
34264
- const getItemById = itemId => {
34265
- return tracker.itemsSignal.peek().find(item => item.id === itemId);
34266
- };
34267
- return jsx(Box, {
34268
- ...rest,
34269
- ref: ref,
34270
- baseClassName: "navi_list_container",
34271
- popover: popover,
34272
- "data-expand-x": expandX || expand ? "" : undefined,
34273
- expandX: expandX,
34274
- expand: expand,
34275
- maxHeight: maxHeight,
34276
- styleCSSVars: LIST_STYLE_CSS_VARS,
34277
- pseudoClasses: LIST_PSEUDO_CLASSES,
34278
- hasChildUsingForwardedProps: true,
34279
- onnavi_request_scroll: e => {
34280
- if (!Object.hasOwn(e.detail, "id")) {
34281
- console.warn(`navi_request_scroll event is missing the "id" property in its detail.`, e);
34282
- return;
34053
+ return values.length === 0 ? undefined : values;
34054
+ } : childUIStateControllers => {
34055
+ let activeValue;
34056
+ for (const childUIStateController of childUIStateControllers) {
34057
+ if (childUIStateController.uiState) {
34058
+ activeValue = childUIStateController.uiState;
34059
+ break;
34060
+ }
34283
34061
  }
34284
- const {
34285
- id
34286
- } = e.detail;
34287
- const item = getItemById(id);
34288
- scrollToItem(item, {
34289
- event: e,
34290
- reason: "navi_request_scroll"
34291
- });
34292
- },
34293
- children: jsx(ListContent, {
34294
- role: role,
34295
- fallback: fallback,
34296
- noMatchFallback: noMatchFallback,
34297
- searchText: searchText,
34298
- separator: separator,
34299
- expandX: expandX,
34300
- expand: expand,
34301
- tracker: tracker,
34302
- renderWindow: renderWindow,
34303
- virtualItemHeightSignal: virtualItemHeightSignal,
34304
- pendingScrollRef: pendingScrollRef,
34305
- children: children
34306
- })
34307
- });
34308
- };
34309
- const ListContent = ({
34310
- role,
34311
- fallback,
34312
- noMatchFallback,
34313
- searchText,
34314
- separator,
34315
- expandX,
34316
- expand,
34317
- tracker,
34318
- renderWindow,
34319
- virtualItemHeightSignal,
34320
- pendingScrollRef,
34321
- children
34322
- }) => {
34323
- const listProps = useContext(BoxForwardedPropsContext);
34324
- return jsx("div", {
34325
- className: "navi_list_scroll_container",
34326
- children: jsx(UnorderedList, {
34327
- role: role,
34328
- fallback: fallback,
34329
- noMatchFallback: noMatchFallback,
34330
- searchText: searchText,
34331
- separator: separator === true ? jsx(Separator, {
34332
- margin: "0"
34333
- }) : separator,
34334
- expandX: expandX || expand,
34335
- ...listProps,
34336
- tracker: tracker,
34337
- renderWindow: renderWindow,
34338
- virtualItemHeightSignal: virtualItemHeightSignal,
34339
- children: jsx(PendingScrollRefContext.Provider, {
34340
- value: pendingScrollRef,
34341
- children: children
34342
- })
34343
- })
34062
+ return activeValue;
34063
+ }
34344
34064
  });
34345
- };
34346
- const LIST_STYLE_CSS_VARS = {
34347
- maxHeight: "--list-max-height",
34348
- borderColor: "--list-border-color",
34349
- borderRadius: "--list-border-radius",
34350
- borderWidth: "--list-border-width"
34351
- };
34352
- const LIST_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-void", ":-navi-expanded"];
34353
- const useListScrollSync = ({
34354
- ref,
34355
- tracker,
34356
- renderBudget,
34357
- virtualItemHeight,
34358
- searchText
34359
- }) => {
34360
- const debugScroll = useDebugScroll();
34361
- const virtualItemHeightSignal = useVirtualItemHeightSignal(ref, virtualItemHeight);
34362
- const [renderWindow, setRenderWindow] = useState({
34363
- start: 0,
34364
- end: renderBudget
34065
+ useFocusGroup(ref, {
34066
+ direction: focusGroupDirection,
34067
+ wrap: focusGroupWrap,
34068
+ // Up/Down navigate between list items only (the visually-hidden real inputs).
34069
+ ySelector: "[navi-selectable-real-input]"
34365
34070
  });
34366
- const renderWindowRef = useRef(null);
34367
- renderWindowRef.current = renderWindow;
34368
- const updateRenderWindow = (newStart, newEnd, reason) => {
34369
- const {
34370
- start,
34371
- end
34372
- } = renderWindowRef.current;
34373
- if (newStart === start && newEnd === end) {
34374
- return;
34375
- }
34376
- debugScroll(`updateRenderWindow(${newStart}, ${newEnd}, "${reason}")`);
34377
- const renderWindow = {
34378
- start: newStart,
34379
- end: newEnd
34380
- };
34381
- renderWindowRef.current = renderWindow;
34382
- setRenderWindow(renderWindow);
34383
- };
34384
- const pendingScrollRef = useRef();
34385
- const scrollToItem = (item, {
34386
- event,
34387
- reason
34388
- }) => {
34389
- if (!item) {
34390
- return;
34391
- }
34392
- const items = tracker.itemsSignal.peek();
34393
- const itemCount = items.length;
34394
- if (itemCount === 0) {
34071
+
34072
+ // "Current item" tracking — the item that an external controller (e.g. an
34073
+ // <input navi-list>) navigates from. Defaults to the first selected item,
34074
+ // else the first navigable item. Updated when:
34075
+ // - an item's real input gains focus (via Tab, click, etc.)
34076
+ // - the controller dispatches navi_request_list_nav
34077
+ // The current id is announced via navi_list_current_change (bubbling) so a
34078
+ // connected input can update its aria-controls / aria-activedescendant.
34079
+ const currentIdRef = useRef(null);
34080
+ const setCurrentId = (id, event) => {
34081
+ const previousId = currentIdRef.current;
34082
+ if (previousId === id) {
34395
34083
  return;
34396
34084
  }
34397
- let index = items.findIndex(i => i.id === item.id);
34398
- if (index === -1) {
34085
+ currentIdRef.current = id;
34086
+ const listEl = ref.current;
34087
+ if (!listEl) {
34399
34088
  return;
34400
34089
  }
34401
- if (index >= itemCount) {
34402
- index = itemCount - 1;
34090
+ if (id) {
34091
+ listEl.setAttribute("navi-current-id", id);
34092
+ } else {
34093
+ listEl.removeAttribute("navi-current-id");
34403
34094
  }
34404
- const scrollItemIntoView = itemEl => {
34405
- const trigger = `"${event.type}" on ${getElementSignature(event.target)} (${reason})`;
34406
- // When we display the list we prefer to have selected item at the center
34407
- // otherwise, usually when focused by arrow nav, we want to keep it into view close to the nearest edge
34408
- const block = event.type === "navi_displayed" ? "center" : "nearest";
34409
- const scrollToItemCall = `${getElementSignature(itemEl)}.scrollIntoView({ block: "${block}", container: "nearest" })`;
34410
- const listScrollContainerEl = ref.current.querySelector(`.navi_list_scroll_container`);
34411
- debugScroll(`${trigger} -> ${scrollToItemCall}`);
34412
- scrollIntoViewScoped(itemEl, {
34413
- container: listScrollContainerEl,
34414
- block
34415
- });
34416
- const listEl = ref.current.querySelector(".navi_list");
34417
- dispatchPublicCustomEvent(listEl, "navi_scroll", {
34418
- event,
34419
- item
34420
- });
34421
- };
34422
- const {
34423
- start,
34424
- end
34425
- } = renderWindowRef.current;
34426
- const isInWindow = index >= start && index < end;
34427
- if (isInWindow) {
34428
- const itemEl = document.getElementById(item.id);
34429
- if (itemEl) {
34430
- scrollItemIntoView(itemEl);
34431
- return;
34432
- }
34095
+ dispatchPublicCustomEvent(listEl, "navi_current_change", {
34096
+ event,
34097
+ id,
34098
+ realInputId: id ? `${id}_input` : null
34099
+ });
34100
+ };
34101
+ const getNavigableElements = () => {
34102
+ const listEl = ref.current;
34103
+ if (!listEl) {
34104
+ return [];
34433
34105
  }
34434
- // Not in DOM — shift the render window. The item will read
34435
- // pendingScrollRef on mount and scroll into view.
34436
- pendingScrollRef.current = {
34437
- id: item.id,
34438
- resolve: itemEl => {
34439
- pendingScrollRef.current = null;
34440
- scrollItemIntoView(itemEl);
34106
+ const itemEls = Array.from(listEl.querySelectorAll("[navi-list-item-real]"));
34107
+ const navigableEls = [];
34108
+ for (const itemEl of itemEls) {
34109
+ if (itemEl.hidden) {
34110
+ continue;
34441
34111
  }
34442
- };
34443
- const half = Math.floor(renderBudget / 2);
34444
- const newStart = Math.max(0, index - half);
34445
- const newEnd = newStart + renderBudget;
34446
- updateRenderWindow(newStart, newEnd, `item to scroll (at ${index}) is out of render window`);
34447
- };
34448
- const currentScrollRef = useRef(null);
34449
- const updateCurrentScroll = () => {
34450
- const listScrollContainerEl = ref.current.querySelector(`.navi_list_scroll_container`);
34451
- const currentScrollLeft = listScrollContainerEl.scrollLeft;
34452
- const currentScrollTop = listScrollContainerEl.scrollTop;
34453
- const renderWindow = renderWindowRef.current;
34454
- currentScrollRef.current = {
34455
- left: currentScrollLeft,
34456
- top: currentScrollTop,
34457
- renderWindow: {
34458
- ...renderWindow
34112
+ const realInput = itemEl.querySelector("[navi-selectable-real-input]");
34113
+ if (!realInput || realInput.disabled) {
34114
+ continue;
34459
34115
  }
34460
- };
34461
- debugScroll(`store currentScroll: scrollTop=${currentScrollTop}, renderWindow=[${renderWindow.start}, ${renderWindow.end})`);
34462
- };
34463
- const searchTextRef = useRef();
34464
- let searchTextBecomesActive = false;
34465
- if (searchTextRef.current === undefined) {
34466
- searchTextRef.current = searchText;
34467
- } else {
34468
- const searchTextPrevious = searchTextRef.current;
34469
- searchTextRef.current = searchText;
34470
- if (!searchTextPrevious && searchText) {
34471
- searchTextBecomesActive = true;
34472
- }
34473
- }
34474
- // Scroll to the selected item only the FIRST time the list is presented on screen,
34475
- // so the user can see what's selected on initial open. On subsequent re-displays
34476
- // (e.g. reopening a popover containing the list), we intentionally keep the previous
34477
- // scroll position — it's less disruptive UX to land where the user last was, even
34478
- // if that means the selected item isn't currently visible.
34479
- // Skipped when inside a closed <dialog>/<details> (scrollIntoView is a no-op
34480
- // on hidden elements); re-runs automatically every time the ancestor opens.
34481
- const hasBeenDisplayedRef = useRef(false);
34482
- useDisplayedLayoutEffect(ref, (el, openEvent) => {
34483
- updateCurrentScroll();
34484
- if (hasBeenDisplayedRef.current) {
34485
- return;
34486
- }
34487
- hasBeenDisplayedRef.current = true;
34488
- const items = tracker.itemsSignal.peek();
34489
- const firstSelected = items.find(i => i.selected);
34490
- if (firstSelected) {
34491
- scrollToItem(firstSelected, {
34492
- event: new CustomEvent("navi_displayed", {
34493
- detail: {
34494
- originalEvent: openEvent
34495
- }
34496
- }),
34497
- reason: "scroll to selected"
34498
- });
34499
- } else {
34500
- scrollToItem(items[0], {
34501
- event: new CustomEvent("navi_displayed", {
34502
- detail: {
34503
- originalEvent: openEvent
34504
- }
34505
- }),
34506
- reason: "scroll to top (no selected item)"
34507
- });
34116
+ navigableEls.push(itemEl);
34508
34117
  }
34509
- }, []);
34510
- // Watch scores of the top renderBudget items.
34511
- // When scores change during an active search, scroll to top to reveal the most relevant items.
34512
- // When search becomes empty, restore the scroll position from before the search started.
34513
- // We save the first-visible item ID so restoration is item-precise
34514
- // and survives render-window shifts or item reordering.
34515
-
34516
- // NOTE POUR LE JOUR OU ON A LE MULTISELECT:
34517
- // Lorsqu'on selectionne quelque chose pendant une recherche, alors ensuite meme si on clear
34518
- // on veut pas revenir a la position scroll précédente car on veut garde l'item qu'on a selectionné visible
34519
- // (pour l'instant pas grave car on travaille pour le mode select qui fermera le dialog au select)
34520
- const savedScrollRef = useRef(null);
34521
- const topMatchScoresKeyRef = useRef("");
34118
+ return navigableEls;
34119
+ };
34120
+ // On mount: set the initial current item to the first selected, else the first navigable.
34121
+ // After that, focusin events on the list keep currentIdRef up to date.
34522
34122
  useLayoutEffect(() => {
34523
- const listScrollContainerEl = ref.current?.querySelector(`.navi_list_scroll_container`);
34524
- if (!listScrollContainerEl) {
34525
- return undefined;
34123
+ const navigableEls = getNavigableElements();
34124
+ if (navigableEls.length === 0) {
34125
+ return;
34526
34126
  }
34527
- if (!searchText) {
34528
- // no search -> try to restore scroll position
34529
- topMatchScoresKeyRef.current = "";
34530
- const savedScroll = savedScrollRef.current;
34531
- if (!savedScroll) {
34532
- // nothing to restore
34533
- return undefined;
34127
+ let initialEl;
34128
+ for (const el of navigableEls) {
34129
+ const realInput = el.querySelector("[navi-selectable-real-input]");
34130
+ if (realInput && realInput.checked) {
34131
+ initialEl = el;
34132
+ break;
34534
34133
  }
34535
- savedScrollRef.current = null;
34536
- debugScroll("Restoring scroll to", savedScroll);
34537
- updateRenderWindow(savedScroll.renderWindow.start, savedScroll.renderWindow.end, "restore scroll window");
34538
- const raf = requestAnimationFrame(() => {
34539
- const left = savedScroll.left;
34540
- const top = savedScroll.top;
34541
- // use scrollTo to respect eventual css scroll-behavior: smooth;
34542
- debugScroll(`restore scroll: ${getElementSignature(listScrollContainerEl)}.scrollTo({ left: ${left}, top: ${top} })`);
34543
- // The reliable way to restore scroll is to use scrollTop because otherwise we will estimate the item to scroll
34544
- // based on virtual item height which can wrongly restore the scroll.
34545
- // However we have a contract with outside to inside which item is scrolled
34546
- // (used by keyboard nav to enable anchoring the item for list item nav with arrow keys)
34547
- // so we do our best to give that item back
34548
- const {
34549
- item
34550
- } = getScrollInfo({
34551
- scrollTop: savedScroll.top
34552
- }, listScrollContainerEl, tracker, virtualItemHeightSignal, renderWindowRef);
34553
- listScrollContainerEl.scrollTo({
34554
- left: savedScroll.left,
34555
- top: savedScroll.top
34556
- });
34557
- const listEl = ref.current.querySelector(".navi_list");
34558
- dispatchPublicCustomEvent(listEl, "navi_scroll", {
34559
- item,
34560
- event: new CustomEvent("navi_scroll_restore")
34561
- });
34562
- });
34563
- return () => {
34564
- cancelAnimationFrame(raf);
34565
- };
34566
- }
34567
- const visibleItems = tracker.visibleItemsSignal.peek();
34568
- const topItems = visibleItems.slice(0, renderBudget);
34569
- const topMatchScoresKey = topItems.map(i => `${i.id}:${i.matchScore ?? ""}`).join(",");
34570
- const currentTopMatchScore = topMatchScoresKeyRef.current;
34571
- if (topMatchScoresKey === currentTopMatchScore) {
34572
- // no changes in top matches -> no need to scroll
34573
- return undefined;
34574
34134
  }
34575
- // n items are now more important to see, scrollTop to show them
34576
- topMatchScoresKeyRef.current = topMatchScoresKey;
34577
- if (searchTextBecomesActive) {
34578
- // search just started -> save the currently scrolled item id to restore later
34579
- const currentScroll = currentScrollRef.current;
34580
- savedScrollRef.current = currentScroll;
34581
- debugScroll(`Saving scroll: { top: ${currentScroll.top}, renderWindowStart: ${currentScroll.renderWindow.start}, renderWindowEnd: ${currentScroll.renderWindow.end} }`);
34135
+ if (!initialEl) {
34136
+ initialEl = navigableEls[0];
34582
34137
  }
34583
- // -> scroll to the top
34584
- scrollToItem(visibleItems[0], {
34585
- event: new CustomEvent("navi_list_top_match_change")
34586
- });
34587
- return undefined;
34588
- });
34138
+ setCurrentId(initialEl.id);
34139
+ }, []);
34140
+ const listVnode = jsx(Next, {
34141
+ as: "fieldset",
34142
+ "navi-has-selected-background": selectedIndicator === "backgroundColor" ? "" : undefined,
34143
+ ...listControlProps,
34144
+ ...remainingProps,
34145
+ name: undefined,
34146
+ selectedIndicator: undefined,
34147
+ multiple: undefined
34148
+ // Track focus inside the list: whichever item gets focus becomes current.
34149
+ ,
34589
34150
 
34590
- // Scroll listener — slides the window as the user scrolls.
34591
- useLayoutEffect(() => {
34592
- const listContainerEl = ref.current;
34593
- if (!listContainerEl) {
34594
- return undefined;
34595
- }
34596
- const listScrollContainerEl = listContainerEl.querySelector(`.navi_list_scroll_container`);
34597
- const listEl = listContainerEl.querySelector(".navi_list");
34598
- const onScroll = () => {
34599
- updateCurrentScroll();
34600
- const visibleItemCount = tracker.visibleCountSignal.peek();
34601
- if (visibleItemCount <= renderBudget) {
34151
+ onFocusIn: e => {
34152
+ const realInput = e.target.closest("[navi-selectable-real-input]");
34153
+ if (!realInput) {
34602
34154
  return;
34603
34155
  }
34604
- const oneRealListItemInDom = Boolean(listEl.querySelector(REAL_LIST_ITEM_SELECTOR));
34605
- if (!oneRealListItemInDom) {
34606
- return;
34156
+ const itemEl = realInput.closest("[navi-list-item-real]");
34157
+ if (itemEl && itemEl.id) {
34158
+ setCurrentId(itemEl.id, e);
34607
34159
  }
34608
- let reason = "";
34609
- const scrollInfo = getScrollInfo({
34610
- scrollTop: listScrollContainerEl.scrollTop
34611
- }, listScrollContainerEl, tracker, virtualItemHeightSignal, renderWindowRef);
34612
- if (!scrollInfo) {
34160
+ },
34161
+ onnavi_request_select: e => {
34162
+ const {
34163
+ id
34164
+ } = e.detail;
34165
+ if (id === undefined) {
34166
+ return;
34167
+ }
34168
+ const inputId = `${id}_input`;
34169
+ const childController = uiGroupStateController.findChildById(inputId);
34170
+ if (!childController) {
34171
+ return;
34172
+ }
34173
+ const list = ref.current;
34174
+ const allowed = dispatchRequestInteraction(list, e, "select");
34175
+ if (!allowed) {
34176
+ e.preventDefault();
34613
34177
  return;
34614
34178
  }
34179
+ if (childController.setUIState(true, e)) {
34180
+ dispatchRequestAction(list, {
34181
+ event: e
34182
+ });
34183
+ }
34184
+ },
34185
+ onnavi_request_unselect: e => {
34615
34186
  const {
34616
- index,
34617
- reason: hitReason
34618
- } = scrollInfo;
34619
- reason = hitReason;
34620
- const half = Math.floor(renderBudget / 2);
34621
- let newStart = Math.max(0, index - half);
34622
- let newEnd = Math.min(visibleItemCount, newStart + renderBudget);
34623
- if (newEnd === visibleItemCount) {
34624
- newStart = Math.max(0, visibleItemCount - renderBudget);
34187
+ id
34188
+ } = e.detail;
34189
+ if (id === undefined) {
34190
+ return;
34625
34191
  }
34626
- updateRenderWindow(newStart, newEnd, reason);
34627
- };
34628
- listScrollContainerEl.addEventListener("scroll", onScroll, {
34629
- passive: true
34630
- });
34631
- return () => {
34632
- listScrollContainerEl.removeEventListener("scroll", onScroll);
34633
- };
34634
- }, [renderBudget]);
34635
- return {
34636
- virtualItemHeightSignal,
34637
- renderWindow,
34638
- pendingScrollRef,
34639
- scrollToItem
34640
- };
34641
- };
34642
- // Returns the item located at the current scroll position of a list container.
34643
- // Uses DOM hit-testing to find visible items/fillers; falls back to index
34644
- // estimation via virtualItemHeight or renderWindow.start.
34645
- // Returns { index, item, reason } or null if nothing can be determined.
34646
- const getScrollInfo = ({
34647
- scrollTop
34648
- }, listScrollContainerEl, tracker, virtualItemHeightSignal, renderWindowRef) => {
34649
- const listEl = listScrollContainerEl.querySelector(".navi_list");
34650
- const items = tracker.itemsSignal.peek();
34651
- const containerRect = listScrollContainerEl.getBoundingClientRect();
34652
- let hitEl = null;
34653
- let hitFiller = null;
34654
- // Start scanning from the vertical center of the viewport rather than the top.
34655
- // The render window places half its budget above and half below the hit index.
34656
- // Anchoring to the center maximises how many rendered items fall within the
34657
- // visible area: starting from the top would waste the "above" budget on items
34658
- // already scrolled past, leaving the bottom of the viewport uncovered.
34659
- // For large lists where renderBudget >> visible item count this never matters
34660
- // in practice (the window always covers the whole viewport), but it is
34661
- // strictly better and costs nothing.
34662
- const scanStartY = (containerRect.top + containerRect.bottom) / 2;
34663
- for (let y = scanStartY; y < containerRect.bottom; y += 4) {
34664
- const el = document.elementFromPoint(containerRect.left + 1, y);
34665
- if (!el || !listEl.contains(el)) {
34666
- continue;
34667
- }
34668
- const realItem = el.closest(REAL_LIST_ITEM_SELECTOR);
34669
- if (realItem) {
34670
- hitEl = realItem;
34671
- break;
34672
- }
34673
- const filler = el.closest("[navi-virtual-filler]");
34674
- if (filler) {
34675
- hitFiller = filler;
34676
- break;
34677
- }
34678
- }
34679
- if (hitFiller) {
34680
- const virtualItemHeight = virtualItemHeightSignal.peek();
34681
- if (virtualItemHeight === 0) {
34682
- return null;
34683
- }
34684
- const estimatedIndex = Math.floor(scrollTop / virtualItemHeight);
34685
- const index = Math.min(items.length - 1, estimatedIndex);
34686
- return {
34687
- item: items[index],
34688
- index,
34689
- reason: `hit filler, estimated at ${index} (${items[index]?.value})`
34690
- };
34691
- }
34692
- if (hitEl) {
34693
- const hitId = hitEl.id;
34694
- const index = items.findIndex(i => i.id === hitId);
34695
- if (index === -1) {
34696
- return null;
34697
- }
34698
- return {
34699
- item: items[index],
34700
- index,
34701
- reason: `hit item at ${index} (${items[index].value})`
34702
- };
34703
- }
34704
- const fallbackIndex = renderWindowRef.current.start;
34705
- return {
34706
- item: items[fallbackIndex],
34707
- index: fallbackIndex,
34708
- reason: "no hit"
34709
- };
34192
+ const inputId = `${id}_input`;
34193
+ const childController = uiGroupStateController.findChildById(inputId);
34194
+ if (!childController) {
34195
+ return;
34196
+ }
34197
+ const list = ref.current;
34198
+ const allowed = dispatchRequestInteraction(list, e, "unselect");
34199
+ if (!allowed) {
34200
+ e.preventDefault();
34201
+ return;
34202
+ }
34203
+ if (childController.setUIState(false, e)) {
34204
+ dispatchRequestAction(list, {
34205
+ event: e
34206
+ });
34207
+ }
34208
+ },
34209
+ onnavi_request_nav: e => {
34210
+ const {
34211
+ goal
34212
+ } = e.detail;
34213
+ const navigableEls = getNavigableElements();
34214
+ if (navigableEls.length === 0) {
34215
+ return;
34216
+ }
34217
+ const currentId = currentIdRef.current;
34218
+ let currentIndex = -1;
34219
+ if (currentId) {
34220
+ currentIndex = navigableEls.findIndex(el => el.id === currentId);
34221
+ }
34222
+ let targetEl;
34223
+ if (goal === "first") {
34224
+ targetEl = navigableEls[0];
34225
+ } else if (goal === "last") {
34226
+ targetEl = navigableEls[navigableEls.length - 1];
34227
+ } else if (goal === "down") {
34228
+ if (currentIndex === -1) {
34229
+ targetEl = navigableEls[0];
34230
+ } else if (currentIndex < navigableEls.length - 1) {
34231
+ targetEl = navigableEls[currentIndex + 1];
34232
+ } else {
34233
+ targetEl = navigableEls[navigableEls.length - 1];
34234
+ }
34235
+ } else if (goal === "up") {
34236
+ if (currentIndex === -1) {
34237
+ targetEl = navigableEls[0];
34238
+ } else if (currentIndex > 0) {
34239
+ targetEl = navigableEls[currentIndex - 1];
34240
+ } else {
34241
+ targetEl = navigableEls[0];
34242
+ }
34243
+ }
34244
+ if (!targetEl) {
34245
+ return;
34246
+ }
34247
+ setCurrentId(targetEl.id, e);
34248
+ dispatchCustomEvent(ref.current, "navi_request_scroll", {
34249
+ event: e,
34250
+ id: targetEl.id
34251
+ });
34252
+ },
34253
+ onnavi_request_activate: e => {
34254
+ const currentId = currentIdRef.current;
34255
+ if (!currentId) {
34256
+ return;
34257
+ }
34258
+ if (multiple) {
34259
+ const inputId = `${currentId}_input`;
34260
+ const childController = uiGroupStateController.findChildById(inputId);
34261
+ const isSelected = childController && childController.uiState;
34262
+ dispatchCustomEvent(ref.current, isSelected ? "navi_request_unselect" : "navi_request_select", {
34263
+ event: e,
34264
+ id: currentId
34265
+ });
34266
+ return;
34267
+ }
34268
+ dispatchCustomEvent(ref.current, "navi_request_select", {
34269
+ event: e,
34270
+ id: currentId
34271
+ });
34272
+ },
34273
+ children: jsx(ControlgroupChildrenWrapper, {
34274
+ ...childrenWrapperProps,
34275
+ children: props.children
34276
+ })
34277
+ });
34278
+ return jsx(SelectableListMultipleContext.Provider, {
34279
+ value: multiple,
34280
+ children: listVnode
34281
+ });
34710
34282
  };
34711
- const useVirtualItemHeightSignal = (ref, virtualItemHeightProp = 0) => {
34712
- const virtualHeightSignalRef = useRef(null);
34713
- if (!virtualHeightSignalRef.current) {
34714
- virtualHeightSignalRef.current = signal(virtualItemHeightProp);
34715
- }
34716
- const virtualHeightSignal = virtualHeightSignalRef.current;
34717
- // propagate prop changes to the signal
34718
- if (virtualItemHeightProp && virtualHeightSignal.peek() !== virtualItemHeightProp) {
34719
- virtualHeightSignal.value = virtualItemHeightProp;
34283
+ const SelectableRealInputContext = createContext(null);
34284
+ const ListItemSelectableResolver = props => {
34285
+ const Next = useNextResolver();
34286
+ if (props.selectable) {
34287
+ return jsx(ListItemSelectable, {
34288
+ ...props
34289
+ });
34720
34290
  }
34721
- useLayoutEffect(() => {
34722
- if (virtualHeightSignal.peek() !== 0) {
34723
- return;
34724
- }
34725
- const listEl = ref.current?.querySelector(".navi_list");
34726
- if (!listEl) {
34727
- return;
34728
- }
34729
- const firstListItem = listEl.querySelector(REAL_LIST_ITEM_SELECTOR);
34730
- if (!firstListItem) {
34731
- return;
34732
- }
34733
- const measuredHeight = firstListItem.getBoundingClientRect().height;
34734
- virtualHeightSignal.value = measuredHeight;
34291
+ return jsx(Next, {
34292
+ ...props
34735
34293
  });
34736
- return virtualHeightSignal;
34737
34294
  };
34738
-
34739
- // Inner <ul> — hosts the fillers + items.
34740
- // Creates a virtualItemHeight signal so TopFiller and BottomFiller can
34741
- // subscribe to it independently. When virtualItemHeight is passed as a prop it
34742
- // initialises the signal directly; otherwise UnorderedList measures a rendered
34743
- // item after each commit and writes to the signal, causing only the fillers to
34744
- // re-render.
34745
- const UnorderedList = ({
34746
- tracker,
34747
- renderWindow,
34748
- virtualItemHeightSignal,
34749
- fallback,
34750
- noMatchFallback,
34751
- searchText,
34752
- separator,
34753
- children,
34754
- ...rest
34755
- }) => {
34756
- return jsxs(Box, {
34757
- as: "ul",
34295
+ const ListItemSelectable = props => {
34296
+ const Next = useNextResolver();
34297
+ const {
34298
+ index,
34299
+ id,
34300
+ highlight,
34301
+ hidden,
34302
+ filtered,
34303
+ matchScore,
34304
+ defaultSelected,
34305
+ selected,
34306
+ pointed,
34307
+ selectableArea = "all",
34308
+ ...rest
34309
+ } = props;
34310
+ const multiple = useContext(SelectableListMultipleContext);
34311
+ const inputRef = useRef();
34312
+ const inputType = multiple ? "checkbox" : "radio";
34313
+ const inputId = `${id}_input`;
34314
+ inputRef.nullCanHappen = true; // virtualization
34315
+ const [checkableProps, remainingProps, ChildrenContextWrapper] = useCheckableProps({
34316
+ readOnlyMessage: naviI18n(`constraint.readonly.option`, props),
34758
34317
  ...rest,
34759
- baseClassName: "navi_list",
34760
- children: [jsx(TopFiller, {
34761
- virtualItemHeightSignal: virtualItemHeightSignal,
34762
- renderWindowStart: renderWindow.start
34763
- }), jsx(NoMatchFallback, {
34764
- noMatchFallback: noMatchFallback,
34765
- tracker: tracker,
34766
- searchText: searchText
34767
- }), jsx(Fallback, {
34768
- fallback: fallback,
34769
- tracker: tracker
34770
- }), jsx(RenderWindowContext.Provider, {
34771
- value: renderWindow,
34772
- children: jsx(SeparatorContext.Provider, {
34773
- value: separator ?? null,
34774
- children: jsx(ListItemTrackerContext.Provider, {
34775
- value: tracker,
34776
- children: children
34777
- })
34318
+ ref: inputRef,
34319
+ id: inputId,
34320
+ type: inputType,
34321
+ defaultChecked: defaultSelected,
34322
+ checked: selected,
34323
+ action: (v, {
34324
+ event
34325
+ }) => {
34326
+ const listContainerEl = event.currentTarget.closest(".navi_list_container");
34327
+ dispatchRequestAction(listContainerEl, {
34328
+ event
34329
+ });
34330
+ }
34331
+ });
34332
+ const {
34333
+ checked,
34334
+ value,
34335
+ basePseudoState,
34336
+ children
34337
+ } = checkableProps;
34338
+ const readOnly = basePseudoState[":read-only"];
34339
+ // const disabled = basePseudoState[":disabled"];
34340
+ // const loading = basePseudoState[":-navi-loading"];
34341
+ const realInputContextValue = useMemo(() => {
34342
+ return {
34343
+ id: inputId,
34344
+ type: inputType,
34345
+ checked,
34346
+ readOnly,
34347
+ value
34348
+ };
34349
+ }, [inputId, inputType, checked, readOnly, value]);
34350
+ return jsxs(Next, {
34351
+ id: id,
34352
+ index: index,
34353
+ highlight: highlight,
34354
+ filtered: filtered,
34355
+ hidden: hidden,
34356
+ matchScore: matchScore,
34357
+ "aria-selected": checked,
34358
+ selected: checked,
34359
+ "navi-selectable": "",
34360
+ padding: "m",
34361
+ spacing: "s",
34362
+ flex: true,
34363
+ alignY: "center",
34364
+ ...remainingProps,
34365
+ pseudoClasses: SELECTABLE_PSEUDO_CLASSES,
34366
+ basePseudoState: {
34367
+ ":-navi-selected": checked,
34368
+ ":-navi-pointed": pointed,
34369
+ ...basePseudoState
34370
+ },
34371
+ ref: props.ref,
34372
+ selectable: undefined,
34373
+ "navi-selectable-area-all": selectableArea === "all" ? "" : undefined,
34374
+ children: [jsx(SelectableRealInput, {
34375
+ ...checkableProps,
34376
+ // eslint-disable-next-line react/no-children-prop
34377
+ children: undefined
34378
+ }), jsx(SelectableRealInputContext.Provider, {
34379
+ value: realInputContextValue,
34380
+ children: jsx(ChildrenContextWrapper, {
34381
+ children: children
34778
34382
  })
34779
- }), jsx(BottomFiller, {
34780
- virtualItemHeightSignal: virtualItemHeightSignal,
34781
- renderWindowEnd: renderWindow.end,
34782
- tracker: tracker
34783
34383
  })]
34784
34384
  });
34785
34385
  };
34786
- const NoMatchFallback = ({
34787
- tracker,
34788
- noMatchFallback,
34789
- searchText
34790
- }) => {
34791
- const itemCount = tracker.countSignal.value;
34792
- const visibleItemCount = tracker.visibleCountSignal.value;
34793
- const matchCount = tracker.matchCountSignal.value;
34794
- // Show when all items are filtered out (filtered prop), or when search is
34795
- // active but no visible item has a positive match score.
34796
- const allHidden = itemCount > 0 && visibleItemCount === 0;
34797
- const noneMatch = searchText && visibleItemCount > 0 && matchCount === 0;
34798
- const showMatchFallback = allHidden || noneMatch;
34799
- if (noMatchFallback === undefined) {
34800
- noMatchFallback = allHidden ? naviI18n("list.no_match") : naviI18n("list.no_match_rest_shown");
34801
- }
34802
- if (!showMatchFallback) {
34803
- return null;
34804
- }
34805
- return jsx(ListItem, {
34806
- role: "presentation",
34807
- className: "navi_list_item navi_list_no_match_fallback",
34808
- hidden: !showMatchFallback,
34809
- "navi-default": typeof noMatchFallback === "string" ? "" : undefined,
34810
- children: noMatchFallback
34811
- });
34812
- };
34813
- const Fallback = ({
34814
- tracker,
34815
- fallback
34816
- }) => {
34817
- const itemCount = tracker.countSignal.value;
34818
- const showFallback = itemCount === 0;
34819
- if (fallback === undefined) {
34820
- fallback = naviI18n("list.empty");
34821
- }
34822
- if (!showFallback) {
34823
- return null;
34824
- }
34825
- return jsx(ListItem, {
34826
- role: "presentation",
34827
- className: "navi_list_item navi_list_fallback",
34828
- hidden: !showFallback,
34829
- "navi-default": typeof fallback === "string" ? "" : undefined,
34830
- children: fallback
34386
+ const SELECTABLE_PSEUDO_CLASSES = [":hover", ":disabled", ":read-only", ":focus-within", ":focus", ":focus-visible", ":-navi-loading", ":-navi-pointed", ":-navi-selected", ":disabled", ":read-only"];
34387
+ const SelectableRealInput = props => {
34388
+ // here for some reason we can't use <Input, so instead we use <Box
34389
+ // ideally we could use <Input but it would interfere with the control props we already create
34390
+ // in the ListItemSelectable
34391
+ return jsx(Box, {
34392
+ as: "input",
34393
+ pseudoClasses: SELECTABLE_INPUT_PSEUDO_CLASSES,
34394
+ ...props,
34395
+ "navi-visually-hidden": "",
34396
+ "navi-selectable-real-input": "",
34397
+ "data-callout-arrow-x": "center"
34398
+ // navi-debug
34831
34399
  });
34832
34400
  };
34833
- const TopFiller = ({
34834
- virtualItemHeightSignal,
34835
- renderWindowStart
34836
- }) => {
34837
- const virtualItemHeight = virtualItemHeightSignal.value;
34838
- const numberOfItemsAbove = renderWindowStart;
34839
- const heightToFillAbove = numberOfItemsAbove * virtualItemHeight;
34840
- if (!heightToFillAbove) {
34841
- return null;
34401
+ const SELECTABLE_INPUT_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":checked"];
34402
+ const SelectableInputProxy = props => {
34403
+ const selectableRealInputProps = useContext(SelectableRealInputContext);
34404
+ if (!selectableRealInputProps) {
34405
+ throw new Error("Selectable.Input must be used within a Selectable component");
34842
34406
  }
34843
- return jsx("li", {
34844
- className: "navi_list_virtual_filler"
34845
- // eslint-disable-next-line react/no-unknown-property
34846
- ,
34847
34407
 
34848
- "navi-virtual-filler": "top",
34849
- "aria-hidden": true,
34850
- style: {
34851
- height: `${heightToFillAbove}px`
34852
- }
34853
- });
34854
- };
34855
- const BottomFiller = ({
34856
- virtualItemHeightSignal,
34857
- renderWindowEnd,
34858
- tracker
34859
- }) => {
34860
- const visibleItemCount = tracker.visibleCountSignal.value;
34861
- const virtualItemHeight = virtualItemHeightSignal.value;
34862
- const numberOfItemsBelow = Math.max(visibleItemCount - renderWindowEnd, 0);
34863
- const heightToFillBelow = numberOfItemsBelow * virtualItemHeight;
34864
- if (!heightToFillBelow) {
34865
- return null;
34866
- }
34867
- return jsx("li", {
34868
- className: "navi_list_virtual_filler"
34869
- // eslint-disable-next-line react/no-unknown-property
34870
- ,
34408
+ // Reset FieldToInterfaceContext to ensure we don't read id or report our
34409
+ // states (real input should take id and report)
34410
+ return jsx(ControlToInterfaceContext.Provider, {
34411
+ value: undefined,
34412
+ children: jsx(Input, {
34413
+ ...props,
34414
+ ...selectableRealInputProps,
34415
+ id: undefined,
34416
+ "navi-control-proxy-for": selectableRealInputProps.id
34417
+ // give it a specific name to avoid radio name (would unselect others)
34418
+ // (making it unique to the list would be enough, but here it's even more unique)
34419
+ ,
34871
34420
 
34872
- "navi-virtual-filler": "bottom",
34873
- "aria-hidden": true,
34874
- style: {
34875
- height: `${heightToFillBelow}px`
34876
- }
34421
+ name: `${selectableRealInputProps.id}_proxy`,
34422
+ "aria-hidden": "true",
34423
+ tabIndex: -1
34424
+ })
34877
34425
  });
34878
34426
  };
34427
+ const SelectableInput = SelectableInputProxy;
34879
34428
 
34880
34429
  /**
34881
- * ListItem a trackable item that participates in virtualization.
34430
+ * CSS Highlight API integration for navi-search-match.
34882
34431
  *
34883
- * Must be used inside <List>. Handles:
34884
- * - Registration with item tracker (always runs, even when hidden)
34885
- * - Early return when outside the render window
34886
- * - Separator rendering between visible items
34432
+ * Provides a shared `Highlight` instance registered as "navi-search-match"
34433
+ * and a `useSearchHighlight` hook that marks matching text ranges on an element.
34887
34434
  *
34888
- * Props:
34889
- * itemId — stable string id for tracking (auto-generated if omitted)
34890
- * filtered — when true, item is excluded from visible count and removed from DOM entirely
34891
- * hidden — when true, item is excluded from visible count (no virtual scroll height)
34892
- * but stays in DOM with the native HTML hidden attribute
34893
- * highlight — array of [start, end] ranges to highlight via CSS Highlight API
34894
- * ...rest — forwarded to the rendered <li> element
34435
+ * The highlight is not specific to ListItem — any element that wants to mark
34436
+ * search match ranges (e.g. Suggestion, custom search results) can use these.
34437
+ *
34438
+ * CSS to paint the highlight:
34439
+ * ::highlight(navi-search-match) {
34440
+ * color: var(--search-match-color);
34441
+ * background-color: var(--search-match-background-color);
34442
+ * }
34443
+ *
34444
+ * The `highlight` prop can be:
34445
+ * - an array of [start, end] pairs — applied to all text nodes under the root element
34446
+ * - an object { [domSelector]: [[start, end], …] } — applied to each sub-element
34447
+ * matched by the selector (the format produced by createSearch)
34895
34448
  */
34896
- const ListItem = props => {
34897
- if (props.role === "presentation") {
34898
- return jsx(ListItemPresentation, {
34899
- ...props
34900
- });
34449
+
34450
+ // Module-level shared Highlight instance — created lazily once.
34451
+ let naviSearchHighlight = null;
34452
+ const getNaviSearchHighlight = () => {
34453
+ if (!CSS.highlights) {
34454
+ return null;
34901
34455
  }
34902
- return jsx(ListItemRealOrVoid, {
34903
- ...props
34904
- });
34456
+ if (!naviSearchHighlight) {
34457
+ naviSearchHighlight = new Highlight();
34458
+ CSS.highlights.set("navi-search-match", naviSearchHighlight);
34459
+ }
34460
+ return naviSearchHighlight;
34905
34461
  };
34906
- const ListItemPresentation = props => {
34907
- return jsx(Box, {
34908
- as: "li",
34909
- ...props
34910
- });
34462
+
34463
+ /**
34464
+ * useSearchHighlight — registers/unregisters CSS Highlight API ranges for an element.
34465
+ *
34466
+ * @param {import("preact").RefObject} ref - ref to the root element
34467
+ * @param {Array|Object|null|undefined} highlight - ranges to highlight
34468
+ * @param {Array} deps - additional dependencies that should retrigger the effect
34469
+ * (typically [children, hidden] from the consuming component)
34470
+ */
34471
+ const useSearchHighlight = (ref, highlight, deps = []) => {
34472
+ useLayoutEffect(() => {
34473
+ const el = ref.current;
34474
+ if (!el || !highlight) {
34475
+ return undefined;
34476
+ }
34477
+ return applySearchHighlight(el, highlight);
34478
+ }, [highlight, ...deps]);
34911
34479
  };
34912
- const ListItemRealOrVoid = props => {
34913
- if (props.id === undefined) {
34914
- console.warn("ListItem is missing an explicit id prop. Provide a stable id so pointed/selected state survives search reordering.");
34480
+
34481
+ const applySearchHighlight = (el, highlight) => {
34482
+ const hl = getNaviSearchHighlight();
34483
+ if (!hl) {
34484
+ return undefined;
34915
34485
  }
34916
- if (props.index === undefined) {
34917
- console.warn("ListItem is missing an explicit index prop. Provide an index so item ordering is stable regardless of render order.");
34486
+
34487
+ // Normalise highlight to { root, ranges }[] entries.
34488
+ // Flat array → single entry scoped to the whole element.
34489
+ const entries = Array.isArray(highlight)
34490
+ ? highlight.length === 0
34491
+ ? []
34492
+ : [{ root: el, ranges: highlight }]
34493
+ : Object.entries(highlight).map(([selector, ranges]) => ({
34494
+ root: el.querySelector(selector) ?? el,
34495
+ ranges,
34496
+ }));
34497
+
34498
+ if (entries.length === 0) {
34499
+ return undefined;
34918
34500
  }
34919
- const idDefault = useId();
34920
- props.id = props.id || idDefault;
34921
- const renderWindow = useContext(RenderWindowContext);
34922
- const tracker = useContext(ListItemTrackerContext);
34923
- const item = props;
34924
- const visibleIndex = tracker.useTrackItem(item);
34925
- const groupTracker = useContext(GroupItemTrackerContext);
34926
- const groupVisibleIndex = groupTracker ? groupTracker.useTrackItem(item) : null;
34927
- const separator = useContext(SeparatorContext);
34928
- if (props.filtered) {
34929
- return null;
34930
- }
34931
- // html-hidden items: excluded from virtual scroll accounting but always in DOM
34932
- if (props.hidden) {
34933
- return jsx(ListItemReal, {
34934
- ...props
34501
+
34502
+ const ownRanges = [];
34503
+ for (const { root, ranges } of entries) {
34504
+ // Collect text nodes under root with cumulative character offsets so that
34505
+ // [start, end] ranges (positions in the field string) map directly to
34506
+ // the correct DOM text node positions without re-searching.
34507
+ const textNodes = [];
34508
+ let totalLength = 0;
34509
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
34510
+ acceptNode: (node) => {
34511
+ // Skip text nodes inside aria-hidden elements (icons, decorative content, etc.)
34512
+ let parent = node.parentElement;
34513
+ while (parent && parent !== root) {
34514
+ if (parent.getAttribute("aria-hidden") === "true") {
34515
+ return NodeFilter.FILTER_REJECT;
34516
+ }
34517
+ parent = parent.parentElement;
34518
+ }
34519
+ return NodeFilter.FILTER_ACCEPT;
34520
+ },
34935
34521
  });
34936
- }
34937
- if (visibleIndex === -1) {
34938
- return null;
34939
- }
34940
- if (visibleIndex < renderWindow.start || visibleIndex >= renderWindow.end) {
34941
- return jsx(ListItemVoid, {});
34942
- }
34943
- const listItemVnode = jsx(ListItemReal, {
34944
- ...props
34945
- });
34946
- // For separator decision, we need to know "am I the first visible item?".
34947
- // We deliberately do NOT use tracker's visibleIndex here because, during a
34948
- // reorder render pass (e.g. items resorted by search score), other items
34949
- // still have stale keyToExplicitOrder values — the binary search reads
34950
- // those stale values and computes wrong indices. The result is that no
34951
- // item gets visibleIndex === 0 and a spurious <hr> appears at the top.
34952
- //
34953
- // Instead we use the parent-provided index, which is race-free:
34954
- // - global list: props.index === 0 means "first by explicit order"
34955
- // (parent passes sequential indices starting at 0; filtered items
34956
- // are already pushed to the end by useSearchText)
34957
- // - inside a group: each group has its own item tracker and group
34958
- // items don't reorder, so groupVisibleIndex is reliable
34959
- const isFirstInList = groupVisibleIndex === null ? props.index === 0 : groupVisibleIndex === 0;
34960
- if (!separator || isFirstInList) {
34961
- return listItemVnode;
34962
- }
34963
- // separatorIndex is only used as the function-form argument (gap index)
34964
- const separatorIndex = groupVisibleIndex === null ? visibleIndex : groupVisibleIndex;
34965
- const separatorVnode = typeof separator === "function" ? separator(separatorIndex - 1) : separator;
34966
- return jsxs(Fragment$1, {
34967
- children: [separatorVnode, listItemVnode]
34968
- });
34969
- };
34970
- // When an item is outside the render window it cannot render a DOM node.
34971
- // If it wants to scroll into view it sets scrollTop so the scroll event
34972
- // shifts the window; once the item mounts as ListItemReal its layout effect
34973
- // calls scrollIntoViewWithStickyAwareness to fine-tune the position.
34974
- const ListItemVoid = () => {
34975
- return null;
34976
- };
34977
- const ListItemReal = props => {
34978
- const defaultRef = useRef(null);
34979
- props.ref = props.ref || defaultRef;
34980
- const {
34981
- ref,
34982
- id,
34983
- hidden,
34984
- highlight,
34985
- children,
34986
- ...rest
34987
- } = props;
34988
- const pendingScrollRef = useContext(PendingScrollRefContext);
34989
- const pendingScroll = pendingScrollRef.current;
34990
- const needScrollOnMount = pendingScroll && pendingScroll.id === id;
34991
- useLayoutEffect(() => {
34992
- if (!needScrollOnMount) {
34993
- return;
34522
+ let node;
34523
+ while ((node = walker.nextNode())) {
34524
+ textNodes.push({ node, offset: totalLength });
34525
+ totalLength += node.textContent.length;
34994
34526
  }
34995
- const itemEl = ref.current;
34996
- if (!itemEl) {
34997
- return;
34527
+ for (const [start, end] of ranges) {
34528
+ for (const { node: textNode, offset: nodeOffset } of textNodes) {
34529
+ const nodeEnd = nodeOffset + textNode.textContent.length;
34530
+ if (nodeEnd <= start || nodeOffset >= end) {
34531
+ continue;
34532
+ }
34533
+ const rangeStart = start - nodeOffset;
34534
+ const rangeEnd = end - nodeOffset;
34535
+ const range = new Range();
34536
+ range.setStart(textNode, rangeStart < 0 ? 0 : rangeStart);
34537
+ range.setEnd(
34538
+ textNode,
34539
+ rangeEnd > textNode.textContent.length
34540
+ ? textNode.textContent.length
34541
+ : rangeEnd,
34542
+ );
34543
+ hl.add(range);
34544
+ ownRanges.push(range);
34545
+ }
34998
34546
  }
34999
- pendingScroll.resolve(itemEl);
35000
- }, [needScrollOnMount]);
35001
-
35002
- // CSS Highlight API: mark matching text ranges when highlight prop is set.
35003
- useSearchHighlight(ref, highlight, [children, hidden]);
35004
- return jsx(Box, {
35005
- as: "li",
35006
- baseClassName: "navi_list_item",
35007
- styleCSSVars: LIST_ITEM_STYLE_CSS_VARS,
35008
- pseudoClasses: LIST_ITEM_PSEUDO_CLASSES,
35009
- pseudoElements: LIST_ITEM_PSEUDO_ELEMENTS,
35010
- id: id,
35011
- "navi-list-item-real": "",
35012
- ...rest,
35013
- index: undefined,
35014
- selected: undefined,
35015
- matchScore: undefined,
35016
- hidden: hidden,
35017
- ref: ref,
35018
- children: children
35019
- });
35020
- };
35021
- const LIST_ITEM_STYLE_CSS_VARS = {
35022
- "paddingX": "--list-item-padding-x",
35023
- "paddingY": "--list-item-padding-y",
35024
- "padding": "--list-item-padding",
35025
- "color": "--list-item-color",
35026
- "backgroundColor": "--list-item-background-color",
35027
- "fontWeight": "--list-item-font-weight",
35028
- ":-navi-pointed": {
35029
- color: "--list-item-color-keyboard-pointed",
35030
- backgroundColor: "--list-item-background-color-keyboard-pointed"
35031
- },
35032
- ":hover": {
35033
- color: "--list-item-color-hover",
35034
- backgroundColor: "--list-item-background-color-hover"
35035
- },
35036
- ":-navi-selected": {
35037
- color: "--list-item-color-selected",
35038
- backgroundColor: "--list-item-background-color-selected"
35039
- },
35040
- ":disabled": {
35041
- color: "--list-item-color-disabled",
35042
- backgroundColor: "--list-item-background-color-disabled"
35043
- },
35044
- "::highlight": {
35045
- color: "--suggestion-color-highlight",
35046
- backgroundColor: "--suggestion-background-color-highlight"
35047
34547
  }
34548
+ return () => {
34549
+ for (const range of ownRanges) {
34550
+ hl.delete(range);
34551
+ }
34552
+ };
35048
34553
  };
35049
- const LIST_ITEM_PSEUDO_CLASSES = [];
35050
- const LIST_ITEM_PSEUDO_ELEMENTS = ["::highlight"];
35051
34554
 
35052
- /**
35053
- * ListGroup — a labeled group of list items.
35054
- *
35055
- * Renders a <li role="presentation"> wrapper containing a label span
35056
- * (accessible via aria-labelledby) and a <ul role="group"> for the items.
35057
- *
35058
- * Props:
35059
- * label — group label content
35060
- * labelProps — props forwarded to the label <span>
35061
- * ...rest — forwarded to the outer <li role="presentation">
35062
- */
35063
- const ListItemGroup = ({
35064
- label,
35065
- hiddenWhileEmpty,
35066
- children,
35067
- ...rest
35068
- }) => {
35069
- const groupId = useId();
35070
- const groupTracker = useItemTracker();
35071
- const groupRef = useRef(null);
35072
- const labelRef = useRef(null);
35073
- useDisplayedLayoutEffect(labelRef, labelEl => {
35074
- const groupEl = groupRef.current;
35075
- if (!groupEl) {
35076
- return;
35077
- }
35078
- const labelHeight = labelEl.getBoundingClientRect().height;
35079
- groupEl.style.setProperty("--list-group-label-height", `${labelHeight}px`);
35080
- }, []);
35081
- return jsxs(ListItem, {
35082
- ...rest,
35083
- ref: groupRef,
35084
- baseClassName: "navi_list_item_group",
35085
- role: "presentation",
35086
- "data-hidden-while-empty": hiddenWhileEmpty ? "" : undefined,
35087
- children: [jsx("span", {
35088
- ref: labelRef,
35089
- id: groupId,
35090
- className: "navi_list_item_group_label",
35091
- role: "presentation"
35092
- // eslint-disable-next-line react/no-unknown-property
35093
- ,
34555
+ installImportMetaCssBuild(import.meta);const ListItemTrackerContext = createContext(null);
34556
+ const GroupItemTrackerContext = createContext(null);
34557
+ const PendingScrollRefContext = createContext(null);
35094
34558
 
35095
- "navi-default": typeof label === "string" ? "" : undefined,
35096
- children: label
35097
- }), jsx("ul", {
35098
- className: "navi_list_item_group_list",
35099
- role: "group",
35100
- "aria-labelledby": groupId,
35101
- children: jsx(GroupItemTrackerContext.Provider, {
35102
- value: groupTracker,
35103
- children: children
35104
- })
35105
- })]
35106
- });
35107
- };
35108
- const ListItemHeader = props => {
35109
- const defaultRef = useRef(null);
35110
- const ref = props.ref || defaultRef;
35111
- useDisplayedLayoutEffect(ref, headerEl => {
35112
- const listContainerEl = headerEl.closest(".navi_list_container");
35113
- const headerHeight = headerEl.getBoundingClientRect().height;
35114
- listContainerEl.style.setProperty("--list-header-height", `${headerHeight}px`);
35115
- }, []);
35116
- return jsx(ListItem, {
35117
- ...props,
35118
- ref: ref,
35119
- role: "presentation",
35120
- baseClassName: "navi_list_item_header"
35121
- });
35122
- };
35123
- const ListItemFooter = props => {
35124
- const defaultRef = useRef(null);
35125
- const ref = props.ref || defaultRef;
35126
- useDisplayedLayoutEffect(ref, headerEl => {
35127
- const listContainerEl = headerEl.closest(".navi_list_container");
35128
- const headerHeight = headerEl.getBoundingClientRect().height;
35129
- listContainerEl.style.setProperty("--list-footer-height", `${headerHeight}px`);
35130
- }, []);
35131
- return jsx(ListItem, {
35132
- ...props,
35133
- ref: ref,
35134
- role: "presentation",
35135
- baseClassName: "navi_list_item_footer"
35136
- });
35137
- };
34559
+ // When total rendered items exceeds renderBudget, a render window [start, end)
34560
+ // is activated to cap the number of DOM nodes. Items outside the window return
34561
+ // null. The window slides as the user scrolls, using actual DOM positions
34562
+ // (getBoundingClientRect) to find the first visible item — no height estimation.
34563
+ const RENDER_BUDGET_DEFAULT = 100;
35138
34564
 
35139
- installImportMetaCssBuild(import.meta);/**
35140
- *
35141
- * Voila le délire:
35142
- *
35143
- * En fait quoiqu'il on mettre un radio/checkbox MAIS
35144
- *
35145
- * il est aussi possible d'afficher une version décorative
35146
- * en instanciant un input dans le <Selectable>
35147
- * il faudra donc ptet un Selectable.Input par example
35148
- * qui se charge de render un input qui est décoratif, le vrai input est caché
35149
- * mais pilote cet input décoratif
35150
- */
34565
+ // Attribute used on <li> elements rendered by ListItemReal so the scroll listener
34566
+ // and filler-height calculation can find real items without matching presentation ones.
34567
+ const REAL_LIST_ITEM_SELECTOR = `[navi-list-item-real]`;
34568
+
34569
+ // Carries the render window {start, end} (or null = render all) from
34570
+ // List down to each ListItem.
34571
+ const RenderWindowContext = createContext(null);
34572
+ // Carries the separator element/function down to each ListItem so separators
34573
+ // are only rendered between items that actually mount (post-filter, post-window).
34574
+ const SeparatorContext = createContext(null);
35151
34575
  const css$l = /* css */`
35152
34576
  @layer navi {
35153
34577
  .navi_list_container {
35154
- --list-outline-color: var(--navi-focus-outline-color);
35155
- --list-item-outline-color: var(--navi-focus-outline-color);
35156
- --list-item-outline-width: 2px;
35157
- --list-item-outline-offset: calc(-1 * var(--list-item-outline-width));
35158
- /* Hover (mouse) */
35159
- --list-item-color-hover: var(--list-item-color);
35160
- --list-item-background-color-hover: light-dark(#f5f5f5, #2a2a2a);
35161
- /* Pointed by mouse — subtle, just a shade above background */
35162
- --list-item-color-mouse-pointed: var(--list-item-color);
35163
- --list-item-background-color-mouse-pointed: light-dark(#ebebeb, #303030);
35164
- /* Pointed by keyboard — subtle light blue highlight */
35165
- --list-item-color-keyboard-pointed: var(--list-item-color);
35166
- --list-item-background-color-keyboard-pointed: light-dark(
35167
- #c2dcff,
35168
- #1c3a6e
35169
- );
35170
- /* Pointed by proxy */
35171
- --list-item-color-pointed: var(--list-item-color);
35172
- --list-item-background-color-pointed: light-dark(#dbeafe, #1c3a6e);
35173
- /* Selected — vivid blue accent */
35174
- --list-item-color-selected: white;
35175
- --list-item-background-color-selected: rgb(3, 30, 60);
35176
- /* Disabled */
35177
- --list-item-color-disabled: light-dark(#aaa, #555);
35178
- --list-item-background-color-disabled: var(--list-item-background-color);
34578
+ --list-outline-width: 1px;
34579
+ --list-border-radius: 4px;
34580
+ --list-border-width: 1px;
34581
+ --list-border-color: light-dark(#ccc, #555);
34582
+ --list-background-color: light-dark(#fff, #1e1e1e);
34583
+ --list-max-height: 220px;
35179
34584
  }
35180
- }
35181
-
35182
- fieldset.navi_list_container {
35183
- margin: 0; /* Reset margin that might come from fieldset */
35184
- padding: 0; /* Reset padding that might come from fieldset */
35185
- }
35186
-
35187
- .navi_list_container {
35188
- --x-list-outline-width: calc(
35189
- var(--list-outline-width) + var(--list-border-width)
35190
- );
35191
- --x-list-outline-offset: calc(-1 * var(--list-border-width));
34585
+ .navi_list_item {
34586
+ --list-item-padding-x: 0px;
34587
+ --list-item-padding-y: 0px;
34588
+ --list-item-padding: var(--list-item-padding-y) var(--list-item-padding-x);
34589
+ --list-item-color: inherit;
34590
+ --list-item-font-weight: inherit;
34591
+ --list-item-background-color: transparent;
35192
34592
 
35193
- outline-width: var(--x-list-outline-width);
35194
- outline-color: var(--list-outline-color);
35195
- outline-offset: var(--x-list-outline-offset);
34593
+ /* Highlight (CSS Highlight API match) */
34594
+ --list-item-color-highlight: inherit;
34595
+ --list-item-background-color-highlight: #ffe066;
35196
34596
 
35197
- &[data-focus] {
35198
- /* outline: var(--list-outline-width) solid var(--navi-focus-outline-color);
35199
- outline-offset: calc(-1 * var(--list-outline-width)); */
34597
+ /* Here to be overridable by box layout props such as flex */
34598
+ display: inline-block;
35200
34599
  }
35201
- &[data-focus-visible] {
35202
- outline-style: solid;
34600
+ .navi_list_item_group_label {
34601
+ --list-group-label-background-color: var(--list-background-color);
35203
34602
  }
35204
- &[data-callout] {
35205
- --x-list-border-color: var(--callout-color);
34603
+ .navi_list_item_header {
34604
+ background: var(--list-background-color);
34605
+ }
34606
+ .navi_list_item_footer {
34607
+ background: var(--list-background-color);
35206
34608
  }
35207
34609
  }
35208
34610
 
35209
- .navi_list_item {
35210
- position: relative;
35211
- outline-width: var(--list-item-outline-width);
35212
- outline-color: var(--list-item-outline-color);
35213
- outline-offset: var(--list-item-outline-offset);
34611
+ .navi_list_container {
34612
+ --x-list-border-radius: var(--list-border-radius);
34613
+ --x-list-border-width: var(--list-border-width);
34614
+ --x-list-border-color: var(--list-border-color);
34615
+ --x-list-background-color: var(--list-background-color);
34616
+ /* When typing inside an input browser tries to keep caret visible */
34617
+ /* For input within a sticky element inside a scrollable container */
34618
+ /* Browser will try to scroll that input into view */
34619
+ /* When that scrollable container has a scroll padding it causes scroll on each keystroke */
34620
+ /* Even putting a scroll margin on the input won't fix */
34621
+ /* The only solution is to use scroll-margins on each item that can scroll */
34622
+ /* This is why these props are named list-scroll-spacing-top and applied via scroll-margin on items */
34623
+ --x-list-scroll-spacing-top: calc(
34624
+ var(--list-header-height, 0px) + var(--list-scroll-padding-top, 0px)
34625
+ );
34626
+ --x-list-scroll-spacing-bottom: calc(
34627
+ var(--list-footer-height, 0px) + var(--list-scroll-padding-bottom, 0px)
34628
+ );
34629
+ --x-list-scroll-spacing-left: calc(
34630
+ var(--list-header-width, 0px) + var(--list-scroll-padding-left, 0px)
34631
+ );
34632
+ --x-list-scroll-spacing-right: calc(
34633
+ var(--list-footer-width, 0px) + var(--list-scroll-padding-right, 0px)
34634
+ );
35214
34635
 
35215
- &[navi-selectable] {
35216
- user-select: none;
35217
- }
34636
+ display: flex;
34637
+ min-width: 0;
34638
+ /* fit-content by default, but never wider than the parent */
34639
+ max-width: 100%;
34640
+ flex-direction: column;
34641
+ background-color: var(--x-list-background-color);
34642
+ border: var(--x-list-border-width) solid var(--x-list-border-color);
34643
+ border-radius: var(--x-list-border-radius);
35218
34644
 
35219
- &[data-interactive] {
35220
- cursor: pointer;
35221
- user-select: none;
35222
- }
35223
- &[data-hover] {
35224
- --x-list-item-color: var(--list-item-color-mouse-pointed);
35225
- --x-list-item-background-color: var(
35226
- --list-item-background-color-mouse-pointed
35227
- );
34645
+ transition: opacity 0.2s ease;
34646
+ /* overflow:hidden is required on the container (not the inner scroll element)
34647
+ so that border-radius clips the content correctly. Without it, items near
34648
+ the corners would visually overflow the rounded corners during scroll. */
34649
+ overflow: hidden;
34650
+
34651
+ .navi_list_scroll_container {
34652
+ width: inherit;
34653
+ min-width: inherit;
34654
+ max-width: inherit;
34655
+ max-height: var(--list-max-height);
34656
+ overflow: auto;
34657
+ overscroll-behavior: inherit; /* inherit select behavior */
35228
34658
  }
35229
- &[data-pointed] {
35230
- --x-list-item-color: var(--list-item-color-pointed);
35231
- --x-list-item-background-color: var(--list-item-background-color-pointed);
34659
+
34660
+ &[data-expand-x] {
34661
+ width: 100%;
35232
34662
  }
35233
- /* No input proxy: focused,selected */
35234
- &:not(:has(input[navi-control-proxy-for])) {
35235
- &:has([data-focus-visible]) {
35236
- --x-list-item-color: var(--list-item-color-keyboard-pointed);
35237
- --x-list-item-background-color: var(
35238
- --list-item-background-color-keyboard-pointed
35239
- );
35240
- outline-style: solid;
34663
+ &[popover] {
34664
+ position: absolute;
34665
+ inset: unset;
34666
+ display: none;
34667
+ min-width: var(--list-anchor-width, 0px);
34668
+ max-width: 95vw;
34669
+ margin: 0;
34670
+ padding: 0;
35241
34671
 
35242
- /* Selected must win over keyboard-pointed */
35243
- &[data-selected] {
35244
- --x-list-item-background-color: var(
35245
- --list-item-background-color-selected,
35246
- var(--list-item-background-color-keyboard-pointed)
35247
- );
35248
- }
34672
+ &:popover-open {
34673
+ display: flex;
34674
+ }
34675
+ .navi_list {
34676
+ width: 100%;
35249
34677
  }
35250
34678
 
35251
- &[data-selected] {
35252
- --x-list-item-color: var(--list-item-color-selected);
35253
- --x-list-item-background-color: var(
35254
- --list-item-background-color-selected
35255
- );
35256
- &[data-hover] {
35257
- --x-list-item-background-color: var(
35258
- --list-item-background-color-selected,
35259
- var(--list-item-background-color-mouse-pointed)
35260
- ) !important;
35261
- }
34679
+ &[data-anchor-hidden] {
34680
+ opacity: 0;
34681
+ pointer-events: none;
35262
34682
  }
35263
34683
  }
34684
+ }
35264
34685
 
35265
- &[data-disabled] {
35266
- --x-list-item-color: var(--list-item-color-disabled);
35267
- --x-list-item-background-color: var(
35268
- --list-item-background-color-disabled
35269
- );
35270
- cursor: default;
35271
- pointer-events: none;
35272
- }
35273
- &[data-readonly] {
35274
- --x-list-item-color: var(--list-item-color-disabled);
35275
- cursor: default;
35276
- }
34686
+ .navi_list {
34687
+ box-sizing: border-box;
34688
+ margin: 0;
34689
+ padding: 0;
34690
+ list-style: none;
34691
+ outline: none; /* Focus is displayed on the container */
35277
34692
  }
35278
- `;
35279
- const SelectableListMultipleContext = createContext(false);
35280
34693
 
35281
- // Interactive variant: manages hover/keyboard/selection state and handles the
35282
- // navi event protocol. When an action is provided it binds the action to ui state
35283
- // and fires it on select. When only uiAction is provided it calls it directly.
35284
- const SelectableList = props => {
35285
- import.meta.css = [css$l, "@jsenv/navi/src/control/list/selectable_list.jsx"];
35286
- const defaultRef = useRef();
35287
- props.ref = props.ref || defaultRef;
35288
- // we allow ourselves to auto-generate a name
35289
- const defaultName = useId();
35290
- props.name = props.name || `listbox_${defaultName}`;
35291
- const {
35292
- ref,
35293
- multiple,
35294
- selectedIndicator = "backgroundColor",
35295
- focusGroupDirection,
35296
- focusGroupWrap
35297
- } = props;
35298
- const [listControlProps, remainingProps, childrenWrapperProps, uiGroupStateController] = useControlgroupProps(props, {
35299
- stateType: multiple ? "array" : "",
35300
- controlType: multiple ? "checkbox_group" : "radio_group",
35301
- childControlFilter: multiple ? childUIStateController => {
35302
- return childUIStateController.controlType === "input" && childUIStateController.props.type === "checkbox";
35303
- } : childUIStateController => {
35304
- return childUIStateController.controlType === "input" && childUIStateController.props.type === "radio";
35305
- },
35306
- aggregateChildStates: multiple ? childUIStateControllers => {
35307
- const values = [];
35308
- for (const childUIStateController of childUIStateControllers) {
35309
- if (childUIStateController.uiState) {
35310
- values.push(childUIStateController.uiState);
35311
- }
35312
- }
35313
- return values.length === 0 ? undefined : values;
35314
- } : childUIStateControllers => {
35315
- let activeValue;
35316
- for (const childUIStateController of childUIStateControllers) {
35317
- if (childUIStateController.uiState) {
35318
- activeValue = childUIStateController.uiState;
35319
- break;
35320
- }
34694
+ .navi_list_item {
34695
+ --x-list-item-color: var(--list-item-color);
34696
+ --x-list-item-background-color: var(--list-item-background-color);
34697
+ --x-list-item-font-weight: var(--list-item-font-weight);
34698
+ --x-list-item-border-width: var(--list-item-border-width, 0px);
34699
+ --x-list-item-border-color: var(--list-item-border-color, black);
34700
+
34701
+ box-sizing: border-box;
34702
+ min-width: 0;
34703
+ max-width: 100%;
34704
+ padding: var(--list-item-padding);
34705
+ color: var(--x-list-item-color);
34706
+ font-weight: var(--x-list-item-font-weight);
34707
+ background-color: var(--x-list-item-background-color);
34708
+ border: var(--x-list-item-border-width) solid
34709
+ var(--x-list-item-border-color);
34710
+ /*
34711
+ CSS impossible d'obtenir un layout qui ferait en gros:
34712
+ width = max(min(max-content, 100%), unbreakable-content)
34713
+ Donc 3 options:
34714
+ - Laisser le contenu overflow
34715
+ - moche, background ne suit pas
34716
+ -> NOPE
34717
+ - Force overflow hidden + ellipsis
34718
+ - casse la lisibilité des mots insécables
34719
+ - possible d'optin en utilisant overflowEllipsis sur le ListItem
34720
+ -> Bien mais pas par défaut
34721
+ - Forcer le retour a la ligne des mot inécables
34722
+ - Aucun des inconvénient ci dessus
34723
+ -> Comportement par défaut
34724
+ */
34725
+ overflow-wrap: anywhere;
34726
+ /* When list has sticky header/footer, put a scroll padding */
34727
+ scroll-margin-top: var(--x-list-scroll-spacing-top);
34728
+ scroll-margin-right: var(--x-list-scroll-spacing-right);
34729
+ scroll-margin-bottom: var(--x-list-scroll-spacing-bottom);
34730
+ scroll-margin-left: var(--x-list-scroll-spacing-left);
34731
+ }
34732
+
34733
+ /* Virtual scroll fillers — must remain invisible.
34734
+ The browser may briefly flash them during scroll before the render window
34735
+ updates, so giving them a visible background would cause visual glitches. */
34736
+ .navi_list_virtual_filler {
34737
+ display: inline-block;
34738
+ height: var(--size-to-fill, 0px);
34739
+ list-style: none;
34740
+ /* background: pink; */
34741
+ }
34742
+ &[data-horizontal] {
34743
+ .navi_list_virtual_filler {
34744
+ width: var(--size-to-fill, 0px);
34745
+ height: 100%;
34746
+ }
34747
+ }
34748
+
34749
+ /* Empty state — hidden by default, shown when no list items are rendered.
34750
+ order: 1 pushes fallbacks after all regular items in flex column layout.
34751
+ The list children are open-ended (headers, presentation items, real items),
34752
+ so we cannot control where the consumer places the fallback nodes in the DOM.
34753
+ Using order ensures fallbacks always appear after items regardless of DOM order.
34754
+ matchFallback intentionally shares the same order as fallback so it appears
34755
+ at the same visual position — after an input if present but before any items
34756
+ still displayed (non-matching items remain in DOM with hidden prop):
34757
+ 1. Input (sticky header, order: -2)
34758
+ 2. matchFallback (order: -1)
34759
+ 3. hidden items (regular order, after DOM flow)
34760
+ 4. HOT FIX OF THE DEAD for bottom filler + preact issue: order: 1
34761
+ 5. sticky footer (order: 2)
34762
+ */
34763
+ /* order: 0 keeps the header pinned before fallbacks (order: 1) in flex order,
34764
+ ensuring the header (e.g. a search input) always appears above them. */
34765
+ .navi_list_item_header {
34766
+ position: sticky;
34767
+ top: 0;
34768
+ left: 0;
34769
+ z-index: 1;
34770
+ order: -2;
34771
+ }
34772
+ .navi_list_fallback,
34773
+ .navi_list_no_match_fallback {
34774
+ order: -1;
34775
+ color: light-dark(#888, #aaa);
34776
+ &[navi-default] {
34777
+ display: inline;
34778
+ padding: var(--list-item-padding);
34779
+ text-align: center;
34780
+ user-select: none;
34781
+ }
34782
+ }
34783
+ [navi-virtual-filler="after"] {
34784
+ /* for some reason preact ends up puttin this element before the list items in some scenarios
34785
+ I've noticed that removing the ItemIndexToScrollOnMountRefContext.Provider
34786
+ does fix this issue (I suppose it's because it cause on less render of the list which is the problematic one)
34787
+ this order ENSURE that even when preact hallucinates we are still correctly putting the bottom filler
34788
+ after the list items */
34789
+ order: 1;
34790
+ }
34791
+ /* order: 2 pins the footer after fallbacks (order: 1) and all items. */
34792
+ .navi_list_item_footer {
34793
+ position: sticky;
34794
+ right: 0;
34795
+ bottom: 0;
34796
+ z-index: 1;
34797
+ order: 2;
34798
+ }
34799
+
34800
+ ::highlight(navi-search-match) {
34801
+ color: var(--list-item-color-highlight);
34802
+ background-color: var(--list-item-background-color-highlight);
34803
+ }
34804
+
34805
+ /* Hide groups that have no rendered items. */
34806
+ .navi_list_item_group {
34807
+ min-width: 100%;
34808
+
34809
+ .navi_list_item_group_label {
34810
+ position: sticky;
34811
+ top: 0;
34812
+ z-index: 1;
34813
+ display: block;
34814
+ background-color: var(--list-group-label-background-color);
34815
+ user-select: none;
34816
+
34817
+ &[navi-default] {
34818
+ padding: 4px 12px 2px;
34819
+ color: light-dark(#888, #aaa);
34820
+ font-weight: 600;
34821
+ font-size: 0.75em;
34822
+ text-transform: uppercase;
34823
+ letter-spacing: 0.05em;
35321
34824
  }
35322
- return activeValue;
34825
+ }
34826
+ .navi_list_item_group_list {
34827
+ display: flex;
34828
+ width: max-content;
34829
+ min-width: 100%;
34830
+ margin: 0;
34831
+ padding: 0;
34832
+ flex-direction: column;
34833
+ list-style: none;
34834
+
34835
+ /* Items inside a group must account for the sticky group label height
34836
+ on top of the list's global header/scroll-padding spacing. */
34837
+ .navi_list_item {
34838
+ scroll-margin-top: calc(
34839
+ var(--x-list-scroll-spacing-top) + var(--list-group-label-height, 0px)
34840
+ );
34841
+ scroll-margin-left: calc(
34842
+ var(--x-list-scroll-spacing-left) + var(--list-group-label-width, 0px)
34843
+ );
34844
+ }
34845
+ }
34846
+
34847
+ &[data-hidden-while-empty]:not(:has([navi-list-item-real])) {
34848
+ display: none;
34849
+ }
34850
+ }
34851
+ `;
34852
+
34853
+ /**
34854
+ * List — generic virtualized scroll container.
34855
+ *
34856
+ * Renders children inside a scrollable container with an optional render budget
34857
+ * for virtual scrolling. Items must use <ListItem> to participate in tracking.
34858
+ *
34859
+ * Props:
34860
+ * keyboardInteractions — when true, attaches arrow/enter/escape keyboard shortcuts
34861
+ * that dispatch navi_list_nav / navi_list_confirm / navi_list_clear
34862
+ * to the list container. Pair with uiAction for a full keyboard-
34863
+ * navigable list.
34864
+ * uiAction — called with the selected value on confirm. When provided
34865
+ * the list becomes interactive: tracks hover and keyboard-
34866
+ * pointed state, handles navi_list_nav / navi_list_clear /
34867
+ * navi_list_confirm custom events via ListInteractionContext.
34868
+ * popover — when true, renders as a managed popover positioned near
34869
+ * an anchor element via navi_list_open / navi_list_close events.
34870
+ * renderBudget — max items in DOM at once (default 100, virtual scroll when exceeded)
34871
+ * virtualItemSize — fixed px size per item (width if horizontal, height otherwise) when all items have the same size.
34872
+ * Enables precise virtual-scroll filler sizing without a DOM
34873
+ * measurement pass. Required when renderBudget is active and
34874
+ * item height is known up-front.
34875
+ * fallback — content shown when no items exist at all
34876
+ * matchFallback — content shown when items exist but all are hidden (e.g. no search match)
34877
+ * separator — element or function(index, { previousItem, currentItem }) inserted between visible items
34878
+ * lockSize — when true, captures the container's dimensions on first render
34879
+ * (always in unfiltered state). Those values become min-width/
34880
+ * min-height so filtering cannot collapse the layout.
34881
+ * ...rest — forwarded to the outer scroll container <Box>
34882
+ */
34883
+ const ListUI = props => {
34884
+ import.meta.css = [css$l, "@jsenv/navi/src/control/list/list.jsx"];
34885
+ const {
34886
+ ref,
34887
+ renderBudget = RENDER_BUDGET_DEFAULT,
34888
+ renderBudgetSkipCheck,
34889
+ role,
34890
+ fallback,
34891
+ noMatchFallback,
34892
+ separator,
34893
+ children,
34894
+ popover,
34895
+ expandX,
34896
+ expand,
34897
+ maxHeight,
34898
+ onListVisibleItemsChange,
34899
+ virtualItemSize,
34900
+ lockSize,
34901
+ searchText,
34902
+ horizontal,
34903
+ spacing,
34904
+ ...rest
34905
+ } = props;
34906
+ if (renderBudget < 30 && !renderBudgetSkipCheck) {
34907
+ console.warn(`List: renderBudget=${renderBudget} is too low. A renderBudget below 30 is not supported: on large screens or when the list grows, items outside the window would appear as blank space instead of rendered content. Use a value of at least 30, or omit the prop to use the default (${RENDER_BUDGET_DEFAULT}).`);
34908
+ }
34909
+
34910
+ // lockSize: capture the container's dimensions on first render so filtering
34911
+ // cannot collapse the layout. Measurement happens on the initial (unfiltered)
34912
+ // state because the parent controls hidden props before any search is applied.
34913
+ const sizeLocked = useRef(false);
34914
+ useDisplayedLayoutEffect(ref, listContainerEl => {
34915
+ if (!lockSize) {
34916
+ return undefined;
34917
+ }
34918
+ const observer = new ResizeObserver(entries => {
34919
+ const entry = entries[0];
34920
+ // Use borderBoxSize (outer width) not contentRect (which excludes the
34921
+ // scrollbar width). If we used contentRect, min-width would be set to
34922
+ // outerWidth − scrollbarWidth, and the container would shrink by exactly
34923
+ // the scrollbar width when the scrollbar disappears.
34924
+ const borderBoxEntry = entry.borderBoxSize ? entry.borderBoxSize[0] : null;
34925
+ const width = borderBoxEntry ? borderBoxEntry.inlineSize : entry.contentRect.width;
34926
+ const height = borderBoxEntry ? borderBoxEntry.blockSize : entry.contentRect.height;
34927
+ if (width === 0 && height === 0) {
34928
+ return;
34929
+ }
34930
+ listContainerEl.style.minWidth = `${width}px`;
34931
+ listContainerEl.style.minHeight = `${height}px`;
34932
+ sizeLocked.current = true;
34933
+ observer.disconnect();
34934
+ });
34935
+ observer.observe(listContainerEl);
34936
+ return () => {
34937
+ observer.disconnect();
34938
+ };
34939
+ }, [lockSize]);
34940
+ const tracker = useItemTracker({
34941
+ onChange: () => {
34942
+ onListVisibleItemsChange?.(tracker.visibleItemsSignal.peek());
35323
34943
  }
35324
34944
  });
35325
- useFocusGroup(ref, {
35326
- direction: focusGroupDirection,
35327
- wrap: focusGroupWrap,
35328
- // Up/Down navigate between list items only (the visually-hidden real inputs).
35329
- ySelector: "[navi-selectable-real-input]"
34945
+ const {
34946
+ virtualItemSizeSignal,
34947
+ renderWindow,
34948
+ scrollToItem,
34949
+ pendingScrollRef
34950
+ } = useListScrollSync({
34951
+ ref,
34952
+ tracker,
34953
+ renderBudget,
34954
+ virtualItemSize,
34955
+ searchText,
34956
+ horizontal
35330
34957
  });
35331
-
35332
- // "Current item" tracking — the item that an external controller (e.g. an
35333
- // <input navi-list>) navigates from. Defaults to the first selected item,
35334
- // else the first navigable item. Updated when:
35335
- // - an item's real input gains focus (via Tab, click, etc.)
35336
- // - the controller dispatches navi_request_list_nav
35337
- // The current id is announced via navi_list_current_change (bubbling) so a
35338
- // connected input can update its aria-controls / aria-activedescendant.
35339
- const currentIdRef = useRef(null);
35340
- const setCurrentId = (id, event) => {
35341
- const previousId = currentIdRef.current;
35342
- if (previousId === id) {
34958
+ const getItemById = itemId => {
34959
+ return tracker.itemsSignal.peek().find(item => item.id === itemId);
34960
+ };
34961
+ return jsx(Box, {
34962
+ ...rest,
34963
+ ref: ref,
34964
+ baseClassName: "navi_list_container",
34965
+ popover: popover,
34966
+ "data-horizontal": horizontal ? "" : undefined,
34967
+ "data-expand-x": expandX || expand ? "" : undefined,
34968
+ expandX: expandX,
34969
+ expand: expand,
34970
+ maxHeight: maxHeight,
34971
+ styleCSSVars: LIST_STYLE_CSS_VARS,
34972
+ pseudoClasses: LIST_PSEUDO_CLASSES,
34973
+ hasChildUsingForwardedProps: true,
34974
+ onnavi_request_scroll: e => {
34975
+ if (!Object.hasOwn(e.detail, "id")) {
34976
+ console.warn(`navi_request_scroll event is missing the "id" property in its detail.`, e);
34977
+ return;
34978
+ }
34979
+ const {
34980
+ id
34981
+ } = e.detail;
34982
+ const item = getItemById(id);
34983
+ scrollToItem(item, {
34984
+ event: e,
34985
+ reason: "navi_request_scroll"
34986
+ });
34987
+ },
34988
+ children: jsx(ListContent, {
34989
+ role: role,
34990
+ fallback: fallback,
34991
+ noMatchFallback: noMatchFallback,
34992
+ searchText: searchText,
34993
+ separator: separator,
34994
+ expandX: expandX,
34995
+ expand: expand,
34996
+ horizontal: horizontal,
34997
+ spacing: spacing,
34998
+ tracker: tracker,
34999
+ renderWindow: renderWindow,
35000
+ virtualItemSizeSignal: virtualItemSizeSignal,
35001
+ pendingScrollRef: pendingScrollRef,
35002
+ children: children
35003
+ })
35004
+ });
35005
+ };
35006
+ const ListFirstResolver = props => {
35007
+ const Next = useNextResolver();
35008
+ const refDefault = useRef(null);
35009
+ props.ref = props.ref || refDefault;
35010
+ const idDefault = useId();
35011
+ props.id = props.id || idDefault;
35012
+ return jsx(Next, {
35013
+ ...props
35014
+ });
35015
+ };
35016
+ const List = createComponentResolver([ListFirstResolver, ListSelectableResolver, ListUI]);
35017
+ const ListContent = ({
35018
+ role,
35019
+ fallback,
35020
+ noMatchFallback,
35021
+ searchText,
35022
+ separator,
35023
+ expandX,
35024
+ expand,
35025
+ horizontal,
35026
+ spacing,
35027
+ tracker,
35028
+ renderWindow,
35029
+ virtualItemSizeSignal,
35030
+ pendingScrollRef,
35031
+ children
35032
+ }) => {
35033
+ const listProps = useContext(BoxForwardedPropsContext);
35034
+ return jsx("div", {
35035
+ className: "navi_list_scroll_container",
35036
+ children: jsx(UnorderedList, {
35037
+ role: role,
35038
+ fallback: fallback,
35039
+ noMatchFallback: noMatchFallback,
35040
+ searchText: searchText,
35041
+ separator: separator === true ? jsx(Separator, {
35042
+ margin: "0"
35043
+ }) : separator,
35044
+ expandX: expandX || expand,
35045
+ horizontal: horizontal,
35046
+ spacing: spacing,
35047
+ ...listProps,
35048
+ tracker: tracker,
35049
+ renderWindow: renderWindow,
35050
+ virtualItemSizeSignal: virtualItemSizeSignal,
35051
+ children: jsx(PendingScrollRefContext.Provider, {
35052
+ value: pendingScrollRef,
35053
+ children: children
35054
+ })
35055
+ })
35056
+ });
35057
+ };
35058
+ const LIST_STYLE_CSS_VARS = {
35059
+ maxHeight: "--list-max-height",
35060
+ borderColor: "--list-border-color",
35061
+ borderRadius: "--list-border-radius",
35062
+ borderWidth: "--list-border-width"
35063
+ };
35064
+ const LIST_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-void", ":-navi-expanded"];
35065
+ const useListScrollSync = ({
35066
+ ref,
35067
+ tracker,
35068
+ renderBudget,
35069
+ virtualItemSize,
35070
+ searchText,
35071
+ horizontal
35072
+ }) => {
35073
+ const debugScroll = useDebugScroll();
35074
+ const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal);
35075
+ const [renderWindow, setRenderWindow] = useState({
35076
+ start: 0,
35077
+ end: renderBudget
35078
+ });
35079
+ const renderWindowRef = useRef(null);
35080
+ renderWindowRef.current = renderWindow;
35081
+ const updateRenderWindow = (newStart, newEnd, reason) => {
35082
+ const {
35083
+ start,
35084
+ end
35085
+ } = renderWindowRef.current;
35086
+ if (newStart === start && newEnd === end) {
35343
35087
  return;
35344
35088
  }
35345
- currentIdRef.current = id;
35346
- const listEl = ref.current;
35347
- if (!listEl) {
35089
+ debugScroll(`updateRenderWindow(${newStart}, ${newEnd}, "${reason}")`);
35090
+ const renderWindow = {
35091
+ start: newStart,
35092
+ end: newEnd
35093
+ };
35094
+ renderWindowRef.current = renderWindow;
35095
+ setRenderWindow(renderWindow);
35096
+ };
35097
+ const pendingScrollRef = useRef();
35098
+ const scrollToItem = (item, {
35099
+ event,
35100
+ reason
35101
+ }) => {
35102
+ if (!item) {
35348
35103
  return;
35349
35104
  }
35350
- if (id) {
35351
- listEl.setAttribute("navi-current-id", id);
35105
+ const items = tracker.itemsSignal.peek();
35106
+ const itemCount = items.length;
35107
+ if (itemCount === 0) {
35108
+ return;
35109
+ }
35110
+ let index = items.findIndex(i => i.id === item.id);
35111
+ if (index === -1) {
35112
+ return;
35113
+ }
35114
+ if (index >= itemCount) {
35115
+ index = itemCount - 1;
35116
+ }
35117
+ const scrollItemIntoView = itemEl => {
35118
+ const trigger = `"${event.type}" on ${getElementSignature(event.target)} (${reason})`;
35119
+ // When we display the list we prefer to have selected item at the center
35120
+ // otherwise, usually when focused by arrow nav, we want to keep it into view close to the nearest edge
35121
+ const block = event.type === "navi_displayed" ? "center" : "nearest";
35122
+ const scrollToItemCall = `${getElementSignature(itemEl)}.scrollIntoView({ block: "${block}", container: "nearest" })`;
35123
+ const listScrollContainerEl = ref.current.querySelector(`.navi_list_scroll_container`);
35124
+ debugScroll(`${trigger} -> ${scrollToItemCall}`);
35125
+ scrollIntoViewScoped(itemEl, {
35126
+ container: listScrollContainerEl,
35127
+ block
35128
+ });
35129
+ const listEl = ref.current.querySelector(".navi_list");
35130
+ dispatchPublicCustomEvent(listEl, "navi_scroll", {
35131
+ event,
35132
+ item
35133
+ });
35134
+ };
35135
+ const {
35136
+ start,
35137
+ end
35138
+ } = renderWindowRef.current;
35139
+ const isInWindow = index >= start && index < end;
35140
+ if (isInWindow) {
35141
+ const itemEl = document.getElementById(item.id);
35142
+ if (itemEl) {
35143
+ scrollItemIntoView(itemEl);
35144
+ return;
35145
+ }
35146
+ }
35147
+ // Not in DOM — shift the render window. The item will read
35148
+ // pendingScrollRef on mount and scroll into view.
35149
+ pendingScrollRef.current = {
35150
+ id: item.id,
35151
+ resolve: itemEl => {
35152
+ pendingScrollRef.current = null;
35153
+ scrollItemIntoView(itemEl);
35154
+ }
35155
+ };
35156
+ const half = Math.floor(renderBudget / 2);
35157
+ const newStart = Math.max(0, index - half);
35158
+ const newEnd = newStart + renderBudget;
35159
+ updateRenderWindow(newStart, newEnd, `item to scroll (at ${index}) is out of render window`);
35160
+ };
35161
+ const currentScrollRef = useRef(null);
35162
+ const updateCurrentScroll = () => {
35163
+ const listScrollContainerEl = ref.current.querySelector(`.navi_list_scroll_container`);
35164
+ const currentScrollLeft = listScrollContainerEl.scrollLeft;
35165
+ const currentScrollTop = listScrollContainerEl.scrollTop;
35166
+ const renderWindow = renderWindowRef.current;
35167
+ currentScrollRef.current = {
35168
+ left: currentScrollLeft,
35169
+ top: currentScrollTop,
35170
+ renderWindow: {
35171
+ ...renderWindow
35172
+ }
35173
+ };
35174
+ debugScroll(`store currentScroll: scrollTop=${currentScrollTop}, renderWindow=[${renderWindow.start}, ${renderWindow.end})`);
35175
+ };
35176
+ const searchTextRef = useRef();
35177
+ let searchTextBecomesActive = false;
35178
+ if (searchTextRef.current === undefined) {
35179
+ searchTextRef.current = searchText;
35180
+ } else {
35181
+ const searchTextPrevious = searchTextRef.current;
35182
+ searchTextRef.current = searchText;
35183
+ if (!searchTextPrevious && searchText) {
35184
+ searchTextBecomesActive = true;
35185
+ }
35186
+ }
35187
+ // Scroll to the selected item only the FIRST time the list is presented on screen,
35188
+ // so the user can see what's selected on initial open. On subsequent re-displays
35189
+ // (e.g. reopening a popover containing the list), we intentionally keep the previous
35190
+ // scroll position — it's less disruptive UX to land where the user last was, even
35191
+ // if that means the selected item isn't currently visible.
35192
+ // Skipped when inside a closed <dialog>/<details> (scrollIntoView is a no-op
35193
+ // on hidden elements); re-runs automatically every time the ancestor opens.
35194
+ const hasBeenDisplayedRef = useRef(false);
35195
+ useDisplayedLayoutEffect(ref, (el, openEvent) => {
35196
+ updateCurrentScroll();
35197
+ if (hasBeenDisplayedRef.current) {
35198
+ return;
35199
+ }
35200
+ hasBeenDisplayedRef.current = true;
35201
+ const items = tracker.itemsSignal.peek();
35202
+ const firstSelected = items.find(i => i.selected);
35203
+ if (firstSelected) {
35204
+ scrollToItem(firstSelected, {
35205
+ event: new CustomEvent("navi_displayed", {
35206
+ detail: {
35207
+ originalEvent: openEvent
35208
+ }
35209
+ }),
35210
+ reason: "scroll to selected"
35211
+ });
35352
35212
  } else {
35353
- listEl.removeAttribute("navi-current-id");
35213
+ scrollToItem(items[0], {
35214
+ event: new CustomEvent("navi_displayed", {
35215
+ detail: {
35216
+ originalEvent: openEvent
35217
+ }
35218
+ }),
35219
+ reason: "scroll to top (no selected item)"
35220
+ });
35354
35221
  }
35355
- dispatchPublicCustomEvent(listEl, "navi_current_change", {
35356
- event,
35357
- id,
35358
- realInputId: id ? `${id}_input` : null
35222
+ }, []);
35223
+ // Watch scores of the top renderBudget items.
35224
+ // When scores change during an active search, scroll to top to reveal the most relevant items.
35225
+ // When search becomes empty, restore the scroll position from before the search started.
35226
+ // We save the first-visible item ID so restoration is item-precise
35227
+ // and survives render-window shifts or item reordering.
35228
+
35229
+ // NOTE POUR LE JOUR OU ON A LE MULTISELECT:
35230
+ // Lorsqu'on selectionne quelque chose pendant une recherche, alors ensuite meme si on clear
35231
+ // on veut pas revenir a la position scroll précédente car on veut garde l'item qu'on a selectionné visible
35232
+ // (pour l'instant pas grave car on travaille pour le mode select qui fermera le dialog au select)
35233
+ const savedScrollRef = useRef(null);
35234
+ const topMatchScoresKeyRef = useRef("");
35235
+ useLayoutEffect(() => {
35236
+ const listScrollContainerEl = ref.current?.querySelector(`.navi_list_scroll_container`);
35237
+ if (!listScrollContainerEl) {
35238
+ return undefined;
35239
+ }
35240
+ if (!searchText) {
35241
+ // no search -> try to restore scroll position
35242
+ topMatchScoresKeyRef.current = "";
35243
+ const savedScroll = savedScrollRef.current;
35244
+ if (!savedScroll) {
35245
+ // nothing to restore
35246
+ return undefined;
35247
+ }
35248
+ savedScrollRef.current = null;
35249
+ debugScroll("Restoring scroll to", savedScroll);
35250
+ updateRenderWindow(savedScroll.renderWindow.start, savedScroll.renderWindow.end, "restore scroll window");
35251
+ const raf = requestAnimationFrame(() => {
35252
+ const left = savedScroll.left;
35253
+ const top = savedScroll.top;
35254
+ // use scrollTo to respect eventual css scroll-behavior: smooth;
35255
+ debugScroll(`restore scroll: ${getElementSignature(listScrollContainerEl)}.scrollTo({ left: ${left}, top: ${top} })`);
35256
+ // The reliable way to restore scroll is to use scrollTop because otherwise we will estimate the item to scroll
35257
+ // based on virtual item height which can wrongly restore the scroll.
35258
+ // However we have a contract with outside to inside which item is scrolled
35259
+ // (used by keyboard nav to enable anchoring the item for list item nav with arrow keys)
35260
+ // so we do our best to give that item back
35261
+ const {
35262
+ item
35263
+ } = getScrollInfo(savedScroll, listScrollContainerEl, tracker, virtualItemSizeSignal, renderWindowRef, horizontal);
35264
+ listScrollContainerEl.scrollTo({
35265
+ left: savedScroll.left,
35266
+ top: savedScroll.top
35267
+ });
35268
+ const listEl = ref.current.querySelector(".navi_list");
35269
+ dispatchPublicCustomEvent(listEl, "navi_scroll", {
35270
+ item,
35271
+ event: new CustomEvent("navi_scroll_restore")
35272
+ });
35273
+ });
35274
+ return () => {
35275
+ cancelAnimationFrame(raf);
35276
+ };
35277
+ }
35278
+ const visibleItems = tracker.visibleItemsSignal.peek();
35279
+ const topItems = visibleItems.slice(0, renderBudget);
35280
+ const topMatchScoresKey = topItems.map(i => `${i.id}:${i.matchScore ?? ""}`).join(",");
35281
+ const currentTopMatchScore = topMatchScoresKeyRef.current;
35282
+ if (topMatchScoresKey === currentTopMatchScore) {
35283
+ // no changes in top matches -> no need to scroll
35284
+ return undefined;
35285
+ }
35286
+ // n items are now more important to see, scrollTop to show them
35287
+ topMatchScoresKeyRef.current = topMatchScoresKey;
35288
+ if (searchTextBecomesActive) {
35289
+ // search just started -> save the currently scrolled item id to restore later
35290
+ const currentScroll = currentScrollRef.current;
35291
+ savedScrollRef.current = currentScroll;
35292
+ debugScroll(`Saving scroll: { top: ${currentScroll.top}, renderWindowStart: ${currentScroll.renderWindow.start}, renderWindowEnd: ${currentScroll.renderWindow.end} }`);
35293
+ }
35294
+ // -> scroll to the top
35295
+ scrollToItem(visibleItems[0], {
35296
+ event: new CustomEvent("navi_list_top_match_change")
35359
35297
  });
35360
- };
35361
- const getNavigableElements = () => {
35362
- const listEl = ref.current;
35363
- if (!listEl) {
35364
- return [];
35298
+ return undefined;
35299
+ });
35300
+
35301
+ // Scroll listener — slides the window as the user scrolls.
35302
+ useLayoutEffect(() => {
35303
+ const listContainerEl = ref.current;
35304
+ if (!listContainerEl) {
35305
+ return undefined;
35365
35306
  }
35366
- const itemEls = Array.from(listEl.querySelectorAll("[navi-list-item-real]"));
35367
- const navigableEls = [];
35368
- for (const itemEl of itemEls) {
35369
- if (itemEl.hidden) {
35370
- continue;
35307
+ const listScrollContainerEl = listContainerEl.querySelector(`.navi_list_scroll_container`);
35308
+ const listEl = listContainerEl.querySelector(".navi_list");
35309
+ const onScroll = () => {
35310
+ updateCurrentScroll();
35311
+ const visibleItemCount = tracker.visibleCountSignal.peek();
35312
+ if (visibleItemCount <= renderBudget) {
35313
+ return;
35371
35314
  }
35372
- const realInput = itemEl.querySelector("[navi-selectable-real-input]");
35373
- if (!realInput || realInput.disabled) {
35374
- continue;
35315
+ const oneRealListItemInDom = Boolean(listEl.querySelector(REAL_LIST_ITEM_SELECTOR));
35316
+ if (!oneRealListItemInDom) {
35317
+ return;
35375
35318
  }
35376
- navigableEls.push(itemEl);
35319
+ let reason = "";
35320
+ const scrollInfo = getScrollInfo({
35321
+ left: listScrollContainerEl.scrollLeft,
35322
+ top: listScrollContainerEl.scrollTop
35323
+ }, listScrollContainerEl, tracker, virtualItemSizeSignal, renderWindowRef, horizontal);
35324
+ if (!scrollInfo) {
35325
+ return;
35326
+ }
35327
+ const {
35328
+ index,
35329
+ reason: hitReason
35330
+ } = scrollInfo;
35331
+ reason = hitReason;
35332
+ const half = Math.floor(renderBudget / 2);
35333
+ let newStart = Math.max(0, index - half);
35334
+ let newEnd = Math.min(visibleItemCount, newStart + renderBudget);
35335
+ if (newEnd === visibleItemCount) {
35336
+ newStart = Math.max(0, visibleItemCount - renderBudget);
35337
+ }
35338
+ updateRenderWindow(newStart, newEnd, reason);
35339
+ };
35340
+ listScrollContainerEl.addEventListener("scroll", onScroll, {
35341
+ passive: true
35342
+ });
35343
+ return () => {
35344
+ listScrollContainerEl.removeEventListener("scroll", onScroll);
35345
+ };
35346
+ }, [renderBudget]);
35347
+ return {
35348
+ virtualItemSizeSignal,
35349
+ renderWindow,
35350
+ pendingScrollRef,
35351
+ scrollToItem
35352
+ };
35353
+ };
35354
+ // Returns the item located at the current scroll position of a list container.
35355
+ // Uses DOM hit-testing to find visible items/fillers; falls back to index
35356
+ // estimation via virtualItemSize or renderWindow.start.
35357
+ // Returns { index, item, reason } or null if nothing can be determined.
35358
+ const getScrollInfo = (scrollValues, listScrollContainerEl, tracker, virtualItemSizeSignal, renderWindowRef, horizontal) => {
35359
+ const listEl = listScrollContainerEl.querySelector(".navi_list");
35360
+ const items = tracker.itemsSignal.peek();
35361
+ const containerRect = listScrollContainerEl.getBoundingClientRect();
35362
+ let hitEl = null;
35363
+ let hitFiller = null;
35364
+ const scrollPos = horizontal ? scrollValues.left : scrollValues.top;
35365
+ // Start scanning from the center of the viewport along the main axis.
35366
+ // The render window places half its budget before and half after the hit index.
35367
+ // Anchoring to the center maximises how many rendered items fall within the
35368
+ // visible area.
35369
+ const scanStart = horizontal ? (containerRect.left + containerRect.right) / 2 : (containerRect.top + containerRect.bottom) / 2;
35370
+ const scanEnd = horizontal ? containerRect.right : containerRect.bottom;
35371
+ for (let pos = scanStart; pos < scanEnd; pos += 4) {
35372
+ const x = horizontal ? pos : containerRect.left + 1;
35373
+ const y = horizontal ? containerRect.top + 1 : pos;
35374
+ const el = document.elementFromPoint(x, y);
35375
+ if (!el || !listEl.contains(el)) {
35376
+ continue;
35377
35377
  }
35378
- return navigableEls;
35378
+ const realItem = el.closest(REAL_LIST_ITEM_SELECTOR);
35379
+ if (realItem) {
35380
+ hitEl = realItem;
35381
+ break;
35382
+ }
35383
+ const filler = el.closest("[navi-virtual-filler]");
35384
+ if (filler) {
35385
+ hitFiller = filler;
35386
+ break;
35387
+ }
35388
+ }
35389
+ if (hitFiller) {
35390
+ const virtualItemSize = virtualItemSizeSignal.peek();
35391
+ if (virtualItemSize === 0) {
35392
+ return null;
35393
+ }
35394
+ const estimatedIndex = Math.floor(scrollPos / virtualItemSize);
35395
+ const index = Math.min(items.length - 1, estimatedIndex);
35396
+ return {
35397
+ item: items[index],
35398
+ index,
35399
+ reason: `hit filler, estimated at ${index} (${items[index]?.value})`
35400
+ };
35401
+ }
35402
+ if (hitEl) {
35403
+ const hitId = hitEl.id;
35404
+ const index = items.findIndex(i => i.id === hitId);
35405
+ if (index === -1) {
35406
+ return null;
35407
+ }
35408
+ return {
35409
+ item: items[index],
35410
+ index,
35411
+ reason: `hit item at ${index} (${items[index].value})`
35412
+ };
35413
+ }
35414
+ const fallbackIndex = renderWindowRef.current.start;
35415
+ return {
35416
+ item: items[fallbackIndex],
35417
+ index: fallbackIndex,
35418
+ reason: "no hit"
35379
35419
  };
35380
- // On mount: set the initial current item to the first selected, else the first navigable.
35381
- // After that, focusin events on the list keep currentIdRef up to date.
35420
+ };
35421
+ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal) => {
35422
+ const virtualSizeSignalRef = useRef(null);
35423
+ if (!virtualSizeSignalRef.current) {
35424
+ virtualSizeSignalRef.current = signal(virtualItemSizeProp);
35425
+ }
35426
+ const virtualSizeSignal = virtualSizeSignalRef.current;
35427
+ // propagate prop changes to the signal
35428
+ if (virtualItemSizeProp && virtualSizeSignal.peek() !== virtualItemSizeProp) {
35429
+ virtualSizeSignal.value = virtualItemSizeProp;
35430
+ }
35382
35431
  useLayoutEffect(() => {
35383
- const navigableEls = getNavigableElements();
35384
- if (navigableEls.length === 0) {
35432
+ if (virtualSizeSignal.peek() !== 0) {
35385
35433
  return;
35386
35434
  }
35387
- let initialEl;
35388
- for (const el of navigableEls) {
35389
- const realInput = el.querySelector("[navi-selectable-real-input]");
35390
- if (realInput && realInput.checked) {
35391
- initialEl = el;
35392
- break;
35393
- }
35435
+ const listEl = ref.current?.querySelector(".navi_list");
35436
+ if (!listEl) {
35437
+ return;
35394
35438
  }
35395
- if (!initialEl) {
35396
- initialEl = navigableEls[0];
35439
+ const firstListItem = listEl.querySelector(REAL_LIST_ITEM_SELECTOR);
35440
+ if (!firstListItem) {
35441
+ return;
35442
+ }
35443
+ const rect = firstListItem.getBoundingClientRect();
35444
+ const measuredSize = horizontal ? rect.width : rect.height;
35445
+ virtualSizeSignal.value = measuredSize;
35446
+ });
35447
+ return virtualSizeSignal;
35448
+ };
35449
+
35450
+ // Inner <ul> — hosts the fillers + items.
35451
+ // Creates a virtualItemSize signal so BeforeFiller and AfterFiller can
35452
+ // subscribe to it independently. When virtualItemSize is passed as a prop it
35453
+ // initialises the signal directly; otherwise UnorderedList measures a rendered
35454
+ // item after each commit and writes to the signal, causing only the fillers to
35455
+ // re-render.
35456
+ const UnorderedList = ({
35457
+ tracker,
35458
+ renderWindow,
35459
+ virtualItemSizeSignal,
35460
+ fallback,
35461
+ noMatchFallback,
35462
+ searchText,
35463
+ separator,
35464
+ horizontal,
35465
+ spacing,
35466
+ children,
35467
+ ...rest
35468
+ }) => {
35469
+ return jsxs(Box, {
35470
+ as: "ul",
35471
+ ...rest,
35472
+ flex: horizontal ? "x" : "y",
35473
+ spacing: spacing,
35474
+ baseClassName: "navi_list",
35475
+ children: [jsx(BeforeFiller, {
35476
+ virtualItemSizeSignal: virtualItemSizeSignal,
35477
+ renderWindowStart: renderWindow.start
35478
+ }), jsx(NoMatchFallback, {
35479
+ noMatchFallback: noMatchFallback,
35480
+ tracker: tracker,
35481
+ searchText: searchText
35482
+ }), jsx(Fallback, {
35483
+ fallback: fallback,
35484
+ tracker: tracker
35485
+ }), jsx(RenderWindowContext.Provider, {
35486
+ value: renderWindow,
35487
+ children: jsx(SeparatorContext.Provider, {
35488
+ value: separator ?? null,
35489
+ children: jsx(ListItemTrackerContext.Provider, {
35490
+ value: tracker,
35491
+ children: children
35492
+ })
35493
+ })
35494
+ }), jsx(AfterFiller, {
35495
+ virtualItemSizeSignal: virtualItemSizeSignal,
35496
+ renderWindowEnd: renderWindow.end,
35497
+ tracker: tracker
35498
+ })]
35499
+ });
35500
+ };
35501
+ const NoMatchFallback = ({
35502
+ tracker,
35503
+ noMatchFallback,
35504
+ searchText
35505
+ }) => {
35506
+ const itemCount = tracker.countSignal.value;
35507
+ const visibleItemCount = tracker.visibleCountSignal.value;
35508
+ const matchCount = tracker.matchCountSignal.value;
35509
+ // Show when all items are filtered out (filtered prop), or when search is
35510
+ // active but no visible item has a positive match score.
35511
+ const allHidden = itemCount > 0 && visibleItemCount === 0;
35512
+ const noneMatch = searchText && visibleItemCount > 0 && matchCount === 0;
35513
+ const showMatchFallback = allHidden || noneMatch;
35514
+ if (noMatchFallback === undefined) {
35515
+ noMatchFallback = allHidden ? naviI18n("list.no_match") : naviI18n("list.no_match_rest_shown");
35516
+ }
35517
+ if (!showMatchFallback) {
35518
+ return null;
35519
+ }
35520
+ return jsx(ListItem, {
35521
+ role: "presentation",
35522
+ className: "navi_list_item navi_list_no_match_fallback",
35523
+ hidden: !showMatchFallback,
35524
+ "navi-default": typeof noMatchFallback === "string" ? "" : undefined,
35525
+ children: noMatchFallback
35526
+ });
35527
+ };
35528
+ const Fallback = ({
35529
+ tracker,
35530
+ fallback
35531
+ }) => {
35532
+ const itemCount = tracker.countSignal.value;
35533
+ const showFallback = itemCount === 0;
35534
+ if (fallback === undefined) {
35535
+ fallback = naviI18n("list.empty");
35536
+ }
35537
+ if (!showFallback) {
35538
+ return null;
35539
+ }
35540
+ return jsx(ListItem, {
35541
+ role: "presentation",
35542
+ className: "navi_list_item navi_list_fallback",
35543
+ hidden: !showFallback,
35544
+ "navi-default": typeof fallback === "string" ? "" : undefined,
35545
+ children: fallback
35546
+ });
35547
+ };
35548
+ const BeforeFiller = ({
35549
+ virtualItemSizeSignal,
35550
+ renderWindowStart
35551
+ }) => {
35552
+ const virtualItemSize = virtualItemSizeSignal.value;
35553
+ const numberOfItemsBefore = renderWindowStart;
35554
+ const sizeToFillBefore = numberOfItemsBefore * virtualItemSize;
35555
+ if (!sizeToFillBefore) {
35556
+ return null;
35557
+ }
35558
+ return jsx("li", {
35559
+ className: "navi_list_virtual_filler"
35560
+ // eslint-disable-next-line react/no-unknown-property
35561
+ ,
35562
+
35563
+ "navi-virtual-filler": "before",
35564
+ "aria-hidden": true,
35565
+ style: {
35566
+ "--size-to-fill": `${sizeToFillBefore}px`
35397
35567
  }
35398
- setCurrentId(initialEl.id);
35399
- }, []);
35400
- const listVnode = jsx(List, {
35401
- as: "fieldset",
35402
- "navi-has-selected-background": selectedIndicator === "backgroundColor" ? "" : undefined,
35403
- ...listControlProps,
35404
- ...remainingProps,
35405
- name: undefined,
35406
- selectedIndicator: undefined,
35407
- multiple: undefined
35408
- // Track focus inside the list: whichever item gets focus becomes current.
35568
+ });
35569
+ };
35570
+ const AfterFiller = ({
35571
+ virtualItemSizeSignal,
35572
+ renderWindowEnd,
35573
+ tracker
35574
+ }) => {
35575
+ const visibleItemCount = tracker.visibleCountSignal.value;
35576
+ const virtualItemSize = virtualItemSizeSignal.value;
35577
+ const numberOfItemsAfter = Math.max(visibleItemCount - renderWindowEnd, 0);
35578
+ const sizeToFillAfter = numberOfItemsAfter * virtualItemSize;
35579
+ if (!sizeToFillAfter) {
35580
+ return null;
35581
+ }
35582
+ return jsx("li", {
35583
+ className: "navi_list_virtual_filler"
35584
+ // eslint-disable-next-line react/no-unknown-property
35409
35585
  ,
35410
35586
 
35411
- onFocusIn: e => {
35412
- const realInput = e.target.closest("[navi-selectable-real-input]");
35413
- if (!realInput) {
35414
- return;
35415
- }
35416
- const itemEl = realInput.closest("[navi-list-item-real]");
35417
- if (itemEl && itemEl.id) {
35418
- setCurrentId(itemEl.id, e);
35419
- }
35420
- },
35421
- onnavi_request_select: e => {
35422
- const {
35423
- id
35424
- } = e.detail;
35425
- if (id === undefined) {
35426
- return;
35427
- }
35428
- const inputId = `${id}_input`;
35429
- const childController = uiGroupStateController.findChildById(inputId);
35430
- if (!childController) {
35431
- return;
35432
- }
35433
- const list = ref.current;
35434
- const allowed = dispatchRequestInteraction(list, e, "select");
35435
- if (!allowed) {
35436
- e.preventDefault();
35437
- return;
35438
- }
35439
- if (childController.setUIState(true, e)) {
35440
- dispatchRequestAction(list, {
35441
- event: e
35442
- });
35443
- }
35444
- },
35445
- onnavi_request_unselect: e => {
35446
- const {
35447
- id
35448
- } = e.detail;
35449
- if (id === undefined) {
35450
- return;
35451
- }
35452
- const inputId = `${id}_input`;
35453
- const childController = uiGroupStateController.findChildById(inputId);
35454
- if (!childController) {
35455
- return;
35456
- }
35457
- const list = ref.current;
35458
- const allowed = dispatchRequestInteraction(list, e, "unselect");
35459
- if (!allowed) {
35460
- e.preventDefault();
35461
- return;
35462
- }
35463
- if (childController.setUIState(false, e)) {
35464
- dispatchRequestAction(list, {
35465
- event: e
35466
- });
35467
- }
35468
- },
35469
- onnavi_request_nav: e => {
35470
- const {
35471
- goal
35472
- } = e.detail;
35473
- const navigableEls = getNavigableElements();
35474
- if (navigableEls.length === 0) {
35475
- return;
35476
- }
35477
- const currentId = currentIdRef.current;
35478
- let currentIndex = -1;
35479
- if (currentId) {
35480
- currentIndex = navigableEls.findIndex(el => el.id === currentId);
35481
- }
35482
- let targetEl;
35483
- if (goal === "first") {
35484
- targetEl = navigableEls[0];
35485
- } else if (goal === "last") {
35486
- targetEl = navigableEls[navigableEls.length - 1];
35487
- } else if (goal === "down") {
35488
- if (currentIndex === -1) {
35489
- targetEl = navigableEls[0];
35490
- } else if (currentIndex < navigableEls.length - 1) {
35491
- targetEl = navigableEls[currentIndex + 1];
35492
- } else {
35493
- targetEl = navigableEls[navigableEls.length - 1];
35494
- }
35495
- } else if (goal === "up") {
35496
- if (currentIndex === -1) {
35497
- targetEl = navigableEls[0];
35498
- } else if (currentIndex > 0) {
35499
- targetEl = navigableEls[currentIndex - 1];
35500
- } else {
35501
- targetEl = navigableEls[0];
35502
- }
35503
- }
35504
- if (!targetEl) {
35505
- return;
35506
- }
35507
- setCurrentId(targetEl.id, e);
35508
- dispatchCustomEvent(ref.current, "navi_request_scroll", {
35509
- event: e,
35510
- id: targetEl.id
35511
- });
35512
- },
35513
- onnavi_request_activate: e => {
35514
- const currentId = currentIdRef.current;
35515
- if (!currentId) {
35516
- return;
35517
- }
35518
- if (multiple) {
35519
- const inputId = `${currentId}_input`;
35520
- const childController = uiGroupStateController.findChildById(inputId);
35521
- const isSelected = childController && childController.uiState;
35522
- dispatchCustomEvent(ref.current, isSelected ? "navi_request_unselect" : "navi_request_select", {
35523
- event: e,
35524
- id: currentId
35525
- });
35526
- return;
35527
- }
35528
- dispatchCustomEvent(ref.current, "navi_request_select", {
35529
- event: e,
35530
- id: currentId
35531
- });
35532
- },
35533
- children: jsx(ControlgroupChildrenWrapper, {
35534
- ...childrenWrapperProps,
35535
- children: props.children
35536
- })
35587
+ "navi-virtual-filler": "after",
35588
+ "aria-hidden": true,
35589
+ style: {
35590
+ "--size-to-fill": `${sizeToFillAfter}px`
35591
+ }
35537
35592
  });
35538
- return jsx(SelectableListMultipleContext.Provider, {
35539
- value: multiple,
35540
- children: listVnode
35593
+ };
35594
+ const ListItemFirstResolver = props => {
35595
+ const Next = useNextResolver();
35596
+ const defaultRef = useRef(null);
35597
+ props.ref = props.ref || defaultRef;
35598
+ return jsx(Next, {
35599
+ ...props
35541
35600
  });
35542
35601
  };
35543
- const SelectableRealInputContext = createContext(null);
35544
- const Selectable = props => {
35602
+ const ListItemPresentationResolver = props => {
35603
+ const Next = useNextResolver();
35604
+ if (props.role === "presentation") {
35605
+ return jsx(ListItemPresentation, {
35606
+ ...props
35607
+ });
35608
+ }
35609
+ return jsx(Next, {
35610
+ ...props
35611
+ });
35612
+ };
35613
+ const ListItemPresentation = props => {
35614
+ return jsx(Box, {
35615
+ as: "li",
35616
+ ...props
35617
+ });
35618
+ };
35619
+ const ListItemUI = props => {
35620
+ if (props.id === undefined) {
35621
+ console.warn("ListItem is missing an explicit id prop. Provide a stable id so pointed/selected state survives search reordering.");
35622
+ }
35623
+ if (props.index === undefined) {
35624
+ console.warn("ListItem is missing an explicit index prop. Provide an index so item ordering is stable regardless of render order.");
35625
+ }
35626
+ const idDefault = useId();
35627
+ props.id = props.id || idDefault;
35628
+ const renderWindow = useContext(RenderWindowContext);
35629
+ const tracker = useContext(ListItemTrackerContext);
35630
+ const item = props;
35631
+ const visibleIndex = tracker.useTrackItem(item);
35632
+ const groupTracker = useContext(GroupItemTrackerContext);
35633
+ const groupVisibleIndex = groupTracker ? groupTracker.useTrackItem(item) : null;
35634
+ const separator = useContext(SeparatorContext);
35635
+ if (props.filtered) {
35636
+ return null;
35637
+ }
35638
+ // html-hidden items: excluded from virtual scroll accounting but always in DOM
35639
+ if (props.hidden) {
35640
+ return jsx(ListItemReal, {
35641
+ ...props
35642
+ });
35643
+ }
35644
+ if (visibleIndex === -1) {
35645
+ return null;
35646
+ }
35647
+ if (visibleIndex < renderWindow.start || visibleIndex >= renderWindow.end) {
35648
+ return jsx(ListItemVoid, {});
35649
+ }
35650
+ const listItemVnode = jsx(ListItemReal, {
35651
+ ...props
35652
+ });
35653
+ // For separator decision, we need to know "am I the first visible item?".
35654
+ // We deliberately do NOT use tracker's visibleIndex here because, during a
35655
+ // reorder render pass (e.g. items resorted by search score), other items
35656
+ // still have stale keyToExplicitOrder values — the binary search reads
35657
+ // those stale values and computes wrong indices. The result is that no
35658
+ // item gets visibleIndex === 0 and a spurious <hr> appears at the top.
35659
+ //
35660
+ // Instead we use the parent-provided index, which is race-free:
35661
+ // - global list: props.index === 0 means "first by explicit order"
35662
+ // (parent passes sequential indices starting at 0; filtered items
35663
+ // are already pushed to the end by useSearchText)
35664
+ // - inside a group: each group has its own item tracker and group
35665
+ // items don't reorder, so groupVisibleIndex is reliable
35666
+ const isFirstInList = groupVisibleIndex === null ? props.index === 0 : groupVisibleIndex === 0;
35667
+ if (!separator || isFirstInList) {
35668
+ return listItemVnode;
35669
+ }
35670
+ // separatorIndex is only used as the function-form argument (gap index)
35671
+ const separatorIndex = groupVisibleIndex === null ? visibleIndex : groupVisibleIndex;
35672
+ const separatorVnode = typeof separator === "function" ? separator(separatorIndex - 1) : separator;
35673
+ return jsxs(Fragment$1, {
35674
+ children: [separatorVnode, listItemVnode]
35675
+ });
35676
+ };
35677
+ // When an item is outside the render window it cannot render a DOM node.
35678
+ // If it wants to scroll into view it sets scrollTop so the scroll event
35679
+ // shifts the window; once the item mounts as ListItemReal its layout effect
35680
+ // calls scrollIntoViewWithStickyAwareness to fine-tune the position.
35681
+ const ListItemVoid = () => {
35682
+ return null;
35683
+ };
35684
+ const ListItemReal = props => {
35545
35685
  const {
35546
- index,
35686
+ ref,
35547
35687
  id,
35548
- highlight,
35549
35688
  hidden,
35550
- filtered,
35551
- matchScore,
35552
- defaultSelected,
35553
- selected,
35554
- pointed,
35555
- selectableArea,
35689
+ highlight,
35690
+ children,
35556
35691
  ...rest
35557
35692
  } = props;
35558
- const multiple = useContext(SelectableListMultipleContext);
35559
- const inputRef = useRef();
35560
- const inputType = multiple ? "checkbox" : "radio";
35561
- const inputId = `${id}_input`;
35562
- inputRef.nullExpected = true; // virtualization
35563
- const [checkableProps, remainingProps, ChildrenContextWrapper] = useCheckableProps({
35564
- readOnlyMessage: naviI18n(`constraints.readonly.option`, props),
35565
- ...rest,
35566
- ref: inputRef,
35567
- id: inputId,
35568
- type: inputType,
35569
- defaultChecked: defaultSelected,
35570
- checked: selected,
35571
- action: (v, {
35572
- event
35573
- }) => {
35574
- const listContainerEl = event.currentTarget.closest(".navi_list_container");
35575
- dispatchRequestAction(listContainerEl, {
35576
- event
35577
- });
35693
+ const pendingScrollRef = useContext(PendingScrollRefContext);
35694
+ const pendingScroll = pendingScrollRef.current;
35695
+ const needScrollOnMount = pendingScroll && pendingScroll.id === id;
35696
+ useLayoutEffect(() => {
35697
+ if (!needScrollOnMount) {
35698
+ return;
35578
35699
  }
35579
- });
35580
- const {
35581
- checked,
35582
- value,
35583
- basePseudoState,
35584
- children
35585
- } = checkableProps;
35586
- const readOnly = basePseudoState[":read-only"];
35587
- const disabled = basePseudoState[":disabled"];
35588
- const loading = basePseudoState[":-navi-loading"];
35589
- const realInputContextValue = useMemo(() => {
35590
- return {
35591
- id: inputId,
35592
- type: inputType,
35593
- checked,
35594
- readOnly,
35595
- value
35596
- };
35597
- }, [inputId, inputType, checked, readOnly, value]);
35598
- return jsx(ListItem, {
35700
+ const itemEl = ref.current;
35701
+ if (!itemEl) {
35702
+ return;
35703
+ }
35704
+ pendingScroll.resolve(itemEl);
35705
+ }, [needScrollOnMount]);
35706
+
35707
+ // CSS Highlight API: mark matching text ranges when highlight prop is set.
35708
+ useSearchHighlight(ref, highlight, [children, hidden]);
35709
+ return jsx(Box, {
35710
+ as: "li",
35711
+ baseClassName: "navi_list_item",
35712
+ styleCSSVars: LIST_ITEM_STYLE_CSS_VARS,
35713
+ pseudoClasses: LIST_ITEM_PSEUDO_CLASSES,
35714
+ pseudoElements: LIST_ITEM_PSEUDO_ELEMENTS,
35599
35715
  id: id,
35600
- index: index,
35601
- highlight: highlight,
35602
- filtered: filtered,
35716
+ "navi-list-item-real": "",
35717
+ ...rest,
35718
+ index: undefined,
35719
+ selected: undefined,
35720
+ matchScore: undefined,
35603
35721
  hidden: hidden,
35604
- matchScore: matchScore,
35605
- pseudoClasses: SELECTABLE_PSEUDO_CLASSES,
35606
- basePseudoState: {
35607
- ":-navi-selected": checked,
35608
- ":-navi-pointed": pointed,
35609
- ...basePseudoState
35610
- },
35611
- "aria-selected": checked,
35612
- selected: checked,
35613
- "navi-selectable": "",
35614
- children: jsxs(Field, {
35615
- as: selectableArea === "manual" ? "div" : undefined,
35616
- padding: "m",
35617
- flex: true,
35618
- alignY: "center",
35619
- spacing: "s",
35620
- expandX: true,
35621
- ...remainingProps,
35622
- selectableArea: undefined,
35623
- basePseudoState: basePseudoState,
35624
- pseudoStateSelector: "[navi-selectable-real-input]",
35625
- disabled: disabled,
35626
- readOnly: readOnly,
35627
- loading: loading,
35628
- interactive: true,
35629
- children: [jsx(SelectableRealInput, {
35630
- ...checkableProps,
35631
- // eslint-disable-next-line react/no-children-prop
35632
- children: undefined
35633
- }), jsx(SelectableRealInputContext.Provider, {
35634
- value: realInputContextValue,
35635
- children: jsx(ChildrenContextWrapper, {
35636
- children: children
35637
- })
35638
- })]
35639
- })
35640
- });
35641
- };
35642
- const SELECTABLE_PSEUDO_CLASSES = [...LIST_ITEM_PSEUDO_CLASSES, ":hover", ":disabled", ":read-only", ":focus-within", ":focus", ":focus-visible", ":-navi-loading", ":-navi-pointed", ":-navi-selected", ":disabled", ":read-only"];
35643
- const SelectableRealInput = props => {
35644
- return jsx(Box, {
35645
- as: "input",
35646
- ...props,
35647
- "navi-selectable-real-input": "",
35648
- "navi-visually-hidden": "",
35649
- "data-callout-arrow-x": "center"
35650
- // navi-debug
35722
+ ref: ref,
35723
+ children: children
35651
35724
  });
35652
35725
  };
35653
- const SelectableInputProxy = props => {
35654
- const selectableRealInputProps = useContext(SelectableRealInputContext);
35655
- if (!selectableRealInputProps) {
35656
- throw new Error("Selectable.Input must be used within a Selectable component");
35726
+ const LIST_ITEM_STYLE_CSS_VARS = {
35727
+ "paddingX": "--list-item-padding-x",
35728
+ "paddingY": "--list-item-padding-y",
35729
+ "padding": "--list-item-padding",
35730
+ "color": "--list-item-color",
35731
+ "backgroundColor": "--list-item-background-color",
35732
+ "fontWeight": "--list-item-font-weight",
35733
+ "borderWidth": "--list-item-border-width",
35734
+ "borderColor": "--list-item-border-color",
35735
+ ":-navi-pointed": {
35736
+ color: "--list-item-color-keyboard-pointed",
35737
+ backgroundColor: "--list-item-background-color-keyboard-pointed"
35738
+ },
35739
+ ":hover": {
35740
+ color: "--list-item-color-hover",
35741
+ backgroundColor: "--list-item-background-color-hover"
35742
+ },
35743
+ ":-navi-selected": {
35744
+ color: "--list-item-color-selected",
35745
+ backgroundColor: "--list-item-background-color-selected",
35746
+ borderColor: "--list-item-border-color-selected"
35747
+ },
35748
+ ":disabled": {
35749
+ color: "--list-item-color-disabled",
35750
+ backgroundColor: "--list-item-background-color-disabled"
35751
+ },
35752
+ "::highlight": {
35753
+ color: "--suggestion-color-highlight",
35754
+ backgroundColor: "--suggestion-background-color-highlight"
35657
35755
  }
35756
+ };
35757
+ const LIST_ITEM_PSEUDO_CLASSES = [];
35758
+ const LIST_ITEM_PSEUDO_ELEMENTS = ["::highlight"];
35658
35759
 
35659
- // Reset FieldToInterfaceContext to ensure we don't read id or report our
35660
- // states (real input should take id and report)
35661
- return jsx(ControlToInterfaceContext.Provider, {
35662
- value: undefined,
35663
- children: jsx(Input, {
35664
- ...props,
35665
- ...selectableRealInputProps,
35666
- id: undefined,
35667
- "navi-control-proxy-for": selectableRealInputProps.id
35668
- // give it a specific name to avoid radio name (would unselect others)
35669
- // (making it unique to the list would be enough, but here it's even more unique)
35760
+ /**
35761
+ * ListItem a trackable item that participates in virtualization.
35762
+ *
35763
+ * Must be used inside <List>. Handles:
35764
+ * - Registration with item tracker (always runs, even when hidden)
35765
+ * - Early return when outside the render window
35766
+ * - Separator rendering between visible items
35767
+ *
35768
+ * Props:
35769
+ * itemId — stable string id for tracking (auto-generated if omitted)
35770
+ * filtered — when true, item is excluded from visible count and removed from DOM entirely
35771
+ * hidden — when true, item is excluded from visible count (no virtual scroll height)
35772
+ * but stays in DOM with the native HTML hidden attribute
35773
+ * highlight — array of [start, end] ranges to highlight via CSS Highlight API
35774
+ * ...rest — forwarded to the rendered <li> element
35775
+ */
35776
+ const ListItem = createComponentResolver([ListItemFirstResolver, ListItemSelectableResolver, ListItemHeaderOrFooterResolver, ListItemPresentationResolver, ListItemUI]);
35777
+ List.Item = ListItem;
35778
+
35779
+ /**
35780
+ * ListGroup — a labeled group of list items.
35781
+ *
35782
+ * Renders a <li role="presentation"> wrapper containing a label span
35783
+ * (accessible via aria-labelledby) and a <ul role="group"> for the items.
35784
+ *
35785
+ * Props:
35786
+ * label — group label content
35787
+ * labelProps — props forwarded to the label <span>
35788
+ * ...rest — forwarded to the outer <li role="presentation">
35789
+ */
35790
+ const ListItemGroup = ({
35791
+ label,
35792
+ hiddenWhileEmpty,
35793
+ children,
35794
+ ...rest
35795
+ }) => {
35796
+ const groupId = useId();
35797
+ const groupTracker = useItemTracker();
35798
+ const groupRef = useRef(null);
35799
+ const labelRef = useRef(null);
35800
+ useDisplayedLayoutEffect(labelRef, labelEl => {
35801
+ const groupEl = groupRef.current;
35802
+ if (!groupEl) {
35803
+ return;
35804
+ }
35805
+ const rect = labelEl.getBoundingClientRect();
35806
+ groupEl.style.setProperty("--list-group-label-height", `${rect.height}px`);
35807
+ groupEl.style.setProperty("--list-group-label-width", `${rect.width}px`);
35808
+ }, []);
35809
+ return jsxs(ListItem, {
35810
+ ...rest,
35811
+ ref: groupRef,
35812
+ baseClassName: "navi_list_item_group",
35813
+ role: "presentation",
35814
+ "data-hidden-while-empty": hiddenWhileEmpty ? "" : undefined,
35815
+ children: [jsx("span", {
35816
+ ref: labelRef,
35817
+ id: groupId,
35818
+ className: "navi_list_item_group_label",
35819
+ role: "presentation"
35820
+ // eslint-disable-next-line react/no-unknown-property
35670
35821
  ,
35671
35822
 
35672
- name: `${selectableRealInputProps.id}_proxy`,
35673
- "aria-hidden": "true",
35674
- tabIndex: -1
35675
- })
35823
+ "navi-default": typeof label === "string" ? "" : undefined,
35824
+ children: label
35825
+ }), jsx("ul", {
35826
+ className: "navi_list_item_group_list",
35827
+ role: "group",
35828
+ "aria-labelledby": groupId,
35829
+ children: jsx(GroupItemTrackerContext.Provider, {
35830
+ value: groupTracker,
35831
+ children: children
35832
+ })
35833
+ })]
35676
35834
  });
35677
35835
  };
35678
- Selectable.Input = SelectableInputProxy;
35679
35836
 
35680
35837
  const PickerNaviTime = props => {
35681
35838
  const Next = useNextResolver();
@@ -35690,9 +35847,11 @@ const PickerNaviTime = props => {
35690
35847
  return jsx(Next, {
35691
35848
  ...props,
35692
35849
  type: "time",
35693
- children: jsx(SelectableList, {
35850
+ children: jsx(List, {
35851
+ selectable: true,
35694
35852
  action: "send",
35695
- children: slots.map((slot, i) => jsx(Selectable, {
35853
+ children: slots.map((slot, i) => jsx(List.Item, {
35854
+ selectable: true,
35696
35855
  id: slot,
35697
35856
  index: i,
35698
35857
  value: slot,
@@ -36042,13 +36201,6 @@ installImportMetaCssBuild(import.meta);const css$k = /* css */`
36042
36201
  * children — content to display inside the popup (enables popover/dialog mode)
36043
36202
  * mode — "popover" or "dialog"; auto-detected from screen size when omitted
36044
36203
  */
36045
- const Picker = props => {
36046
- const defaultRef = useRef(null);
36047
- props.ref = props.ref || defaultRef;
36048
- const picker = renderPicker(PickerButton, props);
36049
- return picker;
36050
- };
36051
- const renderPicker = createComponentResolver(pickerResolvers);
36052
36204
  const PickerButton = props => {
36053
36205
  import.meta.css = [css$k, "@jsenv/navi/src/control/picker/picker.jsx"];
36054
36206
  resolveInputProps(props);
@@ -36214,6 +36366,15 @@ const PickerDefaultUI = () => {
36214
36366
  children: value
36215
36367
  });
36216
36368
  };
36369
+ const PickerFirstResolver = props => {
36370
+ const Next = useNextResolver();
36371
+ const defaultRef = useRef(null);
36372
+ props.ref = props.ref || defaultRef;
36373
+ return jsx(Next, {
36374
+ ...props
36375
+ });
36376
+ };
36377
+ const Picker = createComponentResolver([PickerFirstResolver, ...pickerResolvers, PickerButton]);
36217
36378
  Picker.Placeholder = PickerPlaceholder;
36218
36379
  Picker.Value = PickerValue;
36219
36380
  Picker.UI = PickerDefaultUI;
@@ -40252,7 +40413,7 @@ const ButtonCopyToClipboard = ({
40252
40413
  children,
40253
40414
  ...props
40254
40415
  }) => {
40255
- import.meta.css = [css$c, "@jsenv/navi/src/control/button_copy_to_clipboard.jsx"];
40416
+ import.meta.css = [css$c, "@jsenv/navi/src/control/input/button_copy_to_clipboard.jsx"];
40256
40417
  const [copied, setCopied] = useState(false);
40257
40418
  const renderedRef = useRef();
40258
40419
  useEffect(() => {
@@ -42409,5 +42570,5 @@ const UserSvg = () => jsx("svg", {
42409
42570
  })
42410
42571
  });
42411
42572
 
42412
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Box, Button, ButtonCopyToClipboard, Caption, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, Details, Dialog, DialogLayout, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemFooter, ListItemGroup, ListItemHeader, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, Paragraph, Picker, Popover, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Selectable, SelectableList, SelectionContext, Separator, SettingsSvg, SidePanel, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Thead, Time, Title, Tr, UITransition, UserSvg, ViewportLayout, actionIntegratedVia, actionRunEffect, addCustomMessage, anyMatchingRouteSignal, applySearch, arraySignalMembership, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, installCustomConstraintValidation, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, langSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navTo, naviI18n, openCallout, rawUrlPart, reload, removeCustomMessage, rerunActions, resource, route, routeAction, setBaseUrl, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSidePanelClose, useSignalSync, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
42573
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Box, Button, ButtonCopyToClipboard, Caption, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, Details, Dialog, DialogLayout, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, Paragraph, Picker, Popover, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Thead, Time, Title, Tr, UITransition, UserSvg, ViewportLayout, actionIntegratedVia, actionRunEffect, addCustomMessage, anyMatchingRouteSignal, applySearch, arraySignalMembership, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, installCustomConstraintValidation, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, langSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navTo, naviI18n, openCallout, rawUrlPart, reload, removeCustomMessage, rerunActions, resource, route, routeAction, setBaseUrl, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSidePanelClose, useSignalSync, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
42413
42574
  //# sourceMappingURL=jsenv_navi.js.map