@jsenv/dom 0.4.0 → 0.5.1

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.
package/dist/jsenv_dom.js CHANGED
@@ -100,6 +100,38 @@ const createPubSub = (clearOnPublish = false) => {
100
100
  return [publish, subscribe, clear];
101
101
  };
102
102
 
103
+ const createValueEffect = (value) => {
104
+ const callbackSet = new Set();
105
+ const previousValueCleanupSet = new Set();
106
+
107
+ const updateValue = (newValue) => {
108
+ if (newValue === value) {
109
+ return;
110
+ }
111
+ for (const cleanup of previousValueCleanupSet) {
112
+ cleanup();
113
+ }
114
+ previousValueCleanupSet.clear();
115
+ const oldValue = value;
116
+ value = newValue;
117
+ for (const callback of callbackSet) {
118
+ const returnValue = callback(newValue, oldValue);
119
+ if (typeof returnValue === "function") {
120
+ previousValueCleanupSet.add(returnValue);
121
+ }
122
+ }
123
+ };
124
+
125
+ const addEffect = (callback) => {
126
+ callbackSet.add(callback);
127
+ return () => {
128
+ callbackSet.delete(callback);
129
+ };
130
+ };
131
+
132
+ return [updateValue, addEffect];
133
+ };
134
+
103
135
  // https://github.com/davidtheclark/tabbable/blob/master/index.js
104
136
  const isDocumentElement = (node) =>
105
137
  node === node.ownerDocument.documentElement;
@@ -284,6 +316,9 @@ const pxProperties = [
284
316
  "borderTopRightRadius",
285
317
  "borderBottomLeftRadius",
286
318
  "borderBottomRightRadius",
319
+ "gap",
320
+ "rowGap",
321
+ "columnGap",
287
322
  ];
288
323
 
289
324
  // Properties that need deg units
