@jsenv/navi 0.23.9 → 0.24.0

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.
@@ -1,5 +1,5 @@
1
1
  import { installImportMetaCssBuild } from "./jsenv_navi_side_effects.js";
2
- import { isValidElement, h, createContext, toChildArray, render, createRef, cloneElement } from "preact";
2
+ import { isValidElement, h, createContext, options, toChildArray, render, createRef, cloneElement } from "preact";
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";
@@ -7515,6 +7515,111 @@ const updateStyle = (element, style, preventInitialTransition) => {
7515
7515
  styleKeySetWeakMap.set(element, styleKeySet);
7516
7516
  };
7517
7517
 
7518
+ // Implementation notes:
7519
+ //
7520
+ // options.__r fires before each component render — we capture the current
7521
+ // component instance (vnode.__c) so useEarlyDOMEffect can register itself.
7522
+ //
7523
+ // options.__c (commitRoot) fires after refs are assigned and before any
7524
+ // useLayoutEffect runs. We flush all pending effects there.
7525
+ // The DOM node is read from component.__v.__e (vnode → root DOM node),
7526
+ // which Preact sets during diffing, before options.__c fires.
7527
+ //
7528
+ // stateMap (WeakMap) stores { cleanup, deps } per component instance.
7529
+ // It's auto-GC'd when a component is destroyed; options.unmount also
7530
+ // deletes entries eagerly to release cleanup functions sooner.
7531
+ //
7532
+ // pendingMap (Map) holds effects registered during the current render pass.
7533
+ // It is always fully cleared in options.__c — bounded to one commit, no leak.
7534
+
7535
+ /**
7536
+ * Like useLayoutEffect, but runs before any layout effect in the commit —
7537
+ * including those of descendant components.
7538
+ *
7539
+ * Use this when a parent needs to mutate the DOM (e.g. apply styles) so that
7540
+ * children can read those mutations in their own useLayoutEffect.
7541
+ *
7542
+ * The DOM node of the component is passed as the first argument to fn.
7543
+ * The effect is skipped if no DOM node is found (e.g. on a fragment root).
7544
+ *
7545
+ * Supports deps and cleanup return, same as useLayoutEffect.
7546
+ */
7547
+ const useEarlyDOMEffect = (fn, deps) => {
7548
+ const component = _currentComponent;
7549
+ if (component) {
7550
+ pendingMap.set(component, { fn, deps });
7551
+ }
7552
+ };
7553
+
7554
+ // Populated during render, consumed + cleared in options.__c each commit.
7555
+ const pendingMap = new Map(); // component → { fn, deps, ref }
7556
+
7557
+ // Persists across commits. WeakMap → no leak when component is destroyed.
7558
+ const stateMap = new WeakMap(); // component → { cleanup, deps }
7559
+
7560
+ let _currentComponent = null;
7561
+ const _prevBeforeRender = options.__r;
7562
+ options.__r = (vnode) => {
7563
+ _currentComponent = vnode.__c;
7564
+ if (_prevBeforeRender) {
7565
+ _prevBeforeRender(vnode);
7566
+ }
7567
+ };
7568
+
7569
+ const _prevCommit = options.__c;
7570
+ options.__c = (root, commitQueue) => {
7571
+ for (const [component, { fn, deps }] of pendingMap) {
7572
+ // component.__v is the component's vnode; __e is its root DOM node.
7573
+ // Both are set during diff, before options.__c fires.
7574
+ const element = component.__v && component.__v.__e;
7575
+ if (!element) {
7576
+ continue;
7577
+ }
7578
+ const prev = stateMap.get(component);
7579
+ const prevDeps = prev ? prev.deps : undefined;
7580
+ let depsChanged;
7581
+ if (!prevDeps || !deps || prevDeps.length !== deps.length) {
7582
+ depsChanged = true;
7583
+ } else {
7584
+ for (let i = 0; i < deps.length; i++) {
7585
+ if (!Object.is(deps[i], prevDeps[i])) {
7586
+ depsChanged = true;
7587
+ break;
7588
+ }
7589
+ }
7590
+ }
7591
+ if (depsChanged) {
7592
+ if (prev && prev.cleanup) {
7593
+ prev.cleanup();
7594
+ }
7595
+ const result = fn(element);
7596
+ const cleanup = typeof result === "function" ? result : undefined;
7597
+ stateMap.set(component, { cleanup, deps });
7598
+ }
7599
+ }
7600
+ pendingMap.clear();
7601
+ if (_prevCommit) {
7602
+ _prevCommit(root, commitQueue);
7603
+ }
7604
+ };
7605
+
7606
+ const _prevUnmount = options.unmount;
7607
+ options.unmount = (vnode) => {
7608
+ const component = vnode.__c;
7609
+ if (component) {
7610
+ const state = stateMap.get(component);
7611
+ if (state && state.cleanup) {
7612
+ state.cleanup();
7613
+ }
7614
+ // stateMap is a WeakMap so the entry is GC'd automatically,
7615
+ // but deleting explicitly releases the cleanup fn sooner.
7616
+ stateMap.delete(component);
7617
+ }
7618
+ if (_prevUnmount) {
7619
+ _prevUnmount(vnode);
7620
+ }
7621
+ };
7622
+
7518
7623
  installImportMetaCssBuild(import.meta);/**
7519
7624
  * Box - A Swiss Army Knife for Layout
7520
7625
  *
@@ -7539,6 +7644,33 @@ installImportMetaCssBuild(import.meta);/**
7539
7644
  * ## Spacing & Sizing
7540
7645
  *
7541
7646
  * Props for margin, padding, gap, width, height, expand, shrink, and more.
7647
+ *
7648
+ * ## Pseudo-class Styles
7649
+ *
7650
+ * The `style` prop supports pseudo-class keys alongside regular CSS properties.
7651
+ * This lets you express hover, focus, and custom interaction states in one object,
7652
+ * without writing CSS or adding class names:
7653
+ *
7654
+ * ```jsx
7655
+ * <Box
7656
+ * style={{
7657
+ * backgroundColor: "blue",
7658
+ * ":-navi:pressed": {
7659
+ * backgroundColor: "darkblue",
7660
+ * },
7661
+ * ":hover": {
7662
+ * backgroundColor: "lightblue",
7663
+ * },
7664
+ * }}
7665
+ * />
7666
+ * ```
7667
+ *
7668
+ * Styles are applied directly to the DOM (not via Preact's style prop) for two reasons:
7669
+ * 1. **Pseudo-class support**: reacting to `:hover`, `:focus`, or custom states like
7670
+ * `:-navi:pressed` without re-rendering the component on every pseudo state change.
7671
+ * 2. **Correct initial render**: pseudo-class state must be read from the DOM node at
7672
+ * mount time. Preact's style prop runs before the DOM exists, so the right initial
7673
+ * style can only be determined once the node is available.
7542
7674
  */
