@jsenv/navi 0.29.78 → 0.29.80

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.
@@ -21678,6 +21678,393 @@ const createDisplayedEvent = (ancestor, becauseAncestorOpened) => {
21678
21678
  });
21679
21679
  };
21680
21680
 
21681
+ /**
21682
+ * Decides which element receives focus when a container (popover, dialog, …)
21683
+ * opens, and gives it back to where it came from when the container closes.
21684
+ *
21685
+ * The [navi-autofocus] attribute (written by use_auto_focus.js) tunes where
21686
+ * focus lands. Candidates are tried in this order:
21687
+ * 1. The element that held focus when the container was last closed
21688
+ * 2. [navi-autofocus] asking for it ("" for a plain `autoFocus`)
21689
+ * 3. The first focusable element
21690
+ * 4. [navi-autofocus="last-resort"], the container itself included
21691
+ * 5. The element focused before the container opened
21692
+ *
21693
+ * [navi-autofocus="restore"] appears in step 1 only: it never claims focus on
21694
+ * a fresh open, it only gets it back.
21695
+ *
21696
+ * A ladder that comes back empty — a container holding nothing focusable yet —
21697
+ * places no focus, and says so on the container ([navi-autofocus-unplaced]),
21698
+ * because content arriving a moment later would otherwise stand aside for a
21699
+ * transfer that never happened (see claimUnplacedAutofocus).
21700
+ */
21701
+
21702
+ // The element that held focus when a container closed is marked with
21703
+ // [navi-autofocus-last-focused], and its container with
21704
+ // [navi-autofocus-restore]. Both carry the same generated id: containers can
21705
+ // nest (a popover inside a dialog), so the id is what tells a reopening
21706
+ // container which mark among its descendants is its own.
21707
+ let restoreIdCounter = 0;
21708
+
21709
+ // The values that never ASK for the focus: one takes it for want of anything
21710
+ // better ("last-resort"), the other only takes it back ("restore"). What they
21711
+ // have in common is being worth giving back to — a container that was holding
21712
+ // the keyboard itself, a field that said it wants it, are both places one was,
21713
+ // and coming back to where one was is the whole point of a restore.
21714
+ const isRestorableAutofocus = (el) => {
21715
+ const value = el.getAttribute("navi-autofocus");
21716
+ return value === "last-resort" || value === "restore";
21717
+ };
21718
+
21719
+ const clearAutofocusRestore = (containerEl) => {
21720
+ const restoreId = containerEl.getAttribute("navi-autofocus-restore");
21721
+ if (restoreId === null) {
21722
+ return null;
21723
+ }
21724
+ containerEl.removeAttribute("navi-autofocus-restore");
21725
+ const selector = `[navi-autofocus-last-focused="${restoreId}"]`;
21726
+ const lastFocused = containerEl.matches(selector)
21727
+ ? containerEl
21728
+ : containerEl.querySelector(selector);
21729
+ if (lastFocused) {
21730
+ lastFocused.removeAttribute("navi-autofocus-last-focused");
21731
+ }
21732
+ return lastFocused;
21733
+ };
21734
+
21735
+ // An opening whose transfer had nothing to give: the ladder came back empty, or
21736
+ // only found somewhere outside the container to leave the focus (see
21737
+ // transferFocus). Nothing inside was focused, so nothing inside owes the
21738
+ // opening anything either — what mounts or gets displayed right after may
21739
+ // claim the focus with its own autofocus (see use_auto_focus.js), instead of
21740
+ // standing aside for a transfer that placed nothing.
21741
+ const AUTOFOCUS_UNPLACED_ATTRIBUTE = "navi-autofocus-unplaced";
21742
+
21743
+ /**
21744
+ * "Did this container's opening leave the focus unplaced, and may I take it?" —
21745
+ * asked by whatever appears inside it right after. Answering yes settles the
21746
+ * debt: the first to ask is the one the opening was missing, and the ones after
21747
+ * it are content appearing alongside, which has no more claim than usual.
21748
+ *
21749
+ * @param {HTMLElement} containerEl
21750
+ * @returns {boolean}
21751
+ */
21752
+ const claimUnplacedAutofocus = (containerEl) => {
21753
+ if (!containerEl.hasAttribute?.(AUTOFOCUS_UNPLACED_ATTRIBUTE)) {
21754
+ return false;
21755
+ }
21756
+ containerEl.removeAttribute(AUTOFOCUS_UNPLACED_ATTRIBUTE);
21757
+ return true;
21758
+ };
21759
+
21760
+ /**
21761
+ * "When the focus comes back here, put it on this" — what transferFocus reads
21762
+ * first when it next hands the focus to that container.
21763
+ *
21764
+ * Told rather than watched: whoever is about to take the focus away is the one
21765
+ * moment that still knows what was holding it.
21766
+ */
21767
+ const markAutofocusRestore = (containerEl, element) => {
21768
+ clearAutofocusRestore(containerEl);
21769
+ if (!element || !(containerEl === element || containerEl.contains(element))) {
21770
+ return;
21771
+ }
21772
+ const restoreId = `${++restoreIdCounter}`;
21773
+ containerEl.setAttribute("navi-autofocus-restore", restoreId);
21774
+ element.setAttribute("navi-autofocus-last-focused", restoreId);
21775
+ };
21776
+
21777
+ // A popup closing remembers what held the focus, so reopening comes back to
21778
+ // it — re-focusing where the user was takes priority over any autofocus the
21779
+ // contents declare (see transferFocus). One exception: the element the closing
21780
+ // pointer itself pressed (a close button, an option whose click dismissed the
21781
+ // popup) is remembered only if it asked to be (restorable) — reopening a
21782
+ // dialog on the button one pressed to leave it would be surprising. A keyboard
21783
+ // close (Escape) designates no element, so whatever holds the focus is
21784
+ // remembered as where the user was.
21785
+ const markAutofocusRestoreOnClose = (
21786
+ containerEl,
21787
+ closeEvent,
21788
+ // Received rather than read here: by the time the close cleanups run, the
21789
+ // closing itself may have moved the focus already (a native <dialog>.close()
21790
+ // hands it back to what held it at showModal() time) — the caller captured
21791
+ // it when the close was decided.
21792
+ focused = document.activeElement,
21793
+ ) => {
21794
+ clearAutofocusRestore(containerEl);
21795
+ if (!focused || !(containerEl === focused || containerEl.contains(focused))) {
21796
+ return;
21797
+ }
21798
+ if (!isRestorableAutofocus(focused)) {
21799
+ const pointerEvent = closeEvent
21800
+ ? findEvent(closeEvent, "mousedown") || findEvent(closeEvent, "click")
21801
+ : null;
21802
+ if (pointerEvent) {
21803
+ const pointerTarget = pointerEvent.target;
21804
+ if (
21805
+ pointerTarget &&
21806
+ (focused === pointerTarget || focused.contains(pointerTarget))
21807
+ ) {
21808
+ return;
21809
+ }
21810
+ }
21811
+ }
21812
+ markAutofocusRestore(containerEl, focused);
21813
+ };
21814
+
21815
+ /**
21816
+ * Where the focus goes inside a container, in the order candidates are tried:
21817
+ * 1. the first [navi-autofocus] that leads somewhere focusable — "put it here";
21818
+ * 2. the first focusable that asks for nothing in particular — what one came to
21819
+ * do;
21820
+ * 3. the DEEPEST [navi-autofocus="last-resort"], the container itself included
21821
+ * — "not me, unless you have nothing else". Deepest first, because of two
21822
+ * nested ones the inner is the more precise answer: a dialog holding a panel
21823
+ * holding a close button lands on the button, not on the dialog;
21824
+ * 4. nothing, and the caller decides what that means.
21825
+ *
21826
+ * One word covers both readings of "last resort", because they are the same
21827
+ * sentence said by different elements. On a FOCUSABLE — a picker's search box,
21828
+ * a panel's close button, a slide's chevron — it means "prefer anything else in
21829
+ * here to me". On a CONTAINER — a dialog, a popover, a slide — it means the
21830
+ * same about its own contents, and those contents being tried first (step 2
21831
+ * walks them) is exactly what makes the container a last resort.
21832
+ *
21833
+ * @param {HTMLElement} containerEl
21834
+ * @param {object} [options]
21835
+ * @param {boolean} [options.avoidEditable]
21836
+ * Keeps step 2 off anything the virtual keyboard comes up for. Step 1 is
21837
+ * untouched: a field that says `autoFocus` still gets the keyboard, because
21838
+ * asking for it is the one way to mean it (see open_controller.js, which
21839
+ * turns this on for a surface docked on a small touch screen).
21840
+ * @returns {{target: HTMLElement, reason: string}|undefined}
21841
+ */
21842
+ const findFocusTarget = (containerEl, { avoidEditable } = {}) => {
21843
+ // Not while there is anything else: what takes the focus only for want of
21844
+ // anything better ("last-resort") and what only takes it back ("restore").
21845
+ // Neither is dropped, both are simply tried later — step 3 below for the
21846
+ // first, and for the second the restore transferFocus does before ever
21847
+ // calling here.
21848
+ //
21849
+ // Skipped for good, unlike the two above: an element hidden from assistive
21850
+ // technology is not a place the focus can land at all. Something aria-hidden
21851
+ // and out of the tab order is a value holder standing behind what one
21852
+ // actually uses — a spin's headless picker behind its slides, say — and
21853
+ // landing there puts a ring on it, raises a phone's keyboard over the panel
21854
+ // that just opened, and has the browser complain about a focused aria-hidden
21855
+ // element. What one came to use is further down the same container.
21856
+ const isHiddenFromAssistiveTech = (element) =>
21857
+ Boolean(element.closest?.(`[aria-hidden="true"]`));
21858
+
21859
+ const skip = (element) =>
21860
+ isRestorableAutofocus(element) || isHiddenFromAssistiveTech(element);
21861
+
21862
+ // Every mark, not just the first: a mark is only worth stopping at if it
21863
+ // leads somewhere focusable. One inside a screen waiting its turn (an inert
21864
+ // slide) says where the focus goes WHEN it arrives there, not now — so it is
21865
+ // passed over here rather than treated as an answer that then fails silently.
21866
+ for (const asked of containerEl.querySelectorAll(`[navi-autofocus]`)) {
21867
+ if (skip(asked)) {
21868
+ continue;
21869
+ }
21870
+ // Through findFocusable: the mark is not always ON the focusable itself — a
21871
+ // control puts it on the box it renders, the field inside being what takes
21872
+ // the keyboard — and it is also what answers "can this be focused at all"
21873
+ // (inert, hidden, disabled).
21874
+ const askedFocusable = findFocusable(asked, { exclude: skip });
21875
+ if (askedFocusable) {
21876
+ return { target: askedFocusable, reason: "navi-autofocus" };
21877
+ }
21878
+ }
21879
+ const focusable = findFocusable(containerEl, {
21880
+ exclude: avoidEditable
21881
+ ? (element) => skip(element) || isEditableTarget(element)
21882
+ : skip,
21883
+ });
21884
+ if (focusable) {
21885
+ return { target: focusable, reason: "first focusable element" };
21886
+ }
21887
+ const lastResorts = Array.from(
21888
+ containerEl.querySelectorAll(`[navi-autofocus="last-resort"]`),
21889
+ );
21890
+ if (containerEl.matches?.(`[navi-autofocus="last-resort"]`)) {
21891
+ // Last of all: querySelectorAll only looks at descendants, and the
21892
+ // container is the outermost last resort there is.
21893
+ lastResorts.push(containerEl);
21894
+ }
21895
+ const deepestLastResort = lastResorts.find(
21896
+ (candidate) =>
21897
+ !lastResorts.some(
21898
+ (other) => other !== candidate && candidate.contains(other),
21899
+ ),
21900
+ );
21901
+ if (deepestLastResort) {
21902
+ const lastResortFocusable = findFocusable(deepestLastResort);
21903
+ if (lastResortFocusable) {
21904
+ return {
21905
+ target: lastResortFocusable,
21906
+ reason: "navi-autofocus last-resort",
21907
+ };
21908
+ }
21909
+ }
21910
+ return undefined;
21911
+ };
21912
+
21913
+ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
21914
+ const focusedElement = getFocusedBeforeTransfer(prepareEvent);
21915
+ // Whether what receives the focus shows a ring: the modality of the
21916
+ // interaction asking for the transfer, not the state of the element handing
21917
+ // it over. That element is often no witness at all — a popup opened from a
21918
+ // trigger whose mousedown we prevented keeps a :focus-visible nobody can see,
21919
+ // and a slide handing over to the next one was itself focused programmatically
21920
+ // without a ring, so it would report "no ring" for a travel asked for with
21921
+ // ArrowLeft. The modality answers "was the user on the keyboard when this was
21922
+ // asked for", which is the whole question (see isKeyboardModality).
21923
+ const focusVisible = isKeyboardModality();
21924
+
21925
+ debugFocus(
21926
+ prepareEvent,
21927
+ `prepare focus transfer from`,
21928
+ focusedElement,
21929
+ focusVisible ? " matching :focus-visible" : "not matching :focus-visible",
21930
+ );
21931
+
21932
+ return {
21933
+ focusedElement,
21934
+ focusVisible,
21935
+
21936
+ /**
21937
+ * Moves the focus into `containerEl`, on the element the ladder above
21938
+ * picks.
21939
+ *
21940
+ * `getDelay(target)` — asked once the target is known, answers how many
21941
+ * milliseconds to wait before actually focusing it. The ladder is what
21942
+ * decides WHO gets the focus and it may only run once (it consumes the
21943
+ * autofocus-restore mark), so a caller with a policy about WHEN cannot
21944
+ * resolve the target itself to make up its mind: it is handed the answer
21945
+ * instead. Returns a cancel function when it did delay, so a container
21946
+ * closing before the delay is up takes back a focus it never gave;
21947
+ * undefined when it focused straight away and there is nothing to take
21948
+ * back.
21949
+ */
21950
+ transferFocus: (
21951
+ transferEvent,
21952
+ containerEl,
21953
+ { getDelay, avoidEditable } = {},
21954
+ ) => {
21955
+ let target;
21956
+ let reason;
21957
+ containerEl.removeAttribute(AUTOFOCUS_UNPLACED_ATTRIBUTE);
21958
+ const lastFocused = clearAutofocusRestore(containerEl);
21959
+ if (lastFocused) {
21960
+ // Through findFocusable: what was remembered may have become a wrapper
21961
+ // since (or stopped taking focus at all), and what is inside it is then
21962
+ // what the memory meant.
21963
+ const stillFocusable = findFocusable(lastFocused);
21964
+ if (stillFocusable) {
21965
+ reason = "element focused when it was left (restore)";
21966
+ target = stillFocusable;
21967
+ }
21968
+ }
21969
+ if (!target) {
21970
+ const found = findFocusTarget(containerEl, { avoidEditable });
21971
+ if (found) {
21972
+ reason = found.reason;
21973
+ target = found.target;
21974
+ }
21975
+ }
21976
+ if (!target) {
21977
+ if (focusedElement) {
21978
+ reason = "focused element before open (fallback)";
21979
+ target = focusedElement;
21980
+ }
21981
+ }
21982
+ // Whether the focus ends up inside is what the transfer is asked for; a
21983
+ // container that has to say no leaves the mark saying so, for whatever
21984
+ // appears inside it next (see claimUnplacedAutofocus). Both ways of
21985
+ // saying no count: finding nothing at all, and the fallback above, which
21986
+ // leaves the focus where it already was — outside.
21987
+ if (
21988
+ !target ||
21989
+ !(containerEl === target || containerEl.contains(target))
21990
+ ) {
21991
+ containerEl.setAttribute(AUTOFOCUS_UNPLACED_ATTRIBUTE, "");
21992
+ }
21993
+ if (!target) {
21994
+ return undefined;
21995
+ }
21996
+ // The modality speaks for the transfer, but an editable target outranks
21997
+ // it: it draws its ring on any focus (see isMatchingFocusVisible), so
21998
+ // the native :focus-visible is told the same.
21999
+ const targetFocusVisible = focusVisible || isEditableTarget(target);
22000
+ const giveFocus = () => {
22001
+ debugFocus(
22002
+ transferEvent,
22003
+ `Moving focus to ${getElementSignature(target)}.focus({ preventScroll: true, focusVisible: ${targetFocusVisible} }) (reason: ${reason})`,
22004
+ );
22005
+ target.focus({
22006
+ preventScroll: true,
22007
+ focusVisible: targetFocusVisible,
22008
+ });
22009
+ if (target.hasAttribute("navi-autofocus-select")) {
22010
+ target.select();
22011
+ target.scrollLeft = 0;
22012
+ }
22013
+ };
22014
+ const delay = getDelay?.(target) || 0;
22015
+ if (!delay) {
22016
+ giveFocus();
22017
+ return undefined;
22018
+ }
22019
+ debugFocus(
22020
+ transferEvent,
22021
+ `Delaying focus to ${getElementSignature(target)} by ${delay}ms`,
22022
+ );
22023
+ const timeout = setTimeout(giveFocus, delay);
22024
+ return () => {
22025
+ clearTimeout(timeout);
22026
+ };
22027
+ },
22028
+
22029
+ restoreFocus: (restoreEvent) => {
22030
+ debugFocus(
22031
+ restoreEvent,
22032
+ `restore focus to previously focused element`,
22033
+ focusedElement,
22034
+ );
22035
+ const restoreFocusVisible =
22036
+ isKeyboardModality() || isEditableTarget(focusedElement);
22037
+ focusedElement.focus({
22038
+ preventScroll: true,
22039
+ focusVisible: restoreFocusVisible,
22040
+ });
22041
+ },
22042
+ };
22043
+ };
22044
+
22045
+ // Get the active element before we transfer focus in the popover/dialog
22046
+ // We don't just use document.activeElement because when dialog is opened by mousedown
22047
+ // we prevent default so browser don't steal focus back from the dialog
22048
+ // meaning the focus did not yet reach the element receiving the mousedown
22049
+ // as a result document.activeElement is not up-to-date (can be document.body for instance)
22050
+ const getFocusedBeforeTransfer = (e) => {
22051
+ // No event at all: a transfer asked for by code (a `current` prop moving a
22052
+ // slide, say) has no interaction to read — whatever holds the focus is all
22053
+ // there is to know.
22054
+ const initiator = e?.detail?.eventChain ? e.detail.eventChain[0] : null;
22055
+ if (initiator) {
22056
+ if (initiator.type === "mousedown") {
22057
+ // if we we had let browser give focus, the element would be the one that would be focused
22058
+ return initiator.currentTarget;
22059
+ }
22060
+ if (initiator.type === "click") {
22061
+ // label use case
22062
+ return initiator.currentTarget;
22063
+ }
22064
+ }
22065
+ return document.activeElement;
22066
+ };
22067
+
21681
22068
  // see also https://github.com/preactjs/preact/issues/1255
