@jsenv/navi 0.27.3 → 0.27.5

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.
@@ -12823,6 +12823,17 @@ const requestPseudoStateCheck = (element, detail) => {
12823
12823
  "navi_pseudo_state_request_check",
12824
12824
  detail,
12825
12825
  );
12826
+ // When a control has a visible proxy mirroring its state (e.g. selectable
12827
+ // radio with `navi-control-proxy-for`), re-check the proxy too so it stays
12828
+ // in sync with the real control.
12829
+ const proxy = findControlProxy(element);
12830
+ if (proxy) {
12831
+ dispatchInternalCustomEvent(
12832
+ proxy,
12833
+ "navi_pseudo_state_request_check",
12834
+ detail,
12835
+ );
12836
+ }
12826
12837
  };
12827
12838
  const NAVI_PSEUDO_STATE_CUSTOM_EVENT = "navi_pseudo_state";
12828
12839
  const dispatchPseudoStateCustomEvent = (element, value, oldValue) => {
@@ -13045,7 +13056,10 @@ definePseudoClass(":active", {
13045
13056
  // component.
13046
13057
  //
13047
13058
  // When a controller element (e.g. combobox input) gains or loses focus,
13048
- // notify the elements it controls via aria-controls so they re-check their focus state.
13059
+ // notify the elements it controls via aria-controls so they re-check their
13060
+ // focus state. `requestPseudoStateCheck` also re-checks the controlled
13061
+ // element's proxy (if any), so the visible proxy mirrors the hidden real
13062
+ // input's inherited focus.
13049
13063
  const notifyAriaControlled = (el, e) => {
13050
13064
  const controlledIds = el.getAttribute("aria-controls");
13051
13065
  if (!controlledIds) {
@@ -13058,53 +13072,104 @@ definePseudoClass(":active", {
13058
13072
  }
13059
13073
  }
13060
13074
  };
13061
- // Check if any element whose aria-controls includes el's id currently has focus.
13062
- const isControlledByFocusedElement = (
13063
- el,
13064
- { requireFocusVisible = false } = {},
13065
- ) => {
13066
- const id = el.id;
13067
- if (!id) {
13075
+ // Returns true when el holds focus indirectly either because a controlling
13076
+ // element (aria-controls) has focus, or because el is a proxy whose target
13077
+ // is itself controlled by a focused element.
13078
+ const hasIndirectFocus = (el, { requireFocusVisible = false } = {}) => {
13079
+ const pseudoClass = requireFocusVisible ? ":focus-visible" : ":focus";
13080
+ const isControlledBy = (target) => {
13081
+ const id = target.id;
13082
+ if (!id) {
13083
+ return false;
13084
+ }
13085
+ const controllers = document.querySelectorAll(`[aria-controls~="${id}"]`);
13086
+ for (const controller of controllers) {
13087
+ // If the controller is inside the element it controls, focus is already
13088
+ // native (:focus-within) — no need to inherit it.
13089
+ if (target.contains(controller)) {
13090
+ continue;
13091
+ }
13092
+ if (controller.matches(pseudoClass)) {
13093
+ return true;
13094
+ }
13095
+ }
13068
13096
  return false;
13097
+ };
13098
+ if (isControlledBy(el)) {
13099
+ return true;
13069
13100
  }
13070
- const controllers = document.querySelectorAll(`[aria-controls~="${id}"]`);
13071
- for (const controller of controllers) {
13072
- // If the controller is inside the element it controls, the element already
13073
- // receives native :focus/:focus-within — no need to inherit focus from it.
13074
- if (el.contains(controller)) {
13075
- continue;
13101
+ const proxyTarget = findControlProxyTarget(el);
13102
+ if (proxyTarget) {
13103
+ if (proxyTarget.matches(pseudoClass)) {
13104
+ return true;
13076
13105
  }
13077
- const pseudoClass = requireFocusVisible ? ":focus-visible" : ":focus";
13078
- if (controller.matches(pseudoClass)) {
13106
+ if (isControlledBy(proxyTarget)) {
13079
13107
  return true;
13080
13108
  }
13081
13109
  }
13082
13110
  return false;
13083
13111
  };
13084
13112
 
13113
+ // Shared setup for :focus and :focus-visible. Both need focusin/focusout
13114
+ // listeners + a MutationObserver on aria-controls so that when the attribute
13115
+ // changes while the element is focused, old and new controlled elements are
13116
+ // notified to re-check their own focus state.
13117
+ // extraSetup: optional (el, callback) => teardown for pseudo-class-specific
13118
+ // listeners (e.g. keydown/keyup for :focus-visible).
13119
+ const setupFocus = (el, callback) => {
13120
+ const onFocusChange = (e) => {
13121
+ callback();
13122
+ notifyAriaControlled(el, e);
13123
+ };
13124
+ el.addEventListener("focusin", onFocusChange);
13125
+ el.addEventListener("focusout", onFocusChange);
13126
+ // Only observe aria-controls mutations when the element already has the
13127
+ // attribute at setup time. If aria-controls is guaranteed to be set before
13128
+ // initPseudoStyles runs (e.g. passed as a prop in box.jsx), this covers all
13129
+ // real cases without paying the MutationObserver cost for every element.
13130
+ let observer;
13131
+ // if (el.hasAttribute("aria-controls")) {
13132
+ observer = new MutationObserver((mutations) => {
13133
+ if (!el.matches(":focus-within")) {
13134
+ return;
13135
+ }
13136
+ for (const mutation of mutations) {
13137
+ const oldIds = (mutation.oldValue || "").split(" ").filter(Boolean);
13138
+ for (const id of oldIds) {
13139
+ const controlled = document.getElementById(id);
13140
+ if (controlled) {
13141
+ requestPseudoStateCheck(controlled, {});
13142
+ }
13143
+ }
13144
+ }
13145
+ notifyAriaControlled(el, {});
13146
+ });
13147
+ observer.observe(el, {
13148
+ attributes: true,
13149
+ attributeFilter: ["aria-controls"],
13150
+ attributeOldValue: true,
13151
+ });
13152
+ // }
13153
+ return () => {
13154
+ el.removeEventListener("focusin", onFocusChange);
13155
+ el.removeEventListener("focusout", onFocusChange);
13156
+ observer?.disconnect();
13157
+ };
13158
+ };
13159
+
13085
13160
  definePseudoClass(":focus", {
13086
13161
  attribute: "data-focus",
13087
13162
  setup: (el, callback) => {
13088
- const onFocusChange = (e) => {
13089
- callback();
13090
- notifyAriaControlled(el, e);
13091
- };
13092
- el.addEventListener("focusin", onFocusChange);
13093
- el.addEventListener("focusout", onFocusChange);
13163
+ const cleanup = setupFocus(el, callback);
13094
13164
  return () => {
13095
- el.removeEventListener("focusin", onFocusChange);
13096
- el.removeEventListener("focusout", onFocusChange);
13165
+ cleanup();
13097
13166
  };
13098
13167
  },
13099
13168
  test: (el) => {
13100
13169
  if (el.matches(":focus")) {
13101
13170
  return true;
13102
13171
  }
13103
- const proxyTarget = findControlProxyTarget(el);
13104
- if (proxyTarget && proxyTarget.matches(":focus")) {
13105
- return true;
13106
- }
13107
- if (isControlledByFocusedElement(el)) {
13172
+ if (hasIndirectFocus(el)) {
13108
13173
  return true;
13109
13174
  }
13110
13175
  return false;
@@ -13113,30 +13178,20 @@ definePseudoClass(":active", {
13113
13178
  definePseudoClass(":focus-visible", {
13114
13179
  attribute: "data-focus-visible",
13115
13180
  setup: (el, callback) => {
13116
- const onFocusChange = (e) => {
13117
- callback();
13118
- notifyAriaControlled(el, e);
13119
- };
13181
+ const cleanup = setupFocus(el, callback);
13120
13182
  document.addEventListener("keydown", callback);
13121
13183
  document.addEventListener("keyup", callback);
13122
- el.addEventListener("focusin", onFocusChange);
13123
- el.addEventListener("focusout", onFocusChange);
13124
13184
  return () => {
13185
+ cleanup();
13125
13186
  document.removeEventListener("keydown", callback);
13126
13187
  document.removeEventListener("keyup", callback);
13127
- el.removeEventListener("focusin", onFocusChange);
13128
- el.removeEventListener("focusout", onFocusChange);
13129
13188
  };
13130
13189
  },
13131
13190
  test: (el) => {
13132
13191
  if (el.matches(":focus-visible")) {
13133
13192
  return true;
13134
13193
  }
13135
- const proxyTarget = findControlProxyTarget(el);
13136
- if (proxyTarget && proxyTarget.matches(":focus-visible")) {
13137
- return true;
13138
- }
13139
- if (isControlledByFocusedElement(el, { requireFocusVisible: true })) {
13194
+ if (hasIndirectFocus(el, { requireFocusVisible: true })) {
13140
13195
  return true;
13141
13196
  }
13142
13197
  return false;
@@ -13160,7 +13215,7 @@ definePseudoClass(":active", {
13160
13215
  if (el.matches(":focus-within")) {
13161
13216
  return true;
13162
13217
  }
13163
- if (isControlledByFocusedElement(el)) {
13218
+ if (hasIndirectFocus(el)) {
13164
13219
  return true;
13165
13220
  }
13166
13221
  if (el.contains(document.activeElement)) {
@@ -18945,6 +19000,22 @@ naviI18n.addAll({
18945
19000
  },
18946
19001
  });
18947
19002
 
19003
+ // List messages — override any key to customize list messages
19004
+ naviI18n.addAll({
19005
+ "list.empty": {
19006
+ en: "No items in this list.",
19007
+ fr: "Aucun élément dans cette liste.",
19008
+ },
19009
+ "list.no_match": {
19010
+ en: "No item matches this search.",
19011
+ fr: "Aucun élément ne correspond à cette recherche.",
19012
+ },
19013
+ "list.no_match_rest_shown": {
19014
+ en: "No item matches this search. The rest is shown below.",
19015
+ fr: "Aucun élément ne correspond à cette recherche. Le reste est affiché ci-dessous.",
19016
+ },
19017
+ });
19018
+
18948
19019
  // Constraint validation messages — override any key to customize error messages
18949
19020
  naviI18n.addAll({
18950
19021
  "constraint.available": {
@@ -21673,11 +21744,12 @@ const useCustomValidationRef = (
21673
21744
  }
21674
21745
  const element = elementRef.current;
21675
21746
  if (!element) {
21676
- console.warn(
21677
- `useCustomValidationRef: elementRef.current is null, make sure to pass a ref to an element
21747
+ if (!elementRef.nullExpected) {
21748
+ console.warn(
21749
+ `useCustomValidationRef: elementRef.current is null, make sure to pass a ref to an element
21678
21750
  ${callSiteRef.current}`,
21679
- );
21680
- /* can happen if the component does this for instance:
21751
+ );
21752
+ /* can happen if the component does this for instance:
21681
21753
  const Component = () => {
21682
21754
  const ref = useRef(null)
21683
21755
 
@@ -21689,6 +21761,7 @@ ${callSiteRef.current}`,
21689
21761
 
21690
21762
  usually it's better to split the component in two but hey
21691
21763
  */
21764
+ }
21692
21765
  return undefined;
21693
21766
  }
21694
21767
  let target;
@@ -22997,10 +23070,7 @@ const applyKeyboardShortcuts = (shortcuts, keyboardEvent) => {
22997
23070
  } else {
22998
23071
  // we need to check if the default action is something that we can allow to be intercepted
22999
23072
  const defaultAction = getKeyboardEventDefaultAction(keyboardEvent);
23000
- canIntercept =
23001
- !defaultAction ||
23002
- defaultAction === "scroll" ||
23003
- defaultAction === "dismiss";
23073
+ canIntercept = !defaultAction || defaultAction === "scroll";
23004
23074
  }
23005
23075
  if (!canIntercept) {
23006
23076
  return null;
@@ -24518,6 +24588,13 @@ const useAutoFocus = (
24518
24588
  if (!focusableElement) {
24519
24589
  return () => {};
24520
24590
  }
24591
+ // Only autofocus when the element is mounted directly on the document.
24592
+ // Any other event type means an expandable (popover, dialog, …) just opened
24593
+ // and revealed this element — the expandable's opening logic already calls
24594
+ // focusFirstAutofocusOrFocusable, so we must not steal focus here.
24595
+ if (e.type !== "navi_displayed_on_document") {
24596
+ return () => {};
24597
+ }
24521
24598
  const activeElement = document.activeElement;
24522
24599
  const focusDebugCall = `${getElementSignature(focusableElement)}.focus({ preventScroll: ${preventScroll} })`;
24523
24600
  if (e.type === "navi_displayed_on_document") {
@@ -24920,12 +24997,8 @@ const createUICallback = ({ name = "ui callback", event, action }) => {
24920
24997
  if (event && action) {
24921
24998
  return (...args) => {
24922
24999
  return routeArgs(args, {
24923
- event: (e) => {
24924
- return event(e);
24925
- },
24926
- action: (value, actionSecondArg) => {
24927
- return action(value, actionSecondArg);
24928
- },
25000
+ event,
25001
+ action,
24929
25002
  other: () => {
24930
25003
  console.warn(
24931
25004
  `${name} unsupported call attempt. It is designed to be called by action.`,
@@ -24944,9 +25017,7 @@ const createUICallback = ({ name = "ui callback", event, action }) => {
24944
25017
  );
24945
25018
  return false;
24946
25019
  },
24947
- action: (value, secondArg) => {
24948
- return action(value, secondArg);
24949
- },
25020
+ action,
24950
25021
  other: () => {
24951
25022
  console.warn(
24952
25023
  `${name} unsupported call attempt. It is designed to be called by action.`,
@@ -24959,9 +25030,7 @@ const createUICallback = ({ name = "ui callback", event, action }) => {
24959
25030
  // event only
24960
25031
  return (...args) => {
24961
25032
  return routeArgs(args, {
24962
- event: (e) => {
24963
- return event(e);
24964
- },
25033
+ event,
24965
25034
  action: (_, { event: eventFromArg }) => {
24966
25035
  console.info(
24967
25036
  `${name} got called by action. It works but is designed to be called by DOM`,
@@ -24999,8 +25068,8 @@ const routeArgs = (args, { event, action, other }) => {
24999
25068
  return other(...args);
25000
25069
  };
25001
25070
 
25002
- const triggerStringAction = (actionName, event) => {
25003
- return resolveActionProp(actionName)(event);
25071
+ const triggerStringAction = (actionName, event, options) => {
25072
+ return resolveActionProp(actionName)(event, options);
25004
25073
  };
25005
25074
  const resolveActionProp = (action) => {
25006
25075
  if (typeof action === "string") {
@@ -25198,11 +25267,13 @@ const requestClosestAction = (event) => {
25198
25267
  */
25199
25268
  const clear = createUICallback({
25200
25269
  name: "clear",
25201
- event: (event) => {
25270
+ event: (event, { skipClose } = {}) => {
25202
25271
  requestUpdate(event, "", { isClear: true });
25203
- const expandableEl = event.currentTarget.closest("[aria-expanded]");
25204
- if (expandableEl) {
25205
- return requestClose(event);
25272
+ if (!skipClose) {
25273
+ const expandableEl = event.currentTarget.closest("[aria-expanded]");
25274
+ if (expandableEl) {
25275
+ return requestClose(event);
25276
+ }
25206
25277
  }
25207
25278
  return true;
25208
25279
  },
@@ -25831,7 +25902,8 @@ const useInteractiveProps = (props, {
25831
25902
  autoSelect
25832
25903
  });
25833
25904
  Object.assign(controlProps, {
25834
- "navi-autofocus": autoFocus ? "" : undefined
25905
+ "navi-autofocus": autoFocus ? autoFocus === true ? "" : autoFocus : undefined,
25906
+ "navi-autofocus-select": autoFocus && autoSelect ? "" : undefined
25835
25907
  });
25836
25908
  }
25837
25909
  {
@@ -29903,6 +29975,78 @@ const Label = props => {
29903
29975
  };
29904
29976
  const LabelPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
29905
29977
 
29978
+ const InputWithList = props => {
29979
+ const Next = useNextResolver();
29980
+ const {
29981
+ "navi-list": naviList,
29982
+ onKeyDown
29983
+ } = props;
29984
+ const getListEl = () => {
29985
+ return document.getElementById(naviList);
29986
+ };
29987
+ const [currentId, setCurrentId] = useState(() => {
29988
+ const listEl = getListEl();
29989
+ return listEl ? listEl.getAttribute("navi-current-id") || null : null;
29990
+ });
29991
+ useLayoutEffect(() => {
29992
+ const listEl = getListEl();
29993
+ if (!listEl) {
29994
+ return undefined;
29995
+ }
29996
+ // Sync in case list updated before our effect ran.
29997
+ setCurrentId(listEl.getAttribute("navi-current-id") || null);
29998
+ const onCurrentChange = e => {
29999
+ setCurrentId(e.detail.id || null);
30000
+ };
30001
+ listEl.addEventListener("navi_current_change", onCurrentChange);
30002
+ return () => {
30003
+ listEl.removeEventListener("navi_current_change", onCurrentChange);
30004
+ };
30005
+ }, [naviList]);
30006
+ const requestListNav = (e, goal) => {
30007
+ const listEl = getListEl();
30008
+ if (!listEl) {
30009
+ return;
30010
+ }
30011
+ e.preventDefault();
30012
+ e.stopPropagation();
30013
+ dispatchCustomEvent(listEl, "navi_request_nav", {
30014
+ event: e,
30015
+ goal
30016
+ });
30017
+ };
30018
+ const onKeyDownShortcuts = createOnKeyDownForShortcuts({
30019
+ arrowdown: e => requestListNav(e, "down"),
30020
+ arrowup: e => requestListNav(e, "up"),
30021
+ home: e => requestListNav(e, "first"),
30022
+ end: e => requestListNav(e, "last"),
30023
+ enter: e => {
30024
+ const listEl = getListEl();
30025
+ if (!listEl) {
30026
+ return;
30027
+ }
30028
+ e.preventDefault();
30029
+ e.stopPropagation();
30030
+ dispatchCustomEvent(listEl, "navi_request_activate", {
30031
+ event: e
30032
+ });
30033
+ }
30034
+ });
30035
+ return jsx(Next, {
30036
+ role: "combobox",
30037
+ "aria-haspopup": "listbox",
30038
+ "aria-autocomplete": "list",
30039
+ "aria-controls": currentId ? `${currentId}_input` : undefined,
30040
+ "aria-activedescendant": currentId || undefined,
30041
+ autoComplete: "off",
30042
+ ...props,
30043
+ onKeyDown: e => {
30044
+ onKeyDown?.(e);
30045
+ onKeyDownShortcuts(e);
30046
+ }
30047
+ });
30048
+ };
30049
+
29906
30050
  installImportMetaCssBuild(import.meta);/**
29907
30051
  * Input component for all textual input types.
29908
30052
  *
@@ -30169,6 +30313,11 @@ const InputTextual = props => {
30169
30313
  };
30170
30314
  const InputTextualWithListResolver = props => {
30171
30315
  const Next = useNextResolver();
30316
+ if (props["navi-list"]) {
30317
+ return jsx(InputWithList, {
30318
+ ...props
30319
+ });
30320
+ }
30172
30321
  if (props.suggestions) {
30173
30322
  return jsx(InputTextualWithSuggestions, {
30174
30323
  ...props
@@ -30441,7 +30590,9 @@ const InputSearchUI = ({
30441
30590
  const input = e.currentTarget;
30442
30591
  const allowed = dispatchRequestInteraction(input, e);
30443
30592
  if (allowed) {
30444
- triggerStringAction("clear", e);
30593
+ triggerStringAction("clear", e, {
30594
+ skipClose: true
30595
+ });
30445
30596
  }
30446
30597
  },
30447
30598
  children: jsx(Icon, {
@@ -31351,23 +31502,76 @@ window.addEventListener("resize", () => {
31351
31502
 
31352
31503
  /**
31353
31504
  * Mirrors what browsers do when navigating to a page:
31354
- * 1. Focus the first element with [navi-autofocus] inside the container
31505
+ * 1. Focus the first element with [navi-autofocus] (but not [navi-autofocus="fallback"]) inside the container
31355
31506
  * 2. Fall back to the first focusable element
31507
+ * 3. Fall back to the first element with [navi-autofocus="fallback"]
31356
31508
  * Does nothing if no candidate is found.
31357
31509
  */
31510
+ const markAutofocusRestoreOnClose = (containerEl) => {
31511
+ const focused = document.activeElement;
31512
+ if (
31513
+ focused &&
31514
+ containerEl.contains(focused) &&
31515
+ focused.getAttribute("navi-autofocus") === "fallback"
31516
+ ) {
31517
+ containerEl.setAttribute("navi-autofocus-restore", "");
31518
+ } else {
31519
+ containerEl.removeAttribute("navi-autofocus-restore");
31520
+ }
31521
+ };
31522
+
31358
31523
  const focusFirstAutofocusOrFocusable = (containerEl, debugFocus, e) => {
31359
- let target = containerEl.querySelector("[navi-autofocus]");
31524
+ let target;
31525
+ let reason;
31526
+ if (containerEl.hasAttribute("navi-autofocus-restore")) {
31527
+ containerEl.removeAttribute("navi-autofocus-restore");
31528
+ const naviAutoFocusFallback = containerEl.querySelector(
31529
+ "[navi-autofocus='fallback']",
31530
+ );
31531
+ if (naviAutoFocusFallback) {
31532
+ reason = "navi-autofocus fallback (restore)";
31533
+ target = naviAutoFocusFallback;
31534
+ }
31535
+ }
31536
+ if (!target) {
31537
+ const naviAutoFocus = containerEl.querySelector(
31538
+ "[navi-autofocus]:not([navi-autofocus='fallback'])",
31539
+ );
31540
+ if (naviAutoFocus) {
31541
+ reason = "navi-autofocus";
31542
+ target = naviAutoFocus;
31543
+ }
31544
+ }
31545
+ if (!target) {
31546
+ const focusable = findFocusable(containerEl, {
31547
+ exclude: (el) => el.getAttribute("navi-autofocus") === "fallback",
31548
+ });
31549
+ if (focusable) {
31550
+ reason = "first focusable element";
31551
+ target = focusable;
31552
+ }
31553
+ }
31360
31554
  if (!target) {
31361
- target = findFocusable(containerEl);
31555
+ const naviAutoFocusFallback = containerEl.querySelector(
31556
+ "[navi-autofocus='fallback']",
31557
+ );
31558
+ if (naviAutoFocusFallback) {
31559
+ reason = "navi-autofocus fallback";
31560
+ target = naviAutoFocusFallback;
31561
+ }
31362
31562
  }
31363
31563
  if (!target) {
31364
31564
  return;
31365
31565
  }
31366
31566
  debugFocus(
31367
31567
  e,
31368
- `Moving focus to ${getElementSignature(target)}.focus({ preventScroll: true })`,
31568
+ `Moving focus to ${getElementSignature(target)}.focus({ preventScroll: true }) (reason: ${reason})`,
31369
31569
  );
31370
31570
  target.focus({ preventScroll: true });
31571
+ if (target.hasAttribute("navi-autofocus-select")) {
31572
+ target.select();
31573
+ target.scrollLeft = 0;
31574
+ }
31371
31575
  };
31372
31576
 
31373
31577
  const useCleanup = () => {
@@ -31435,6 +31639,7 @@ const Dialog = props => {
31435
31639
  const close = e => {
31436
31640
  debugPopup(`"${e.type}" on ${getElementSignature(e.target)} -> closeDialog`);
31437
31641
  const dialogEl = ref.current;
31642
+ markAutofocusRestoreOnClose(dialogEl);
31438
31643
  dialogEl.close();
31439
31644
  cleanup();
31440
31645
  openedRef.current = false;
@@ -31615,6 +31820,7 @@ const Popover = props => {
31615
31820
  const close = e => {
31616
31821
  debugPopup(e, `closePopover()`);
31617
31822
  const popoverEl = ref.current;
31823
+ markAutofocusRestoreOnClose(popoverEl);
31618
31824
  popoverEl.hidePopover();
31619
31825
  cleanup();
31620
31826
  openedRef.current = false;
@@ -33724,6 +33930,7 @@ const ListUI = props => {
33724
33930
  const {
33725
33931
  ref,
33726
33932
  renderBudget = RENDER_BUDGET_DEFAULT,
33933
+ renderBudgetSkipCheck,
33727
33934
  role,
33728
33935
  fallback,
33729
33936
  noMatchFallback,
@@ -33739,7 +33946,7 @@ const ListUI = props => {
33739
33946
  searchText,
33740
33947
  ...rest
33741
33948
  } = props;
33742
- if (renderBudget < 30) {
33949
+ if (renderBudget < 30 && !renderBudgetSkipCheck) {
33743
33950
  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}).`);
33744
33951
  }
33745
33952
 
@@ -34320,7 +34527,7 @@ const NoMatchFallback = ({
34320
34527
  const noneMatch = searchText && visibleItemCount > 0 && matchCount === 0;
34321
34528
  const showMatchFallback = allHidden || noneMatch;
34322
34529
  if (noMatchFallback === undefined) {
34323
- noMatchFallback = allHidden ? "Aucun élément ne correspond à cette recherche." : "Aucun élément ne correspond à cette recherche. Le reste est affiché ci-dessous";
34530
+ noMatchFallback = allHidden ? naviI18n("list.no_match") : naviI18n("list.no_match_rest_shown");
34324
34531
  }
34325
34532
  return jsx(ListItem, {
34326
34533
  role: "presentation",
@@ -34337,7 +34544,7 @@ const Fallback = ({
34337
34544
  const itemCount = tracker.countSignal.value;
34338
34545
  const showFallback = itemCount === 0;
34339
34546
  if (fallback === undefined) {
34340
- fallback = "Aucun élément dans cette liste.";
34547
+ fallback = naviI18n("list.empty");
34341
34548
  }
34342
34549
  return jsx(ListItem, {
34343
34550
  role: "presentation",
@@ -34508,6 +34715,7 @@ const ListItemReal = props => {
34508
34715
  id: id,
34509
34716
  "navi-list-item-real": "",
34510
34717
  ...rest,
34718
+ index: undefined,
34511
34719
  hidden: hidden,
34512
34720
  ref: ref,
34513
34721
  children: children
@@ -34819,6 +35027,75 @@ const SelectableList = props => {
34819
35027
  // Up/Down navigate between list items only (the visually-hidden real inputs).
34820
35028
  ySelector: "[navi-selectable-real-input]"
34821
35029
  });
35030
+
35031
+ // "Current item" tracking — the item that an external controller (e.g. an
35032
+ // <input navi-list>) navigates from. Defaults to the first selected item,
35033
+ // else the first navigable item. Updated when:
35034
+ // - an item's real input gains focus (via Tab, click, etc.)
35035
+ // - the controller dispatches navi_request_list_nav
35036
+ // The current id is announced via navi_list_current_change (bubbling) so a
35037
+ // connected input can update its aria-controls / aria-activedescendant.
35038
+ const currentIdRef = useRef(null);
35039
+ const setCurrentId = (id, event) => {
35040
+ const previousId = currentIdRef.current;
35041
+ if (previousId === id) {
35042
+ return;
35043
+ }
35044
+ currentIdRef.current = id;
35045
+ const listEl = ref.current;
35046
+ if (!listEl) {
35047
+ return;
35048
+ }
35049
+ if (id) {
35050
+ listEl.setAttribute("navi-current-id", id);
35051
+ } else {
35052
+ listEl.removeAttribute("navi-current-id");
35053
+ }
35054
+ dispatchPublicCustomEvent(listEl, "navi_current_change", {
35055
+ event,
35056
+ id,
35057
+ realInputId: id ? `${id}_input` : null
35058
+ });
35059
+ };
35060
+ const getNavigableElements = () => {
35061
+ const listEl = ref.current;
35062
+ if (!listEl) {
35063
+ return [];
35064
+ }
35065
+ const itemEls = Array.from(listEl.querySelectorAll("[navi-list-item-real]"));
35066
+ const navigableEls = [];
35067
+ for (const itemEl of itemEls) {
35068
+ if (itemEl.hidden) {
35069
+ continue;
35070
+ }
35071
+ const realInput = itemEl.querySelector("[navi-selectable-real-input]");
35072
+ if (!realInput || realInput.disabled) {
35073
+ continue;
35074
+ }
35075
+ navigableEls.push(itemEl);
35076
+ }
35077
+ return navigableEls;
35078
+ };
35079
+ // On mount: set the initial current item to the first selected, else the first navigable.
35080
+ // After that, focusin events on the list keep currentIdRef up to date.
35081
+ useLayoutEffect(() => {
35082
+ const navigableEls = getNavigableElements();
35083
+ if (navigableEls.length === 0) {
35084
+ return;
35085
+ }
35086
+ let initialEl;
35087
+ for (const el of navigableEls) {
35088
+ const realInput = el.querySelector("[navi-selectable-real-input]");
35089
+ if (realInput && realInput.checked) {
35090
+ initialEl = el;
35091
+ break;
35092
+ }
35093
+ }
35094
+ if (!initialEl) {
35095
+ initialEl = navigableEls[0];
35096
+ }
35097
+ setCurrentId(initialEl.id);
35098
+ }, []);
34822
35099
  const listVnode = jsx(List, {
34823
35100
  as: "fieldset",
34824
35101
  "navi-has-selected-background": selectedIndicator === "backgroundColor" ? "" : undefined,
@@ -34826,7 +35103,20 @@ const SelectableList = props => {
34826
35103
  ...remainingProps,
34827
35104
  name: undefined,
34828
35105
  selectedIndicator: undefined,
34829
- multiple: undefined,
35106
+ multiple: undefined
35107
+ // Track focus inside the list: whichever item gets focus becomes current.
35108
+ ,
35109
+
35110
+ onFocusIn: e => {
35111
+ const realInput = e.target.closest("[navi-selectable-real-input]");
35112
+ if (!realInput) {
35113
+ return;
35114
+ }
35115
+ const itemEl = realInput.closest("[navi-list-item-real]");
35116
+ if (itemEl && itemEl.id) {
35117
+ setCurrentId(itemEl.id, e);
35118
+ }
35119
+ },
34830
35120
  onnavi_request_select: e => {
34831
35121
  const {
34832
35122
  id
@@ -34875,6 +35165,70 @@ const SelectableList = props => {
34875
35165
  });
34876
35166
  }
34877
35167
  },
35168
+ onnavi_request_nav: e => {
35169
+ const {
35170
+ goal
35171
+ } = e.detail;
35172
+ const navigableEls = getNavigableElements();
35173
+ if (navigableEls.length === 0) {
35174
+ return;
35175
+ }
35176
+ const currentId = currentIdRef.current;
35177
+ let currentIndex = -1;
35178
+ if (currentId) {
35179
+ currentIndex = navigableEls.findIndex(el => el.id === currentId);
35180
+ }
35181
+ let targetEl;
35182
+ if (goal === "first") {
35183
+ targetEl = navigableEls[0];
35184
+ } else if (goal === "last") {
35185
+ targetEl = navigableEls[navigableEls.length - 1];
35186
+ } else if (goal === "down") {
35187
+ if (currentIndex === -1) {
35188
+ targetEl = navigableEls[0];
35189
+ } else if (currentIndex < navigableEls.length - 1) {
35190
+ targetEl = navigableEls[currentIndex + 1];
35191
+ } else {
35192
+ targetEl = navigableEls[navigableEls.length - 1];
35193
+ }
35194
+ } else if (goal === "up") {
35195
+ if (currentIndex === -1) {
35196
+ targetEl = navigableEls[0];
35197
+ } else if (currentIndex > 0) {
35198
+ targetEl = navigableEls[currentIndex - 1];
35199
+ } else {
35200
+ targetEl = navigableEls[0];
35201
+ }
35202
+ }
35203
+ if (!targetEl) {
35204
+ return;
35205
+ }
35206
+ setCurrentId(targetEl.id, e);
35207
+ dispatchCustomEvent(ref.current, "navi_request_scroll", {
35208
+ event: e,
35209
+ id: targetEl.id
35210
+ });
35211
+ },
35212
+ onnavi_request_activate: e => {
35213
+ const currentId = currentIdRef.current;
35214
+ if (!currentId) {
35215
+ return;
35216
+ }
35217
+ if (multiple) {
35218
+ const inputId = `${currentId}_input`;
35219
+ const childController = uiGroupStateController.findChildById(inputId);
35220
+ const isSelected = childController && childController.uiState;
35221
+ dispatchCustomEvent(ref.current, isSelected ? "navi_request_unselect" : "navi_request_select", {
35222
+ event: e,
35223
+ id: currentId
35224
+ });
35225
+ return;
35226
+ }
35227
+ dispatchCustomEvent(ref.current, "navi_request_select", {
35228
+ event: e,
35229
+ id: currentId
35230
+ });
35231
+ },
34878
35232
  children: jsx(ControlgroupChildrenWrapper, {
34879
35233
  ...childrenWrapperProps,
34880
35234
  children: props.children
@@ -34893,6 +35247,7 @@ const Selectable = props => {
34893
35247
  highlight,
34894
35248
  hidden,
34895
35249
  filtered,
35250
+ matchScore,
34896
35251
  defaultSelected,
34897
35252
  selected,
34898
35253
  pointed,
@@ -34903,6 +35258,7 @@ const Selectable = props => {
34903
35258
  const inputRef = useRef();
34904
35259
  const inputType = multiple ? "checkbox" : "radio";
34905
35260
  const inputId = `${id}_input`;
35261
+ inputRef.nullExpected = true; // virtualization
34906
35262
  const [checkableProps, remainingProps, ChildrenContextWrapper] = useCheckableProps({
34907
35263
  readOnlyMessage: naviI18n(`constraints.readonly.option`, props),
34908
35264
  ...rest,
@@ -34944,6 +35300,7 @@ const Selectable = props => {
34944
35300
  highlight: highlight,
34945
35301
  filtered: filtered,
34946
35302
  hidden: hidden,
35303
+ matchScore: matchScore,
34947
35304
  pseudoClasses: SELECTABLE_PSEUDO_CLASSES,
34948
35305
  basePseudoState: {
34949
35306
  ":-navi-selected": checked,