@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.
@@ -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,9 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller {
4
+ static targets = ['input'];
5
+
6
+ toggle() {
7
+ this.inputTarget.type = this.inputTarget.type === 'password' ? 'text' : 'password';
8
+ }
9
+ }
@@ -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';
@@ -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
+ }