@jsenv/navi 0.25.1 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { isValidElement, h, createContext, options, toChildArray, render, create
3
3
  import { useErrorBoundary, useLayoutEffect, useEffect, useMemo, useRef, useState, useCallback, useContext, useImperativeHandle, useId } from "preact/hooks";
4
4
  import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
5
5
  import { signal, effect, computed, batch, useSignal } from "@preact/signals";
6
- import { createIterableWeakSet, mergeOneStyle, stringifyStyle, createPubSub, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, findBefore, findAfter, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, allowWheelThrough, resolveCSSColor, createStyleController, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, resolveCSSSize, activeElementSignal, canInterceptKeys, hasCSSSizeUnit, contrastColor, initFocusGroup, elementIsFocusable, resolveColorLuminance, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
6
+ import { createIterableWeakSet, mergeOneStyle, stringifyStyle, createPubSub, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, findBefore, findAfter, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, allowWheelThrough, resolveCSSColor, createStyleController, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, resolveCSSSize, activeElementSignal, canInterceptKeys, contrastColor, hasCSSSizeUnit, initFocusGroup, elementIsFocusable, resolveColorLuminance, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
7
  export { contrastColor } from "@jsenv/dom";
8
8
  import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize";
9
9
  import { createValidity } from "@jsenv/validity";
@@ -6306,7 +6306,7 @@ const DIMENSION_PROPS = {
6306
6306
  return {
6307
6307
  alignSelf: "stretch",
6308
6308
  // Here flex grow is "useless" for the item itself
6309
- // buuut it would allow children (hello ".navi_text_bold_wrapper")
6309
+ // buuut it would allow children (hello ".navi_text_sizer")
6310
6310
  // to inherit expand behavior
6311
6311
  flexGrow: 1,
6312
6312
  };
@@ -6328,7 +6328,7 @@ const DIMENSION_PROPS = {
6328
6328
  return {
6329
6329
  alignSelf: "stretch",
6330
6330
  // Here flex grow is "useless" for the item itself
6331
- // buuut it would allow children (hello ".navi_text_bold_wrapper")
6331
+ // buuut it would allow children (hello ".navi_text_sizer")
6332
6332
  // to inherit expand behavior
6333
6333
  flexGrow: 1,
6334
6334
  };
@@ -7245,6 +7245,9 @@ const PSEUDO_CLASSES = {
7245
7245
  ":-navi-status-error": {
7246
7246
  attribute: "data-status-error",
7247
7247
  },
7248
+ ":navi-expanded": {
7249
+ attribute: "data-expanded",
7250
+ },
7248
7251
  };
7249
7252
 
7250
7253
  const NAVI_PSEUDO_STATE_CUSTOM_EVENT = "navi_pseudo_state";
@@ -16486,7 +16489,7 @@ const stickCalloutToAnchor = (calloutElement, anchorElement) => {
16486
16489
  }
16487
16490
  calloutElement.setAttribute("data-position", position);
16488
16491
  calloutStyleController.set(calloutElement, {
16489
- opacity: visibilityRatio ? 1 : 0,
16492
+ opacity: visibilityRatio > 0 ? 1 : 0,
16490
16493
  transform: {
16491
16494
  translateX: calloutLeft,
16492
16495
  translateY: calloutTop
@@ -20276,6 +20279,109 @@ const isSameKey = (browserEventKey, key) => {
20276
20279
  return false;
20277
20280
  };
20278
20281
 
20282
+ /**
20283
+ * Toggles a `data-dark-background` attribute on the referenced element based on its
20284
+ * computed background color. Pair it with a CSS variable to get automatic
20285
+ * light/dark text without hard-coding colors:
20286
+ *
20287
+ * ```css
20288
+ * .my-element {
20289
+ * --color-contrasting: black;
20290
+ * &[data-dark-background] {
20291
+ * --color-contrasting: white;
20292
+ * }
20293
+ * color: var(--color-contrasting);
20294
+ * }
20295
+ * ```
20296
+ *
20297
+ * - `data-dark-background` is **set** when the background is dark enough that white text
20298
+ * provides better (or equal) contrast.
20299
+ * - `data-dark-background` is **absent** when black text is the better choice.
20300
+ *
20301
+ * @param {import("preact").RefObject} ref - Ref to the element that receives
20302
+ * the `data-dark-background` attribute and is also passed to `contrastColor` for
20303
+ * resolving CSS variables.
20304
+ * @param {object} [options]
20305
+ * @param {string} [options.backgroundElementSelector] - CSS selector relative
20306
+ * to `ref.current` pointing to a child element whose `background-color`
20307
+ * should be tested instead of the element itself. Useful when the element
20308
+ * has a transparent background but contains a coloured child (e.g. a fill
20309
+ * bar inside a track).
20310
+ */
20311
+
20312
+ const useDarkBackgroundAttribute = (
20313
+ ref,
20314
+ deps = [],
20315
+ {
20316
+ backgroundElementSelector,
20317
+ attributeName = "data-dark-background",
20318
+ hardcoded = {},
20319
+ } = {},
20320
+ ) => {
20321
+ const innerDeps = [
20322
+ ...deps,
20323
+ // ref can change is the component pass a different ref on different render based on some logic
20324
+ // (can be used to control which element backgroundColor is being checked by switching the ref to another element)
20325
+ ref,
20326
+ // backgroundElementSelector can change if the component pass a different selector on different render based on some logic
20327
+ // (can be used to control which element backgroundColor is being checked by switching the selector to point to another element)
20328
+ backgroundElementSelector,
20329
+ ];
20330
+
20331
+ const hardcodedMap = new Map();
20332
+ for (const key of Object.keys(hardcoded)) {
20333
+ const value = hardcoded[key];
20334
+ innerDeps.push(key, value);
20335
+ const colorString = normalizeColorString(key);
20336
+ hardcodedMap.set(colorString, value);
20337
+ }
20338
+
20339
+ useLayoutEffect(() => {
20340
+ const el = ref.current;
20341
+ if (!el) {
20342
+ return undefined;
20343
+ }
20344
+ let elementToCheck = el;
20345
+ if (backgroundElementSelector) {
20346
+ elementToCheck = el.querySelector(backgroundElementSelector);
20347
+ if (!elementToCheck) {
20348
+ return undefined;
20349
+ }
20350
+ }
20351
+ const updateAttribute = () => {
20352
+ const computedStyle = getComputedStyle(elementToCheck);
20353
+ const backgroundColor = computedStyle.backgroundColor;
20354
+ if (!backgroundColor) {
20355
+ el.removeAttribute(attributeName);
20356
+ return;
20357
+ }
20358
+ const backgroundColorString = normalizeColorString(backgroundColor, el);
20359
+ const hardcodedContrast = hardcodedMap.get(backgroundColorString);
20360
+ const contrastingColor =
20361
+ hardcodedContrast || contrastColor(backgroundColor, el);
20362
+ if (contrastingColor === "white") {
20363
+ el.setAttribute(attributeName, "");
20364
+ } else {
20365
+ el.removeAttribute(attributeName);
20366
+ }
20367
+ };
20368
+ updateAttribute();
20369
+ el.addEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttribute);
20370
+ return () => {
20371
+ el.removeEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttribute);
20372
+ el.removeAttribute(attributeName);
20373
+ };
20374
+ }, innerDeps);
20375
+ };
20376
+
20377
+ const normalizeColorString = (color, el) => {
20378
+ const colorRgba = resolveCSSColor(color, el);
20379
+ if (!colorRgba) {
20380
+ return "";
20381
+ }
20382
+ return String(colorRgba);
20383
+ };
20384
+
20279
20385
  const useInitialTextSelection = (ref, textSelection) => {
20280
20386
  const deps = [];
20281
20387
  if (Array.isArray(textSelection)) {
@@ -20375,8 +20481,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
20375
20481
  }
20376
20482
  };
20377
20483
 
20378
- installImportMetaCssBuild(import.meta);/* eslint-disable jsenv/no-unknown-params */
20379
- const css$v = /* css */`
20484
+ installImportMetaCssBuild(import.meta);const css$v = /* css */`
20380
20485
  @layer navi {
20381
20486
  .navi_text {
20382
20487
  &[data-skeleton] {
@@ -20391,23 +20496,27 @@ const css$v = /* css */`
20391
20496
  .navi_text {
20392
20497
  position: relative;
20393
20498
 
20499
+ &[data-dark-background] {
20500
+ color: white;
20501
+ }
20502
+
20394
20503
  /* There is a chrome specific bug that prevents text-transform: capitalize to be applied in nested DOM structure */
20395
20504
  /* The CSS below ensure capitalize is propagated to the bold clones */
20396
20505
  &[data-capitalize] {
20397
20506
  &::first-letter {
20398
20507
  text-transform: uppercase;
20399
20508
  }
20400
- .navi_text_bold_clone::first-letter {
20509
+ .navi_text_sizer_placeholder::first-letter {
20401
20510
  text-transform: uppercase;
20402
20511
  }
20403
- .navi_text_bold_foreground::first-letter {
20512
+ .navi_text_sizer_overlay::first-letter {
20404
20513
  text-transform: uppercase;
20405
20514
  }
20406
20515
  }
20407
20516
 
20408
- .navi_text_bold_wrapper,
20409
- .navi_text_bold_clone,
20410
- .navi_text_bold_foreground {
20517
+ .navi_text_sizer,
20518
+ .navi_text_sizer_placeholder,
20519
+ .navi_text_sizer_overlay {
20411
20520
  display: inherit;
20412
20521
  width: inherit;
20413
20522
  min-width: inherit;
@@ -20520,15 +20629,14 @@ const css$v = /* css */`
20520
20629
  }
20521
20630
  }
20522
20631
 
20523
- .navi_text_bold_wrapper {
20632
+ .navi_text_sizer {
20524
20633
  position: relative;
20525
20634
  display: inline-block;
20526
20635
 
20527
- .navi_text_bold_clone {
20528
- font-weight: bold;
20636
+ .navi_text_sizer_placeholder {
20529
20637
  opacity: 0;
20530
20638
  }
20531
- .navi_text_bold_foreground {
20639
+ .navi_text_sizer_overlay {
20532
20640
  position: absolute;
20533
20641
  inset: 0;
20534
20642
  }
@@ -20547,26 +20655,14 @@ const css$v = /* css */`
20547
20655
  -webkit-text-fill-color: transparent;
20548
20656
  opacity: 0;
20549
20657
  }
20550
-
20658
+ .navi_text[data-contains-absolute-child] {
20659
+ display: inline-block;
20660
+ }
20551
20661
  .navi_text[data-bold] {
20552
20662
  .navi_text_bold_background {
20553
20663
  opacity: 1;
20554
20664
  }
20555
20665
  }
20556
-
20557
- .navi_text[data-bold-transition] {
20558
- .navi_text_bold_foreground {
20559
- transition-property: font-weight;
20560
- transition-duration: 0.3s;
20561
- transition-timing-function: ease;
20562
- }
20563
-
20564
- .navi_text_bold_background {
20565
- transition-property: opacity;
20566
- transition-duration: 0.3s;
20567
- transition-timing-function: ease;
20568
- }
20569
- }
20570
20666
  `;
20571
20667
  const REGULAR_SPACE = jsx("span", {
20572
20668
  "data-navi-space": "",
@@ -20685,6 +20781,61 @@ const shouldInjectSpacingBetween = (left, right) => {
20685
20781
  return true;
20686
20782
  };
20687
20783
  const OverflowPinnedElementContext = createContext(null);
20784
+ /**
20785
+ * Text component for rendering inline or block text with layout-stable style changes.
20786
+ *
20787
+ * Most props are forwarded to the underlying `Box` component (as, style, bold, noWrap, …).
20788
+ * The props listed below are specific to Text.
20789
+ *
20790
+ * @param {object} props
20791
+ *
20792
+ * @param {boolean} [props.overflowEllipsis]
20793
+ * Truncates overflowing text with an ellipsis.
20794
+ *
20795
+ * @param {boolean} [props.overflowPinned]
20796
+ * Must be used inside a `<Text overflowEllipsis>` parent.
20797
+ * Pins this element outside the truncated text flow (e.g. a badge or icon).
20798
+ *
20799
+ * @param {string} [props.spacing]
20800
+ * Controls the separator injected between child nodes.
20801
+ * Accepts a size/spacing scale key, a CSS length, or `"pre"` / `0` to disable.
20802
+ * Defaults to a regular space character (or a padding-based fake space when
20803
+ * `preventSpaceUnderlines` is active).
20804
+ *
20805
+ * @param {boolean} [props.loading]
20806
+ * Renders a shimmer skeleton in place of the text.
20807
+ *
20808
+ * @param {boolean} [props.skeleton]
20809
+ * Same as `loading` but without the shimmer animation.
20810
+ *
20811
+ * @param {boolean} [props.preventSpaceUnderlines]
20812
+ * Replaces real space characters between children with padding-based spaces
20813
+ * to avoid the underline browsers draw under spaces inside links.
20814
+ *
20815
+ * @param {object} [props.holdSpaceForStyle]
20816
+ * Prevents layout shifts when text styles change (font-weight, font-size, …).
20817
+ * Pass an object of CSS-in-JS style properties representing the "maximum" state of the text.
20818
+ * An invisible placeholder is rendered with those styles to reserve the space,
20819
+ * and the real visible text is layered on top via `position: absolute`.
20820
+ * Only works reliably with single-line (`noWrap`) text.
20821
+ * Example: `holdSpaceForStyle={{ fontWeight: "bold", fontSize: "1.5rem" }}`
20822
+ *
20823
+ * @param {boolean} [props.boldStable]
20824
+ * Alternative to `holdSpaceForStyle` for multi-line text.
20825
+ * Keeps a consistent visual width regardless of font-weight by painting normal-weight
20826
+ * text on top of a bold background using `background-clip: text`.
20827
+ * Does not support font-size changes.
20828
+ *
20829
+ * @param {boolean} [props.capitalize]
20830
+ * Applies `text-transform: uppercase` to the first letter via CSS.
20831
+ *
20832
+ * @param {string|Array} [props.selectRange]
20833
+ * Selects a portion of the text on mount. Forwarded to `useInitialTextSelection`.
20834
+ *
20835
+ * @param {*} [props.childrenOutsideFlow]
20836
+ * Rendered after children but outside the text flow (useful for overlays
20837
+ * like the skeleton container).
20838
+ */
20688
20839
  const Text = props => {
20689
20840
  import.meta.css = [css$v, "@jsenv/navi/src/text/text.jsx"];
20690
20841
  if (props.loading || props.skeleton) {
@@ -20814,21 +20965,25 @@ const TextWithSelectRange = ({
20814
20965
  const TextBasic = ({
20815
20966
  spacing,
20816
20967
  preventSpaceUnderlines = false,
20817
- boldTransition,
20818
20968
  boldStable,
20819
- preventBoldLayoutShift = boldTransition,
20969
+ holdSpaceForStyle,
20820
20970
  capitalize,
20821
20971
  children,
20822
20972
  childrenOutsideFlow,
20973
+ basePseudoState,
20823
20974
  ...rest
20824
20975
  }) => {
20976
+ const defaultRef = useRef();
20977
+ const ref = rest.ref || defaultRef;
20978
+ const bgDeps = basePseudoState ? Object.values(basePseudoState) : [];
20979
+ useDarkBackgroundAttribute(ref, bgDeps);
20825
20980
  const defaultSpace = preventSpaceUnderlines ? FAKE_SPACE : REGULAR_SPACE;
20826
20981
  const resolvedSpacing = spacing ?? defaultSpace;
20827
20982
  const boxProps = {
20828
20983
  "as": "span",
20829
- "data-bold-transition": boldTransition ? "" : undefined,
20830
20984
  "data-capitalize": capitalize ? "" : undefined,
20831
20985
  ...rest,
20986
+ ref,
20832
20987
  "baseClassName": withPropsClassName("navi_text", rest.baseClassName)
20833
20988
  };
20834
20989
  const shouldPreserveSpacing = rest.as === "pre" || rest.flex || rest.grid;
@@ -20845,6 +21000,7 @@ const TextBasic = ({
20845
21000
  ...boxProps,
20846
21001
  bold: undefined,
20847
21002
  "data-bold": bold ? "" : undefined,
21003
+ "data-contains-absolute-child": "",
20848
21004
  children: [jsx("span", {
20849
21005
  className: "navi_text_bold_background",
20850
21006
  "aria-hidden": "true",
@@ -20852,25 +21008,23 @@ const TextBasic = ({
20852
21008
  }), children, childrenOutsideFlow]
20853
21009
  });
20854
21010
  }
20855
- if (preventBoldLayoutShift) {
20856
- const alignX = rest.alignX || rest.align || "start";
20857
-
20858
- // La technique consiste a avoid un double gras qui force une taille
20859
- // et la version light par dessus en position absolute
20860
- // on la centre aussi pour donner l'impression que le gras s'applique depuis le centre
20861
- // ne fonctionne que sur une seule ligne de texte (donc lorsque noWrap est actif)
20862
- // on pourrait auto-active cela sur une prop genre boldCanChange
21011
+ if (holdSpaceForStyle) {
21012
+ // The sizer technique prevents layout shifts when styles that affect text dimensions change.
21013
+ // - navi_text_sizer_placeholder: invisible, rendered with holdSpaceForStyle applied so it
21014
+ // always occupies the "maximum" dimensions (e.g. bold + larger font-size).
21015
+ // - navi_text_sizer_overlay: absolutely positioned on top, renders the actual visible text
21016
+ // with its current style. Transitions can be applied on this element from the outside.
20863
21017
  return jsxs(Box, {
20864
21018
  ...boxProps,
20865
21019
  children: [jsxs("span", {
20866
- className: "navi_text_bold_wrapper",
21020
+ className: "navi_text_sizer",
20867
21021
  children: [jsx("span", {
20868
- className: "navi_text_bold_clone",
21022
+ className: "navi_text_sizer_placeholder",
20869
21023
  "aria-hidden": "true",
21024
+ style: holdSpaceForStyle,
20870
21025
  children: children
20871
21026
  }), jsx("span", {
20872
- className: "navi_text_bold_foreground",
20873
- "data-align": alignX,
21027
+ className: "navi_text_sizer_overlay",
20874
21028
  children: children
20875
21029
  })]
20876
21030
  }), childrenOutsideFlow]
@@ -21166,109 +21320,6 @@ const Icon = ({
21166
21320
  });
21167
21321
  };
21168
21322
 
21169
- /**
21170
- * Toggles a `data-dark-background` attribute on the referenced element based on its
21171
- * computed background color. Pair it with a CSS variable to get automatic
21172
- * light/dark text without hard-coding colors:
21173
- *
21174
- * ```css
21175
- * .my-element {
21176
- * --color-contrasting: black;
21177
- * &[data-dark-background] {
21178
- * --color-contrasting: white;
21179
- * }
21180
- * color: var(--color-contrasting);
21181
- * }
21182
- * ```
21183
- *
21184
- * - `data-dark-background` is **set** when the background is dark enough that white text
21185
- * provides better (or equal) contrast.
21186
- * - `data-dark-background` is **absent** when black text is the better choice.
21187
- *
21188
- * @param {import("preact").RefObject} ref - Ref to the element that receives
21189
- * the `data-dark-background` attribute and is also passed to `contrastColor` for
21190
- * resolving CSS variables.
21191
- * @param {object} [options]
21192
- * @param {string} [options.backgroundElementSelector] - CSS selector relative
21193
- * to `ref.current` pointing to a child element whose `background-color`
21194
- * should be tested instead of the element itself. Useful when the element
21195
- * has a transparent background but contains a coloured child (e.g. a fill
21196
- * bar inside a track).
21197
- */
21198
-
21199
- const useDarkBackgroundAttribute = (
21200
- ref,
21201
- deps = [],
21202
- {
21203
- backgroundElementSelector,
21204
- attributeName = "data-dark-background",
21205
- hardcoded = {},
21206
- } = {},
21207
- ) => {
21208
- const innerDeps = [
21209
- ...deps,
21210
- // ref can change is the component pass a different ref on different render based on some logic
21211
- // (can be used to control which element backgroundColor is being checked by switching the ref to another element)
21212
- ref,
21213
- // backgroundElementSelector can change if the component pass a different selector on different render based on some logic
21214
- // (can be used to control which element backgroundColor is being checked by switching the selector to point to another element)
21215
- backgroundElementSelector,
21216
- ];
21217
-
21218
- const hardcodedMap = new Map();
21219
- for (const key of Object.keys(hardcoded)) {
21220
- const value = hardcoded[key];
21221
- innerDeps.push(key, value);
21222
- const colorString = normalizeColorString(key);
21223
- hardcodedMap.set(colorString, value);
21224
- }
21225
-
21226
- useLayoutEffect(() => {
21227
- const el = ref.current;
21228
- if (!el) {
21229
- return undefined;
21230
- }
21231
- let elementToCheck = el;
21232
- if (backgroundElementSelector) {
21233
- elementToCheck = el.querySelector(backgroundElementSelector);
21234
- if (!elementToCheck) {
21235
- return undefined;
21236
- }
21237
- }
21238
- const updateAttribute = () => {
21239
- const computedStyle = getComputedStyle(elementToCheck);
21240
- const backgroundColor = computedStyle.backgroundColor;
21241
- if (!backgroundColor) {
21242
- el.removeAttribute(attributeName);
21243
- return;
21244
- }
21245
- const backgroundColorString = normalizeColorString(backgroundColor, el);
21246
- const hardcodedContrast = hardcodedMap.get(backgroundColorString);
21247
- const contrastingColor =
21248
- hardcodedContrast || contrastColor(backgroundColor, el);
21249
- if (contrastingColor === "white") {
21250
- el.setAttribute(attributeName, "");
21251
- } else {
21252
- el.removeAttribute(attributeName);
21253
- }
21254
- };
21255
- updateAttribute();
21256
- el.addEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttribute);
21257
- return () => {
21258
- el.removeEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttribute);
21259
- el.removeAttribute(attributeName);
21260
- };
21261
- }, innerDeps);
21262
- };
21263
-
21264
- const normalizeColorString = (color, el) => {
21265
- const colorRgba = resolveCSSColor(color, el);
21266
- if (!colorRgba) {
21267
- return "";
21268
- }
21269
- return String(colorRgba);
21270
- };
21271
-
21272
21323
  const useFormEvents = (
21273
21324
  elementRef,
21274
21325
  {
@@ -21790,8 +21841,7 @@ const useUIState = (uiStateController) => {
21790
21841
  return trackedUIState;
21791
21842
  };
21792
21843
 
21793
- installImportMetaCssBuild(import.meta);/* eslint-disable jsenv/no-unknown-params */
21794
- const css$s = /* css */`
21844
+ installImportMetaCssBuild(import.meta);const css$s = /* css */`
21795
21845
  @layer navi {
21796
21846
  .navi_button {
21797
21847
  --button-outline-width: 1px;
@@ -22588,8 +22638,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
22588
22638
  });
22589
22639
  };
22590
22640
 
22591
- installImportMetaCssBuild(import.meta);/* eslint-disable jsenv/no-unknown-params */
22592
- const css$r = /* css */`
22641
+ installImportMetaCssBuild(import.meta);const css$r = /* css */`
22593
22642
  @layer navi {
22594
22643
  .navi_link {
22595
22644
  --link-border-radius: unset;
@@ -22735,7 +22784,7 @@ const css$r = /* css */`
22735
22784
  }
22736
22785
 
22737
22786
  /* Dark background */
22738
- &[data-dark-background] {
22787
+ &[data-dark-background].navi_text {
22739
22788
  --x-link-contrasting-color: white;
22740
22789
  --x-link-color: var(--link-color, white);
22741
22790
  }
@@ -23041,7 +23090,6 @@ const LinkPlain = props => {
23041
23090
  isCurrent
23042
23091
  } = getHrefTargetInfo(href);
23043
23092
  const innerCurrent = current || isCurrent;
23044
- useDarkBackgroundAttribute(ref, [selected, innerCurrent], {});
23045
23093
  const innerTarget = target === undefined ? isSameSite ? "_self" : "_blank" : target;
23046
23094
  const innerRel = rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel;
23047
23095
  let innerEndIcon;
@@ -23094,7 +23142,9 @@ const LinkPlain = props => {
23094
23142
  onnavi_value: e => {
23095
23143
  e.detail.setValue(value);
23096
23144
  },
23097
- preventBoldLayoutShift: currentEffectBold,
23145
+ holdSpaceForStyle: currentEffectBold ? {
23146
+ fontWeight: "bold"
23147
+ } : undefined,
23098
23148
  preventSpaceUnderlines: true,
23099
23149
  overflowEllipsis: overflowEllipsis
23100
23150
  // Visual
@@ -25699,6 +25749,16 @@ const InputRangeWithAction = props => {
25699
25749
  });
25700
25750
  };
25701
25751
 
25752
+ const ChevronDownSvg = () => {
25753
+ return jsx("svg", {
25754
+ viewBox: "0 0 16 16",
25755
+ fill: "currentColor",
25756
+ children: jsx("path", {
25757
+ 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"
25758
+ })
25759
+ });
25760
+ };
25761
+
25702
25762
  const SearchSvg = () => jsx("svg", {
25703
25763
  viewBox: "0 0 24 24",
25704
25764
  xmlns: "http://www.w3.org/2000/svg",
@@ -25708,7 +25768,23 @@ const SearchSvg = () => jsx("svg", {
25708
25768
  })
25709
25769
  });
25710
25770
 
25711
- installImportMetaCssBuild(import.meta);/* eslint-disable jsenv/no-unknown-params */
25771
+ installImportMetaCssBuild(import.meta);/**
25772
+ * Input component for all textual input types.
25773
+ *
25774
+ * Supports:
25775
+ * - text (default)
25776
+ * - password
25777
+ * - hidden
25778
+ * - email
25779
+ * - url
25780
+ * - search
25781
+ * - tel
25782
+ * - etc.
25783
+ *
25784
+ * For non-textual inputs, specialized components will be used:
25785
+ * - <InputCheckbox /> for type="checkbox"
25786
+ * - <InputRadio /> for type="radio"
25787
+ */
25712
25788
  const css$j = /* css */`
25713
25789
  @layer navi {
25714
25790
  .navi_input {
@@ -25767,8 +25843,8 @@ const css$j = /* css */`
25767
25843
  border-radius: inherit;
25768
25844
  cursor: inherit;
25769
25845
 
25770
- --start-icon-size: 0px;
25771
- --end-icon-size: 0px;
25846
+ --left-slot-size: 0px;
25847
+ --right-slot-size: 0px;
25772
25848
  --x-outline-width: var(--outline-width);
25773
25849
  --x-border-radius: var(--border-radius);
25774
25850
  --x-border-width: var(--border-width);
@@ -25799,9 +25875,9 @@ const css$j = /* css */`
25799
25875
  .navi_native_input {
25800
25876
  box-sizing: border-box;
25801
25877
  padding-top: var(--x-padding-top-base);
25802
- padding-right: calc(var(--x-padding-right-base) + var(--end-icon-size));
25878
+ padding-right: calc(var(--x-padding-right-base) + var(--right-slot-size));
25803
25879
  padding-bottom: var(--x-padding-bottom-base);
25804
- padding-left: calc(var(--x-padding-left-base) + var(--start-icon-size));
25880
+ padding-left: calc(var(--x-padding-left-base) + var(--left-slot-size));
25805
25881
  color: var(--x-color);
25806
25882
  font-size: var(--font-size);
25807
25883
  background-color: var(--x-background-color);
@@ -25824,18 +25900,10 @@ const css$j = /* css */`
25824
25900
  }
25825
25901
  }
25826
25902
 
25827
- .navi_input_start_icon {
25903
+ .navi_input_slot {
25828
25904
  position: absolute;
25829
25905
  top: 0;
25830
25906
  bottom: 0;
25831
- left: var(--x-padding-left-base);
25832
- font-size: var(--font-size);
25833
- }
25834
- .navi_input_end_button {
25835
- position: absolute;
25836
- top: 0;
25837
- right: var(--x-padding-right-base);
25838
- bottom: 0;
25839
25907
  display: inline-flex;
25840
25908
  margin: 0;
25841
25909
  padding: 0;
@@ -25844,34 +25912,43 @@ const css$j = /* css */`
25844
25912
  font-size: var(--font-size);
25845
25913
  background: none;
25846
25914
  border: none;
25847
- opacity: 0;
25848
- pointer-events: none;
25915
+
25916
+ &[data-left] {
25917
+ left: var(--x-padding-left-base);
25918
+ }
25919
+ &[data-right] {
25920
+ right: var(--x-padding-right-base);
25921
+ }
25922
+ &[data-hide-while-empty] {
25923
+ opacity: 0;
25924
+ pointer-events: none;
25925
+ }
25849
25926
  }
25850
25927
  &[data-has-value] {
25851
- .navi_input_end_button {
25928
+ .navi_input_slot[data-hide-while-empty] {
25852
25929
  opacity: 1;
25853
25930
  cursor: pointer;
25854
25931
  pointer-events: auto;
25855
25932
  }
25856
25933
 
25857
25934
  &[data-readonly] {
25858
- .navi_input_end_button {
25935
+ .navi_input_slot[data-hide-while-empty] {
25859
25936
  opacity: 0;
25860
25937
  pointer-events: none;
25861
25938
  }
25862
25939
  }
25863
25940
  &[data-disabled] {
25864
- .navi_input_end_button {
25941
+ .navi_input_slot[data-hide-while-empty] {
25865
25942
  opacity: 0;
25866
25943
  pointer-events: none;
25867
25944
  }
25868
25945
  }
25869
25946
  }
25870
- &[data-start-icon] {
25871
- --start-icon-size: 1em;
25947
+ &:has(.navi_input_slot[data-left]) {
25948
+ --left-slot-size: 1em;
25872
25949
  }
25873
- &[data-end-icon] {
25874
- --end-icon-size: 1em;
25950
+ &:has(.navi_input_slot[data-right]) {
25951
+ --right-slot-size: 1em;
25875
25952
  }
25876
25953
 
25877
25954
  /* Hover */
@@ -25977,7 +26054,7 @@ const InputStyleCSSVars = {
25977
26054
  color: "--color-disabled"
25978
26055
  }
25979
26056
  };
25980
- const InputPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading", ":navi-has-value"];
26057
+ const InputPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading", ":navi-has-value", ":navi-expanded"];
25981
26058
  Object.assign(PSEUDO_CLASSES, {
25982
26059
  ":navi-has-value": {
25983
26060
  attribute: "data-has-value",
@@ -25994,6 +26071,55 @@ Object.assign(PSEUDO_CLASSES, {
25994
26071
  });
25995
26072
  const InputPseudoElements = ["::-navi-loader"];
25996
26073
  const InputChildPropSet = new Set([...fieldPropSet]);
26074
+ const InputNativeContext = createContext(null);
26075
+ const InputSlot = ({
26076
+ side,
26077
+ onClick,
26078
+ hideWhileEmpty,
26079
+ ...props
26080
+ }) => {
26081
+ const ctx = useContext(InputNativeContext);
26082
+ const {
26083
+ id,
26084
+ readOnly,
26085
+ disabled
26086
+ } = ctx;
26087
+ return jsx(Label, {
26088
+ htmlFor: id,
26089
+ className: "navi_input_slot",
26090
+ disabled: disabled,
26091
+ readOnly: readOnly,
26092
+ "data-readonly": readOnly,
26093
+ "data-disabled": disabled,
26094
+ "data-left": side === "left" ? "" : undefined,
26095
+ "data-right": side === "right" ? "" : undefined,
26096
+ "data-hide-while-empty": hideWhileEmpty ? "" : undefined,
26097
+ flex: true,
26098
+ alignY: "center",
26099
+ onMouseDown: e => {
26100
+ e.preventDefault(); // keep focus in the input
26101
+ },
26102
+ onClick: e => {
26103
+ if (readOnly || disabled) {
26104
+ return;
26105
+ }
26106
+ onClick?.(e);
26107
+ },
26108
+ ...props
26109
+ });
26110
+ };
26111
+ const InputLeftSlot = props => {
26112
+ return jsx(InputSlot, {
26113
+ ...props,
26114
+ side: "left"
26115
+ });
26116
+ };
26117
+ const InputRightSlot = props => {
26118
+ return jsx(InputSlot, {
26119
+ ...props,
26120
+ side: "right"
26121
+ });
26122
+ };
25997
26123
  const InputTextualBasic = props => {
25998
26124
  if (props.suggestions) {
25999
26125
  return jsx(InputTextualWithSuggestions, {
@@ -26009,54 +26135,48 @@ const InputTextualWithSuggestions = ({
26009
26135
  onInput,
26010
26136
  onFocus,
26011
26137
  onBlur,
26138
+ children,
26012
26139
  ...rest
26013
26140
  }) => {
26014
26141
  const defaultRef = useRef();
26015
26142
  const ref = rest.ref || defaultRef;
26016
- const [suggestionsOpen, setSuggestionsOpen] = useState(false);
26017
- const suggestionsOpenRef = useRef(false);
26018
- suggestionsOpenRef.current = suggestionsOpen;
26019
- const showPopover = e => {
26020
- if (suggestionsOpenRef.current) {
26143
+ const [expanded, setExpanded] = useState(false);
26144
+ const expandedRef = useRef(expanded);
26145
+ expandedRef.current = expanded;
26146
+ const expand = () => {
26147
+ expandedRef.current = true;
26148
+ setExpanded(true);
26149
+ };
26150
+ const collapse = () => {
26151
+ expandedRef.current = false;
26152
+ setExpanded(false);
26153
+ };
26154
+ const showSuggestions = e => {
26155
+ if (expandedRef.current) {
26021
26156
  return;
26022
26157
  }
26023
26158
  console.debug(`showPopover (e.type:${e.type})`);
26024
26159
  const popoverEl = document.getElementById(suggestions);
26025
- positionPopover();
26026
- popoverEl.showPopover();
26027
- suggestionsOpenRef.current = true;
26028
- setSuggestionsOpen(true);
26029
- window.addEventListener("scroll", positionPopover, {
26030
- capture: true,
26031
- passive: true
26032
- });
26160
+ if (!popoverEl) {
26161
+ return;
26162
+ }
26163
+ popoverEl.dispatchEvent(new CustomEvent("navi_suggestion_list_open", {
26164
+ detail: {
26165
+ anchor: ref.current
26166
+ }
26167
+ }));
26168
+ expand();
26033
26169
  };
26034
- const hidePopover = e => {
26035
- if (!suggestionsOpenRef.current) {
26170
+ const hideSuggestions = e => {
26171
+ if (!expandedRef.current) {
26036
26172
  return;
26037
26173
  }
26038
26174
  console.debug(`hidePopover (e.type:${e.type})`);
26039
- suggestionsOpenRef.current = false;
26040
- setSuggestionsOpen(false);
26041
- window.removeEventListener("scroll", positionPopover, {
26042
- capture: true
26043
- });
26044
- const popoverEl = document.getElementById(suggestions);
26045
- if (popoverEl) {
26046
- popoverEl.dispatchEvent(new CustomEvent("navi_suggestion_list_clear"));
26047
- popoverEl.hidePopover();
26048
- }
26049
- setSuggestionsOpen(false);
26050
- };
26051
- const positionPopover = () => {
26052
- const input = ref.current;
26053
- const rect = input.getBoundingClientRect();
26054
26175
  const popoverEl = document.getElementById(suggestions);
26055
26176
  if (popoverEl) {
26056
- popoverEl.style.top = `${rect.bottom + 2}px`;
26057
- popoverEl.style.left = `${rect.left}px`;
26058
- popoverEl.style.width = `${rect.width}px`;
26177
+ popoverEl.dispatchEvent(new CustomEvent("navi_suggestion_list_close"));
26059
26178
  }
26179
+ collapse();
26060
26180
  };
26061
26181
  const dispatchToSuggestionList = customEvent => {
26062
26182
  const popoverEl = document.getElementById(suggestions);
@@ -26070,7 +26190,7 @@ const InputTextualWithSuggestions = ({
26070
26190
  key: "arrowdown",
26071
26191
  description: "Open popover and point to next suggestion",
26072
26192
  handler: e => {
26073
- showPopover(e);
26193
+ showSuggestions(e);
26074
26194
  const popoverEl = document.getElementById(suggestions);
26075
26195
  if (!popoverEl) {
26076
26196
  return false;
@@ -26086,7 +26206,7 @@ const InputTextualWithSuggestions = ({
26086
26206
  key: "arrowup",
26087
26207
  description: "Open popover and point to previous suggestion",
26088
26208
  handler: e => {
26089
- showPopover(e);
26209
+ showSuggestions(e);
26090
26210
  return dispatchToSuggestionList(new CustomEvent("navi_suggestion_list_navigate", {
26091
26211
  detail: {
26092
26212
  direction: "up"
@@ -26097,7 +26217,7 @@ const InputTextualWithSuggestions = ({
26097
26217
  key: "home",
26098
26218
  description: "Point to first suggestion",
26099
26219
  handler: () => {
26100
- if (!suggestionsOpenRef.current) {
26220
+ if (!expandedRef.current) {
26101
26221
  return false;
26102
26222
  }
26103
26223
  return dispatchToSuggestionList(new CustomEvent("navi_suggestion_list_navigate", {
@@ -26110,7 +26230,7 @@ const InputTextualWithSuggestions = ({
26110
26230
  key: "end",
26111
26231
  description: "Point to last suggestion",
26112
26232
  handler: () => {
26113
- if (!suggestionsOpenRef.current) {
26233
+ if (!expandedRef.current) {
26114
26234
  return false;
26115
26235
  }
26116
26236
  return dispatchToSuggestionList(new CustomEvent("navi_suggestion_list_navigate", {
@@ -26123,7 +26243,7 @@ const InputTextualWithSuggestions = ({
26123
26243
  key: "enter",
26124
26244
  description: "Confirm pointed suggestion",
26125
26245
  handler: () => {
26126
- if (!suggestionsOpenRef.current) {
26246
+ if (!expandedRef.current) {
26127
26247
  return false;
26128
26248
  }
26129
26249
  return dispatchToSuggestionList(new CustomEvent("navi_suggestion_list_confirm", {
@@ -26134,10 +26254,10 @@ const InputTextualWithSuggestions = ({
26134
26254
  key: "escape",
26135
26255
  description: "Close popover",
26136
26256
  handler: e => {
26137
- if (!suggestionsOpenRef.current) {
26257
+ if (!expandedRef.current) {
26138
26258
  return false;
26139
26259
  }
26140
- hidePopover(e);
26260
+ hideSuggestions(e);
26141
26261
  return true;
26142
26262
  }
26143
26263
  }]);
@@ -26152,7 +26272,7 @@ const InputTextualWithSuggestions = ({
26152
26272
  inputEl.dispatchEvent(new Event("input", {
26153
26273
  bubbles: true
26154
26274
  }));
26155
- hidePopover(e);
26275
+ hideSuggestions(e);
26156
26276
  };
26157
26277
  popoverEl.addEventListener("navi_suggestion_list_selected", onSelected);
26158
26278
  return () => {
@@ -26165,24 +26285,40 @@ const InputTextualWithSuggestions = ({
26165
26285
  autoComplete: "off",
26166
26286
  "aria-controls": suggestions,
26167
26287
  "aria-haspopup": "listbox",
26168
- "aria-expanded": suggestionsOpen,
26288
+ "aria-expanded": expanded,
26169
26289
  "aria-autocomplete": "list",
26290
+ basePseudoState: {
26291
+ ":navi-expanded": expanded
26292
+ },
26170
26293
  onnavi_callout_open: e => {
26171
- hidePopover(e);
26294
+ hideSuggestions(e);
26172
26295
  },
26173
26296
  onFocus: e => {
26174
26297
  onFocus?.(e);
26175
- showPopover(e);
26298
+ showSuggestions(e);
26176
26299
  },
26177
26300
  onBlur: e => {
26178
26301
  onBlur?.(e);
26179
- hidePopover(e);
26302
+ hideSuggestions(e);
26180
26303
  },
26181
26304
  onInput: e => {
26182
26305
  onInput?.(e);
26183
- showPopover(e);
26306
+ showSuggestions(e);
26184
26307
  },
26185
- ...rest
26308
+ ...rest,
26309
+ children: children || jsx(InputRightSlot, {
26310
+ onClick: e => {
26311
+ if (expanded) {
26312
+ hideSuggestions(e);
26313
+ } else {
26314
+ showSuggestions(e);
26315
+ }
26316
+ },
26317
+ children: jsx(Icon, {
26318
+ color: "rgba(28, 43, 52, 0.5)",
26319
+ children: jsx(ChevronDownSvg, {})
26320
+ })
26321
+ })
26186
26322
  });
26187
26323
  };
26188
26324
  const InputTextualPlain = props => {
@@ -26201,8 +26337,8 @@ const InputTextualPlain = props => {
26201
26337
  autoFocus,
26202
26338
  autoFocusVisible,
26203
26339
  autoSelect,
26204
- icon,
26205
- cancelButton = type === "search",
26340
+ basePseudoState,
26341
+ children,
26206
26342
  ...rest
26207
26343
  } = props;
26208
26344
  const defaultRef = useRef();
@@ -26261,17 +26397,45 @@ const InputTextualPlain = props => {
26261
26397
  });
26262
26398
  };
26263
26399
  const renderInputMemoized = useCallback(renderInput, [type, uiState, innerValue, innerOnInput, innerId]);
26264
- let innerIcon;
26265
- if (icon === undefined) {
26266
- if (type === "search") {
26267
- innerIcon = jsx(SearchSvg, {});
26268
- } else if (type === "email") {
26269
- innerIcon = jsx(EmailSvg, {});
26270
- } else if (type === "tel") {
26271
- innerIcon = jsx(PhoneSvg, {});
26272
- }
26273
- } else {
26274
- innerIcon = icon;
26400
+ let innerChildren;
26401
+ if (children) {
26402
+ innerChildren = children;
26403
+ } else if (type === "search") {
26404
+ innerChildren = jsxs(Fragment, {
26405
+ children: [jsx(InputLeftSlot, {
26406
+ children: jsx(Icon, {
26407
+ color: "rgba(28, 43, 52, 0.5)",
26408
+ children: jsx(SearchSvg, {})
26409
+ })
26410
+ }), jsx(InputRightSlot, {
26411
+ hideWhileEmpty: true,
26412
+ onClick: () => {
26413
+ uiStateController.setUIState("", {
26414
+ trigger: "cancel_button"
26415
+ });
26416
+ ref.current.value = "";
26417
+ ref.current.dispatchEvent(new Event("navi_delete_content"));
26418
+ },
26419
+ children: jsx(Icon, {
26420
+ color: "rgba(28, 43, 52, 0.5)",
26421
+ children: jsx(CloseSvg, {})
26422
+ })
26423
+ })]
26424
+ });
26425
+ } else if (type === "email") {
26426
+ innerChildren = jsx(InputLeftSlot, {
26427
+ children: jsx(Icon, {
26428
+ color: "rgba(28, 43, 52, 0.5)",
26429
+ children: jsx(EmailSvg, {})
26430
+ })
26431
+ });
26432
+ } else if (type === "tel") {
26433
+ innerChildren = jsx(InputLeftSlot, {
26434
+ children: jsx(Icon, {
26435
+ color: "rgba(28, 43, 52, 0.5)",
26436
+ children: jsx(PhoneSvg, {})
26437
+ })
26438
+ });
26275
26439
  }
26276
26440
  return jsxs(Box, {
26277
26441
  as: "span",
@@ -26281,6 +26445,7 @@ const InputTextualPlain = props => {
26281
26445
  pseudoStateSelector: ".navi_native_input",
26282
26446
  visualSelector: ".navi_native_input",
26283
26447
  basePseudoState: {
26448
+ ...basePseudoState,
26284
26449
  ":read-only": innerReadOnly,
26285
26450
  ":disabled": innerDisabled,
26286
26451
  ":-navi-loading": innerLoading
@@ -26289,48 +26454,20 @@ const InputTextualPlain = props => {
26289
26454
  pseudoElements: InputPseudoElements,
26290
26455
  hasChildFunction: true,
26291
26456
  baseChildPropSet: InputChildPropSet,
26292
- "data-start-icon": innerIcon ? "" : undefined,
26293
- "data-end-icon": cancelButton ? "" : undefined,
26294
26457
  ...remainingProps,
26295
26458
  ref: undefined,
26296
26459
  children: [jsx(LoaderBackground, {
26297
26460
  loading: innerLoading,
26298
26461
  color: "var(--loader-color)",
26299
26462
  inset: -1
26300
- }), innerIcon && jsx(Label, {
26301
- htmlFor: innerId,
26302
- disabled: innerDisabled,
26303
- readOnly: innerReadOnly,
26304
- className: "navi_input_start_icon",
26305
- flex: true,
26306
- alignY: "center",
26307
- children: jsx(Icon, {
26308
- color: "rgba(28, 43, 52, 0.5)",
26309
- children: innerIcon
26310
- })
26311
- }), renderInputMemoized, cancelButton && jsx("label", {
26312
- htmlFor: innerId,
26313
- "data-readonly": innerReadOnly ? "" : undefined,
26314
- "data-disabled": innerDisabled ? "" : undefined,
26315
- className: "navi_input_end_button",
26316
- onMouseDown: e => {
26317
- e.preventDefault(); // keep focus in the input
26318
- },
26319
- onClick: () => {
26320
- if (innerReadOnly || innerDisabled) {
26321
- return;
26322
- }
26323
- uiStateController.setUIState("", {
26324
- trigger: "cancel_button"
26325
- });
26326
- ref.current.value = "";
26327
- ref.current.dispatchEvent(new Event("navi_delete_content"));
26463
+ }), renderInputMemoized, innerChildren ? jsx(InputNativeContext.Provider, {
26464
+ value: {
26465
+ id: innerId,
26466
+ readOnly: innerReadOnly,
26467
+ disabled: innerDisabled
26328
26468
  },
26329
- children: jsx(Icon, {
26330
- color: "rgba(28, 43, 52, 0.5)",
26331
- children: jsx(CloseSvg, {})
26332
- })
26333
- })]
26469
+ children: innerChildren
26470
+ }) : null]
26334
26471
  });
26335
26472
  };
26336
26473
  const InputTextualWithAction = props => {
@@ -27058,553 +27195,32 @@ const Group = ({
27058
27195
  });
27059
27196
  };
27060
27197
 
27061
- const createItemTracker = () => {
27062
- const ItemTrackerContext = createContext();
27063
- const useItemTrackerProvider = () => {
27064
- const itemsRef = useRef([]);
27065
- const items = itemsRef.current;
27066
- const itemCountRef = useRef(0);
27067
- const tracker = useMemo(() => {
27068
- const ItemTrackerProvider = ({
27069
- children
27070
- }) => {
27071
- // Reset on each render to start fresh
27072
- tracker.reset();
27073
- return jsx(ItemTrackerContext.Provider, {
27074
- value: tracker,
27075
- children: children
27076
- });
27077
- };
27078
- ItemTrackerProvider.items = items;
27079
- return {
27080
- ItemTrackerProvider,
27081
- items,
27082
- registerItem: data => {
27083
- const index = itemCountRef.current++;
27084
- items[index] = data;
27085
- return index;
27086
- },
27087
- getItem: index => {
27088
- return items[index];
27089
- },
27090
- getAllItems: () => {
27091
- return items;
27092
- },
27093
- reset: () => {
27094
- items.length = 0;
27095
- itemCountRef.current = 0;
27198
+ const RadioList = props => {
27199
+ const uiStateController = useUIGroupStateController(props, "radio_list", {
27200
+ childComponentType: "radio",
27201
+ aggregateChildStates: childUIStateControllers => {
27202
+ let activeValue;
27203
+ for (const childUIStateController of childUIStateControllers) {
27204
+ if (childUIStateController.uiState) {
27205
+ activeValue = childUIStateController.uiState;
27206
+ break;
27096
27207
  }
27097
- };
27098
- }, []);
27099
- return tracker.ItemTrackerProvider;
27100
- };
27101
- const useTrackItem = data => {
27102
- const tracker = useContext(ItemTrackerContext);
27103
- if (!tracker) {
27104
- throw new Error("useTrackItem must be used within SimpleItemTrackerProvider");
27105
- }
27106
- return tracker.registerItem(data);
27107
- };
27108
- const useTrackedItem = index => {
27109
- const trackedItems = useTrackedItems();
27110
- const item = trackedItems[index];
27111
- return item;
27112
- };
27113
- const useTrackedItems = () => {
27114
- const tracker = useContext(ItemTrackerContext);
27115
- if (!tracker) {
27116
- throw new Error("useTrackedItems must be used within SimpleItemTrackerProvider");
27208
+ }
27209
+ return activeValue;
27117
27210
  }
27118
- return tracker.items;
27119
- };
27120
- return [useItemTrackerProvider, useTrackItem, useTrackedItem, useTrackedItems];
27121
- };
27122
-
27123
- installImportMetaCssBuild(import.meta);const [useSuggestionItemTrackerProvider, useTrackSuggestion] = createItemTracker();
27124
-
27125
- /**
27126
- * SuggestionList + Suggestion: a composable accessible listbox.
27127
- *
27128
- * Usage:
27129
- * <SuggestionList id="my-list" value={selected} onChange={setSelected}>
27130
- * <Suggestion value="a">Option A</Suggestion>
27131
- * <Suggestion value="b">Option B</Suggestion>
27132
- * </SuggestionList>
27133
- *
27134
- * CSS vars on .navi_suggestion_list:
27135
- * --suggestion-list-border-radius, --suggestion-list-border-width, --suggestion-list-border-color, --suggestion-list-background-color, --suggestion-list-max-height
27136
- *
27137
- * CSS vars on .navi_suggestion:
27138
- * --suggestion-padding, --suggestion-color, --suggestion-background-color, --suggestion-font-weight
27139
- * --suggestion-color-hover, --suggestion-background-color-hover
27140
- * --suggestion-color-pointed, --suggestion-background-color-pointed
27141
- * --suggestion-color-selected, --suggestion-background-color-selected, --suggestion-font-weight-selected
27142
- * --suggestion-color-pointed-selected, --suggestion-background-color-pointed-selected
27143
- * --suggestion-color-highlight, --suggestion-background-color-highlight
27144
- *
27145
- * CSS vars on .navi_suggestion_group_label:
27146
- * --suggestion-group-label-padding, --suggestion-group-label-color, --suggestion-group-label-font-size, --suggestion-group-label-font-weight
27147
- */
27148
-
27149
- const css$g = /* css */`
27150
- @layer navi {
27151
- .navi_suggestion_list {
27152
- --suggestion-list-border-radius: 4px;
27153
- --suggestion-list-border-width: 1px;
27154
- --suggestion-list-border-color: light-dark(#ccc, #555);
27155
- --suggestion-list-background-color: light-dark(#fff, #1e1e1e);
27156
- --suggestion-list-max-height: 220px;
27157
- }
27158
- .navi_suggestion_group_label {
27159
- --suggestion-group-label-padding: 4px 12px 2px;
27160
- --suggestion-group-label-color: light-dark(#888, #aaa);
27161
- --suggestion-group-label-font-size: 0.75em;
27162
- --suggestion-group-label-font-weight: 600;
27163
- }
27164
- .navi_suggestion {
27165
- --suggestion-padding: 8px 12px;
27166
- --suggestion-color: inherit;
27167
- --suggestion-background-color: transparent;
27168
- --suggestion-font-weight: inherit;
27169
-
27170
- /* Hover (mouse) */
27171
- --suggestion-color-hover: var(--suggestion-color);
27172
- --suggestion-background-color-hover: light-dark(#f5f5f5, #2a2a2a);
27173
-
27174
- /* Pointed (keyboard navigation position) */
27175
- --suggestion-color-pointed: var(--suggestion-color);
27176
- --suggestion-background-color-pointed: light-dark(#e8f0fe, #1c3a6e);
27177
-
27178
- /* Selected */
27179
- --suggestion-color-selected: light-dark(#1a73e8, #7baaf7);
27180
- --suggestion-background-color-selected: light-dark(#e8f0fe, #1c3a6e);
27181
- --suggestion-font-weight-selected: 500;
27182
-
27183
- /* Highlight (CSS Highlight API match) */
27184
- --suggestion-color-highlight: inherit;
27185
- --suggestion-background-color-highlight: #ffe066;
27186
- --suggestion-color-pointed-selected: var(--suggestion-color-selected);
27187
- --suggestion-background-color-pointed-selected: light-dark(
27188
- #d2e3fc,
27189
- #174ea6
27190
- );
27191
- }
27192
- }
27193
-
27194
- .navi_suggestion_list {
27195
- --x-border-radius: var(--suggestion-list-border-radius);
27196
- --x-border-width: var(--suggestion-list-border-width);
27197
- --x-border-color: var(--suggestion-list-border-color);
27198
- --x-background-color: var(--suggestion-list-background-color);
27199
- box-sizing: border-box;
27200
- max-height: var(--suggestion-list-max-height);
27201
-
27202
- margin: 0;
27203
- padding: 0;
27204
- list-style: none;
27205
- background-color: var(--x-background-color);
27206
- border: var(--x-border-width) solid var(--x-border-color);
27207
- border-radius: var(--x-border-radius);
27208
- outline: none;
27209
- overflow-y: auto;
27210
-
27211
- /* Popover reset — browser adds border, background, padding, margin by default */
27212
- &[popover] {
27213
- position: fixed;
27214
- inset: unset;
27215
- margin: 0;
27216
- padding: 0;
27217
- border: none;
27218
- }
27219
- }
27220
- ::highlight(navi-suggestion-match) {
27221
- color: var(--suggestion-color-highlight);
27222
- background-color: var(--suggestion-background-color-highlight);
27223
- }
27224
- .navi_suggestion {
27225
- --x-color: var(--suggestion-color);
27226
- --x-background-color: var(--suggestion-background-color);
27227
- --x-font-weight: var(--suggestion-font-weight);
27228
-
27229
- padding: var(--suggestion-padding);
27230
- color: var(--x-color);
27231
- font-weight: var(--x-font-weight);
27232
- background-color: var(--x-background-color);
27233
- cursor: pointer;
27234
- user-select: none;
27235
-
27236
- &:hover {
27237
- --x-color: var(--suggestion-color-hover);
27238
- --x-background-color: var(--suggestion-background-color-hover);
27239
- }
27240
-
27241
- &[data-pointed] {
27242
- --x-color: var(--suggestion-color-pointed);
27243
- --x-background-color: var(--suggestion-background-color-pointed);
27244
- }
27245
-
27246
- &[data-selected] {
27247
- --x-color: var(--suggestion-color-selected);
27248
- --x-background-color: var(--suggestion-background-color-selected);
27249
- --x-font-weight: var(--suggestion-font-weight-selected);
27250
- }
27251
-
27252
- &[data-pointed][data-selected] {
27253
- --x-color: var(--suggestion-color-pointed-selected);
27254
- --x-background-color: var(--suggestion-background-color-pointed-selected);
27255
- }
27256
- }
27257
- .navi_suggestion_group_label {
27258
- position: sticky;
27259
- top: 0;
27260
- z-index: 1;
27261
- display: block;
27262
- padding: var(--suggestion-group-label-padding);
27263
- color: var(--suggestion-group-label-color);
27264
- font-weight: var(--suggestion-group-label-font-weight);
27265
- font-size: var(--suggestion-group-label-font-size);
27266
- text-transform: uppercase;
27267
- letter-spacing: 0.05em;
27268
- background-color: var(--suggestion-group-label-background-color);
27269
- user-select: none;
27270
- }
27271
- `;
27272
- const SuggestionListStyleCSSVars = {
27273
- borderRadius: "--suggestion-list-border-radius",
27274
- borderWidth: "--suggestion-list-border-width",
27275
- borderColor: "--suggestion-list-border-color",
27276
- backgroundColor: "--suggestion-list-background-color",
27277
- maxHeight: "--suggestion-list-max-height"
27278
- };
27279
- const SuggestionStyleCSSVars = {
27280
- "padding": "--suggestion-padding",
27281
- "color": "--suggestion-color",
27282
- "backgroundColor": "--suggestion-background-color",
27283
- "fontWeight": "--suggestion-font-weight",
27284
- ":-navi-pointed": {
27285
- color: "--suggestion-color-pointed",
27286
- backgroundColor: "--suggestion-background-color-pointed"
27287
- },
27288
- ":hover": {
27289
- color: "--suggestion-color-hover",
27290
- backgroundColor: "--suggestion-background-color-hover"
27291
- },
27292
- ":-navi-selected": {
27293
- color: "--suggestion-color-selected",
27294
- backgroundColor: "--suggestion-background-color-selected",
27295
- fontWeight: "--suggestion-font-weight-selected"
27296
- },
27297
- "::highlight": {
27298
- color: "--suggestion-color-highlight",
27299
- backgroundColor: "--suggestion-background-color-highlight"
27300
- }
27301
- };
27302
-
27303
- /**
27304
- * Context OptionList provides downward to its Option children.
27305
- */
27306
- const SuggestionListContext = createContext(null);
27307
- const SuggestionList = ({
27308
- popover,
27309
- onChange: onChangeProp,
27310
- highlight,
27311
- children,
27312
- ...rest
27313
- }) => {
27314
- import.meta.css = [css$g, "@jsenv/navi/src/field/suggestion_list.jsx"];
27315
- const ItemTrackerProvider = useSuggestionItemTrackerProvider();
27316
- const [pointedValue, setPointedValue] = useState(null);
27317
- const pointedValueRef = useRef(null);
27318
- pointedValueRef.current = pointedValue;
27319
- const ownId = useId();
27320
- const id = rest.id ?? ownId;
27321
- const defaultRef = useRef(null);
27322
- const ref = rest.ref || defaultRef;
27323
- useLayoutEffect(() => {
27324
- if (!CSS.highlights) {
27325
- return undefined;
27326
- }
27327
- if (!highlight) {
27328
- CSS.highlights.delete("navi-suggestion-match");
27329
- return undefined;
27330
- }
27331
- const listEl = ref.current;
27332
- if (!listEl) {
27333
- return undefined;
27334
- }
27335
- const ranges = [];
27336
- const lowerHighlight = highlight.toLowerCase();
27337
- for (const suggestionEl of listEl.querySelectorAll("[role='option']")) {
27338
- const walker = document.createTreeWalker(suggestionEl, NodeFilter.SHOW_TEXT);
27339
- let node;
27340
- while (node = walker.nextNode()) {
27341
- const text = node.textContent;
27342
- const lowerText = text.toLowerCase();
27343
- let index = lowerText.indexOf(lowerHighlight);
27344
- while (index !== -1) {
27345
- const range = new Range();
27346
- range.setStart(node, index);
27347
- range.setEnd(node, index + highlight.length);
27348
- ranges.push(range);
27349
- index = lowerText.indexOf(lowerHighlight, index + 1);
27350
- }
27351
- }
27352
- }
27353
- if (ranges.length === 0) {
27354
- CSS.highlights.delete("navi-suggestion-match");
27355
- } else {
27356
- CSS.highlights.set("navi-suggestion-match", new Highlight(...ranges));
27357
- }
27358
- return () => {
27359
- CSS.highlights.delete("navi-suggestion-match");
27360
- };
27361
- }, [highlight, children]);
27362
- const effectiveOnChange = popover ? value => {
27363
- onChangeProp?.(value);
27364
- ref.current?.dispatchEvent(new CustomEvent("navi_suggestion_list_selected", {
27365
- detail: {
27366
- value
27367
- },
27368
- bubbles: true
27369
- }));
27370
- } : onChangeProp;
27371
- const onChangeRef = useRef(effectiveOnChange);
27372
- onChangeRef.current = effectiveOnChange;
27373
- const navigate = direction => {
27374
- const values = ItemTrackerProvider.items.filter(item => !item.hidden).map(item => item.value);
27375
- if (values.length === 0) {
27376
- return false;
27377
- }
27378
- const current = pointedValueRef.current;
27379
- if (direction === "down") {
27380
- const idx = current === null ? -1 : values.indexOf(current);
27381
- setPointedValue(values[idx < values.length - 1 ? idx + 1 : idx]);
27382
- } else if (direction === "up") {
27383
- const idx = current === null ? -1 : values.indexOf(current);
27384
- setPointedValue(values[idx > 0 ? idx - 1 : 0]);
27385
- } else if (direction === "first") {
27386
- setPointedValue(values[0]);
27387
- } else if (direction === "last") {
27388
- setPointedValue(values[values.length - 1]);
27389
- }
27390
- return true;
27391
- };
27392
-
27393
- // Listen for commands dispatched by a linked Input (combobox mode)
27394
- const noopRef = useRef(null);
27395
- useEffect(() => {
27396
- if (!popover || !ref.current) {
27397
- return undefined;
27398
- }
27399
- const el = ref.current;
27400
- const onNavigate = e => {
27401
- navigate(e.detail.direction);
27402
- };
27403
- const onConfirm = e => {
27404
- const current = pointedValueRef.current;
27405
- if (current !== null) {
27406
- onChangeRef.current?.(current);
27407
- e.preventDefault();
27408
- }
27409
- };
27410
- const onClear = () => {
27411
- setPointedValue(null);
27412
- };
27413
- el.addEventListener("navi_suggestion_list_navigate", onNavigate);
27414
- el.addEventListener("navi_suggestion_list_confirm", onConfirm);
27415
- el.addEventListener("navi_suggestion_list_clear", onClear);
27416
- return () => {
27417
- el.removeEventListener("navi_suggestion_list_navigate", onNavigate);
27418
- el.removeEventListener("navi_suggestion_list_confirm", onConfirm);
27419
- el.removeEventListener("navi_suggestion_list_clear", onClear);
27420
- };
27421
- }, [popover]);
27422
- useKeyboardShortcuts(popover ? noopRef : ref, [{
27423
- key: "arrowdown",
27424
- description: "Point to next suggestion",
27425
- handler: () => navigate("down")
27426
- }, {
27427
- key: "arrowup",
27428
- description: "Point to previous suggestion",
27429
- handler: () => navigate("up")
27430
- }, {
27431
- key: "home",
27432
- description: "Point to first suggestion",
27433
- handler: () => navigate("first")
27434
- }, {
27435
- key: "end",
27436
- description: "Point to last suggestion",
27437
- handler: () => navigate("last")
27438
- }, {
27439
- key: "enter",
27440
- description: "Confirm pointed suggestion",
27441
- handler: () => {
27442
- const current = pointedValueRef.current;
27443
- if (current === null) {
27444
- return false;
27445
- }
27446
- onChangeRef.current?.(current);
27447
- return true;
27448
- }
27449
- }, {
27450
- key: "escape",
27451
- description: "Clear pointed suggestion",
27452
- handler: () => {
27453
- setPointedValue(null);
27454
- return true;
27455
- }
27456
- }]);
27457
- const suggestionListContext = {
27458
- pointedValue,
27459
- setPointedValue,
27460
- onSelect: effectiveOnChange
27461
- };
27462
- return jsx(Box, {
27463
- as: "ul",
27464
- ref: ref,
27465
- id: id,
27466
- role: "listbox",
27467
- tabIndex: popover ? -1 : 0,
27468
- popover: popover ? "manual" : undefined,
27469
- ...rest,
27470
- baseClassName: "navi_suggestion_list",
27471
- styleCSSVars: SuggestionListStyleCSSVars,
27472
- children: jsx(SuggestionListContext.Provider, {
27473
- value: suggestionListContext,
27474
- children: jsx(ItemTrackerProvider, {
27475
- children: children
27476
- })
27477
- })
27478
- });
27479
- };
27480
- const SUGGESTION_PSEUDO_CLASSES = [":-navi-pointed", ":-navi-selected"];
27481
- const SUGGESTION_PSEUDO_ELEMENTS = ["::highlight"];
27482
- const Suggestion = ({
27483
- value,
27484
- selected,
27485
- hidden,
27486
- children,
27487
- ...rest
27488
- }) => {
27489
- import.meta.css = [css$g, "@jsenv/navi/src/field/suggestion_list.jsx"];
27490
- const suggestionId = useId();
27491
- const id = rest.id || suggestionId;
27492
- useTrackSuggestion({
27493
- value,
27494
- suggestionId: id,
27495
- hidden
27496
- });
27497
- const {
27498
- pointedValue,
27499
- setPointedValue,
27500
- onSelect
27501
- } = useContext(SuggestionListContext);
27502
- const isPointed = pointedValue === value;
27503
- const suggestionRef = useRef(null);
27504
- useEffect(() => {
27505
- const suggestionEl = suggestionRef.current;
27506
- if (isPointed && suggestionEl) {
27507
- suggestionEl.scrollIntoView({
27508
- block: "nearest"
27509
- });
27510
- }
27511
- }, [isPointed]);
27512
- return jsx(Box, {
27513
- as: "li",
27514
- ref: suggestionRef,
27515
- baseClassName: "navi_suggestion",
27516
- id: suggestionId,
27517
- role: "option",
27518
- "aria-selected": selected,
27519
- "aria-hidden": hidden ? true : undefined,
27520
- hidden: hidden,
27521
- basePseudoState: {
27522
- ":-navi-pointed": isPointed,
27523
- ":-navi-selected": selected
27524
- },
27525
- pseudoClasses: SUGGESTION_PSEUDO_CLASSES,
27526
- pseudoElements: SUGGESTION_PSEUDO_ELEMENTS,
27527
- styleCSSVars: SuggestionStyleCSSVars,
27528
- onMouseEnter: () => {
27529
- if (!hidden) {
27530
- setPointedValue(value);
27531
- }
27532
- },
27533
- onMouseLeave: () => {
27534
- if (!hidden) {
27535
- setPointedValue(null);
27536
- }
27537
- },
27538
- onMouseDown: e => {
27539
- if (hidden || e.button !== 0) {
27540
- return;
27541
- }
27542
- onSelect?.(value);
27543
- },
27544
- ...rest,
27545
- children: children
27546
- });
27547
- };
27548
- const SuggestionGroup = ({
27549
- label,
27550
- children,
27551
- ...rest
27552
- }) => {
27553
- import.meta.css = [css$g, "@jsenv/navi/src/field/suggestion_list.jsx"];
27554
- const groupId = useId();
27555
- return jsxs("li", {
27556
- role: "presentation",
27557
- ...rest,
27558
- children: [jsx("span", {
27559
- id: groupId,
27560
- role: "presentation",
27561
- "aria-hidden": "true",
27562
- style: {
27563
- display: "contents"
27564
- },
27565
- children: typeof label === "string" ? jsx("span", {
27566
- className: "navi_suggestion_group_label",
27567
- children: label
27568
- }) : label
27569
- }), jsx("ul", {
27570
- role: "group",
27571
- "aria-labelledby": groupId,
27572
- style: {
27573
- margin: 0,
27574
- padding: 0,
27575
- listStyle: "none"
27576
- },
27577
- children: children
27578
- })]
27579
- });
27580
- };
27581
-
27582
- const RadioList = props => {
27583
- const uiStateController = useUIGroupStateController(props, "radio_list", {
27584
- childComponentType: "radio",
27585
- aggregateChildStates: childUIStateControllers => {
27586
- let activeValue;
27587
- for (const childUIStateController of childUIStateControllers) {
27588
- if (childUIStateController.uiState) {
27589
- activeValue = childUIStateController.uiState;
27590
- break;
27591
- }
27592
- }
27593
- return activeValue;
27594
- }
27595
- });
27596
- const uiState = useUIState(uiStateController);
27597
- const radioList = renderActionableComponent(props, {
27598
- Basic: RadioListBasic,
27599
- WithAction: RadioListWithAction
27600
- });
27601
- return jsx(UIStateControllerContext.Provider, {
27602
- value: uiStateController,
27603
- children: jsx(UIStateContext.Provider, {
27604
- value: uiState,
27605
- children: radioList
27606
- })
27607
- });
27211
+ });
27212
+ const uiState = useUIState(uiStateController);
27213
+ const radioList = renderActionableComponent(props, {
27214
+ Basic: RadioListBasic,
27215
+ WithAction: RadioListWithAction
27216
+ });
27217
+ return jsx(UIStateControllerContext.Provider, {
27218
+ value: uiStateController,
27219
+ children: jsx(UIStateContext.Provider, {
27220
+ value: uiState,
27221
+ children: radioList
27222
+ })
27223
+ });
27608
27224
  };
27609
27225
  const Radio = InputRadio;
27610
27226
  const RadioListBasic = props => {
@@ -27653,12 +27269,208 @@ const RadioListBasic = props => {
27653
27269
  })
27654
27270
  })
27655
27271
  });
27656
- };
27657
- const RadioListWithAction = props => {
27658
- const uiStateController = useContext(UIStateControllerContext);
27659
- const uiState = useContext(UIStateContext);
27272
+ };
27273
+ const RadioListWithAction = props => {
27274
+ const uiStateController = useContext(UIStateControllerContext);
27275
+ const uiState = useContext(UIStateContext);
27276
+ const {
27277
+ action,
27278
+ onCancel,
27279
+ onActionPrevented,
27280
+ onActionStart,
27281
+ onActionAbort,
27282
+ onActionError,
27283
+ onActionEnd,
27284
+ actionErrorEffect,
27285
+ loading,
27286
+ children,
27287
+ ...rest
27288
+ } = props;
27289
+ const innerRef = useRef();
27290
+ const [boundAction] = useActionBoundToOneParam(action, uiState);
27291
+ const {
27292
+ loading: actionLoading
27293
+ } = useActionStatus(boundAction);
27294
+ const executeAction = useExecuteAction(innerRef, {
27295
+ errorEffect: actionErrorEffect
27296
+ });
27297
+ const [actionRequester, setActionRequester] = useState(null);
27298
+ useActionEvents(innerRef, {
27299
+ onCancel: (e, reason) => {
27300
+ uiStateController.resetUIState(e);
27301
+ onCancel?.(e, reason);
27302
+ },
27303
+ onPrevented: onActionPrevented,
27304
+ onAction: actionEvent => {
27305
+ setActionRequester(actionEvent.detail.requester);
27306
+ executeAction(actionEvent);
27307
+ },
27308
+ onStart: onActionStart,
27309
+ onAbort: e => {
27310
+ uiStateController.resetUIState(e);
27311
+ onActionAbort?.(e);
27312
+ },
27313
+ onError: e => {
27314
+ uiStateController.resetUIState(e);
27315
+ onActionError?.(e);
27316
+ },
27317
+ onEnd: e => {
27318
+ onActionEnd?.(e);
27319
+ }
27320
+ });
27321
+ return jsx(RadioListBasic, {
27322
+ "data-action": boundAction,
27323
+ ...rest,
27324
+ ref: innerRef,
27325
+ onChange: e => {
27326
+ const radio = e.target;
27327
+ const radioListContainer = innerRef.current;
27328
+ requestAction(radioListContainer, boundAction, {
27329
+ event: e,
27330
+ requester: radio,
27331
+ actionOrigin: "action_prop"
27332
+ });
27333
+ },
27334
+ loading: loading || actionLoading,
27335
+ children: jsx(LoadingElementContext.Provider, {
27336
+ value: actionRequester,
27337
+ children: children
27338
+ })
27339
+ });
27340
+ };
27341
+
27342
+ const useRefArray = (items, keyFromItem) => {
27343
+ const refMapRef = useRef(new Map());
27344
+ const previousKeySetRef = useRef(new Set());
27345
+
27346
+ return useMemo(() => {
27347
+ const refMap = refMapRef.current;
27348
+ const previousKeySet = previousKeySetRef.current;
27349
+ const currentKeySet = new Set();
27350
+ const refArray = [];
27351
+
27352
+ for (let i = 0; i < items.length; i++) {
27353
+ const item = items[i];
27354
+ const key = keyFromItem(item);
27355
+ currentKeySet.add(key);
27356
+
27357
+ const refForKey = refMap.get(key);
27358
+ if (refForKey) {
27359
+ refArray[i] = refForKey;
27360
+ } else {
27361
+ const newRef = createRef();
27362
+ refMap.set(key, newRef);
27363
+ refArray[i] = newRef;
27364
+ }
27365
+ }
27366
+
27367
+ for (const key of previousKeySet) {
27368
+ if (!currentKeySet.has(key)) {
27369
+ refMap.delete(key);
27370
+ }
27371
+ }
27372
+ previousKeySetRef.current = currentKeySet;
27373
+
27374
+ return refArray;
27375
+ }, [items]);
27376
+ };
27377
+
27378
+ installImportMetaCssBuild(import.meta);const useNavState = () => {};
27379
+ const css$g = /* css */`
27380
+ .navi_select[data-readonly] {
27381
+ pointer-events: none;
27382
+ }
27383
+ `;
27384
+ const Select = forwardRef((props, ref) => {
27385
+ import.meta.css = [css$g, "@jsenv/navi/src/field/select.jsx"];
27386
+ const select = renderActionableComponent(props, ref);
27387
+ return select;
27388
+ });
27389
+ const SelectControlled = forwardRef((props, ref) => {
27390
+ const {
27391
+ name,
27392
+ value,
27393
+ loading,
27394
+ disabled,
27395
+ readOnly,
27396
+ children,
27397
+ ...rest
27398
+ } = props;
27399
+ const innerRef = useRef();
27400
+ useImperativeHandle(ref, () => innerRef.current);
27401
+ const selectElement = jsx("select", {
27402
+ className: "navi_select",
27403
+ ref: innerRef,
27404
+ "data-readonly": readOnly && !disabled ? "" : undefined,
27405
+ onKeyDown: e => {
27406
+ if (readOnly) {
27407
+ e.preventDefault();
27408
+ }
27409
+ },
27410
+ ...rest,
27411
+ children: children.map(child => {
27412
+ const {
27413
+ label,
27414
+ readOnly: childReadOnly,
27415
+ disabled: childDisabled,
27416
+ loading: childLoading,
27417
+ value: childValue,
27418
+ ...childRest
27419
+ } = child;
27420
+ return jsx("option", {
27421
+ name: name,
27422
+ value: childValue,
27423
+ selected: childValue === value,
27424
+ readOnly: readOnly || childReadOnly,
27425
+ disabled: disabled || childDisabled,
27426
+ loading: loading || childLoading,
27427
+ ...childRest,
27428
+ children: label
27429
+ }, childValue);
27430
+ })
27431
+ });
27432
+ return jsx(LoaderBackground, {
27433
+ loading: loading,
27434
+ color: "light-dark(#355fcc, #3b82f6)",
27435
+ inset: -1,
27436
+ children: selectElement
27437
+ });
27438
+ });
27439
+ forwardRef((props, ref) => {
27440
+ const {
27441
+ value: initialValue,
27442
+ id,
27443
+ children,
27444
+ ...rest
27445
+ } = props;
27446
+ const innerRef = useRef();
27447
+ useImperativeHandle(ref, () => innerRef.current);
27448
+ const [navState, setNavState] = useNavState();
27449
+ const valueAtStart = navState === undefined ? initialValue : navState;
27450
+ const [value, setValue] = useState(valueAtStart);
27451
+ useEffect(() => {
27452
+ setNavState(value);
27453
+ }, [value]);
27454
+ return jsx(SelectControlled, {
27455
+ ref: innerRef,
27456
+ value: value,
27457
+ onChange: event => {
27458
+ const select = event.target;
27459
+ const selectedValue = select.value;
27460
+ setValue(selectedValue);
27461
+ },
27462
+ ...rest,
27463
+ children: children
27464
+ });
27465
+ });
27466
+ forwardRef((props, ref) => {
27660
27467
  const {
27468
+ id,
27469
+ name,
27470
+ value: externalValue,
27471
+ valueSignal,
27661
27472
  action,
27473
+ children,
27662
27474
  onCancel,
27663
27475
  onActionPrevented,
27664
27476
  onActionStart,
@@ -27666,272 +27478,693 @@ const RadioListWithAction = props => {
27666
27478
  onActionError,
27667
27479
  onActionEnd,
27668
27480
  actionErrorEffect,
27669
- loading,
27670
- children,
27671
27481
  ...rest
27672
27482
  } = props;
27673
27483
  const innerRef = useRef();
27674
- const [boundAction] = useActionBoundToOneParam(action, uiState);
27484
+ useImperativeHandle(ref, () => innerRef.current);
27485
+ const [navState, setNavState, resetNavState] = useNavState();
27486
+ const [boundAction, value, setValue, initialValue] = useActionBoundToOneParam(action, name);
27675
27487
  const {
27676
27488
  loading: actionLoading
27677
27489
  } = useActionStatus(boundAction);
27678
27490
  const executeAction = useExecuteAction(innerRef, {
27679
27491
  errorEffect: actionErrorEffect
27680
27492
  });
27681
- const [actionRequester, setActionRequester] = useState(null);
27493
+ useEffect(() => {
27494
+ setNavState(value);
27495
+ }, [value]);
27496
+ const actionRequesterRef = useRef(null);
27682
27497
  useActionEvents(innerRef, {
27683
27498
  onCancel: (e, reason) => {
27684
- uiStateController.resetUIState(e);
27499
+ resetNavState();
27500
+ setValue(initialValue);
27685
27501
  onCancel?.(e, reason);
27686
27502
  },
27687
27503
  onPrevented: onActionPrevented,
27688
27504
  onAction: actionEvent => {
27689
- setActionRequester(actionEvent.detail.requester);
27505
+ actionRequesterRef.current = actionEvent.detail.requester;
27690
27506
  executeAction(actionEvent);
27691
27507
  },
27692
27508
  onStart: onActionStart,
27693
27509
  onAbort: e => {
27694
- uiStateController.resetUIState(e);
27510
+ setValue(initialValue);
27695
27511
  onActionAbort?.(e);
27696
27512
  },
27697
- onError: e => {
27698
- uiStateController.resetUIState(e);
27699
- onActionError?.(e);
27513
+ onError: error => {
27514
+ setValue(initialValue);
27515
+ onActionError?.(error);
27700
27516
  },
27701
- onEnd: e => {
27702
- onActionEnd?.(e);
27517
+ onEnd: () => {
27518
+ resetNavState();
27519
+ onActionEnd?.();
27703
27520
  }
27704
27521
  });
27705
- return jsx(RadioListBasic, {
27706
- "data-action": boundAction,
27707
- ...rest,
27522
+ const childRefArray = useRefArray(children, child => child.value);
27523
+ return jsx(SelectControlled, {
27708
27524
  ref: innerRef,
27709
- onChange: e => {
27710
- const radio = e.target;
27525
+ name: name,
27526
+ value: value,
27527
+ "data-action": boundAction,
27528
+ onChange: event => {
27529
+ const select = event.target;
27530
+ const selectedValue = select.value;
27531
+ setValue(selectedValue);
27711
27532
  const radioListContainer = innerRef.current;
27533
+ const optionSelected = select.querySelector(`option[value="${selectedValue}"]`);
27712
27534
  requestAction(radioListContainer, boundAction, {
27713
- event: e,
27714
- requester: radio,
27715
- actionOrigin: "action_prop"
27535
+ event,
27536
+ requester: optionSelected
27716
27537
  });
27717
27538
  },
27718
- loading: loading || actionLoading,
27719
- children: jsx(LoadingElementContext.Provider, {
27720
- value: actionRequester,
27721
- children: children
27539
+ ...rest,
27540
+ children: children.map((child, i) => {
27541
+ const childRef = childRefArray[i];
27542
+ return {
27543
+ ...child,
27544
+ ref: childRef,
27545
+ loading: child.loading || actionLoading && actionRequesterRef.current === childRef.current,
27546
+ readOnly: child.readOnly || actionLoading
27547
+ };
27722
27548
  })
27723
27549
  });
27550
+ });
27551
+
27552
+ const createItemTracker = () => {
27553
+ const ItemTrackerContext = createContext();
27554
+ const useItemTrackerProvider = () => {
27555
+ const itemsRef = useRef([]);
27556
+ const items = itemsRef.current;
27557
+ const itemCountRef = useRef(0);
27558
+ const tracker = useMemo(() => {
27559
+ const ItemTrackerProvider = ({
27560
+ children
27561
+ }) => {
27562
+ // Reset on each render to start fresh
27563
+ tracker.reset();
27564
+ return jsx(ItemTrackerContext.Provider, {
27565
+ value: tracker,
27566
+ children: children
27567
+ });
27568
+ };
27569
+ ItemTrackerProvider.items = items;
27570
+ return {
27571
+ ItemTrackerProvider,
27572
+ items,
27573
+ registerItem: data => {
27574
+ const index = itemCountRef.current++;
27575
+ items[index] = data;
27576
+ return index;
27577
+ },
27578
+ getItem: index => {
27579
+ return items[index];
27580
+ },
27581
+ getAllItems: () => {
27582
+ return items;
27583
+ },
27584
+ reset: () => {
27585
+ items.length = 0;
27586
+ itemCountRef.current = 0;
27587
+ }
27588
+ };
27589
+ }, []);
27590
+ return tracker.ItemTrackerProvider;
27591
+ };
27592
+ const useTrackItem = data => {
27593
+ const tracker = useContext(ItemTrackerContext);
27594
+ if (!tracker) {
27595
+ throw new Error("useTrackItem must be used within SimpleItemTrackerProvider");
27596
+ }
27597
+ return tracker.registerItem(data);
27598
+ };
27599
+ const useTrackedItem = index => {
27600
+ const trackedItems = useTrackedItems();
27601
+ const item = trackedItems[index];
27602
+ return item;
27603
+ };
27604
+ const useTrackedItems = () => {
27605
+ const tracker = useContext(ItemTrackerContext);
27606
+ if (!tracker) {
27607
+ throw new Error("useTrackedItems must be used within SimpleItemTrackerProvider");
27608
+ }
27609
+ return tracker.items;
27610
+ };
27611
+ return [useItemTrackerProvider, useTrackItem, useTrackedItem, useTrackedItems];
27724
27612
  };
27725
27613
 
27726
- const useRefArray = (items, keyFromItem) => {
27727
- const refMapRef = useRef(new Map());
27728
- const previousKeySetRef = useRef(new Set());
27614
+ installImportMetaCssBuild(import.meta);const [useSuggestionItemTrackerProvider, useTrackSuggestion] = createItemTracker();
27729
27615
 
27730
- return useMemo(() => {
27731
- const refMap = refMapRef.current;
27732
- const previousKeySet = previousKeySetRef.current;
27733
- const currentKeySet = new Set();
27734
- const refArray = [];
27616
+ /**
27617
+ * SuggestionList + Suggestion: a composable accessible listbox.
27618
+ *
27619
+ * Usage:
27620
+ * <SuggestionList id="my-list" value={selected} onChange={setSelected}>
27621
+ * <Suggestion value="a">Option A</Suggestion>
27622
+ * <Suggestion value="b">Option B</Suggestion>
27623
+ * </SuggestionList>
27624
+ *
27625
+ * CSS vars on .navi_suggestion_list:
27626
+ * --suggestion-list-border-radius, --suggestion-list-border-width, --suggestion-list-border-color, --suggestion-list-background-color, --suggestion-list-max-height
27627
+ *
27628
+ * CSS vars on .navi_suggestion:
27629
+ * --suggestion-padding, --suggestion-color, --suggestion-background-color, --suggestion-font-weight
27630
+ * --suggestion-color-hover, --suggestion-background-color-hover
27631
+ * --suggestion-color-pointed, --suggestion-background-color-pointed
27632
+ * --suggestion-color-selected, --suggestion-background-color-selected, --suggestion-font-weight-selected
27633
+ * --suggestion-color-pointed-selected, --suggestion-background-color-pointed-selected
27634
+ * --suggestion-color-highlight, --suggestion-background-color-highlight
27635
+ *
27636
+ * CSS vars on .navi_suggestion_group_label:
27637
+ * --suggestion-group-label-padding, --suggestion-group-label-color, --suggestion-group-label-font-size, --suggestion-group-label-font-weight
27638
+ */
27639
+
27640
+ const css$f = /* css */`
27641
+ @layer navi {
27642
+ .navi_suggestion_list {
27643
+ --suggestion-list-border-radius: 4px;
27644
+ --suggestion-list-border-width: 1px;
27645
+ --suggestion-list-border-color: light-dark(#ccc, #555);
27646
+ --suggestion-list-border-style: solid;
27647
+ --suggestion-list-background-color: light-dark(#fff, #1e1e1e);
27648
+ --suggestion-list-max-height: 220px;
27649
+ }
27650
+ .navi_suggestion {
27651
+ --suggestion-padding: 8px 12px;
27652
+ --suggestion-color: inherit;
27653
+ --suggestion-font-weight: inherit;
27654
+
27655
+ /* Hover (mouse) */
27656
+ --suggestion-color-hover: var(--suggestion-color);
27657
+ --suggestion-background-color-hover: light-dark(#f5f5f5, #2a2a2a);
27658
+
27659
+ /* Pointed (keyboard navigation position) */
27660
+ --suggestion-color-pointed: var(--suggestion-color);
27661
+ --suggestion-background-color-pointed: light-dark(#e8f0fe, #1c3a6e);
27662
+
27663
+ /* Selected */
27664
+ --suggestion-color-selected: light-dark(#1a73e8, #7baaf7);
27665
+ --suggestion-background-color-selected: light-dark(#e8f0fe, #1c3a6e);
27666
+ --suggestion-font-weight-selected: 500;
27667
+
27668
+ /* Highlight (CSS Highlight API match) */
27669
+ --suggestion-color-highlight: inherit;
27670
+ --suggestion-background-color-highlight: #ffe066;
27671
+ --suggestion-color-pointed-selected: var(--suggestion-color-selected);
27672
+ --suggestion-background-color-pointed-selected: light-dark(
27673
+ #d2e3fc,
27674
+ #174ea6
27675
+ );
27676
+ }
27677
+ }
27678
+
27679
+ .navi_suggestion_list {
27680
+ --x-border-radius: var(--suggestion-list-border-radius);
27681
+ --x-border-width: var(--suggestion-list-border-width);
27682
+ --x-border-color: var(--suggestion-list-border-color);
27683
+ --x-border-style: var(--suggestion-list-border-style);
27684
+ --x-background-color: var(--suggestion-list-background-color);
27685
+ width: fit-content;
27686
+ max-width: 100%;
27687
+
27688
+ max-height: var(--suggestion-list-max-height);
27689
+ background-color: var(--x-background-color);
27690
+ border: var(--x-border-width) var(--x-border-style) var(--x-border-color);
27691
+ border-radius: var(--x-border-radius);
27692
+ transition: opacity 0.2s ease;
27693
+ overflow: auto;
27694
+
27695
+ /* Popover reset — browser adds border, background, padding, margin by default */
27696
+ &[popover] {
27697
+ position: absolute;
27698
+ inset: unset;
27699
+ min-width: var(--suggestion-list-anchor-width, 0px);
27700
+ max-width: 95vw;
27701
+ margin: 0;
27702
+ padding: 0;
27703
+ /* border: none; */
27704
+ }
27705
+ &[data-anchor-hidden] {
27706
+ opacity: 0;
27707
+ pointer-events: none;
27708
+ }
27709
+ }
27710
+
27711
+ .navi_suggestion_listbox {
27712
+ box-sizing: border-box;
27713
+ width: max-content;
27714
+ min-width: 100%;
27715
+ margin: 0;
27716
+ padding: 0;
27717
+ list-style: none;
27718
+ }
27719
+ ::highlight(navi-suggestion-match) {
27720
+ color: var(--suggestion-color-highlight);
27721
+ background-color: var(--suggestion-background-color-highlight);
27722
+ }
27723
+ .navi_suggestion {
27724
+ --x-color: var(--suggestion-color);
27725
+ --x-background-color: var(--suggestion-background-color);
27726
+ --x-font-weight: var(--suggestion-font-weight);
27727
+ display: flex;
27728
+ box-sizing: border-box;
27729
+ width: max-content;
27730
+ min-width: 100%;
27731
+
27732
+ padding: var(--suggestion-padding);
27733
+ flex-direction: column;
27734
+ color: var(--x-color);
27735
+ font-weight: var(--x-font-weight);
27736
+ background-color: var(--x-background-color);
27737
+ cursor: pointer;
27738
+ user-select: none;
27735
27739
 
27736
- for (let i = 0; i < items.length; i++) {
27737
- const item = items[i];
27738
- const key = keyFromItem(item);
27739
- currentKeySet.add(key);
27740
+ &:hover {
27741
+ --x-color: var(--suggestion-color-hover);
27742
+ --x-background-color: var(--suggestion-background-color-hover);
27743
+ }
27740
27744
 
27741
- const refForKey = refMap.get(key);
27742
- if (refForKey) {
27743
- refArray[i] = refForKey;
27744
- } else {
27745
- const newRef = createRef();
27746
- refMap.set(key, newRef);
27747
- refArray[i] = newRef;
27748
- }
27745
+ &[data-pointed] {
27746
+ --x-color: var(--suggestion-color-pointed);
27747
+ --x-background-color: var(--suggestion-background-color-pointed);
27749
27748
  }
27750
27749
 
27751
- for (const key of previousKeySet) {
27752
- if (!currentKeySet.has(key)) {
27753
- refMap.delete(key);
27754
- }
27750
+ &[data-selected] {
27751
+ --x-color: var(--suggestion-color-selected);
27752
+ --x-background-color: var(--suggestion-background-color-selected);
27753
+ --x-font-weight: var(--suggestion-font-weight-selected);
27755
27754
  }
27756
- previousKeySetRef.current = currentKeySet;
27757
27755
 
27758
- return refArray;
27759
- }, [items]);
27760
- };
27756
+ &[data-pointed][data-selected] {
27757
+ --x-color: var(--suggestion-color-pointed-selected);
27758
+ --x-background-color: var(--suggestion-background-color-pointed-selected);
27759
+ }
27760
+ }
27761
+ .navi_suggestion_group_label {
27762
+ position: sticky;
27763
+ top: 0;
27764
+ z-index: 1;
27765
+ display: block;
27766
+ background-color: var(
27767
+ --suggestion-group-label-background-color,
27768
+ var(--suggestion-list-background-color)
27769
+ );
27770
+ user-select: none;
27761
27771
 
27762
- installImportMetaCssBuild(import.meta);const useNavState = () => {};
27763
- const css$f = /* css */`
27764
- .navi_select[data-readonly] {
27765
- pointer-events: none;
27772
+ &[data-default-label] {
27773
+ padding: 4px 12px 2px;
27774
+ color: light-dark(#888, #aaa);
27775
+ font-weight: 600;
27776
+ font-size: 0.75em;
27777
+ text-transform: uppercase;
27778
+ letter-spacing: 0.05em;
27779
+ }
27780
+ }
27781
+ .navi_suggestion_list_empty {
27782
+ display: none;
27783
+ padding: var(--suggestion-padding);
27784
+ color: var(--suggestion-group-label-color);
27785
+ font-size: 0.9em;
27786
+ text-align: center;
27787
+ user-select: none;
27788
+ }
27789
+ /* Show the empty state only when there are no visible suggestions */
27790
+ .navi_suggestion_list:not(:has([role="option"]:not([hidden]))) {
27791
+ .navi_suggestion_list_empty {
27792
+ display: block;
27793
+ }
27766
27794
  }
27767
27795
  `;
27768
- const Select = forwardRef((props, ref) => {
27769
- import.meta.css = [css$f, "@jsenv/navi/src/field/select.jsx"];
27770
- const select = renderActionableComponent(props, ref);
27771
- return select;
27772
- });
27773
- const SelectControlled = forwardRef((props, ref) => {
27774
- const {
27775
- name,
27776
- value,
27777
- loading,
27778
- disabled,
27779
- readOnly,
27780
- children,
27781
- ...rest
27782
- } = props;
27783
- const innerRef = useRef();
27784
- useImperativeHandle(ref, () => innerRef.current);
27785
- const selectElement = jsx("select", {
27786
- className: "navi_select",
27787
- ref: innerRef,
27788
- "data-readonly": readOnly && !disabled ? "" : undefined,
27789
- onKeyDown: e => {
27790
- if (readOnly) {
27796
+ const SuggestionListStyleCSSVars = {
27797
+ borderRadius: "--suggestion-list-border-radius",
27798
+ borderWidth: "--suggestion-list-border-width",
27799
+ borderColor: "--suggestion-list-border-color",
27800
+ backgroundColor: "--suggestion-list-background-color",
27801
+ maxHeight: "--suggestion-list-max-height"
27802
+ };
27803
+ const SuggestionStyleCSSVars = {
27804
+ "padding": "--suggestion-padding",
27805
+ "color": "--suggestion-color",
27806
+ "backgroundColor": "--suggestion-background-color",
27807
+ "fontWeight": "--suggestion-font-weight",
27808
+ ":-navi-pointed": {
27809
+ color: "--suggestion-color-pointed",
27810
+ backgroundColor: "--suggestion-background-color-pointed"
27811
+ },
27812
+ ":hover": {
27813
+ color: "--suggestion-color-hover",
27814
+ backgroundColor: "--suggestion-background-color-hover"
27815
+ },
27816
+ ":-navi-selected": {
27817
+ color: "--suggestion-color-selected",
27818
+ backgroundColor: "--suggestion-background-color-selected",
27819
+ fontWeight: "--suggestion-font-weight-selected"
27820
+ },
27821
+ "::highlight": {
27822
+ color: "--suggestion-color-highlight",
27823
+ backgroundColor: "--suggestion-background-color-highlight"
27824
+ }
27825
+ };
27826
+
27827
+ /**
27828
+ * Context OptionList provides downward to its Option children.
27829
+ */
27830
+ const SuggestionListContext = createContext(null);
27831
+ const SuggestionList = ({
27832
+ popover,
27833
+ onChange: onChangeProp,
27834
+ highlight,
27835
+ emptyState = "No results",
27836
+ children,
27837
+ ...rest
27838
+ }) => {
27839
+ import.meta.css = [css$f, "@jsenv/navi/src/field/suggestion_list.jsx"];
27840
+ const ItemTrackerProvider = useSuggestionItemTrackerProvider();
27841
+ const [pointedValue, setPointedValue] = useState(null);
27842
+ const pointedValueRef = useRef(null);
27843
+ pointedValueRef.current = pointedValue;
27844
+ const ownId = useId();
27845
+ const id = rest.id ?? ownId;
27846
+ const defaultRef = useRef(null);
27847
+ const ref = rest.ref || defaultRef;
27848
+ useLayoutEffect(() => {
27849
+ if (!CSS.highlights) {
27850
+ return undefined;
27851
+ }
27852
+ if (!highlight) {
27853
+ CSS.highlights.delete("navi-suggestion-match");
27854
+ return undefined;
27855
+ }
27856
+ const listEl = ref.current;
27857
+ if (!listEl) {
27858
+ return undefined;
27859
+ }
27860
+ const ranges = [];
27861
+ const lowerHighlight = highlight.toLowerCase();
27862
+ for (const suggestionEl of listEl.querySelectorAll("[role='option']")) {
27863
+ const walker = document.createTreeWalker(suggestionEl, NodeFilter.SHOW_TEXT);
27864
+ let node;
27865
+ while (node = walker.nextNode()) {
27866
+ const text = node.textContent;
27867
+ const lowerText = text.toLowerCase();
27868
+ let index = lowerText.indexOf(lowerHighlight);
27869
+ while (index !== -1) {
27870
+ const range = new Range();
27871
+ range.setStart(node, index);
27872
+ range.setEnd(node, index + highlight.length);
27873
+ ranges.push(range);
27874
+ index = lowerText.indexOf(lowerHighlight, index + 1);
27875
+ }
27876
+ }
27877
+ }
27878
+ if (ranges.length === 0) {
27879
+ CSS.highlights.delete("navi-suggestion-match");
27880
+ } else {
27881
+ CSS.highlights.set("navi-suggestion-match", new Highlight(...ranges));
27882
+ }
27883
+ return () => {
27884
+ CSS.highlights.delete("navi-suggestion-match");
27885
+ };
27886
+ }, [highlight, children]);
27887
+ const effectiveOnChange = popover ? value => {
27888
+ onChangeProp?.(value);
27889
+ ref.current?.dispatchEvent(new CustomEvent("navi_suggestion_list_selected", {
27890
+ detail: {
27891
+ value
27892
+ },
27893
+ bubbles: true
27894
+ }));
27895
+ } : onChangeProp;
27896
+ const onChangeRef = useRef(effectiveOnChange);
27897
+ onChangeRef.current = effectiveOnChange;
27898
+ const navigate = direction => {
27899
+ const values = ItemTrackerProvider.items.filter(item => !item.hidden).map(item => item.value);
27900
+ if (values.length === 0) {
27901
+ return false;
27902
+ }
27903
+ const current = pointedValueRef.current;
27904
+ if (direction === "down") {
27905
+ const idx = current === null ? -1 : values.indexOf(current);
27906
+ setPointedValue(values[idx < values.length - 1 ? idx + 1 : idx]);
27907
+ } else if (direction === "up") {
27908
+ const idx = current === null ? -1 : values.indexOf(current);
27909
+ setPointedValue(values[idx > 0 ? idx - 1 : 0]);
27910
+ } else if (direction === "first") {
27911
+ setPointedValue(values[0]);
27912
+ } else if (direction === "last") {
27913
+ setPointedValue(values[values.length - 1]);
27914
+ }
27915
+ return true;
27916
+ };
27917
+
27918
+ // Listen for commands dispatched by a linked Input (combobox mode)
27919
+ const noopRef = useRef(null);
27920
+ useEffect(() => {
27921
+ if (!popover || !ref.current) {
27922
+ return undefined;
27923
+ }
27924
+ const el = ref.current;
27925
+ let positionEffectCleanup = null;
27926
+ const positionPopover = anchor => {
27927
+ const anchorRect = anchor.getBoundingClientRect();
27928
+ el.style.setProperty("--suggestion-list-anchor-width", `${anchorRect.width}px`);
27929
+ const minLeft = 1;
27930
+ const {
27931
+ left,
27932
+ top
27933
+ } = pickPositionRelativeTo(el, anchor, {
27934
+ positionPreference: "below",
27935
+ minLeft
27936
+ });
27937
+ el.style.top = `${top}px`;
27938
+ const popoverRect = el.getBoundingClientRect();
27939
+ const maxWidth = parseFloat(getComputedStyle(el).maxWidth);
27940
+ if (!isNaN(maxWidth) && popoverRect.width >= maxWidth - 1) {
27941
+ const viewportWidth = document.documentElement.clientWidth;
27942
+ const centeredLeft = (viewportWidth - popoverRect.width) / 2;
27943
+ el.style.left = `${Math.max(centeredLeft, minLeft)}px`;
27944
+ } else {
27945
+ el.style.left = `${Math.max(left, minLeft)}px`;
27946
+ }
27947
+ };
27948
+ const onOpen = e => {
27949
+ const anchor = e.detail?.anchor;
27950
+ el.showPopover();
27951
+ if (anchor) {
27952
+ positionEffectCleanup = visibleRectEffect(anchor, ({
27953
+ visibilityRatio
27954
+ }) => {
27955
+ if (visibilityRatio <= 0.2) {
27956
+ el.setAttribute("data-anchor-hidden", "");
27957
+ return;
27958
+ }
27959
+ el.removeAttribute("data-anchor-hidden");
27960
+ positionPopover(anchor);
27961
+ });
27962
+ }
27963
+ };
27964
+ const onClose = () => {
27965
+ if (positionEffectCleanup) {
27966
+ positionEffectCleanup.disconnect();
27967
+ positionEffectCleanup = null;
27968
+ }
27969
+ el.removeAttribute("data-anchor-hidden");
27970
+ el.dispatchEvent(new CustomEvent("navi_suggestion_list_clear"));
27971
+ el.hidePopover();
27972
+ };
27973
+ const onNavigate = e => {
27974
+ navigate(e.detail.direction);
27975
+ };
27976
+ const onConfirm = e => {
27977
+ const current = pointedValueRef.current;
27978
+ if (current !== null) {
27979
+ onChangeRef.current?.(current);
27791
27980
  e.preventDefault();
27792
27981
  }
27793
- },
27982
+ };
27983
+ const onClear = () => {
27984
+ setPointedValue(null);
27985
+ };
27986
+ el.addEventListener("navi_suggestion_list_open", onOpen);
27987
+ el.addEventListener("navi_suggestion_list_close", onClose);
27988
+ el.addEventListener("navi_suggestion_list_navigate", onNavigate);
27989
+ el.addEventListener("navi_suggestion_list_confirm", onConfirm);
27990
+ el.addEventListener("navi_suggestion_list_clear", onClear);
27991
+ return () => {
27992
+ el.removeEventListener("navi_suggestion_list_open", onOpen);
27993
+ el.removeEventListener("navi_suggestion_list_close", onClose);
27994
+ el.removeEventListener("navi_suggestion_list_navigate", onNavigate);
27995
+ el.removeEventListener("navi_suggestion_list_confirm", onConfirm);
27996
+ el.removeEventListener("navi_suggestion_list_clear", onClear);
27997
+ if (positionEffectCleanup) {
27998
+ positionEffectCleanup.disconnect();
27999
+ }
28000
+ };
28001
+ }, [popover]);
28002
+ useKeyboardShortcuts(popover ? noopRef : ref, [{
28003
+ key: "arrowdown",
28004
+ description: "Point to next suggestion",
28005
+ handler: () => navigate("down")
28006
+ }, {
28007
+ key: "arrowup",
28008
+ description: "Point to previous suggestion",
28009
+ handler: () => navigate("up")
28010
+ }, {
28011
+ key: "home",
28012
+ description: "Point to first suggestion",
28013
+ handler: () => navigate("first")
28014
+ }, {
28015
+ key: "end",
28016
+ description: "Point to last suggestion",
28017
+ handler: () => navigate("last")
28018
+ }, {
28019
+ key: "enter",
28020
+ description: "Confirm pointed suggestion",
28021
+ handler: () => {
28022
+ const current = pointedValueRef.current;
28023
+ if (current === null) {
28024
+ return false;
28025
+ }
28026
+ onChangeRef.current?.(current);
28027
+ return true;
28028
+ }
28029
+ }, {
28030
+ key: "escape",
28031
+ description: "Clear pointed suggestion",
28032
+ handler: () => {
28033
+ setPointedValue(null);
28034
+ return true;
28035
+ }
28036
+ }]);
28037
+ const suggestionListContext = {
28038
+ pointedValue,
28039
+ setPointedValue,
28040
+ onSelect: effectiveOnChange
28041
+ };
28042
+ return jsx(Box, {
28043
+ ref: ref,
28044
+ id: id,
28045
+ popover: popover ? "manual" : undefined,
28046
+ tabIndex: popover ? -1 : 0,
27794
28047
  ...rest,
27795
- children: children.map(child => {
27796
- const {
27797
- label,
27798
- readOnly: childReadOnly,
27799
- disabled: childDisabled,
27800
- loading: childLoading,
27801
- value: childValue,
27802
- ...childRest
27803
- } = child;
27804
- return jsx("option", {
27805
- name: name,
27806
- value: childValue,
27807
- selected: childValue === value,
27808
- readOnly: readOnly || childReadOnly,
27809
- disabled: disabled || childDisabled,
27810
- loading: loading || childLoading,
27811
- ...childRest,
27812
- children: label
27813
- }, childValue);
28048
+ baseClassName: "navi_suggestion_list",
28049
+ children: jsx(Box, {
28050
+ as: "ul",
28051
+ role: "listbox",
28052
+ baseClassName: "navi_suggestion_listbox",
28053
+ styleCSSVars: SuggestionListStyleCSSVars,
28054
+ children: jsxs(SuggestionListContext.Provider, {
28055
+ value: suggestionListContext,
28056
+ children: [jsx(ItemTrackerProvider, {
28057
+ children: children
28058
+ }), emptyState && jsx("li", {
28059
+ className: "navi_suggestion_list_empty",
28060
+ children: emptyState
28061
+ })]
28062
+ })
27814
28063
  })
27815
28064
  });
27816
- return jsx(LoaderBackground, {
27817
- loading: loading,
27818
- color: "light-dark(#355fcc, #3b82f6)",
27819
- inset: -1,
27820
- children: selectElement
27821
- });
27822
- });
27823
- forwardRef((props, ref) => {
27824
- const {
27825
- value: initialValue,
27826
- id,
27827
- children,
27828
- ...rest
27829
- } = props;
27830
- const innerRef = useRef();
27831
- useImperativeHandle(ref, () => innerRef.current);
27832
- const [navState, setNavState] = useNavState();
27833
- const valueAtStart = navState === undefined ? initialValue : navState;
27834
- const [value, setValue] = useState(valueAtStart);
27835
- useEffect(() => {
27836
- setNavState(value);
27837
- }, [value]);
27838
- return jsx(SelectControlled, {
27839
- ref: innerRef,
27840
- value: value,
27841
- onChange: event => {
27842
- const select = event.target;
27843
- const selectedValue = select.value;
27844
- setValue(selectedValue);
27845
- },
27846
- ...rest,
27847
- children: children
28065
+ };
28066
+ const SUGGESTION_PSEUDO_CLASSES = [":-navi-pointed", ":-navi-selected"];
28067
+ const SUGGESTION_PSEUDO_ELEMENTS = ["::highlight"];
28068
+ const Suggestion = ({
28069
+ value,
28070
+ selected,
28071
+ hidden,
28072
+ children,
28073
+ ...rest
28074
+ }) => {
28075
+ import.meta.css = [css$f, "@jsenv/navi/src/field/suggestion_list.jsx"];
28076
+ const suggestionId = useId();
28077
+ const id = rest.id || suggestionId;
28078
+ useTrackSuggestion({
28079
+ value,
28080
+ suggestionId: id,
28081
+ hidden
27848
28082
  });
27849
- });
27850
- forwardRef((props, ref) => {
27851
- const {
27852
- id,
27853
- name,
27854
- value: externalValue,
27855
- valueSignal,
27856
- action,
27857
- children,
27858
- onCancel,
27859
- onActionPrevented,
27860
- onActionStart,
27861
- onActionAbort,
27862
- onActionError,
27863
- onActionEnd,
27864
- actionErrorEffect,
27865
- ...rest
27866
- } = props;
27867
- const innerRef = useRef();
27868
- useImperativeHandle(ref, () => innerRef.current);
27869
- const [navState, setNavState, resetNavState] = useNavState();
27870
- const [boundAction, value, setValue, initialValue] = useActionBoundToOneParam(action, name);
27871
28083
  const {
27872
- loading: actionLoading
27873
- } = useActionStatus(boundAction);
27874
- const executeAction = useExecuteAction(innerRef, {
27875
- errorEffect: actionErrorEffect
27876
- });
28084
+ pointedValue,
28085
+ setPointedValue,
28086
+ onSelect
28087
+ } = useContext(SuggestionListContext);
28088
+ const isPointed = pointedValue === value;
28089
+ const suggestionRef = useRef(null);
27877
28090
  useEffect(() => {
27878
- setNavState(value);
27879
- }, [value]);
27880
- const actionRequesterRef = useRef(null);
27881
- useActionEvents(innerRef, {
27882
- onCancel: (e, reason) => {
27883
- resetNavState();
27884
- setValue(initialValue);
27885
- onCancel?.(e, reason);
28091
+ const suggestionEl = suggestionRef.current;
28092
+ if (isPointed && suggestionEl) {
28093
+ suggestionEl.scrollIntoView({
28094
+ block: "nearest"
28095
+ });
28096
+ }
28097
+ }, [isPointed]);
28098
+ return jsx(Box, {
28099
+ as: "li",
28100
+ ref: suggestionRef,
28101
+ baseClassName: "navi_suggestion",
28102
+ id: suggestionId,
28103
+ role: "option",
28104
+ "aria-selected": selected,
28105
+ "aria-hidden": hidden ? true : undefined,
28106
+ hidden: hidden,
28107
+ basePseudoState: {
28108
+ ":-navi-pointed": isPointed,
28109
+ ":-navi-selected": selected
27886
28110
  },
27887
- onPrevented: onActionPrevented,
27888
- onAction: actionEvent => {
27889
- actionRequesterRef.current = actionEvent.detail.requester;
27890
- executeAction(actionEvent);
28111
+ pseudoClasses: SUGGESTION_PSEUDO_CLASSES,
28112
+ pseudoElements: SUGGESTION_PSEUDO_ELEMENTS,
28113
+ styleCSSVars: SuggestionStyleCSSVars,
28114
+ onMouseEnter: () => {
28115
+ if (!hidden) {
28116
+ setPointedValue(value);
28117
+ }
27891
28118
  },
27892
- onStart: onActionStart,
27893
- onAbort: e => {
27894
- setValue(initialValue);
27895
- onActionAbort?.(e);
28119
+ onMouseLeave: () => {
28120
+ if (!hidden) {
28121
+ setPointedValue(null);
28122
+ }
27896
28123
  },
27897
- onError: error => {
27898
- setValue(initialValue);
27899
- onActionError?.(error);
28124
+ onMouseDown: e => {
28125
+ if (hidden || e.button !== 0) {
28126
+ return;
28127
+ }
28128
+ onSelect?.(value);
27900
28129
  },
27901
- onEnd: () => {
27902
- resetNavState();
27903
- onActionEnd?.();
27904
- }
28130
+ ...rest,
28131
+ children: children
27905
28132
  });
27906
- const childRefArray = useRefArray(children, child => child.value);
27907
- return jsx(SelectControlled, {
27908
- ref: innerRef,
27909
- name: name,
27910
- value: value,
27911
- "data-action": boundAction,
27912
- onChange: event => {
27913
- const select = event.target;
27914
- const selectedValue = select.value;
27915
- setValue(selectedValue);
27916
- const radioListContainer = innerRef.current;
27917
- const optionSelected = select.querySelector(`option[value="${selectedValue}"]`);
27918
- requestAction(radioListContainer, boundAction, {
27919
- event,
27920
- requester: optionSelected
27921
- });
27922
- },
28133
+ };
28134
+ const SuggestionGroup = ({
28135
+ label,
28136
+ children,
28137
+ ...rest
28138
+ }) => {
28139
+ import.meta.css = [css$f, "@jsenv/navi/src/field/suggestion_list.jsx"];
28140
+ const groupId = useId();
28141
+ return jsxs("li", {
28142
+ role: "presentation",
27923
28143
  ...rest,
27924
- children: children.map((child, i) => {
27925
- const childRef = childRefArray[i];
27926
- return {
27927
- ...child,
27928
- ref: childRef,
27929
- loading: child.loading || actionLoading && actionRequesterRef.current === childRef.current,
27930
- readOnly: child.readOnly || actionLoading
27931
- };
27932
- })
28144
+ children: [jsx("span", {
28145
+ id: groupId,
28146
+ role: "presentation",
28147
+ "aria-hidden": "true",
28148
+ style: {
28149
+ display: "contents"
28150
+ },
28151
+ children: jsx("span", {
28152
+ className: "navi_suggestion_group_label",
28153
+ "data-default-label": typeof label === "string" ? "" : undefined,
28154
+ children: label
28155
+ })
28156
+ }), jsx("ul", {
28157
+ role: "group",
28158
+ "aria-labelledby": groupId,
28159
+ style: {
28160
+ margin: 0,
28161
+ padding: 0,
28162
+ listStyle: "none"
28163
+ },
28164
+ children: children
28165
+ })]
27933
28166
  });
27934
- });
28167
+ };
27935
28168
 
27936
28169
  const TableSelectionContext = createContext();
27937
28170
  const useTableSelectionContextValue = (