pulse-js-framework 1.10.0 → 1.10.3
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/compiler/parser/_extract.js +393 -0
- package/compiler/parser/blocks.js +361 -0
- package/compiler/parser/core.js +306 -0
- package/compiler/parser/expressions.js +386 -0
- package/compiler/parser/imports.js +108 -0
- package/compiler/parser/index.js +47 -0
- package/compiler/parser/state.js +155 -0
- package/compiler/parser/style.js +445 -0
- package/compiler/parser/view.js +632 -0
- package/compiler/parser.js +15 -2372
- package/compiler/parser.js.original +2376 -0
- package/package.json +2 -1
- package/runtime/a11y/announcements.js +213 -0
- package/runtime/a11y/contrast.js +125 -0
- package/runtime/a11y/focus.js +412 -0
- package/runtime/a11y/index.js +35 -0
- package/runtime/a11y/preferences.js +121 -0
- package/runtime/a11y/utils.js +164 -0
- package/runtime/a11y/validation.js +258 -0
- package/runtime/a11y/widgets.js +545 -0
- package/runtime/a11y.js +15 -1840
- package/runtime/a11y.js.original +1844 -0
- package/runtime/graphql/cache.js +69 -0
- package/runtime/graphql/client.js +563 -0
- package/runtime/graphql/hooks.js +492 -0
- package/runtime/graphql/index.js +62 -0
- package/runtime/graphql/subscriptions.js +241 -0
- package/runtime/graphql.js +12 -1322
- package/runtime/graphql.js.original +1326 -0
- package/runtime/router/core.js +956 -0
- package/runtime/router/guards.js +90 -0
- package/runtime/router/history.js +204 -0
- package/runtime/router/index.js +36 -0
- package/runtime/router/lazy.js +180 -0
- package/runtime/router/utils.js +226 -0
- package/runtime/router.js +12 -1600
- package/runtime/router.js.original +1605 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse A11y - Utilities
|
|
3
|
+
*
|
|
4
|
+
* Utility functions for accessibility features
|
|
5
|
+
*
|
|
6
|
+
* @module pulse-js-framework/runtime/a11y/utils
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// UTILITIES
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a unique ID for ARIA relationships
|
|
15
|
+
* @param {string} prefix - ID prefix
|
|
16
|
+
* @returns {string} Unique ID
|
|
17
|
+
*/
|
|
18
|
+
export function generateId(prefix = 'pulse') {
|
|
19
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute the accessible name of an element
|
|
24
|
+
* Follows simplified ARIA accessible name computation algorithm
|
|
25
|
+
* @param {HTMLElement} element - Element to get name for
|
|
26
|
+
* @returns {string} The accessible name
|
|
27
|
+
*/
|
|
28
|
+
export function getAccessibleName(element) {
|
|
29
|
+
if (!element) return '';
|
|
30
|
+
|
|
31
|
+
// 1. aria-labelledby takes precedence
|
|
32
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
33
|
+
if (labelledBy) {
|
|
34
|
+
const ids = labelledBy.split(/\s+/);
|
|
35
|
+
const names = ids
|
|
36
|
+
.map(id => document.getElementById(id))
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map(el => el.textContent?.trim() || '');
|
|
39
|
+
if (names.length > 0) {
|
|
40
|
+
return names.join(' ');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. aria-label
|
|
45
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
46
|
+
if (ariaLabel && ariaLabel.trim()) {
|
|
47
|
+
return ariaLabel.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Native label association (for form controls)
|
|
51
|
+
if (element.labels && element.labels.length > 0) {
|
|
52
|
+
return Array.from(element.labels)
|
|
53
|
+
.map(label => label.textContent?.trim() || '')
|
|
54
|
+
.join(' ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 4. title attribute
|
|
58
|
+
const title = element.getAttribute('title');
|
|
59
|
+
if (title && title.trim()) {
|
|
60
|
+
return title.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 5. Placeholder (for inputs)
|
|
64
|
+
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
|
65
|
+
const placeholder = element.getAttribute('placeholder');
|
|
66
|
+
if (placeholder && placeholder.trim()) {
|
|
67
|
+
return placeholder.trim();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 6. alt attribute (for images)
|
|
72
|
+
if (element.tagName === 'IMG') {
|
|
73
|
+
const alt = element.getAttribute('alt');
|
|
74
|
+
if (alt) return alt;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 7. Text content (for buttons, links)
|
|
78
|
+
const textContent = element.textContent?.trim();
|
|
79
|
+
if (textContent) {
|
|
80
|
+
return textContent;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 8. value attribute (for inputs with type=button/submit)
|
|
84
|
+
const type = element.getAttribute('type');
|
|
85
|
+
if (element.tagName === 'INPUT' && (type === 'button' || type === 'submit')) {
|
|
86
|
+
return element.value || '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if an element is hidden from accessibility tree
|
|
94
|
+
* Considers aria-hidden, display:none, visibility:hidden, and inert
|
|
95
|
+
* @param {HTMLElement} element - Element to check
|
|
96
|
+
* @returns {boolean} True if element is hidden from a11y
|
|
97
|
+
*/
|
|
98
|
+
export function isAccessiblyHidden(element) {
|
|
99
|
+
if (!element) return true;
|
|
100
|
+
|
|
101
|
+
// Check aria-hidden
|
|
102
|
+
if (element.getAttribute('aria-hidden') === 'true') return true;
|
|
103
|
+
|
|
104
|
+
// Check CSS
|
|
105
|
+
const style = getComputedStyle(element);
|
|
106
|
+
if (style.display === 'none') return true;
|
|
107
|
+
if (style.visibility === 'hidden') return true;
|
|
108
|
+
|
|
109
|
+
// Check inert
|
|
110
|
+
if (element.hasAttribute('inert')) return true;
|
|
111
|
+
|
|
112
|
+
// Check ancestors
|
|
113
|
+
let parent = element.parentElement;
|
|
114
|
+
while (parent) {
|
|
115
|
+
if (parent.getAttribute('aria-hidden') === 'true') return true;
|
|
116
|
+
if (parent.hasAttribute('inert')) return true;
|
|
117
|
+
parent = parent.parentElement;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Make an element and its descendants inert (non-interactive)
|
|
125
|
+
* Sets both inert attribute and aria-hidden="true"
|
|
126
|
+
* @param {HTMLElement} element - Element to make inert
|
|
127
|
+
* @returns {Function} Restore function to undo inert state
|
|
128
|
+
*/
|
|
129
|
+
export function makeInert(element) {
|
|
130
|
+
const wasInert = element.hasAttribute('inert');
|
|
131
|
+
element.setAttribute('inert', '');
|
|
132
|
+
element.setAttribute('aria-hidden', 'true');
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
if (!wasInert) {
|
|
136
|
+
element.removeAttribute('inert');
|
|
137
|
+
}
|
|
138
|
+
element.removeAttribute('aria-hidden');
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a visually hidden element (screen reader only)
|
|
144
|
+
* Uses the "sr-only" pattern: visible to assistive tech, hidden visually
|
|
145
|
+
* @param {string} text - Text content for screen readers
|
|
146
|
+
* @returns {HTMLElement} The sr-only element
|
|
147
|
+
*/
|
|
148
|
+
export function srOnly(text) {
|
|
149
|
+
const span = document.createElement('span');
|
|
150
|
+
span.textContent = text;
|
|
151
|
+
span.className = 'sr-only';
|
|
152
|
+
span.style.cssText = `
|
|
153
|
+
position: absolute;
|
|
154
|
+
width: 1px;
|
|
155
|
+
height: 1px;
|
|
156
|
+
padding: 0;
|
|
157
|
+
margin: -1px;
|
|
158
|
+
overflow: hidden;
|
|
159
|
+
clip: rect(0, 0, 0, 0);
|
|
160
|
+
white-space: nowrap;
|
|
161
|
+
border: 0;
|
|
162
|
+
`;
|
|
163
|
+
return span;
|
|
164
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse A11y - Accessibility Validation
|
|
3
|
+
*
|
|
4
|
+
* A11y validation and auditing tools
|
|
5
|
+
*
|
|
6
|
+
* @module pulse-js-framework/runtime/a11y/validation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pulse } from '../pulse.js';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// VALIDATION & AUDITING
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A11y issues found during validation
|
|
17
|
+
* @typedef {object} A11yIssue
|
|
18
|
+
* @property {'error'|'warning'} severity - Issue severity
|
|
19
|
+
* @property {string} rule - Rule identifier
|
|
20
|
+
* @property {string} message - Human-readable message
|
|
21
|
+
* @property {HTMLElement} element - The element with the issue
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate accessibility of a container
|
|
26
|
+
* @param {HTMLElement} container - Container to validate (default: document.body)
|
|
27
|
+
* @returns {A11yIssue[]} Array of issues found
|
|
28
|
+
*/
|
|
29
|
+
export function validateA11y(container = document.body) {
|
|
30
|
+
const issues = [];
|
|
31
|
+
|
|
32
|
+
const addIssue = (severity, rule, message, element) => {
|
|
33
|
+
issues.push({ severity, rule, message, element });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Check images for alt text
|
|
37
|
+
container.querySelectorAll('img').forEach(img => {
|
|
38
|
+
if (!img.hasAttribute('alt')) {
|
|
39
|
+
addIssue('error', 'img-alt', 'Image missing alt attribute', img);
|
|
40
|
+
} else if (img.alt === '') {
|
|
41
|
+
// Empty alt is OK for decorative images, but warn
|
|
42
|
+
if (!img.getAttribute('role')?.includes('presentation')) {
|
|
43
|
+
addIssue('warning', 'img-alt-empty', 'Image has empty alt - ensure it is decorative', img);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Check buttons for accessible names
|
|
49
|
+
container.querySelectorAll('button').forEach(button => {
|
|
50
|
+
const hasText = button.textContent.trim().length > 0;
|
|
51
|
+
const hasAriaLabel = button.hasAttribute('aria-label');
|
|
52
|
+
const hasAriaLabelledBy = button.hasAttribute('aria-labelledby');
|
|
53
|
+
const hasTitle = button.hasAttribute('title');
|
|
54
|
+
|
|
55
|
+
if (!hasText && !hasAriaLabel && !hasAriaLabelledBy && !hasTitle) {
|
|
56
|
+
addIssue('error', 'button-name', 'Button has no accessible name', button);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Check links for accessible names
|
|
61
|
+
container.querySelectorAll('a[href]').forEach(link => {
|
|
62
|
+
const hasText = link.textContent.trim().length > 0;
|
|
63
|
+
const hasAriaLabel = link.hasAttribute('aria-label');
|
|
64
|
+
const hasImg = link.querySelector('img[alt]');
|
|
65
|
+
|
|
66
|
+
if (!hasText && !hasAriaLabel && !hasImg) {
|
|
67
|
+
addIssue('error', 'link-name', 'Link has no accessible name', link);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Check form inputs for labels
|
|
72
|
+
container.querySelectorAll('input, select, textarea').forEach(input => {
|
|
73
|
+
if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') return;
|
|
74
|
+
|
|
75
|
+
const id = input.id;
|
|
76
|
+
const hasLabel = id && container.querySelector(`label[for="${id}"]`);
|
|
77
|
+
const hasAriaLabel = input.hasAttribute('aria-label');
|
|
78
|
+
const hasAriaLabelledBy = input.hasAttribute('aria-labelledby');
|
|
79
|
+
const isWrappedByLabel = input.closest('label');
|
|
80
|
+
const hasPlaceholder = input.hasAttribute('placeholder');
|
|
81
|
+
|
|
82
|
+
if (!hasLabel && !hasAriaLabel && !hasAriaLabelledBy && !isWrappedByLabel) {
|
|
83
|
+
const msg = hasPlaceholder
|
|
84
|
+
? 'Form input uses placeholder but missing label (placeholder is not a label substitute)'
|
|
85
|
+
: 'Form input missing associated label';
|
|
86
|
+
addIssue('error', 'input-label', msg, input);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Check for positive tabindex (anti-pattern)
|
|
91
|
+
container.querySelectorAll('[tabindex]').forEach(el => {
|
|
92
|
+
const tabindex = parseInt(el.getAttribute('tabindex'), 10);
|
|
93
|
+
if (tabindex > 0) {
|
|
94
|
+
addIssue('warning', 'tabindex-positive', 'Avoid positive tabindex values - use DOM order instead', el);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Check for click handlers on non-interactive elements
|
|
99
|
+
container.querySelectorAll('div[onclick], span[onclick]').forEach(el => {
|
|
100
|
+
if (!el.hasAttribute('role') && !el.hasAttribute('tabindex')) {
|
|
101
|
+
addIssue('warning', 'click-non-interactive', 'Click handler on non-interactive element - consider using button', el);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Check headings hierarchy
|
|
106
|
+
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
107
|
+
let lastLevel = 0;
|
|
108
|
+
headings.forEach(heading => {
|
|
109
|
+
const level = parseInt(heading.tagName[1], 10);
|
|
110
|
+
if (level > lastLevel + 1 && lastLevel !== 0) {
|
|
111
|
+
addIssue('warning', 'heading-order', `Heading level skipped (h${lastLevel} to h${level})`, heading);
|
|
112
|
+
}
|
|
113
|
+
lastLevel = level;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Check for autoplay media
|
|
117
|
+
container.querySelectorAll('video[autoplay], audio[autoplay]').forEach(media => {
|
|
118
|
+
if (!media.hasAttribute('muted')) {
|
|
119
|
+
addIssue('warning', 'media-autoplay', 'Autoplaying media should be muted', media);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Check for duplicate IDs
|
|
124
|
+
const idMap = new Map();
|
|
125
|
+
container.querySelectorAll('[id]').forEach(el => {
|
|
126
|
+
const id = el.id;
|
|
127
|
+
if (id) {
|
|
128
|
+
if (idMap.has(id)) {
|
|
129
|
+
addIssue('error', 'duplicate-id', `Duplicate ID "${id}" found`, el);
|
|
130
|
+
} else {
|
|
131
|
+
idMap.set(id, el);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Check for landmark regions (main, nav, etc.)
|
|
137
|
+
if (typeof container.querySelector === 'function' && container === document.body) {
|
|
138
|
+
const hasMain = container.querySelector('main, [role="main"]');
|
|
139
|
+
if (!hasMain) {
|
|
140
|
+
addIssue('warning', 'missing-main', 'Page should have a <main> landmark', document.body);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check for nested interactive elements
|
|
145
|
+
container.querySelectorAll('a, button').forEach(el => {
|
|
146
|
+
if (typeof el.querySelector === 'function') {
|
|
147
|
+
const nestedInteractive = el.querySelector('a, button, input, select, textarea');
|
|
148
|
+
if (nestedInteractive) {
|
|
149
|
+
addIssue('error', 'nested-interactive',
|
|
150
|
+
'Interactive elements should not be nested inside other interactive elements', el);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Check for missing html lang attribute
|
|
156
|
+
if (container === document.body && typeof document !== 'undefined' && document.documentElement) {
|
|
157
|
+
const lang = document.documentElement.getAttribute?.('lang');
|
|
158
|
+
if (!lang) {
|
|
159
|
+
addIssue('warning', 'missing-lang',
|
|
160
|
+
'Document should have a lang attribute on <html>', document.documentElement);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check for touch target sizes (WCAG 2.2 - 24x24px minimum)
|
|
165
|
+
if (typeof getComputedStyle === 'function') {
|
|
166
|
+
container.querySelectorAll('a, button, input, select, [role="button"], [role="link"]').forEach(el => {
|
|
167
|
+
if (typeof el.getBoundingClientRect === 'function') {
|
|
168
|
+
const rect = el.getBoundingClientRect();
|
|
169
|
+
if (rect.width > 0 && rect.height > 0 && (rect.width < 24 || rect.height < 24)) {
|
|
170
|
+
// Only flag if element is visible
|
|
171
|
+
const style = getComputedStyle(el);
|
|
172
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
173
|
+
addIssue('warning', 'touch-target-size',
|
|
174
|
+
`Touch target (${Math.round(rect.width)}x${Math.round(rect.height)}px) smaller than 24x24px minimum`,
|
|
175
|
+
el);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return issues;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Log validation results to console
|
|
187
|
+
* @param {A11yIssue[]} issues - Issues from validateA11y
|
|
188
|
+
*/
|
|
189
|
+
export function logA11yIssues(issues) {
|
|
190
|
+
if (issues.length === 0) {
|
|
191
|
+
console.log('%c✓ No accessibility issues found', 'color: green; font-weight: bold');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const errors = issues.filter(i => i.severity === 'error');
|
|
196
|
+
const warnings = issues.filter(i => i.severity === 'warning');
|
|
197
|
+
|
|
198
|
+
console.group(`%cAccessibility Issues (${errors.length} errors, ${warnings.length} warnings)`,
|
|
199
|
+
'color: red; font-weight: bold');
|
|
200
|
+
|
|
201
|
+
issues.forEach(issue => {
|
|
202
|
+
const icon = issue.severity === 'error' ? '❌' : '⚠️';
|
|
203
|
+
const color = issue.severity === 'error' ? 'color: red' : 'color: orange';
|
|
204
|
+
console.log(`%c${icon} [${issue.rule}] ${issue.message}`, color, issue.element);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
console.groupEnd();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Highlight elements with accessibility issues in the DOM
|
|
212
|
+
* @param {A11yIssue[]} issues - Issues from validateA11y
|
|
213
|
+
* @returns {Function} Cleanup function to remove highlights
|
|
214
|
+
*/
|
|
215
|
+
export function highlightA11yIssues(issues) {
|
|
216
|
+
const highlights = [];
|
|
217
|
+
|
|
218
|
+
issues.forEach(issue => {
|
|
219
|
+
const el = issue.element;
|
|
220
|
+
const rect = el.getBoundingClientRect();
|
|
221
|
+
|
|
222
|
+
const highlight = document.createElement('div');
|
|
223
|
+
highlight.className = 'pulse-a11y-highlight';
|
|
224
|
+
highlight.style.cssText = `
|
|
225
|
+
position: fixed;
|
|
226
|
+
top: ${rect.top}px;
|
|
227
|
+
left: ${rect.left}px;
|
|
228
|
+
width: ${rect.width}px;
|
|
229
|
+
height: ${rect.height}px;
|
|
230
|
+
border: 2px solid ${issue.severity === 'error' ? 'red' : 'orange'};
|
|
231
|
+
background: ${issue.severity === 'error' ? 'rgba(255,0,0,0.1)' : 'rgba(255,165,0,0.1)'};
|
|
232
|
+
pointer-events: none;
|
|
233
|
+
z-index: 99999;
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
const label = document.createElement('div');
|
|
237
|
+
label.style.cssText = `
|
|
238
|
+
position: absolute;
|
|
239
|
+
top: -20px;
|
|
240
|
+
left: 0;
|
|
241
|
+
background: ${issue.severity === 'error' ? 'red' : 'orange'};
|
|
242
|
+
color: white;
|
|
243
|
+
font-size: 10px;
|
|
244
|
+
padding: 2px 4px;
|
|
245
|
+
border-radius: 2px;
|
|
246
|
+
white-space: nowrap;
|
|
247
|
+
`;
|
|
248
|
+
label.textContent = issue.rule;
|
|
249
|
+
highlight.appendChild(label);
|
|
250
|
+
|
|
251
|
+
document.body.appendChild(highlight);
|
|
252
|
+
highlights.push(highlight);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return () => {
|
|
256
|
+
highlights.forEach(h => h.remove());
|
|
257
|
+
};
|
|
258
|
+
}
|