@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 +209 -32
- package/index.js +10 -1
- package/package.json +1 -1
- package/src/interaction/focus/element_is_focusable.js +8 -8
- package/src/interaction/focus/element_visibility.js +111 -0
- package/src/interaction/scroll/wheel_through.js +5 -5
- package/src/style/style_composition.js +25 -7
- package/src/style/style_controller.js +2 -2
- package/src/style/style_parsing.js +58 -1
- package/src/ui_transition/ui_transition.js +1 -3
- package/src/value_effect.js +31 -0
- package/src/interaction/focus/element_is_visible.js +0 -36
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
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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[
|
|
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,
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2298
|
+
return elementIsVisibleForFocus(node);
|
|
2120
2299
|
}
|
|
2121
2300
|
if (nodeName === "summary") {
|
|
2122
|
-
return
|
|
2301
|
+
return elementIsVisibleForFocus(node);
|
|
2123
2302
|
}
|
|
2124
2303
|
if (node.hasAttribute("tabindex") || node.hasAttribute("tabIndex")) {
|
|
2125
|
-
return
|
|
2304
|
+
return elementIsVisibleForFocus(node);
|
|
2126
2305
|
}
|
|
2127
2306
|
if (node.hasAttribute("draggable")) {
|
|
2128
|
-
return
|
|
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
|
-
//
|
|
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,
|
|
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 {
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
34
|
+
return elementIsVisibleForFocus(node);
|
|
35
35
|
}
|
|
36
36
|
if (nodeName === "summary") {
|
|
37
|
-
return
|
|
37
|
+
return elementIsVisibleForFocus(node);
|
|
38
38
|
}
|
|
39
39
|
if (node.hasAttribute("tabindex") || node.hasAttribute("tabIndex")) {
|
|
40
|
-
return
|
|
40
|
+
return elementIsVisibleForFocus(node);
|
|
41
41
|
}
|
|
42
42
|
if (node.hasAttribute("draggable")) {
|
|
43
|
-
return
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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[
|
|
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,
|
|
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
|
|
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
|
-
};
|