21682
22069
 
21683
22070
 
@@ -21760,10 +22147,21 @@ const useAutoFocus = (
21760
22147
  // added to a visible list), nothing else speaks for it — an autofocus it
21761
22148
  // declares is the only word there is, exactly like dialog content saying
21762
22149
  // where the keyboard goes when the dialog's transfer looks for it.
22150
+ //
22151
+ // Unless that owner came back empty-handed: a transfer that found nothing
22152
+ // to focus inside the ancestor placed no focus to steal back, and marks
22153
+ // itself as such (see claimUnplacedAutofocus in focus_transfer.js). What
22154
+ // this element says is then the only word there is after all — this runs
22155
+ // right after the transfer, which is exactly when content the transfer was
22156
+ // too early to see arrives. "last-resort" stays out of it: it means "not me
22157
+ // unless you have nothing else", a question the transfer's own ladder has
22158
+ // already asked and answered.
21763
22159
  const { ancestor, ancestorType, becauseAncestorOpened } = e.detail;
21764
22160
  const isSelfAncestor = ancestor === focusableElement;
21765
22161
  if (becauseAncestorOpened && !isSelfAncestor) {
21766
- return () => {};
22162
+ if (autoFocus === "last-resort" || !claimUnplacedAutofocus(ancestor)) {
22163
+ return () => {};
22164
+ }
21767
22165
  }
21768
22166
  if (autoFocus === "last-resort" && !isSelfAncestor) {
21769
22167
  // "not me, unless you have nothing else" is a question only whoever hands
@@ -28419,337 +28817,6 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
28419
28817
  };
28420
28818
  const Button = createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonUI]);
