@reticular/speakable 1.3.0 → 1.5.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.
- package/README.md +124 -5
- package/dist/index.d.ts +768 -1
- package/dist/index.js +2831 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +340 -0
- package/dist/mcp.js.map +1 -1
- package/package.json +11 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __export = (target, all) => {
|
|
3
|
+
for (var name in all)
|
|
4
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
|
+
};
|
|
6
|
+
|
|
1
7
|
// src/model/types.ts
|
|
2
8
|
var SUPPORTED_ROLES = [
|
|
3
9
|
// Original roles (22)
|
|
@@ -2658,6 +2664,2830 @@ function createGenericContainer(rootElement, warnings) {
|
|
|
2658
2664
|
children
|
|
2659
2665
|
};
|
|
2660
2666
|
}
|
|
2667
|
+
|
|
2668
|
+
// src/runtime/index.ts
|
|
2669
|
+
var runtime_exports = {};
|
|
2670
|
+
__export(runtime_exports, {
|
|
2671
|
+
analyzeVerbosity: () => analyzeVerbosity,
|
|
2672
|
+
classifyDiff: () => classifyDiff,
|
|
2673
|
+
createBaselineStorage: () => createBaselineStorage,
|
|
2674
|
+
createEngine: () => createEngine,
|
|
2675
|
+
createHeuristicAnalyzer: () => createHeuristicAnalyzer,
|
|
2676
|
+
createTimelineGenerator: () => createTimelineGenerator,
|
|
2677
|
+
deserializeEvent: () => deserializeEvent,
|
|
2678
|
+
deserializeInteractionSequence: () => deserializeInteractionSequence,
|
|
2679
|
+
deserializeTimeline: () => deserializeTimeline,
|
|
2680
|
+
diffTimelines: () => diffTimelines,
|
|
2681
|
+
executeAction: () => executeAction,
|
|
2682
|
+
executeSequence: () => executeSequence,
|
|
2683
|
+
formatVerbosityReport: () => formatVerbosityReport,
|
|
2684
|
+
generateSelector: () => generateSelector,
|
|
2685
|
+
getBuiltinPattern: () => getBuiltinPattern,
|
|
2686
|
+
serializeEvent: () => serializeEvent,
|
|
2687
|
+
serializeInteractionSequence: () => serializeInteractionSequence,
|
|
2688
|
+
serializeTimeline: () => serializeTimeline,
|
|
2689
|
+
timelinesEqual: () => timelinesEqual
|
|
2690
|
+
});
|
|
2691
|
+
|
|
2692
|
+
// src/runtime/selector.ts
|
|
2693
|
+
function generateSelector(element) {
|
|
2694
|
+
if (element.id) {
|
|
2695
|
+
return `#${escapeCSSIdentifier(element.id)}`;
|
|
2696
|
+
}
|
|
2697
|
+
const document = element.ownerDocument;
|
|
2698
|
+
if (!document) {
|
|
2699
|
+
return getTagName(element);
|
|
2700
|
+
}
|
|
2701
|
+
const segments = [];
|
|
2702
|
+
let current = element;
|
|
2703
|
+
while (current && current !== document.documentElement) {
|
|
2704
|
+
const segment = buildSegment(current, document);
|
|
2705
|
+
segments.unshift(segment);
|
|
2706
|
+
const partialSelector = segments.join(" > ");
|
|
2707
|
+
try {
|
|
2708
|
+
const matches = document.querySelectorAll(partialSelector);
|
|
2709
|
+
if (matches.length === 1 && matches[0] === element) {
|
|
2710
|
+
return partialSelector;
|
|
2711
|
+
}
|
|
2712
|
+
} catch {
|
|
2713
|
+
}
|
|
2714
|
+
current = current.parentElement;
|
|
2715
|
+
}
|
|
2716
|
+
if (segments.length === 0) {
|
|
2717
|
+
return getTagName(element);
|
|
2718
|
+
}
|
|
2719
|
+
const fullSelector = segments.join(" > ");
|
|
2720
|
+
return fullSelector;
|
|
2721
|
+
}
|
|
2722
|
+
function buildSegment(element, _document) {
|
|
2723
|
+
const tag = getTagName(element);
|
|
2724
|
+
const parent = element.parentElement;
|
|
2725
|
+
if (parent) {
|
|
2726
|
+
const siblingsWithSameTag = Array.from(parent.children).filter(
|
|
2727
|
+
(child) => getTagName(child) === tag
|
|
2728
|
+
);
|
|
2729
|
+
if (siblingsWithSameTag.length === 1) {
|
|
2730
|
+
return tag;
|
|
2731
|
+
}
|
|
2732
|
+
} else {
|
|
2733
|
+
return tag;
|
|
2734
|
+
}
|
|
2735
|
+
const classes = getStableClasses(element);
|
|
2736
|
+
if (classes.length > 0) {
|
|
2737
|
+
const tagWithClasses = `${tag}.${classes.join(".")}`;
|
|
2738
|
+
if (parent) {
|
|
2739
|
+
const siblingsMatchingClasses = Array.from(parent.children).filter(
|
|
2740
|
+
(child) => {
|
|
2741
|
+
if (getTagName(child) !== tag) return false;
|
|
2742
|
+
return classes.every((cls) => child.classList.contains(cls));
|
|
2743
|
+
}
|
|
2744
|
+
);
|
|
2745
|
+
if (siblingsMatchingClasses.length === 1) {
|
|
2746
|
+
return tagWithClasses;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
const index = getNthChildIndex(element);
|
|
2751
|
+
return `${tag}:nth-child(${index})`;
|
|
2752
|
+
}
|
|
2753
|
+
function getTagName(element) {
|
|
2754
|
+
return element.tagName.toLowerCase();
|
|
2755
|
+
}
|
|
2756
|
+
function getStableClasses(element) {
|
|
2757
|
+
const classes = Array.from(element.classList);
|
|
2758
|
+
if (classes.length === 0) return [];
|
|
2759
|
+
const stableClasses = classes.filter((cls) => !looksLikeDynamicClass(cls));
|
|
2760
|
+
return stableClasses.sort().map(escapeCSSIdentifier);
|
|
2761
|
+
}
|
|
2762
|
+
function looksLikeDynamicClass(className) {
|
|
2763
|
+
if (/^[a-f0-9]{5,}$/i.test(className)) return true;
|
|
2764
|
+
if (/[_-][a-f0-9]{5,}$/i.test(className)) return true;
|
|
2765
|
+
if (/_{2,}[A-Za-z0-9]{4,}$/.test(className)) return true;
|
|
2766
|
+
return false;
|
|
2767
|
+
}
|
|
2768
|
+
function getNthChildIndex(element) {
|
|
2769
|
+
const parent = element.parentElement;
|
|
2770
|
+
if (!parent) return 1;
|
|
2771
|
+
const children = Array.from(parent.children);
|
|
2772
|
+
const index = children.indexOf(element);
|
|
2773
|
+
return index + 1;
|
|
2774
|
+
}
|
|
2775
|
+
function escapeCSSIdentifier(value) {
|
|
2776
|
+
if (!value) return value;
|
|
2777
|
+
let result = "";
|
|
2778
|
+
for (let i = 0; i < value.length; i++) {
|
|
2779
|
+
const char = value[i];
|
|
2780
|
+
const code = char.charCodeAt(0);
|
|
2781
|
+
if (i === 0 && code >= 48 && code <= 57) {
|
|
2782
|
+
result += `\\3${char} `;
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
if (char === "!" || char === '"' || char === "#" || char === "$" || char === "%" || char === "&" || char === "'" || char === "(" || char === ")" || char === "*" || char === "+" || char === "," || char === "." || char === "/" || char === ":" || char === ";" || char === "<" || char === "=" || char === ">" || char === "?" || char === "@" || char === "[" || char === "\\" || char === "]" || char === "^" || char === "`" || char === "{" || char === "|" || char === "}" || char === "~" || char === " ") {
|
|
2786
|
+
result += `\\${char}`;
|
|
2787
|
+
continue;
|
|
2788
|
+
}
|
|
2789
|
+
result += char;
|
|
2790
|
+
}
|
|
2791
|
+
return result;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// src/runtime/observers/focus-observer.ts
|
|
2795
|
+
function computeAccessibleName2(element, document) {
|
|
2796
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
2797
|
+
if (ariaLabel && ariaLabel.trim()) {
|
|
2798
|
+
return ariaLabel.trim();
|
|
2799
|
+
}
|
|
2800
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
2801
|
+
if (labelledBy && labelledBy.trim()) {
|
|
2802
|
+
const ids = labelledBy.trim().split(/\s+/);
|
|
2803
|
+
const parts = [];
|
|
2804
|
+
for (const id of ids) {
|
|
2805
|
+
const referenced = document.getElementById(id);
|
|
2806
|
+
if (referenced && referenced.textContent) {
|
|
2807
|
+
parts.push(referenced.textContent.trim());
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
const combined = parts.join(" ");
|
|
2811
|
+
if (combined) {
|
|
2812
|
+
return combined;
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
const textContent = element.textContent;
|
|
2816
|
+
if (textContent && textContent.trim()) {
|
|
2817
|
+
return textContent.trim();
|
|
2818
|
+
}
|
|
2819
|
+
return "";
|
|
2820
|
+
}
|
|
2821
|
+
function getElementRole(element) {
|
|
2822
|
+
const explicitRole = element.getAttribute("role");
|
|
2823
|
+
if (explicitRole && explicitRole.trim()) {
|
|
2824
|
+
return explicitRole.trim();
|
|
2825
|
+
}
|
|
2826
|
+
const tagName = element.tagName.toLowerCase();
|
|
2827
|
+
switch (tagName) {
|
|
2828
|
+
case "button":
|
|
2829
|
+
return "button";
|
|
2830
|
+
case "a":
|
|
2831
|
+
return element.hasAttribute("href") ? "link" : "generic";
|
|
2832
|
+
case "input": {
|
|
2833
|
+
const type = (element.getAttribute("type") || "text").toLowerCase();
|
|
2834
|
+
switch (type) {
|
|
2835
|
+
case "button":
|
|
2836
|
+
case "submit":
|
|
2837
|
+
case "reset":
|
|
2838
|
+
case "image":
|
|
2839
|
+
return "button";
|
|
2840
|
+
case "checkbox":
|
|
2841
|
+
return "checkbox";
|
|
2842
|
+
case "radio":
|
|
2843
|
+
return "radio";
|
|
2844
|
+
case "range":
|
|
2845
|
+
return "slider";
|
|
2846
|
+
case "search":
|
|
2847
|
+
return "searchbox";
|
|
2848
|
+
default:
|
|
2849
|
+
return "textbox";
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
case "textarea":
|
|
2853
|
+
return "textbox";
|
|
2854
|
+
case "select":
|
|
2855
|
+
return element.hasAttribute("multiple") ? "listbox" : "combobox";
|
|
2856
|
+
case "nav":
|
|
2857
|
+
return "navigation";
|
|
2858
|
+
case "main":
|
|
2859
|
+
return "main";
|
|
2860
|
+
case "header":
|
|
2861
|
+
return "banner";
|
|
2862
|
+
case "footer":
|
|
2863
|
+
return "contentinfo";
|
|
2864
|
+
case "aside":
|
|
2865
|
+
return "complementary";
|
|
2866
|
+
case "form":
|
|
2867
|
+
return "form";
|
|
2868
|
+
case "section":
|
|
2869
|
+
return element.hasAttribute("aria-label") || element.hasAttribute("aria-labelledby") ? "region" : "generic";
|
|
2870
|
+
case "article":
|
|
2871
|
+
return "article";
|
|
2872
|
+
case "dialog":
|
|
2873
|
+
return "dialog";
|
|
2874
|
+
case "table":
|
|
2875
|
+
return "table";
|
|
2876
|
+
case "ul":
|
|
2877
|
+
case "ol":
|
|
2878
|
+
return "list";
|
|
2879
|
+
case "li":
|
|
2880
|
+
return "listitem";
|
|
2881
|
+
case "img":
|
|
2882
|
+
return element.getAttribute("alt") === "" ? "presentation" : "img";
|
|
2883
|
+
case "h1":
|
|
2884
|
+
case "h2":
|
|
2885
|
+
case "h3":
|
|
2886
|
+
case "h4":
|
|
2887
|
+
case "h5":
|
|
2888
|
+
case "h6":
|
|
2889
|
+
return "heading";
|
|
2890
|
+
case "option":
|
|
2891
|
+
return "option";
|
|
2892
|
+
case "progress":
|
|
2893
|
+
return "progressbar";
|
|
2894
|
+
case "output":
|
|
2895
|
+
return "status";
|
|
2896
|
+
case "menu":
|
|
2897
|
+
return "menu";
|
|
2898
|
+
default:
|
|
2899
|
+
return "generic";
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
function createFocusObserver(options) {
|
|
2903
|
+
const { document: doc, onEvent, getTimestamp } = options;
|
|
2904
|
+
let active = false;
|
|
2905
|
+
let previousTarget = null;
|
|
2906
|
+
function buildEventTarget2(element) {
|
|
2907
|
+
return {
|
|
2908
|
+
role: getElementRole(element),
|
|
2909
|
+
accessibleName: computeAccessibleName2(element, doc),
|
|
2910
|
+
selector: generateSelector(element)
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
function handleFocusIn(event) {
|
|
2914
|
+
if (!active) return;
|
|
2915
|
+
const target = event.target;
|
|
2916
|
+
if (!target || !(target instanceof doc.defaultView.Element)) {
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const currentTarget = buildEventTarget2(target);
|
|
2920
|
+
const a11yEvent = {
|
|
2921
|
+
type: "FOCUS_CHANGED",
|
|
2922
|
+
timestamp: getTimestamp(),
|
|
2923
|
+
target: currentTarget,
|
|
2924
|
+
payload: {
|
|
2925
|
+
kind: "focus_changed",
|
|
2926
|
+
previousTarget
|
|
2927
|
+
}
|
|
2928
|
+
};
|
|
2929
|
+
onEvent(a11yEvent);
|
|
2930
|
+
previousTarget = currentTarget;
|
|
2931
|
+
}
|
|
2932
|
+
return {
|
|
2933
|
+
start() {
|
|
2934
|
+
if (active) return;
|
|
2935
|
+
active = true;
|
|
2936
|
+
previousTarget = null;
|
|
2937
|
+
doc.addEventListener("focusin", handleFocusIn);
|
|
2938
|
+
},
|
|
2939
|
+
stop() {
|
|
2940
|
+
if (!active) return;
|
|
2941
|
+
active = false;
|
|
2942
|
+
doc.removeEventListener("focusin", handleFocusIn);
|
|
2943
|
+
},
|
|
2944
|
+
get isActive() {
|
|
2945
|
+
return active;
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
// src/runtime/observers/mutation-observer.ts
|
|
2951
|
+
var ROLE_ATTRIBUTE = "role";
|
|
2952
|
+
var NAME_ATTRIBUTES = /* @__PURE__ */ new Set(["aria-label", "aria-labelledby"]);
|
|
2953
|
+
var STATE_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
2954
|
+
"aria-expanded",
|
|
2955
|
+
"aria-selected",
|
|
2956
|
+
"aria-checked",
|
|
2957
|
+
"aria-disabled",
|
|
2958
|
+
"aria-hidden"
|
|
2959
|
+
]);
|
|
2960
|
+
var OBSERVED_ATTRIBUTES = [
|
|
2961
|
+
ROLE_ATTRIBUTE,
|
|
2962
|
+
"aria-label",
|
|
2963
|
+
"aria-labelledby",
|
|
2964
|
+
"aria-expanded",
|
|
2965
|
+
"aria-selected",
|
|
2966
|
+
"aria-checked",
|
|
2967
|
+
"aria-disabled",
|
|
2968
|
+
"aria-hidden"
|
|
2969
|
+
];
|
|
2970
|
+
function computeAccessibleName3(element, document) {
|
|
2971
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
2972
|
+
if (ariaLabel && ariaLabel.trim()) {
|
|
2973
|
+
return ariaLabel.trim();
|
|
2974
|
+
}
|
|
2975
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
2976
|
+
if (labelledBy && labelledBy.trim()) {
|
|
2977
|
+
const ids = labelledBy.trim().split(/\s+/);
|
|
2978
|
+
const parts = [];
|
|
2979
|
+
for (const id of ids) {
|
|
2980
|
+
const referenced = document.getElementById(id);
|
|
2981
|
+
if (referenced && referenced.textContent) {
|
|
2982
|
+
parts.push(referenced.textContent.trim());
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
const combined = parts.join(" ");
|
|
2986
|
+
if (combined) {
|
|
2987
|
+
return combined;
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
const textContent = element.textContent;
|
|
2991
|
+
if (textContent && textContent.trim()) {
|
|
2992
|
+
return textContent.trim();
|
|
2993
|
+
}
|
|
2994
|
+
return "";
|
|
2995
|
+
}
|
|
2996
|
+
function getElementRole2(element) {
|
|
2997
|
+
const explicitRole = element.getAttribute("role");
|
|
2998
|
+
if (explicitRole && explicitRole.trim()) {
|
|
2999
|
+
return explicitRole.trim();
|
|
3000
|
+
}
|
|
3001
|
+
const tagName = element.tagName.toLowerCase();
|
|
3002
|
+
switch (tagName) {
|
|
3003
|
+
case "button":
|
|
3004
|
+
return "button";
|
|
3005
|
+
case "a":
|
|
3006
|
+
return element.hasAttribute("href") ? "link" : "generic";
|
|
3007
|
+
case "input": {
|
|
3008
|
+
const type = (element.getAttribute("type") || "text").toLowerCase();
|
|
3009
|
+
switch (type) {
|
|
3010
|
+
case "button":
|
|
3011
|
+
case "submit":
|
|
3012
|
+
case "reset":
|
|
3013
|
+
case "image":
|
|
3014
|
+
return "button";
|
|
3015
|
+
case "checkbox":
|
|
3016
|
+
return "checkbox";
|
|
3017
|
+
case "radio":
|
|
3018
|
+
return "radio";
|
|
3019
|
+
case "range":
|
|
3020
|
+
return "slider";
|
|
3021
|
+
case "search":
|
|
3022
|
+
return "searchbox";
|
|
3023
|
+
default:
|
|
3024
|
+
return "textbox";
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
case "textarea":
|
|
3028
|
+
return "textbox";
|
|
3029
|
+
case "select":
|
|
3030
|
+
return element.hasAttribute("multiple") ? "listbox" : "combobox";
|
|
3031
|
+
case "nav":
|
|
3032
|
+
return "navigation";
|
|
3033
|
+
case "main":
|
|
3034
|
+
return "main";
|
|
3035
|
+
case "header":
|
|
3036
|
+
return "banner";
|
|
3037
|
+
case "footer":
|
|
3038
|
+
return "contentinfo";
|
|
3039
|
+
case "aside":
|
|
3040
|
+
return "complementary";
|
|
3041
|
+
case "form":
|
|
3042
|
+
return "form";
|
|
3043
|
+
case "section":
|
|
3044
|
+
return element.hasAttribute("aria-label") || element.hasAttribute("aria-labelledby") ? "region" : "generic";
|
|
3045
|
+
case "article":
|
|
3046
|
+
return "article";
|
|
3047
|
+
case "dialog":
|
|
3048
|
+
return "dialog";
|
|
3049
|
+
case "table":
|
|
3050
|
+
return "table";
|
|
3051
|
+
case "ul":
|
|
3052
|
+
case "ol":
|
|
3053
|
+
return "list";
|
|
3054
|
+
case "li":
|
|
3055
|
+
return "listitem";
|
|
3056
|
+
case "img":
|
|
3057
|
+
return element.getAttribute("alt") === "" ? "presentation" : "img";
|
|
3058
|
+
case "h1":
|
|
3059
|
+
case "h2":
|
|
3060
|
+
case "h3":
|
|
3061
|
+
case "h4":
|
|
3062
|
+
case "h5":
|
|
3063
|
+
case "h6":
|
|
3064
|
+
return "heading";
|
|
3065
|
+
case "option":
|
|
3066
|
+
return "option";
|
|
3067
|
+
case "progress":
|
|
3068
|
+
return "progressbar";
|
|
3069
|
+
case "output":
|
|
3070
|
+
return "status";
|
|
3071
|
+
case "menu":
|
|
3072
|
+
return "menu";
|
|
3073
|
+
default:
|
|
3074
|
+
return "generic";
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
function createMutationObserver(options) {
|
|
3078
|
+
const { document: doc, onEvent, getTimestamp } = options;
|
|
3079
|
+
let observer = null;
|
|
3080
|
+
let active = false;
|
|
3081
|
+
function handleMutations(mutations) {
|
|
3082
|
+
if (!active) return;
|
|
3083
|
+
for (const mutation of mutations) {
|
|
3084
|
+
if (mutation.type !== "attributes") {
|
|
3085
|
+
continue;
|
|
3086
|
+
}
|
|
3087
|
+
const attributeName = mutation.attributeName;
|
|
3088
|
+
if (!attributeName) {
|
|
3089
|
+
continue;
|
|
3090
|
+
}
|
|
3091
|
+
const target = mutation.target;
|
|
3092
|
+
if (!(target instanceof Element)) {
|
|
3093
|
+
continue;
|
|
3094
|
+
}
|
|
3095
|
+
if (attributeName === ROLE_ATTRIBUTE) {
|
|
3096
|
+
handleRoleChange(target, mutation.oldValue);
|
|
3097
|
+
} else if (NAME_ATTRIBUTES.has(attributeName)) {
|
|
3098
|
+
handleNameChange(target, attributeName, mutation.oldValue);
|
|
3099
|
+
} else if (STATE_ATTRIBUTES.has(attributeName)) {
|
|
3100
|
+
handleStateChange(target, attributeName, mutation.oldValue);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
function handleRoleChange(element, oldValue) {
|
|
3105
|
+
const previousRole = oldValue?.trim() || getImplicitRole(element);
|
|
3106
|
+
const newRole = element.getAttribute("role")?.trim() || getImplicitRole(element);
|
|
3107
|
+
if (previousRole === newRole) {
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
const eventTarget = buildEventTarget2(element);
|
|
3111
|
+
const payload = {
|
|
3112
|
+
kind: "role_changed",
|
|
3113
|
+
previousRole,
|
|
3114
|
+
newRole
|
|
3115
|
+
};
|
|
3116
|
+
onEvent({
|
|
3117
|
+
type: "ROLE_CHANGED",
|
|
3118
|
+
timestamp: getTimestamp(),
|
|
3119
|
+
target: eventTarget,
|
|
3120
|
+
payload
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
function handleNameChange(element, attributeName, oldValue) {
|
|
3124
|
+
let previousName;
|
|
3125
|
+
let newName;
|
|
3126
|
+
if (attributeName === "aria-label") {
|
|
3127
|
+
previousName = oldValue?.trim() || "";
|
|
3128
|
+
newName = computeAccessibleName3(element, doc);
|
|
3129
|
+
if (!previousName && oldValue === null) {
|
|
3130
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
3131
|
+
if (labelledBy) {
|
|
3132
|
+
previousName = resolveAriaLabelledBy(element, labelledBy);
|
|
3133
|
+
} else {
|
|
3134
|
+
previousName = element.textContent?.trim() || "";
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
} else {
|
|
3138
|
+
previousName = oldValue ? resolveAriaLabelledBy(element, oldValue) : "";
|
|
3139
|
+
if (!previousName && oldValue === null) {
|
|
3140
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
3141
|
+
if (ariaLabel) {
|
|
3142
|
+
previousName = ariaLabel.trim();
|
|
3143
|
+
} else {
|
|
3144
|
+
previousName = element.textContent?.trim() || "";
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
newName = computeAccessibleName3(element, doc);
|
|
3148
|
+
}
|
|
3149
|
+
if (previousName === newName) {
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
const eventTarget = buildEventTarget2(element);
|
|
3153
|
+
const payload = {
|
|
3154
|
+
kind: "accessible_name_changed",
|
|
3155
|
+
previousName,
|
|
3156
|
+
newName
|
|
3157
|
+
};
|
|
3158
|
+
onEvent({
|
|
3159
|
+
type: "ACCESSIBLE_NAME_CHANGED",
|
|
3160
|
+
timestamp: getTimestamp(),
|
|
3161
|
+
target: eventTarget,
|
|
3162
|
+
payload
|
|
3163
|
+
});
|
|
3164
|
+
}
|
|
3165
|
+
function handleStateChange(element, attributeName, oldValue) {
|
|
3166
|
+
const currentValue = element.getAttribute(attributeName);
|
|
3167
|
+
const normalizedOld = normalizeStateValue(oldValue);
|
|
3168
|
+
const normalizedNew = normalizeStateValue(currentValue);
|
|
3169
|
+
if (normalizedOld === normalizedNew) {
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
const eventTarget = buildEventTarget2(element);
|
|
3173
|
+
const payload = {
|
|
3174
|
+
kind: "state_changed",
|
|
3175
|
+
attribute: attributeName,
|
|
3176
|
+
previousValue: normalizedOld,
|
|
3177
|
+
newValue: normalizedNew
|
|
3178
|
+
};
|
|
3179
|
+
onEvent({
|
|
3180
|
+
type: "STATE_CHANGED",
|
|
3181
|
+
timestamp: getTimestamp(),
|
|
3182
|
+
target: eventTarget,
|
|
3183
|
+
payload
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
function buildEventTarget2(element) {
|
|
3187
|
+
return {
|
|
3188
|
+
role: getElementRole2(element),
|
|
3189
|
+
accessibleName: computeAccessibleName3(element, doc),
|
|
3190
|
+
selector: generateSelector(element)
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
function getImplicitRole(element) {
|
|
3194
|
+
const tagName = element.tagName.toLowerCase();
|
|
3195
|
+
switch (tagName) {
|
|
3196
|
+
case "button":
|
|
3197
|
+
return "button";
|
|
3198
|
+
case "a":
|
|
3199
|
+
return element.hasAttribute("href") ? "link" : "generic";
|
|
3200
|
+
case "input": {
|
|
3201
|
+
const type = (element.getAttribute("type") || "text").toLowerCase();
|
|
3202
|
+
switch (type) {
|
|
3203
|
+
case "button":
|
|
3204
|
+
case "submit":
|
|
3205
|
+
case "reset":
|
|
3206
|
+
case "image":
|
|
3207
|
+
return "button";
|
|
3208
|
+
case "checkbox":
|
|
3209
|
+
return "checkbox";
|
|
3210
|
+
case "radio":
|
|
3211
|
+
return "radio";
|
|
3212
|
+
default:
|
|
3213
|
+
return "textbox";
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
default:
|
|
3217
|
+
return "generic";
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
function resolveAriaLabelledBy(element, idRefs) {
|
|
3221
|
+
const ownerDoc = element.ownerDocument;
|
|
3222
|
+
if (!ownerDoc) {
|
|
3223
|
+
return "";
|
|
3224
|
+
}
|
|
3225
|
+
const ids = idRefs.trim().split(/\s+/).filter(Boolean);
|
|
3226
|
+
const parts = [];
|
|
3227
|
+
for (const id of ids) {
|
|
3228
|
+
const referenced = ownerDoc.getElementById(id);
|
|
3229
|
+
if (referenced && referenced.textContent) {
|
|
3230
|
+
parts.push(referenced.textContent.trim());
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
return parts.join(" ");
|
|
3234
|
+
}
|
|
3235
|
+
function normalizeStateValue(value) {
|
|
3236
|
+
if (value === null) {
|
|
3237
|
+
return null;
|
|
3238
|
+
}
|
|
3239
|
+
if (value === "true") {
|
|
3240
|
+
return true;
|
|
3241
|
+
}
|
|
3242
|
+
if (value === "false") {
|
|
3243
|
+
return false;
|
|
3244
|
+
}
|
|
3245
|
+
return value;
|
|
3246
|
+
}
|
|
3247
|
+
return {
|
|
3248
|
+
start() {
|
|
3249
|
+
if (active) return;
|
|
3250
|
+
active = true;
|
|
3251
|
+
observer = new MutationObserver(handleMutations);
|
|
3252
|
+
observer.observe(doc.body ?? doc.documentElement, {
|
|
3253
|
+
attributes: true,
|
|
3254
|
+
attributeOldValue: true,
|
|
3255
|
+
attributeFilter: OBSERVED_ATTRIBUTES,
|
|
3256
|
+
subtree: true
|
|
3257
|
+
});
|
|
3258
|
+
},
|
|
3259
|
+
stop() {
|
|
3260
|
+
if (!active) return;
|
|
3261
|
+
active = false;
|
|
3262
|
+
if (observer) {
|
|
3263
|
+
observer.disconnect();
|
|
3264
|
+
observer = null;
|
|
3265
|
+
}
|
|
3266
|
+
},
|
|
3267
|
+
get isActive() {
|
|
3268
|
+
return active;
|
|
3269
|
+
}
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
// src/runtime/observers/live-region-observer.ts
|
|
3274
|
+
var IMPLICIT_LIVE_ROLES = /* @__PURE__ */ new Set(["alert", "status", "log", "marquee", "timer"]);
|
|
3275
|
+
var ROLE_POLITENESS_MAP = {
|
|
3276
|
+
alert: "assertive",
|
|
3277
|
+
status: "polite",
|
|
3278
|
+
log: "polite",
|
|
3279
|
+
marquee: "polite",
|
|
3280
|
+
timer: "polite"
|
|
3281
|
+
};
|
|
3282
|
+
function createLiveRegionObserver(document, callback) {
|
|
3283
|
+
let contentObserver = null;
|
|
3284
|
+
let documentObserver = null;
|
|
3285
|
+
const trackedRegions = /* @__PURE__ */ new Set();
|
|
3286
|
+
function getLiveRegionPoliteness(element) {
|
|
3287
|
+
const ariaLive = element.getAttribute("aria-live");
|
|
3288
|
+
if (ariaLive === "assertive") return "assertive";
|
|
3289
|
+
if (ariaLive === "polite") return "polite";
|
|
3290
|
+
if (ariaLive === "off") return null;
|
|
3291
|
+
const role = element.getAttribute("role");
|
|
3292
|
+
if (role && IMPLICIT_LIVE_ROLES.has(role)) {
|
|
3293
|
+
return ROLE_POLITENESS_MAP[role] || "polite";
|
|
3294
|
+
}
|
|
3295
|
+
if (ariaLive && ariaLive !== "off") return "polite";
|
|
3296
|
+
return null;
|
|
3297
|
+
}
|
|
3298
|
+
function isAtomic(element) {
|
|
3299
|
+
return element.getAttribute("aria-atomic") === "true";
|
|
3300
|
+
}
|
|
3301
|
+
function buildEventTarget2(element) {
|
|
3302
|
+
const role = element.getAttribute("role") || "region";
|
|
3303
|
+
const accessibleName = element.getAttribute("aria-label") || element.getAttribute("aria-labelledby") || "";
|
|
3304
|
+
const selector = generateSelector(element);
|
|
3305
|
+
return { role, accessibleName, selector };
|
|
3306
|
+
}
|
|
3307
|
+
function getTextFromNodes(nodes) {
|
|
3308
|
+
const parts = [];
|
|
3309
|
+
nodes.forEach((node) => {
|
|
3310
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
3311
|
+
const text = node.textContent?.trim();
|
|
3312
|
+
if (text) parts.push(text);
|
|
3313
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
3314
|
+
const text = node.textContent?.trim();
|
|
3315
|
+
if (text) parts.push(text);
|
|
3316
|
+
}
|
|
3317
|
+
});
|
|
3318
|
+
return parts.join(" ").trim();
|
|
3319
|
+
}
|
|
3320
|
+
function emitAnnouncement(liveRegion, text, politeness) {
|
|
3321
|
+
if (!text) return;
|
|
3322
|
+
const target = buildEventTarget2(liveRegion);
|
|
3323
|
+
callback({
|
|
3324
|
+
type: "ANNOUNCEMENT",
|
|
3325
|
+
target,
|
|
3326
|
+
payload: {
|
|
3327
|
+
kind: "announcement",
|
|
3328
|
+
politeness,
|
|
3329
|
+
text
|
|
3330
|
+
}
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
function findLiveRegionAncestor(node) {
|
|
3334
|
+
let current = node;
|
|
3335
|
+
while (current) {
|
|
3336
|
+
if (current.nodeType === Node.ELEMENT_NODE && trackedRegions.has(current)) {
|
|
3337
|
+
return current;
|
|
3338
|
+
}
|
|
3339
|
+
current = current.parentNode;
|
|
3340
|
+
}
|
|
3341
|
+
return null;
|
|
3342
|
+
}
|
|
3343
|
+
function handleContentMutations(mutations) {
|
|
3344
|
+
const regionChanges = /* @__PURE__ */ new Map();
|
|
3345
|
+
for (const mutation of mutations) {
|
|
3346
|
+
const liveRegion = findLiveRegionAncestor(mutation.target);
|
|
3347
|
+
if (!liveRegion) continue;
|
|
3348
|
+
if (!regionChanges.has(liveRegion)) {
|
|
3349
|
+
regionChanges.set(liveRegion, { addedText: [], charDataChanged: false });
|
|
3350
|
+
}
|
|
3351
|
+
const changes = regionChanges.get(liveRegion);
|
|
3352
|
+
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
|
|
3353
|
+
const text = getTextFromNodes(mutation.addedNodes);
|
|
3354
|
+
if (text) {
|
|
3355
|
+
changes.addedText.push(text);
|
|
3356
|
+
}
|
|
3357
|
+
} else if (mutation.type === "characterData") {
|
|
3358
|
+
changes.charDataChanged = true;
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
for (const [region, changes] of regionChanges) {
|
|
3362
|
+
const politeness = getLiveRegionPoliteness(region);
|
|
3363
|
+
if (!politeness) continue;
|
|
3364
|
+
let text;
|
|
3365
|
+
if (isAtomic(region)) {
|
|
3366
|
+
text = region.textContent?.trim() || "";
|
|
3367
|
+
} else {
|
|
3368
|
+
if (changes.addedText.length > 0) {
|
|
3369
|
+
text = changes.addedText.join(" ").trim();
|
|
3370
|
+
} else if (changes.charDataChanged) {
|
|
3371
|
+
text = region.textContent?.trim() || "";
|
|
3372
|
+
} else {
|
|
3373
|
+
text = "";
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
emitAnnouncement(region, text, politeness);
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
function handleDocumentMutations(mutations) {
|
|
3380
|
+
for (const mutation of mutations) {
|
|
3381
|
+
if (mutation.type !== "childList") continue;
|
|
3382
|
+
mutation.addedNodes.forEach((node) => {
|
|
3383
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
3384
|
+
const element = node;
|
|
3385
|
+
if (getLiveRegionPoliteness(element) !== null && !trackedRegions.has(element)) {
|
|
3386
|
+
trackRegion(element);
|
|
3387
|
+
}
|
|
3388
|
+
const descendants = element.querySelectorAll("[aria-live], [role]");
|
|
3389
|
+
descendants.forEach((desc) => {
|
|
3390
|
+
if (getLiveRegionPoliteness(desc) !== null && !trackedRegions.has(desc)) {
|
|
3391
|
+
trackRegion(desc);
|
|
3392
|
+
}
|
|
3393
|
+
});
|
|
3394
|
+
});
|
|
3395
|
+
mutation.removedNodes.forEach((node) => {
|
|
3396
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
3397
|
+
const element = node;
|
|
3398
|
+
if (trackedRegions.has(element)) {
|
|
3399
|
+
trackedRegions.delete(element);
|
|
3400
|
+
}
|
|
3401
|
+
const descendants = element.querySelectorAll("[aria-live], [role]");
|
|
3402
|
+
descendants.forEach((desc) => {
|
|
3403
|
+
trackedRegions.delete(desc);
|
|
3404
|
+
});
|
|
3405
|
+
});
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
function trackRegion(element) {
|
|
3409
|
+
trackedRegions.add(element);
|
|
3410
|
+
}
|
|
3411
|
+
function discoverExistingRegions() {
|
|
3412
|
+
const ariaLiveElements = document.querySelectorAll("[aria-live]");
|
|
3413
|
+
ariaLiveElements.forEach((el) => {
|
|
3414
|
+
if (getLiveRegionPoliteness(el) !== null) {
|
|
3415
|
+
trackRegion(el);
|
|
3416
|
+
}
|
|
3417
|
+
});
|
|
3418
|
+
IMPLICIT_LIVE_ROLES.forEach((role) => {
|
|
3419
|
+
const roleElements = document.querySelectorAll(`[role="${role}"]`);
|
|
3420
|
+
roleElements.forEach((el) => {
|
|
3421
|
+
if (!trackedRegions.has(el)) {
|
|
3422
|
+
trackRegion(el);
|
|
3423
|
+
}
|
|
3424
|
+
});
|
|
3425
|
+
});
|
|
3426
|
+
}
|
|
3427
|
+
function start() {
|
|
3428
|
+
discoverExistingRegions();
|
|
3429
|
+
contentObserver = new MutationObserver(handleContentMutations);
|
|
3430
|
+
const body = document.body;
|
|
3431
|
+
if (body) {
|
|
3432
|
+
contentObserver.observe(body, {
|
|
3433
|
+
childList: true,
|
|
3434
|
+
characterData: true,
|
|
3435
|
+
subtree: true,
|
|
3436
|
+
characterDataOldValue: true
|
|
3437
|
+
});
|
|
3438
|
+
}
|
|
3439
|
+
documentObserver = new MutationObserver(handleDocumentMutations);
|
|
3440
|
+
if (body) {
|
|
3441
|
+
documentObserver.observe(body, {
|
|
3442
|
+
childList: true,
|
|
3443
|
+
subtree: true
|
|
3444
|
+
});
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
function stop() {
|
|
3448
|
+
if (contentObserver) {
|
|
3449
|
+
contentObserver.disconnect();
|
|
3450
|
+
contentObserver = null;
|
|
3451
|
+
}
|
|
3452
|
+
if (documentObserver) {
|
|
3453
|
+
documentObserver.disconnect();
|
|
3454
|
+
documentObserver = null;
|
|
3455
|
+
}
|
|
3456
|
+
trackedRegions.clear();
|
|
3457
|
+
}
|
|
3458
|
+
return { start, stop };
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
// src/runtime/observers/dialog-observer.ts
|
|
3462
|
+
function computeDialogAccessibleName(element, document) {
|
|
3463
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
3464
|
+
if (ariaLabel && ariaLabel.trim()) {
|
|
3465
|
+
return ariaLabel.trim();
|
|
3466
|
+
}
|
|
3467
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
3468
|
+
if (labelledBy && labelledBy.trim()) {
|
|
3469
|
+
const ids = labelledBy.trim().split(/\s+/);
|
|
3470
|
+
const parts = [];
|
|
3471
|
+
for (const id of ids) {
|
|
3472
|
+
const referenced = document.getElementById(id);
|
|
3473
|
+
if (referenced && referenced.textContent) {
|
|
3474
|
+
parts.push(referenced.textContent.trim());
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
const combined = parts.join(" ");
|
|
3478
|
+
if (combined) {
|
|
3479
|
+
return combined;
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
const title = element.getAttribute("title");
|
|
3483
|
+
if (title && title.trim()) {
|
|
3484
|
+
return title.trim();
|
|
3485
|
+
}
|
|
3486
|
+
const heading = element.querySelector("h1, h2, h3, h4, h5, h6");
|
|
3487
|
+
if (heading && heading.textContent && heading.textContent.trim()) {
|
|
3488
|
+
return heading.textContent.trim();
|
|
3489
|
+
}
|
|
3490
|
+
return "";
|
|
3491
|
+
}
|
|
3492
|
+
function getDialogRole(element) {
|
|
3493
|
+
const explicitRole = element.getAttribute("role");
|
|
3494
|
+
if (explicitRole && explicitRole.trim()) {
|
|
3495
|
+
return explicitRole.trim();
|
|
3496
|
+
}
|
|
3497
|
+
if (element.tagName.toLowerCase() === "dialog") {
|
|
3498
|
+
return "dialog";
|
|
3499
|
+
}
|
|
3500
|
+
return "generic";
|
|
3501
|
+
}
|
|
3502
|
+
function isDialogElement(element) {
|
|
3503
|
+
if (element.tagName.toLowerCase() === "dialog") {
|
|
3504
|
+
return true;
|
|
3505
|
+
}
|
|
3506
|
+
const role = element.getAttribute("role");
|
|
3507
|
+
return role === "dialog" || role === "alertdialog";
|
|
3508
|
+
}
|
|
3509
|
+
function isDialogModal(element) {
|
|
3510
|
+
const ariaModal = element.getAttribute("aria-modal");
|
|
3511
|
+
if (ariaModal === "true") {
|
|
3512
|
+
return true;
|
|
3513
|
+
}
|
|
3514
|
+
return false;
|
|
3515
|
+
}
|
|
3516
|
+
function createDialogObserver(options) {
|
|
3517
|
+
const { document: doc, onEvent, getTimestamp } = options;
|
|
3518
|
+
let active = false;
|
|
3519
|
+
let mutationObserver = null;
|
|
3520
|
+
const openDialogStack = [];
|
|
3521
|
+
function buildEventTarget2(element) {
|
|
3522
|
+
return {
|
|
3523
|
+
role: getDialogRole(element),
|
|
3524
|
+
accessibleName: computeDialogAccessibleName(element, doc),
|
|
3525
|
+
selector: generateSelector(element)
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
function emitDialogOpened(element) {
|
|
3529
|
+
const name = computeDialogAccessibleName(element, doc);
|
|
3530
|
+
const isModal = isDialogModal(element);
|
|
3531
|
+
const target = buildEventTarget2(element);
|
|
3532
|
+
openDialogStack.push({
|
|
3533
|
+
element,
|
|
3534
|
+
name,
|
|
3535
|
+
selector: target.selector
|
|
3536
|
+
});
|
|
3537
|
+
const a11yEvent = {
|
|
3538
|
+
type: "DIALOG_OPENED",
|
|
3539
|
+
timestamp: getTimestamp(),
|
|
3540
|
+
target,
|
|
3541
|
+
payload: {
|
|
3542
|
+
kind: "dialog_opened",
|
|
3543
|
+
dialogName: name,
|
|
3544
|
+
isModal
|
|
3545
|
+
}
|
|
3546
|
+
};
|
|
3547
|
+
onEvent(a11yEvent);
|
|
3548
|
+
}
|
|
3549
|
+
function emitDialogClosed(element) {
|
|
3550
|
+
const name = computeDialogAccessibleName(element, doc);
|
|
3551
|
+
const target = buildEventTarget2(element);
|
|
3552
|
+
const index = openDialogStack.findIndex((d) => d.element === element);
|
|
3553
|
+
if (index !== -1) {
|
|
3554
|
+
openDialogStack.splice(index, 1);
|
|
3555
|
+
}
|
|
3556
|
+
const a11yEvent = {
|
|
3557
|
+
type: "DIALOG_CLOSED",
|
|
3558
|
+
timestamp: getTimestamp(),
|
|
3559
|
+
target,
|
|
3560
|
+
payload: {
|
|
3561
|
+
kind: "dialog_closed",
|
|
3562
|
+
dialogName: name
|
|
3563
|
+
}
|
|
3564
|
+
};
|
|
3565
|
+
onEvent(a11yEvent);
|
|
3566
|
+
}
|
|
3567
|
+
function isTrackedAsOpen(element) {
|
|
3568
|
+
return openDialogStack.some((d) => d.element === element);
|
|
3569
|
+
}
|
|
3570
|
+
function handleMutations(mutations) {
|
|
3571
|
+
if (!active) return;
|
|
3572
|
+
for (const mutation of mutations) {
|
|
3573
|
+
if (mutation.type === "attributes") {
|
|
3574
|
+
const target = mutation.target;
|
|
3575
|
+
if (!(target instanceof doc.defaultView.Element)) continue;
|
|
3576
|
+
if (mutation.attributeName === "open" && target.tagName.toLowerCase() === "dialog") {
|
|
3577
|
+
if (target.hasAttribute("open")) {
|
|
3578
|
+
if (!isTrackedAsOpen(target)) {
|
|
3579
|
+
emitDialogOpened(target);
|
|
3580
|
+
}
|
|
3581
|
+
} else {
|
|
3582
|
+
if (isTrackedAsOpen(target)) {
|
|
3583
|
+
emitDialogClosed(target);
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
if (mutation.attributeName === "aria-modal" && isDialogElement(target)) {
|
|
3588
|
+
if (!isTrackedAsOpen(target) && isVisibleDialog(target)) {
|
|
3589
|
+
emitDialogOpened(target);
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
if (mutation.attributeName === "role") {
|
|
3593
|
+
const oldRole = mutation.oldValue;
|
|
3594
|
+
const newRole = target.getAttribute("role");
|
|
3595
|
+
const wasDialog = oldRole === "dialog" || oldRole === "alertdialog";
|
|
3596
|
+
const isNowDialog = newRole === "dialog" || newRole === "alertdialog";
|
|
3597
|
+
if (!wasDialog && isNowDialog && isVisibleDialog(target)) {
|
|
3598
|
+
emitDialogOpened(target);
|
|
3599
|
+
} else if (wasDialog && !isNowDialog && isTrackedAsOpen(target)) {
|
|
3600
|
+
emitDialogClosed(target);
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
if (mutation.type === "childList") {
|
|
3605
|
+
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
|
3606
|
+
const node = mutation.addedNodes[i];
|
|
3607
|
+
if (!(node instanceof doc.defaultView.Element)) continue;
|
|
3608
|
+
if (isDialogElement(node) && isVisibleDialog(node)) {
|
|
3609
|
+
if (!isTrackedAsOpen(node)) {
|
|
3610
|
+
emitDialogOpened(node);
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
const dialogDescendants = node.querySelectorAll(
|
|
3614
|
+
'dialog[open], [role="dialog"], [role="alertdialog"]'
|
|
3615
|
+
);
|
|
3616
|
+
for (let j = 0; j < dialogDescendants.length; j++) {
|
|
3617
|
+
const dialog = dialogDescendants[j];
|
|
3618
|
+
if (isVisibleDialog(dialog) && !isTrackedAsOpen(dialog)) {
|
|
3619
|
+
emitDialogOpened(dialog);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
for (let i = 0; i < mutation.removedNodes.length; i++) {
|
|
3624
|
+
const node = mutation.removedNodes[i];
|
|
3625
|
+
if (!(node instanceof doc.defaultView.Element)) continue;
|
|
3626
|
+
if (isTrackedAsOpen(node)) {
|
|
3627
|
+
emitDialogClosed(node);
|
|
3628
|
+
}
|
|
3629
|
+
const trackedInRemoved = openDialogStack.filter(
|
|
3630
|
+
(d) => node.contains(d.element)
|
|
3631
|
+
);
|
|
3632
|
+
for (const tracked of trackedInRemoved) {
|
|
3633
|
+
emitDialogClosed(tracked.element);
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
function isVisibleDialog(element) {
|
|
3640
|
+
if (element.tagName.toLowerCase() === "dialog") {
|
|
3641
|
+
return element.hasAttribute("open");
|
|
3642
|
+
}
|
|
3643
|
+
return true;
|
|
3644
|
+
}
|
|
3645
|
+
return {
|
|
3646
|
+
start() {
|
|
3647
|
+
if (active) return;
|
|
3648
|
+
active = true;
|
|
3649
|
+
openDialogStack.length = 0;
|
|
3650
|
+
const existingDialogs = doc.querySelectorAll(
|
|
3651
|
+
'dialog[open], [role="dialog"], [role="alertdialog"]'
|
|
3652
|
+
);
|
|
3653
|
+
for (let i = 0; i < existingDialogs.length; i++) {
|
|
3654
|
+
const dialog = existingDialogs[i];
|
|
3655
|
+
if (isVisibleDialog(dialog)) {
|
|
3656
|
+
openDialogStack.push({
|
|
3657
|
+
element: dialog,
|
|
3658
|
+
name: computeDialogAccessibleName(dialog, doc),
|
|
3659
|
+
selector: generateSelector(dialog)
|
|
3660
|
+
});
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
mutationObserver = new MutationObserver(handleMutations);
|
|
3664
|
+
mutationObserver.observe(doc.body || doc.documentElement, {
|
|
3665
|
+
attributes: true,
|
|
3666
|
+
attributeFilter: ["open", "role", "aria-modal"],
|
|
3667
|
+
attributeOldValue: true,
|
|
3668
|
+
childList: true,
|
|
3669
|
+
subtree: true
|
|
3670
|
+
});
|
|
3671
|
+
},
|
|
3672
|
+
stop() {
|
|
3673
|
+
if (!active) return;
|
|
3674
|
+
active = false;
|
|
3675
|
+
if (mutationObserver) {
|
|
3676
|
+
mutationObserver.disconnect();
|
|
3677
|
+
mutationObserver = null;
|
|
3678
|
+
}
|
|
3679
|
+
openDialogStack.length = 0;
|
|
3680
|
+
},
|
|
3681
|
+
get isActive() {
|
|
3682
|
+
return active;
|
|
3683
|
+
},
|
|
3684
|
+
getOpenDialogs() {
|
|
3685
|
+
return openDialogStack.map((d) => d.name);
|
|
3686
|
+
}
|
|
3687
|
+
};
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
// src/runtime/engine.ts
|
|
3691
|
+
function createEngine(options) {
|
|
3692
|
+
const { document: doc } = options;
|
|
3693
|
+
let attached = false;
|
|
3694
|
+
let sessionStart = 0;
|
|
3695
|
+
let events = [];
|
|
3696
|
+
function getTimestamp() {
|
|
3697
|
+
return Date.now() - sessionStart;
|
|
3698
|
+
}
|
|
3699
|
+
function collectEvent(event) {
|
|
3700
|
+
events.push(event);
|
|
3701
|
+
}
|
|
3702
|
+
function collectLiveRegionEvent(event) {
|
|
3703
|
+
const fullEvent = {
|
|
3704
|
+
...event,
|
|
3705
|
+
timestamp: getTimestamp()
|
|
3706
|
+
};
|
|
3707
|
+
events.push(fullEvent);
|
|
3708
|
+
}
|
|
3709
|
+
const focusObs = createFocusObserver({
|
|
3710
|
+
document: doc,
|
|
3711
|
+
onEvent: collectEvent,
|
|
3712
|
+
getTimestamp
|
|
3713
|
+
});
|
|
3714
|
+
const mutationObs = createMutationObserver({
|
|
3715
|
+
document: doc,
|
|
3716
|
+
onEvent: collectEvent,
|
|
3717
|
+
getTimestamp
|
|
3718
|
+
});
|
|
3719
|
+
const liveRegionObs = createLiveRegionObserver(
|
|
3720
|
+
doc,
|
|
3721
|
+
collectLiveRegionEvent
|
|
3722
|
+
);
|
|
3723
|
+
const dialogObs = createDialogObserver({
|
|
3724
|
+
document: doc,
|
|
3725
|
+
onEvent: collectEvent,
|
|
3726
|
+
getTimestamp
|
|
3727
|
+
});
|
|
3728
|
+
return {
|
|
3729
|
+
attach() {
|
|
3730
|
+
if (attached) return;
|
|
3731
|
+
attached = true;
|
|
3732
|
+
sessionStart = Date.now();
|
|
3733
|
+
events = [];
|
|
3734
|
+
focusObs.start();
|
|
3735
|
+
mutationObs.start();
|
|
3736
|
+
liveRegionObs.start();
|
|
3737
|
+
dialogObs.start();
|
|
3738
|
+
},
|
|
3739
|
+
detach() {
|
|
3740
|
+
if (!attached) return;
|
|
3741
|
+
attached = false;
|
|
3742
|
+
focusObs.stop();
|
|
3743
|
+
mutationObs.stop();
|
|
3744
|
+
liveRegionObs.stop();
|
|
3745
|
+
dialogObs.stop();
|
|
3746
|
+
},
|
|
3747
|
+
getEvents() {
|
|
3748
|
+
return [...events];
|
|
3749
|
+
},
|
|
3750
|
+
reset() {
|
|
3751
|
+
events = [];
|
|
3752
|
+
},
|
|
3753
|
+
get isAttached() {
|
|
3754
|
+
return attached;
|
|
3755
|
+
},
|
|
3756
|
+
get dialogObserver() {
|
|
3757
|
+
return {
|
|
3758
|
+
getOpenDialogs() {
|
|
3759
|
+
return dialogObs.getOpenDialogs();
|
|
3760
|
+
}
|
|
3761
|
+
};
|
|
3762
|
+
}
|
|
3763
|
+
};
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
// src/runtime/interactions.ts
|
|
3767
|
+
var KEY_MAP = {
|
|
3768
|
+
Tab: { key: "Tab", code: "Tab", keyCode: 9 },
|
|
3769
|
+
Escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
3770
|
+
Enter: { key: "Enter", code: "Enter", keyCode: 13 },
|
|
3771
|
+
" ": { key: " ", code: "Space", keyCode: 32 },
|
|
3772
|
+
ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
3773
|
+
ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
3774
|
+
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
3775
|
+
ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 }
|
|
3776
|
+
};
|
|
3777
|
+
var FOCUSABLE_SELECTOR = [
|
|
3778
|
+
"button:not([disabled])",
|
|
3779
|
+
"a[href]",
|
|
3780
|
+
"input:not([disabled])",
|
|
3781
|
+
"select:not([disabled])",
|
|
3782
|
+
"textarea:not([disabled])",
|
|
3783
|
+
'[tabindex]:not([tabindex="-1"])'
|
|
3784
|
+
].join(", ");
|
|
3785
|
+
function getFocusableElements(document) {
|
|
3786
|
+
return Array.from(document.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
3787
|
+
}
|
|
3788
|
+
function computeAccessibleName4(element, document) {
|
|
3789
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
3790
|
+
if (ariaLabel && ariaLabel.trim()) {
|
|
3791
|
+
return ariaLabel.trim();
|
|
3792
|
+
}
|
|
3793
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
3794
|
+
if (labelledBy && labelledBy.trim()) {
|
|
3795
|
+
const ids = labelledBy.trim().split(/\s+/);
|
|
3796
|
+
const parts = [];
|
|
3797
|
+
for (const id of ids) {
|
|
3798
|
+
const referenced = document.getElementById(id);
|
|
3799
|
+
if (referenced && referenced.textContent) {
|
|
3800
|
+
parts.push(referenced.textContent.trim());
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
const combined = parts.join(" ");
|
|
3804
|
+
if (combined) return combined;
|
|
3805
|
+
}
|
|
3806
|
+
const textContent = element.textContent;
|
|
3807
|
+
if (textContent && textContent.trim()) {
|
|
3808
|
+
return textContent.trim();
|
|
3809
|
+
}
|
|
3810
|
+
return "";
|
|
3811
|
+
}
|
|
3812
|
+
function getElementRole3(element) {
|
|
3813
|
+
const explicitRole = element.getAttribute("role");
|
|
3814
|
+
if (explicitRole && explicitRole.trim()) {
|
|
3815
|
+
return explicitRole.trim();
|
|
3816
|
+
}
|
|
3817
|
+
const tagName = element.tagName.toLowerCase();
|
|
3818
|
+
switch (tagName) {
|
|
3819
|
+
case "button":
|
|
3820
|
+
return "button";
|
|
3821
|
+
case "a":
|
|
3822
|
+
return element.hasAttribute("href") ? "link" : "generic";
|
|
3823
|
+
case "input": {
|
|
3824
|
+
const type = (element.getAttribute("type") || "text").toLowerCase();
|
|
3825
|
+
switch (type) {
|
|
3826
|
+
case "button":
|
|
3827
|
+
case "submit":
|
|
3828
|
+
case "reset":
|
|
3829
|
+
case "image":
|
|
3830
|
+
return "button";
|
|
3831
|
+
case "checkbox":
|
|
3832
|
+
return "checkbox";
|
|
3833
|
+
case "radio":
|
|
3834
|
+
return "radio";
|
|
3835
|
+
case "range":
|
|
3836
|
+
return "slider";
|
|
3837
|
+
case "search":
|
|
3838
|
+
return "searchbox";
|
|
3839
|
+
default:
|
|
3840
|
+
return "textbox";
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
case "textarea":
|
|
3844
|
+
return "textbox";
|
|
3845
|
+
case "select":
|
|
3846
|
+
return element.hasAttribute("multiple") ? "listbox" : "combobox";
|
|
3847
|
+
case "dialog":
|
|
3848
|
+
return "dialog";
|
|
3849
|
+
default:
|
|
3850
|
+
return "generic";
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
function buildEventTarget(element, document) {
|
|
3854
|
+
return {
|
|
3855
|
+
role: getElementRole3(element),
|
|
3856
|
+
accessibleName: computeAccessibleName4(element, document),
|
|
3857
|
+
selector: generateSelector(element)
|
|
3858
|
+
};
|
|
3859
|
+
}
|
|
3860
|
+
function dispatchKeyboardEvent(document, options) {
|
|
3861
|
+
const target = document.activeElement || document.body;
|
|
3862
|
+
const commonProps = {
|
|
3863
|
+
key: options.key,
|
|
3864
|
+
code: options.code,
|
|
3865
|
+
keyCode: options.keyCode,
|
|
3866
|
+
which: options.keyCode,
|
|
3867
|
+
shiftKey: options.shiftKey || false,
|
|
3868
|
+
ctrlKey: options.ctrlKey || false,
|
|
3869
|
+
altKey: options.altKey || false,
|
|
3870
|
+
metaKey: options.metaKey || false,
|
|
3871
|
+
bubbles: true,
|
|
3872
|
+
cancelable: true
|
|
3873
|
+
};
|
|
3874
|
+
target.dispatchEvent(new KeyboardEvent("keydown", commonProps));
|
|
3875
|
+
if (options.key.length === 1) {
|
|
3876
|
+
target.dispatchEvent(new KeyboardEvent("keypress", commonProps));
|
|
3877
|
+
}
|
|
3878
|
+
target.dispatchEvent(new KeyboardEvent("keyup", commonProps));
|
|
3879
|
+
}
|
|
3880
|
+
function parseKeyCombo(combo) {
|
|
3881
|
+
const parts = combo.split("+");
|
|
3882
|
+
const modifiers = [];
|
|
3883
|
+
let shiftKey = false;
|
|
3884
|
+
let ctrlKey = false;
|
|
3885
|
+
let altKey = false;
|
|
3886
|
+
let metaKey = false;
|
|
3887
|
+
const key = parts[parts.length - 1];
|
|
3888
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
3889
|
+
const mod = parts[i].toLowerCase();
|
|
3890
|
+
switch (mod) {
|
|
3891
|
+
case "shift":
|
|
3892
|
+
shiftKey = true;
|
|
3893
|
+
modifiers.push("Shift");
|
|
3894
|
+
break;
|
|
3895
|
+
case "control":
|
|
3896
|
+
case "ctrl":
|
|
3897
|
+
ctrlKey = true;
|
|
3898
|
+
modifiers.push("Control");
|
|
3899
|
+
break;
|
|
3900
|
+
case "alt":
|
|
3901
|
+
altKey = true;
|
|
3902
|
+
modifiers.push("Alt");
|
|
3903
|
+
break;
|
|
3904
|
+
case "meta":
|
|
3905
|
+
case "cmd":
|
|
3906
|
+
case "command":
|
|
3907
|
+
metaKey = true;
|
|
3908
|
+
modifiers.push("Meta");
|
|
3909
|
+
break;
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
return { key, modifiers, shiftKey, ctrlKey, altKey, metaKey };
|
|
3913
|
+
}
|
|
3914
|
+
function getKeyInfo(key) {
|
|
3915
|
+
if (KEY_MAP[key]) {
|
|
3916
|
+
return KEY_MAP[key];
|
|
3917
|
+
}
|
|
3918
|
+
if (key.length === 1) {
|
|
3919
|
+
return {
|
|
3920
|
+
key,
|
|
3921
|
+
code: `Key${key.toUpperCase()}`,
|
|
3922
|
+
keyCode: key.toUpperCase().charCodeAt(0)
|
|
3923
|
+
};
|
|
3924
|
+
}
|
|
3925
|
+
return {
|
|
3926
|
+
key,
|
|
3927
|
+
code: key,
|
|
3928
|
+
keyCode: 0
|
|
3929
|
+
};
|
|
3930
|
+
}
|
|
3931
|
+
function executeClick(selector, document, onEvent, getTimestamp) {
|
|
3932
|
+
const element = document.querySelector(selector);
|
|
3933
|
+
if (!element) {
|
|
3934
|
+
const warningEvent = {
|
|
3935
|
+
type: "WARNING",
|
|
3936
|
+
timestamp: getTimestamp(),
|
|
3937
|
+
target: {
|
|
3938
|
+
role: "generic",
|
|
3939
|
+
accessibleName: "",
|
|
3940
|
+
selector
|
|
3941
|
+
},
|
|
3942
|
+
payload: {
|
|
3943
|
+
kind: "warning",
|
|
3944
|
+
message: `Element not found: ${selector}`
|
|
3945
|
+
}
|
|
3946
|
+
};
|
|
3947
|
+
onEvent(warningEvent);
|
|
3948
|
+
return;
|
|
3949
|
+
}
|
|
3950
|
+
const eventProps = { bubbles: true, cancelable: true };
|
|
3951
|
+
element.dispatchEvent(new MouseEvent("mousedown", eventProps));
|
|
3952
|
+
element.dispatchEvent(new MouseEvent("mouseup", eventProps));
|
|
3953
|
+
element.dispatchEvent(new MouseEvent("click", eventProps));
|
|
3954
|
+
if ("focus" in element && typeof element.focus === "function") {
|
|
3955
|
+
element.focus();
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
function executeTab(document, onEvent, getTimestamp) {
|
|
3959
|
+
const focusableElements = getFocusableElements(document);
|
|
3960
|
+
if (focusableElements.length === 0) return;
|
|
3961
|
+
const activeElement = document.activeElement;
|
|
3962
|
+
let currentIndex = -1;
|
|
3963
|
+
if (activeElement) {
|
|
3964
|
+
currentIndex = focusableElements.indexOf(activeElement);
|
|
3965
|
+
}
|
|
3966
|
+
const nextIndex = (currentIndex + 1) % focusableElements.length;
|
|
3967
|
+
const nextElement = focusableElements[nextIndex];
|
|
3968
|
+
dispatchKeyboardEvent(document, {
|
|
3969
|
+
...KEY_MAP["Tab"],
|
|
3970
|
+
shiftKey: false
|
|
3971
|
+
});
|
|
3972
|
+
if (nextElement && typeof nextElement.focus === "function") {
|
|
3973
|
+
nextElement.focus();
|
|
3974
|
+
}
|
|
3975
|
+
const target = document.activeElement || document.body;
|
|
3976
|
+
const keyboardEvent = {
|
|
3977
|
+
type: "KEYBOARD_ACTION",
|
|
3978
|
+
timestamp: getTimestamp(),
|
|
3979
|
+
target: buildEventTarget(target, document),
|
|
3980
|
+
payload: {
|
|
3981
|
+
kind: "keyboard_action",
|
|
3982
|
+
key: "Tab",
|
|
3983
|
+
modifiers: []
|
|
3984
|
+
}
|
|
3985
|
+
};
|
|
3986
|
+
onEvent(keyboardEvent);
|
|
3987
|
+
}
|
|
3988
|
+
function executeShiftTab(document, onEvent, getTimestamp) {
|
|
3989
|
+
const focusableElements = getFocusableElements(document);
|
|
3990
|
+
if (focusableElements.length === 0) return;
|
|
3991
|
+
const activeElement = document.activeElement;
|
|
3992
|
+
let currentIndex = -1;
|
|
3993
|
+
if (activeElement) {
|
|
3994
|
+
currentIndex = focusableElements.indexOf(activeElement);
|
|
3995
|
+
}
|
|
3996
|
+
const prevIndex = currentIndex <= 0 ? focusableElements.length - 1 : currentIndex - 1;
|
|
3997
|
+
const prevElement = focusableElements[prevIndex];
|
|
3998
|
+
dispatchKeyboardEvent(document, {
|
|
3999
|
+
...KEY_MAP["Tab"],
|
|
4000
|
+
shiftKey: true
|
|
4001
|
+
});
|
|
4002
|
+
if (prevElement && typeof prevElement.focus === "function") {
|
|
4003
|
+
prevElement.focus();
|
|
4004
|
+
}
|
|
4005
|
+
const target = document.activeElement || document.body;
|
|
4006
|
+
const keyboardEvent = {
|
|
4007
|
+
type: "KEYBOARD_ACTION",
|
|
4008
|
+
timestamp: getTimestamp(),
|
|
4009
|
+
target: buildEventTarget(target, document),
|
|
4010
|
+
payload: {
|
|
4011
|
+
kind: "keyboard_action",
|
|
4012
|
+
key: "Tab",
|
|
4013
|
+
modifiers: ["Shift"]
|
|
4014
|
+
}
|
|
4015
|
+
};
|
|
4016
|
+
onEvent(keyboardEvent);
|
|
4017
|
+
}
|
|
4018
|
+
function executeKeyAction(keyName, document, onEvent, getTimestamp) {
|
|
4019
|
+
const keyInfo = KEY_MAP[keyName];
|
|
4020
|
+
if (!keyInfo) return;
|
|
4021
|
+
dispatchKeyboardEvent(document, {
|
|
4022
|
+
...keyInfo
|
|
4023
|
+
});
|
|
4024
|
+
const target = document.activeElement || document.body;
|
|
4025
|
+
const keyboardEvent = {
|
|
4026
|
+
type: "KEYBOARD_ACTION",
|
|
4027
|
+
timestamp: getTimestamp(),
|
|
4028
|
+
target: buildEventTarget(target, document),
|
|
4029
|
+
payload: {
|
|
4030
|
+
kind: "keyboard_action",
|
|
4031
|
+
key: keyName,
|
|
4032
|
+
modifiers: []
|
|
4033
|
+
}
|
|
4034
|
+
};
|
|
4035
|
+
onEvent(keyboardEvent);
|
|
4036
|
+
}
|
|
4037
|
+
function executeKeyCombo(combo, document, onEvent, getTimestamp) {
|
|
4038
|
+
const parsed = parseKeyCombo(combo);
|
|
4039
|
+
const keyInfo = getKeyInfo(parsed.key);
|
|
4040
|
+
dispatchKeyboardEvent(document, {
|
|
4041
|
+
...keyInfo,
|
|
4042
|
+
shiftKey: parsed.shiftKey,
|
|
4043
|
+
ctrlKey: parsed.ctrlKey,
|
|
4044
|
+
altKey: parsed.altKey,
|
|
4045
|
+
metaKey: parsed.metaKey
|
|
4046
|
+
});
|
|
4047
|
+
const target = document.activeElement || document.body;
|
|
4048
|
+
const keyboardEvent = {
|
|
4049
|
+
type: "KEYBOARD_ACTION",
|
|
4050
|
+
timestamp: getTimestamp(),
|
|
4051
|
+
target: buildEventTarget(target, document),
|
|
4052
|
+
payload: {
|
|
4053
|
+
kind: "keyboard_action",
|
|
4054
|
+
key: parsed.key,
|
|
4055
|
+
modifiers: parsed.modifiers
|
|
4056
|
+
}
|
|
4057
|
+
};
|
|
4058
|
+
onEvent(keyboardEvent);
|
|
4059
|
+
}
|
|
4060
|
+
async function executeAction(action, document, onEvent, getTimestamp) {
|
|
4061
|
+
switch (action.type) {
|
|
4062
|
+
case "click":
|
|
4063
|
+
executeClick(action.selector, document, onEvent, getTimestamp);
|
|
4064
|
+
break;
|
|
4065
|
+
case "tab":
|
|
4066
|
+
executeTab(document, onEvent, getTimestamp);
|
|
4067
|
+
break;
|
|
4068
|
+
case "shiftTab":
|
|
4069
|
+
executeShiftTab(document, onEvent, getTimestamp);
|
|
4070
|
+
break;
|
|
4071
|
+
case "escape":
|
|
4072
|
+
executeKeyAction("Escape", document, onEvent, getTimestamp);
|
|
4073
|
+
break;
|
|
4074
|
+
case "enter":
|
|
4075
|
+
executeKeyAction("Enter", document, onEvent, getTimestamp);
|
|
4076
|
+
break;
|
|
4077
|
+
case "space":
|
|
4078
|
+
executeKeyAction(" ", document, onEvent, getTimestamp);
|
|
4079
|
+
break;
|
|
4080
|
+
case "arrowUp":
|
|
4081
|
+
executeKeyAction("ArrowUp", document, onEvent, getTimestamp);
|
|
4082
|
+
break;
|
|
4083
|
+
case "arrowDown":
|
|
4084
|
+
executeKeyAction("ArrowDown", document, onEvent, getTimestamp);
|
|
4085
|
+
break;
|
|
4086
|
+
case "arrowLeft":
|
|
4087
|
+
executeKeyAction("ArrowLeft", document, onEvent, getTimestamp);
|
|
4088
|
+
break;
|
|
4089
|
+
case "arrowRight":
|
|
4090
|
+
executeKeyAction("ArrowRight", document, onEvent, getTimestamp);
|
|
4091
|
+
break;
|
|
4092
|
+
case "key":
|
|
4093
|
+
executeKeyCombo(action.combo, document, onEvent, getTimestamp);
|
|
4094
|
+
break;
|
|
4095
|
+
case "wait":
|
|
4096
|
+
await new Promise((resolve) => setTimeout(resolve, action.ms));
|
|
4097
|
+
break;
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
async function executeSequence(sequence, document, onEvent, getTimestamp, settleTime = 100) {
|
|
4101
|
+
for (let i = 0; i < sequence.actions.length; i++) {
|
|
4102
|
+
const action = sequence.actions[i];
|
|
4103
|
+
await executeAction(action, document, onEvent, getTimestamp);
|
|
4104
|
+
if (i < sequence.actions.length - 1) {
|
|
4105
|
+
await new Promise((resolve) => setTimeout(resolve, settleTime));
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
// src/runtime/heuristics.ts
|
|
4111
|
+
var DEFAULT_CONFIG = {
|
|
4112
|
+
dialogFocusTimeout: 100,
|
|
4113
|
+
rapidAnnouncementWindow: 500,
|
|
4114
|
+
rapidAnnouncementThreshold: 3,
|
|
4115
|
+
keyboardResponseTimeout: 200
|
|
4116
|
+
};
|
|
4117
|
+
function createHeuristicAnalyzer(config) {
|
|
4118
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
4119
|
+
const trackedDialogs = [];
|
|
4120
|
+
const pendingDialogFocusChecks = [];
|
|
4121
|
+
const pendingKeyboardResponses = [];
|
|
4122
|
+
const announcementTimestamps = [];
|
|
4123
|
+
let lastFocusedSelector = null;
|
|
4124
|
+
function createWarning(timestamp, message, target, relatedEvent) {
|
|
4125
|
+
const payload = {
|
|
4126
|
+
kind: "warning",
|
|
4127
|
+
message,
|
|
4128
|
+
relatedEvent
|
|
4129
|
+
};
|
|
4130
|
+
return {
|
|
4131
|
+
type: "WARNING",
|
|
4132
|
+
timestamp,
|
|
4133
|
+
target,
|
|
4134
|
+
payload
|
|
4135
|
+
};
|
|
4136
|
+
}
|
|
4137
|
+
function checkExpiredDialogFocusTimeouts(currentTimestamp) {
|
|
4138
|
+
const warnings = [];
|
|
4139
|
+
for (let i = pendingDialogFocusChecks.length - 1; i >= 0; i--) {
|
|
4140
|
+
const pending = pendingDialogFocusChecks[i];
|
|
4141
|
+
if (pending.resolved) continue;
|
|
4142
|
+
const elapsed = currentTimestamp - pending.openedAt;
|
|
4143
|
+
if (elapsed > cfg.dialogFocusTimeout) {
|
|
4144
|
+
if (pending.isModal) {
|
|
4145
|
+
warnings.push(
|
|
4146
|
+
createWarning(
|
|
4147
|
+
currentTimestamp,
|
|
4148
|
+
"Focus was not moved into modal dialog",
|
|
4149
|
+
{
|
|
4150
|
+
role: "dialog",
|
|
4151
|
+
accessibleName: pending.dialogName,
|
|
4152
|
+
selector: pending.dialogSelector
|
|
4153
|
+
}
|
|
4154
|
+
)
|
|
4155
|
+
);
|
|
4156
|
+
}
|
|
4157
|
+
pending.resolved = true;
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
return warnings;
|
|
4161
|
+
}
|
|
4162
|
+
function checkExpiredKeyboardResponses(currentTimestamp) {
|
|
4163
|
+
const warnings = [];
|
|
4164
|
+
for (let i = pendingKeyboardResponses.length - 1; i >= 0; i--) {
|
|
4165
|
+
const pending = pendingKeyboardResponses[i];
|
|
4166
|
+
if (pending.resolved) continue;
|
|
4167
|
+
const elapsed = currentTimestamp - pending.timestamp;
|
|
4168
|
+
if (elapsed > cfg.keyboardResponseTimeout) {
|
|
4169
|
+
warnings.push(
|
|
4170
|
+
createWarning(
|
|
4171
|
+
currentTimestamp,
|
|
4172
|
+
"Keyboard action produced no accessibility response",
|
|
4173
|
+
pending.event.target,
|
|
4174
|
+
pending.event
|
|
4175
|
+
)
|
|
4176
|
+
);
|
|
4177
|
+
pending.resolved = true;
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4180
|
+
return warnings;
|
|
4181
|
+
}
|
|
4182
|
+
function checkRapidAnnouncements(currentTimestamp) {
|
|
4183
|
+
const warnings = [];
|
|
4184
|
+
const windowStart = currentTimestamp - cfg.rapidAnnouncementWindow;
|
|
4185
|
+
while (announcementTimestamps.length > 0 && announcementTimestamps[0] < windowStart) {
|
|
4186
|
+
announcementTimestamps.shift();
|
|
4187
|
+
}
|
|
4188
|
+
if (announcementTimestamps.length > cfg.rapidAnnouncementThreshold) {
|
|
4189
|
+
warnings.push(
|
|
4190
|
+
createWarning(
|
|
4191
|
+
currentTimestamp,
|
|
4192
|
+
"Rapid announcements detected: assistive technology users may miss content",
|
|
4193
|
+
{
|
|
4194
|
+
role: "region",
|
|
4195
|
+
accessibleName: "",
|
|
4196
|
+
selector: "body"
|
|
4197
|
+
}
|
|
4198
|
+
)
|
|
4199
|
+
);
|
|
4200
|
+
announcementTimestamps.length = 0;
|
|
4201
|
+
}
|
|
4202
|
+
return warnings;
|
|
4203
|
+
}
|
|
4204
|
+
function checkFocusEscape(event) {
|
|
4205
|
+
const warnings = [];
|
|
4206
|
+
if (event.type !== "FOCUS_CHANGED") return warnings;
|
|
4207
|
+
const modalDialogs = trackedDialogs.filter((d) => d.isModal);
|
|
4208
|
+
if (modalDialogs.length === 0) return warnings;
|
|
4209
|
+
const focusedSelector = event.target.selector;
|
|
4210
|
+
for (const dialog of modalDialogs) {
|
|
4211
|
+
if (!isSelectorInside(focusedSelector, dialog.selector)) {
|
|
4212
|
+
warnings.push(
|
|
4213
|
+
createWarning(
|
|
4214
|
+
event.timestamp,
|
|
4215
|
+
"Focus escaped modal dialog",
|
|
4216
|
+
event.target,
|
|
4217
|
+
event
|
|
4218
|
+
)
|
|
4219
|
+
);
|
|
4220
|
+
break;
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
return warnings;
|
|
4224
|
+
}
|
|
4225
|
+
function checkFocusedElementRemoved(event) {
|
|
4226
|
+
const warnings = [];
|
|
4227
|
+
if (event.type === "DOM_MUTATION" && lastFocusedSelector !== null) {
|
|
4228
|
+
if (event.target.selector === lastFocusedSelector) {
|
|
4229
|
+
warnings.push(
|
|
4230
|
+
createWarning(
|
|
4231
|
+
event.timestamp,
|
|
4232
|
+
"Focused element removed without focus management",
|
|
4233
|
+
event.target,
|
|
4234
|
+
event
|
|
4235
|
+
)
|
|
4236
|
+
);
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
return warnings;
|
|
4240
|
+
}
|
|
4241
|
+
function isSelectorInside(childSelector, containerSelector) {
|
|
4242
|
+
if (childSelector === containerSelector) return true;
|
|
4243
|
+
if (childSelector.startsWith(containerSelector + " ")) return true;
|
|
4244
|
+
if (childSelector.startsWith(containerSelector + " > ")) return true;
|
|
4245
|
+
if (childSelector.startsWith(containerSelector + ">")) return true;
|
|
4246
|
+
return false;
|
|
4247
|
+
}
|
|
4248
|
+
function processEvent(event) {
|
|
4249
|
+
const warnings = [];
|
|
4250
|
+
warnings.push(...checkExpiredDialogFocusTimeouts(event.timestamp));
|
|
4251
|
+
warnings.push(...checkExpiredKeyboardResponses(event.timestamp));
|
|
4252
|
+
switch (event.type) {
|
|
4253
|
+
case "DIALOG_OPENED": {
|
|
4254
|
+
const payload = event.payload;
|
|
4255
|
+
const dialog = {
|
|
4256
|
+
selector: event.target.selector,
|
|
4257
|
+
name: payload.dialogName,
|
|
4258
|
+
isModal: payload.isModal
|
|
4259
|
+
};
|
|
4260
|
+
trackedDialogs.push(dialog);
|
|
4261
|
+
if (payload.isModal) {
|
|
4262
|
+
pendingDialogFocusChecks.push({
|
|
4263
|
+
dialogSelector: event.target.selector,
|
|
4264
|
+
dialogName: payload.dialogName,
|
|
4265
|
+
openedAt: event.timestamp,
|
|
4266
|
+
isModal: true,
|
|
4267
|
+
resolved: false
|
|
4268
|
+
});
|
|
4269
|
+
}
|
|
4270
|
+
break;
|
|
4271
|
+
}
|
|
4272
|
+
case "DIALOG_CLOSED": {
|
|
4273
|
+
const closedSelector = event.target.selector;
|
|
4274
|
+
const idx = trackedDialogs.findIndex(
|
|
4275
|
+
(d) => d.selector === closedSelector
|
|
4276
|
+
);
|
|
4277
|
+
if (idx !== -1) {
|
|
4278
|
+
trackedDialogs.splice(idx, 1);
|
|
4279
|
+
}
|
|
4280
|
+
for (const pending of pendingDialogFocusChecks) {
|
|
4281
|
+
if (pending.dialogSelector === closedSelector && !pending.resolved) {
|
|
4282
|
+
pending.resolved = true;
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
break;
|
|
4286
|
+
}
|
|
4287
|
+
case "FOCUS_CHANGED": {
|
|
4288
|
+
for (const pending of pendingDialogFocusChecks) {
|
|
4289
|
+
if (pending.resolved) continue;
|
|
4290
|
+
if (isSelectorInside(event.target.selector, pending.dialogSelector)) {
|
|
4291
|
+
pending.resolved = true;
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
for (const pending of pendingKeyboardResponses) {
|
|
4295
|
+
if (!pending.resolved) {
|
|
4296
|
+
pending.resolved = true;
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
warnings.push(...checkFocusEscape(event));
|
|
4300
|
+
lastFocusedSelector = event.target.selector;
|
|
4301
|
+
break;
|
|
4302
|
+
}
|
|
4303
|
+
case "ANNOUNCEMENT": {
|
|
4304
|
+
announcementTimestamps.push(event.timestamp);
|
|
4305
|
+
for (const pending of pendingKeyboardResponses) {
|
|
4306
|
+
if (!pending.resolved) {
|
|
4307
|
+
pending.resolved = true;
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
warnings.push(...checkRapidAnnouncements(event.timestamp));
|
|
4311
|
+
break;
|
|
4312
|
+
}
|
|
4313
|
+
case "KEYBOARD_ACTION": {
|
|
4314
|
+
pendingKeyboardResponses.push({
|
|
4315
|
+
event,
|
|
4316
|
+
timestamp: event.timestamp,
|
|
4317
|
+
resolved: false
|
|
4318
|
+
});
|
|
4319
|
+
break;
|
|
4320
|
+
}
|
|
4321
|
+
case "DOM_MUTATION": {
|
|
4322
|
+
warnings.push(...checkFocusedElementRemoved(event));
|
|
4323
|
+
for (const pending of pendingKeyboardResponses) {
|
|
4324
|
+
if (!pending.resolved) {
|
|
4325
|
+
pending.resolved = true;
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
break;
|
|
4329
|
+
}
|
|
4330
|
+
case "STATE_CHANGED":
|
|
4331
|
+
case "ROLE_CHANGED":
|
|
4332
|
+
case "ACCESSIBLE_NAME_CHANGED": {
|
|
4333
|
+
for (const pending of pendingKeyboardResponses) {
|
|
4334
|
+
if (!pending.resolved) {
|
|
4335
|
+
pending.resolved = true;
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
break;
|
|
4339
|
+
}
|
|
4340
|
+
default:
|
|
4341
|
+
break;
|
|
4342
|
+
}
|
|
4343
|
+
cleanupResolvedChecks();
|
|
4344
|
+
return warnings;
|
|
4345
|
+
}
|
|
4346
|
+
function cleanupResolvedChecks() {
|
|
4347
|
+
for (let i = pendingDialogFocusChecks.length - 1; i >= 0; i--) {
|
|
4348
|
+
if (pendingDialogFocusChecks[i].resolved) {
|
|
4349
|
+
pendingDialogFocusChecks.splice(i, 1);
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
for (let i = pendingKeyboardResponses.length - 1; i >= 0; i--) {
|
|
4353
|
+
if (pendingKeyboardResponses[i].resolved) {
|
|
4354
|
+
pendingKeyboardResponses.splice(i, 1);
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
return {
|
|
4359
|
+
process(event) {
|
|
4360
|
+
return processEvent(event);
|
|
4361
|
+
},
|
|
4362
|
+
get openDialogs() {
|
|
4363
|
+
return trackedDialogs.map((d) => d.selector);
|
|
4364
|
+
}
|
|
4365
|
+
};
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4368
|
+
// src/runtime/timeline-generator.ts
|
|
4369
|
+
var SPEAKABLE_VERSION = "1.3.0";
|
|
4370
|
+
var TIMELINE_FORMAT_VERSION = "1.0";
|
|
4371
|
+
var DEFAULT_SETTLE_PERIOD = 500;
|
|
4372
|
+
var DEFAULT_LOAD_TIMEOUT = 1e4;
|
|
4373
|
+
var DOM_STABILITY_WAIT = 100;
|
|
4374
|
+
function createTimelineGenerator(options) {
|
|
4375
|
+
const {
|
|
4376
|
+
document: doc,
|
|
4377
|
+
componentName,
|
|
4378
|
+
storyName,
|
|
4379
|
+
settlePeriod = DEFAULT_SETTLE_PERIOD,
|
|
4380
|
+
loadTimeout = DEFAULT_LOAD_TIMEOUT,
|
|
4381
|
+
heuristics: enableHeuristics = true
|
|
4382
|
+
} = options;
|
|
4383
|
+
let aborted = false;
|
|
4384
|
+
let currentEngine = null;
|
|
4385
|
+
return {
|
|
4386
|
+
async capture(sequence) {
|
|
4387
|
+
aborted = false;
|
|
4388
|
+
await waitForDocumentReady(doc, loadTimeout);
|
|
4389
|
+
if (aborted) {
|
|
4390
|
+
return buildAbortedTimeline(componentName, storyName, sequence);
|
|
4391
|
+
}
|
|
4392
|
+
const engine = createEngine({ document: doc });
|
|
4393
|
+
currentEngine = engine;
|
|
4394
|
+
const heuristicAnalyzer = enableHeuristics ? createHeuristicAnalyzer() : null;
|
|
4395
|
+
const interactionEvents = [];
|
|
4396
|
+
const sessionStart = Date.now();
|
|
4397
|
+
engine.attach();
|
|
4398
|
+
if (aborted) {
|
|
4399
|
+
engine.detach();
|
|
4400
|
+
currentEngine = null;
|
|
4401
|
+
return buildAbortedTimeline(componentName, storyName, sequence);
|
|
4402
|
+
}
|
|
4403
|
+
function getTimestamp() {
|
|
4404
|
+
return Date.now() - sessionStart;
|
|
4405
|
+
}
|
|
4406
|
+
await executeSequence(
|
|
4407
|
+
sequence,
|
|
4408
|
+
doc,
|
|
4409
|
+
(event) => {
|
|
4410
|
+
interactionEvents.push(event);
|
|
4411
|
+
},
|
|
4412
|
+
getTimestamp,
|
|
4413
|
+
DOM_STABILITY_WAIT
|
|
4414
|
+
);
|
|
4415
|
+
if (aborted) {
|
|
4416
|
+
engine.detach();
|
|
4417
|
+
currentEngine = null;
|
|
4418
|
+
return buildAbortedTimeline(componentName, storyName, sequence);
|
|
4419
|
+
}
|
|
4420
|
+
await delay(settlePeriod);
|
|
4421
|
+
engine.detach();
|
|
4422
|
+
currentEngine = null;
|
|
4423
|
+
const duration = Date.now() - sessionStart;
|
|
4424
|
+
const engineEvents = engine.getEvents();
|
|
4425
|
+
const allEvents = mergeEvents(engineEvents, interactionEvents);
|
|
4426
|
+
if (heuristicAnalyzer) {
|
|
4427
|
+
const heuristicWarnings = [];
|
|
4428
|
+
for (const event of allEvents) {
|
|
4429
|
+
const warnings = heuristicAnalyzer.process(event);
|
|
4430
|
+
heuristicWarnings.push(...warnings);
|
|
4431
|
+
}
|
|
4432
|
+
for (const warning of heuristicWarnings) {
|
|
4433
|
+
allEvents.push(warning);
|
|
4434
|
+
}
|
|
4435
|
+
allEvents.sort((a, b) => a.timestamp - b.timestamp);
|
|
4436
|
+
}
|
|
4437
|
+
const allWarnings = allEvents.filter((e) => e.type === "WARNING");
|
|
4438
|
+
const metadata = {
|
|
4439
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4440
|
+
speakableVersion: SPEAKABLE_VERSION,
|
|
4441
|
+
sourceUrl: getSourceUrl(doc),
|
|
4442
|
+
userAgent: getUserAgent()
|
|
4443
|
+
};
|
|
4444
|
+
const timeline = {
|
|
4445
|
+
version: TIMELINE_FORMAT_VERSION,
|
|
4446
|
+
component: componentName,
|
|
4447
|
+
story: storyName || null,
|
|
4448
|
+
interactionSequence: sequence.description,
|
|
4449
|
+
duration,
|
|
4450
|
+
events: allEvents,
|
|
4451
|
+
warnings: allWarnings,
|
|
4452
|
+
metadata
|
|
4453
|
+
};
|
|
4454
|
+
return timeline;
|
|
4455
|
+
},
|
|
4456
|
+
abort() {
|
|
4457
|
+
aborted = true;
|
|
4458
|
+
if (currentEngine && currentEngine.isAttached) {
|
|
4459
|
+
currentEngine.detach();
|
|
4460
|
+
currentEngine = null;
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
};
|
|
4464
|
+
}
|
|
4465
|
+
async function waitForDocumentReady(doc, timeout) {
|
|
4466
|
+
if (doc.body) return;
|
|
4467
|
+
return new Promise((resolve, reject) => {
|
|
4468
|
+
const timeoutId = setTimeout(() => {
|
|
4469
|
+
reject(
|
|
4470
|
+
new Error(
|
|
4471
|
+
`Document load timeout (${timeout}ms): ${getSourceUrl(doc)}`
|
|
4472
|
+
)
|
|
4473
|
+
);
|
|
4474
|
+
}, timeout);
|
|
4475
|
+
const interval = setInterval(() => {
|
|
4476
|
+
if (doc.body) {
|
|
4477
|
+
clearTimeout(timeoutId);
|
|
4478
|
+
clearInterval(interval);
|
|
4479
|
+
resolve();
|
|
4480
|
+
}
|
|
4481
|
+
}, 50);
|
|
4482
|
+
});
|
|
4483
|
+
}
|
|
4484
|
+
function mergeEvents(engineEvents, interactionEvents) {
|
|
4485
|
+
const all = [...engineEvents, ...interactionEvents];
|
|
4486
|
+
all.sort((a, b) => a.timestamp - b.timestamp);
|
|
4487
|
+
return all;
|
|
4488
|
+
}
|
|
4489
|
+
function getSourceUrl(doc) {
|
|
4490
|
+
try {
|
|
4491
|
+
const url = doc.URL;
|
|
4492
|
+
if (url && url !== "about:blank" && url !== "") {
|
|
4493
|
+
return url;
|
|
4494
|
+
}
|
|
4495
|
+
} catch {
|
|
4496
|
+
}
|
|
4497
|
+
return "local";
|
|
4498
|
+
}
|
|
4499
|
+
function getUserAgent() {
|
|
4500
|
+
try {
|
|
4501
|
+
if (typeof navigator !== "undefined" && navigator.userAgent) {
|
|
4502
|
+
return navigator.userAgent;
|
|
4503
|
+
}
|
|
4504
|
+
} catch {
|
|
4505
|
+
}
|
|
4506
|
+
return "jsdom";
|
|
4507
|
+
}
|
|
4508
|
+
function delay(ms) {
|
|
4509
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4510
|
+
}
|
|
4511
|
+
function buildAbortedTimeline(componentName, storyName, sequence) {
|
|
4512
|
+
return {
|
|
4513
|
+
version: TIMELINE_FORMAT_VERSION,
|
|
4514
|
+
component: componentName,
|
|
4515
|
+
story: storyName || null,
|
|
4516
|
+
interactionSequence: sequence.description,
|
|
4517
|
+
duration: 0,
|
|
4518
|
+
events: [],
|
|
4519
|
+
warnings: [],
|
|
4520
|
+
metadata: {
|
|
4521
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4522
|
+
speakableVersion: SPEAKABLE_VERSION,
|
|
4523
|
+
sourceUrl: "local",
|
|
4524
|
+
userAgent: getUserAgent()
|
|
4525
|
+
}
|
|
4526
|
+
};
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
// src/runtime/patterns.ts
|
|
4530
|
+
var MODAL_DIALOG_DEFAULTS = {
|
|
4531
|
+
trigger: "button",
|
|
4532
|
+
container: '[role="dialog"], dialog'
|
|
4533
|
+
};
|
|
4534
|
+
var COMBOBOX_DEFAULTS = {
|
|
4535
|
+
input: '[role="combobox"], input[type="text"]',
|
|
4536
|
+
items: '[role="option"]'
|
|
4537
|
+
};
|
|
4538
|
+
var TABS_DEFAULTS = {
|
|
4539
|
+
container: '[role="tablist"]',
|
|
4540
|
+
items: '[role="tab"]'
|
|
4541
|
+
};
|
|
4542
|
+
var ACCORDION_DEFAULTS = {
|
|
4543
|
+
trigger: "button[aria-expanded]"
|
|
4544
|
+
};
|
|
4545
|
+
function buildModalDialogPattern(selectors) {
|
|
4546
|
+
const trigger = selectors?.trigger ?? MODAL_DIALOG_DEFAULTS.trigger;
|
|
4547
|
+
const actions = [
|
|
4548
|
+
{ type: "click", selector: trigger },
|
|
4549
|
+
{ type: "tab" },
|
|
4550
|
+
{ type: "tab" },
|
|
4551
|
+
{ type: "tab" },
|
|
4552
|
+
{ type: "escape" }
|
|
4553
|
+
];
|
|
4554
|
+
return {
|
|
4555
|
+
description: "Modal dialog: click trigger to open, tab through dialog content, press Escape to close, verify focus returns to trigger",
|
|
4556
|
+
actions
|
|
4557
|
+
};
|
|
4558
|
+
}
|
|
4559
|
+
function buildComboboxPattern(selectors) {
|
|
4560
|
+
const input = selectors?.input ?? COMBOBOX_DEFAULTS.input;
|
|
4561
|
+
const actions = [
|
|
4562
|
+
{ type: "click", selector: input },
|
|
4563
|
+
{ type: "key", combo: "a" },
|
|
4564
|
+
{ type: "arrowDown" },
|
|
4565
|
+
{ type: "arrowDown" },
|
|
4566
|
+
{ type: "enter" }
|
|
4567
|
+
];
|
|
4568
|
+
return {
|
|
4569
|
+
description: "Combobox: focus input, type to filter, navigate options with arrow keys, select with Enter",
|
|
4570
|
+
actions
|
|
4571
|
+
};
|
|
4572
|
+
}
|
|
4573
|
+
function buildTabsPattern(selectors) {
|
|
4574
|
+
const container = selectors?.container ?? TABS_DEFAULTS.container;
|
|
4575
|
+
const actions = [
|
|
4576
|
+
{ type: "click", selector: container },
|
|
4577
|
+
{ type: "arrowRight" },
|
|
4578
|
+
{ type: "arrowRight" },
|
|
4579
|
+
{ type: "enter" },
|
|
4580
|
+
{ type: "arrowLeft" },
|
|
4581
|
+
{ type: "space" }
|
|
4582
|
+
];
|
|
4583
|
+
return {
|
|
4584
|
+
description: "Tabs: focus tablist, navigate between tabs with arrow keys, activate with Enter/Space",
|
|
4585
|
+
actions
|
|
4586
|
+
};
|
|
4587
|
+
}
|
|
4588
|
+
function buildAccordionPattern(selectors) {
|
|
4589
|
+
const trigger = selectors?.trigger ?? ACCORDION_DEFAULTS.trigger;
|
|
4590
|
+
const actions = [
|
|
4591
|
+
{ type: "click", selector: trigger },
|
|
4592
|
+
{ type: "enter" },
|
|
4593
|
+
{ type: "arrowDown" },
|
|
4594
|
+
{ type: "space" },
|
|
4595
|
+
{ type: "arrowDown" },
|
|
4596
|
+
{ type: "enter" }
|
|
4597
|
+
];
|
|
4598
|
+
return {
|
|
4599
|
+
description: "Accordion: focus header, toggle with Enter/Space, navigate between headers with arrow keys",
|
|
4600
|
+
actions
|
|
4601
|
+
};
|
|
4602
|
+
}
|
|
4603
|
+
function getBuiltinPattern(name, selectors) {
|
|
4604
|
+
switch (name) {
|
|
4605
|
+
case "modal-dialog":
|
|
4606
|
+
return buildModalDialogPattern(selectors);
|
|
4607
|
+
case "combobox":
|
|
4608
|
+
return buildComboboxPattern(selectors);
|
|
4609
|
+
case "tabs":
|
|
4610
|
+
return buildTabsPattern(selectors);
|
|
4611
|
+
case "accordion":
|
|
4612
|
+
return buildAccordionPattern(selectors);
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
// src/runtime/serialization.ts
|
|
4617
|
+
var VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
4618
|
+
"FOCUS_CHANGED",
|
|
4619
|
+
"ANNOUNCEMENT",
|
|
4620
|
+
"DOM_MUTATION",
|
|
4621
|
+
"ROLE_CHANGED",
|
|
4622
|
+
"ACCESSIBLE_NAME_CHANGED",
|
|
4623
|
+
"STATE_CHANGED",
|
|
4624
|
+
"KEYBOARD_ACTION",
|
|
4625
|
+
"DIALOG_OPENED",
|
|
4626
|
+
"DIALOG_CLOSED",
|
|
4627
|
+
"WARNING",
|
|
4628
|
+
"REGRESSION"
|
|
4629
|
+
]);
|
|
4630
|
+
var VALID_PAYLOAD_KINDS = /* @__PURE__ */ new Set([
|
|
4631
|
+
"focus_changed",
|
|
4632
|
+
"announcement",
|
|
4633
|
+
"role_changed",
|
|
4634
|
+
"accessible_name_changed",
|
|
4635
|
+
"state_changed",
|
|
4636
|
+
"dialog_opened",
|
|
4637
|
+
"dialog_closed",
|
|
4638
|
+
"keyboard_action",
|
|
4639
|
+
"warning"
|
|
4640
|
+
]);
|
|
4641
|
+
var VALID_ACTION_TYPES = /* @__PURE__ */ new Set([
|
|
4642
|
+
"click",
|
|
4643
|
+
"tab",
|
|
4644
|
+
"shiftTab",
|
|
4645
|
+
"escape",
|
|
4646
|
+
"enter",
|
|
4647
|
+
"space",
|
|
4648
|
+
"arrowUp",
|
|
4649
|
+
"arrowDown",
|
|
4650
|
+
"arrowLeft",
|
|
4651
|
+
"arrowRight",
|
|
4652
|
+
"key",
|
|
4653
|
+
"wait"
|
|
4654
|
+
]);
|
|
4655
|
+
function sortKeysDeep(value) {
|
|
4656
|
+
if (value === null || value === void 0) {
|
|
4657
|
+
return value;
|
|
4658
|
+
}
|
|
4659
|
+
if (Array.isArray(value)) {
|
|
4660
|
+
return value.map(sortKeysDeep);
|
|
4661
|
+
}
|
|
4662
|
+
if (typeof value === "object") {
|
|
4663
|
+
const sorted = {};
|
|
4664
|
+
const keys = Object.keys(value).sort();
|
|
4665
|
+
for (const key of keys) {
|
|
4666
|
+
sorted[key] = sortKeysDeep(value[key]);
|
|
4667
|
+
}
|
|
4668
|
+
return sorted;
|
|
4669
|
+
}
|
|
4670
|
+
return value;
|
|
4671
|
+
}
|
|
4672
|
+
function serializeEvent(event) {
|
|
4673
|
+
const sorted = sortKeysDeep(event);
|
|
4674
|
+
return JSON.stringify(sorted, null, 2);
|
|
4675
|
+
}
|
|
4676
|
+
function deserializeEvent(json) {
|
|
4677
|
+
const parsed = JSON.parse(json);
|
|
4678
|
+
validateEvent(parsed);
|
|
4679
|
+
return parsed;
|
|
4680
|
+
}
|
|
4681
|
+
function serializeTimeline(timeline) {
|
|
4682
|
+
const sorted = sortKeysDeep(timeline);
|
|
4683
|
+
return JSON.stringify(sorted, null, 2);
|
|
4684
|
+
}
|
|
4685
|
+
function deserializeTimeline(json) {
|
|
4686
|
+
const parsed = JSON.parse(json);
|
|
4687
|
+
validateTimeline(parsed);
|
|
4688
|
+
return parsed;
|
|
4689
|
+
}
|
|
4690
|
+
function serializeInteractionSequence(seq) {
|
|
4691
|
+
const sorted = sortKeysDeep(seq);
|
|
4692
|
+
return JSON.stringify(sorted, null, 2);
|
|
4693
|
+
}
|
|
4694
|
+
function deserializeInteractionSequence(json) {
|
|
4695
|
+
const parsed = JSON.parse(json);
|
|
4696
|
+
validateInteractionSequence(parsed);
|
|
4697
|
+
return parsed;
|
|
4698
|
+
}
|
|
4699
|
+
function timelinesEqual(a, b) {
|
|
4700
|
+
return serializeTimeline(a) === serializeTimeline(b);
|
|
4701
|
+
}
|
|
4702
|
+
function validateEvent(obj) {
|
|
4703
|
+
if (obj === null || typeof obj !== "object") {
|
|
4704
|
+
throw new Error("Event must be a non-null object");
|
|
4705
|
+
}
|
|
4706
|
+
const event = obj;
|
|
4707
|
+
if (typeof event.type !== "string" || !VALID_EVENT_TYPES.has(event.type)) {
|
|
4708
|
+
throw new Error(
|
|
4709
|
+
`Event "type" must be one of: ${[...VALID_EVENT_TYPES].join(", ")}. Got: ${String(event.type)}`
|
|
4710
|
+
);
|
|
4711
|
+
}
|
|
4712
|
+
if (typeof event.timestamp !== "number" || !Number.isFinite(event.timestamp)) {
|
|
4713
|
+
throw new Error('Event "timestamp" must be a finite number');
|
|
4714
|
+
}
|
|
4715
|
+
validateEventTarget(event.target, 'Event "target"');
|
|
4716
|
+
validatePayload(event.payload);
|
|
4717
|
+
}
|
|
4718
|
+
function validateEventTarget(obj, label) {
|
|
4719
|
+
if (obj === null || typeof obj !== "object") {
|
|
4720
|
+
throw new Error(`${label} must be a non-null object`);
|
|
4721
|
+
}
|
|
4722
|
+
const target = obj;
|
|
4723
|
+
if (typeof target.role !== "string") {
|
|
4724
|
+
throw new Error(`${label}.role must be a string`);
|
|
4725
|
+
}
|
|
4726
|
+
if (typeof target.accessibleName !== "string") {
|
|
4727
|
+
throw new Error(`${label}.accessibleName must be a string`);
|
|
4728
|
+
}
|
|
4729
|
+
if (typeof target.selector !== "string") {
|
|
4730
|
+
throw new Error(`${label}.selector must be a string`);
|
|
4731
|
+
}
|
|
4732
|
+
}
|
|
4733
|
+
function validatePayload(obj) {
|
|
4734
|
+
if (obj === null || typeof obj !== "object") {
|
|
4735
|
+
throw new Error('Event "payload" must be a non-null object');
|
|
4736
|
+
}
|
|
4737
|
+
const payload = obj;
|
|
4738
|
+
if (typeof payload.kind !== "string" || !VALID_PAYLOAD_KINDS.has(payload.kind)) {
|
|
4739
|
+
throw new Error(
|
|
4740
|
+
`Payload "kind" must be one of: ${[...VALID_PAYLOAD_KINDS].join(", ")}. Got: ${String(payload.kind)}`
|
|
4741
|
+
);
|
|
4742
|
+
}
|
|
4743
|
+
switch (payload.kind) {
|
|
4744
|
+
case "focus_changed":
|
|
4745
|
+
if (payload.previousTarget !== null) {
|
|
4746
|
+
validateEventTarget(payload.previousTarget, "FocusChangedPayload.previousTarget");
|
|
4747
|
+
}
|
|
4748
|
+
break;
|
|
4749
|
+
case "announcement":
|
|
4750
|
+
if (payload.politeness !== "polite" && payload.politeness !== "assertive") {
|
|
4751
|
+
throw new Error('AnnouncementPayload.politeness must be "polite" or "assertive"');
|
|
4752
|
+
}
|
|
4753
|
+
if (typeof payload.text !== "string") {
|
|
4754
|
+
throw new Error("AnnouncementPayload.text must be a string");
|
|
4755
|
+
}
|
|
4756
|
+
break;
|
|
4757
|
+
case "role_changed":
|
|
4758
|
+
if (typeof payload.previousRole !== "string") {
|
|
4759
|
+
throw new Error("RoleChangedPayload.previousRole must be a string");
|
|
4760
|
+
}
|
|
4761
|
+
if (typeof payload.newRole !== "string") {
|
|
4762
|
+
throw new Error("RoleChangedPayload.newRole must be a string");
|
|
4763
|
+
}
|
|
4764
|
+
break;
|
|
4765
|
+
case "accessible_name_changed":
|
|
4766
|
+
if (typeof payload.previousName !== "string") {
|
|
4767
|
+
throw new Error("AccessibleNameChangedPayload.previousName must be a string");
|
|
4768
|
+
}
|
|
4769
|
+
if (typeof payload.newName !== "string") {
|
|
4770
|
+
throw new Error("AccessibleNameChangedPayload.newName must be a string");
|
|
4771
|
+
}
|
|
4772
|
+
break;
|
|
4773
|
+
case "state_changed":
|
|
4774
|
+
if (typeof payload.attribute !== "string") {
|
|
4775
|
+
throw new Error("StateChangedPayload.attribute must be a string");
|
|
4776
|
+
}
|
|
4777
|
+
validateStateValue(payload.previousValue, "StateChangedPayload.previousValue");
|
|
4778
|
+
validateStateValue(payload.newValue, "StateChangedPayload.newValue");
|
|
4779
|
+
break;
|
|
4780
|
+
case "dialog_opened":
|
|
4781
|
+
if (typeof payload.dialogName !== "string") {
|
|
4782
|
+
throw new Error("DialogOpenedPayload.dialogName must be a string");
|
|
4783
|
+
}
|
|
4784
|
+
if (typeof payload.isModal !== "boolean") {
|
|
4785
|
+
throw new Error("DialogOpenedPayload.isModal must be a boolean");
|
|
4786
|
+
}
|
|
4787
|
+
break;
|
|
4788
|
+
case "dialog_closed":
|
|
4789
|
+
if (typeof payload.dialogName !== "string") {
|
|
4790
|
+
throw new Error("DialogClosedPayload.dialogName must be a string");
|
|
4791
|
+
}
|
|
4792
|
+
break;
|
|
4793
|
+
case "keyboard_action":
|
|
4794
|
+
if (typeof payload.key !== "string") {
|
|
4795
|
+
throw new Error("KeyboardActionPayload.key must be a string");
|
|
4796
|
+
}
|
|
4797
|
+
if (!Array.isArray(payload.modifiers)) {
|
|
4798
|
+
throw new Error("KeyboardActionPayload.modifiers must be an array");
|
|
4799
|
+
}
|
|
4800
|
+
for (const mod of payload.modifiers) {
|
|
4801
|
+
if (typeof mod !== "string") {
|
|
4802
|
+
throw new Error("KeyboardActionPayload.modifiers must contain only strings");
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
break;
|
|
4806
|
+
case "warning":
|
|
4807
|
+
if (typeof payload.message !== "string") {
|
|
4808
|
+
throw new Error("WarningPayload.message must be a string");
|
|
4809
|
+
}
|
|
4810
|
+
if (payload.relatedEvent !== void 0 && payload.relatedEvent !== null) {
|
|
4811
|
+
validateEvent(payload.relatedEvent);
|
|
4812
|
+
}
|
|
4813
|
+
break;
|
|
4814
|
+
}
|
|
4815
|
+
}
|
|
4816
|
+
function validateStateValue(value, label) {
|
|
4817
|
+
if (value !== null && typeof value !== "string" && typeof value !== "boolean") {
|
|
4818
|
+
throw new Error(`${label} must be a string, boolean, or null`);
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
function validateTimeline(obj) {
|
|
4822
|
+
if (obj === null || typeof obj !== "object") {
|
|
4823
|
+
throw new Error("Timeline must be a non-null object");
|
|
4824
|
+
}
|
|
4825
|
+
const timeline = obj;
|
|
4826
|
+
if (typeof timeline.version !== "string") {
|
|
4827
|
+
throw new Error('Timeline "version" must be a string');
|
|
4828
|
+
}
|
|
4829
|
+
if (typeof timeline.component !== "string") {
|
|
4830
|
+
throw new Error('Timeline "component" must be a string');
|
|
4831
|
+
}
|
|
4832
|
+
if (timeline.story !== null && typeof timeline.story !== "string") {
|
|
4833
|
+
throw new Error('Timeline "story" must be a string or null');
|
|
4834
|
+
}
|
|
4835
|
+
if (typeof timeline.interactionSequence !== "string") {
|
|
4836
|
+
throw new Error('Timeline "interactionSequence" must be a string');
|
|
4837
|
+
}
|
|
4838
|
+
if (typeof timeline.duration !== "number" || !Number.isFinite(timeline.duration)) {
|
|
4839
|
+
throw new Error('Timeline "duration" must be a finite number');
|
|
4840
|
+
}
|
|
4841
|
+
if (!Array.isArray(timeline.events)) {
|
|
4842
|
+
throw new Error('Timeline "events" must be an array');
|
|
4843
|
+
}
|
|
4844
|
+
for (let i = 0; i < timeline.events.length; i++) {
|
|
4845
|
+
try {
|
|
4846
|
+
validateEvent(timeline.events[i]);
|
|
4847
|
+
} catch (err) {
|
|
4848
|
+
throw new Error(
|
|
4849
|
+
`Timeline events[${i}]: ${err instanceof Error ? err.message : String(err)}`
|
|
4850
|
+
);
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
if (!Array.isArray(timeline.warnings)) {
|
|
4854
|
+
throw new Error('Timeline "warnings" must be an array');
|
|
4855
|
+
}
|
|
4856
|
+
for (let i = 0; i < timeline.warnings.length; i++) {
|
|
4857
|
+
try {
|
|
4858
|
+
validateEvent(timeline.warnings[i]);
|
|
4859
|
+
} catch (err) {
|
|
4860
|
+
throw new Error(
|
|
4861
|
+
`Timeline warnings[${i}]: ${err instanceof Error ? err.message : String(err)}`
|
|
4862
|
+
);
|
|
4863
|
+
}
|
|
4864
|
+
}
|
|
4865
|
+
validateTimelineMetadata(timeline.metadata);
|
|
4866
|
+
}
|
|
4867
|
+
function validateTimelineMetadata(obj) {
|
|
4868
|
+
if (obj === null || typeof obj !== "object") {
|
|
4869
|
+
throw new Error('Timeline "metadata" must be a non-null object');
|
|
4870
|
+
}
|
|
4871
|
+
const meta = obj;
|
|
4872
|
+
if (typeof meta.capturedAt !== "string") {
|
|
4873
|
+
throw new Error("Timeline metadata.capturedAt must be a string");
|
|
4874
|
+
}
|
|
4875
|
+
if (typeof meta.speakableVersion !== "string") {
|
|
4876
|
+
throw new Error("Timeline metadata.speakableVersion must be a string");
|
|
4877
|
+
}
|
|
4878
|
+
if (typeof meta.sourceUrl !== "string") {
|
|
4879
|
+
throw new Error("Timeline metadata.sourceUrl must be a string");
|
|
4880
|
+
}
|
|
4881
|
+
if (typeof meta.userAgent !== "string") {
|
|
4882
|
+
throw new Error("Timeline metadata.userAgent must be a string");
|
|
4883
|
+
}
|
|
4884
|
+
}
|
|
4885
|
+
function validateInteractionSequence(obj) {
|
|
4886
|
+
if (obj === null || typeof obj !== "object") {
|
|
4887
|
+
throw new Error("InteractionSequence must be a non-null object");
|
|
4888
|
+
}
|
|
4889
|
+
const seq = obj;
|
|
4890
|
+
if (typeof seq.description !== "string") {
|
|
4891
|
+
throw new Error('InteractionSequence "description" must be a string');
|
|
4892
|
+
}
|
|
4893
|
+
if (!Array.isArray(seq.actions)) {
|
|
4894
|
+
throw new Error('InteractionSequence "actions" must be an array');
|
|
4895
|
+
}
|
|
4896
|
+
for (let i = 0; i < seq.actions.length; i++) {
|
|
4897
|
+
validateAction(seq.actions[i], i);
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
function validateAction(obj, index) {
|
|
4901
|
+
if (obj === null || typeof obj !== "object") {
|
|
4902
|
+
throw new Error(`InteractionSequence actions[${index}] must be a non-null object`);
|
|
4903
|
+
}
|
|
4904
|
+
const action = obj;
|
|
4905
|
+
if (typeof action.type !== "string" || !VALID_ACTION_TYPES.has(action.type)) {
|
|
4906
|
+
throw new Error(
|
|
4907
|
+
`InteractionSequence actions[${index}].type must be one of: ${[...VALID_ACTION_TYPES].join(", ")}. Got: ${String(action.type)}`
|
|
4908
|
+
);
|
|
4909
|
+
}
|
|
4910
|
+
switch (action.type) {
|
|
4911
|
+
case "click":
|
|
4912
|
+
if (typeof action.selector !== "string") {
|
|
4913
|
+
throw new Error(
|
|
4914
|
+
`InteractionSequence actions[${index}].selector must be a string for click actions`
|
|
4915
|
+
);
|
|
4916
|
+
}
|
|
4917
|
+
break;
|
|
4918
|
+
case "key":
|
|
4919
|
+
if (typeof action.combo !== "string") {
|
|
4920
|
+
throw new Error(
|
|
4921
|
+
`InteractionSequence actions[${index}].combo must be a string for key actions`
|
|
4922
|
+
);
|
|
4923
|
+
}
|
|
4924
|
+
break;
|
|
4925
|
+
case "wait":
|
|
4926
|
+
if (typeof action.ms !== "number" || !Number.isFinite(action.ms)) {
|
|
4927
|
+
throw new Error(
|
|
4928
|
+
`InteractionSequence actions[${index}].ms must be a finite number for wait actions`
|
|
4929
|
+
);
|
|
4930
|
+
}
|
|
4931
|
+
break;
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4935
|
+
// src/runtime/diff-engine.ts
|
|
4936
|
+
function matchingKey(event) {
|
|
4937
|
+
return `${event.type}::${event.target.selector}`;
|
|
4938
|
+
}
|
|
4939
|
+
function payloadsEqual(a, b) {
|
|
4940
|
+
if (a.type !== b.type) return false;
|
|
4941
|
+
if (!targetsEqual(a.target, b.target)) return false;
|
|
4942
|
+
return deepEqual(a.payload, b.payload);
|
|
4943
|
+
}
|
|
4944
|
+
function targetsEqual(a, b) {
|
|
4945
|
+
return a.role === b.role && a.accessibleName === b.accessibleName && a.selector === b.selector;
|
|
4946
|
+
}
|
|
4947
|
+
function deepEqual(a, b) {
|
|
4948
|
+
if (a === b) return true;
|
|
4949
|
+
if (a === null || b === null) return a === b;
|
|
4950
|
+
if (typeof a !== typeof b) return false;
|
|
4951
|
+
if (typeof a !== "object") return a === b;
|
|
4952
|
+
const aObj = a;
|
|
4953
|
+
const bObj = b;
|
|
4954
|
+
const aKeys = Object.keys(aObj).sort();
|
|
4955
|
+
const bKeys = Object.keys(bObj).sort();
|
|
4956
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
4957
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
4958
|
+
if (aKeys[i] !== bKeys[i]) return false;
|
|
4959
|
+
if (!deepEqual(aObj[aKeys[i]], bObj[bKeys[i]])) return false;
|
|
4960
|
+
}
|
|
4961
|
+
return true;
|
|
4962
|
+
}
|
|
4963
|
+
function buildModifiedMessage(baseline, current) {
|
|
4964
|
+
const type = current.type;
|
|
4965
|
+
const selector = current.target.selector;
|
|
4966
|
+
if (baseline.target.accessibleName !== current.target.accessibleName) {
|
|
4967
|
+
return `${type} on "${selector}": accessible name changed from "${baseline.target.accessibleName}" to "${current.target.accessibleName}"`;
|
|
4968
|
+
}
|
|
4969
|
+
if (baseline.target.role !== current.target.role) {
|
|
4970
|
+
return `${type} on "${selector}": role changed from "${baseline.target.role}" to "${current.target.role}"`;
|
|
4971
|
+
}
|
|
4972
|
+
return `${type} on "${selector}": payload values differ`;
|
|
4973
|
+
}
|
|
4974
|
+
function diffTimelines(baseline, current) {
|
|
4975
|
+
const added = [];
|
|
4976
|
+
const removed = [];
|
|
4977
|
+
const modified = [];
|
|
4978
|
+
const currentByKey = /* @__PURE__ */ new Map();
|
|
4979
|
+
for (const event of current.events) {
|
|
4980
|
+
const key = matchingKey(event);
|
|
4981
|
+
const existing = currentByKey.get(key);
|
|
4982
|
+
if (existing) {
|
|
4983
|
+
existing.push(event);
|
|
4984
|
+
} else {
|
|
4985
|
+
currentByKey.set(key, [event]);
|
|
4986
|
+
}
|
|
4987
|
+
}
|
|
4988
|
+
const matchedCurrentIndices = /* @__PURE__ */ new Set();
|
|
4989
|
+
for (const baselineEvent of baseline.events) {
|
|
4990
|
+
const key = matchingKey(baselineEvent);
|
|
4991
|
+
const candidates = currentByKey.get(key);
|
|
4992
|
+
if (!candidates || candidates.length === 0) {
|
|
4993
|
+
removed.push({
|
|
4994
|
+
event: baselineEvent,
|
|
4995
|
+
message: `${baselineEvent.type} on "${baselineEvent.target.selector}" was removed`
|
|
4996
|
+
});
|
|
4997
|
+
continue;
|
|
4998
|
+
}
|
|
4999
|
+
let matched = false;
|
|
5000
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
5001
|
+
const candidateId = `${key}::${i}`;
|
|
5002
|
+
if (matchedCurrentIndices.has(candidateId)) continue;
|
|
5003
|
+
const currentEvent = candidates[i];
|
|
5004
|
+
matchedCurrentIndices.add(candidateId);
|
|
5005
|
+
if (!payloadsEqual(baselineEvent, currentEvent)) {
|
|
5006
|
+
modified.push({
|
|
5007
|
+
event: currentEvent,
|
|
5008
|
+
previousEvent: baselineEvent,
|
|
5009
|
+
message: buildModifiedMessage(baselineEvent, currentEvent)
|
|
5010
|
+
});
|
|
5011
|
+
}
|
|
5012
|
+
matched = true;
|
|
5013
|
+
break;
|
|
5014
|
+
}
|
|
5015
|
+
if (!matched) {
|
|
5016
|
+
removed.push({
|
|
5017
|
+
event: baselineEvent,
|
|
5018
|
+
message: `${baselineEvent.type} on "${baselineEvent.target.selector}" was removed`
|
|
5019
|
+
});
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
for (const [key, candidates] of currentByKey.entries()) {
|
|
5023
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
5024
|
+
const candidateId = `${key}::${i}`;
|
|
5025
|
+
if (!matchedCurrentIndices.has(candidateId)) {
|
|
5026
|
+
const event = candidates[i];
|
|
5027
|
+
added.push({
|
|
5028
|
+
event,
|
|
5029
|
+
message: `${event.type} on "${event.target.selector}" was added`
|
|
5030
|
+
});
|
|
5031
|
+
}
|
|
5032
|
+
}
|
|
5033
|
+
}
|
|
5034
|
+
return {
|
|
5035
|
+
baseline: { totalEvents: baseline.events.length },
|
|
5036
|
+
current: { totalEvents: current.events.length },
|
|
5037
|
+
added,
|
|
5038
|
+
removed,
|
|
5039
|
+
modified,
|
|
5040
|
+
summary: {
|
|
5041
|
+
totalBaseline: baseline.events.length,
|
|
5042
|
+
totalCurrent: current.events.length,
|
|
5043
|
+
added: added.length,
|
|
5044
|
+
removed: removed.length,
|
|
5045
|
+
modified: modified.length
|
|
5046
|
+
}
|
|
5047
|
+
};
|
|
5048
|
+
}
|
|
5049
|
+
|
|
5050
|
+
// src/runtime/severity.ts
|
|
5051
|
+
function classifyDiff(report) {
|
|
5052
|
+
const entries = [];
|
|
5053
|
+
for (const entry of report.removed) {
|
|
5054
|
+
const severity = classifyRemoved(entry);
|
|
5055
|
+
entries.push({ ...entry, severity });
|
|
5056
|
+
}
|
|
5057
|
+
for (const entry of report.added) {
|
|
5058
|
+
const severity = classifyAdded(entry);
|
|
5059
|
+
entries.push({ ...entry, severity });
|
|
5060
|
+
}
|
|
5061
|
+
for (const entry of report.modified) {
|
|
5062
|
+
const severity = classifyModified(entry);
|
|
5063
|
+
entries.push({ ...entry, severity });
|
|
5064
|
+
}
|
|
5065
|
+
const criticalCount = entries.filter((e) => e.severity === "critical").length;
|
|
5066
|
+
const highCount = entries.filter((e) => e.severity === "high").length;
|
|
5067
|
+
const mediumCount = entries.filter((e) => e.severity === "medium").length;
|
|
5068
|
+
const lowCount = entries.filter((e) => e.severity === "low").length;
|
|
5069
|
+
let highestSeverity = null;
|
|
5070
|
+
if (criticalCount > 0) highestSeverity = "critical";
|
|
5071
|
+
else if (highCount > 0) highestSeverity = "high";
|
|
5072
|
+
else if (mediumCount > 0) highestSeverity = "medium";
|
|
5073
|
+
else if (lowCount > 0) highestSeverity = "low";
|
|
5074
|
+
return { entries, highestSeverity, criticalCount, highCount, mediumCount, lowCount };
|
|
5075
|
+
}
|
|
5076
|
+
function classifyRemoved(entry) {
|
|
5077
|
+
switch (entry.event.type) {
|
|
5078
|
+
case "FOCUS_CHANGED":
|
|
5079
|
+
return "critical";
|
|
5080
|
+
case "ANNOUNCEMENT":
|
|
5081
|
+
return "critical";
|
|
5082
|
+
case "DIALOG_OPENED":
|
|
5083
|
+
return "high";
|
|
5084
|
+
case "DIALOG_CLOSED":
|
|
5085
|
+
return "high";
|
|
5086
|
+
case "STATE_CHANGED":
|
|
5087
|
+
return "high";
|
|
5088
|
+
case "KEYBOARD_ACTION":
|
|
5089
|
+
return "medium";
|
|
5090
|
+
default:
|
|
5091
|
+
return "medium";
|
|
5092
|
+
}
|
|
5093
|
+
}
|
|
5094
|
+
function classifyAdded(entry) {
|
|
5095
|
+
switch (entry.event.type) {
|
|
5096
|
+
case "WARNING":
|
|
5097
|
+
return "high";
|
|
5098
|
+
case "REGRESSION":
|
|
5099
|
+
return "critical";
|
|
5100
|
+
case "ANNOUNCEMENT":
|
|
5101
|
+
return "medium";
|
|
5102
|
+
case "FOCUS_CHANGED":
|
|
5103
|
+
return "medium";
|
|
5104
|
+
default:
|
|
5105
|
+
return "low";
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
function classifyModified(entry) {
|
|
5109
|
+
switch (entry.event.type) {
|
|
5110
|
+
case "FOCUS_CHANGED":
|
|
5111
|
+
return "high";
|
|
5112
|
+
case "STATE_CHANGED":
|
|
5113
|
+
return "high";
|
|
5114
|
+
case "ACCESSIBLE_NAME_CHANGED":
|
|
5115
|
+
return "medium";
|
|
5116
|
+
case "ANNOUNCEMENT":
|
|
5117
|
+
return "medium";
|
|
5118
|
+
default:
|
|
5119
|
+
return "medium";
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
|
|
5123
|
+
// src/runtime/baseline-storage.ts
|
|
5124
|
+
import { readFile, writeFile, mkdir, access } from "fs/promises";
|
|
5125
|
+
import { join, dirname } from "path";
|
|
5126
|
+
var SPEAKABLE_VERSION2 = "1.3.0";
|
|
5127
|
+
function createBaselineStorage(baseDir) {
|
|
5128
|
+
function sanitize(name) {
|
|
5129
|
+
return name.toLowerCase().replace(/[/\\]/g, "-").replace(/[^a-z0-9._-]/g, "-");
|
|
5130
|
+
}
|
|
5131
|
+
function getPath(componentName, storyName) {
|
|
5132
|
+
return join(baseDir, sanitize(componentName), `${sanitize(storyName)}.timeline.json`);
|
|
5133
|
+
}
|
|
5134
|
+
return {
|
|
5135
|
+
async save(componentName, storyName, timeline, meta) {
|
|
5136
|
+
const filePath = getPath(componentName, storyName);
|
|
5137
|
+
const dir = dirname(filePath);
|
|
5138
|
+
try {
|
|
5139
|
+
await mkdir(dir, { recursive: true });
|
|
5140
|
+
} catch (err) {
|
|
5141
|
+
throw new Error(`Cannot create baseline directory "${dir}": ${err.message}`);
|
|
5142
|
+
}
|
|
5143
|
+
const baselineFile = {
|
|
5144
|
+
baseline: {
|
|
5145
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5146
|
+
speakableVersion: SPEAKABLE_VERSION2,
|
|
5147
|
+
storybookUrl: meta?.storybookUrl,
|
|
5148
|
+
interactionSequence: timeline.interactionSequence
|
|
5149
|
+
},
|
|
5150
|
+
timeline
|
|
5151
|
+
};
|
|
5152
|
+
const json = JSON.stringify(baselineFile, null, 2);
|
|
5153
|
+
try {
|
|
5154
|
+
await writeFile(filePath, json, "utf-8");
|
|
5155
|
+
} catch (err) {
|
|
5156
|
+
throw new Error(`Cannot write baseline file "${filePath}": ${err.message}`);
|
|
5157
|
+
}
|
|
5158
|
+
},
|
|
5159
|
+
async load(componentName, storyName) {
|
|
5160
|
+
const filePath = getPath(componentName, storyName);
|
|
5161
|
+
try {
|
|
5162
|
+
const content = await readFile(filePath, "utf-8");
|
|
5163
|
+
const parsed = JSON.parse(content);
|
|
5164
|
+
return parsed;
|
|
5165
|
+
} catch {
|
|
5166
|
+
return null;
|
|
5167
|
+
}
|
|
5168
|
+
},
|
|
5169
|
+
async exists(componentName, storyName) {
|
|
5170
|
+
const filePath = getPath(componentName, storyName);
|
|
5171
|
+
try {
|
|
5172
|
+
await access(filePath);
|
|
5173
|
+
return true;
|
|
5174
|
+
} catch {
|
|
5175
|
+
return false;
|
|
5176
|
+
}
|
|
5177
|
+
},
|
|
5178
|
+
getBaselinePath(componentName, storyName) {
|
|
5179
|
+
return getPath(componentName, storyName);
|
|
5180
|
+
}
|
|
5181
|
+
};
|
|
5182
|
+
}
|
|
5183
|
+
|
|
5184
|
+
// src/runtime/verbosity-analyzer.ts
|
|
5185
|
+
var DEFAULT_CONFIG2 = {
|
|
5186
|
+
interactionFrameWindow: 150,
|
|
5187
|
+
textSimilarityThreshold: 0.6
|
|
5188
|
+
};
|
|
5189
|
+
function textSimilarity(a, b) {
|
|
5190
|
+
if (!a || !b) return 0;
|
|
5191
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
|
5192
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
|
5193
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
5194
|
+
let overlap = 0;
|
|
5195
|
+
for (const word of wordsA) {
|
|
5196
|
+
if (wordsB.has(word)) overlap++;
|
|
5197
|
+
}
|
|
5198
|
+
const maxSize = Math.max(wordsA.size, wordsB.size);
|
|
5199
|
+
return overlap / maxSize;
|
|
5200
|
+
}
|
|
5201
|
+
function groupIntoFrames(events, windowMs) {
|
|
5202
|
+
if (events.length === 0) return [];
|
|
5203
|
+
const frames = [];
|
|
5204
|
+
let currentFrame = {
|
|
5205
|
+
start: events[0].timestamp,
|
|
5206
|
+
end: events[0].timestamp,
|
|
5207
|
+
events: [events[0]]
|
|
5208
|
+
};
|
|
5209
|
+
for (let i = 1; i < events.length; i++) {
|
|
5210
|
+
const event = events[i];
|
|
5211
|
+
if (event.timestamp - currentFrame.end <= windowMs) {
|
|
5212
|
+
currentFrame.events.push(event);
|
|
5213
|
+
currentFrame.end = event.timestamp;
|
|
5214
|
+
} else {
|
|
5215
|
+
frames.push(currentFrame);
|
|
5216
|
+
currentFrame = {
|
|
5217
|
+
start: event.timestamp,
|
|
5218
|
+
end: event.timestamp,
|
|
5219
|
+
events: [event]
|
|
5220
|
+
};
|
|
5221
|
+
}
|
|
5222
|
+
}
|
|
5223
|
+
frames.push(currentFrame);
|
|
5224
|
+
return frames;
|
|
5225
|
+
}
|
|
5226
|
+
function detectRedundantStateAndLiveRegion(frame, _config) {
|
|
5227
|
+
const findings = [];
|
|
5228
|
+
const stateChanges = frame.events.filter((e) => e.type === "STATE_CHANGED");
|
|
5229
|
+
const announcements = frame.events.filter((e) => e.type === "ANNOUNCEMENT");
|
|
5230
|
+
for (const stateEvent of stateChanges) {
|
|
5231
|
+
const payload = stateEvent.payload;
|
|
5232
|
+
const announcedAttributes = [
|
|
5233
|
+
"aria-selected",
|
|
5234
|
+
"aria-checked",
|
|
5235
|
+
"aria-pressed",
|
|
5236
|
+
"aria-expanded",
|
|
5237
|
+
"aria-disabled",
|
|
5238
|
+
"aria-invalid",
|
|
5239
|
+
"aria-current"
|
|
5240
|
+
];
|
|
5241
|
+
if (!announcedAttributes.includes(payload.attribute)) continue;
|
|
5242
|
+
for (const announcement of announcements) {
|
|
5243
|
+
const annoPayload = announcement.payload;
|
|
5244
|
+
const annoText = annoPayload.text.toLowerCase();
|
|
5245
|
+
const elementName = stateEvent.target.accessibleName.toLowerCase();
|
|
5246
|
+
const nameInAnnouncement = elementName.length > 0 && annoText.includes(elementName);
|
|
5247
|
+
const stateEchoWords = ["selected", "deselected", "checked", "unchecked", "expanded", "collapsed", "pressed"];
|
|
5248
|
+
const hasStateEcho = stateEchoWords.some((word) => annoText.includes(word));
|
|
5249
|
+
if (nameInAnnouncement && hasStateEcho) {
|
|
5250
|
+
findings.push({
|
|
5251
|
+
pattern: "redundant-state-and-live-region",
|
|
5252
|
+
severity: "high",
|
|
5253
|
+
description: `The ${payload.attribute} state change on "${stateEvent.target.accessibleName}" (${stateEvent.target.role}) is already announced by the screen reader, but a live region also announces similar content ("${annoPayload.text}"). This causes VoiceOver to read the information 2-3 times.`,
|
|
5254
|
+
remediation: [
|
|
5255
|
+
`Remove the live region announcement when ${payload.attribute} changes on the active element, since the screen reader already announces state transitions on the focused/active item.`,
|
|
5256
|
+
`If you need the live region for summary info (e.g., "3 of 5 selected"), debounce it with a 150ms+ delay and ensure the text does NOT repeat the option name.`,
|
|
5257
|
+
`Use a summary-only pattern in the live region: "3 of 5 selected" instead of "JavaScript selected. 3 of 5 selected." The element name is already announced via the state change.`,
|
|
5258
|
+
`Consider removing the live region entirely and relying on aria-selected/aria-checked state changes for per-item feedback.`
|
|
5259
|
+
],
|
|
5260
|
+
affectedReaders: ["VoiceOver (macOS)", "VoiceOver (iOS)"],
|
|
5261
|
+
relatedEvents: [stateEvent, announcement],
|
|
5262
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5263
|
+
elementSelector: stateEvent.target.selector
|
|
5264
|
+
});
|
|
5265
|
+
}
|
|
5266
|
+
}
|
|
5267
|
+
}
|
|
5268
|
+
return findings;
|
|
5269
|
+
}
|
|
5270
|
+
function detectActiveDescendantPlusStateChange(frame, _config) {
|
|
5271
|
+
const findings = [];
|
|
5272
|
+
const activedescendantChanges = frame.events.filter(
|
|
5273
|
+
(e) => e.type === "STATE_CHANGED" && e.payload.attribute === "aria-activedescendant"
|
|
5274
|
+
);
|
|
5275
|
+
const otherStateChanges = frame.events.filter(
|
|
5276
|
+
(e) => e.type === "STATE_CHANGED" && e.payload.attribute !== "aria-activedescendant"
|
|
5277
|
+
);
|
|
5278
|
+
for (const adChange of activedescendantChanges) {
|
|
5279
|
+
const adPayload = adChange.payload;
|
|
5280
|
+
const newValue = String(adPayload.newValue);
|
|
5281
|
+
const previousValue = String(adPayload.previousValue ?? "");
|
|
5282
|
+
const isSameElement = newValue === previousValue;
|
|
5283
|
+
for (const stateChange of otherStateChanges) {
|
|
5284
|
+
const statePayload = stateChange.payload;
|
|
5285
|
+
const targetMatchesAD = stateChange.target.selector.includes(newValue) || stateChange.target.selector === adChange.target.selector;
|
|
5286
|
+
if (!targetMatchesAD) continue;
|
|
5287
|
+
if (isSameElement) {
|
|
5288
|
+
findings.push({
|
|
5289
|
+
pattern: "activedescendant-plus-state-change",
|
|
5290
|
+
severity: "high",
|
|
5291
|
+
description: `aria-activedescendant was re-set to the same value ("${newValue}") AND ${statePayload.attribute} changed on that element within ${frame.end - frame.start}ms. VoiceOver reads the element once for the activedescendant update, then again for the state change.`,
|
|
5292
|
+
remediation: [
|
|
5293
|
+
`When toggling selection on the already-active option, do NOT re-set aria-activedescendant to the same value. Only update it when the user navigates to a different option.`,
|
|
5294
|
+
`Batch DOM attribute changes: set aria-selected BEFORE updating aria-activedescendant, or skip the activedescendant update entirely when the active item hasn't changed.`,
|
|
5295
|
+
`For click interactions on options, avoid calling both setActiveDescendant() and toggleSelection() if the clicked item is already the active descendant.`
|
|
5296
|
+
],
|
|
5297
|
+
affectedReaders: ["VoiceOver (macOS)", "VoiceOver (iOS)", "Narrator"],
|
|
5298
|
+
relatedEvents: [adChange, stateChange],
|
|
5299
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5300
|
+
elementSelector: stateChange.target.selector
|
|
5301
|
+
});
|
|
5302
|
+
} else {
|
|
5303
|
+
findings.push({
|
|
5304
|
+
pattern: "activedescendant-plus-state-change",
|
|
5305
|
+
severity: "low",
|
|
5306
|
+
description: `aria-activedescendant moved to "${stateChange.target.accessibleName}" AND ${statePayload.attribute} changed on that element within ${frame.end - frame.start}ms. VoiceOver may announce the option name twice (once for navigation, once for state).`,
|
|
5307
|
+
remediation: [
|
|
5308
|
+
`If this is a click-to-select interaction, consider separating the activedescendant update and the state change by a microtask (Promise.resolve()) so VoiceOver processes them as separate assertions.`,
|
|
5309
|
+
`Alternatively, accept this as expected behavior for click interactions where navigation and selection happen simultaneously.`
|
|
5310
|
+
],
|
|
5311
|
+
affectedReaders: ["VoiceOver (macOS)"],
|
|
5312
|
+
relatedEvents: [adChange, stateChange],
|
|
5313
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5314
|
+
elementSelector: stateChange.target.selector
|
|
5315
|
+
});
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
}
|
|
5319
|
+
return findings;
|
|
5320
|
+
}
|
|
5321
|
+
function detectNameChangePlusLiveRegion(frame, config) {
|
|
5322
|
+
const findings = [];
|
|
5323
|
+
const nameChanges = frame.events.filter((e) => e.type === "ACCESSIBLE_NAME_CHANGED");
|
|
5324
|
+
const announcements = frame.events.filter((e) => e.type === "ANNOUNCEMENT");
|
|
5325
|
+
for (const nameEvent of nameChanges) {
|
|
5326
|
+
for (const announcement of announcements) {
|
|
5327
|
+
const annoPayload = announcement.payload;
|
|
5328
|
+
const similarity = textSimilarity(nameEvent.target.accessibleName, annoPayload.text);
|
|
5329
|
+
if (similarity >= config.textSimilarityThreshold) {
|
|
5330
|
+
findings.push({
|
|
5331
|
+
pattern: "name-change-plus-live-region",
|
|
5332
|
+
severity: "medium",
|
|
5333
|
+
description: `The accessible name of "${nameEvent.target.accessibleName}" (${nameEvent.target.role}) changed AND a live region announced similar content ("${annoPayload.text}"). Screen readers may announce both the name change and the live region.`,
|
|
5334
|
+
remediation: [
|
|
5335
|
+
`Choose one announcement channel: either update the element's accessible name (which the screen reader reads if the element is focused) OR use a live region, not both.`,
|
|
5336
|
+
`If the element is focused, prefer the name change. If it's not focused, prefer the live region.`,
|
|
5337
|
+
`Add a 200ms+ debounce to the live region update to let the name change announce first.`
|
|
5338
|
+
],
|
|
5339
|
+
affectedReaders: ["VoiceOver (macOS)", "NVDA", "JAWS"],
|
|
5340
|
+
relatedEvents: [nameEvent, announcement],
|
|
5341
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5342
|
+
elementSelector: nameEvent.target.selector
|
|
5343
|
+
});
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
return findings;
|
|
5348
|
+
}
|
|
5349
|
+
function detectFocusPlusActiveDescendant(frame, _config) {
|
|
5350
|
+
const findings = [];
|
|
5351
|
+
const focusEvents = frame.events.filter((e) => e.type === "FOCUS_CHANGED");
|
|
5352
|
+
const adChanges = frame.events.filter(
|
|
5353
|
+
(e) => e.type === "STATE_CHANGED" && e.payload.attribute === "aria-activedescendant"
|
|
5354
|
+
);
|
|
5355
|
+
if (focusEvents.length > 0 && adChanges.length > 0) {
|
|
5356
|
+
findings.push({
|
|
5357
|
+
pattern: "focus-plus-activedescendant",
|
|
5358
|
+
severity: "medium",
|
|
5359
|
+
description: `Both real DOM focus moved AND aria-activedescendant changed within ${frame.end - frame.start}ms. Screen readers may announce the focused element AND then re-announce via activedescendant, causing a double-read.`,
|
|
5360
|
+
remediation: [
|
|
5361
|
+
`Choose one focus management strategy: either move real DOM focus to options (roving tabindex) OR use aria-activedescendant on the container. Do not mix both.`,
|
|
5362
|
+
`If using aria-activedescendant, keep DOM focus on the container (listbox/combobox) and only change the activedescendant attribute.`,
|
|
5363
|
+
`If using roving tabindex, set tabindex="0" on the active item and tabindex="-1" on others, and move focus directly via element.focus().`
|
|
5364
|
+
],
|
|
5365
|
+
affectedReaders: ["VoiceOver (macOS)", "NVDA", "JAWS", "Narrator"],
|
|
5366
|
+
relatedEvents: [...focusEvents, ...adChanges],
|
|
5367
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5368
|
+
elementSelector: focusEvents[0].target.selector
|
|
5369
|
+
});
|
|
5370
|
+
}
|
|
5371
|
+
return findings;
|
|
5372
|
+
}
|
|
5373
|
+
function detectMultipleStateChangesSameElement(frame, _config) {
|
|
5374
|
+
const findings = [];
|
|
5375
|
+
const stateChanges = frame.events.filter((e) => e.type === "STATE_CHANGED");
|
|
5376
|
+
const bySelector = /* @__PURE__ */ new Map();
|
|
5377
|
+
for (const event of stateChanges) {
|
|
5378
|
+
const existing = bySelector.get(event.target.selector) || [];
|
|
5379
|
+
existing.push(event);
|
|
5380
|
+
bySelector.set(event.target.selector, existing);
|
|
5381
|
+
}
|
|
5382
|
+
for (const [selector, events] of bySelector) {
|
|
5383
|
+
if (events.length >= 3) {
|
|
5384
|
+
const attributes = events.map((e) => e.payload.attribute);
|
|
5385
|
+
findings.push({
|
|
5386
|
+
pattern: "multiple-state-changes-same-element",
|
|
5387
|
+
severity: "medium",
|
|
5388
|
+
description: `${events.length} ARIA attributes changed simultaneously on "${events[0].target.accessibleName}" (${events[0].target.role}): ${attributes.join(", ")}. Some screen readers announce each attribute change individually, causing a verbose multi-part announcement.`,
|
|
5389
|
+
remediation: [
|
|
5390
|
+
`Batch attribute changes into a single DOM mutation frame by setting all attributes before the next microtask. Use requestAnimationFrame() or Promise.resolve() to group them.`,
|
|
5391
|
+
`Evaluate which attributes actually need to be communicated. For example, if aria-selected changes, the screen reader already announces it; additional aria-checked or aria-pressed changes may be redundant.`,
|
|
5392
|
+
`Consider using a single composite state (e.g., just aria-selected for listbox options) rather than multiple state attributes.`
|
|
5393
|
+
],
|
|
5394
|
+
affectedReaders: ["VoiceOver (macOS)", "Narrator"],
|
|
5395
|
+
relatedEvents: events,
|
|
5396
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5397
|
+
elementSelector: selector
|
|
5398
|
+
});
|
|
5399
|
+
}
|
|
5400
|
+
}
|
|
5401
|
+
return findings;
|
|
5402
|
+
}
|
|
5403
|
+
function detectRapidIdenticalAnnouncements(frame, _config) {
|
|
5404
|
+
const findings = [];
|
|
5405
|
+
const announcements = frame.events.filter((e) => e.type === "ANNOUNCEMENT");
|
|
5406
|
+
const seen = /* @__PURE__ */ new Map();
|
|
5407
|
+
for (const event of announcements) {
|
|
5408
|
+
const text = event.payload.text.toLowerCase().trim();
|
|
5409
|
+
const existing = seen.get(text) || [];
|
|
5410
|
+
existing.push(event);
|
|
5411
|
+
seen.set(text, existing);
|
|
5412
|
+
}
|
|
5413
|
+
for (const [text, events] of seen) {
|
|
5414
|
+
if (events.length >= 2) {
|
|
5415
|
+
findings.push({
|
|
5416
|
+
pattern: "rapid-identical-announcements",
|
|
5417
|
+
severity: "high",
|
|
5418
|
+
description: `The same live region content ("${text}") was announced ${events.length} times within ${frame.end - frame.start}ms. Users will hear the same information repeated.`,
|
|
5419
|
+
remediation: [
|
|
5420
|
+
`Debounce live region updates. Clear the live region text content first, then set the new value after a 100-200ms delay.`,
|
|
5421
|
+
`If updating a live region's textContent to the same value, the screen reader may ignore it OR re-read it depending on implementation. Clear to empty string first.`,
|
|
5422
|
+
`Avoid updating the live region inside a loop or rapid event handler. Compute the final state and announce once.`
|
|
5423
|
+
],
|
|
5424
|
+
affectedReaders: ["VoiceOver (macOS)", "VoiceOver (iOS)", "NVDA", "Narrator"],
|
|
5425
|
+
relatedEvents: events,
|
|
5426
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5427
|
+
elementSelector: events[0].target.selector
|
|
5428
|
+
});
|
|
5429
|
+
}
|
|
5430
|
+
}
|
|
5431
|
+
return findings;
|
|
5432
|
+
}
|
|
5433
|
+
function analyzeVerbosity(events, config) {
|
|
5434
|
+
const cfg = { ...DEFAULT_CONFIG2, ...config };
|
|
5435
|
+
const frames = groupIntoFrames(events, cfg.interactionFrameWindow);
|
|
5436
|
+
const findings = [];
|
|
5437
|
+
for (const frame of frames) {
|
|
5438
|
+
findings.push(...detectRedundantStateAndLiveRegion(frame, cfg));
|
|
5439
|
+
findings.push(...detectActiveDescendantPlusStateChange(frame, cfg));
|
|
5440
|
+
findings.push(...detectNameChangePlusLiveRegion(frame, cfg));
|
|
5441
|
+
findings.push(...detectFocusPlusActiveDescendant(frame, cfg));
|
|
5442
|
+
findings.push(...detectMultipleStateChangesSameElement(frame, cfg));
|
|
5443
|
+
findings.push(...detectRapidIdenticalAnnouncements(frame, cfg));
|
|
5444
|
+
}
|
|
5445
|
+
const severityOrder = { high: 0, medium: 1, low: 2 };
|
|
5446
|
+
findings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
5447
|
+
const summary = {
|
|
5448
|
+
high: findings.filter((f) => f.severity === "high").length,
|
|
5449
|
+
medium: findings.filter((f) => f.severity === "medium").length,
|
|
5450
|
+
low: findings.filter((f) => f.severity === "low").length,
|
|
5451
|
+
total: findings.length
|
|
5452
|
+
};
|
|
5453
|
+
const deductions = summary.high * 25 + summary.medium * 10 + summary.low * 3;
|
|
5454
|
+
const score = Math.max(0, 100 - deductions);
|
|
5455
|
+
return { findings, summary, score };
|
|
5456
|
+
}
|
|
5457
|
+
function formatVerbosityReport(report) {
|
|
5458
|
+
const lines = [];
|
|
5459
|
+
lines.push("");
|
|
5460
|
+
lines.push("=== VERBOSITY ANALYSIS REPORT ===");
|
|
5461
|
+
lines.push("");
|
|
5462
|
+
lines.push(`Score: ${report.score}/100 ${report.score >= 80 ? "(Good)" : report.score >= 50 ? "(Needs attention)" : "(Critical verbosity issues)"}`);
|
|
5463
|
+
lines.push(`Findings: ${report.summary.total} (${report.summary.high} high, ${report.summary.medium} medium, ${report.summary.low} low)`);
|
|
5464
|
+
lines.push("");
|
|
5465
|
+
if (report.findings.length === 0) {
|
|
5466
|
+
lines.push("No verbosity issues detected. Announcements appear clean.");
|
|
5467
|
+
return lines.join("\n");
|
|
5468
|
+
}
|
|
5469
|
+
for (let i = 0; i < report.findings.length; i++) {
|
|
5470
|
+
const finding = report.findings[i];
|
|
5471
|
+
const severityIcon = finding.severity === "high" ? "\u2718" : finding.severity === "medium" ? "\u26A0" : "\u2139";
|
|
5472
|
+
lines.push(`${severityIcon} [${finding.severity.toUpperCase()}] ${finding.pattern}`);
|
|
5473
|
+
lines.push(` Element: ${finding.elementSelector}`);
|
|
5474
|
+
lines.push(` Window: ${finding.timeWindow.start}ms - ${finding.timeWindow.end}ms`);
|
|
5475
|
+
lines.push(` ${finding.description}`);
|
|
5476
|
+
lines.push("");
|
|
5477
|
+
lines.push(" Affected: " + finding.affectedReaders.join(", "));
|
|
5478
|
+
lines.push("");
|
|
5479
|
+
lines.push(" Remediation:");
|
|
5480
|
+
for (const step of finding.remediation) {
|
|
5481
|
+
lines.push(` - ${step}`);
|
|
5482
|
+
}
|
|
5483
|
+
lines.push("");
|
|
5484
|
+
if (i < report.findings.length - 1) {
|
|
5485
|
+
lines.push(" " + "\u2500".repeat(60));
|
|
5486
|
+
lines.push("");
|
|
5487
|
+
}
|
|
5488
|
+
}
|
|
5489
|
+
return lines.join("\n");
|
|
5490
|
+
}
|
|
2661
5491
|
export {
|
|
2662
5492
|
CURRENT_MODEL_VERSION,
|
|
2663
5493
|
ParsingError,
|
|
@@ -2691,6 +5521,7 @@ export {
|
|
|
2691
5521
|
renderNVDA,
|
|
2692
5522
|
renderNarrator,
|
|
2693
5523
|
renderVoiceOver,
|
|
5524
|
+
runtime_exports as runtime,
|
|
2694
5525
|
serializeModel,
|
|
2695
5526
|
validateHTMLNotEmpty,
|
|
2696
5527
|
validateModel,
|