@jsenv/navi 0.27.15 → 0.27.16

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