28421
28819
 
28422
- /**
28423
- * Decides which element receives focus when a container (popover, dialog, …)
28424
- * opens, and gives it back to where it came from when the container closes.
28425
- *
28426
- * The [navi-autofocus] attribute (written by use_auto_focus.js) tunes where
28427
- * focus lands. Candidates are tried in this order:
28428
- * 1. The element that held focus when the container was last closed
28429
- * 2. [navi-autofocus] asking for it ("" for a plain `autoFocus`)
28430
- * 3. The first focusable element
28431
- * 4. [navi-autofocus="last-resort"], the container itself included
28432
- * 5. The element focused before the container opened
28433
- *
28434
- * [navi-autofocus="restore"] appears in step 1 only: it never claims focus on
28435
- * a fresh open, it only gets it back.
28436
- */
28437
-
28438
- // The element that held focus when a container closed is marked with
28439
- // [navi-autofocus-last-focused], and its container with
28440
- // [navi-autofocus-restore]. Both carry the same generated id: containers can
28441
- // nest (a popover inside a dialog), so the id is what tells a reopening
28442
- // container which mark among its descendants is its own.
28443
- let restoreIdCounter = 0;
28444
-
28445
- // The values that never ASK for the focus: one takes it for want of anything
28446
- // better ("last-resort"), the other only takes it back ("restore"). What they
28447
- // have in common is being worth giving back to — a container that was holding
28448
- // the keyboard itself, a field that said it wants it, are both places one was,
28449
- // and coming back to where one was is the whole point of a restore.
28450
- const isRestorableAutofocus = (el) => {
28451
- const value = el.getAttribute("navi-autofocus");
28452
- return value === "last-resort" || value === "restore";
28453
- };
28454
-
28455
- const clearAutofocusRestore = (containerEl) => {
28456
- const restoreId = containerEl.getAttribute("navi-autofocus-restore");
28457
- if (restoreId === null) {
28458
- return null;
28459
- }
28460
- containerEl.removeAttribute("navi-autofocus-restore");
28461
- const selector = `[navi-autofocus-last-focused="${restoreId}"]`;
28462
- const lastFocused = containerEl.matches(selector)
28463
- ? containerEl
28464
- : containerEl.querySelector(selector);
28465
- if (lastFocused) {
28466
- lastFocused.removeAttribute("navi-autofocus-last-focused");
28467
- }
28468
- return lastFocused;
28469
- };
28470
-
28471
- /**
28472
- * "When the focus comes back here, put it on this" — what transferFocus reads
28473
- * first when it next hands the focus to that container.
28474
- *
28475
- * Told rather than watched: whoever is about to take the focus away is the one
28476
- * moment that still knows what was holding it.
28477
- */
28478
- const markAutofocusRestore = (containerEl, element) => {
28479
- clearAutofocusRestore(containerEl);
28480
- if (!element || !(containerEl === element || containerEl.contains(element))) {
28481
- return;
28482
- }
28483
- const restoreId = `${++restoreIdCounter}`;
28484
- containerEl.setAttribute("navi-autofocus-restore", restoreId);
28485
- element.setAttribute("navi-autofocus-last-focused", restoreId);
28486
- };
28487
-
28488
- // A popup closing remembers what held the focus, so reopening comes back to
28489
- // it — re-focusing where the user was takes priority over any autofocus the
28490
- // contents declare (see transferFocus). One exception: the element the closing
28491
- // pointer itself pressed (a close button, an option whose click dismissed the
28492
- // popup) is remembered only if it asked to be (restorable) — reopening a
28493
- // dialog on the button one pressed to leave it would be surprising. A keyboard
28494
- // close (Escape) designates no element, so whatever holds the focus is
28495
- // remembered as where the user was.
28496
- const markAutofocusRestoreOnClose = (
28497
- containerEl,
28498
- closeEvent,
28499
- // Received rather than read here: by the time the close cleanups run, the
28500
- // closing itself may have moved the focus already (a native <dialog>.close()
28501
- // hands it back to what held it at showModal() time) — the caller captured
28502
- // it when the close was decided.
28503
- focused = document.activeElement,
28504
- ) => {
28505
- clearAutofocusRestore(containerEl);
28506
- if (!focused || !(containerEl === focused || containerEl.contains(focused))) {
28507
- return;
28508
- }
28509
- if (!isRestorableAutofocus(focused)) {
28510
- const pointerEvent = closeEvent
28511
- ? findEvent(closeEvent, "mousedown") || findEvent(closeEvent, "click")
28512
- : null;
28513
- if (pointerEvent) {
28514
- const pointerTarget = pointerEvent.target;
28515
- if (
28516
- pointerTarget &&
28517
- (focused === pointerTarget || focused.contains(pointerTarget))
28518
- ) {
28519
- return;
28520
- }
28521
- }
28522
- }
28523
- markAutofocusRestore(containerEl, focused);
28524
- };
28525
-
28526
- /**
28527
- * Where the focus goes inside a container, in the order candidates are tried:
28528
- * 1. the first [navi-autofocus] that leads somewhere focusable — "put it here";
28529
- * 2. the first focusable that asks for nothing in particular — what one came to
28530
- * do;
28531
- * 3. the DEEPEST [navi-autofocus="last-resort"], the container itself included
28532
- * — "not me, unless you have nothing else". Deepest first, because of two
28533
- * nested ones the inner is the more precise answer: a dialog holding a panel
28534
- * holding a close button lands on the button, not on the dialog;
28535
- * 4. nothing, and the caller decides what that means.
28536
- *
28537
- * One word covers both readings of "last resort", because they are the same
28538
- * sentence said by different elements. On a FOCUSABLE — a picker's search box,
28539
- * a panel's close button, a slide's chevron — it means "prefer anything else in
28540
- * here to me". On a CONTAINER — a dialog, a popover, a slide — it means the
28541
- * same about its own contents, and those contents being tried first (step 2
28542
- * walks them) is exactly what makes the container a last resort.
28543
- *
28544
- * @param {HTMLElement} containerEl
28545
- * @returns {{target: HTMLElement, reason: string}|undefined}
28546
- */
28547
- const findFocusTarget = (containerEl) => {
28548
- // Not while there is anything else: what takes the focus only for want of
28549
- // anything better ("last-resort") and what only takes it back ("restore").
28550
- // Neither is dropped, both are simply tried later — step 3 below for the
28551
- // first, and for the second the restore transferFocus does before ever
28552
- // calling here.
28553
- //
28554
- // Skipped for good, unlike the two above: an element hidden from assistive
28555
- // technology is not a place the focus can land at all. Something aria-hidden
28556
- // and out of the tab order is a value holder standing behind what one
28557
- // actually uses — a spin's headless picker behind its slides, say — and
28558
- // landing there puts a ring on it, raises a phone's keyboard over the panel
28559
- // that just opened, and has the browser complain about a focused aria-hidden
28560
- // element. What one came to use is further down the same container.
28561
- const isHiddenFromAssistiveTech = (element) =>
28562
- Boolean(element.closest?.(`[aria-hidden="true"]`));
28563
-
28564
- const skip = (element) =>
28565
- isRestorableAutofocus(element) || isHiddenFromAssistiveTech(element);
28566
-
28567
- // Every mark, not just the first: a mark is only worth stopping at if it
28568
- // leads somewhere focusable. One inside a screen waiting its turn (an inert
28569
- // slide) says where the focus goes WHEN it arrives there, not now — so it is
28570
- // passed over here rather than treated as an answer that then fails silently.
28571
- for (const asked of containerEl.querySelectorAll(`[navi-autofocus]`)) {
28572
- if (skip(asked)) {
28573
- continue;
28574
- }
28575
- // Through findFocusable: the mark is not always ON the focusable itself — a
28576
- // control puts it on the box it renders, the field inside being what takes
28577
- // the keyboard — and it is also what answers "can this be focused at all"
28578
- // (inert, hidden, disabled).
28579
- const askedFocusable = findFocusable(asked, { exclude: skip });
28580
- if (askedFocusable) {
28581
- return { target: askedFocusable, reason: "navi-autofocus" };
28582
- }
28583
- }
28584
- const focusable = findFocusable(containerEl, { exclude: skip });
28585
- if (focusable) {
28586
- return { target: focusable, reason: "first focusable element" };
28587
- }
28588
- const lastResorts = Array.from(
28589
- containerEl.querySelectorAll(`[navi-autofocus="last-resort"]`),
28590
- );
28591
- if (containerEl.matches?.(`[navi-autofocus="last-resort"]`)) {
28592
- // Last of all: querySelectorAll only looks at descendants, and the
28593
- // container is the outermost last resort there is.
28594
- lastResorts.push(containerEl);
28595
- }
28596
- const deepestLastResort = lastResorts.find(
28597
- (candidate) =>
28598
- !lastResorts.some(
28599
- (other) => other !== candidate && candidate.contains(other),
28600
- ),
28601
- );
28602
- if (deepestLastResort) {
28603
- const lastResortFocusable = findFocusable(deepestLastResort);
28604
- if (lastResortFocusable) {
28605
- return {
28606
- target: lastResortFocusable,
28607
- reason: "navi-autofocus last-resort",
28608
- };
28609
- }
28610
- }
28611
- return undefined;
28612
- };
28613
-
28614
- const prepareFocusTransfer = (prepareEvent, debugFocus) => {
28615
- const focusedElement = getFocusedBeforeTransfer(prepareEvent);
28616
- // Whether what receives the focus shows a ring: the modality of the
28617
- // interaction asking for the transfer, not the state of the element handing
28618
- // it over. That element is often no witness at all — a popup opened from a
28619
- // trigger whose mousedown we prevented keeps a :focus-visible nobody can see,
28620
- // and a slide handing over to the next one was itself focused programmatically
28621
- // without a ring, so it would report "no ring" for a travel asked for with
28622
- // ArrowLeft. The modality answers "was the user on the keyboard when this was
28623
- // asked for", which is the whole question (see isKeyboardModality).
28624
- const focusVisible = isKeyboardModality();
28625
-
28626
- debugFocus(
28627
- prepareEvent,
28628
- `prepare focus transfer from`,
28629
- focusedElement,
28630
- focusVisible ? " matching :focus-visible" : "not matching :focus-visible",
28631
- );
28632
-
28633
- return {
28634
- focusedElement,
28635
- focusVisible,
28636
-
28637
- /**
28638
- * Moves the focus into `containerEl`, on the element the ladder above
28639
- * picks.
28640
- *
28641
- * `getDelay(target)` — asked once the target is known, answers how many
28642
- * milliseconds to wait before actually focusing it. The ladder is what
28643
- * decides WHO gets the focus and it may only run once (it consumes the
28644
- * autofocus-restore mark), so a caller with a policy about WHEN cannot
28645
- * resolve the target itself to make up its mind: it is handed the answer
28646
- * instead. Returns a cancel function when it did delay, so a container
28647
- * closing before the delay is up takes back a focus it never gave;
28648
- * undefined when it focused straight away and there is nothing to take
28649
- * back.
28650
- */
28651
- transferFocus: (transferEvent, containerEl, { getDelay } = {}) => {
28652
- let target;
28653
- let reason;
28654
- const lastFocused = clearAutofocusRestore(containerEl);
28655
- if (lastFocused) {
28656
- // Through findFocusable: what was remembered may have become a wrapper
28657
- // since (or stopped taking focus at all), and what is inside it is then
28658
- // what the memory meant.
28659
- const stillFocusable = findFocusable(lastFocused);
28660
- if (stillFocusable) {
28661
- reason = "element focused when it was left (restore)";
28662
- target = stillFocusable;
28663
- }
28664
- }
28665
- if (!target) {
28666
- const found = findFocusTarget(containerEl);
28667
- if (found) {
28668
- reason = found.reason;
28669
- target = found.target;
28670
- }
28671
- }
28672
- if (!target) {
28673
- if (focusedElement) {
28674
- reason = "focused element before open (fallback)";
28675
- target = focusedElement;
28676
- }
28677
- }
28678
- if (!target) {
28679
- return undefined;
28680
- }
28681
- // The modality speaks for the transfer, but an editable target outranks
28682
- // it: it draws its ring on any focus (see isMatchingFocusVisible), so
28683
- // the native :focus-visible is told the same.
28684
- const targetFocusVisible = focusVisible || isEditableTarget(target);
28685
- const giveFocus = () => {
28686
- debugFocus(
28687
- transferEvent,
28688
- `Moving focus to ${getElementSignature(target)}.focus({ preventScroll: true, focusVisible: ${targetFocusVisible} }) (reason: ${reason})`,
28689
- );
28690
- target.focus({
28691
- preventScroll: true,
28692
- focusVisible: targetFocusVisible,
28693
- });
28694
- if (target.hasAttribute("navi-autofocus-select")) {
28695
- target.select();
28696
- target.scrollLeft = 0;
28697
- }
28698
- };
28699
- const delay = getDelay?.(target) || 0;
28700
- if (!delay) {
28701
- giveFocus();
28702
- return undefined;
28703
- }
28704
- debugFocus(
28705
- transferEvent,
28706
- `Delaying focus to ${getElementSignature(target)} by ${delay}ms`,
28707
- );
28708
- const timeout = setTimeout(giveFocus, delay);
28709
- return () => {
28710
- clearTimeout(timeout);
28711
- };
28712
- },
28713
-
28714
- restoreFocus: (restoreEvent) => {
28715
- debugFocus(
28716
- restoreEvent,
28717
- `restore focus to previously focused element`,
28718
- focusedElement,
28719
- );
28720
- const restoreFocusVisible =
28721
- isKeyboardModality() || isEditableTarget(focusedElement);
28722
- focusedElement.focus({
28723
- preventScroll: true,
28724
- focusVisible: restoreFocusVisible,
28725
- });
28726
- },
28727
- };
28728
- };
28729
-
28730
- // Get the active element before we transfer focus in the popover/dialog
28731
- // We don't just use document.activeElement because when dialog is opened by mousedown
28732
- // we prevent default so browser don't steal focus back from the dialog
28733
- // meaning the focus did not yet reach the element receiving the mousedown
28734
- // as a result document.activeElement is not up-to-date (can be document.body for instance)
28735
- const getFocusedBeforeTransfer = (e) => {
28736
- // No event at all: a transfer asked for by code (a `current` prop moving a
28737
- // slide, say) has no interaction to read — whatever holds the focus is all
28738
- // there is to know.
28739
- const initiator = e?.detail?.eventChain ? e.detail.eventChain[0] : null;
28740
- if (initiator) {
28741
- if (initiator.type === "mousedown") {
28742
- // if we we had let browser give focus, the element would be the one that would be focused
28743
- return initiator.currentTarget;
28744
- }
28745
- if (initiator.type === "click") {
28746
- // label use case
28747
- return initiator.currentTarget;
28748
- }
28749
- }
28750
- return document.activeElement;
28751
- };
28752
-
28753
28820
  // How long a popup waits before handing the focus to a field, when giving it
