@jsenv/navi 0.25.0 → 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";
@@ -6298,15 +6298,21 @@ const DIMENSION_PROPS = {
6298
6298
  }
6299
6299
  const inHorizontalFlexFlow =
6300
6300
  parentBoxFlow === "flex-x" || parentBoxFlow === "inline-flex-x";
6301
- if (!inHorizontalFlexFlow) {
6302
- // Can't use flexGrow parent is not flex-x
6303
- if (parentBoxFlow === "flex-y" || parentBoxFlow === "inline-flex-y") {
6304
- return { alignSelf: "stretch" };
6305
- }
6306
- return { width: "100%" };
6301
+ if (inHorizontalFlexFlow) {
6302
+ // Parent is flex-x: grow as flex item
6303
+ return { flexGrow: 1, flexBasis: "0%" };
6304
+ }
6305
+ if (parentBoxFlow === "flex-y" || parentBoxFlow === "inline-flex-y") {
6306
+ return {
6307
+ alignSelf: "stretch",
6308
+ // Here flex grow is "useless" for the item itself
6309
+ // buuut it would allow children (hello ".navi_text_sizer")
6310
+ // to inherit expand behavior
6311
+ flexGrow: 1,
6312
+ };
6307
6313
  }
6308
- // Parent is flex-x: grow as flex item
6309
- return { flexGrow: 1, flexBasis: "0%" };
6314
+ // Can't use flexGrow — parent is not flex-x
6315
+ return { width: "100%" };
6310
6316
  },
