@stimulus-plumbers/controllers 0.2.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/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/stimulus-plumbers-controllers.es.js +1459 -0
- package/dist/stimulus-plumbers-controllers.umd.js +1 -0
- package/package.json +57 -0
- package/src/aria.js +173 -0
- package/src/controllers/auto_resize_controller.js +17 -0
- package/src/controllers/calendar_month_controller.js +100 -0
- package/src/controllers/calendar_month_observer_controller.js +24 -0
- package/src/controllers/datepicker_controller.js +101 -0
- package/src/controllers/dismisser_controller.js +10 -0
- package/src/controllers/flipper_controller.js +33 -0
- package/src/controllers/form-field_controller.js +77 -0
- package/src/controllers/modal_controller.js +104 -0
- package/src/controllers/panner_controller.js +10 -0
- package/src/controllers/password_reveal_controller.js +9 -0
- package/src/controllers/popover_controller.js +76 -0
- package/src/controllers/visibility_controller.js +32 -0
- package/src/focus.js +128 -0
- package/src/index.js +23 -0
- package/src/keyboard.js +92 -0
- package/src/plumbers/calendar.js +399 -0
- package/src/plumbers/content_loader.js +134 -0
- package/src/plumbers/dismisser.js +82 -0
- package/src/plumbers/flipper.js +272 -0
- package/src/plumbers/index.js +10 -0
- package/src/plumbers/plumber/index.js +110 -0
- package/src/plumbers/plumber/support.js +101 -0
- package/src/plumbers/shifter.js +164 -0
- package/src/plumbers/visibility.js +136 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
import { FocusTrap } from '../focus';
|
|
3
|
+
import { announce } from '../aria';
|
|
4
|
+
import { attachDismisser } from '../plumbers';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Modal Dialog Controller
|
|
8
|
+
* Implements WAI-ARIA Dialog (Modal) pattern
|
|
9
|
+
* Supports both native <dialog> elements and custom implementations
|
|
10
|
+
*/
|
|
11
|
+
export default class ModalController extends Controller {
|
|
12
|
+
static targets = ['modal', 'overlay'];
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
if (!this.hasModalTarget) {
|
|
16
|
+
console.error('ModalController requires a modal target. Add data-modal-target="modal" to your element.');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
this.isNativeDialog = this.modalTarget instanceof HTMLDialogElement;
|
|
21
|
+
|
|
22
|
+
if (this.isNativeDialog) {
|
|
23
|
+
this.modalTarget.addEventListener('cancel', this.close);
|
|
24
|
+
this.modalTarget.addEventListener('click', this.handleBackdropClick);
|
|
25
|
+
} else {
|
|
26
|
+
this.focusTrap = new FocusTrap(this.modalTarget, {
|
|
27
|
+
escapeDeactivates: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
attachDismisser(this, { element: this.modalTarget });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
dismissed = () => {
|
|
35
|
+
this.close();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
disconnect() {
|
|
39
|
+
if (this.isNativeDialog) {
|
|
40
|
+
this.modalTarget.removeEventListener('cancel', this.close);
|
|
41
|
+
this.modalTarget.removeEventListener('click', this.handleBackdropClick);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
open(event) {
|
|
46
|
+
if (event) event.preventDefault();
|
|
47
|
+
if (!this.hasModalTarget) return;
|
|
48
|
+
|
|
49
|
+
if (this.isNativeDialog) {
|
|
50
|
+
this.previouslyFocused = document.activeElement;
|
|
51
|
+
this.modalTarget.showModal();
|
|
52
|
+
} else {
|
|
53
|
+
const targetToShow = this.hasOverlayTarget ? this.overlayTarget : this.modalTarget;
|
|
54
|
+
targetToShow.hidden = false;
|
|
55
|
+
|
|
56
|
+
document.body.style.overflow = 'hidden';
|
|
57
|
+
|
|
58
|
+
if (this.focusTrap) {
|
|
59
|
+
this.focusTrap.activate();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
announce('Modal opened');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
close(event) {
|
|
67
|
+
if (event) event.preventDefault();
|
|
68
|
+
if (!this.hasModalTarget) return;
|
|
69
|
+
|
|
70
|
+
if (this.isNativeDialog) {
|
|
71
|
+
this.modalTarget.close();
|
|
72
|
+
|
|
73
|
+
if (this.previouslyFocused && this.previouslyFocused.isConnected) {
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
this.previouslyFocused.focus();
|
|
76
|
+
}, 0);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
const targetToHide = this.hasOverlayTarget ? this.overlayTarget : this.modalTarget;
|
|
80
|
+
targetToHide.hidden = true;
|
|
81
|
+
|
|
82
|
+
document.body.style.overflow = '';
|
|
83
|
+
|
|
84
|
+
if (this.focusTrap) {
|
|
85
|
+
this.focusTrap.deactivate();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
announce('Modal closed');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
handleBackdropClick = (event) => {
|
|
93
|
+
const rect = this.modalTarget.getBoundingClientRect();
|
|
94
|
+
const isOutsideDialog =
|
|
95
|
+
event.clientY < rect.top ||
|
|
96
|
+
event.clientY > rect.bottom ||
|
|
97
|
+
event.clientX < rect.left ||
|
|
98
|
+
event.clientX > rect.right;
|
|
99
|
+
|
|
100
|
+
if (isOutsideDialog) {
|
|
101
|
+
this.close();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
import { attachShifter } from '../plumbers';
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ['content'];
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
attachShifter(this, { element: this.hasContentTarget ? this.contentTarget : null });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
import { attachContentLoader, attachVisibility } from '../plumbers';
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ['content', 'template', 'loader', 'activator'];
|
|
6
|
+
static classes = ['hidden'];
|
|
7
|
+
static values = {
|
|
8
|
+
url: String,
|
|
9
|
+
loadedAt: String,
|
|
10
|
+
reload: { type: String, default: 'never' },
|
|
11
|
+
staleAfter: { type: Number, default: 3600 },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
attachContentLoader(this, {
|
|
16
|
+
element: this.hasContentTarget ? this.contentTarget : null,
|
|
17
|
+
url: this.hasUrlValue ? this.urlValue : null,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (this.hasContentTarget) {
|
|
21
|
+
attachVisibility(this, {
|
|
22
|
+
element: this.contentTarget,
|
|
23
|
+
activator: this.hasActivatorTarget ? this.activatorTarget : null,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (this.hasLoaderTarget)
|
|
27
|
+
attachVisibility(this, { element: this.loaderTarget, visibility: 'contentLoaderVisibility' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async show() {
|
|
31
|
+
await this.visibility.show();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async hide() {
|
|
35
|
+
await this.visibility.hide();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async shown() {
|
|
39
|
+
await this.load();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
contentLoad() {
|
|
43
|
+
if (this.hasContentTarget && this.contentTarget.tagName.toLowerCase() === 'turbo-frame') {
|
|
44
|
+
if (this.hasUrlValue) this.contentTarget.setAttribute('src', this.urlValue);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async contentLoading() {
|
|
51
|
+
if (this.hasLoaderTarget) await this.contentLoaderVisibility.show();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async contentLoaded({ content }) {
|
|
55
|
+
if (this.hasContentTarget) {
|
|
56
|
+
this.contentTarget.replaceChildren(this.getContentNode(content));
|
|
57
|
+
}
|
|
58
|
+
if (this.hasLoaderTarget) await this.contentLoaderVisibility.hide();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getContentNode(content) {
|
|
62
|
+
if (typeof content === 'string') {
|
|
63
|
+
const template = document.createElement('template');
|
|
64
|
+
template.innerHTML = content;
|
|
65
|
+
return document.importNode(template.content, true);
|
|
66
|
+
}
|
|
67
|
+
return document.importNode(content, true);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
contentLoader() {
|
|
71
|
+
if (!this.hasTemplateTarget) return;
|
|
72
|
+
if (this.templateTarget instanceof HTMLTemplateElement) return this.templateTarget.content;
|
|
73
|
+
|
|
74
|
+
return this.templateTarget.innerHTML;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
import { attachDismisser, attachVisibility, attachShifter } from '../plumbers';
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ['content'];
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
attachDismisser(this);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
contentTargetConnected(target) {
|
|
12
|
+
attachShifter(this, { element: target });
|
|
13
|
+
attachVisibility(this, { element: target });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async dismissed() {
|
|
17
|
+
if (!this.hasContentTarget) return;
|
|
18
|
+
|
|
19
|
+
await this.visibility.hide();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async toggle() {
|
|
23
|
+
if (!this.hasContentTarget) return;
|
|
24
|
+
|
|
25
|
+
if (this.visibility.visible) {
|
|
26
|
+
await this.visibility.hide();
|
|
27
|
+
} else {
|
|
28
|
+
await this.visibility.show();
|
|
29
|
+
this.shift(this.contentTarget);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/focus.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query selector for all focusable elements
|
|
3
|
+
*/
|
|
4
|
+
export const FOCUSABLE_SELECTOR = [
|
|
5
|
+
'a[href]',
|
|
6
|
+
'area[href]',
|
|
7
|
+
'button:not([disabled])',
|
|
8
|
+
'input:not([disabled])',
|
|
9
|
+
'select:not([disabled])',
|
|
10
|
+
'textarea:not([disabled])',
|
|
11
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
12
|
+
'audio[controls]',
|
|
13
|
+
'video[controls]',
|
|
14
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
15
|
+
].join(',');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get all focusable elements within a container
|
|
19
|
+
*/
|
|
20
|
+
export function getFocusableElements(container) {
|
|
21
|
+
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => isVisible(el));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if an element is visible
|
|
26
|
+
*/
|
|
27
|
+
export function isVisible(element) {
|
|
28
|
+
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Focus the first focusable element in a container
|
|
33
|
+
*/
|
|
34
|
+
export function focusFirst(container) {
|
|
35
|
+
const elements = getFocusableElements(container);
|
|
36
|
+
if (elements.length > 0) {
|
|
37
|
+
elements[0].focus();
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a focus trap within a container
|
|
45
|
+
*/
|
|
46
|
+
export class FocusTrap {
|
|
47
|
+
constructor(container, options = {}) {
|
|
48
|
+
this.container = container;
|
|
49
|
+
this.previouslyFocused = null;
|
|
50
|
+
this.options = options;
|
|
51
|
+
this.isActive = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
activate() {
|
|
55
|
+
if (this.isActive) return;
|
|
56
|
+
|
|
57
|
+
this.previouslyFocused = document.activeElement;
|
|
58
|
+
this.isActive = true;
|
|
59
|
+
|
|
60
|
+
// Focus initial element or first focusable
|
|
61
|
+
if (this.options.initialFocus) {
|
|
62
|
+
this.options.initialFocus.focus();
|
|
63
|
+
} else {
|
|
64
|
+
focusFirst(this.container);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Add event listeners
|
|
68
|
+
this.container.addEventListener('keydown', this.handleKeyDown);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
deactivate() {
|
|
72
|
+
if (!this.isActive) return;
|
|
73
|
+
|
|
74
|
+
this.isActive = false;
|
|
75
|
+
this.container.removeEventListener('keydown', this.handleKeyDown);
|
|
76
|
+
|
|
77
|
+
// Return focus to previously focused element
|
|
78
|
+
const returnElement = this.options.returnFocus || this.previouslyFocused;
|
|
79
|
+
if (returnElement && isVisible(returnElement)) {
|
|
80
|
+
returnElement.focus();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
handleKeyDown = (event) => {
|
|
85
|
+
if (event.key === 'Escape' && this.options.escapeDeactivates) {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
this.deactivate();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (event.key !== 'Tab') return;
|
|
92
|
+
|
|
93
|
+
const focusableElements = getFocusableElements(this.container);
|
|
94
|
+
if (focusableElements.length === 0) return;
|
|
95
|
+
|
|
96
|
+
const firstElement = focusableElements[0];
|
|
97
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
98
|
+
|
|
99
|
+
// Trap focus within container
|
|
100
|
+
if (event.shiftKey && document.activeElement === firstElement) {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
lastElement.focus();
|
|
103
|
+
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
firstElement.focus();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Save and restore focus utility
|
|
112
|
+
*/
|
|
113
|
+
export class FocusRestoration {
|
|
114
|
+
constructor() {
|
|
115
|
+
this.savedElement = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
save() {
|
|
119
|
+
this.savedElement = document.activeElement;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
restore() {
|
|
123
|
+
if (this.savedElement && isVisible(this.savedElement)) {
|
|
124
|
+
this.savedElement.focus();
|
|
125
|
+
this.savedElement = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @stimulus-plumbers/controllers
|
|
3
|
+
*
|
|
4
|
+
* Stimulus Plumbers controllers for UI components
|
|
5
|
+
* Following WCAG 2.1+ and WAI-ARIA best practices
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Export utilities (framework-agnostic)
|
|
9
|
+
export * from './focus.js';
|
|
10
|
+
export * from './keyboard.js';
|
|
11
|
+
export * from './aria.js';
|
|
12
|
+
|
|
13
|
+
// Export Stimulus controllers
|
|
14
|
+
export { default as ModalController } from './controllers/modal_controller.js';
|
|
15
|
+
export { default as DismisserController } from './controllers/dismisser_controller.js';
|
|
16
|
+
export { default as FlipperController } from './controllers/flipper_controller.js';
|
|
17
|
+
export { default as PopoverController } from './controllers/popover_controller.js';
|
|
18
|
+
export { default as CalendarMonthController } from './controllers/calendar_month_controller.js';
|
|
19
|
+
export { default as CalendarMonthObserverController } from './controllers/calendar_month_observer_controller.js';
|
|
20
|
+
export { default as DatepickerController } from './controllers/datepicker_controller.js';
|
|
21
|
+
export { default as PannerController } from './controllers/panner_controller.js';
|
|
22
|
+
export { default as PasswordRevealController } from './controllers/password_reveal_controller.js';
|
|
23
|
+
export { default as AutoResizeController } from './controllers/auto_resize_controller.js';
|
package/src/keyboard.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard interaction utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a key matches the expected key
|
|
7
|
+
*/
|
|
8
|
+
export function isKey(event, key) {
|
|
9
|
+
return event.key === key;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if Enter or Space was pressed (activation keys)
|
|
14
|
+
*/
|
|
15
|
+
export function isActivationKey(event) {
|
|
16
|
+
return event.key === 'Enter' || event.key === ' ';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if an arrow key was pressed
|
|
21
|
+
*/
|
|
22
|
+
export function isArrowKey(event) {
|
|
23
|
+
return ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Prevent default and stop propagation
|
|
28
|
+
*/
|
|
29
|
+
export function preventDefault(event) {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
event.stopPropagation();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle roving tabindex for a list of items
|
|
36
|
+
*/
|
|
37
|
+
export class RovingTabIndex {
|
|
38
|
+
constructor(items, initialIndex = 0) {
|
|
39
|
+
this.items = items;
|
|
40
|
+
this.currentIndex = initialIndex;
|
|
41
|
+
this.updateTabIndex();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
handleKeyDown(event) {
|
|
45
|
+
let newIndex;
|
|
46
|
+
|
|
47
|
+
switch (event.key) {
|
|
48
|
+
case 'ArrowDown':
|
|
49
|
+
case 'ArrowRight':
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
newIndex = (this.currentIndex + 1) % this.items.length;
|
|
52
|
+
break;
|
|
53
|
+
case 'ArrowUp':
|
|
54
|
+
case 'ArrowLeft':
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
newIndex = this.currentIndex === 0 ? this.items.length - 1 : this.currentIndex - 1;
|
|
57
|
+
break;
|
|
58
|
+
case 'Home':
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
newIndex = 0;
|
|
61
|
+
break;
|
|
62
|
+
case 'End':
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
newIndex = this.items.length - 1;
|
|
65
|
+
break;
|
|
66
|
+
default:
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.setCurrentIndex(newIndex);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setCurrentIndex(index) {
|
|
74
|
+
if (index >= 0 && index < this.items.length) {
|
|
75
|
+
this.currentIndex = index;
|
|
76
|
+
this.updateTabIndex();
|
|
77
|
+
this.items[index].focus();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
updateTabIndex() {
|
|
82
|
+
this.items.forEach((item, index) => {
|
|
83
|
+
item.tabIndex = index === this.currentIndex ? 0 : -1;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
updateItems(items) {
|
|
88
|
+
this.items = items;
|
|
89
|
+
this.currentIndex = Math.min(this.currentIndex, items.length - 1);
|
|
90
|
+
this.updateTabIndex();
|
|
91
|
+
}
|
|
92
|
+
}
|