7543
7675
  import.meta.css = [/* css */`
7544
7676
  [navi-box-flow="inline"] {
@@ -7930,42 +8062,36 @@ const Box = props => {
7930
8062
  visitProp(styleValue, styleName, styleContext, boxStyles, "style");
7931
8063
  }
7932
8064
  }
7933
- const updateStyle = useCallback(state => {
7934
- const boxEl = ref.current;
7935
- applyStyle(boxEl, boxStyles, state, boxPseudoNamedStyles, preventInitialTransition);
7936
- }, styleDeps);
7937
- const finalStyleDeps = [pseudoStateSelector, innerPseudoState, updateStyle];
8065
+ styleDeps.push(pseudoStateSelector, innerPseudoState);
7938
8066
  let innerPseudoClasses;
7939
8067
  if (pseudoClassesFromStyleSet.size) {
7940
8068
  innerPseudoClasses = [...pseudoClasses];
7941
8069
  if (pseudoClasses !== PSEUDO_CLASSES_DEFAULT) {
7942
- finalStyleDeps.push(...pseudoClasses);
8070
+ styleDeps.push(...pseudoClasses);
7943
8071
  }
7944
8072
  for (const key of pseudoClassesFromStyleSet) {
7945
8073
  innerPseudoClasses.push(key);
7946
- finalStyleDeps.push(key);
8074
+ styleDeps.push(key);
7947
8075
  }
7948
8076
  } else {
7949
8077
  innerPseudoClasses = pseudoClasses;
7950
8078
  if (pseudoClasses !== PSEUDO_CLASSES_DEFAULT) {
7951
- finalStyleDeps.push(...pseudoClasses);
8079
+ styleDeps.push(...pseudoClasses);
7952
8080
  }
7953
8081
  }
7954
- useLayoutEffect(() => {
7955
- const boxEl = ref.current;
7956
- if (!boxEl) {
7957
- return null;
7958
- }
8082
+ useEarlyDOMEffect(boxEl => {
7959
8083
  const pseudoStateEl = pseudoStateSelector ? boxEl.querySelector(pseudoStateSelector) : boxEl;
7960
8084
  const visualEl = visualSelector ? boxEl.querySelector(visualSelector) : null;
7961
8085
  return initPseudoStyles(pseudoStateEl, {
7962
8086
  pseudoClasses: innerPseudoClasses,
7963
8087
  pseudoState: innerPseudoState,
7964
- effect: updateStyle,
8088
+ effect: state => {
8089
+ applyStyle(boxEl, boxStyles, state, boxPseudoNamedStyles, preventInitialTransition);
8090
+ },
7965
8091
  elementToImpact: boxEl,
7966
8092
  elementListeningPseudoState: visualEl === pseudoStateEl ? null : visualEl
7967
8093
  });
7968
- }, finalStyleDeps);
8094
+ }, styleDeps);
7969
8095
  }
7970
8096
 
7971
8097
  // When hasChildFunction is used it means
@@ -13884,7 +14010,7 @@ const RouteActive = ({
13884
14010
  const routeAction = (
13885
14011
  route,
13886
14012
  action,
13887
- paramsEffect = () => route.paramsSignal.value,
14013
+ paramsEffect = () => true,
13888
14014
  options = {},
13889
14015
  ) => {
13890
14016
  const actionBoundToRoute = actionRunEffect(
@@ -20955,6 +21081,109 @@ const Icon = ({
20955
21081
  });
20956
21082
  };
20957
21083
 
21084
+ /**
21085
+ * Toggles a `data-dark-background` attribute on the referenced element based on its
21086
+ * computed background color. Pair it with a CSS variable to get automatic
21087
+ * light/dark text without hard-coding colors:
21088
+ *
21089
+ * ```css
21090
+ * .my-element {
21091
+ * --color-contrasting: black;
21092
+ * &[data-dark-background] {
21093
+ * --color-contrasting: white;
21094
+ * }
21095
+ * color: var(--color-contrasting);
21096
+ * }
21097
+ * ```
21098
+ *
21099
+ * - `data-dark-background` is **set** when the background is dark enough that white text
21100
+ * provides better (or equal) contrast.
21101
+ * - `data-dark-background` is **absent** when black text is the better choice.
21102
+ *
21103
+ * @param {import("preact").RefObject} ref - Ref to the element that receives
21104
+ * the `data-dark-background` attribute and is also passed to `contrastColor` for
21105
+ * resolving CSS variables.
21106
+ * @param {object} [options]
21107
+ * @param {string} [options.backgroundElementSelector] - CSS selector relative
21108
+ * to `ref.current` pointing to a child element whose `background-color`
21109
+ * should be tested instead of the element itself. Useful when the element
21110
+ * has a transparent background but contains a coloured child (e.g. a fill
21111
+ * bar inside a track).
21112
+ */
21113
+
21114
+ const useDarkBackgroundAttribute = (
21115
+ ref,
21116
+ deps = [],
21117
+ {
21118
+ backgroundElementSelector,
21119
+ attributeName = "data-dark-background",
21120
+ hardcoded = {},
21121
+ } = {},
21122
+ ) => {
21123
+ const innerDeps = [
21124
+ ...deps,
21125
+ // ref can change is the component pass a different ref on different render based on some logic
21126
+ // (can be used to control which element backgroundColor is being checked by switching the ref to another element)
21127
+ ref,
21128
+ // backgroundElementSelector can change if the component pass a different selector on different render based on some logic
21129
+ // (can be used to control which element backgroundColor is being checked by switching the selector to point to another element)
21130
+ backgroundElementSelector,
21131
+ ];
21132
+
21133
+ const hardcodedMap = new Map();
21134
+ for (const key of Object.keys(hardcoded)) {
21135
+ const value = hardcoded[key];
21136
+ innerDeps.push(key, value);
21137
+ const colorString = normalizeColorString(key);
21138
+ hardcodedMap.set(colorString, value);
21139
+ }
21140
+
21141
+ useLayoutEffect(() => {
21142
+ const el = ref.current;
21143
+ if (!el) {
21144
+ return undefined;
21145
+ }
21146
+ let elementToCheck = el;
21147
+ if (backgroundElementSelector) {
21148
+ elementToCheck = el.querySelector(backgroundElementSelector);
21149
+ if (!elementToCheck) {
21150
+ return undefined;
21151
+ }
21152
+ }
21153
+ const updateAttribute = () => {
21154
+ const computedStyle = getComputedStyle(elementToCheck);
21155
+ const backgroundColor = computedStyle.backgroundColor;
21156
+ if (!backgroundColor) {
21157
+ el.removeAttribute(attributeName);
21158
+ return;
21159
+ }
21160
+ const backgroundColorString = normalizeColorString(backgroundColor, el);
21161
+ const hardcodedContrast = hardcodedMap.get(backgroundColorString);
21162
+ const contrastingColor =
21163
+ hardcodedContrast || contrastColor(backgroundColor, el);
21164
+ if (contrastingColor === "white") {
21165
+ el.setAttribute(attributeName, "");
21166
+ } else {
21167
+ el.removeAttribute(attributeName);
21168
+ }
21169
+ };
21170
+ updateAttribute();
21171
+ el.addEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttribute);
21172
+ return () => {
21173
+ el.removeEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, updateAttribute);
21174
+ el.removeAttribute(attributeName);
21175
+ };
21176
+ }, innerDeps);
21177
+ };
21178
+
21179
+ const normalizeColorString = (color, el) => {
21180
+ const colorRgba = resolveCSSColor(color, el);
21181
+ if (!colorRgba) {
21182
+ return "";
21183
+ }
21184
+ return String(colorRgba);
21185
+ };
21186
+
20958
21187
  const useFormEvents = (
20959
21188
  elementRef,
20960
21189
  {
@@ -21559,8 +21788,6 @@ const css$r = /* css */`
21559
21788
  --x-button-background-color: var(--button-background-color);
21560
21789
  --x-button-color: var(--button-color);
21561
21790
  --x-button-cursor: var(--button-cursor);
21562
-
21563
- position: relative;
21564
21791
  box-sizing: border-box;
21565
21792
  aspect-ratio: inherit;
21566
21793
  padding: 0;
@@ -21573,6 +21800,10 @@ const css$r = /* css */`
21573
21800
  touch-action: manipulation;
21574
21801
  user-select: none;
21575
21802
 
21803
+ &[data-dark-background] {
21804
+ --button-color: white;
21805
+ }
21806
+
21576
21807
  &[data-icon] {
21577
21808
  --button-padding: 0;
21578
21809
  }
@@ -21855,6 +22086,7 @@ const ButtonBasic = props => {
21855
22086
  innerTarget = target === undefined ? isSameSite ? undefined : "_blank" : target;
21856
22087
  innerRel = rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel;
21857
22088
  }
22089
+ useDarkBackgroundAttribute(ref, [innerLoading, innerDisabled, innerReadOnly]);
21858
22090
  const renderButtonContent = buttonProps => {
21859
22091
  return jsxs(Text, {
21860
22092
  ...buttonProps,
@@ -22217,104 +22449,6 @@ const Title = props => {
22217
22449
  });
22218
22450
  };
22219
22451
 
22220
- /**
22221
- * Toggles a `data-dark-background` attribute on the referenced element based on its
22222
- * computed background color. Pair it with a CSS variable to get automatic
22223
- * light/dark text without hard-coding colors:
22224
- *
22225
- * ```css
22226
- * .my-element {
22227
- * --color-contrasting: black;
22228
- * &[data-dark-background] {
22229
- * --color-contrasting: white;
22230
- * }
22231
- * color: var(--color-contrasting);
22232
- * }
22233
- * ```
22234
- *
22235
- * - `data-dark-background` is **set** when the background is dark enough that white text
22236
- * provides better (or equal) contrast.
22237
- * - `data-dark-background` is **absent** when black text is the better choice.
22238
- *
22239
- * @param {import("preact").RefObject} ref - Ref to the element that receives
22240
- * the `data-dark-background` attribute and is also passed to `contrastColor` for
22241
- * resolving CSS variables.
22242
- * @param {object} [options]
22243
- * @param {string} [options.backgroundElementSelector] - CSS selector relative
22244
- * to `ref.current` pointing to a child element whose `background-color`
22245
- * should be tested instead of the element itself. Useful when the element
22246
- * has a transparent background but contains a coloured child (e.g. a fill
22247
- * bar inside a track).
22248
- */
22249
-
22250
- const useDarkBackgroundAttribute = (
22251
- ref,
22252
- deps = [],
22253
- {
22254
- backgroundElementSelector,
22255
- attributeName = "data-dark-background",
22256
- hardcoded = {},
22257
- } = {},
22258
- ) => {
22259
- const innerDeps = [
22260
- ...deps,
22261
- // ref can change is the component pass a different ref on different render based on some logic
22262
- // (can be used to control which element backgroundColor is being checked by switching the ref to another element)
22263
- ref,
22264
- // backgroundElementSelector can change if the component pass a different selector on different render based on some logic
22265
- // (can be used to control which element backgroundColor is being checked by switching the selector to point to another element)
22266
- backgroundElementSelector,
22267
- ];
22268
-
22269
- const hardcodedMap = new Map();
22270
- for (const key of Object.keys(hardcoded)) {
22271
- const value = hardcoded[key];
22272
- innerDeps.push(key, value);
22273
- const colorString = normalizeColorString(key);
22274
- hardcodedMap.set(colorString, value);
22275
- }
22276
-
22277
- useLayoutEffect(() => {
22278
- const el = ref.current;
22279
- if (!el) {
22280
- return null;
22281
- }
22282
- let elementToCheck = el;
22283
- if (backgroundElementSelector) {
22284
- elementToCheck = el.querySelector(backgroundElementSelector);
22285
- if (!elementToCheck) {
22286
- return null;
22287
- }
22288
- }
22289
- const computedStyle = getComputedStyle(elementToCheck);
22290
- const backgroundColor = computedStyle.backgroundColor;
22291
- if (!backgroundColor) {
22292
- el.removeAttribute(attributeName);
22293
- return null;
22294
- }
22295
- const backgroundColorString = normalizeColorString(backgroundColor, el);
22296
- const hardcodedContrast = hardcodedMap.get(backgroundColorString);
22297
- const contrastingColor =
22298
- hardcodedContrast || contrastColor(backgroundColor, el);
22299
- if (contrastingColor === "white") {
22300
- el.setAttribute(attributeName, "");
22301
- return () => {
22302
- el.removeAttribute(attributeName);
22303
- };
22304
- }
22305
- el.removeAttribute(attributeName);
22306
- return null;
22307
- }, innerDeps);
22308
- };
22309
-
22310
- const normalizeColorString = (color, el) => {
22311
- const colorRgba = resolveCSSColor(color, el);
22312
- if (!colorRgba) {
22313
- return "";
22314
- }
22315
- return String(colorRgba);
22316
- };
22317
-
22318
22452
  /**
22319
22453
  * Hook that reactively checks if a URL is visited.
22320
22454
  * Re-renders when the visited URL set changes.