stimeo-ui 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/controllers/accordion_controller.d.ts +59 -0
- package/dist/controllers/accordion_controller.js +76 -0
- package/dist/controllers/accordion_controller.js.map +1 -0
- package/dist/controllers/announcer_controller.d.ts +78 -0
- package/dist/controllers/announcer_controller.js +184 -0
- package/dist/controllers/announcer_controller.js.map +1 -0
- package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
- package/dist/controllers/aspect_ratio_controller.js +36 -0
- package/dist/controllers/aspect_ratio_controller.js.map +1 -0
- package/dist/controllers/auto_submit_controller.d.ts +74 -0
- package/dist/controllers/auto_submit_controller.js +147 -0
- package/dist/controllers/auto_submit_controller.js.map +1 -0
- package/dist/controllers/avatar_controller.d.ts +50 -0
- package/dist/controllers/avatar_controller.js +66 -0
- package/dist/controllers/avatar_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +60 -0
- package/dist/controllers/breadcrumb_controller.js +123 -0
- package/dist/controllers/breadcrumb_controller.js.map +1 -0
- package/dist/controllers/bulk_select_controller.d.ts +67 -0
- package/dist/controllers/bulk_select_controller.js +104 -0
- package/dist/controllers/bulk_select_controller.js.map +1 -0
- package/dist/controllers/calendar_controller.d.ts +100 -0
- package/dist/controllers/calendar_controller.js +394 -0
- package/dist/controllers/calendar_controller.js.map +1 -0
- package/dist/controllers/character_counter_controller.d.ts +63 -0
- package/dist/controllers/character_counter_controller.js +179 -0
- package/dist/controllers/character_counter_controller.js.map +1 -0
- package/dist/controllers/checkbox_controller.d.ts +52 -0
- package/dist/controllers/checkbox_controller.js +73 -0
- package/dist/controllers/checkbox_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +76 -0
- package/dist/controllers/combobox_controller.js +186 -0
- package/dist/controllers/combobox_controller.js.map +1 -0
- package/dist/controllers/command_palette_controller.d.ts +103 -0
- package/dist/controllers/command_palette_controller.js +381 -0
- package/dist/controllers/command_palette_controller.js.map +1 -0
- package/dist/controllers/conditional_fields_controller.d.ts +56 -0
- package/dist/controllers/conditional_fields_controller.js +112 -0
- package/dist/controllers/conditional_fields_controller.js.map +1 -0
- package/dist/controllers/confirm_controller.d.ts +83 -0
- package/dist/controllers/confirm_controller.js +276 -0
- package/dist/controllers/confirm_controller.js.map +1 -0
- package/dist/controllers/context_menu_controller.d.ts +81 -0
- package/dist/controllers/context_menu_controller.js +112 -0
- package/dist/controllers/context_menu_controller.js.map +1 -0
- package/dist/controllers/countdown_controller.d.ts +93 -0
- package/dist/controllers/countdown_controller.js +202 -0
- package/dist/controllers/countdown_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.d.ts +57 -0
- package/dist/controllers/dialog_controller.js +207 -0
- package/dist/controllers/dialog_controller.js.map +1 -0
- package/dist/controllers/direct_upload_controller.d.ts +77 -0
- package/dist/controllers/direct_upload_controller.js +212 -0
- package/dist/controllers/direct_upload_controller.js.map +1 -0
- package/dist/controllers/dirty_form_controller.d.ts +52 -0
- package/dist/controllers/dirty_form_controller.js +128 -0
- package/dist/controllers/dirty_form_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +49 -0
- package/dist/controllers/dropdown_controller.js +66 -0
- package/dist/controllers/dropdown_controller.js.map +1 -0
- package/dist/controllers/empty_state_controller.d.ts +60 -0
- package/dist/controllers/empty_state_controller.js +67 -0
- package/dist/controllers/empty_state_controller.js.map +1 -0
- package/dist/controllers/flash_controller.d.ts +63 -0
- package/dist/controllers/flash_controller.js +221 -0
- package/dist/controllers/flash_controller.js.map +1 -0
- package/dist/controllers/focus_controller.d.ts +69 -0
- package/dist/controllers/focus_controller.js +216 -0
- package/dist/controllers/focus_controller.js.map +1 -0
- package/dist/controllers/form_field_controller.d.ts +79 -0
- package/dist/controllers/form_field_controller.js +154 -0
- package/dist/controllers/form_field_controller.js.map +1 -0
- package/dist/controllers/form_validation_controller.d.ts +114 -0
- package/dist/controllers/form_validation_controller.js +202 -0
- package/dist/controllers/form_validation_controller.js.map +1 -0
- package/dist/controllers/frame_loading_controller.d.ts +58 -0
- package/dist/controllers/frame_loading_controller.js +177 -0
- package/dist/controllers/frame_loading_controller.js.map +1 -0
- package/dist/controllers/highlight_controller.d.ts +50 -0
- package/dist/controllers/highlight_controller.js +107 -0
- package/dist/controllers/highlight_controller.js.map +1 -0
- package/dist/controllers/hover_card_controller.d.ts +79 -0
- package/dist/controllers/hover_card_controller.js +165 -0
- package/dist/controllers/hover_card_controller.js.map +1 -0
- package/dist/controllers/idle_controller.d.ts +51 -0
- package/dist/controllers/idle_controller.js +141 -0
- package/dist/controllers/idle_controller.js.map +1 -0
- package/dist/controllers/input_mask_controller.d.ts +74 -0
- package/dist/controllers/input_mask_controller.js +166 -0
- package/dist/controllers/input_mask_controller.js.map +1 -0
- package/dist/controllers/lazy_frame_controller.d.ts +53 -0
- package/dist/controllers/lazy_frame_controller.js +68 -0
- package/dist/controllers/lazy_frame_controller.js.map +1 -0
- package/dist/controllers/listbox_controller.d.ts +88 -0
- package/dist/controllers/listbox_controller.js +256 -0
- package/dist/controllers/listbox_controller.js.map +1 -0
- package/dist/controllers/local_time_controller.d.ts +60 -0
- package/dist/controllers/local_time_controller.js +81 -0
- package/dist/controllers/local_time_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +81 -0
- package/dist/controllers/menu_controller.js +134 -0
- package/dist/controllers/menu_controller.js.map +1 -0
- package/dist/controllers/meter_controller.d.ts +93 -0
- package/dist/controllers/meter_controller.js +96 -0
- package/dist/controllers/meter_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +85 -0
- package/dist/controllers/nested_form_controller.js +131 -0
- package/dist/controllers/nested_form_controller.js.map +1 -0
- package/dist/controllers/network_status_controller.d.ts +49 -0
- package/dist/controllers/network_status_controller.js +126 -0
- package/dist/controllers/network_status_controller.js.map +1 -0
- package/dist/controllers/number_input_controller.d.ts +93 -0
- package/dist/controllers/number_input_controller.js +306 -0
- package/dist/controllers/number_input_controller.js.map +1 -0
- package/dist/controllers/otp_controller.d.ts +68 -0
- package/dist/controllers/otp_controller.js +201 -0
- package/dist/controllers/otp_controller.js.map +1 -0
- package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
- package/dist/controllers/overflow_indicator_controller.js +169 -0
- package/dist/controllers/overflow_indicator_controller.js.map +1 -0
- package/dist/controllers/overflow_menu_controller.d.ts +64 -0
- package/dist/controllers/overflow_menu_controller.js +274 -0
- package/dist/controllers/overflow_menu_controller.js.map +1 -0
- package/dist/controllers/pagination_controller.d.ts +69 -0
- package/dist/controllers/pagination_controller.js +89 -0
- package/dist/controllers/pagination_controller.js.map +1 -0
- package/dist/controllers/password_strength_controller.d.ts +67 -0
- package/dist/controllers/password_strength_controller.js +175 -0
- package/dist/controllers/password_strength_controller.js.map +1 -0
- package/dist/controllers/persist_controller.d.ts +63 -0
- package/dist/controllers/persist_controller.js +259 -0
- package/dist/controllers/persist_controller.js.map +1 -0
- package/dist/controllers/popover_controller.d.ts +63 -0
- package/dist/controllers/popover_controller.js +94 -0
- package/dist/controllers/popover_controller.js.map +1 -0
- package/dist/controllers/portal_controller.d.ts +58 -0
- package/dist/controllers/portal_controller.js +63 -0
- package/dist/controllers/portal_controller.js.map +1 -0
- package/dist/controllers/preview_guard_controller.d.ts +47 -0
- package/dist/controllers/preview_guard_controller.js +69 -0
- package/dist/controllers/preview_guard_controller.js.map +1 -0
- package/dist/controllers/progress_controller.d.ts +85 -0
- package/dist/controllers/progress_controller.js +93 -0
- package/dist/controllers/progress_controller.js.map +1 -0
- package/dist/controllers/radio_group_controller.d.ts +49 -0
- package/dist/controllers/radio_group_controller.js +128 -0
- package/dist/controllers/radio_group_controller.js.map +1 -0
- package/dist/controllers/rating_controller.d.ts +82 -0
- package/dist/controllers/rating_controller.js +179 -0
- package/dist/controllers/rating_controller.js.map +1 -0
- package/dist/controllers/relative_time_controller.d.ts +48 -0
- package/dist/controllers/relative_time_controller.js +129 -0
- package/dist/controllers/relative_time_controller.js.map +1 -0
- package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
- package/dist/controllers/reset_before_cache_controller.js +62 -0
- package/dist/controllers/reset_before_cache_controller.js.map +1 -0
- package/dist/controllers/resizable_controller.d.ts +81 -0
- package/dist/controllers/resizable_controller.js +163 -0
- package/dist/controllers/resizable_controller.js.map +1 -0
- package/dist/controllers/roving_controller.d.ts +63 -0
- package/dist/controllers/roving_controller.js +116 -0
- package/dist/controllers/roving_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.d.ts +45 -0
- package/dist/controllers/scroll_area_controller.js +183 -0
- package/dist/controllers/scroll_area_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
- package/dist/controllers/scroll_visibility_controller.js +103 -0
- package/dist/controllers/scroll_visibility_controller.js.map +1 -0
- package/dist/controllers/scrollspy_controller.d.ts +65 -0
- package/dist/controllers/scrollspy_controller.js +171 -0
- package/dist/controllers/scrollspy_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.d.ts +50 -0
- package/dist/controllers/skeleton_controller.js +125 -0
- package/dist/controllers/skeleton_controller.js.map +1 -0
- package/dist/controllers/slider_controller.d.ts +76 -0
- package/dist/controllers/slider_controller.js +109 -0
- package/dist/controllers/slider_controller.js.map +1 -0
- package/dist/controllers/spinner_controller.d.ts +62 -0
- package/dist/controllers/spinner_controller.js +164 -0
- package/dist/controllers/spinner_controller.js.map +1 -0
- package/dist/controllers/step_indicator_controller.d.ts +55 -0
- package/dist/controllers/step_indicator_controller.js +55 -0
- package/dist/controllers/step_indicator_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +66 -0
- package/dist/controllers/stepper_controller.js +78 -0
- package/dist/controllers/stepper_controller.js.map +1 -0
- package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
- package/dist/controllers/stick_to_bottom_controller.js +100 -0
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
- package/dist/controllers/sticky_observer_controller.d.ts +54 -0
- package/dist/controllers/sticky_observer_controller.js +53 -0
- package/dist/controllers/sticky_observer_controller.js.map +1 -0
- package/dist/controllers/submit_once_controller.d.ts +66 -0
- package/dist/controllers/submit_once_controller.js +206 -0
- package/dist/controllers/submit_once_controller.js.map +1 -0
- package/dist/controllers/switch_controller.d.ts +43 -0
- package/dist/controllers/switch_controller.js +50 -0
- package/dist/controllers/switch_controller.js.map +1 -0
- package/dist/controllers/tabs_controller.d.ts +48 -0
- package/dist/controllers/tabs_controller.js +63 -0
- package/dist/controllers/tabs_controller.js.map +1 -0
- package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
- package/dist/controllers/textarea_autosize_controller.js +72 -0
- package/dist/controllers/textarea_autosize_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +70 -0
- package/dist/controllers/theme_controller.js +154 -0
- package/dist/controllers/theme_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +123 -0
- package/dist/controllers/toast_controller.js +310 -0
- package/dist/controllers/toast_controller.js.map +1 -0
- package/dist/controllers/toggle_group_controller.d.ts +56 -0
- package/dist/controllers/toggle_group_controller.js +130 -0
- package/dist/controllers/toggle_group_controller.js.map +1 -0
- package/dist/controllers/toolbar_controller.d.ts +59 -0
- package/dist/controllers/toolbar_controller.js +113 -0
- package/dist/controllers/toolbar_controller.js.map +1 -0
- package/dist/controllers/tooltip_controller.d.ts +74 -0
- package/dist/controllers/tooltip_controller.js +165 -0
- package/dist/controllers/tooltip_controller.js.map +1 -0
- package/dist/controllers/transition_controller.d.ts +86 -0
- package/dist/controllers/transition_controller.js +203 -0
- package/dist/controllers/transition_controller.js.map +1 -0
- package/dist/index.d.ts +1931 -0
- package/dist/index.js +12241 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector/cli.d.ts +149 -0
- package/dist/inspector/cli.js +663 -0
- package/dist/inspector/cli.js.map +1 -0
- package/dist/inspector/cli_bin.d.ts +2 -0
- package/dist/inspector/cli_bin.js +665 -0
- package/dist/inspector/cli_bin.js.map +1 -0
- package/dist/inspector/manifest.json +2235 -0
- package/dist/positioning/index.d.ts +226 -0
- package/dist/positioning/index.js +145 -0
- package/dist/positioning/index.js.map +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless progress UI for ActiveStorage Direct Uploads: subscribes to the
|
|
5
|
+
* `direct-upload:*` events and renders a per-file progress row (no dedicated APG
|
|
6
|
+
* pattern; the rows follow the `role="progressbar"` practice). The companion to
|
|
7
|
+
* {@link "file-dropzone"}, which leaves transport out of scope.
|
|
8
|
+
*
|
|
9
|
+
* Markup contract (identifier: `stimeo--direct-upload`):
|
|
10
|
+
* <div data-controller="stimeo--direct-upload">
|
|
11
|
+
* <div data-stimeo--direct-upload-target="list"></div>
|
|
12
|
+
* <template data-stimeo--direct-upload-target="row">
|
|
13
|
+
* <div role="progressbar" aria-valuemin="0" aria-valuemax="100">
|
|
14
|
+
* <span data-field="name"></span><span data-field="percent"></span>
|
|
15
|
+
* </div>
|
|
16
|
+
* </template>
|
|
17
|
+
* <span data-stimeo--direct-upload-target="status" aria-live="polite"></span>
|
|
18
|
+
* </div>
|
|
19
|
+
*
|
|
20
|
+
* For each upload it clones the `row` template into `list`, then updates
|
|
21
|
+
* `aria-valuenow`, the `[data-field="percent"]` text, and the
|
|
22
|
+
* `--stimeo-upload-progress` custom property as `direct-upload:progress` arrives,
|
|
23
|
+
* and flips `data-upload-state` to `done` / `error`. Completion and failure are
|
|
24
|
+
* announced into the optional `status` live region using the consumer-provided
|
|
25
|
+
* `doneLabel` / `errorLabel` (so copy stays localizable); per-tick progress is not
|
|
26
|
+
* announced (the progressbar's `aria-valuenow` conveys it) to avoid flooding.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Behavior only — no bars are drawn. The `direct-upload:*` listeners live on
|
|
30
|
+
* `document` (the events bubble there) and are removed on `disconnect()` (Turbo
|
|
31
|
+
* navigation included), along with any pending removal timers, so a callback that
|
|
32
|
+
* arrives after teardown never touches a detached row. With multiple upload widgets
|
|
33
|
+
* on one page, set `scope` to a selector for the owning form/root so each widget
|
|
34
|
+
* only handles its own uploads.
|
|
35
|
+
*/
|
|
36
|
+
declare class DirectUploadController extends Controller<HTMLElement> {
|
|
37
|
+
#private;
|
|
38
|
+
static targets: string[];
|
|
39
|
+
static values: {
|
|
40
|
+
announce: {
|
|
41
|
+
type: BooleanConstructor;
|
|
42
|
+
default: boolean;
|
|
43
|
+
};
|
|
44
|
+
removeOnDone: {
|
|
45
|
+
type: BooleanConstructor;
|
|
46
|
+
default: boolean;
|
|
47
|
+
};
|
|
48
|
+
doneLabel: {
|
|
49
|
+
type: StringConstructor;
|
|
50
|
+
default: string;
|
|
51
|
+
};
|
|
52
|
+
errorLabel: {
|
|
53
|
+
type: StringConstructor;
|
|
54
|
+
default: string;
|
|
55
|
+
};
|
|
56
|
+
scope: {
|
|
57
|
+
type: StringConstructor;
|
|
58
|
+
default: string;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
static events: readonly ["progress", "done", "error"];
|
|
62
|
+
readonly listTarget: HTMLElement;
|
|
63
|
+
readonly rowTarget: HTMLTemplateElement;
|
|
64
|
+
readonly statusTarget: HTMLElement;
|
|
65
|
+
readonly hasListTarget: boolean;
|
|
66
|
+
readonly hasRowTarget: boolean;
|
|
67
|
+
readonly hasStatusTarget: boolean;
|
|
68
|
+
announceValue: boolean;
|
|
69
|
+
removeOnDoneValue: boolean;
|
|
70
|
+
doneLabelValue: string;
|
|
71
|
+
errorLabelValue: string;
|
|
72
|
+
scopeValue: string;
|
|
73
|
+
connect(): void;
|
|
74
|
+
disconnect(): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export { DirectUploadController };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/direct_upload_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/direct_upload_controller.ts
|
|
59
|
+
var REMOVE_DELAY = 4e3;
|
|
60
|
+
var DirectUploadController = class extends Controller {
|
|
61
|
+
static targets = ["list", "row", "status"];
|
|
62
|
+
static values = {
|
|
63
|
+
announce: { type: Boolean, default: true },
|
|
64
|
+
removeOnDone: { type: Boolean, default: false },
|
|
65
|
+
doneLabel: { type: String, default: "" },
|
|
66
|
+
errorLabel: { type: String, default: "" },
|
|
67
|
+
scope: { type: String, default: "" }
|
|
68
|
+
};
|
|
69
|
+
static events = ["progress", "done", "error"];
|
|
70
|
+
#timeouts = new SafeTimeout();
|
|
71
|
+
#rows = /* @__PURE__ */ new Map();
|
|
72
|
+
#onInitialize = (event) => {
|
|
73
|
+
if (!this.#inScope(event)) return;
|
|
74
|
+
const detail = this.#detail(event);
|
|
75
|
+
this.#rowFor(detail.id, detail.file?.name ?? "");
|
|
76
|
+
};
|
|
77
|
+
#onProgress = (event) => {
|
|
78
|
+
if (!this.#inScope(event)) return;
|
|
79
|
+
const detail = this.#detail(event);
|
|
80
|
+
this.#updateProgress(this.#key(detail.id), Math.round(detail.progress ?? 0));
|
|
81
|
+
};
|
|
82
|
+
#onError = (event) => {
|
|
83
|
+
if (!this.#inScope(event)) return;
|
|
84
|
+
const detail = this.#detail(event);
|
|
85
|
+
this.#fail(this.#key(detail.id), detail.error ?? "");
|
|
86
|
+
};
|
|
87
|
+
#onEnd = (event) => {
|
|
88
|
+
if (!this.#inScope(event)) return;
|
|
89
|
+
const detail = this.#detail(event);
|
|
90
|
+
this.#complete(this.#key(detail.id), detail.file?.name ?? "");
|
|
91
|
+
};
|
|
92
|
+
connect() {
|
|
93
|
+
document.addEventListener("direct-upload:initialize", this.#onInitialize);
|
|
94
|
+
document.addEventListener("direct-upload:progress", this.#onProgress);
|
|
95
|
+
document.addEventListener("direct-upload:error", this.#onError);
|
|
96
|
+
document.addEventListener("direct-upload:end", this.#onEnd);
|
|
97
|
+
}
|
|
98
|
+
disconnect() {
|
|
99
|
+
document.removeEventListener("direct-upload:initialize", this.#onInitialize);
|
|
100
|
+
document.removeEventListener("direct-upload:progress", this.#onProgress);
|
|
101
|
+
document.removeEventListener("direct-upload:error", this.#onError);
|
|
102
|
+
document.removeEventListener("direct-upload:end", this.#onEnd);
|
|
103
|
+
this.#timeouts.clearAll();
|
|
104
|
+
this.#rows.clear();
|
|
105
|
+
}
|
|
106
|
+
/** Updates a row's progress and the aggregate, emitting `progress`. */
|
|
107
|
+
#updateProgress(id, percent) {
|
|
108
|
+
const row = this.#rowFor(id);
|
|
109
|
+
if (row === null) return;
|
|
110
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
111
|
+
row.setAttribute("aria-valuenow", String(clamped));
|
|
112
|
+
row.setAttribute("aria-valuetext", `${clamped}%`);
|
|
113
|
+
row.style.setProperty("--stimeo-upload-progress", `${clamped}%`);
|
|
114
|
+
this.#setField(row, "percent", `${clamped}%`);
|
|
115
|
+
this.#syncAggregate();
|
|
116
|
+
this.dispatch("progress", { detail: { id, percent: clamped } });
|
|
117
|
+
}
|
|
118
|
+
/** Marks a row done, announces it, and emits `done`. */
|
|
119
|
+
#complete(id, name = "") {
|
|
120
|
+
const row = this.#rowFor(id, name);
|
|
121
|
+
if (row === null) return;
|
|
122
|
+
row.setAttribute("data-upload-state", "done");
|
|
123
|
+
this.#announce(this.doneLabelValue, row);
|
|
124
|
+
this.dispatch("done", { detail: { id } });
|
|
125
|
+
if (this.removeOnDoneValue) {
|
|
126
|
+
this.#timeouts.set(() => this.#removeRow(id), REMOVE_DELAY);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Marks a row failed, announces it, and emits `error`. */
|
|
130
|
+
#fail(id, error) {
|
|
131
|
+
const row = this.#rowFor(id);
|
|
132
|
+
if (row === null) return;
|
|
133
|
+
row.setAttribute("data-upload-state", "error");
|
|
134
|
+
this.#announce(this.errorLabelValue, row);
|
|
135
|
+
this.dispatch("error", { detail: { id, error } });
|
|
136
|
+
}
|
|
137
|
+
/** Returns an existing row or clones one from the template. */
|
|
138
|
+
#rowFor(id, name) {
|
|
139
|
+
const key = this.#key(id);
|
|
140
|
+
const existing = this.#rows.get(key);
|
|
141
|
+
if (existing !== void 0) return existing;
|
|
142
|
+
if (!this.hasRowTarget || !this.hasListTarget) return null;
|
|
143
|
+
const clone = this.rowTarget.content.firstElementChild?.cloneNode(true);
|
|
144
|
+
if (!(clone instanceof HTMLElement)) return null;
|
|
145
|
+
if (name !== void 0 && name.length > 0) {
|
|
146
|
+
this.#setField(clone, "name", name);
|
|
147
|
+
clone.setAttribute("aria-label", name);
|
|
148
|
+
}
|
|
149
|
+
clone.setAttribute("aria-valuenow", "0");
|
|
150
|
+
clone.setAttribute("data-upload-state", "uploading");
|
|
151
|
+
clone.style.setProperty("--stimeo-upload-progress", "0%");
|
|
152
|
+
this.listTarget.appendChild(clone);
|
|
153
|
+
this.#rows.set(key, clone);
|
|
154
|
+
return clone;
|
|
155
|
+
}
|
|
156
|
+
#removeRow(id) {
|
|
157
|
+
const row = this.#rows.get(id);
|
|
158
|
+
if (row === void 0) return;
|
|
159
|
+
row.remove();
|
|
160
|
+
this.#rows.delete(id);
|
|
161
|
+
this.#syncAggregate();
|
|
162
|
+
}
|
|
163
|
+
/** Reflects the average progress across rows on the controller element. */
|
|
164
|
+
#syncAggregate() {
|
|
165
|
+
if (this.#rows.size === 0) {
|
|
166
|
+
this.element.removeAttribute("data-upload-progress");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
let total = 0;
|
|
170
|
+
for (const row of this.#rows.values()) {
|
|
171
|
+
total += Number(row.getAttribute("aria-valuenow") ?? "0");
|
|
172
|
+
}
|
|
173
|
+
const overall = Math.round(total / this.#rows.size);
|
|
174
|
+
this.element.setAttribute("data-upload-progress", String(overall));
|
|
175
|
+
this.element.style.setProperty("--stimeo-upload-progress", `${overall}%`);
|
|
176
|
+
}
|
|
177
|
+
/** Writes a consumer label (with `%{name}` substituted) to the status region. */
|
|
178
|
+
#announce(label, row) {
|
|
179
|
+
if (!this.announceValue || !this.hasStatusTarget || label.length === 0) return;
|
|
180
|
+
const name = this.#field(row, "name")?.textContent ?? "";
|
|
181
|
+
this.statusTarget.textContent = label.replace("%{name}", name);
|
|
182
|
+
}
|
|
183
|
+
#field(row, name) {
|
|
184
|
+
return row.querySelector(`[data-field="${name}"]`);
|
|
185
|
+
}
|
|
186
|
+
#setField(row, name, text) {
|
|
187
|
+
const field = this.#field(row, name);
|
|
188
|
+
if (field !== null) field.textContent = text;
|
|
189
|
+
}
|
|
190
|
+
#detail(event) {
|
|
191
|
+
return event.detail ?? {};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Whether an event belongs to this controller. With `scope` set, only events
|
|
195
|
+
* whose target (the file input) sits inside an element matching `scope` are
|
|
196
|
+
* handled, so several upload widgets on one page do not cross-populate. Resolved
|
|
197
|
+
* with `closest()` from the target itself, so the chatty `progress` stream never
|
|
198
|
+
* pays a document-wide query. Empty `scope` handles all.
|
|
199
|
+
*/
|
|
200
|
+
#inScope(event) {
|
|
201
|
+
if (this.scopeValue.length === 0) return true;
|
|
202
|
+
const target = event.target;
|
|
203
|
+
return target instanceof Element && target.closest(this.scopeValue) !== null;
|
|
204
|
+
}
|
|
205
|
+
#key(id) {
|
|
206
|
+
return String(id ?? "");
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export { DirectUploadController };
|
|
211
|
+
//# sourceMappingURL=direct_upload_controller.js.map
|
|
212
|
+
//# sourceMappingURL=direct_upload_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/direct_upload_controller.ts"],"names":[],"mappings":"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","file":"direct_upload_controller.js","sourcesContent":["/**\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 { SafeTimeout } from \"../utils/safe_timeout\";\n\n/** Detail shapes for the ActiveStorage `direct-upload:*` events. */\ninterface UploadDetail {\n id?: string | number;\n file?: { name?: string };\n progress?: number;\n error?: string;\n}\n\n/** Delay (ms) before a completed row is removed when `removeOnDone` is set. */\nconst REMOVE_DELAY = 4000;\n\n/**\n * Headless progress UI for ActiveStorage Direct Uploads: subscribes to the\n * `direct-upload:*` events and renders a per-file progress row (no dedicated APG\n * pattern; the rows follow the `role=\"progressbar\"` practice). The companion to\n * {@link \"file-dropzone\"}, which leaves transport out of scope.\n *\n * Markup contract (identifier: `stimeo--direct-upload`):\n * <div data-controller=\"stimeo--direct-upload\">\n * <div data-stimeo--direct-upload-target=\"list\"></div>\n * <template data-stimeo--direct-upload-target=\"row\">\n * <div role=\"progressbar\" aria-valuemin=\"0\" aria-valuemax=\"100\">\n * <span data-field=\"name\"></span><span data-field=\"percent\"></span>\n * </div>\n * </template>\n * <span data-stimeo--direct-upload-target=\"status\" aria-live=\"polite\"></span>\n * </div>\n *\n * For each upload it clones the `row` template into `list`, then updates\n * `aria-valuenow`, the `[data-field=\"percent\"]` text, and the\n * `--stimeo-upload-progress` custom property as `direct-upload:progress` arrives,\n * and flips `data-upload-state` to `done` / `error`. Completion and failure are\n * announced into the optional `status` live region using the consumer-provided\n * `doneLabel` / `errorLabel` (so copy stays localizable); per-tick progress is not\n * announced (the progressbar's `aria-valuenow` conveys it) to avoid flooding.\n *\n * @remarks\n * Behavior only — no bars are drawn. The `direct-upload:*` listeners live on\n * `document` (the events bubble there) and are removed on `disconnect()` (Turbo\n * navigation included), along with any pending removal timers, so a callback that\n * arrives after teardown never touches a detached row. With multiple upload widgets\n * on one page, set `scope` to a selector for the owning form/root so each widget\n * only handles its own uploads.\n */\nexport class DirectUploadController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"row\", \"status\"];\n static override values = {\n announce: { type: Boolean, default: true },\n removeOnDone: { type: Boolean, default: false },\n doneLabel: { type: String, default: \"\" },\n errorLabel: { type: String, default: \"\" },\n scope: { type: String, default: \"\" },\n };\n static events = [\"progress\", \"done\", \"error\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly rowTarget: HTMLTemplateElement;\n declare readonly statusTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasRowTarget: boolean;\n declare readonly hasStatusTarget: boolean;\n\n declare announceValue: boolean;\n declare removeOnDoneValue: boolean;\n declare doneLabelValue: string;\n declare errorLabelValue: string;\n declare scopeValue: string;\n\n readonly #timeouts = new SafeTimeout();\n readonly #rows = new Map<string, HTMLElement>();\n\n readonly #onInitialize = (event: Event): void => {\n if (!this.#inScope(event)) return;\n const detail = this.#detail(event);\n this.#rowFor(detail.id, detail.file?.name ?? \"\");\n };\n\n readonly #onProgress = (event: Event): void => {\n if (!this.#inScope(event)) return;\n const detail = this.#detail(event);\n this.#updateProgress(this.#key(detail.id), Math.round(detail.progress ?? 0));\n };\n\n readonly #onError = (event: Event): void => {\n if (!this.#inScope(event)) return;\n const detail = this.#detail(event);\n this.#fail(this.#key(detail.id), detail.error ?? \"\");\n };\n\n readonly #onEnd = (event: Event): void => {\n if (!this.#inScope(event)) return;\n const detail = this.#detail(event);\n this.#complete(this.#key(detail.id), detail.file?.name ?? \"\");\n };\n\n override connect(): void {\n document.addEventListener(\"direct-upload:initialize\", this.#onInitialize);\n document.addEventListener(\"direct-upload:progress\", this.#onProgress);\n document.addEventListener(\"direct-upload:error\", this.#onError);\n document.addEventListener(\"direct-upload:end\", this.#onEnd);\n }\n\n override disconnect(): void {\n document.removeEventListener(\"direct-upload:initialize\", this.#onInitialize);\n document.removeEventListener(\"direct-upload:progress\", this.#onProgress);\n document.removeEventListener(\"direct-upload:error\", this.#onError);\n document.removeEventListener(\"direct-upload:end\", this.#onEnd);\n this.#timeouts.clearAll();\n this.#rows.clear();\n }\n\n /** Updates a row's progress and the aggregate, emitting `progress`. */\n #updateProgress(id: string, percent: number): void {\n const row = this.#rowFor(id);\n if (row === null) return;\n const clamped = Math.max(0, Math.min(100, percent));\n row.setAttribute(\"aria-valuenow\", String(clamped));\n row.setAttribute(\"aria-valuetext\", `${clamped}%`);\n row.style.setProperty(\"--stimeo-upload-progress\", `${clamped}%`);\n this.#setField(row, \"percent\", `${clamped}%`);\n this.#syncAggregate();\n this.dispatch(\"progress\", { detail: { id, percent: clamped } });\n }\n\n /** Marks a row done, announces it, and emits `done`. */\n #complete(id: string, name = \"\"): void {\n // Lazily resolve like `#fail`/`#updateProgress` so an `end` that arrives\n // without a prior `initialize`/`progress` (no row yet) still records the\n // completion instead of silently dropping it; the file name (when the event\n // carries one) gives the freshly-created row an accessible label.\n const row = this.#rowFor(id, name);\n if (row === null) return;\n row.setAttribute(\"data-upload-state\", \"done\");\n this.#announce(this.doneLabelValue, row);\n this.dispatch(\"done\", { detail: { id } });\n if (this.removeOnDoneValue) {\n this.#timeouts.set(() => this.#removeRow(id), REMOVE_DELAY);\n }\n }\n\n /** Marks a row failed, announces it, and emits `error`. */\n #fail(id: string, error: string): void {\n const row = this.#rowFor(id);\n if (row === null) return;\n row.setAttribute(\"data-upload-state\", \"error\");\n this.#announce(this.errorLabelValue, row);\n this.dispatch(\"error\", { detail: { id, error } });\n }\n\n /** Returns an existing row or clones one from the template. */\n #rowFor(id: string | number | undefined, name?: string): HTMLElement | null {\n const key = this.#key(id);\n const existing = this.#rows.get(key);\n if (existing !== undefined) return existing;\n if (!this.hasRowTarget || !this.hasListTarget) return null;\n const clone = this.rowTarget.content.firstElementChild?.cloneNode(true);\n if (!(clone instanceof HTMLElement)) return null;\n if (name !== undefined && name.length > 0) {\n this.#setField(clone, \"name\", name);\n // Give the progressbar an accessible name (per-file).\n clone.setAttribute(\"aria-label\", name);\n }\n clone.setAttribute(\"aria-valuenow\", \"0\");\n clone.setAttribute(\"data-upload-state\", \"uploading\");\n clone.style.setProperty(\"--stimeo-upload-progress\", \"0%\");\n this.listTarget.appendChild(clone);\n this.#rows.set(key, clone);\n return clone;\n }\n\n #removeRow(id: string): void {\n const row = this.#rows.get(id);\n if (row === undefined) return;\n row.remove();\n this.#rows.delete(id);\n this.#syncAggregate();\n }\n\n /** Reflects the average progress across rows on the controller element. */\n #syncAggregate(): void {\n if (this.#rows.size === 0) {\n this.element.removeAttribute(\"data-upload-progress\");\n return;\n }\n let total = 0;\n for (const row of this.#rows.values()) {\n total += Number(row.getAttribute(\"aria-valuenow\") ?? \"0\");\n }\n const overall = Math.round(total / this.#rows.size);\n this.element.setAttribute(\"data-upload-progress\", String(overall));\n this.element.style.setProperty(\"--stimeo-upload-progress\", `${overall}%`);\n }\n\n /** Writes a consumer label (with `%{name}` substituted) to the status region. */\n #announce(label: string, row: HTMLElement): void {\n if (!this.announceValue || !this.hasStatusTarget || label.length === 0) return;\n const name = this.#field(row, \"name\")?.textContent ?? \"\";\n this.statusTarget.textContent = label.replace(\"%{name}\", name);\n }\n\n #field(row: HTMLElement, name: string): HTMLElement | null {\n return row.querySelector<HTMLElement>(`[data-field=\"${name}\"]`);\n }\n\n #setField(row: HTMLElement, name: string, text: string): void {\n const field = this.#field(row, name);\n if (field !== null) field.textContent = text;\n }\n\n #detail(event: Event): UploadDetail {\n return (event as CustomEvent<UploadDetail>).detail ?? {};\n }\n\n /**\n * Whether an event belongs to this controller. With `scope` set, only events\n * whose target (the file input) sits inside an element matching `scope` are\n * handled, so several upload widgets on one page do not cross-populate. Resolved\n * with `closest()` from the target itself, so the chatty `progress` stream never\n * pays a document-wide query. Empty `scope` handles all.\n */\n #inScope(event: Event): boolean {\n if (this.scopeValue.length === 0) return true;\n const target = event.target;\n return target instanceof Element && target.closest(this.scopeValue) !== null;\n }\n\n #key(id: string | number | undefined): string {\n return String(id ?? \"\");\n }\n}\n"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless "unsaved changes" guard for a form (no dedicated APG pattern; supports
|
|
5
|
+
* WCAG 2.2 error-prevention 3.3.4 / 3.3.6 by preventing accidental data loss).
|
|
6
|
+
*
|
|
7
|
+
* Markup contract (identifier: `stimeo--dirty-form`):
|
|
8
|
+
* <form data-controller="stimeo--dirty-form">
|
|
9
|
+
* …fields…
|
|
10
|
+
* </form>
|
|
11
|
+
*
|
|
12
|
+
* Snapshots the form's field values on connect, marks the form `data-dirty` once a
|
|
13
|
+
* value changes, and — while dirty — guards both a full unload (`beforeunload`) and
|
|
14
|
+
* a Turbo visit (`turbo:before-visit`). On a Turbo visit it dispatches a cancelable
|
|
15
|
+
* `guard` event; if a consumer cancels it (or `confirmBridge` is set) the visit is
|
|
16
|
+
* blocked, otherwise it falls back to a native `confirm(message)`. While a submit is
|
|
17
|
+
* in flight the guard is suppressed (so a legitimate submit never prompts); the dirty
|
|
18
|
+
* state is cleared only on a successful `turbo:submit-end`, and a failed submit
|
|
19
|
+
* re-arms the guard. `markClean` clears it manually (e.g. after a custom save).
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Behavior only — it renders no confirmation UI (pair with a Confirm Bridge) and
|
|
23
|
+
* does not persist input (pair with Persist). The dirty baseline is read from the
|
|
24
|
+
* DOM on `connect()` (no module-scope state) — which also clears a stale
|
|
25
|
+
* `data-dirty` left in a Turbo cache snapshot, since the restored values are the
|
|
26
|
+
* new baseline — `beforeunload` is wired only while dirty, and every listener is
|
|
27
|
+
* removed on `disconnect()` (Turbo navigation included) so a stale guard never
|
|
28
|
+
* outlives the form.
|
|
29
|
+
*/
|
|
30
|
+
declare class DirtyFormController extends Controller<HTMLFormElement> {
|
|
31
|
+
#private;
|
|
32
|
+
static values: {
|
|
33
|
+
message: {
|
|
34
|
+
type: StringConstructor;
|
|
35
|
+
default: string;
|
|
36
|
+
};
|
|
37
|
+
confirmBridge: {
|
|
38
|
+
type: BooleanConstructor;
|
|
39
|
+
default: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
static actions: readonly ["markClean"];
|
|
43
|
+
static events: readonly ["dirty", "guard"];
|
|
44
|
+
messageValue: string;
|
|
45
|
+
confirmBridgeValue: boolean;
|
|
46
|
+
connect(): void;
|
|
47
|
+
disconnect(): void;
|
|
48
|
+
/** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
|
|
49
|
+
markClean(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { DirtyFormController };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/dirty_form_controller.ts
|
|
4
|
+
var DirtyFormController = class extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
message: { type: String, default: "You have unsaved changes that will be lost." },
|
|
7
|
+
confirmBridge: { type: Boolean, default: false }
|
|
8
|
+
};
|
|
9
|
+
static actions = ["markClean"];
|
|
10
|
+
static events = ["dirty", "guard"];
|
|
11
|
+
#baseline = "";
|
|
12
|
+
#dirty = false;
|
|
13
|
+
#beforeunloadBound = false;
|
|
14
|
+
/** True between a form `submit` and its `turbo:submit-end`, suppressing the guard. */
|
|
15
|
+
#submitting = false;
|
|
16
|
+
#onFieldChange = () => {
|
|
17
|
+
this.#submitting = false;
|
|
18
|
+
this.#evaluate();
|
|
19
|
+
};
|
|
20
|
+
#onSubmit = () => {
|
|
21
|
+
this.#submitting = true;
|
|
22
|
+
};
|
|
23
|
+
#onBeforeVisit = (event) => {
|
|
24
|
+
this.#guardVisit(event);
|
|
25
|
+
};
|
|
26
|
+
#onSubmitEnd = (event) => {
|
|
27
|
+
this.#submitting = false;
|
|
28
|
+
const success = event.detail?.success;
|
|
29
|
+
if (success !== false) this.markClean();
|
|
30
|
+
};
|
|
31
|
+
#onBeforeUnload = (event) => {
|
|
32
|
+
if (!this.#dirty || this.#submitting) return;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
event.returnValue = this.messageValue;
|
|
35
|
+
};
|
|
36
|
+
connect() {
|
|
37
|
+
this.#baseline = this.#serialize();
|
|
38
|
+
this.element.removeAttribute("data-dirty");
|
|
39
|
+
this.element.addEventListener("input", this.#onFieldChange);
|
|
40
|
+
this.element.addEventListener("change", this.#onFieldChange);
|
|
41
|
+
this.element.addEventListener("submit", this.#onSubmit);
|
|
42
|
+
this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
43
|
+
document.addEventListener("turbo:before-visit", this.#onBeforeVisit);
|
|
44
|
+
}
|
|
45
|
+
disconnect() {
|
|
46
|
+
this.element.removeEventListener("input", this.#onFieldChange);
|
|
47
|
+
this.element.removeEventListener("change", this.#onFieldChange);
|
|
48
|
+
this.element.removeEventListener("submit", this.#onSubmit);
|
|
49
|
+
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
50
|
+
document.removeEventListener("turbo:before-visit", this.#onBeforeVisit);
|
|
51
|
+
this.#unbindBeforeUnload();
|
|
52
|
+
}
|
|
53
|
+
/** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
|
|
54
|
+
markClean() {
|
|
55
|
+
this.#baseline = this.#serialize();
|
|
56
|
+
this.#setDirty(false);
|
|
57
|
+
}
|
|
58
|
+
/** Recomputes dirty against the baseline and flips state when it changes. */
|
|
59
|
+
#evaluate() {
|
|
60
|
+
this.#setDirty(this.#serialize() !== this.#baseline);
|
|
61
|
+
}
|
|
62
|
+
#setDirty(dirty) {
|
|
63
|
+
if (dirty === this.#dirty) return;
|
|
64
|
+
this.#dirty = dirty;
|
|
65
|
+
if (dirty) {
|
|
66
|
+
this.element.setAttribute("data-dirty", "true");
|
|
67
|
+
this.#bindBeforeUnload();
|
|
68
|
+
} else {
|
|
69
|
+
this.element.removeAttribute("data-dirty");
|
|
70
|
+
this.#unbindBeforeUnload();
|
|
71
|
+
}
|
|
72
|
+
this.dispatch("dirty", { detail: { dirty } });
|
|
73
|
+
}
|
|
74
|
+
/** Guards a Turbo visit while dirty: consumer cancel → confirmBridge → confirm. */
|
|
75
|
+
#guardVisit(event) {
|
|
76
|
+
if (!this.#dirty || this.#submitting) return;
|
|
77
|
+
const guard = this.dispatch("guard", { detail: { event }, cancelable: true });
|
|
78
|
+
if (guard.defaultPrevented) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.confirmBridgeValue) {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!window.confirm(this.messageValue)) {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
#bindBeforeUnload() {
|
|
91
|
+
if (this.#beforeunloadBound) return;
|
|
92
|
+
window.addEventListener("beforeunload", this.#onBeforeUnload);
|
|
93
|
+
this.#beforeunloadBound = true;
|
|
94
|
+
}
|
|
95
|
+
#unbindBeforeUnload() {
|
|
96
|
+
if (!this.#beforeunloadBound) return;
|
|
97
|
+
window.removeEventListener("beforeunload", this.#onBeforeUnload);
|
|
98
|
+
this.#beforeunloadBound = false;
|
|
99
|
+
}
|
|
100
|
+
/** Stable serialization of the form's controls for change detection. */
|
|
101
|
+
#serialize() {
|
|
102
|
+
const parts = [];
|
|
103
|
+
for (const el of Array.from(this.element.elements)) {
|
|
104
|
+
const name = this.#nameOf(el);
|
|
105
|
+
if (name === null) continue;
|
|
106
|
+
if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
|
|
107
|
+
parts.push(`${name}:${el.checked ? 1 : 0}`);
|
|
108
|
+
} else if (el instanceof HTMLSelectElement) {
|
|
109
|
+
const value = el.multiple ? Array.from(el.selectedOptions).map((o) => o.value).join(",") : el.value;
|
|
110
|
+
parts.push(`${name}:${value}`);
|
|
111
|
+
} else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
112
|
+
parts.push(`${name}:${el.value}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return parts.join("|");
|
|
116
|
+
}
|
|
117
|
+
/** A stable key for a control, or null for elements without value semantics. */
|
|
118
|
+
#nameOf(el) {
|
|
119
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
|
|
120
|
+
return el.name || el.id || "";
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export { DirtyFormController };
|
|
127
|
+
//# sourceMappingURL=dirty_form_controller.js.map
|
|
128
|
+
//# sourceMappingURL=dirty_form_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/dirty_form_controller.ts"],"names":[],"mappings":"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","file":"dirty_form_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless \"unsaved changes\" guard for a form (no dedicated APG pattern; supports\n * WCAG 2.2 error-prevention 3.3.4 / 3.3.6 by preventing accidental data loss).\n *\n * Markup contract (identifier: `stimeo--dirty-form`):\n * <form data-controller=\"stimeo--dirty-form\">\n * …fields…\n * </form>\n *\n * Snapshots the form's field values on connect, marks the form `data-dirty` once a\n * value changes, and — while dirty — guards both a full unload (`beforeunload`) and\n * a Turbo visit (`turbo:before-visit`). On a Turbo visit it dispatches a cancelable\n * `guard` event; if a consumer cancels it (or `confirmBridge` is set) the visit is\n * blocked, otherwise it falls back to a native `confirm(message)`. While a submit is\n * in flight the guard is suppressed (so a legitimate submit never prompts); the dirty\n * state is cleared only on a successful `turbo:submit-end`, and a failed submit\n * re-arms the guard. `markClean` clears it manually (e.g. after a custom save).\n *\n * @remarks\n * Behavior only — it renders no confirmation UI (pair with a Confirm Bridge) and\n * does not persist input (pair with Persist). The dirty baseline is read from the\n * DOM on `connect()` (no module-scope state) — which also clears a stale\n * `data-dirty` left in a Turbo cache snapshot, since the restored values are the\n * new baseline — `beforeunload` is wired only while dirty, and every listener is\n * removed on `disconnect()` (Turbo navigation included) so a stale guard never\n * outlives the form.\n */\nexport class DirtyFormController extends Controller<HTMLFormElement> {\n static override values = {\n message: { type: String, default: \"You have unsaved changes that will be lost.\" },\n confirmBridge: { type: Boolean, default: false },\n };\n static actions = [\"markClean\"] as const;\n static events = [\"dirty\", \"guard\"] as const;\n\n declare messageValue: string;\n declare confirmBridgeValue: boolean;\n\n #baseline = \"\";\n #dirty = false;\n #beforeunloadBound = false;\n /** True between a form `submit` and its `turbo:submit-end`, suppressing the guard. */\n #submitting = false;\n\n readonly #onFieldChange = (): void => {\n // Any edit means the user is actively working again, so a prior submit that\n // never resolved (e.g. one cancelled client-side, with no turbo:submit-end)\n // must not keep the guard suppressed.\n this.#submitting = false;\n this.#evaluate();\n };\n\n readonly #onSubmit = (): void => {\n // A real submit is leaving on purpose: suppress the guard for the in-flight\n // request. We do NOT clear the baseline yet — a failed submit must stay dirty.\n this.#submitting = true;\n };\n\n readonly #onBeforeVisit = (event: Event): void => {\n this.#guardVisit(event);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n this.#submitting = false;\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success;\n // Only a successful submit clears the dirty state; a failure re-arms the guard.\n if (success !== false) this.markClean();\n };\n\n readonly #onBeforeUnload = (event: BeforeUnloadEvent): void => {\n if (!this.#dirty || this.#submitting) return;\n event.preventDefault();\n // Legacy requirement: a non-empty returnValue triggers the native prompt.\n event.returnValue = this.messageValue;\n };\n\n override connect(): void {\n this.#baseline = this.#serialize();\n // Re-baselining means the restored values ARE the clean state, so a stale\n // data-dirty captured in a Turbo cache snapshot mid-edit must not linger\n // (the guard would not fire, but consumer CSS would keep claiming \"unsaved\").\n this.element.removeAttribute(\"data-dirty\");\n this.element.addEventListener(\"input\", this.#onFieldChange);\n this.element.addEventListener(\"change\", this.#onFieldChange);\n this.element.addEventListener(\"submit\", this.#onSubmit);\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n document.addEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"input\", this.#onFieldChange);\n this.element.removeEventListener(\"change\", this.#onFieldChange);\n this.element.removeEventListener(\"submit\", this.#onSubmit);\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n document.removeEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n this.#unbindBeforeUnload();\n }\n\n /** Re-baselines to the current values and clears the dirty state (e.g. after a save). */\n markClean(): void {\n this.#baseline = this.#serialize();\n this.#setDirty(false);\n }\n\n /** Recomputes dirty against the baseline and flips state when it changes. */\n #evaluate(): void {\n this.#setDirty(this.#serialize() !== this.#baseline);\n }\n\n #setDirty(dirty: boolean): void {\n if (dirty === this.#dirty) return;\n this.#dirty = dirty;\n if (dirty) {\n this.element.setAttribute(\"data-dirty\", \"true\");\n this.#bindBeforeUnload();\n } else {\n this.element.removeAttribute(\"data-dirty\");\n this.#unbindBeforeUnload();\n }\n this.dispatch(\"dirty\", { detail: { dirty } });\n }\n\n /** Guards a Turbo visit while dirty: consumer cancel → confirmBridge → confirm. */\n #guardVisit(event: Event): void {\n if (!this.#dirty || this.#submitting) return;\n const guard = this.dispatch(\"guard\", { detail: { event }, cancelable: true });\n if (guard.defaultPrevented) {\n event.preventDefault();\n return;\n }\n if (this.confirmBridgeValue) {\n // Defer the actual prompt to a consumer-wired Confirm Bridge (via the guard\n // event); block the visit here so nothing navigates before it decides.\n event.preventDefault();\n return;\n }\n if (!window.confirm(this.messageValue)) {\n event.preventDefault();\n }\n }\n\n #bindBeforeUnload(): void {\n if (this.#beforeunloadBound) return;\n window.addEventListener(\"beforeunload\", this.#onBeforeUnload);\n this.#beforeunloadBound = true;\n }\n\n #unbindBeforeUnload(): void {\n if (!this.#beforeunloadBound) return;\n window.removeEventListener(\"beforeunload\", this.#onBeforeUnload);\n this.#beforeunloadBound = false;\n }\n\n /** Stable serialization of the form's controls for change detection. */\n #serialize(): string {\n const parts: string[] = [];\n for (const el of Array.from(this.element.elements)) {\n const name = this.#nameOf(el);\n if (name === null) continue;\n if (el instanceof HTMLInputElement && (el.type === \"checkbox\" || el.type === \"radio\")) {\n parts.push(`${name}:${el.checked ? 1 : 0}`);\n } else if (el instanceof HTMLSelectElement) {\n // Single select: read `value` (robust); multi-select: join the selected values.\n const value = el.multiple\n ? Array.from(el.selectedOptions)\n .map((o) => o.value)\n .join(\",\")\n : el.value;\n parts.push(`${name}:${value}`);\n } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {\n parts.push(`${name}:${el.value}`);\n }\n }\n return parts.join(\"|\");\n }\n\n /** A stable key for a control, or null for elements without value semantics. */\n #nameOf(el: Element): string | null {\n if (\n el instanceof HTMLInputElement ||\n el instanceof HTMLTextAreaElement ||\n el instanceof HTMLSelectElement\n ) {\n return el.name || el.id || \"\";\n }\n return null;\n }\n}\n"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible dropdown menu behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--dropdown`):
|
|
7
|
+
* <div data-controller="stimeo--dropdown">
|
|
8
|
+
* <button data-stimeo--dropdown-target="trigger"
|
|
9
|
+
* data-action="stimeo--dropdown#toggle">Menu</button>
|
|
10
|
+
* <div data-stimeo--dropdown-target="menu">...</div>
|
|
11
|
+
* </div>
|
|
12
|
+
*
|
|
13
|
+
* This is a **disclosure** pattern (WAI-ARIA APG): a button toggles the
|
|
14
|
+
* visibility of an adjacent region. It is intentionally *not* a full APG
|
|
15
|
+
* "menu" widget — there is no roving-tabindex arrow-key navigation.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* The library owns behavior only (ARIA state, keyboard, focus, outside-click).
|
|
19
|
+
* Visual styling is left entirely to the consumer's CSS.
|
|
20
|
+
*
|
|
21
|
+
* Behavior provided:
|
|
22
|
+
* - Click the trigger to toggle the menu (`aria-expanded` + `hidden` reflect state).
|
|
23
|
+
* - `Escape` closes the menu and returns focus to the trigger.
|
|
24
|
+
* - A click outside the controller element closes the menu.
|
|
25
|
+
*/
|
|
26
|
+
declare class DropdownController extends Controller<HTMLElement> {
|
|
27
|
+
#private;
|
|
28
|
+
static targets: string[];
|
|
29
|
+
static actions: readonly ["close", "open", "toggle"];
|
|
30
|
+
readonly triggerTarget: HTMLButtonElement;
|
|
31
|
+
readonly menuTarget: HTMLElement;
|
|
32
|
+
readonly hasMenuTarget: boolean;
|
|
33
|
+
readonly hasTriggerTarget: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Starts in the closed state and registers the document-level listeners that
|
|
36
|
+
* power outside-click and `Escape` handling.
|
|
37
|
+
*/
|
|
38
|
+
connect(): void;
|
|
39
|
+
/** Removes the document-level listeners registered in {@link connect}. */
|
|
40
|
+
disconnect(): void;
|
|
41
|
+
/** Toggles the menu between open and closed. Bound via `data-action`. */
|
|
42
|
+
toggle(): void;
|
|
43
|
+
/** Reveals the menu and reflects the open state on the trigger. */
|
|
44
|
+
open(): void;
|
|
45
|
+
/** Hides the menu and reflects the closed state on the trigger. */
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { DropdownController };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/dropdown_controller.ts
|
|
4
|
+
var DropdownController = class extends Controller {
|
|
5
|
+
static targets = ["trigger", "menu"];
|
|
6
|
+
static actions = ["close", "open", "toggle"];
|
|
7
|
+
/** Closes the menu when a click lands outside the controller's element. */
|
|
8
|
+
#onOutsideClick = (event) => {
|
|
9
|
+
if (!this.element.contains(event.target)) {
|
|
10
|
+
this.close();
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/** Closes the menu on `Escape` and restores focus to the trigger. */
|
|
14
|
+
#onKeydown = (event) => {
|
|
15
|
+
if (event.key === "Escape" && this.#isOpen) {
|
|
16
|
+
this.close();
|
|
17
|
+
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Starts in the closed state and registers the document-level listeners that
|
|
22
|
+
* power outside-click and `Escape` handling.
|
|
23
|
+
*/
|
|
24
|
+
connect() {
|
|
25
|
+
this.close();
|
|
26
|
+
document.addEventListener("click", this.#onOutsideClick);
|
|
27
|
+
document.addEventListener("keydown", this.#onKeydown);
|
|
28
|
+
}
|
|
29
|
+
/** Removes the document-level listeners registered in {@link connect}. */
|
|
30
|
+
disconnect() {
|
|
31
|
+
document.removeEventListener("click", this.#onOutsideClick);
|
|
32
|
+
document.removeEventListener("keydown", this.#onKeydown);
|
|
33
|
+
}
|
|
34
|
+
/** Toggles the menu between open and closed. Bound via `data-action`. */
|
|
35
|
+
toggle() {
|
|
36
|
+
if (this.#isOpen) {
|
|
37
|
+
this.close();
|
|
38
|
+
} else {
|
|
39
|
+
this.open();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Reveals the menu and reflects the open state on the trigger. */
|
|
43
|
+
open() {
|
|
44
|
+
if (!this.hasMenuTarget) return;
|
|
45
|
+
this.menuTarget.hidden = false;
|
|
46
|
+
if (this.hasTriggerTarget) {
|
|
47
|
+
this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Hides the menu and reflects the closed state on the trigger. */
|
|
51
|
+
close() {
|
|
52
|
+
if (!this.hasMenuTarget) return;
|
|
53
|
+
this.menuTarget.hidden = true;
|
|
54
|
+
if (this.hasTriggerTarget) {
|
|
55
|
+
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Whether the menu is currently visible. */
|
|
59
|
+
get #isOpen() {
|
|
60
|
+
return this.hasMenuTarget && !this.menuTarget.hidden;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export { DropdownController };
|
|
65
|
+
//# sourceMappingURL=dropdown_controller.js.map
|
|
66
|
+
//# sourceMappingURL=dropdown_controller.js.map
|