@@ -403,13 +438,67 @@ const normalizeNumber = (value, context, unit, propertyName) => {
403
438
 
404
439
  // Normalize styles for DOM application
405
440
  const normalizeStyles = (styles, context = "js") => {
441
+ if (typeof styles === "string") {
442
+ styles = parseStyleString(styles);
443
+ return styles;
444
+ }
406
445
  const normalized = {};
407
- for (const [key, value] of Object.entries(styles)) {
446
+ for (const key of Object.keys(styles)) {
447
+ const value = styles[key];
408
448
  normalized[key] = normalizeStyle(value, key, context);
409
449
  }
410
450
  return normalized;
411
451
  };
412
452
 
453
+ /**
454
+ * Parses a CSS style string into a style object.
455
+ * Handles CSS properties with proper camelCase conversion.
456
+ *
457
+ * @param {string} styleString - CSS style string like "color: red; font-size: 14px;"
458
+ * @returns {object} Style object with camelCase properties
459
+ */
460
+ const parseStyleString = (styleString, context = "js") => {
461
+ const style = {};
462
+
463
+ if (!styleString || typeof styleString !== "string") {
464
+ return style;
465
+ }
466
+
467
+ // Split by semicolon and process each declaration
468
+ const declarations = styleString.split(";");
469
+
470
+ for (let declaration of declarations) {
471
+ declaration = declaration.trim();
472
+ if (!declaration) continue;
473
+
474
+ const colonIndex = declaration.indexOf(":");
475
+ if (colonIndex === -1) continue;
476
+
477
+ const property = declaration.slice(0, colonIndex).trim();
478
+ const value = declaration.slice(colonIndex + 1).trim();
479
+
480
+ if (property && value) {
481
+ // CSS custom properties (starting with --) should NOT be converted to camelCase
482
+ if (property.startsWith("--")) {
483
+ style[property] = normalizeStyle(value, property, context);
484
+ } else {
485
+ // Convert kebab-case to camelCase (e.g., "font-size" -> "fontSize")
486
+ const camelCaseProperty = property.replace(
487
+ /-([a-z])/g,
488
+ (match, letter) => letter.toUpperCase(),
489
+ );
490
+ style[camelCaseProperty] = normalizeStyle(
491
+ value,
492
+ camelCaseProperty,
493
+ context,
494
+ );
495
+ }
496
+ }
497
+ }
498
+
499
+ return style;
500
+ };
501
+
413
502
  // Convert transform object to CSS string
414
503
  const stringifyCSSTransform = (transformObj) => {
415
504
  const transforms = [];
@@ -570,15 +659,28 @@ const parseSimple2DMatrix = (a, b, c, d, e, f) => {
570
659
  };
571
660
 
572
661
  // Merge two style objects, handling special cases like transform
573
- const mergeStyles = (stylesA, stylesB) => {
574
- const result = { ...stylesA };
575
- for (const key of Object.keys(stylesB)) {
576
- if (key === "transform") {
577
- result[key] = mergeOneStyle(stylesA[key], stylesB[key], key);
662
+ const mergeStyles = (stylesA, stylesB, context = "js") => {
663
+ if (!stylesA) {
664
+ return normalizeStyles(stylesB, context);
665
+ }
666
+ if (!stylesB) {
667
+ return normalizeStyles(stylesA, context);
668
+ }
669
+ const result = {};
670
+ const aKeys = Object.keys(stylesA);
671
+ const bKeyToVisitSet = new Set(Object.keys(stylesB));
672
+ for (const aKey of aKeys) {
673
+ const bHasKey = bKeyToVisitSet.has(aKey);
674
+ if (bHasKey) {
675
+ bKeyToVisitSet.delete(aKey);
676
+ result[aKey] = mergeOneStyle(stylesA[aKey], stylesB[aKey], aKey, context);
578
677
  } else {
579
- result[key] = stylesB[key];
678
+ result[aKey] = normalizeStyle(stylesA[aKey], aKey, context);
580
679
  }
581
680
  }
681
+ for (const bKey of bKeyToVisitSet) {
682
+ result[bKey] = normalizeStyle(stylesB[bKey], bKey, context);
683
+ }
582
684
  return result;
583
685
  };
584
686
 
@@ -740,9 +842,9 @@ const createStyleController = (name = "anonymous") => {
740
842
  throw new Error("styles must be an object");
741
843
  }
742
844
 
743
- const normalizedStylesToSet = normalizeStyles(stylesToSet, "js");
744
845
  const elementData = elementWeakMap.get(element);
745
846
  if (!elementData) {
847
+ const normalizedStylesToSet = normalizeStyles(stylesToSet, "js");
746
848
  const animation = createAnimationForStyles(
747
849
  element,
748
850
  normalizedStylesToSet,
@@ -757,7 +859,7 @@ const createStyleController = (name = "anonymous") => {
757
859
  }
758
860
 
759
861
  const { styles, animation } = elementData;
760
- const mergedStyles = mergeStyles(styles, normalizedStylesToSet);
862
+ const mergedStyles = mergeStyles(styles, stylesToSet);
761
863
  elementData.styles = mergedStyles;
762
864
  updateAnimationStyles(animation, mergedStyles);
763
865
  };
@@ -2057,12 +2159,21 @@ const addActiveElementEffect = (callback) => {
2057
2159
  return remove;
2058
2160
  };
2059
2161
 
2060
- const elementIsVisible = (node) => {
2162
+ const elementIsVisibleForFocus = (node) => {
2163
+ return getFocusVisibilityInfo(node).visible;
2164
+ };
2165
+ const getFocusVisibilityInfo = (node) => {
2061
2166
  if (isDocumentElement(node)) {
2062
- return true;
2167
+ return { visible: true, reason: "is document" };
2168
+ }
2169
+ if (node.hasAttribute("hidden")) {
2170
+ return { visible: false, reason: "has hidden attribute" };
2063
2171
  }
2064
2172
  if (getStyle(node, "visibility") === "hidden") {
2065
- return false;
2173
+ return { visible: false, reason: "uses visiblity: hidden" };
2174
+ }
2175
+ if (node.tagName === "INPUT" && node.type === "hidden") {
2176
+ return { visible: false, reason: "input type hidden" };
2066
2177
  }
2067
2178
  let nodeOrAncestor = node;
2068
2179
  while (nodeOrAncestor) {
@@ -2070,19 +2181,87 @@ const elementIsVisible = (node) => {
2070
2181
  break;
2071
2182
  }
2072
2183
  if (getStyle(nodeOrAncestor, "display") === "none") {
2073
- return false;
2184
+ return { visible: false, reason: "ancestor uses display: none" };
2074
2185
  }
2075
2186
  // Check if element is inside a closed details element
2076
2187
  if (elementIsDetails(nodeOrAncestor) && !nodeOrAncestor.open) {
2077
2188
  // Special case: summary elements are visible even when their parent details is closed
2078
2189
  // But only if this details element is the direct parent of the summary
2079
- if (elementIsSummary(node) && node.parentElement === nodeOrAncestor) ; else {
2080
- return false;
2190
+ if (!elementIsSummary(node) || node.parentElement !== nodeOrAncestor) {
2191
+ return { visible: false, reason: "inside closed details element" };
2081
2192
  }
2193
+ // Continue checking ancestors
2082
2194
  }
2083
2195
  nodeOrAncestor = nodeOrAncestor.parentNode;
2084
2196
  }
2085
- return true;
2197
+ return { visible: true, reason: "no reason to be hidden" };
2198
+ };
2199
+
2200
+ const elementIsVisuallyVisible = (node, options = {}) => {
2201
+ return getVisuallyVisibleInfo(node, options).visible;
2202
+ };
2203
+ const getVisuallyVisibleInfo = (
2204
+ node,
2205
+ { countOffscreenAsVisible = false } = {},
2206
+ ) => {
2207
+ // First check all the focusable visibility conditions
2208
+ const focusVisibilityInfo = getFocusVisibilityInfo(node);
2209
+ if (!focusVisibilityInfo.visible) {
2210
+ return focusVisibilityInfo;
2211
+ }
2212
+
2213
+ // Additional visual visibility checks
2214
+ if (getStyle(node, "opacity") === "0") {
2215
+ return { visible: false, reason: "uses opacity: 0" };
2216
+ }
2217
+
2218
+ const rect = node.getBoundingClientRect();
2219
+ if (rect.width === 0 && rect.height === 0) {
2220
+ return { visible: false, reason: "has zero dimensions" };
2221
+ }
2222
+
2223
+ // Check for clipping
2224
+ const clipStyle = getStyle(node, "clip");
2225
+ if (clipStyle && clipStyle !== "auto" && clipStyle.includes("rect(0")) {
2226
+ return { visible: false, reason: "clipped with clip property" };
2227
+ }
2228
+
2229
+ const clipPathStyle = getStyle(node, "clip-path");
2230
+ if (clipPathStyle && clipPathStyle.includes("inset(100%")) {
2231
+ return { visible: false, reason: "clipped with clip-path" };
2232
+ }
2233
+
2234
+ // Check if positioned off-screen (unless option says to count as visible)
2235
+ if (!countOffscreenAsVisible) {
2236
+ if (
2237
+ rect.right < 0 ||
2238
+ rect.bottom < 0 ||
2239
+ rect.left > window.innerWidth ||
2240
+ rect.top > window.innerHeight
2241
+ ) {
2242
+ return { visible: false, reason: "positioned off-screen" };
2243
+ }
2244
+ }
2245
+
2246
+ // Check for transform scale(0)
2247
+ const transformStyle = getStyle(node, "transform");
2248
+ if (transformStyle && transformStyle.includes("scale(0")) {
2249
+ return { visible: false, reason: "scaled to zero with transform" };
2250
+ }
2251
+
2252
+ return { visible: true, reason: "visually visible" };
2253
+ };
2254
+ const getFirstVisuallyVisibleAncestor = (node, options = {}) => {
2255
+ let ancestorCandidate = node.parentNode;
2256
+ while (ancestorCandidate) {
2257
+ const visibilityInfo = getVisuallyVisibleInfo(ancestorCandidate, options);
2258
+ if (visibilityInfo.visible) {
2259
+ return ancestorCandidate;
2260
+ }
2261
+ ancestorCandidate = ancestorCandidate.parentElement;
2262
+ }
2263
+ // This shouldn't happen in normal cases since document element is always visible
2264
+ return null;
2086
2265
  };
2087
2266
 
2088
2267
  const elementIsFocusable = (node) => {
@@ -2098,34 +2277,34 @@ const elementIsFocusable = (node) => {
2098
2277
  if (node.type === "hidden") {
2099
2278
  return false;
2100
2279
  }
2101
- return elementIsVisible(node);
2280
+ return elementIsVisibleForFocus(node);
2102
2281
  }
2103
2282
  if (
2104
2283
  ["button", "select", "datalist", "iframe", "textarea"].indexOf(nodeName) >
2105
2284
  -1
2106
2285
  ) {
2107
- return elementIsVisible(node);
2286
+ return elementIsVisibleForFocus(node);
2108
2287
  }
2109
2288
  if (["a", "area"].indexOf(nodeName) > -1) {
2110
2289
  if (node.hasAttribute("href") === false) {
2111
2290
  return false;
2112
2291
  }
2113
- return elementIsVisible(node);
2292
+ return elementIsVisibleForFocus(node);
2114
2293
  }
2115
2294
  if (["audio", "video"].indexOf(nodeName) > -1) {
2116
2295
  if (node.hasAttribute("controls") === false) {
2117
2296
  return false;
2118
2297
  }
2119
- return elementIsVisible(node);
2298
+ return elementIsVisibleForFocus(node);
2120
2299
  }
2121
2300
  if (nodeName === "summary") {
2122
- return elementIsVisible(node);
2301
+ return elementIsVisibleForFocus(node);
2123
2302
  }
2124
2303
  if (node.hasAttribute("tabindex") || node.hasAttribute("tabIndex")) {
2125
- return elementIsVisible(node);
2304
+ return elementIsVisibleForFocus(node);
2126
2305
  }
2127
2306
  if (node.hasAttribute("draggable")) {
2128
- return elementIsVisible(node);
2307
+ return elementIsVisibleForFocus(node);
2129
2308
  }
2130
2309
  return false;
2131
2310
  };
@@ -3558,11 +3737,12 @@ const allowWheelThrough = (element, connectedElement) => {
3558
3737
  wheelEvent.clientY,
3559
3738
  );
3560
3739
  for (const elementBehindMouse of elementsBehindMouse) {
3561
- const belongsToElement = isElementOrDescendant(elementBehindMouse);
3562
3740
  // try to scroll element itself
3563
3741
  if (tryToScrollOne(elementBehindMouse, wheelEvent)) {
3564
3742
  return;
3565
3743
  }
3744
+ const belongsToElement = isElementOrDescendant(elementBehindMouse);
3745
+ // try to scroll what is behind
3566
3746
  if (!belongsToElement) {
3567
3747
  break;
3568
3748
  }
@@ -3581,17 +3761,16 @@ const allowWheelThrough = (element, connectedElement) => {
3581
3761
  wheelEvent.clientY,
3582
3762
  );
3583
3763
  for (const elementBehindMouse of elementsBehindMouse) {
3584
- const belongsToElement = isElementOrDescendant(elementBehindMouse);
3585
3764
  // try to scroll element itself
3586
3765
  if (tryToScrollOne(elementBehindMouse, wheelEvent)) {
3587
3766
  return;
3588
3767
  }
3768
+ const belongsToElement = isElementOrDescendant(elementBehindMouse);
3589
3769
  if (belongsToElement) {
3590
- // the element is not scrollable and we don't care about his
3591
- // scrollable parent (because we know it's going to be the document)
3592
- // we search for scrollable container that might be behind it
3770
+ // keep searching if something in our element is scrollable
3593
3771
  continue;
3594
3772
  }
3773
+ // our element is not scrollable, try to scroll the container behind the mouse
3595
3774
  const scrollContainer = getScrollContainer(elementBehindMouse);
3596
3775
  if (tryToScrollOne(scrollContainer, wheelEvent)) {
3597
3776
  return;
@@ -10369,10 +10548,9 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
10369
10548
  installImportMetaCss(import.meta);
10370
10549
  import.meta.css = /* css */ `
10371
10550
  .ui_transition_container {
10551
+ position: relative;
10372
10552
  display: inline-flex;
10373
10553
  flex: 1;
10374
- position: relative;
10375
- overflow: hidden;
10376
10554
  }
10377
10555
 
10378
10556
  .ui_transition_outer_wrapper {
@@ -10381,7 +10559,6 @@ import.meta.css = /* css */ `
10381
10559
  }
10382
10560
 
10383
10561
  .ui_transition_measure_wrapper {
10384
- overflow: hidden;
10385
10562
  display: inline-flex;
10386
10563
  flex: 1;
10387
10564
  }
@@ -11802,4 +11979,4 @@ const crossFade = {
11802
11979
  },
11803
11980
  };
11804
11981
 
11805
- export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, addWillChange, allowWheelThrough, canInterceptKeys, captureScrollState, createDragGestureController, createDragToMoveGestureController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createWidthTransition, cubicBezier, dragAfterThreshold, elementIsFocusable, elementIsVisible, findAfter, findAncestor, findBefore, findDescendant, findFocusable, getAvailableHeight, getAvailableWidth, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getHeight, getInnerHeight, getInnerWidth, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getPaddingSizes, getPositionedParent, getPreferedColorScheme, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getWidth, initFlexDetailsSet, initFocusGroup, initPositionSticky, initUITransition, isScrollable, parseCSSColor, pickLightOrDark, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, resolveCSSColor, resolveCSSSize, setAttribute, setAttributes, setStyles, startDragToResizeGesture, stickyAsRelativeCoords, stringifyCSSColor, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
11982
+ export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, addWillChange, allowWheelThrough, canInterceptKeys, captureScrollState, createDragGestureController, createDragToMoveGestureController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findFocusable, getAvailableHeight, getAvailableWidth, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getInnerHeight, getInnerWidth, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getPaddingSizes, getPositionedParent, getPreferedColorScheme, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getVisuallyVisibleInfo, getWidth, initFlexDetailsSet, initFocusGroup, initPositionSticky, initUITransition, isScrollable, mergeStyles, normalizeStyles, parseCSSColor, pickLightOrDark, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, resolveCSSColor, resolveCSSSize, setAttribute, setAttributes, setStyles, startDragToResizeGesture, stickyAsRelativeCoords, stringifyCSSColor, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
package/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // state management
2
2
  export { createIterableWeakSet } from "./src/iterable_weak_set.js";
3
3
  export { createPubSub } from "./src/pub_sub.js";
4
+ export { createValueEffect } from "./src/value_effect.js";
4
5
 
5
6
  // style
6
7
  export { addWillChange, getStyle, setStyles } from "./src/style/dom_styles.js";
8
+ export { mergeStyles } from "./src/style/style_composition.js";
7
9
  export { createStyleController } from "./src/style/style_controller.js";
8
10
  export { getDefaultStyles } from "./src/style/style_default.js";
11
+ export { normalizeStyles } from "./src/style/style_parsing.js";
9
12
 
10
13
  // attributes
11
14
  export { addAttributeEffect } from "./src/attr/add_attribute_effect.js";
@@ -37,7 +40,13 @@ export {
37
40
  useActiveElement,
38
41
  } from "./src/interaction/focus/active_element.js";
39
42
  export { elementIsFocusable } from "./src/interaction/focus/element_is_focusable.js";
40
- export { elementIsVisible } from "./src/interaction/focus/element_is_visible.js";
43
+ export {
44
+ elementIsVisibleForFocus,
45
+ elementIsVisuallyVisible,
46
+ getFirstVisuallyVisibleAncestor,
47
+ getFocusVisibilityInfo,
48
+ getVisuallyVisibleInfo,
49
+ } from "./src/interaction/focus/element_visibility.js";
41
50
  export { findFocusable } from "./src/interaction/focus/find_focusable.js";
42
51
  export { initFocusGroup } from "./src/interaction/focus/focus_group.js";
43
52
  export { preventFocusNavViaKeyboard } from "./src/interaction/focus/focus_nav.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "DOM utilities for writing frontend code",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,4 @@
1
- import { elementIsVisible } from "./element_is_visible.js";
1
+ import { elementIsVisibleForFocus } from "./element_visibility.js";
2
2
 
3
3
  export const elementIsFocusable = (node) => {
4
4
  // only element node can be focused, document, textNodes etc cannot
@@ -13,34 +13,34 @@ export const elementIsFocusable = (node) => {
13
13
  if (node.type === "hidden") {
14
14
  return false;
15
15
  }
16
- return elementIsVisible(node);
16
+ return elementIsVisibleForFocus(node);
17
17
  }
18
18
  if (
19
19
  ["button", "select", "datalist", "iframe", "textarea"].indexOf(nodeName) >
20
20
  -1
21
21
  ) {
22
- return elementIsVisible(node);
22
+ return elementIsVisibleForFocus(node);
23
23
  }
24
24
  if (["a", "area"].indexOf(nodeName) > -1) {
25
25
  if (node.hasAttribute("href") === false) {
26
26
  return false;
27
27
  }
28
- return elementIsVisible(node);
28
+ return elementIsVisibleForFocus(node);
29
29
  }
30
30
  if (["audio", "video"].indexOf(nodeName) > -1) {
31
31
  if (node.hasAttribute("controls") === false) {
32
32
  return false;
33
33
  }
34
- return elementIsVisible(node);
34
+ return elementIsVisibleForFocus(node);
35
35
  }
36
36
  if (nodeName === "summary") {
37
- return elementIsVisible(node);
37
+ return elementIsVisibleForFocus(node);
38
38
  }
39
39
  if (node.hasAttribute("tabindex") || node.hasAttribute("tabIndex")) {
40
- return elementIsVisible(node);
40
+ return elementIsVisibleForFocus(node);
41
41
  }
42
42
  if (node.hasAttribute("draggable")) {
43
- return elementIsVisible(node);
43
+ return elementIsVisibleForFocus(node);
44
44
  }
45
45
  return false;
46
46
  };
@@ -0,0 +1,111 @@
1
+ import { getStyle } from "../../style/dom_styles.js";
2
+ import {
3
+ elementIsDetails,
4
+ elementIsSummary,
5
+ isDocumentElement,
6
+ } from "../../utils.js";
7
+
8
+ export const elementIsVisibleForFocus = (node) => {
9
+ return getFocusVisibilityInfo(node).visible;
10
+ };
11
+ export const getFocusVisibilityInfo = (node) => {
12
+ if (isDocumentElement(node)) {
13
+ return { visible: true, reason: "is document" };
14
+ }
15
+ if (node.hasAttribute("hidden")) {
16
+ return { visible: false, reason: "has hidden attribute" };
17
+ }
18
+ if (getStyle(node, "visibility") === "hidden") {
19
+ return { visible: false, reason: "uses visiblity: hidden" };
20
+ }
21
+ if (node.tagName === "INPUT" && node.type === "hidden") {
22
+ return { visible: false, reason: "input type hidden" };
23
+ }
24
+ let nodeOrAncestor = node;
25
+ while (nodeOrAncestor) {
26
+ if (isDocumentElement(nodeOrAncestor)) {
27
+ break;
28
+ }
29
+ if (getStyle(nodeOrAncestor, "display") === "none") {
30
+ return { visible: false, reason: "ancestor uses display: none" };
31
+ }
32
+ // Check if element is inside a closed details element
33
+ if (elementIsDetails(nodeOrAncestor) && !nodeOrAncestor.open) {
34
+ // Special case: summary elements are visible even when their parent details is closed
35
+ // But only if this details element is the direct parent of the summary
36
+ if (!elementIsSummary(node) || node.parentElement !== nodeOrAncestor) {
37
+ return { visible: false, reason: "inside closed details element" };
38
+ }
39
+ // Continue checking ancestors
40
+ }
41
+ nodeOrAncestor = nodeOrAncestor.parentNode;
42
+ }
43
+ return { visible: true, reason: "no reason to be hidden" };
44
+ };
45
+
46
+ export const elementIsVisuallyVisible = (node, options = {}) => {
47
+ return getVisuallyVisibleInfo(node, options).visible;
48
+ };
49
+ export const getVisuallyVisibleInfo = (
50
+ node,
51
+ { countOffscreenAsVisible = false } = {},
52
+ ) => {
53
+ // First check all the focusable visibility conditions
54
+ const focusVisibilityInfo = getFocusVisibilityInfo(node);
55
+ if (!focusVisibilityInfo.visible) {
56
+ return focusVisibilityInfo;
57
+ }
58
+
59
+ // Additional visual visibility checks
60
+ if (getStyle(node, "opacity") === "0") {
61
+ return { visible: false, reason: "uses opacity: 0" };
62
+ }
63
+
64
+ const rect = node.getBoundingClientRect();
65
+ if (rect.width === 0 && rect.height === 0) {
66
+ return { visible: false, reason: "has zero dimensions" };
67
+ }
68
+
69
+ // Check for clipping
70
+ const clipStyle = getStyle(node, "clip");
71
+ if (clipStyle && clipStyle !== "auto" && clipStyle.includes("rect(0")) {
72
+ return { visible: false, reason: "clipped with clip property" };
73
+ }
74
+
75
+ const clipPathStyle = getStyle(node, "clip-path");
76
+ if (clipPathStyle && clipPathStyle.includes("inset(100%")) {
77
+ return { visible: false, reason: "clipped with clip-path" };
78
+ }
79
+
80
+ // Check if positioned off-screen (unless option says to count as visible)
81
+ if (!countOffscreenAsVisible) {
82
+ if (
83
+ rect.right < 0 ||
84
+ rect.bottom < 0 ||
85
+ rect.left > window.innerWidth ||
86
+ rect.top > window.innerHeight
87
+ ) {
88
+ return { visible: false, reason: "positioned off-screen" };
89
+ }
90
+ }
91
+
92
+ // Check for transform scale(0)
93
+ const transformStyle = getStyle(node, "transform");
94
+ if (transformStyle && transformStyle.includes("scale(0")) {
95
+ return { visible: false, reason: "scaled to zero with transform" };
96
+ }
97
+
98
+ return { visible: true, reason: "visually visible" };
99
+ };
100
+ export const getFirstVisuallyVisibleAncestor = (node, options = {}) => {
101
+ let ancestorCandidate = node.parentNode;
102
+ while (ancestorCandidate) {
103
+ const visibilityInfo = getVisuallyVisibleInfo(ancestorCandidate, options);
104
+ if (visibilityInfo.visible) {
105
+ return ancestorCandidate;
106
+ }
107
+ ancestorCandidate = ancestorCandidate.parentElement;
108
+ }
109
+ // This shouldn't happen in normal cases since document element is always visible
110
+ return null;
111
+ };
@@ -56,11 +56,12 @@ export const allowWheelThrough = (element, connectedElement) => {
56
56
  wheelEvent.clientY,
57
57
  );
58
58
  for (const elementBehindMouse of elementsBehindMouse) {
59
- const belongsToElement = isElementOrDescendant(elementBehindMouse);
60
59
  // try to scroll element itself
61
60
  if (tryToScrollOne(elementBehindMouse, wheelEvent)) {
62
61
  return;
63
62
  }
63
+ const belongsToElement = isElementOrDescendant(elementBehindMouse);
64
+ // try to scroll what is behind
64
65
  if (!belongsToElement) {
65
66
  break;
66
67
  }
@@ -79,17 +80,16 @@ export const allowWheelThrough = (element, connectedElement) => {
79
80
  wheelEvent.clientY,
80
81
  );
81
82
  for (const elementBehindMouse of elementsBehindMouse) {
82
- const belongsToElement = isElementOrDescendant(elementBehindMouse);
83
83
  // try to scroll element itself
84
84
  if (tryToScrollOne(elementBehindMouse, wheelEvent)) {
85
85
  return;
86
86
  }
87
+ const belongsToElement = isElementOrDescendant(elementBehindMouse);
87
88
  if (belongsToElement) {
88
- // the element is not scrollable and we don't care about his
89
- // scrollable parent (because we know it's going to be the document)
90
- // we search for scrollable container that might be behind it
89
+ // keep searching if something in our element is scrollable
91
90
  continue;
92
91
  }
92
+ // our element is not scrollable, try to scroll the container behind the mouse
93
93
  const scrollContainer = getScrollContainer(elementBehindMouse);
94
94
  if (tryToScrollOne(scrollContainer, wheelEvent)) {
95
95
  return;
@@ -1,15 +1,33 @@
1
- import { parseCSSTransform, stringifyCSSTransform } from "./style_parsing.js";
1
+ import {
2
+ normalizeStyle,
3
+ normalizeStyles,
4
+ parseCSSTransform,
5
+ stringifyCSSTransform,
6
+ } from "./style_parsing.js";
2
7
 
3
8
  // Merge two style objects, handling special cases like transform
4
- export const mergeStyles = (stylesA, stylesB) => {
5
- const result = { ...stylesA };
6
- for (const key of Object.keys(stylesB)) {
7
- if (key === "transform") {
8
- result[key] = mergeOneStyle(stylesA[key], stylesB[key], key);
9
+ export const mergeStyles = (stylesA, stylesB, context = "js") => {
10
+ if (!stylesA) {
11
+ return normalizeStyles(stylesB, context);
12
+ }
13
+ if (!stylesB) {
14
+ return normalizeStyles(stylesA, context);
15
+ }
16
+ const result = {};
17
+ const aKeys = Object.keys(stylesA);
18
+ const bKeyToVisitSet = new Set(Object.keys(stylesB));
19
+ for (const aKey of aKeys) {
20
+ const bHasKey = bKeyToVisitSet.has(aKey);
21
+ if (bHasKey) {
22
+ bKeyToVisitSet.delete(aKey);
23
+ result[aKey] = mergeOneStyle(stylesA[aKey], stylesB[aKey], aKey, context);
9
24
  } else {
10
- result[key] = stylesB[key];
25
+ result[aKey] = normalizeStyle(stylesA[aKey], aKey, context);
11
26
  }
12
27
  }
28
+ for (const bKey of bKeyToVisitSet) {
29
+ result[bKey] = normalizeStyle(stylesB[bKey], bKey, context);
30
+ }
13
31
  return result;
14
32
  };
15
33
 
@@ -94,9 +94,9 @@ export const createStyleController = (name = "anonymous") => {
94
94
  throw new Error("styles must be an object");
95
95
  }
96
96
 
97
- const normalizedStylesToSet = normalizeStyles(stylesToSet, "js");
98
97
  const elementData = elementWeakMap.get(element);
99
98
  if (!elementData) {
99
+ const normalizedStylesToSet = normalizeStyles(stylesToSet, "js");
100
100
  const animation = createAnimationForStyles(
101
101
  element,
102
102
  normalizedStylesToSet,
@@ -111,7 +111,7 @@ export const createStyleController = (name = "anonymous") => {
111
111
  }
112
112
 
113
113
  const { styles, animation } = elementData;
114
- const mergedStyles = mergeStyles(styles, normalizedStylesToSet);
114
+ const mergedStyles = mergeStyles(styles, stylesToSet);
115
115
  elementData.styles = mergedStyles;
116
116
  updateAnimationStyles(animation, mergedStyles);
117
117
  };
@@ -34,6 +34,9 @@ const pxProperties = [
34
34
  "borderTopRightRadius",
35
35
  "borderBottomLeftRadius",
36
36
  "borderBottomRightRadius",
37
+ "gap",
38
+ "rowGap",
39
+ "columnGap",
37
40
  ];
38
41
 
39
42
  // Properties that need deg units
@@ -153,13 +156,67 @@ const normalizeNumber = (value, context, unit, propertyName) => {
153
156
 
154
157
  // Normalize styles for DOM application
155
158
  export const normalizeStyles = (styles, context = "js") => {
159
+ if (typeof styles === "string") {
160
+ styles = parseStyleString(styles);
161
+ return styles;
162
+ }
156
163
  const normalized = {};
157
- for (const [key, value] of Object.entries(styles)) {
164
+ for (const key of Object.keys(styles)) {
165
+ const value = styles[key];
158
166
  normalized[key] = normalizeStyle(value, key, context);
159
167
  }
160
168
  return normalized;
161
169
  };
162
170
 
171
+ /**
172
+ * Parses a CSS style string into a style object.
173
+ * Handles CSS properties with proper camelCase conversion.
174
+ *
175
+ * @param {string} styleString - CSS style string like "color: red; font-size: 14px;"
176
+ * @returns {object} Style object with camelCase properties
177
+ */
178
+ export const parseStyleString = (styleString, context = "js") => {
179
+ const style = {};
180
+
181
+ if (!styleString || typeof styleString !== "string") {
182
+ return style;
183
+ }
184
+
185
+ // Split by semicolon and process each declaration
186
+ const declarations = styleString.split(";");
187
+
188
+ for (let declaration of declarations) {
189
+ declaration = declaration.trim();
190
+ if (!declaration) continue;
191
+
192
+ const colonIndex = declaration.indexOf(":");
193
+ if (colonIndex === -1) continue;
194
+
195
+ const property = declaration.slice(0, colonIndex).trim();
196
+ const value = declaration.slice(colonIndex + 1).trim();
197
+
198
+ if (property && value) {
199
+ // CSS custom properties (starting with --) should NOT be converted to camelCase
200
+ if (property.startsWith("--")) {
201
+ style[property] = normalizeStyle(value, property, context);
202
+ } else {
203
+ // Convert kebab-case to camelCase (e.g., "font-size" -> "fontSize")
204
+ const camelCaseProperty = property.replace(
205
+ /-([a-z])/g,
206
+ (match, letter) => letter.toUpperCase(),
207
+ );
208
+ style[camelCaseProperty] = normalizeStyle(
209
+ value,
210
+ camelCaseProperty,
211
+ context,
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ return style;
218
+ };
219
+
163
220
  // Convert transform object to CSS string
164
221
  export const stringifyCSSTransform = (transformObj) => {
165
222
  const transforms = [];
@@ -58,10 +58,9 @@ import { createGroupTransitionController } from "../transition/group_transition.
58
58
 
59
59
  import.meta.css = /* css */ `
60
60
  .ui_transition_container {
61
+ position: relative;
61
62
  display: inline-flex;
62
63
  flex: 1;
63
- position: relative;
64
- overflow: hidden;
65
64
  }
66
65
 
67
66
  .ui_transition_outer_wrapper {
@@ -70,7 +69,6 @@ import.meta.css = /* css */ `
70
69
  }
71
70
 
72
71
  .ui_transition_measure_wrapper {
73
- overflow: hidden;
74
72
  display: inline-flex;
75
73
  flex: 1;
76
74
  }
@@ -0,0 +1,31 @@
1
+ export const createValueEffect = (value) => {
2
+ const callbackSet = new Set();
3
+ const previousValueCleanupSet = new Set();
4
+
5
+ const updateValue = (newValue) => {
6
+ if (newValue === value) {
7
+ return;
8
+ }
9
+ for (const cleanup of previousValueCleanupSet) {
10
+ cleanup();
11
+ }
12
+ previousValueCleanupSet.clear();
13
+ const oldValue = value;
14
+ value = newValue;
15
+ for (const callback of callbackSet) {
16
+ const returnValue = callback(newValue, oldValue);
17
+ if (typeof returnValue === "function") {
18
+ previousValueCleanupSet.add(returnValue);
19
+ }
20
+ }
21
+ };
22
+
23
+ const addEffect = (callback) => {
24
+ callbackSet.add(callback);
25
+ return () => {
26
+ callbackSet.delete(callback);
27
+ };
28
+ };
29
+
30
+ return [updateValue, addEffect];
31
+ };
@@ -1,36 +0,0 @@
1
- import { getStyle } from "../../style/dom_styles.js";
2
- import {
3
- elementIsDetails,
4
- elementIsSummary,
5
- isDocumentElement,
6
- } from "../../utils.js";
7
-
8
- export const elementIsVisible = (node) => {
9
- if (isDocumentElement(node)) {
10
- return true;
11
- }
12
- if (getStyle(node, "visibility") === "hidden") {
13
- return false;
14
- }
15
- let nodeOrAncestor = node;
16
- while (nodeOrAncestor) {
17
- if (isDocumentElement(nodeOrAncestor)) {
18
- break;
19
- }
20
- if (getStyle(nodeOrAncestor, "display") === "none") {
21
- return false;
22
- }
23
- // Check if element is inside a closed details element
24
- if (elementIsDetails(nodeOrAncestor) && !nodeOrAncestor.open) {
25
- // Special case: summary elements are visible even when their parent details is closed
26
- // But only if this details element is the direct parent of the summary
27
- if (elementIsSummary(node) && node.parentElement === nodeOrAncestor) {
28
- // Continue checking ancestors, don't return false yet
29
- } else {
30
- return false;
31
- }
32
- }
33
- nodeOrAncestor = nodeOrAncestor.parentNode;
34
- }
35
- return true;
36
- };