28754
28821
  // is what raises the on-screen keyboard.
28755
28822
  //
@@ -28992,7 +29059,7 @@ const createOpenController = (
28992
29059
  requestOpenEvent,
28993
29060
  debugInteraction,
28994
29061
  );
28995
- controller.transferFocusOnOpen = (el) => {
29062
+ controller.transferFocusOnOpen = (el, { avoidEditable } = {}) => {
28996
29063
  // requestOpenEvent, not the raw `e` — getFocusedBeforeTransfer needs
28997
29064
  // e.detail.eventChain (built by chainEvent above) to recover the
28998
29065
  // element a mousedown/click landed on. `e` itself is usually the raw
@@ -29019,6 +29086,7 @@ const createOpenController = (
29019
29086
  findEvent(requestOpenEvent, isTouchDrivenEvent),
29020
29087
  );
29021
29088
  const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
29089
+ avoidEditable,
29022
29090
  getDelay: (target) =>
29023
29091
  openedByTouch && isEditableTarget(target)
29024
29092
  ? FOCUS_DELAY_ON_KEYBOARD_MS
@@ -29849,6 +29917,43 @@ const popupCss = /* css */ `
29849
29917
  }
29850
29918
  }
29851
29919
  }
29920
+
29921
+ /* While the PAGES are the ones moving — a route transition, a route travel —
29922
+ an open popup takes a picture of its own. The pictures of such a movement
29923
+ are drawn in the top layer, above everything the document paints, and a
29924
+ popup lives in the top layer too: uncaptured, it is simply covered for the
29925
+ length of the movement, so it disappears the instant its page starts to
29926
+ leave and lands abruptly when the arriving one settles. Captured, it is a
29927
+ group beside the pages, painted over them where it stands, and the browser
29928
+ fades it out with the page it belonged to or in with the page it comes
29929
+ with.
29930
+
29931
+ An identity of its own (match-element), never a shared name: two popups on
29932
+ either side of a navigation are two different popups, and what is wanted is
29933
+ precisely that one goes and the other comes. A browser with no
29934
+ match-element takes no name and the pictures cover the popup. */
29935
+ @supports (view-transition-name: match-element) {
29936
+ :root[data-navi-route-transition] .navi_popover[aria-expanded="true"],
29937
+ :root[data-navi-route-transition] .navi_dialog[aria-expanded="true"],
29938
+ :root[data-navi-route-travel] .navi_popover[aria-expanded="true"],
29939
+ :root[data-navi-route-travel] .navi_dialog[aria-expanded="true"] {
29940
+ view-transition-name: match-element;
29941
+ view-transition-class: navi_popup;
29942
+ }
29943
+ }
29944
+
29945
+ /* On the same clock as the pages: a popup going in a quarter of the time the
29946
+ page it belongs to takes to leave is gone long before the page it was on. */
29947
+ :root[data-navi-route-transition]::view-transition-group(.navi_popup),
29948
+ :root[data-navi-route-transition]::view-transition-old(.navi_popup),
29949
+ :root[data-navi-route-transition]::view-transition-new(.navi_popup) {
29950
+ animation-duration: var(--navi-route-transition-duration, 300ms);
29951
+ }
29952
+ :root[data-navi-route-travel]::view-transition-group(.navi_popup),
29953
+ :root[data-navi-route-travel]::view-transition-old(.navi_popup),
29954
+ :root[data-navi-route-travel]::view-transition-new(.navi_popup) {
29955
+ animation-duration: var(--navi-route-travel-duration, 300ms);
29956
+ }
29852
29957
  `;
29853
29958
 
29854
29959
  /**
@@ -30631,10 +30736,20 @@ const css$X = /* css */`
30631
30736
  * @param {number} [props.tabIndex=-1] - Set on the dialog element itself so
30632
30737
  * `autoFocus="last-resort"` below has somewhere to land when the dialog has
30633
30738
  * no other focusable descendant of its own.
30634
- * @param {boolean|"last-resort"|"restore"} [props.autoFocus="last-resort"] - See
30635
- * `focus_transfer.js` `"last-resort"` focuses the dialog itself only if it
30636
- * has no other focusable descendant, `"restore"` keeps it out of the
30637
- * opening focus chain unless it held focus when the dialog closed.
30739
+ * @param {boolean|"last-resort"|"restore"} [props.autoFocus="last-resort"] -
30740
+ * Where the keyboard goes when this dialog opens one rung of the ladder in
30741
+ * `docs/autofocus.md`, which is what to read for the whole of it.
30742
+ * - `true` the dialog element itself takes the keyboard, whatever it holds.
30743
+ * For a dialog whose content is READ before it is filled: the focus starts
30744
+ * at the top of the reading order and no virtual keyboard rises over it.
30745
+ * - `"last-resort"` — the dialog takes the keyboard only if it holds nothing
30746
+ * focusable of its own.
30747
+ * - `"restore"` — the dialog stays out of the opening focus chain unless it
30748
+ * held focus when it closed.
30749
+ * Docked on a small touch screen, the default already withdraws fields from
30750
+ * the choice: a bottom sheet is read before it is typed in, so the keyboard
30751
+ * goes to a field only when that field asks for it by name (`autoFocus` on
30752
+ * the field, which outranks whatever the dialog says).
30638
30753
  * @param {boolean} [props.open] - Controlled open state.
30639
30754
  * @param {boolean|"interaction"} [props.defaultOpen] - Uncontrolled, mount-only
30640
30755
  * initial open state. `true` plays no entrance animation: the dialog was
@@ -31331,7 +31446,16 @@ const useDialogProps = props => {
31331
31446
  // entrance to be over. Decided by transferFocusOnOpen, the only place that
31332
31447
  // knows WHICH element is about to be focused (open_controller.js and its
31333
31448
  // FOCUS_DELAY_ON_KEYBOARD_MS).
31334
- const restoreFocus = openController.transferFocusOnOpen(dialogEl);
31449
+ //
31450
+ // Docked, the keyboard costs more than a wait: it takes a third of a phone
31451
+ // screen from a dialog that starts at the bottom edge, pushing whatever
31452
+ // comes before the field — the title, the sentence saying why it is asked
31453
+ // for — above the top edge before the dialog has even been looked at. So a
31454
+ // docked dialog is READ first: the transfer only reaches a field that asked
31455
+ // for the keyboard by name (see findFocusTarget's `avoidEditable`).
31456
+ const restoreFocus = openController.transferFocusOnOpen(dialogEl, {
31457
+ avoidEditable: isDocked
31458
+ });
31335
31459
 
31336
31460
  // isModal outside-click detection (see this file's top comment for why
31337
31461
  // this is a plain document-level listener rather than anything
@@ -32056,13 +32180,20 @@ const css$W = /* css */`
32056
32180
  * @param {number} [props.tabIndex=-1] - Set on the popover element itself
32057
32181
  * so `autoFocus="last-resort"` below has somewhere to land when the popover
32058
32182
  * has no other focusable descendant of its own.
32059
- * @param {boolean|"last-resort"|"restore"} [props.autoFocus="last-resort"] - See
32060
- * `focus_transfer.js` `"last-resort"` focuses the popover itself only if it
32061
- * has no other focusable descendant, `"restore"` keeps it out of the
32062
- * opening focus chain unless it held focus when the popover closed. `false`
32063
- * disables the open-time focus transfer entirely: nothing inside the popover
32064
- * receives focus, whoever had the keyboard keeps it the combobox case,
32065
- * where suggestions open under an input being typed in.
32183
+ * @param {boolean|"last-resort"|"restore"} [props.autoFocus="last-resort"] -
32184
+ * Where the keyboard goes when this popover opens one rung of the ladder in
32185
+ * `docs/autofocus.md`, which is what to read for the whole of it.
32186
+ * - `true` the popover element itself takes the keyboard, whatever it
32187
+ * holds. For a popover whose content is READ before it is filled: the focus
32188
+ * starts at the top of the reading order and no virtual keyboard rises over
32189
+ * it.
32190
+ * - `"last-resort"` — the popover takes the keyboard only if it holds nothing
32191
+ * focusable of its own.
32192
+ * - `"restore"` — the popover stays out of the opening focus chain unless it
32193
+ * held focus when it closed.
32194
+ * - `false` — no open-time focus transfer at all: nothing inside the popover
32195
+ * receives focus, whoever had the keyboard keeps it — the combobox case,
32196
+ * where suggestions open under an input being typed in.
32066
32197
  * @param {boolean} [props.open] - Controlled open state.
32067
32198
  * @param {boolean|"interaction"} [props.defaultOpen] - Uncontrolled, mount-only
32068
32199
  * initial open state. `true` plays no entrance animation: the popover was
@@ -54987,8 +55118,15 @@ installImportMetaCssBuild(import.meta);const css$z = /* css */`
54987
55118
  0px,
54988
55119
  var(--picker-border-radius) - var(--picker-border-width)
54989
55120
  );
54990
- overflow: auto;
54991
55121
  overscroll-behavior: none;
55122
+
55123
+ /* Skipped when the list asks for overflow="visible": that ask is
55124
+ about escaping every box the list sits in, and this selector is
55125
+ specific enough to win over the list's own rules and silently put
55126
+ the scroll back. */
55127
+ &:not([data-overflow-visible]) {
55128
+ overflow: auto;
55129
+ }
54992
55130
  }
54993
55131
  }
54994
55132
 
@@ -55046,8 +55184,13 @@ installImportMetaCssBuild(import.meta);const css$z = /* css */`
55046
55184
  0px,
55047
55185
  var(--picker-border-radius) - var(--picker-border-width)
55048
55186
  );
55049
- overflow: auto;
55050
55187
  overscroll-behavior: none;
55188
+
55189
+ /* See the popover block above: overflow="visible" on the list must not
55190
+ be overridden back into a scroll by this rule. */
55191
+ &:not([data-overflow-visible]) {
55192
+ overflow: auto;
55193
+ }
55051
55194
  }
55052
55195
  }
55053
55196