stimeo-ui 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/carousel_controller.d.ts +100 -0
- package/dist/controllers/carousel_controller.js +272 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +76 -0
- package/dist/controllers/color_picker_controller.js +213 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +71 -0
- package/dist/controllers/data_grid_controller.js +168 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +87 -0
- package/dist/controllers/date_range_picker_controller.js +417 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +168 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +41 -18
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +33 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menubar_controller.d.ts +62 -0
- package/dist/controllers/menubar_controller.js +433 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/multi_select_controller.d.ts +115 -0
- package/dist/controllers/multi_select_controller.js +472 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +99 -0
- package/dist/controllers/navigation_menu_controller.js +384 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
- package/dist/controllers/overflow_indicator_controller.js +178 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/range_slider_controller.d.ts +87 -0
- package/dist/controllers/range_slider_controller.js +166 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +194 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.js +15 -2
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.js +8 -4
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js +33 -11
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +54 -0
- package/dist/controllers/separator_controller.js +87 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.js +8 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +88 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +3 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +275 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.js +20 -10
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +212 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +1 -9
- package/dist/controllers/toast_controller.js +36 -9
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +54 -0
- package/dist/controllers/tree_view_controller.js +275 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1818
- package/dist/index.js +811 -295
- package/dist/index.js.map +1 -1
- package/dist/inspector/examples.json +2 -2
- package/dist/inspector/manifest.json +15 -3
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible **Carousel** (slideshow) behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--carousel`):
|
|
7
|
+
* <section data-controller="stimeo--carousel" aria-roledescription="carousel"
|
|
8
|
+
* aria-label="Featured"
|
|
9
|
+
* data-stimeo--carousel-autoplay-value="false"
|
|
10
|
+
* data-stimeo--carousel-interval-value="5000"
|
|
11
|
+
* data-stimeo--carousel-loop-value="true"
|
|
12
|
+
* data-action="mouseenter->stimeo--carousel#pause
|
|
13
|
+
* mouseleave->stimeo--carousel#resume
|
|
14
|
+
* focusin->stimeo--carousel#pause
|
|
15
|
+
* focusout->stimeo--carousel#resume">
|
|
16
|
+
* <button data-stimeo--carousel-target="playToggle"
|
|
17
|
+
* data-action="stimeo--carousel#togglePlay">…</button>
|
|
18
|
+
* <div data-stimeo--carousel-target="viewport">
|
|
19
|
+
* <div role="tabpanel" data-stimeo--carousel-target="slide">…</div>
|
|
20
|
+
* <div role="tabpanel" data-stimeo--carousel-target="slide" hidden>…</div>
|
|
21
|
+
* </div>
|
|
22
|
+
* <button data-stimeo--carousel-target="prev" data-action="stimeo--carousel#prev">‹</button>
|
|
23
|
+
* <button data-stimeo--carousel-target="next" data-action="stimeo--carousel#next">›</button>
|
|
24
|
+
* <div role="tablist">
|
|
25
|
+
* <button role="tab" data-stimeo--carousel-target="picker"
|
|
26
|
+
* data-action="stimeo--carousel#goto
|
|
27
|
+
* keydown->stimeo--carousel#onPickerKeydown"></button>
|
|
28
|
+
* </div>
|
|
29
|
+
* </section>
|
|
30
|
+
*
|
|
31
|
+
* Implements the WAI-ARIA APG **Carousel** (tabbed) pattern. The current slide is
|
|
32
|
+
* exposed through `data-state` (`active`/`inactive`) and the `hidden` attribute on
|
|
33
|
+
* inactive slides (removing them from focus order); the matching picker carries
|
|
34
|
+
* `aria-selected` and the single roving `tabindex`. The play/pause toggle's
|
|
35
|
+
* `aria-pressed` mirrors the autoplay state.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Behavior only — transitions, layout, and visuals are the consumer's CSS.
|
|
39
|
+
* Autoplay honors WCAG 2.2.2: it suspends while the pointer is over the carousel
|
|
40
|
+
* and **hard-stops** when keyboard focus enters (it does not silently resume on
|
|
41
|
+
* focus out — the user must press play), so motion never surprises a keyboard
|
|
42
|
+
* user. The interval is cleared on `disconnect()` (Turbo navigation included).
|
|
43
|
+
* Picker arrow keys move focus only (manual activation); slide changes never steal
|
|
44
|
+
* focus from the control the user operated.
|
|
45
|
+
*/
|
|
46
|
+
declare class CarouselController extends Controller<HTMLElement> {
|
|
47
|
+
#private;
|
|
48
|
+
static targets: string[];
|
|
49
|
+
static values: {
|
|
50
|
+
autoplay: {
|
|
51
|
+
type: BooleanConstructor;
|
|
52
|
+
default: boolean;
|
|
53
|
+
};
|
|
54
|
+
interval: {
|
|
55
|
+
type: NumberConstructor;
|
|
56
|
+
default: number;
|
|
57
|
+
};
|
|
58
|
+
loop: {
|
|
59
|
+
type: BooleanConstructor;
|
|
60
|
+
default: boolean;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
static actions: readonly ["goto", "next", "onPickerKeydown", "pause", "prev", "resume", "togglePlay"];
|
|
64
|
+
static events: readonly ["change", "pause", "play"];
|
|
65
|
+
readonly slideTargets: HTMLElement[];
|
|
66
|
+
readonly pickerTargets: HTMLElement[];
|
|
67
|
+
readonly playToggleTarget: HTMLElement;
|
|
68
|
+
readonly hasPlayToggleTarget: boolean;
|
|
69
|
+
autoplayValue: boolean;
|
|
70
|
+
intervalValue: number;
|
|
71
|
+
loopValue: boolean;
|
|
72
|
+
/** Renders the initial slide and starts autoplay when requested. */
|
|
73
|
+
connect(): void;
|
|
74
|
+
/** Clears the autoplay interval so it never fires after teardown. */
|
|
75
|
+
disconnect(): void;
|
|
76
|
+
/** Advances to the next slide. Bound via `data-action`. */
|
|
77
|
+
next(): void;
|
|
78
|
+
/** Returns to the previous slide. Bound via `data-action`. */
|
|
79
|
+
prev(): void;
|
|
80
|
+
/** Jumps to the slide whose picker was activated (click / Enter / Space). */
|
|
81
|
+
goto(event: Event): void;
|
|
82
|
+
/** Toggles autoplay on the user's explicit request and syncs the timer. */
|
|
83
|
+
togglePlay(): void;
|
|
84
|
+
/**
|
|
85
|
+
* Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes
|
|
86
|
+
* on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so
|
|
87
|
+
* it cannot resume without an explicit play (WCAG 2.2.2).
|
|
88
|
+
*/
|
|
89
|
+
pause(event?: Event): void;
|
|
90
|
+
/**
|
|
91
|
+
* Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still
|
|
92
|
+
* on. A `focusout` does nothing here: the focus pause was a hard stop, so the
|
|
93
|
+
* user must press play to restart.
|
|
94
|
+
*/
|
|
95
|
+
resume(event?: Event): void;
|
|
96
|
+
/** Picker roving: arrows move focus only; Home/End activate first/last slide. */
|
|
97
|
+
onPickerKeydown(event: KeyboardEvent): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { CarouselController };
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/carousel_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/roving_tabindex.ts
|
|
6
|
+
var RovingTabindex = class {
|
|
7
|
+
/** Returns the current ordered item elements; called on every operation. */
|
|
8
|
+
#getItems;
|
|
9
|
+
/**
|
|
10
|
+
* @param getItems - Returns the current ordered item elements. Called on every
|
|
11
|
+
* operation so the live target list is always used.
|
|
12
|
+
*/
|
|
13
|
+
constructor(getItems) {
|
|
14
|
+
this.#getItems = getItems;
|
|
15
|
+
}
|
|
16
|
+
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
17
|
+
get activeIndex() {
|
|
18
|
+
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
22
|
+
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
23
|
+
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
24
|
+
* "nothing is currently tabbable".
|
|
25
|
+
*
|
|
26
|
+
* @param index - Position of the item to make tabbable.
|
|
27
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item.
|
|
28
|
+
*/
|
|
29
|
+
setActive(index, { focus = false } = {}) {
|
|
30
|
+
const items = this.#getItems();
|
|
31
|
+
items.forEach((item, i) => {
|
|
32
|
+
item.tabIndex = i === index ? 0 : -1;
|
|
33
|
+
});
|
|
34
|
+
if (focus) items[index]?.focus();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
function rovingMove(current, length, delta, wrap) {
|
|
38
|
+
if (length === 0) return -1;
|
|
39
|
+
const next = current + delta;
|
|
40
|
+
return (next + length) % length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/utils/safe_timeout.ts
|
|
44
|
+
var TimerRegistry = class {
|
|
45
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
46
|
+
ids = /* @__PURE__ */ new Set();
|
|
47
|
+
/**
|
|
48
|
+
* Cancels a single tracked timer.
|
|
49
|
+
*
|
|
50
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
51
|
+
* registry), so callers can clear defensively without guarding.
|
|
52
|
+
*/
|
|
53
|
+
clear(id) {
|
|
54
|
+
if (this.ids.delete(id)) {
|
|
55
|
+
this.cancel(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
60
|
+
* to guarantee no timer outlives the element.
|
|
61
|
+
*/
|
|
62
|
+
clearAll() {
|
|
63
|
+
for (const id of this.ids) {
|
|
64
|
+
this.cancel(id);
|
|
65
|
+
}
|
|
66
|
+
this.ids.clear();
|
|
67
|
+
}
|
|
68
|
+
/** Number of timers currently tracked (pending). */
|
|
69
|
+
get size() {
|
|
70
|
+
return this.ids.size;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var SafeInterval = class extends TimerRegistry {
|
|
74
|
+
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
75
|
+
set(callback, delay) {
|
|
76
|
+
const id = this.schedule(callback, delay);
|
|
77
|
+
this.ids.add(id);
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
schedule(callback, delay) {
|
|
81
|
+
return window.setInterval(callback, delay);
|
|
82
|
+
}
|
|
83
|
+
cancel(id) {
|
|
84
|
+
window.clearInterval(id);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/controllers/carousel_controller.ts
|
|
89
|
+
var CarouselController = class extends Controller {
|
|
90
|
+
static targets = ["slide", "viewport", "prev", "next", "picker", "playToggle"];
|
|
91
|
+
static values = {
|
|
92
|
+
autoplay: { type: Boolean, default: false },
|
|
93
|
+
interval: { type: Number, default: 5e3 },
|
|
94
|
+
loop: { type: Boolean, default: true }
|
|
95
|
+
};
|
|
96
|
+
static actions = [
|
|
97
|
+
"goto",
|
|
98
|
+
"next",
|
|
99
|
+
"onPickerKeydown",
|
|
100
|
+
"pause",
|
|
101
|
+
"prev",
|
|
102
|
+
"resume",
|
|
103
|
+
"togglePlay"
|
|
104
|
+
];
|
|
105
|
+
static events = ["change", "pause", "play"];
|
|
106
|
+
#roving = new RovingTabindex(() => this.pickerTargets);
|
|
107
|
+
#intervals = new SafeInterval();
|
|
108
|
+
/** Index of the visible slide. */
|
|
109
|
+
#index = 0;
|
|
110
|
+
/** User intent to autoplay (toggled by the play button / focus hard-stop). */
|
|
111
|
+
#playing = false;
|
|
112
|
+
/** Pointer is hovering the carousel: a temporary, auto-resuming suspension. */
|
|
113
|
+
#pointerPaused = false;
|
|
114
|
+
/** Id of the live autoplay interval, or null when stopped. */
|
|
115
|
+
#timerId = null;
|
|
116
|
+
/** Renders the initial slide and starts autoplay when requested. */
|
|
117
|
+
connect() {
|
|
118
|
+
const preselected = this.pickerTargets.findIndex(
|
|
119
|
+
(picker) => picker.getAttribute("aria-selected") === "true"
|
|
120
|
+
);
|
|
121
|
+
this.#index = preselected === -1 ? 0 : preselected;
|
|
122
|
+
this.#playing = this.#initialPlaying();
|
|
123
|
+
this.#render({ focus: false });
|
|
124
|
+
this.#syncTimer();
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
|
|
128
|
+
* source of truth **when present**, so a Turbo Drive cache restore / morph that
|
|
129
|
+
* re-runs `connect()` against existing DOM does not silently resume autoplay the
|
|
130
|
+
* user had stopped (e.g. by focusing into the carousel). Only when no toggle
|
|
131
|
+
* carries `aria-pressed` does it fall back to the declarative `autoplay` value.
|
|
132
|
+
*/
|
|
133
|
+
#initialPlaying() {
|
|
134
|
+
if (this.hasPlayToggleTarget && this.playToggleTarget.hasAttribute("aria-pressed")) {
|
|
135
|
+
return this.playToggleTarget.getAttribute("aria-pressed") === "true";
|
|
136
|
+
}
|
|
137
|
+
return this.autoplayValue;
|
|
138
|
+
}
|
|
139
|
+
/** Clears the autoplay interval so it never fires after teardown. */
|
|
140
|
+
disconnect() {
|
|
141
|
+
this.#intervals.clearAll();
|
|
142
|
+
this.#timerId = null;
|
|
143
|
+
}
|
|
144
|
+
/** Advances to the next slide. Bound via `data-action`. */
|
|
145
|
+
next() {
|
|
146
|
+
this.#select(this.#step(1), { focus: false });
|
|
147
|
+
}
|
|
148
|
+
/** Returns to the previous slide. Bound via `data-action`. */
|
|
149
|
+
prev() {
|
|
150
|
+
this.#select(this.#step(-1), { focus: false });
|
|
151
|
+
}
|
|
152
|
+
/** Jumps to the slide whose picker was activated (click / Enter / Space). */
|
|
153
|
+
goto(event) {
|
|
154
|
+
const target = event.currentTarget;
|
|
155
|
+
const index = this.pickerTargets.indexOf(target);
|
|
156
|
+
if (index !== -1) this.#select(index, { focus: false });
|
|
157
|
+
}
|
|
158
|
+
/** Toggles autoplay on the user's explicit request and syncs the timer. */
|
|
159
|
+
togglePlay() {
|
|
160
|
+
this.#playing = !this.#playing;
|
|
161
|
+
this.#syncTimer();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes
|
|
165
|
+
* on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so
|
|
166
|
+
* it cannot resume without an explicit play (WCAG 2.2.2).
|
|
167
|
+
*/
|
|
168
|
+
pause(event) {
|
|
169
|
+
if (event?.type.startsWith("focus")) {
|
|
170
|
+
this.#playing = false;
|
|
171
|
+
} else {
|
|
172
|
+
this.#pointerPaused = true;
|
|
173
|
+
}
|
|
174
|
+
this.#syncTimer();
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still
|
|
178
|
+
* on. A `focusout` does nothing here: the focus pause was a hard stop, so the
|
|
179
|
+
* user must press play to restart.
|
|
180
|
+
*/
|
|
181
|
+
resume(event) {
|
|
182
|
+
if (event?.type.startsWith("focus")) return;
|
|
183
|
+
this.#pointerPaused = false;
|
|
184
|
+
this.#syncTimer();
|
|
185
|
+
}
|
|
186
|
+
/** Picker roving: arrows move focus only; Home/End activate first/last slide. */
|
|
187
|
+
onPickerKeydown(event) {
|
|
188
|
+
const current = this.pickerTargets.indexOf(event.currentTarget);
|
|
189
|
+
if (current === -1) return;
|
|
190
|
+
const length = this.pickerTargets.length;
|
|
191
|
+
switch (event.key) {
|
|
192
|
+
case "ArrowRight":
|
|
193
|
+
case "ArrowDown":
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
this.#roving.setActive(rovingMove(current, length, 1), { focus: true });
|
|
196
|
+
return;
|
|
197
|
+
case "ArrowLeft":
|
|
198
|
+
case "ArrowUp":
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
this.#roving.setActive(rovingMove(current, length, -1), { focus: true });
|
|
201
|
+
return;
|
|
202
|
+
case "Home":
|
|
203
|
+
event.preventDefault();
|
|
204
|
+
this.#select(0, { focus: true });
|
|
205
|
+
return;
|
|
206
|
+
case "End":
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
this.#select(length - 1, { focus: true });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Resolves the index one step away from the current one, honoring `loop`. */
|
|
213
|
+
#step(delta) {
|
|
214
|
+
const total = this.slideTargets.length;
|
|
215
|
+
if (total === 0) return 0;
|
|
216
|
+
const next = this.#index + delta;
|
|
217
|
+
if (this.loopValue) return (next + total) % total;
|
|
218
|
+
return Math.min(total - 1, Math.max(0, next));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Changes the active slide, updates state hooks, and emits `change` — but only
|
|
222
|
+
* when the index actually changes, so a `next`/`prev` clamped at the end (or an
|
|
223
|
+
* autoplay tick at a non-looping boundary) re-renders without a spurious event
|
|
224
|
+
* (matching the "emit on real change" policy of flash/masonry/bulk-select).
|
|
225
|
+
*/
|
|
226
|
+
#select(index, { focus }) {
|
|
227
|
+
const changed = index !== this.#index;
|
|
228
|
+
this.#index = index;
|
|
229
|
+
this.#render({ focus });
|
|
230
|
+
this.#syncTimer();
|
|
231
|
+
if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
|
|
232
|
+
}
|
|
233
|
+
/** Reflects `this.#index` onto slides and pickers (state hooks + roving). */
|
|
234
|
+
#render({ focus }) {
|
|
235
|
+
this.slideTargets.forEach((slide, i) => {
|
|
236
|
+
const active = i === this.#index;
|
|
237
|
+
slide.setAttribute("data-state", active ? "active" : "inactive");
|
|
238
|
+
slide.hidden = !active;
|
|
239
|
+
});
|
|
240
|
+
this.pickerTargets.forEach((picker, i) => {
|
|
241
|
+
picker.setAttribute("aria-selected", i === this.#index ? "true" : "false");
|
|
242
|
+
});
|
|
243
|
+
this.#roving.setActive(this.#index, { focus });
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Drives the autoplay interval toward the desired state. Autoplay should run
|
|
247
|
+
* only when the user wants it (`playing`), the pointer is not hovering, and more
|
|
248
|
+
* than one slide exists. Transitions emit `play`/`pause` and keep the toggle's
|
|
249
|
+
* `aria-pressed` in sync.
|
|
250
|
+
*/
|
|
251
|
+
#syncTimer() {
|
|
252
|
+
if (!this.loopValue && this.#index >= this.slideTargets.length - 1) {
|
|
253
|
+
this.#playing = false;
|
|
254
|
+
}
|
|
255
|
+
const shouldRun = this.#playing && !this.#pointerPaused && this.slideTargets.length > 1;
|
|
256
|
+
if (shouldRun && this.#timerId === null) {
|
|
257
|
+
this.#timerId = this.#intervals.set(() => this.next(), this.intervalValue);
|
|
258
|
+
this.dispatch("play");
|
|
259
|
+
} else if (!shouldRun && this.#timerId !== null) {
|
|
260
|
+
this.#intervals.clear(this.#timerId);
|
|
261
|
+
this.#timerId = null;
|
|
262
|
+
this.dispatch("pause");
|
|
263
|
+
}
|
|
264
|
+
if (this.hasPlayToggleTarget) {
|
|
265
|
+
this.playToggleTarget.setAttribute("aria-pressed", this.#playing ? "true" : "false");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export { CarouselController };
|
|
271
|
+
//# sourceMappingURL=carousel_controller.js.map
|
|
272
|
+
//# sourceMappingURL=carousel_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/roving_tabindex.ts","../../src/utils/safe_timeout.ts","../../src/controllers/carousel_controller.ts"],"names":[],"mappings":"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","file":"carousel_controller.js","sourcesContent":["/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { RovingTabindex, rovingMove } from \"../utils/roving_tabindex\";\nimport { SafeInterval } from \"../utils/safe_timeout\";\n\n/**\n * Headless, accessible **Carousel** (slideshow) behavior.\n *\n * Markup contract (identifier: `stimeo--carousel`):\n * <section data-controller=\"stimeo--carousel\" aria-roledescription=\"carousel\"\n * aria-label=\"Featured\"\n * data-stimeo--carousel-autoplay-value=\"false\"\n * data-stimeo--carousel-interval-value=\"5000\"\n * data-stimeo--carousel-loop-value=\"true\"\n * data-action=\"mouseenter->stimeo--carousel#pause\n * mouseleave->stimeo--carousel#resume\n * focusin->stimeo--carousel#pause\n * focusout->stimeo--carousel#resume\">\n * <button data-stimeo--carousel-target=\"playToggle\"\n * data-action=\"stimeo--carousel#togglePlay\">…</button>\n * <div data-stimeo--carousel-target=\"viewport\">\n * <div role=\"tabpanel\" data-stimeo--carousel-target=\"slide\">…</div>\n * <div role=\"tabpanel\" data-stimeo--carousel-target=\"slide\" hidden>…</div>\n * </div>\n * <button data-stimeo--carousel-target=\"prev\" data-action=\"stimeo--carousel#prev\">‹</button>\n * <button data-stimeo--carousel-target=\"next\" data-action=\"stimeo--carousel#next\">›</button>\n * <div role=\"tablist\">\n * <button role=\"tab\" data-stimeo--carousel-target=\"picker\"\n * data-action=\"stimeo--carousel#goto\n * keydown->stimeo--carousel#onPickerKeydown\"></button>\n * </div>\n * </section>\n *\n * Implements the WAI-ARIA APG **Carousel** (tabbed) pattern. The current slide is\n * exposed through `data-state` (`active`/`inactive`) and the `hidden` attribute on\n * inactive slides (removing them from focus order); the matching picker carries\n * `aria-selected` and the single roving `tabindex`. The play/pause toggle's\n * `aria-pressed` mirrors the autoplay state.\n *\n * @remarks\n * Behavior only — transitions, layout, and visuals are the consumer's CSS.\n * Autoplay honors WCAG 2.2.2: it suspends while the pointer is over the carousel\n * and **hard-stops** when keyboard focus enters (it does not silently resume on\n * focus out — the user must press play), so motion never surprises a keyboard\n * user. The interval is cleared on `disconnect()` (Turbo navigation included).\n * Picker arrow keys move focus only (manual activation); slide changes never steal\n * focus from the control the user operated.\n */\nexport class CarouselController extends Controller<HTMLElement> {\n static override targets = [\"slide\", \"viewport\", \"prev\", \"next\", \"picker\", \"playToggle\"];\n static override values = {\n autoplay: { type: Boolean, default: false },\n interval: { type: Number, default: 5000 },\n loop: { type: Boolean, default: true },\n };\n static actions = [\n \"goto\",\n \"next\",\n \"onPickerKeydown\",\n \"pause\",\n \"prev\",\n \"resume\",\n \"togglePlay\",\n ] as const;\n static events = [\"change\", \"pause\", \"play\"] as const;\n\n declare readonly slideTargets: HTMLElement[];\n declare readonly pickerTargets: HTMLElement[];\n declare readonly playToggleTarget: HTMLElement;\n declare readonly hasPlayToggleTarget: boolean;\n declare autoplayValue: boolean;\n declare intervalValue: number;\n declare loopValue: boolean;\n\n readonly #roving = new RovingTabindex(() => this.pickerTargets);\n readonly #intervals = new SafeInterval();\n /** Index of the visible slide. */\n #index = 0;\n /** User intent to autoplay (toggled by the play button / focus hard-stop). */\n #playing = false;\n /** Pointer is hovering the carousel: a temporary, auto-resuming suspension. */\n #pointerPaused = false;\n /** Id of the live autoplay interval, or null when stopped. */\n #timerId: number | null = null;\n\n /** Renders the initial slide and starts autoplay when requested. */\n override connect(): void {\n const preselected = this.pickerTargets.findIndex(\n (picker) => picker.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#index = preselected === -1 ? 0 : preselected;\n this.#playing = this.#initialPlaying();\n this.#render({ focus: false });\n this.#syncTimer();\n }\n\n /**\n * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the\n * source of truth **when present**, so a Turbo Drive cache restore / morph that\n * re-runs `connect()` against existing DOM does not silently resume autoplay the\n * user had stopped (e.g. by focusing into the carousel). Only when no toggle\n * carries `aria-pressed` does it fall back to the declarative `autoplay` value.\n */\n #initialPlaying(): boolean {\n if (this.hasPlayToggleTarget && this.playToggleTarget.hasAttribute(\"aria-pressed\")) {\n return this.playToggleTarget.getAttribute(\"aria-pressed\") === \"true\";\n }\n return this.autoplayValue;\n }\n\n /** Clears the autoplay interval so it never fires after teardown. */\n override disconnect(): void {\n this.#intervals.clearAll();\n this.#timerId = null;\n }\n\n /** Advances to the next slide. Bound via `data-action`. */\n next(): void {\n this.#select(this.#step(1), { focus: false });\n }\n\n /** Returns to the previous slide. Bound via `data-action`. */\n prev(): void {\n this.#select(this.#step(-1), { focus: false });\n }\n\n /** Jumps to the slide whose picker was activated (click / Enter / Space). */\n goto(event: Event): void {\n const target = event.currentTarget as HTMLElement;\n const index = this.pickerTargets.indexOf(target);\n if (index !== -1) this.#select(index, { focus: false });\n }\n\n /** Toggles autoplay on the user's explicit request and syncs the timer. */\n togglePlay(): void {\n this.#playing = !this.#playing;\n this.#syncTimer();\n }\n\n /**\n * Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes\n * on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so\n * it cannot resume without an explicit play (WCAG 2.2.2).\n */\n pause(event?: Event): void {\n if (event?.type.startsWith(\"focus\")) {\n this.#playing = false;\n } else {\n this.#pointerPaused = true;\n }\n this.#syncTimer();\n }\n\n /**\n * Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still\n * on. A `focusout` does nothing here: the focus pause was a hard stop, so the\n * user must press play to restart.\n */\n resume(event?: Event): void {\n if (event?.type.startsWith(\"focus\")) return;\n this.#pointerPaused = false;\n this.#syncTimer();\n }\n\n /** Picker roving: arrows move focus only; Home/End activate first/last slide. */\n onPickerKeydown(event: KeyboardEvent): void {\n const current = this.pickerTargets.indexOf(event.currentTarget as HTMLElement);\n if (current === -1) return;\n\n const length = this.pickerTargets.length;\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowDown\":\n event.preventDefault();\n this.#roving.setActive(rovingMove(current, length, 1, \"wrap\"), { focus: true });\n return;\n case \"ArrowLeft\":\n case \"ArrowUp\":\n event.preventDefault();\n this.#roving.setActive(rovingMove(current, length, -1, \"wrap\"), { focus: true });\n return;\n case \"Home\":\n event.preventDefault();\n this.#select(0, { focus: true });\n return;\n case \"End\":\n event.preventDefault();\n this.#select(length - 1, { focus: true });\n return;\n default:\n }\n }\n\n /** Resolves the index one step away from the current one, honoring `loop`. */\n #step(delta: number): number {\n const total = this.slideTargets.length;\n if (total === 0) return 0;\n const next = this.#index + delta;\n if (this.loopValue) return (next + total) % total;\n return Math.min(total - 1, Math.max(0, next));\n }\n\n /**\n * Changes the active slide, updates state hooks, and emits `change` — but only\n * when the index actually changes, so a `next`/`prev` clamped at the end (or an\n * autoplay tick at a non-looping boundary) re-renders without a spurious event\n * (matching the \"emit on real change\" policy of flash/masonry/bulk-select).\n */\n #select(index: number, { focus }: { focus: boolean }): void {\n const changed = index !== this.#index;\n this.#index = index;\n this.#render({ focus });\n // Re-evaluate autoplay after every move so reaching the non-looping end stops\n // the timer (see `#syncTimer`); idempotent for moves that don't cross a boundary.\n this.#syncTimer();\n if (changed) this.dispatch(\"change\", { detail: { index, total: this.slideTargets.length } });\n }\n\n /** Reflects `this.#index` onto slides and pickers (state hooks + roving). */\n #render({ focus }: { focus: boolean }): void {\n this.slideTargets.forEach((slide, i) => {\n const active = i === this.#index;\n slide.setAttribute(\"data-state\", active ? \"active\" : \"inactive\");\n slide.hidden = !active;\n });\n this.pickerTargets.forEach((picker, i) => {\n picker.setAttribute(\"aria-selected\", i === this.#index ? \"true\" : \"false\");\n });\n this.#roving.setActive(this.#index, { focus });\n }\n\n /**\n * Drives the autoplay interval toward the desired state. Autoplay should run\n * only when the user wants it (`playing`), the pointer is not hovering, and more\n * than one slide exists. Transitions emit `play`/`pause` and keep the toggle's\n * `aria-pressed` in sync.\n */\n #syncTimer(): void {\n // A non-looping carousel sitting on its last slide has nothing left to advance\n // to, so autoplay turns itself off (a hard stop, like the focus pause): the\n // timer is cleared, `aria-pressed` flips to false, and a manual step back will\n // not silently restart it without an explicit play.\n if (!this.loopValue && this.#index >= this.slideTargets.length - 1) {\n this.#playing = false;\n }\n const shouldRun = this.#playing && !this.#pointerPaused && this.slideTargets.length > 1;\n\n if (shouldRun && this.#timerId === null) {\n this.#timerId = this.#intervals.set(() => this.next(), this.intervalValue);\n this.dispatch(\"play\");\n } else if (!shouldRun && this.#timerId !== null) {\n this.#intervals.clear(this.#timerId);\n this.#timerId = null;\n this.dispatch(\"pause\");\n }\n\n if (this.hasPlayToggleTarget) {\n this.playToggleTarget.setAttribute(\"aria-pressed\", this.#playing ? \"true\" : \"false\");\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless copy-to-clipboard behavior with a live-region completion notice.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--clipboard`):
|
|
7
|
+
* <div data-controller="stimeo--clipboard"
|
|
8
|
+
* data-stimeo--clipboard-feedback-duration-value="2000">
|
|
9
|
+
* <input type="text" value="https://example.com" readonly
|
|
10
|
+
* data-stimeo--clipboard-target="source">
|
|
11
|
+
* <button type="button" data-stimeo--clipboard-target="button"
|
|
12
|
+
* data-action="stimeo--clipboard#copy">Copy</button>
|
|
13
|
+
* <span role="status" aria-live="polite"
|
|
14
|
+
* data-stimeo--clipboard-target="feedback"></span>
|
|
15
|
+
* </div>
|
|
16
|
+
*
|
|
17
|
+
* No dedicated APG pattern; this follows the Button + live-region practice. The
|
|
18
|
+
* copy uses the standard `navigator.clipboard` API (no extra dependency); when
|
|
19
|
+
* it is unavailable or rejects, the failure is surfaced rather than silently
|
|
20
|
+
* swallowed, and never communicated by icon alone — the `role="status"` region
|
|
21
|
+
* carries text so screen readers announce the outcome.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* Behavior only — icon swaps and styling are the consumer's, keyed off
|
|
25
|
+
* `data-state` (`idle` / `copied` / `error`). The completion notice clears
|
|
26
|
+
* itself after `feedbackDuration`; that timer is torn down on disconnect (Turbo)
|
|
27
|
+
* via {@link SafeTimeout}.
|
|
28
|
+
*/
|
|
29
|
+
declare class ClipboardController extends Controller<HTMLElement> {
|
|
30
|
+
#private;
|
|
31
|
+
static targets: string[];
|
|
32
|
+
static values: {
|
|
33
|
+
text: {
|
|
34
|
+
type: StringConstructor;
|
|
35
|
+
default: string;
|
|
36
|
+
};
|
|
37
|
+
feedbackDuration: {
|
|
38
|
+
type: NumberConstructor;
|
|
39
|
+
default: number;
|
|
40
|
+
};
|
|
41
|
+
copiedLabel: {
|
|
42
|
+
type: StringConstructor;
|
|
43
|
+
default: string;
|
|
44
|
+
};
|
|
45
|
+
errorLabel: {
|
|
46
|
+
type: StringConstructor;
|
|
47
|
+
default: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
static actions: readonly ["copy"];
|
|
51
|
+
static events: readonly ["copy"];
|
|
52
|
+
readonly sourceTarget: HTMLElement;
|
|
53
|
+
readonly buttonTarget: HTMLElement;
|
|
54
|
+
readonly feedbackTarget: HTMLElement;
|
|
55
|
+
readonly hasSourceTarget: boolean;
|
|
56
|
+
readonly hasButtonTarget: boolean;
|
|
57
|
+
readonly hasFeedbackTarget: boolean;
|
|
58
|
+
textValue: string;
|
|
59
|
+
feedbackDurationValue: number;
|
|
60
|
+
copiedLabelValue: string;
|
|
61
|
+
errorLabelValue: string;
|
|
62
|
+
connect(): void;
|
|
63
|
+
disconnect(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Copies the resolved text and reports the outcome. Bound via `data-action`
|
|
66
|
+
* (click). Always dispatches `stimeo--clipboard:copy` with `{ success, text }`
|
|
67
|
+
* — including on failure — so consumers can react either way.
|
|
68
|
+
*/
|
|
69
|
+
copy(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { ClipboardController };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/clipboard_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_timeout.ts
|
|
6
|
+
var TimerRegistry = class {
|
|
7
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
8
|
+
ids = /* @__PURE__ */ new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Cancels a single tracked timer.
|
|
11
|
+
*
|
|
12
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
13
|
+
* registry), so callers can clear defensively without guarding.
|
|
14
|
+
*/
|
|
15
|
+
clear(id) {
|
|
16
|
+
if (this.ids.delete(id)) {
|
|
17
|
+
this.cancel(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
22
|
+
* to guarantee no timer outlives the element.
|
|
23
|
+
*/
|
|
24
|
+
clearAll() {
|
|
25
|
+
for (const id of this.ids) {
|
|
26
|
+
this.cancel(id);
|
|
27
|
+
}
|
|
28
|
+
this.ids.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Number of timers currently tracked (pending). */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.ids.size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
36
|
+
/**
|
|
37
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
38
|
+
*
|
|
39
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
40
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
41
|
+
*/
|
|
42
|
+
set(callback, delay) {
|
|
43
|
+
const id = this.schedule(() => {
|
|
44
|
+
this.ids.delete(id);
|
|
45
|
+
callback();
|
|
46
|
+
}, delay);
|
|
47
|
+
this.ids.add(id);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
schedule(callback, delay) {
|
|
51
|
+
return window.setTimeout(callback, delay);
|
|
52
|
+
}
|
|
53
|
+
cancel(id) {
|
|
54
|
+
window.clearTimeout(id);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/controllers/clipboard_controller.ts
|
|
59
|
+
var ClipboardController = class extends Controller {
|
|
60
|
+
static targets = ["source", "button", "feedback"];
|
|
61
|
+
static values = {
|
|
62
|
+
text: { type: String, default: "" },
|
|
63
|
+
feedbackDuration: { type: Number, default: 2e3 },
|
|
64
|
+
copiedLabel: { type: String, default: "Copied" },
|
|
65
|
+
errorLabel: { type: String, default: "Copy failed" }
|
|
66
|
+
};
|
|
67
|
+
static actions = ["copy"];
|
|
68
|
+
static events = ["copy"];
|
|
69
|
+
/** Auto-clear timer for the completion notice; torn down on disconnect. */
|
|
70
|
+
#timers = new SafeTimeout();
|
|
71
|
+
/**
|
|
72
|
+
* The pending auto-clear timer id, or `null` when none is scheduled. Tracked so
|
|
73
|
+
* a rapid second copy cancels the first window instead of letting a stale timer
|
|
74
|
+
* reset the freshly-shown notice early.
|
|
75
|
+
*/
|
|
76
|
+
#resetTimerId = null;
|
|
77
|
+
connect() {
|
|
78
|
+
if (!this.element.hasAttribute("data-state")) {
|
|
79
|
+
this.element.setAttribute("data-state", "idle");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
disconnect() {
|
|
83
|
+
this.#timers.clearAll();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Copies the resolved text and reports the outcome. Bound via `data-action`
|
|
87
|
+
* (click). Always dispatches `stimeo--clipboard:copy` with `{ success, text }`
|
|
88
|
+
* — including on failure — so consumers can react either way.
|
|
89
|
+
*/
|
|
90
|
+
async copy() {
|
|
91
|
+
const text = this.#resolveText();
|
|
92
|
+
let success = false;
|
|
93
|
+
try {
|
|
94
|
+
if (!navigator.clipboard?.writeText) throw new Error("Clipboard API unavailable");
|
|
95
|
+
await navigator.clipboard.writeText(text);
|
|
96
|
+
success = true;
|
|
97
|
+
} catch {
|
|
98
|
+
success = false;
|
|
99
|
+
}
|
|
100
|
+
this.#reportResult(success);
|
|
101
|
+
this.dispatch("copy", { detail: { success, text } });
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The text to copy: the explicit `text` value when set, otherwise the source
|
|
105
|
+
* target's current value (inputs/textareas) or text content.
|
|
106
|
+
*/
|
|
107
|
+
#resolveText() {
|
|
108
|
+
if (this.textValue.length > 0) return this.textValue;
|
|
109
|
+
if (!this.hasSourceTarget) return "";
|
|
110
|
+
const source = this.sourceTarget;
|
|
111
|
+
if (source instanceof HTMLInputElement || source instanceof HTMLTextAreaElement) {
|
|
112
|
+
return source.value;
|
|
113
|
+
}
|
|
114
|
+
return source.textContent ?? "";
|
|
115
|
+
}
|
|
116
|
+
/** Reflects the result on `data-state`, announces it, and schedules a reset. */
|
|
117
|
+
#reportResult(success) {
|
|
118
|
+
this.element.setAttribute("data-state", success ? "copied" : "error");
|
|
119
|
+
if (this.hasFeedbackTarget) {
|
|
120
|
+
this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
|
|
121
|
+
}
|
|
122
|
+
if (this.#resetTimerId !== null) {
|
|
123
|
+
this.#timers.clear(this.#resetTimerId);
|
|
124
|
+
this.#resetTimerId = null;
|
|
125
|
+
}
|
|
126
|
+
if (this.feedbackDurationValue > 0) {
|
|
127
|
+
this.#resetTimerId = this.#timers.set(() => {
|
|
128
|
+
this.#resetTimerId = null;
|
|
129
|
+
this.#reset();
|
|
130
|
+
}, this.feedbackDurationValue);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Returns to the idle state and clears the completion notice. */
|
|
134
|
+
#reset() {
|
|
135
|
+
this.element.setAttribute("data-state", "idle");
|
|
136
|
+
if (this.hasFeedbackTarget) {
|
|
137
|
+
this.feedbackTarget.textContent = "";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export { ClipboardController };
|
|
143
|
+
//# sourceMappingURL=clipboard_controller.js.map
|
|
144
|
+
//# sourceMappingURL=clipboard_controller.js.map
|