6311
6317
  expandY: (value, { parentBoxFlow }) => {
6312
6318
  if (!value) {
@@ -6314,15 +6320,21 @@ const DIMENSION_PROPS = {
6314
6320
  }
6315
6321
  const inVerticalFlexFlow =
6316
6322
  parentBoxFlow === "flex-y" || parentBoxFlow === "inline-flex-y";
6317
- if (!inVerticalFlexFlow) {
6318
- // Can't use flexGrow parent is not flex-y
6319
- if (parentBoxFlow === "flex-x" || parentBoxFlow === "inline-flex-x") {
6320
- return { alignSelf: "stretch" };
6321
- }
6322
- return { height: "100%" };
6323
+ if (inVerticalFlexFlow) {
6324
+ // Parent is flex-y: grow as flex item
6325
+ return { flexGrow: 1, flexBasis: "0%" };
6326
+ }
6327
+ if (parentBoxFlow === "flex-x" || parentBoxFlow === "inline-flex-x") {
6328
+ return {
6329
+ alignSelf: "stretch",
6330
+ // Here flex grow is "useless" for the item itself
6331
+ // buuut it would allow children (hello ".navi_text_sizer")
6332
+ // to inherit expand behavior
6333
+ flexGrow: 1,
6334
+ };
6323
6335
  }
6324
- // Parent is flex-y: grow as flex item
6325
- return { flexGrow: 1, flexBasis: "0%" };
6336
+ // Can't use flexGrow — parent is not flex-y
6337
+ return { height: "100%" };
6326
6338
  },
6327
6339
  shrinkX: (value) => {
6328
6340
  if (!value || value === "0") {
@@ -7233,6 +7245,9 @@ const PSEUDO_CLASSES = {
7233
7245
  ":-navi-status-error": {
7234
7246
  attribute: "data-status-error",
7235
7247
  },
7248
+ ":navi-expanded": {
7249
+ attribute: "data-expanded",
7250
+ },
7236
7251
  };
7237
7252
 
7238
7253
  const NAVI_PSEUDO_STATE_CUSTOM_EVENT = "navi_pseudo_state";
@@ -16474,7 +16489,7 @@ const stickCalloutToAnchor = (calloutElement, anchorElement) => {
16474
16489
  }
16475
16490
  calloutElement.setAttribute("data-position", position);
16476
16491
  calloutStyleController.set(calloutElement, {
16477
- opacity: visibilityRatio ? 1 : 0,
16492
+ opacity: visibilityRatio > 0 ? 1 : 0,
16478
16493
  transform: {
16479
16494
  translateX: calloutLeft,
16480
16495
  translateY: calloutTop
@@ -20264,6 +20279,109 @@ const isSameKey = (browserEventKey, key) => {
20264
20279
  return false;
20265
20280
  };
20266
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
+
20267
20385
  const useInitialTextSelection = (ref, textSelection) => {
20268
20386
  const deps = [];
20269
20387
  if (Array.isArray(textSelection)) {
@@ -20363,8 +20481,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
20363
20481
  }
20364
20482
  };
20365
20483
 
20366
- installImportMetaCssBuild(import.meta);/* eslint-disable jsenv/no-unknown-params */
20367
- const css$v = /* css */`
20484
+ installImportMetaCssBuild(import.meta);const css$v = /* css */`
20368
20485
  @layer navi {
20369
20486
  .navi_text {
20370
20487
  &[data-skeleton] {
@@ -20379,23 +20496,27 @@ const css$v = /* css */`
20379
20496
  .navi_text {
20380
20497
  position: relative;
20381
20498
 
20499
+ &[data-dark-background] {
20500
+ color: white;
20501
+ }
20502
+
20382
20503
  /* There is a chrome specific bug that prevents text-transform: capitalize to be applied in nested DOM structure */
20383
20504
  /* The CSS below ensure capitalize is propagated to the bold clones */
20384
20505
  &[data-capitalize] {
20385
20506
  &::first-letter {
20386
20507
  text-transform: uppercase;
20387
20508
  }
20388
- .navi_text_bold_clone::first-letter {
20509
+ .navi_text_sizer_placeholder::first-letter {
20389
20510
  text-transform: uppercase;
20390
20511
  }
20391
- .navi_text_bold_foreground::first-letter {
20512
+ .navi_text_sizer_overlay::first-letter {
20392
20513
  text-transform: uppercase;
20393
20514
  }
20394
20515
  }
20395
20516
 
20396
- .navi_text_bold_wrapper,
20397
- .navi_text_bold_clone,
20398
- .navi_text_bold_foreground {
20517
+ .navi_text_sizer,
20518
+ .navi_text_sizer_placeholder,
20519
+ .navi_text_sizer_overlay {
20399
20520
  display: inherit;
20400
20521
  width: inherit;
20401
20522
  min-width: inherit;
@@ -20403,6 +20524,7 @@ const css$v = /* css */`
20403
20524
  min-height: inherit;
20404
20525
  flex-grow: inherit;
20405
20526
  align-items: inherit;
20527
+ align-self: inherit;
20406
20528
  justify-content: inherit;
20407
20529
  gap: inherit;
20408
20530
  text-align: inherit;
@@ -20507,15 +20629,14 @@ const css$v = /* css */`
20507
20629
  }
20508
20630
  }
20509
20631
 
20510
- .navi_text_bold_wrapper {
20632
+ .navi_text_sizer {
20511
20633
  position: relative;
20512
20634
  display: inline-block;
20513
20635
 
20514
- .navi_text_bold_clone {
20515
- font-weight: bold;
20636
+ .navi_text_sizer_placeholder {
20516
20637
  opacity: 0;
20517
20638
  }
20518
- .navi_text_bold_foreground {
20639
+ .navi_text_sizer_overlay {
20519
20640
  position: absolute;
20520
20641
  inset: 0;
20521
20642
  }
@@ -20534,26 +20655,14 @@ const css$v = /* css */`
20534
20655
  -webkit-text-fill-color: transparent;
20535
20656
  opacity: 0;
20536
20657
  }
20537
-
20658
+ .navi_text[data-contains-absolute-child] {
20659
+ display: inline-block;
20660
+ }
20538
20661
  .navi_text[data-bold] {
20539
20662
  .navi_text_bold_background {
20540
20663
  opacity: 1;
20541
20664
  }
20542
20665
  }
20543
-
20544
- .navi_text[data-bold-transition] {
20545
- .navi_text_bold_foreground {
20546
- transition-property: font-weight;
20547
- transition-duration: 0.3s;
20548
- transition-timing-function: ease;
20549
- }
20550
-
20551
- .navi_text_bold_background {
20552
- transition-property: opacity;
20553
- transition-duration: 0.3s;
20554
- transition-timing-function: ease;
20555
- }
20556
- }
20557
20666
  `;
20558
20667
  const REGULAR_SPACE = jsx("span", {
20559
20668
  "data-navi-space": "",
@@ -20672,6 +20781,61 @@ const shouldInjectSpacingBetween = (left, right) => {
20672
20781
  return true;
20673
20782
  };
20674
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
+ */
20675
20839
  const Text = props => {
20676
20840
  import.meta.css = [css$v, "@jsenv/navi/src/text/text.jsx"];
20677
20841
  if (props.loading || props.skeleton) {
@@ -20801,21 +20965,25 @@ const TextWithSelectRange = ({
20801
20965
  const TextBasic = ({
20802
20966
  spacing,
20803
20967
  preventSpaceUnderlines = false,
20804
- boldTransition,
20805
20968
  boldStable,
20806
- preventBoldLayoutShift = boldTransition,
20969
+ holdSpaceForStyle,
20807
20970
  capitalize,
20808
20971
  children,
20809
20972
  childrenOutsideFlow,
20973
+ basePseudoState,
20810
20974
  ...rest
20811
20975
  }) => {
20976
+ const defaultRef = useRef();
20977
+ const ref = rest.ref || defaultRef;
20978
+ const bgDeps = basePseudoState ? Object.values(basePseudoState) : [];
20979
+ useDarkBackgroundAttribute(ref, bgDeps);
20812
20980
  const defaultSpace = preventSpaceUnderlines ? FAKE_SPACE : REGULAR_SPACE;
20813
20981
  const resolvedSpacing = spacing ?? defaultSpace;
20814
20982
  const boxProps = {
20815
20983
  "as": "span",
20816
- "data-bold-transition": boldTransition ? "" : undefined,
20817
20984
  "data-capitalize": capitalize ? "" : undefined,
20818
20985
  ...rest,
20986
+ ref,
20819
20987
  "baseClassName": withPropsClassName("navi_text", rest.baseClassName)
20820
20988
  };
20821
20989
  const shouldPreserveSpacing = rest.as === "pre" || rest.flex || rest.grid;
@@ -20832,6 +21000,7 @@ const TextBasic = ({
20832
21000
  ...boxProps,
20833
21001
  bold: undefined,
20834
21002
  "data-bold": bold ? "" : undefined,
21003
+ "data-contains-absolute-child": "",
20835
21004
  children: [jsx("span", {
20836
21005
  className: "navi_text_bold_background",
20837
21006
  "aria-hidden": "true",
@@ -20839,25 +21008,23 @@ const TextBasic = ({
20839
21008
  }), children, childrenOutsideFlow]
20840
21009
  });
20841
21010
  }
20842
- if (preventBoldLayoutShift) {
20843
- const alignX = rest.alignX || rest.align || "start";
20844
-
20845
- // La technique consiste a avoid un double gras qui force une taille
20846
- // et la version light par dessus en position absolute
20847
- // on la centre aussi pour donner l'impression que le gras s'applique depuis le centre
20848
- // ne fonctionne que sur une seule ligne de texte (donc lorsque noWrap est actif)
20849
- // 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.
20850
21017
  return jsxs(Box, {
20851
21018
  ...boxProps,
20852
21019
  children: [jsxs("span", {
20853
- className: "navi_text_bold_wrapper",
21020
+ className: "navi_text_sizer",
20854
21021
  children: [jsx("span", {
20855
- className: "navi_text_bold_clone",
21022
+ className: "navi_text_sizer_placeholder",
20856
21023
  "aria-hidden": "true",
21024
+ style: holdSpaceForStyle,
20857
21025
  children: children
20858
21026
  }), jsx("span", {
20859
- className: "navi_text_bold_foreground",
20860
- "data-align": alignX,
21027
+ className: "navi_text_sizer_overlay",
20861
21028
  children: children
20862
21029
  })]
20863
21030
  }), childrenOutsideFlow]
@@ -21153,109 +21320,6 @@ const Icon = ({
21153
21320
  });
21154
21321
  };
21155
21322
 
21156
- /**
21157
- * Toggles a `data-dark-background` attribute on the referenced element based on its
21158
- * computed background color. Pair it with a CSS variable to get automatic
21159
- * light/dark text without hard-coding colors:
21160
- *
21161
- * ```css
21162
- * .my-element {
21163
- * --color-contrasting: black;
21164
- * &[data-dark-background] {
21165
- * --color-contrasting: white;
21166
- * }
21167
- * color: var(--color-contrasting);
21168
- * }
21169
- * ```
21170
- *
21171
- * - `data-dark-background` is **set** when the background is dark enough that white text
21172
- * provides better (or equal) contrast.
21173
- * - `data-dark-background` is **absent** when black text is the better choice.
21174
- *
21175
- * @param {import("preact").RefObject} ref - Ref to the element that receives
21176
- * the `data-dark-background` attribute and is also passed to `contrastColor` for
21177
- * resolving CSS variables.
21178
- * @param {object} [options]
21179
- * @param {string} [options.backgroundElementSelector] - CSS selector relative
21180
- * to `ref.current` pointing to a child element whose `background-color`
21181
- * should be tested instead of the element itself. Useful when the element
21182
- * has a transparent background but contains a coloured child (e.g. a fill
21183
- * bar inside a track).
21184
- */
21185
-
21186
- const useDarkBackgroundAttribute = (
21187
- ref,
21188
- deps = [],
21189
- {
21190
- backgroundElementSelector,
21191
- attributeName = "data-dark-background",
21192
- hardcoded = {},
21193
- } = {},
21194
- ) => {
21195
- const innerDeps = [
21196
- ...deps,
21197
- // ref can change is the component pass a different ref on different render based on some logic
21198
- // (can be used to control which element backgroundColor is being checked by switching the ref to another element)
21199
- ref,
21200
- // backgroundElementSelector can change if the component pass a different selector on different render based on some logic
21201
- // (can be used to control which element backgroundColor is being checked by switching the selector to point to another element)
21202
- backgroundElementSelector,
21203
- ];
21204
-
21205
- const hardcodedMap = new Map();
21206
- for (const key of Object.keys(hardcoded)) {
21207
- const value = hardcoded[key];
21208
- innerDeps.push(key, value);
21209
- const colorString = normalizeColorString(key);
21210
- hardcodedMap.set(colorString, value);
21211
- }
21212
-
21213
- useLayoutEffect(() => {
21214
- const el = ref.current;
21215
- if (!el) {
21216
- return undefined;
21217
- }
21218
- let elementToCheck = el;
21219
- if (backgroundElementSelector) {
21220
- elementToCheck = el.querySelector(backgroundElementSelector);
21221
- if (!elementToCheck) {
21222
- return undefined;
21223
- }
21224
- }
21225
- const updateAttribute = () => {
21226
- const computedStyle = getComputedStyle(elementToCheck);
21227
- const backgroundColor = computedStyle.backgroundColor;
21228
- if (!backgroundColor) {
21229
- el.removeAttribute(attributeName);
21230
- return;
21231
- }
21232
- const backgroundColorString = normalizeColorString(backgroundColor, el);
21233
- const hardcodedContrast = hardcodedMap.get(backgroundColorString);
21234
- const contrastingColor =
21235
- hardcodedContrast || contrastColor(backgroundColor, el);
21236
- if (contrastingColor === "white") {
21237
- el.setAttribute(attributeName, "");
21238
- } else {
21239
- el.removeAttribute(attributeName);
21240
- }
21241
- };
21242
- updateAttribute();
21243
- el.addEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttribute);
21244
- return () => {
21245
- el.removeEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttribute);
21246
- el.removeAttribute(attributeName);
21247
- };
21248
- }, innerDeps);
21249
- };
21250
-
21251
- const normalizeColorString = (color, el) => {
21252
- const colorRgba = resolveCSSColor(color, el);
21253
- if (!colorRgba) {
21254
- return "";
21255
- }
21256
- return String(colorRgba);
21257
- };
21258
-
21259
21323
  const useFormEvents = (
21260
21324
  elementRef,
21261
21325
  {
@@ -21777,8 +21841,7 @@ const useUIState = (uiStateController) => {
21777
21841
  return trackedUIState;
21778
21842
  };
21779
21843
 
21780
- installImportMetaCssBuild(import.meta);/* eslint-disable jsenv/no-unknown-params */
21781
- const css$s = /* css */`
21844
+ installImportMetaCssBuild(import.meta);const css$s = /* css */`
21782
21845
  @layer navi {
21783
21846
  .navi_button {
21784
21847
  --button-outline-width: 1px;
@@ -22575,8 +22638,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
22575
22638
  });
22576
22639
  };
22577
22640
 
22578
- installImportMetaCssBuild(import.meta);/* eslint-disable jsenv/no-unknown-params */
22579
- const css$r = /* css */`
22641
+ installImportMetaCssBuild(import.meta);const css$r = /* css */`
22580
22642
  @layer navi {
22581
22643
  .navi_link {
22582
22644
  --link-border-radius: unset;
@@ -22722,7 +22784,7 @@ const css$r = /* css */`
22722
22784
  }
22723
22785
 
22724
22786
  /* Dark background */
22725
- &[data-dark-background] {
22787
+ &[data-dark-background].navi_text {
22726
22788
  --x-link-contrasting-color: white;
22727
22789
  --x-link-color: var(--link-color, white);
22728
22790
  }
@@ -23028,7 +23090,6 @@ const LinkPlain = props => {
23028
23090
  isCurrent
23029
23091
  } = getHrefTargetInfo(href);
23030
23092
  const innerCurrent = current || isCurrent;
23031
- useDarkBackgroundAttribute(ref, [selected, innerCurrent], {});
23032
23093
  const innerTarget = target === undefined ? isSameSite ? "_self" : "_blank" : target;
23033
23094
  const innerRel = rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel;
23034
23095
  let innerEndIcon;
@@ -23081,7 +23142,9 @@ const LinkPlain = props => {
23081
23142
  onnavi_value: e => {
23082
23143
  e.detail.setValue(value);
23083
23144
  },
23084
- preventBoldLayoutShift: currentEffectBold,
23145
+ holdSpaceForStyle: currentEffectBold ? {
23146
+ fontWeight: "bold"
23147
+ } : undefined,
23085
23148
  preventSpaceUnderlines: true,
23086
23149
  overflowEllipsis: overflowEllipsis
23087
23150
  // Visual
@@ -25686,6 +25749,16 @@ const InputRangeWithAction = props => {
25686
25749
  });
25687
25750
  };
25688
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
+
25689
25762
  const SearchSvg = () => jsx("svg", {
25690
25763
  viewBox: "0 0 24 24",
25691
25764
  xmlns: "http://www.w3.org/2000/svg",
@@ -25695,7 +25768,23 @@ const SearchSvg = () => jsx("svg", {
25695
25768
  })
25696
25769
  });
25697
25770
 
25698
- 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
+ */
25699
25788
  const css$j = /* css */`
25700
25789
  @layer navi {
25701
25790
  .navi_input {
@@ -25754,8 +25843,8 @@ const css$j = /* css */`
25754
25843
  border-radius: inherit;
25755
25844
  cursor: inherit;
25756
25845
 
25757
- --start-icon-size: 0px;
25758
- --end-icon-size: 0px;
25846
+ --left-slot-size: 0px;
25847
+ --right-slot-size: 0px;
25759
25848
  --x-outline-width: var(--outline-width);
25760
25849
  --x-border-radius: var(--border-radius);
25761
25850
  --x-border-width: var(--border-width);
@@ -25786,9 +25875,9 @@ const css$j = /* css */`
25786
25875
  .navi_native_input {
25787
25876
  box-sizing: border-box;
25788
25877
  padding-top: var(--x-padding-top-base);
25789
- 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));
25790
25879
  padding-bottom: var(--x-padding-bottom-base);
25791
- 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));
25792
25881
  color: var(--x-color);
25793
25882
  font-size: var(--font-size);
25794
25883
  background-color: var(--x-background-color);
@@ -25811,17 +25900,9 @@ const css$j = /* css */`
25811
25900
  }
25812
25901
  }
25813
25902
 
25814
- .navi_input_start_icon {
25815
- position: absolute;
25816
- top: 0;
25817
- bottom: 0;
25818
- left: var(--x-padding-left-base);
25819
- font-size: var(--font-size);
25820
- }
25821
- .navi_input_end_button {
25903
+ .navi_input_slot {
25822
25904
  position: absolute;
25823
25905
  top: 0;
25824
- right: var(--x-padding-right-base);
25825
25906
  bottom: 0;
25826
25907
  display: inline-flex;
25827
25908
  margin: 0;
@@ -25831,34 +25912,43 @@ const css$j = /* css */`
25831
25912
  font-size: var(--font-size);
25832
25913
  background: none;
25833
25914
  border: none;
25834
- opacity: 0;
25835
- 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
+ }
25836
25926
  }
25837
25927
  &[data-has-value] {
25838
- .navi_input_end_button {
25928
+ .navi_input_slot[data-hide-while-empty] {
25839
25929
  opacity: 1;
25840
25930
  cursor: pointer;
25841
25931
  pointer-events: auto;
25842
25932
  }
25843
25933
 
25844
25934
  &[data-readonly] {
25845
- .navi_input_end_button {
25935
+ .navi_input_slot[data-hide-while-empty] {
25846
25936
  opacity: 0;
25847
25937
  pointer-events: none;
25848
25938
  }
25849
25939
  }
25850
25940
  &[data-disabled] {
25851
- .navi_input_end_button {
25941
+ .navi_input_slot[data-hide-while-empty] {
25852
25942
  opacity: 0;
25853
25943
  pointer-events: none;
25854
25944
  }
25855
25945
  }
25856
25946
  }
25857
- &[data-start-icon] {
25858
- --start-icon-size: 1em;
25947
+ &:has(.navi_input_slot[data-left]) {
25948
+ --left-slot-size: 1em;
25859
25949
  }
25860
- &[data-end-icon] {
25861
- --end-icon-size: 1em;
25950
+ &:has(.navi_input_slot[data-right]) {
25951
+ --right-slot-size: 1em;
25862
25952
  }
25863
25953
 
25864
25954
  /* Hover */
@@ -25964,7 +26054,7 @@ const InputStyleCSSVars = {
25964
26054
  color: "--color-disabled"
25965
26055
  }
25966
26056
  };
25967
- 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"];
25968
26058
  Object.assign(PSEUDO_CLASSES, {
25969
26059
  ":navi-has-value": {
25970
26060
  attribute: "data-has-value",
@@ -25981,6 +26071,55 @@ Object.assign(PSEUDO_CLASSES, {
25981
26071
  });
25982
26072
  const InputPseudoElements = ["::-navi-loader"];
25983
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
+ };
25984
26123
  const InputTextualBasic = props => {
25985
26124
  if (props.suggestions) {
25986
26125
  return jsx(InputTextualWithSuggestions, {
@@ -25996,54 +26135,48 @@ const InputTextualWithSuggestions = ({
25996
26135
  onInput,
25997
26136
  onFocus,
25998
26137
  onBlur,
26138
+ children,
25999
26139
  ...rest
26000
26140
  }) => {
26001
26141
  const defaultRef = useRef();
26002
26142
  const ref = rest.ref || defaultRef;
26003
- const [suggestionsOpen, setSuggestionsOpen] = useState(false);
26004
- const suggestionsOpenRef = useRef(false);
26005
- suggestionsOpenRef.current = suggestionsOpen;
26006
- const showPopover = e => {
26007
- 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) {
26008
26156
  return;
26009
26157
  }
26010
26158
  console.debug(`showPopover (e.type:${e.type})`);
26011
26159
  const popoverEl = document.getElementById(suggestions);
26012
- positionPopover();
26013
- popoverEl.showPopover();
26014
- suggestionsOpenRef.current = true;
26015
- setSuggestionsOpen(true);
26016
- window.addEventListener("scroll", positionPopover, {
26017
- capture: true,
26018
- passive: true
26019
- });
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();
26020
26169
  };
26021
- const hidePopover = e => {
26022
- if (!suggestionsOpenRef.current) {
26170
+ const hideSuggestions = e => {
26171
+ if (!expandedRef.current) {
26023
26172
  return;
26024
26173
  }
26025
26174
  console.debug(`hidePopover (e.type:${e.type})`);
26026
- suggestionsOpenRef.current = false;
26027
- setSuggestionsOpen(false);
26028
- window.removeEventListener("scroll", positionPopover, {
26029
- capture: true
26030
- });
26031
- const popoverEl = document.getElementById(suggestions);
26032
- if (popoverEl) {
26033
- popoverEl.dispatchEvent(new CustomEvent("navi_suggestion_list_clear"));
26034
- popoverEl.hidePopover();
26035
- }
26036
- setSuggestionsOpen(false);
26037
- };
26038
- const positionPopover = () => {
26039
- const input = ref.current;
26040
- const rect = input.getBoundingClientRect();
26041
26175
  const popoverEl = document.getElementById(suggestions);
26042
26176
  if (popoverEl) {
26043
- popoverEl.style.top = `${rect.bottom + 2}px`;
26044
- popoverEl.style.left = `${rect.left}px`;
26045
- popoverEl.style.width = `${rect.width}px`;
26177
+ popoverEl.dispatchEvent(new CustomEvent("navi_suggestion_list_close"));
26046
26178
  }
26179
+ collapse();
26047
26180
  };
26048
26181
  const dispatchToSuggestionList = customEvent => {
26049
26182
  const popoverEl = document.getElementById(suggestions);
@@ -26057,7 +26190,7 @@ const InputTextualWithSuggestions = ({
26057
26190
  key: "arrowdown",
26058
26191
  description: "Open popover and point to next suggestion",
26059
26192
  handler: e => {
26060
- showPopover(e);
26193
+ showSuggestions(e);
26061
26194
  const popoverEl = document.getElementById(suggestions);
26062
26195
  if (!popoverEl) {
26063
26196
  return false;
@@ -26073,7 +26206,7 @@ const InputTextualWithSuggestions = ({
26073
26206
  key: "arrowup",
26074
26207
  description: "Open popover and point to previous suggestion",
26075
26208
  handler: e => {
26076
- showPopover(e);
26209
+ showSuggestions(e);
26077
26210
  return dispatchToSuggestionList(new CustomEvent("navi_suggestion_list_navigate", {
26078
26211
  detail: {
26079
26212
  direction: "up"
@@ -26084,7 +26217,7 @@ const InputTextualWithSuggestions = ({
26084
26217
  key: "home",
26085
26218
  description: "Point to first suggestion",
26086
26219
  handler: () => {
26087
- if (!suggestionsOpenRef.current) {
26220
+ if (!expandedRef.current) {
26088
26221
  return false;
26089
26222
  }
26090
26223
  return dispatchToSuggestionList(new CustomEvent("navi_suggestion_list_navigate", {
@@ -26097,7 +26230,7 @@ const InputTextualWithSuggestions = ({
26097
26230
  key: "end",
26098
26231
  description: "Point to last suggestion",
26099
26232
  handler: () => {
26100
- if (!suggestionsOpenRef.current) {
26233
+ if (!expandedRef.current) {
26101
26234
  return false;
26102
26235
  }
26103
26236
  return dispatchToSuggestionList(new CustomEvent("navi_suggestion_list_navigate", {
@@ -26110,7 +26243,7 @@ const InputTextualWithSuggestions = ({
26110
26243
  key: "enter",
26111
26244
  description: "Confirm pointed suggestion",
26112
26245
  handler: () => {
26113
- if (!suggestionsOpenRef.current) {
26246
+ if (!expandedRef.current) {
26114
26247
  return false;
26115
26248
  }
26116
26249
  return dispatchToSuggestionList(new CustomEvent("navi_suggestion_list_confirm", {
@@ -26121,10 +26254,10 @@ const InputTextualWithSuggestions = ({
26121
26254
  key: "escape",
26122
26255
  description: "Close popover",
26123
26256
  handler: e => {
26124
- if (!suggestionsOpenRef.current) {
26257
+ if (!expandedRef.current) {
26125
26258
  return false;
26126
26259
  }
26127
- hidePopover(e);
26260
+ hideSuggestions(e);
26128
26261
  return true;
26129
26262
  }
26130
26263
  }]);
@@ -26139,7 +26272,7 @@ const InputTextualWithSuggestions = ({
26139
26272
  inputEl.dispatchEvent(new Event("input", {
26140
26273
  bubbles: true
26141
26274
  }));
26142
- hidePopover(e);
26275
+ hideSuggestions(e);
26143
26276
  };
26144
26277
  popoverEl.addEventListener("navi_suggestion_list_selected", onSelected);
26145
26278
  return () => {
@@ -26152,24 +26285,40 @@ const InputTextualWithSuggestions = ({
26152
26285
  autoComplete: "off",
26153
26286
  "aria-controls": suggestions,
26154
26287
  "aria-haspopup": "listbox",
26155
- "aria-expanded": suggestionsOpen,
26288
+ "aria-expanded": expanded,
26156
26289
  "aria-autocomplete": "list",
26290
+ basePseudoState: {
26291
+ ":navi-expanded": expanded
26292
+ },
26157
26293
  onnavi_callout_open: e => {
26158
- hidePopover(e);
26294
+ hideSuggestions(e);
26159
26295
  },
26160
26296
  onFocus: e => {
26161
26297
  onFocus?.(e);
26162
- showPopover(e);
26298
+ showSuggestions(e);
26163
26299
  },
26164
26300
  onBlur: e => {
26165
26301
  onBlur?.(e);
26166
- hidePopover(e);
26302
+ hideSuggestions(e);
26167
26303
  },
26168
26304
  onInput: e => {
26169
26305
  onInput?.(e);
26170
- showPopover(e);
26306
+ showSuggestions(e);
26171
26307
  },
26172
- ...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
+ })
26173
26322
  });
26174
26323
  };
26175
26324
  const InputTextualPlain = props => {
@@ -26188,8 +26337,8 @@ const InputTextualPlain = props => {
26188
26337
  autoFocus,
26189
26338
  autoFocusVisible,
26190
26339
  autoSelect,
26191
- icon,
26192
- cancelButton = type === "search",
26340
+ basePseudoState,
26341
+ children,
26193
26342
  ...rest
26194
26343
  } = props;
26195
26344
  const defaultRef = useRef();
@@ -26248,17 +26397,45 @@ const InputTextualPlain = props => {
26248
26397
  });
26249
26398
  };
26250
26399
  const renderInputMemoized = useCallback(renderInput, [type, uiState, innerValue, innerOnInput, innerId]);
26251
- let innerIcon;
26252
- if (icon === undefined) {
26253
- if (type === "search") {
26254
- innerIcon = jsx(SearchSvg, {});
26255
- } else if (type === "email") {
26256
- innerIcon = jsx(EmailSvg, {});
26257
- } else if (type === "tel") {
26258
- innerIcon = jsx(PhoneSvg, {});
26259
- }
26260
- } else {
26261
- 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
+ });
26262
26439
  }
26263
26440
  return jsxs(Box, {
26264
26441
  as: "span",
@@ -26268,6 +26445,7 @@ const InputTextualPlain = props => {
26268
26445
  pseudoStateSelector: ".navi_native_input",
26269
26446
  visualSelector: ".navi_native_input",
26270
26447
  basePseudoState: {
26448
+ ...basePseudoState,
26271
26449
  ":read-only": innerReadOnly,
26272
26450
  ":disabled": innerDisabled,
26273
26451
  ":-navi-loading": innerLoading
@@ -26276,48 +26454,20 @@ const InputTextualPlain = props => {
26276
26454
  pseudoElements: InputPseudoElements,
26277
26455
  hasChildFunction: true,
26278
26456
  baseChildPropSet: InputChildPropSet,
26279
- "data-start-icon": innerIcon ? "" : undefined,
26280
- "data-end-icon": cancelButton ? "" : undefined,
26281
26457
  ...remainingProps,
26282
26458
  ref: undefined,
26283
26459
  children: [jsx(LoaderBackground, {
26284
26460
  loading: innerLoading,
26285
26461
  color: "var(--loader-color)",
26286
26462
  inset: -1
26287
- }), innerIcon && jsx(Label, {
26288
- htmlFor: innerId,
26289
- disabled: innerDisabled,
26290
- readOnly: innerReadOnly,
26291
- className: "navi_input_start_icon",
26292
- flex: true,
26293
- alignY: "center",
26294
- children: jsx(Icon, {
26295
- color: "rgba(28, 43, 52, 0.5)",
26296
- children: innerIcon
26297
- })
26298
- }), renderInputMemoized, cancelButton && jsx("label", {
26299
- htmlFor: innerId,
26300
- "data-readonly": innerReadOnly ? "" : undefined,
26301
- "data-disabled": innerDisabled ? "" : undefined,
26302
- className: "navi_input_end_button",
26303
- onMouseDown: e => {
26304
- e.preventDefault(); // keep focus in the input
26305
- },
26306
- onClick: () => {
26307
- if (innerReadOnly || innerDisabled) {
26308
- return;
26309
- }
26310
- uiStateController.setUIState("", {
26311
- trigger: "cancel_button"
26312
- });
26313
- ref.current.value = "";
26314
- 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
26315
26468
  },
26316
- children: jsx(Icon, {
26317
- color: "rgba(28, 43, 52, 0.5)",
26318
- children: jsx(CloseSvg, {})
26319
- })
26320
- })]
26469
+ children: innerChildren
26470
+ }) : null]
26321
26471
  });
26322
26472
  };
26323
26473
  const InputTextualWithAction = props => {
@@ -27045,553 +27195,32 @@ const Group = ({
27045
27195
  });
27046
27196
  };
27047
27197
 
27048
- const createItemTracker = () => {
27049
- const ItemTrackerContext = createContext();
27050
- const useItemTrackerProvider = () => {
27051
- const itemsRef = useRef([]);
27052
- const items = itemsRef.current;
27053
- const itemCountRef = useRef(0);
27054
- const tracker = useMemo(() => {
27055
- const ItemTrackerProvider = ({
27056
- children
27057
- }) => {
27058
- // Reset on each render to start fresh
27059
- tracker.reset();
27060
- return jsx(ItemTrackerContext.Provider, {
27061
- value: tracker,
27062
- children: children
27063
- });
27064
- };
27065
- ItemTrackerProvider.items = items;
27066
- return {
27067
- ItemTrackerProvider,
27068
- items,
27069
- registerItem: data => {
27070
- const index = itemCountRef.current++;
27071
- items[index] = data;
27072
- return index;
27073
- },
27074
- getItem: index => {
27075
- return items[index];
27076
- },
27077
- getAllItems: () => {
27078
- return items;
27079
- },
27080
- reset: () => {
27081
- items.length = 0;
27082
- 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;
27083
27207
  }
27084
- };
27085
- }, []);
27086
- return tracker.ItemTrackerProvider;
27087
- };
27088
- const useTrackItem = data => {
27089
- const tracker = useContext(ItemTrackerContext);
27090
- if (!tracker) {
27091
- throw new Error("useTrackItem must be used within SimpleItemTrackerProvider");
27092
- }
27093
- return tracker.registerItem(data);
27094
- };
27095
- const useTrackedItem = index => {
27096
- const trackedItems = useTrackedItems();
27097
- const item = trackedItems[index];
27098
- return item;
27099
- };
27100
- const useTrackedItems = () => {
27101
- const tracker = useContext(ItemTrackerContext);
27102
- if (!tracker) {
27103
- throw new Error("useTrackedItems must be used within SimpleItemTrackerProvider");
27208
+ }
27209
+ return activeValue;
27104
27210
  }
27105
- return tracker.items;
27106
- };
27107
- return [useItemTrackerProvider, useTrackItem, useTrackedItem, useTrackedItems];
27108
- };
27109
-
27110
- installImportMetaCssBuild(import.meta);const [useSuggestionItemTrackerProvider, useTrackSuggestion] = createItemTracker();
27111
-
27112
- /**
27113
- * SuggestionList + Suggestion: a composable accessible listbox.
27114
- *
27115
- * Usage:
27116
- * <SuggestionList id="my-list" value={selected} onChange={setSelected}>
27117
- * <Suggestion value="a">Option A</Suggestion>
27118
- * <Suggestion value="b">Option B</Suggestion>
27119
- * </SuggestionList>
27120
- *
27121
- * CSS vars on .navi_suggestion_list:
27122
- * --suggestion-list-border-radius, --suggestion-list-border-width, --suggestion-list-border-color, --suggestion-list-background-color, --suggestion-list-max-height
27123
- *
27124
- * CSS vars on .navi_suggestion:
27125
- * --suggestion-padding, --suggestion-color, --suggestion-background-color, --suggestion-font-weight
27126
- * --suggestion-color-hover, --suggestion-background-color-hover
27127
- * --suggestion-color-pointed, --suggestion-background-color-pointed
27128
- * --suggestion-color-selected, --suggestion-background-color-selected, --suggestion-font-weight-selected
27129
- * --suggestion-color-pointed-selected, --suggestion-background-color-pointed-selected
27130
- * --suggestion-color-highlight, --suggestion-background-color-highlight
27131
- *
27132
- * CSS vars on .navi_suggestion_group_label:
27133
- * --suggestion-group-label-padding, --suggestion-group-label-color, --suggestion-group-label-font-size, --suggestion-group-label-font-weight
27134
- */
27135
-
27136
- const css$g = /* css */`
27137
- @layer navi {
27138
- .navi_suggestion_list {
27139
- --suggestion-list-border-radius: 4px;
27140
- --suggestion-list-border-width: 1px;
27141
- --suggestion-list-border-color: light-dark(#ccc, #555);
27142
- --suggestion-list-background-color: light-dark(#fff, #1e1e1e);
27143
- --suggestion-list-max-height: 220px;
27144
- }
27145
- .navi_suggestion_group_label {
27146
- --suggestion-group-label-padding: 4px 12px 2px;
27147
- --suggestion-group-label-color: light-dark(#888, #aaa);
27148
- --suggestion-group-label-font-size: 0.75em;
27149
- --suggestion-group-label-font-weight: 600;
27150
- }
27151
- .navi_suggestion {
27152
- --suggestion-padding: 8px 12px;
27153
- --suggestion-color: inherit;
27154
- --suggestion-background-color: transparent;
27155
- --suggestion-font-weight: inherit;
27156
-
27157
- /* Hover (mouse) */
27158
- --suggestion-color-hover: var(--suggestion-color);
27159
- --suggestion-background-color-hover: light-dark(#f5f5f5, #2a2a2a);
27160
-
27161
- /* Pointed (keyboard navigation position) */
27162
- --suggestion-color-pointed: var(--suggestion-color);
27163
- --suggestion-background-color-pointed: light-dark(#e8f0fe, #1c3a6e);
27164
-
27165
- /* Selected */
27166
- --suggestion-color-selected: light-dark(#1a73e8, #7baaf7);
27167
- --suggestion-background-color-selected: light-dark(#e8f0fe, #1c3a6e);
27168
- --suggestion-font-weight-selected: 500;
27169
-
27170
- /* Highlight (CSS Highlight API match) */
27171
- --suggestion-color-highlight: inherit;
27172
- --suggestion-background-color-highlight: #ffe066;
27173
- --suggestion-color-pointed-selected: var(--suggestion-color-selected);
27174
- --suggestion-background-color-pointed-selected: light-dark(
27175
- #d2e3fc,
27176
- #174ea6
27177
- );
27178
- }
27179
- }
27180
-
27181
- .navi_suggestion_list {
27182
- --x-border-radius: var(--suggestion-list-border-radius);
27183
- --x-border-width: var(--suggestion-list-border-width);
27184
- --x-border-color: var(--suggestion-list-border-color);
27185
- --x-background-color: var(--suggestion-list-background-color);
27186
- box-sizing: border-box;
27187
- max-height: var(--suggestion-list-max-height);
27188
-
27189
- margin: 0;
27190
- padding: 0;
27191
- list-style: none;
27192
- background-color: var(--x-background-color);
27193
- border: var(--x-border-width) solid var(--x-border-color);
27194
- border-radius: var(--x-border-radius);
27195
- outline: none;
27196
- overflow-y: auto;
27197
-
27198
- /* Popover reset — browser adds border, background, padding, margin by default */
27199
- &[popover] {
27200
- position: fixed;
27201
- inset: unset;
27202
- margin: 0;
27203
- padding: 0;
27204
- border: none;
27205
- }
27206
- }
27207
- ::highlight(navi-suggestion-match) {
27208
- color: var(--suggestion-color-highlight);
27209
- background-color: var(--suggestion-background-color-highlight);
27210
- }
27211
- .navi_suggestion {
27212
- --x-color: var(--suggestion-color);
27213
- --x-background-color: var(--suggestion-background-color);
27214
- --x-font-weight: var(--suggestion-font-weight);
27215
-
27216
- padding: var(--suggestion-padding);
27217
- color: var(--x-color);
27218
- font-weight: var(--x-font-weight);
27219
- background-color: var(--x-background-color);
27220
- cursor: pointer;
27221
- user-select: none;
27222
-
27223
- &:hover {
27224
- --x-color: var(--suggestion-color-hover);
27225
- --x-background-color: var(--suggestion-background-color-hover);
27226
- }
27227
-
27228
- &[data-pointed] {
27229
- --x-color: var(--suggestion-color-pointed);
27230
- --x-background-color: var(--suggestion-background-color-pointed);
27231
- }
27232
-
27233
- &[data-selected] {
27234
- --x-color: var(--suggestion-color-selected);
27235
- --x-background-color: var(--suggestion-background-color-selected);
27236
- --x-font-weight: var(--suggestion-font-weight-selected);
27237
- }
27238
-
27239
- &[data-pointed][data-selected] {
27240
- --x-color: var(--suggestion-color-pointed-selected);
27241
- --x-background-color: var(--suggestion-background-color-pointed-selected);
27242
- }
27243
- }
27244
- .navi_suggestion_group_label {
27245
- position: sticky;
27246
- top: 0;
27247
- z-index: 1;
27248
- display: block;
27249
- padding: var(--suggestion-group-label-padding);
27250
- color: var(--suggestion-group-label-color);
27251
- font-weight: var(--suggestion-group-label-font-weight);
27252
- font-size: var(--suggestion-group-label-font-size);
27253
- text-transform: uppercase;
27254
- letter-spacing: 0.05em;
27255
- background-color: var(--suggestion-group-label-background-color);
27256
- user-select: none;
27257
- }
27258
- `;
27259
- const SuggestionListStyleCSSVars = {
27260
- borderRadius: "--suggestion-list-border-radius",
27261
- borderWidth: "--suggestion-list-border-width",
27262
- borderColor: "--suggestion-list-border-color",
27263
- backgroundColor: "--suggestion-list-background-color",
27264
- maxHeight: "--suggestion-list-max-height"
27265
- };
27266
- const SuggestionStyleCSSVars = {
27267
- "padding": "--suggestion-padding",
27268
- "color": "--suggestion-color",
27269
- "backgroundColor": "--suggestion-background-color",
27270
- "fontWeight": "--suggestion-font-weight",
27271
- ":-navi-pointed": {
27272
- color: "--suggestion-color-pointed",
27273
- backgroundColor: "--suggestion-background-color-pointed"
27274
- },
27275
- ":hover": {
27276
- color: "--suggestion-color-hover",
27277
- backgroundColor: "--suggestion-background-color-hover"
27278
- },
27279
- ":-navi-selected": {
27280
- color: "--suggestion-color-selected",
27281
- backgroundColor: "--suggestion-background-color-selected",
27282
- fontWeight: "--suggestion-font-weight-selected"
27283
- },
27284
- "::highlight": {
27285
- color: "--suggestion-color-highlight",
27286
- backgroundColor: "--suggestion-background-color-highlight"
27287
- }
27288
- };
27289
-
27290
- /**
27291
- * Context OptionList provides downward to its Option children.
27292
- */
27293
- const SuggestionListContext = createContext(null);
27294
- const SuggestionList = ({
27295
- popover,
27296
- onChange: onChangeProp,
27297
- highlight,
27298
- children,
27299
- ...rest
27300
- }) => {
27301
- import.meta.css = [css$g, "@jsenv/navi/src/field/suggestion_list.jsx"];
27302
- const ItemTrackerProvider = useSuggestionItemTrackerProvider();
27303
- const [pointedValue, setPointedValue] = useState(null);
27304
- const pointedValueRef = useRef(null);
27305
- pointedValueRef.current = pointedValue;
27306
- const ownId = useId();
27307
- const id = rest.id ?? ownId;
27308
- const defaultRef = useRef(null);
27309
- const ref = rest.ref || defaultRef;
27310
- useLayoutEffect(() => {
27311
- if (!CSS.highlights) {
27312
- return undefined;
27313
- }
27314
- if (!highlight) {
27315
- CSS.highlights.delete("navi-suggestion-match");
27316
- return undefined;
27317
- }
27318
- const listEl = ref.current;
27319
- if (!listEl) {
27320
- return undefined;
27321
- }
27322
- const ranges = [];
27323
- const lowerHighlight = highlight.toLowerCase();
27324
- for (const suggestionEl of listEl.querySelectorAll("[role='option']")) {
27325
- const walker = document.createTreeWalker(suggestionEl, NodeFilter.SHOW_TEXT);
27326
- let node;
27327
- while (node = walker.nextNode()) {
27328
- const text = node.textContent;
27329
- const lowerText = text.toLowerCase();
27330
- let index = lowerText.indexOf(lowerHighlight);
27331
- while (index !== -1) {
27332
- const range = new Range();
27333
- range.setStart(node, index);
27334
- range.setEnd(node, index + highlight.length);
27335
- ranges.push(range);
27336
- index = lowerText.indexOf(lowerHighlight, index + 1);
27337
- }
27338
- }
27339
- }
27340
- if (ranges.length === 0) {
27341
- CSS.highlights.delete("navi-suggestion-match");
27342
- } else {
27343
- CSS.highlights.set("navi-suggestion-match", new Highlight(...ranges));
27344
- }
27345
- return () => {
27346
- CSS.highlights.delete("navi-suggestion-match");
27347
- };
27348
- }, [highlight, children]);
27349
- const effectiveOnChange = popover ? value => {
27350
- onChangeProp?.(value);
27351
- ref.current?.dispatchEvent(new CustomEvent("navi_suggestion_list_selected", {
27352
- detail: {
27353
- value
27354
- },
27355
- bubbles: true
27356
- }));
27357
- } : onChangeProp;
27358
- const onChangeRef = useRef(effectiveOnChange);
27359
- onChangeRef.current = effectiveOnChange;
27360
- const navigate = direction => {
27361
- const values = ItemTrackerProvider.items.filter(item => !item.hidden).map(item => item.value);
27362
- if (values.length === 0) {
27363
- return false;
27364
- }
27365
- const current = pointedValueRef.current;
27366
- if (direction === "down") {
27367
- const idx = current === null ? -1 : values.indexOf(current);
27368
- setPointedValue(values[idx < values.length - 1 ? idx + 1 : idx]);
27369
- } else if (direction === "up") {
27370
- const idx = current === null ? -1 : values.indexOf(current);
27371
- setPointedValue(values[idx > 0 ? idx - 1 : 0]);
27372
- } else if (direction === "first") {
27373
- setPointedValue(values[0]);
27374
- } else if (direction === "last") {
27375
- setPointedValue(values[values.length - 1]);
27376
- }
27377
- return true;
27378
- };
27379
-
27380
- // Listen for commands dispatched by a linked Input (combobox mode)
27381
- const noopRef = useRef(null);
27382
- useEffect(() => {
27383
- if (!popover || !ref.current) {
27384
- return undefined;
27385
- }
27386
- const el = ref.current;
27387
- const onNavigate = e => {
27388
- navigate(e.detail.direction);
27389
- };
27390
- const onConfirm = e => {
27391
- const current = pointedValueRef.current;
27392
- if (current !== null) {
27393
- onChangeRef.current?.(current);
27394
- e.preventDefault();
27395
- }
27396
- };
27397
- const onClear = () => {
27398
- setPointedValue(null);
27399
- };
27400
- el.addEventListener("navi_suggestion_list_navigate", onNavigate);
27401
- el.addEventListener("navi_suggestion_list_confirm", onConfirm);
27402
- el.addEventListener("navi_suggestion_list_clear", onClear);
27403
- return () => {
27404
- el.removeEventListener("navi_suggestion_list_navigate", onNavigate);
27405
- el.removeEventListener("navi_suggestion_list_confirm", onConfirm);
27406
- el.removeEventListener("navi_suggestion_list_clear", onClear);
27407
- };
27408
- }, [popover]);
27409
- useKeyboardShortcuts(popover ? noopRef : ref, [{
27410
- key: "arrowdown",
27411
- description: "Point to next suggestion",
27412
- handler: () => navigate("down")
27413
- }, {
27414
- key: "arrowup",
27415
- description: "Point to previous suggestion",
27416
- handler: () => navigate("up")
27417
- }, {
27418
- key: "home",
27419
- description: "Point to first suggestion",
27420
- handler: () => navigate("first")
27421
- }, {
27422
- key: "end",
27423
- description: "Point to last suggestion",
27424
- handler: () => navigate("last")
27425
- }, {
27426
- key: "enter",
27427
- description: "Confirm pointed suggestion",
27428
- handler: () => {
27429
- const current = pointedValueRef.current;
27430
- if (current === null) {
27431
- return false;
27432
- }
27433
- onChangeRef.current?.(current);
27434
- return true;
27435
- }
27436
- }, {
27437
- key: "escape",
27438
- description: "Clear pointed suggestion",
27439
- handler: () => {
27440
- setPointedValue(null);
27441
- return true;
27442
- }
27443
- }]);
27444
- const suggestionListContext = {
27445
- pointedValue,
27446
- setPointedValue,
27447
- onSelect: effectiveOnChange
27448
- };
27449
- return jsx(Box, {
27450
- as: "ul",
27451
- ref: ref,
27452
- id: id,
27453
- role: "listbox",
27454
- tabIndex: popover ? -1 : 0,
27455
- popover: popover ? "manual" : undefined,
27456
- ...rest,
27457
- baseClassName: "navi_suggestion_list",
27458
- styleCSSVars: SuggestionListStyleCSSVars,
27459
- children: jsx(SuggestionListContext.Provider, {
27460
- value: suggestionListContext,
27461
- children: jsx(ItemTrackerProvider, {
27462
- children: children
27463
- })
27464
- })
27465
- });
27466
- };
27467
- const SUGGESTION_PSEUDO_CLASSES = [":-navi-pointed", ":-navi-selected"];
27468
- const SUGGESTION_PSEUDO_ELEMENTS = ["::highlight"];
27469
- const Suggestion = ({
27470
- value,
27471
- selected,
27472
- hidden,
27473
- children,
27474
- ...rest
27475
- }) => {
27476
- import.meta.css = [css$g, "@jsenv/navi/src/field/suggestion_list.jsx"];
27477
- const suggestionId = useId();
27478
- const id = rest.id || suggestionId;
27479
- useTrackSuggestion({
27480
- value,
27481
- suggestionId: id,
27482
- hidden
27483
- });
27484
- const {
27485
- pointedValue,
27486
- setPointedValue,
27487
- onSelect
27488
- } = useContext(SuggestionListContext);
27489
- const isPointed = pointedValue === value;
27490
- const suggestionRef = useRef(null);
27491
- useEffect(() => {
27492
- const suggestionEl = suggestionRef.current;
27493
- if (isPointed && suggestionEl) {
27494
- suggestionEl.scrollIntoView({
27495
- block: "nearest"
27496
- });
27497
- }
27498
- }, [isPointed]);
27499
- return jsx(Box, {
27500
- as: "li",
27501
- ref: suggestionRef,
27502
- baseClassName: "navi_suggestion",
27503
- id: suggestionId,
27504
- role: "option",
27505
- "aria-selected": selected,
27506
- "aria-hidden": hidden ? true : undefined,
27507
- hidden: hidden,
27508
- basePseudoState: {
27509
- ":-navi-pointed": isPointed,
27510
- ":-navi-selected": selected
27511
- },
27512
- pseudoClasses: SUGGESTION_PSEUDO_CLASSES,
27513
- pseudoElements: SUGGESTION_PSEUDO_ELEMENTS,
27514
- styleCSSVars: SuggestionStyleCSSVars,
27515
- onMouseEnter: () => {
27516
- if (!hidden) {
27517
- setPointedValue(value);
27518
- }
27519
- },
27520
- onMouseLeave: () => {
27521
- if (!hidden) {
27522
- setPointedValue(null);
27523
- }
27524
- },
27525
- onMouseDown: e => {
27526
- if (hidden || e.button !== 0) {
27527
- return;
27528
- }
27529
- onSelect?.(value);
27530
- },
27531
- ...rest,
27532
- children: children
27533
- });
27534
- };
27535
- const SuggestionGroup = ({
27536
- label,
27537
- children,
27538
- ...rest
27539
- }) => {
27540
- import.meta.css = [css$g, "@jsenv/navi/src/field/suggestion_list.jsx"];
27541
- const groupId = useId();
27542
- return jsxs("li", {
27543
- role: "presentation",
27544
- ...rest,
27545
- children: [jsx("span", {
27546
- id: groupId,
27547
- role: "presentation",
27548
- "aria-hidden": "true",
27549
- style: {
27550
- display: "contents"
27551
- },
27552
- children: typeof label === "string" ? jsx("span", {
27553
- className: "navi_suggestion_group_label",
27554
- children: label
27555
- }) : label
27556
- }), jsx("ul", {
27557
- role: "group",
27558
- "aria-labelledby": groupId,
27559
- style: {
27560
- margin: 0,
27561
- padding: 0,
27562
- listStyle: "none"
27563
- },
27564
- children: children
27565
- })]
27566
- });
27567
- };
27568
-
27569
- const RadioList = props => {
27570
- const uiStateController = useUIGroupStateController(props, "radio_list", {
27571
- childComponentType: "radio",
27572
- aggregateChildStates: childUIStateControllers => {
27573
- let activeValue;
27574
- for (const childUIStateController of childUIStateControllers) {
27575
- if (childUIStateController.uiState) {
27576
- activeValue = childUIStateController.uiState;
27577
- break;
27578
- }
27579
- }
27580
- return activeValue;
27581
- }
27582
- });
27583
- const uiState = useUIState(uiStateController);
27584
- const radioList = renderActionableComponent(props, {
27585
- Basic: RadioListBasic,
27586
- WithAction: RadioListWithAction
27587
- });
27588
- return jsx(UIStateControllerContext.Provider, {
27589
- value: uiStateController,
27590
- children: jsx(UIStateContext.Provider, {
27591
- value: uiState,
27592
- children: radioList
27593
- })
27594
- });
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
+ });
27595
27224
  };
27596
27225
  const Radio = InputRadio;
27597
27226
  const RadioListBasic = props => {
@@ -27640,12 +27269,208 @@ const RadioListBasic = props => {
27640
27269
  })
27641
27270
  })
27642
27271
  });
27643
- };
27644
- const RadioListWithAction = props => {
27645
- const uiStateController = useContext(UIStateControllerContext);
27646
- 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) => {
27647
27467
  const {
27468
+ id,
27469
+ name,
27470
+ value: externalValue,
27471
+ valueSignal,
27648
27472
  action,
27473
+ children,
27649
27474
  onCancel,
27650
27475
  onActionPrevented,
27651
27476
  onActionStart,
@@ -27653,272 +27478,693 @@ const RadioListWithAction = props => {
27653
27478
  onActionError,
27654
27479
  onActionEnd,
27655
27480
  actionErrorEffect,
27656
- loading,
27657
- children,
27658
27481
  ...rest
27659
27482
  } = props;
27660
27483
  const innerRef = useRef();
27661
- 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);
27662
27487
  const {
27663
27488
  loading: actionLoading
27664
27489
  } = useActionStatus(boundAction);
27665
27490
  const executeAction = useExecuteAction(innerRef, {
27666
27491
  errorEffect: actionErrorEffect
27667
27492
  });
27668
- const [actionRequester, setActionRequester] = useState(null);
27493
+ useEffect(() => {
27494
+ setNavState(value);
27495
+ }, [value]);
27496
+ const actionRequesterRef = useRef(null);
27669
27497
  useActionEvents(innerRef, {
27670
27498
  onCancel: (e, reason) => {
27671
- uiStateController.resetUIState(e);
27499
+ resetNavState();
27500
+ setValue(initialValue);
27672
27501
  onCancel?.(e, reason);
27673
27502
  },
27674
27503
  onPrevented: onActionPrevented,
27675
27504
  onAction: actionEvent => {
27676
- setActionRequester(actionEvent.detail.requester);
27505
+ actionRequesterRef.current = actionEvent.detail.requester;
27677
27506
  executeAction(actionEvent);
27678
27507
  },
27679
27508
  onStart: onActionStart,
27680
27509
  onAbort: e => {
27681
- uiStateController.resetUIState(e);
27510
+ setValue(initialValue);
27682
27511
  onActionAbort?.(e);
27683
27512
  },
27684
- onError: e => {
27685
- uiStateController.resetUIState(e);
27686
- onActionError?.(e);
27513
+ onError: error => {
27514
+ setValue(initialValue);
27515
+ onActionError?.(error);
27687
27516
  },
27688
- onEnd: e => {
27689
- onActionEnd?.(e);
27517
+ onEnd: () => {
27518
+ resetNavState();
27519
+ onActionEnd?.();
27690
27520
  }
27691
27521
  });
27692
- return jsx(RadioListBasic, {
27693
- "data-action": boundAction,
27694
- ...rest,
27522
+ const childRefArray = useRefArray(children, child => child.value);
27523
+ return jsx(SelectControlled, {
27695
27524
  ref: innerRef,
27696
- onChange: e => {
27697
- 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);
27698
27532
  const radioListContainer = innerRef.current;
27533
+ const optionSelected = select.querySelector(`option[value="${selectedValue}"]`);
27699
27534
  requestAction(radioListContainer, boundAction, {
27700
- event: e,
27701
- requester: radio,
27702
- actionOrigin: "action_prop"
27535
+ event,
27536
+ requester: optionSelected
27703
27537
  });
27704
27538
  },
27705
- loading: loading || actionLoading,
27706
- children: jsx(LoadingElementContext.Provider, {
27707
- value: actionRequester,
27708
- 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
+ };
27709
27548
  })
27710
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];
27711
27612
  };
27712
27613
 
27713
- const useRefArray = (items, keyFromItem) => {
27714
- const refMapRef = useRef(new Map());
27715
- const previousKeySetRef = useRef(new Set());
27614
+ installImportMetaCssBuild(import.meta);const [useSuggestionItemTrackerProvider, useTrackSuggestion] = createItemTracker();
27716
27615
 
27717
- return useMemo(() => {
27718
- const refMap = refMapRef.current;
27719
- const previousKeySet = previousKeySetRef.current;
27720
- const currentKeySet = new Set();
27721
- 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;
27722
27739
 
27723
- for (let i = 0; i < items.length; i++) {
27724
- const item = items[i];
27725
- const key = keyFromItem(item);
27726
- currentKeySet.add(key);
27740
+ &:hover {
27741
+ --x-color: var(--suggestion-color-hover);
27742
+ --x-background-color: var(--suggestion-background-color-hover);
27743
+ }
27727
27744
 
27728
- const refForKey = refMap.get(key);
27729
- if (refForKey) {
27730
- refArray[i] = refForKey;
27731
- } else {
27732
- const newRef = createRef();
27733
- refMap.set(key, newRef);
27734
- refArray[i] = newRef;
27735
- }
27745
+ &[data-pointed] {
27746
+ --x-color: var(--suggestion-color-pointed);
27747
+ --x-background-color: var(--suggestion-background-color-pointed);
27736
27748
  }
27737
27749
 
27738
- for (const key of previousKeySet) {
27739
- if (!currentKeySet.has(key)) {
27740
- refMap.delete(key);
27741
- }
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);
27742
27754
  }
27743
- previousKeySetRef.current = currentKeySet;
27744
27755
 
27745
- return refArray;
27746
- }, [items]);
27747
- };
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;
27748
27771
 
27749
- installImportMetaCssBuild(import.meta);const useNavState = () => {};
27750
- const css$f = /* css */`
27751
- .navi_select[data-readonly] {
27752
- 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
+ }
27753
27794
  }
27754
27795
  `;
27755
- const Select = forwardRef((props, ref) => {
27756
- import.meta.css = [css$f, "@jsenv/navi/src/field/select.jsx"];
27757
- const select = renderActionableComponent(props, ref);
27758
- return select;
27759
- });
27760
- const SelectControlled = forwardRef((props, ref) => {
27761
- const {
27762
- name,
27763
- value,
27764
- loading,
27765
- disabled,
27766
- readOnly,
27767
- children,
27768
- ...rest
27769
- } = props;
27770
- const innerRef = useRef();
27771
- useImperativeHandle(ref, () => innerRef.current);
27772
- const selectElement = jsx("select", {
27773
- className: "navi_select",
27774
- ref: innerRef,
27775
- "data-readonly": readOnly && !disabled ? "" : undefined,
27776
- onKeyDown: e => {
27777
- 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);
27778
27980
  e.preventDefault();
27779
27981
  }
27780
- },
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,
27781
28047
  ...rest,
27782
- children: children.map(child => {
27783
- const {
27784
- label,
27785
- readOnly: childReadOnly,
27786
- disabled: childDisabled,
27787
- loading: childLoading,
27788
- value: childValue,
27789
- ...childRest
27790
- } = child;
27791
- return jsx("option", {
27792
- name: name,
27793
- value: childValue,
27794
- selected: childValue === value,
27795
- readOnly: readOnly || childReadOnly,
27796
- disabled: disabled || childDisabled,
27797
- loading: loading || childLoading,
27798
- ...childRest,
27799
- children: label
27800
- }, 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
+ })
27801
28063
  })
27802
28064
  });
27803
- return jsx(LoaderBackground, {
27804
- loading: loading,
27805
- color: "light-dark(#355fcc, #3b82f6)",
27806
- inset: -1,
27807
- children: selectElement
27808
- });
27809
- });
27810
- forwardRef((props, ref) => {
27811
- const {
27812
- value: initialValue,
27813
- id,
27814
- children,
27815
- ...rest
27816
- } = props;
27817
- const innerRef = useRef();
27818
- useImperativeHandle(ref, () => innerRef.current);
27819
- const [navState, setNavState] = useNavState();
27820
- const valueAtStart = navState === undefined ? initialValue : navState;
27821
- const [value, setValue] = useState(valueAtStart);
27822
- useEffect(() => {
27823
- setNavState(value);
27824
- }, [value]);
27825
- return jsx(SelectControlled, {
27826
- ref: innerRef,
27827
- value: value,
27828
- onChange: event => {
27829
- const select = event.target;
27830
- const selectedValue = select.value;
27831
- setValue(selectedValue);
27832
- },
27833
- ...rest,
27834
- 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
27835
28082
  });
27836
- });
27837
- forwardRef((props, ref) => {
27838
- const {
27839
- id,
27840
- name,
27841
- value: externalValue,
27842
- valueSignal,
27843
- action,
27844
- children,
27845
- onCancel,
27846
- onActionPrevented,
27847
- onActionStart,
27848
- onActionAbort,
27849
- onActionError,
27850
- onActionEnd,
27851
- actionErrorEffect,
27852
- ...rest
27853
- } = props;
27854
- const innerRef = useRef();
27855
- useImperativeHandle(ref, () => innerRef.current);
27856
- const [navState, setNavState, resetNavState] = useNavState();
27857
- const [boundAction, value, setValue, initialValue] = useActionBoundToOneParam(action, name);
27858
28083
  const {
27859
- loading: actionLoading
27860
- } = useActionStatus(boundAction);
27861
- const executeAction = useExecuteAction(innerRef, {
27862
- errorEffect: actionErrorEffect
27863
- });
28084
+ pointedValue,
28085
+ setPointedValue,
28086
+ onSelect
28087
+ } = useContext(SuggestionListContext);
28088
+ const isPointed = pointedValue === value;
28089
+ const suggestionRef = useRef(null);
27864
28090
  useEffect(() => {
27865
- setNavState(value);
27866
- }, [value]);
27867
- const actionRequesterRef = useRef(null);
27868
- useActionEvents(innerRef, {
27869
- onCancel: (e, reason) => {
27870
- resetNavState();
27871
- setValue(initialValue);
27872
- 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
27873
28110
  },
27874
- onPrevented: onActionPrevented,
27875
- onAction: actionEvent => {
27876
- actionRequesterRef.current = actionEvent.detail.requester;
27877
- executeAction(actionEvent);
28111
+ pseudoClasses: SUGGESTION_PSEUDO_CLASSES,
28112
+ pseudoElements: SUGGESTION_PSEUDO_ELEMENTS,
28113
+ styleCSSVars: SuggestionStyleCSSVars,
28114
+ onMouseEnter: () => {
28115
+ if (!hidden) {
28116
+ setPointedValue(value);
28117
+ }
27878
28118
  },
27879
- onStart: onActionStart,
27880
- onAbort: e => {
27881
- setValue(initialValue);
27882
- onActionAbort?.(e);
28119
+ onMouseLeave: () => {
28120
+ if (!hidden) {
28121
+ setPointedValue(null);
28122
+ }
27883
28123
  },
27884
- onError: error => {
27885
- setValue(initialValue);
27886
- onActionError?.(error);
28124
+ onMouseDown: e => {
28125
+ if (hidden || e.button !== 0) {
28126
+ return;
28127
+ }
28128
+ onSelect?.(value);
27887
28129
  },
27888
- onEnd: () => {
27889
- resetNavState();
27890
- onActionEnd?.();
27891
- }
28130
+ ...rest,
28131
+ children: children
27892
28132
  });
27893
- const childRefArray = useRefArray(children, child => child.value);
27894
- return jsx(SelectControlled, {
27895
- ref: innerRef,
27896
- name: name,
27897
- value: value,
27898
- "data-action": boundAction,
27899
- onChange: event => {
27900
- const select = event.target;
27901
- const selectedValue = select.value;
27902
- setValue(selectedValue);
27903
- const radioListContainer = innerRef.current;
27904
- const optionSelected = select.querySelector(`option[value="${selectedValue}"]`);
27905
- requestAction(radioListContainer, boundAction, {
27906
- event,
27907
- requester: optionSelected
27908
- });
27909
- },
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",
27910
28143
  ...rest,
27911
- children: children.map((child, i) => {
27912
- const childRef = childRefArray[i];
27913
- return {
27914
- ...child,
27915
- ref: childRef,
27916
- loading: child.loading || actionLoading && actionRequesterRef.current === childRef.current,
27917
- readOnly: child.readOnly || actionLoading
27918
- };
27919
- })
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
+ })]
27920
28166
  });
27921
- });
28167
+ };
27922
28168
 
27923
28169
  const TableSelectionContext = createContext();
27924
28170
  const useTableSelectionContextValue = (