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,74 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/** The outcome of applying a fixed mask to a raw string. */
|
|
4
|
+
interface MaskResult {
|
|
5
|
+
/** The formatted (masked) string. */
|
|
6
|
+
readonly masked: string;
|
|
7
|
+
/** The significant characters only (literals/separators removed). */
|
|
8
|
+
readonly unmasked: string;
|
|
9
|
+
/** Whether every token slot in the pattern is filled. */
|
|
10
|
+
readonly complete: boolean;
|
|
11
|
+
/** Per-output-char flag: true where the char fills a token (not a literal). */
|
|
12
|
+
readonly tokenFlags: readonly boolean[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Applies a fixed mask `pattern` to `value` using `tokens` (placeholder → regex).
|
|
16
|
+
* Non-matching input characters are rejected (skipped); literals are auto-inserted
|
|
17
|
+
* and a typed literal that matches is consumed. Pure and exported for direct testing.
|
|
18
|
+
*/
|
|
19
|
+
declare function applyMask(value: string, pattern: string, tokens: ReadonlyMap<string, RegExp>): MaskResult;
|
|
20
|
+
/**
|
|
21
|
+
* Headless **input mask** — formats a field in place against a fixed pattern
|
|
22
|
+
* (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,
|
|
23
|
+
* rejecting invalid characters, and syncing the raw value to a hidden field. No
|
|
24
|
+
* dedicated APG pattern; Currency Input owns money-specific formatting.
|
|
25
|
+
*
|
|
26
|
+
* Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):
|
|
27
|
+
* <input type="text" inputmode="numeric"
|
|
28
|
+
* data-controller="stimeo--input-mask"
|
|
29
|
+
* data-stimeo--input-mask-pattern-value="999-9999"
|
|
30
|
+
* data-action="input->stimeo--input-mask#format">
|
|
31
|
+
* <input type="hidden" name="zip" data-stimeo--input-mask-unmask>
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Behavior only and **idempotent** — the formatted value lives only in the input and
|
|
35
|
+
* the hidden field (no module-scope state), so `connect()` re-formats the existing
|
|
36
|
+
* value and is stable across Turbo restore/morph. The controller sits on the
|
|
37
|
+
* `<input>` itself (a void element), so the raw-value sink is not a Stimulus target;
|
|
38
|
+
* it is the `[data-stimeo--input-mask-unmask]` field resolved nearest-first within
|
|
39
|
+
* the same form (an explicit pairing by the sink attribute's value naming the
|
|
40
|
+
* input's `id` wins; otherwise the closest ancestor's value-less sink) — when one
|
|
41
|
+
* form holds several masked inputs, wrap each input+sink pair in a container or
|
|
42
|
+
* pair them by `id`. Formatting never steals keys: it rejects disallowed characters
|
|
43
|
+
* silently and keeps the caret on insert, Backspace, and range replacement
|
|
44
|
+
* (WCAG 2.2 3.3.2 / 1.3.5; the expected format is the consumer's `aria-describedby`).
|
|
45
|
+
* It reflects `data-mask-complete` / `data-mask-empty` and dispatches
|
|
46
|
+
* `stimeo--input-mask:change` only when the value actually changes.
|
|
47
|
+
*/
|
|
48
|
+
declare class InputMaskController extends Controller<HTMLInputElement> {
|
|
49
|
+
#private;
|
|
50
|
+
static values: {
|
|
51
|
+
pattern: {
|
|
52
|
+
type: StringConstructor;
|
|
53
|
+
default: string;
|
|
54
|
+
};
|
|
55
|
+
tokens: {
|
|
56
|
+
type: ObjectConstructor;
|
|
57
|
+
default: {};
|
|
58
|
+
};
|
|
59
|
+
unmaskToHidden: {
|
|
60
|
+
type: BooleanConstructor;
|
|
61
|
+
default: boolean;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
static actions: readonly ["format"];
|
|
65
|
+
static events: readonly ["change"];
|
|
66
|
+
patternValue: string;
|
|
67
|
+
tokensValue: Record<string, string>;
|
|
68
|
+
unmaskToHiddenValue: boolean;
|
|
69
|
+
connect(): void;
|
|
70
|
+
/** Formats the field on input, preserving the caret. Bound via `data-action`. */
|
|
71
|
+
format(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { InputMaskController, type MaskResult, applyMask };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/input_mask_controller.ts
|
|
4
|
+
var DEFAULT_TOKENS = {
|
|
5
|
+
"9": "\\d",
|
|
6
|
+
a: "[A-Za-z]",
|
|
7
|
+
"*": "[A-Za-z0-9]"
|
|
8
|
+
};
|
|
9
|
+
var UNMASK_ATTR = "data-stimeo--input-mask-unmask";
|
|
10
|
+
function applyMask(value, pattern, tokens) {
|
|
11
|
+
let masked = "";
|
|
12
|
+
let unmasked = "";
|
|
13
|
+
const tokenFlags = [];
|
|
14
|
+
let valueIndex = 0;
|
|
15
|
+
let totalTokens = 0;
|
|
16
|
+
for (const patternChar of pattern) {
|
|
17
|
+
const regex = tokens.get(patternChar);
|
|
18
|
+
if (regex) totalTokens += 1;
|
|
19
|
+
if (valueIndex >= value.length) continue;
|
|
20
|
+
if (regex) {
|
|
21
|
+
while (valueIndex < value.length && !regex.test(value[valueIndex] ?? "")) valueIndex += 1;
|
|
22
|
+
const char = value[valueIndex];
|
|
23
|
+
if (char === void 0) continue;
|
|
24
|
+
masked += char;
|
|
25
|
+
unmasked += char;
|
|
26
|
+
tokenFlags.push(true);
|
|
27
|
+
valueIndex += 1;
|
|
28
|
+
} else {
|
|
29
|
+
masked += patternChar;
|
|
30
|
+
tokenFlags.push(false);
|
|
31
|
+
if (value[valueIndex] === patternChar) valueIndex += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
masked,
|
|
36
|
+
unmasked,
|
|
37
|
+
complete: totalTokens > 0 && unmasked.length === totalTokens,
|
|
38
|
+
tokenFlags
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
var InputMaskController = class extends Controller {
|
|
42
|
+
static values = {
|
|
43
|
+
pattern: { type: String, default: "" },
|
|
44
|
+
tokens: { type: Object, default: {} },
|
|
45
|
+
unmaskToHidden: { type: Boolean, default: true }
|
|
46
|
+
};
|
|
47
|
+
static actions = ["format"];
|
|
48
|
+
static events = ["change"];
|
|
49
|
+
connect() {
|
|
50
|
+
this.#apply();
|
|
51
|
+
}
|
|
52
|
+
/** Formats the field on input, preserving the caret. Bound via `data-action`. */
|
|
53
|
+
format() {
|
|
54
|
+
this.#apply();
|
|
55
|
+
}
|
|
56
|
+
/** Core reformat: mask the current value, restore the caret, sync, and announce. */
|
|
57
|
+
#apply() {
|
|
58
|
+
if (!this.patternValue) return;
|
|
59
|
+
const input = this.element;
|
|
60
|
+
const previous = input.value;
|
|
61
|
+
const caret = input.selectionStart ?? previous.length;
|
|
62
|
+
const tokens = this.#tokenRegexes();
|
|
63
|
+
const significant = this.#countSignificant(previous.slice(0, caret), tokens);
|
|
64
|
+
const result = applyMask(previous, this.patternValue, tokens);
|
|
65
|
+
input.value = result.masked;
|
|
66
|
+
this.#restoreCaret(input, result.tokenFlags, significant);
|
|
67
|
+
if (this.unmaskToHiddenValue) {
|
|
68
|
+
const unmask = this.#unmaskField();
|
|
69
|
+
if (unmask) unmask.value = result.unmasked;
|
|
70
|
+
}
|
|
71
|
+
this.#flag("data-mask-complete", result.complete);
|
|
72
|
+
this.#flag("data-mask-empty", result.masked.length === 0);
|
|
73
|
+
if (result.masked !== previous) {
|
|
74
|
+
this.dispatch("change", {
|
|
75
|
+
detail: { masked: result.masked, unmasked: result.unmasked, complete: result.complete }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Places the caret after the `n`-th token char (skipping following literals). */
|
|
80
|
+
#restoreCaret(input, tokenFlags, n) {
|
|
81
|
+
let position;
|
|
82
|
+
if (n <= 0) {
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < tokenFlags.length && !tokenFlags[i]) i += 1;
|
|
85
|
+
position = i;
|
|
86
|
+
} else {
|
|
87
|
+
let seen = 0;
|
|
88
|
+
position = tokenFlags.length;
|
|
89
|
+
for (let i = 0; i < tokenFlags.length; i += 1) {
|
|
90
|
+
if (!tokenFlags[i]) continue;
|
|
91
|
+
seen += 1;
|
|
92
|
+
if (seen === n) {
|
|
93
|
+
let j = i + 1;
|
|
94
|
+
while (j < tokenFlags.length && !tokenFlags[j]) j += 1;
|
|
95
|
+
position = j;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
input.setSelectionRange(position, position);
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The hidden raw-value sink for this input, resolved so several masked inputs
|
|
107
|
+
* can coexist in one form:
|
|
108
|
+
*
|
|
109
|
+
* 1. **Explicit pairing** — a sink whose attribute value names this input's
|
|
110
|
+
* `id` (`data-stimeo--input-mask-unmask="zip"`), looked up across the form
|
|
111
|
+
* (or the document when the input is form-less).
|
|
112
|
+
* 2. **Nearest container** — otherwise, walking up from the input (stopping at
|
|
113
|
+
* the form boundary), the first *value-less* sink in the closest ancestor.
|
|
114
|
+
* Wrapped input+sink pairs each find their own sink, and the single
|
|
115
|
+
* form-level sink keeps working unchanged. A sink claimed by another
|
|
116
|
+
* input's id is never matched here.
|
|
117
|
+
*/
|
|
118
|
+
#unmaskField() {
|
|
119
|
+
const id = this.element.id;
|
|
120
|
+
if (id.length > 0) {
|
|
121
|
+
const scope = this.element.form ?? document;
|
|
122
|
+
const quoted = id.replace(/["\\]/g, "\\$&");
|
|
123
|
+
const paired = scope.querySelector(`input[${UNMASK_ATTR}="${quoted}"]`);
|
|
124
|
+
if (paired) return paired;
|
|
125
|
+
}
|
|
126
|
+
for (let node = this.element.parentElement; node !== null; node = node.parentElement) {
|
|
127
|
+
const sink = node.querySelector(`input[${UNMASK_ATTR}=""]`);
|
|
128
|
+
if (sink) return sink;
|
|
129
|
+
if (node === this.element.form) break;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/** Counts characters in `text` that fill any token (the caret-significant chars). */
|
|
134
|
+
#countSignificant(text, tokens) {
|
|
135
|
+
let count = 0;
|
|
136
|
+
for (const char of text) {
|
|
137
|
+
for (const regex of tokens.values()) {
|
|
138
|
+
if (regex.test(char)) {
|
|
139
|
+
count += 1;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return count;
|
|
145
|
+
}
|
|
146
|
+
/** Compiles the effective token map (defaults with the user `tokens` merged over). */
|
|
147
|
+
#tokenRegexes() {
|
|
148
|
+
const map = /* @__PURE__ */ new Map();
|
|
149
|
+
for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...this.tokensValue })) {
|
|
150
|
+
try {
|
|
151
|
+
map.set(key, new RegExp(`^(?:${source})$`));
|
|
152
|
+
} catch {
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return map;
|
|
156
|
+
}
|
|
157
|
+
/** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
|
|
158
|
+
#flag(name, on) {
|
|
159
|
+
if (on) this.element.setAttribute(name, "true");
|
|
160
|
+
else this.element.removeAttribute(name);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export { InputMaskController, applyMask };
|
|
165
|
+
//# sourceMappingURL=input_mask_controller.js.map
|
|
166
|
+
//# sourceMappingURL=input_mask_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/input_mask_controller.ts"],"names":[],"mappings":"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","file":"input_mask_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** Default placeholder tokens → single-char regex sources (user tokens merge over these). */\nconst DEFAULT_TOKENS: Readonly<Record<string, string>> = {\n \"9\": \"\\\\d\",\n a: \"[A-Za-z]\",\n \"*\": \"[A-Za-z0-9]\",\n};\n\n/** Attribute marking the hidden raw-value sink; its value may name the paired input's `id`. */\nconst UNMASK_ATTR = \"data-stimeo--input-mask-unmask\";\n\n/** The outcome of applying a fixed mask to a raw string. */\nexport interface MaskResult {\n /** The formatted (masked) string. */\n readonly masked: string;\n /** The significant characters only (literals/separators removed). */\n readonly unmasked: string;\n /** Whether every token slot in the pattern is filled. */\n readonly complete: boolean;\n /** Per-output-char flag: true where the char fills a token (not a literal). */\n readonly tokenFlags: readonly boolean[];\n}\n\n/**\n * Applies a fixed mask `pattern` to `value` using `tokens` (placeholder → regex).\n * Non-matching input characters are rejected (skipped); literals are auto-inserted\n * and a typed literal that matches is consumed. Pure and exported for direct testing.\n */\nexport function applyMask(\n value: string,\n pattern: string,\n tokens: ReadonlyMap<string, RegExp>,\n): MaskResult {\n let masked = \"\";\n let unmasked = \"\";\n const tokenFlags: boolean[] = [];\n let valueIndex = 0;\n let totalTokens = 0;\n\n for (const patternChar of pattern) {\n const regex = tokens.get(patternChar);\n if (regex) totalTokens += 1;\n if (valueIndex >= value.length) continue;\n\n if (regex) {\n // Skip rejected characters until one matches this token (or input runs out).\n while (valueIndex < value.length && !regex.test(value[valueIndex] ?? \"\")) valueIndex += 1;\n const char = value[valueIndex];\n if (char === undefined) continue;\n masked += char;\n unmasked += char;\n tokenFlags.push(true);\n valueIndex += 1;\n } else {\n masked += patternChar;\n tokenFlags.push(false);\n if (value[valueIndex] === patternChar) valueIndex += 1;\n }\n }\n\n return {\n masked,\n unmasked,\n complete: totalTokens > 0 && unmasked.length === totalTokens,\n tokenFlags,\n };\n}\n\n/**\n * Headless **input mask** — formats a field in place against a fixed pattern\n * (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,\n * rejecting invalid characters, and syncing the raw value to a hidden field. No\n * dedicated APG pattern; Currency Input owns money-specific formatting.\n *\n * Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):\n * <input type=\"text\" inputmode=\"numeric\"\n * data-controller=\"stimeo--input-mask\"\n * data-stimeo--input-mask-pattern-value=\"999-9999\"\n * data-action=\"input->stimeo--input-mask#format\">\n * <input type=\"hidden\" name=\"zip\" data-stimeo--input-mask-unmask>\n *\n * @remarks\n * Behavior only and **idempotent** — the formatted value lives only in the input and\n * the hidden field (no module-scope state), so `connect()` re-formats the existing\n * value and is stable across Turbo restore/morph. The controller sits on the\n * `<input>` itself (a void element), so the raw-value sink is not a Stimulus target;\n * it is the `[data-stimeo--input-mask-unmask]` field resolved nearest-first within\n * the same form (an explicit pairing by the sink attribute's value naming the\n * input's `id` wins; otherwise the closest ancestor's value-less sink) — when one\n * form holds several masked inputs, wrap each input+sink pair in a container or\n * pair them by `id`. Formatting never steals keys: it rejects disallowed characters\n * silently and keeps the caret on insert, Backspace, and range replacement\n * (WCAG 2.2 3.3.2 / 1.3.5; the expected format is the consumer's `aria-describedby`).\n * It reflects `data-mask-complete` / `data-mask-empty` and dispatches\n * `stimeo--input-mask:change` only when the value actually changes.\n */\nexport class InputMaskController extends Controller<HTMLInputElement> {\n static override values = {\n pattern: { type: String, default: \"\" },\n tokens: { type: Object, default: {} },\n unmaskToHidden: { type: Boolean, default: true },\n };\n static actions = [\"format\"] as const;\n static events = [\"change\"] as const;\n\n declare patternValue: string;\n declare tokensValue: Record<string, string>;\n declare unmaskToHiddenValue: boolean;\n\n override connect(): void {\n // Re-format any server-rendered/restored value so the field is consistent.\n this.#apply();\n }\n\n /** Formats the field on input, preserving the caret. Bound via `data-action`. */\n format(): void {\n this.#apply();\n }\n\n /** Core reformat: mask the current value, restore the caret, sync, and announce. */\n #apply(): void {\n // No pattern → act as a pass-through. Without this guard `applyMask` would\n // produce an empty string and blank a misconfigured field's value.\n if (!this.patternValue) return;\n\n const input = this.element;\n const previous = input.value;\n const caret = input.selectionStart ?? previous.length;\n const tokens = this.#tokenRegexes();\n\n const significant = this.#countSignificant(previous.slice(0, caret), tokens);\n const result = applyMask(previous, this.patternValue, tokens);\n\n input.value = result.masked;\n this.#restoreCaret(input, result.tokenFlags, significant);\n\n if (this.unmaskToHiddenValue) {\n const unmask = this.#unmaskField();\n if (unmask) unmask.value = result.unmasked;\n }\n this.#flag(\"data-mask-complete\", result.complete);\n this.#flag(\"data-mask-empty\", result.masked.length === 0);\n\n if (result.masked !== previous) {\n this.dispatch(\"change\", {\n detail: { masked: result.masked, unmasked: result.unmasked, complete: result.complete },\n });\n }\n }\n\n /** Places the caret after the `n`-th token char (skipping following literals). */\n #restoreCaret(input: HTMLInputElement, tokenFlags: readonly boolean[], n: number): void {\n let position: number;\n if (n <= 0) {\n // Sit after any leading literals, before the first token slot.\n let i = 0;\n while (i < tokenFlags.length && !tokenFlags[i]) i += 1;\n position = i;\n } else {\n let seen = 0;\n position = tokenFlags.length;\n for (let i = 0; i < tokenFlags.length; i += 1) {\n if (!tokenFlags[i]) continue;\n seen += 1;\n if (seen === n) {\n let j = i + 1;\n while (j < tokenFlags.length && !tokenFlags[j]) j += 1;\n position = j;\n break;\n }\n }\n }\n try {\n input.setSelectionRange(position, position);\n } catch {\n /* selection unsupported for this input type — value formatting still applies */\n }\n }\n\n /**\n * The hidden raw-value sink for this input, resolved so several masked inputs\n * can coexist in one form:\n *\n * 1. **Explicit pairing** — a sink whose attribute value names this input's\n * `id` (`data-stimeo--input-mask-unmask=\"zip\"`), looked up across the form\n * (or the document when the input is form-less).\n * 2. **Nearest container** — otherwise, walking up from the input (stopping at\n * the form boundary), the first *value-less* sink in the closest ancestor.\n * Wrapped input+sink pairs each find their own sink, and the single\n * form-level sink keeps working unchanged. A sink claimed by another\n * input's id is never matched here.\n */\n #unmaskField(): HTMLInputElement | null {\n // Runs on every keystroke (via format → #apply), so each step is a single\n // engine-side `querySelector` scoped to `input` — no candidate loops in JS.\n const id = this.element.id;\n if (id.length > 0) {\n const scope: ParentNode = this.element.form ?? document;\n // Escape `\"` / `\\` so an authored id cannot break out of the quoted selector.\n const quoted = id.replace(/[\"\\\\]/g, \"\\\\$&\");\n const paired = scope.querySelector<HTMLInputElement>(`input[${UNMASK_ATTR}=\"${quoted}\"]`);\n if (paired) return paired;\n }\n // `[attr=\"\"]` matches bare (value-less) attributes, so a sink naming another\n // input's id can never be claimed by this fallback.\n for (let node = this.element.parentElement; node !== null; node = node.parentElement) {\n const sink = node.querySelector<HTMLInputElement>(`input[${UNMASK_ATTR}=\"\"]`);\n if (sink) return sink;\n if (node === this.element.form) break;\n }\n return null;\n }\n\n /** Counts characters in `text` that fill any token (the caret-significant chars). */\n #countSignificant(text: string, tokens: ReadonlyMap<string, RegExp>): number {\n let count = 0;\n for (const char of text) {\n for (const regex of tokens.values()) {\n if (regex.test(char)) {\n count += 1;\n break;\n }\n }\n }\n return count;\n }\n\n /** Compiles the effective token map (defaults with the user `tokens` merged over). */\n #tokenRegexes(): Map<string, RegExp> {\n const map = new Map<string, RegExp>();\n for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...this.tokensValue })) {\n try {\n map.set(key, new RegExp(`^(?:${source})$`));\n } catch {\n /* skip an invalid token regex rather than breaking the whole mask */\n }\n }\n return map;\n }\n\n /** Sets a boolean `data-*` flag to `\"true\"` when `on`, else removes it. */\n #flag(name: string, on: boolean): void {\n if (on) this.element.setAttribute(name, \"true\");\n else this.element.removeAttribute(name);\n }\n}\n"]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport
|
|
5
|
+
* (or focus reaches it), to keep the initial render light. Turbo's own `loading="lazy"`
|
|
6
|
+
* fires on *render*, not on viewport entry, so this drives an explicit `IntersectionObserver`
|
|
7
|
+
* with a configurable `rootMargin` for early loading, plus a focus fallback so keyboard /
|
|
8
|
+
* assistive-tech users trigger the load too (no APG pattern).
|
|
9
|
+
*
|
|
10
|
+
* Markup contract (identifier: `stimeo--lazy-frame`):
|
|
11
|
+
* <turbo-frame id="comments" data-controller="stimeo--lazy-frame"
|
|
12
|
+
* data-stimeo--lazy-frame-url-value="/posts/1/comments"
|
|
13
|
+
* data-stimeo--lazy-frame-root-margin-value="200px">Loading…</turbo-frame>
|
|
14
|
+
*
|
|
15
|
+
* The URL is *held* in the `url` value (not on `src`) so Turbo does not load it eagerly;
|
|
16
|
+
* when the frame intersects (within `rootMargin`) or focus enters it, the controller
|
|
17
|
+
* writes `url` to `src` — which starts the Turbo load — marks `data-lazy-loaded`, and
|
|
18
|
+
* emits `load`. With `once` (default) it then stops observing; otherwise re-entry asks
|
|
19
|
+
* Turbo to `reload()` the frame.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Behavior only — the load itself and the frame's content are Turbo's / the server's job,
|
|
23
|
+
* and the loading UI (skeleton / `aria-busy`) belongs to Frame Loading State. The trigger
|
|
24
|
+
* is idempotent (`data-lazy-loaded` guards a double load and is honored on a Turbo cache
|
|
25
|
+
* restore — a frame that already loaded is not observed again). The `IntersectionObserver`
|
|
26
|
+
* and focus listener are released once loaded (when `once`) and on `disconnect()` (Turbo
|
|
27
|
+
* navigation included).
|
|
28
|
+
*/
|
|
29
|
+
declare class LazyFrameController extends Controller<HTMLElement> {
|
|
30
|
+
#private;
|
|
31
|
+
static values: {
|
|
32
|
+
url: {
|
|
33
|
+
type: StringConstructor;
|
|
34
|
+
default: string;
|
|
35
|
+
};
|
|
36
|
+
rootMargin: {
|
|
37
|
+
type: StringConstructor;
|
|
38
|
+
default: string;
|
|
39
|
+
};
|
|
40
|
+
once: {
|
|
41
|
+
type: BooleanConstructor;
|
|
42
|
+
default: boolean;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
static events: readonly ["load"];
|
|
46
|
+
urlValue: string;
|
|
47
|
+
rootMarginValue: string;
|
|
48
|
+
onceValue: boolean;
|
|
49
|
+
connect(): void;
|
|
50
|
+
disconnect(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { LazyFrameController };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/lazy_frame_controller.ts
|
|
4
|
+
var LazyFrameController = class extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
url: { type: String, default: "" },
|
|
7
|
+
rootMargin: { type: String, default: "0px" },
|
|
8
|
+
once: { type: Boolean, default: true }
|
|
9
|
+
};
|
|
10
|
+
static events = ["load"];
|
|
11
|
+
#observer = null;
|
|
12
|
+
#loaded = false;
|
|
13
|
+
/** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */
|
|
14
|
+
#onFocus = () => this.#trigger();
|
|
15
|
+
connect() {
|
|
16
|
+
if (this.element.hasAttribute("data-lazy-loaded")) {
|
|
17
|
+
this.#loaded = true;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!this.urlValue) return;
|
|
21
|
+
this.element.addEventListener("focusin", this.#onFocus);
|
|
22
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
23
|
+
this.#observer = new IntersectionObserver((entries) => this.#onIntersect(entries), {
|
|
24
|
+
rootMargin: this.rootMarginValue
|
|
25
|
+
});
|
|
26
|
+
this.#observer.observe(this.element);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.#stopObserving();
|
|
31
|
+
}
|
|
32
|
+
#onIntersect(entries) {
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (entry.isIntersecting) {
|
|
35
|
+
this.#trigger();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
#trigger() {
|
|
41
|
+
if (!this.#loaded) this.#load();
|
|
42
|
+
else if (!this.onceValue) this.#reload();
|
|
43
|
+
}
|
|
44
|
+
/** Starts the load by writing the held URL to `src`. */
|
|
45
|
+
#load() {
|
|
46
|
+
this.#loaded = true;
|
|
47
|
+
this.element.setAttribute("src", this.urlValue);
|
|
48
|
+
this.element.setAttribute("data-lazy-loaded", "true");
|
|
49
|
+
this.dispatch("load", { detail: { url: this.urlValue } });
|
|
50
|
+
if (this.onceValue) this.#stopObserving();
|
|
51
|
+
}
|
|
52
|
+
/** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */
|
|
53
|
+
#reload() {
|
|
54
|
+
const frame = this.element;
|
|
55
|
+
if (typeof frame.reload !== "function") return;
|
|
56
|
+
frame.reload();
|
|
57
|
+
this.dispatch("load", { detail: { url: this.urlValue } });
|
|
58
|
+
}
|
|
59
|
+
#stopObserving() {
|
|
60
|
+
this.#observer?.disconnect();
|
|
61
|
+
this.#observer = null;
|
|
62
|
+
this.element.removeEventListener("focusin", this.#onFocus);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export { LazyFrameController };
|
|
67
|
+
//# sourceMappingURL=lazy_frame_controller.js.map
|
|
68
|
+
//# sourceMappingURL=lazy_frame_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/lazy_frame_controller.ts"],"names":[],"mappings":"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","file":"lazy_frame_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport\n * (or focus reaches it), to keep the initial render light. Turbo's own `loading=\"lazy\"`\n * fires on *render*, not on viewport entry, so this drives an explicit `IntersectionObserver`\n * with a configurable `rootMargin` for early loading, plus a focus fallback so keyboard /\n * assistive-tech users trigger the load too (no APG pattern).\n *\n * Markup contract (identifier: `stimeo--lazy-frame`):\n * <turbo-frame id=\"comments\" data-controller=\"stimeo--lazy-frame\"\n * data-stimeo--lazy-frame-url-value=\"/posts/1/comments\"\n * data-stimeo--lazy-frame-root-margin-value=\"200px\">Loading…</turbo-frame>\n *\n * The URL is *held* in the `url` value (not on `src`) so Turbo does not load it eagerly;\n * when the frame intersects (within `rootMargin`) or focus enters it, the controller\n * writes `url` to `src` — which starts the Turbo load — marks `data-lazy-loaded`, and\n * emits `load`. With `once` (default) it then stops observing; otherwise re-entry asks\n * Turbo to `reload()` the frame.\n *\n * @remarks\n * Behavior only — the load itself and the frame's content are Turbo's / the server's job,\n * and the loading UI (skeleton / `aria-busy`) belongs to Frame Loading State. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is honored on a Turbo cache\n * restore — a frame that already loaded is not observed again). The `IntersectionObserver`\n * and focus listener are released once loaded (when `once`) and on `disconnect()` (Turbo\n * navigation included).\n */\nexport class LazyFrameController extends Controller<HTMLElement> {\n static override values = {\n url: { type: String, default: \"\" },\n rootMargin: { type: String, default: \"0px\" },\n once: { type: Boolean, default: true },\n };\n static events = [\"load\"] as const;\n\n declare urlValue: string;\n declare rootMarginValue: string;\n declare onceValue: boolean;\n\n #observer: IntersectionObserver | null = null;\n #loaded = false;\n\n /** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */\n readonly #onFocus = (): void => this.#trigger();\n\n override connect(): void {\n // A cache restore may bring back an already-loaded frame; respect it, do not reload.\n if (this.element.hasAttribute(\"data-lazy-loaded\")) {\n this.#loaded = true;\n return;\n }\n if (!this.urlValue) return;\n\n this.element.addEventListener(\"focusin\", this.#onFocus);\n if (typeof IntersectionObserver !== \"undefined\") {\n this.#observer = new IntersectionObserver((entries) => this.#onIntersect(entries), {\n rootMargin: this.rootMarginValue,\n });\n this.#observer.observe(this.element);\n }\n }\n\n override disconnect(): void {\n this.#stopObserving();\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n this.#trigger();\n return;\n }\n }\n }\n\n #trigger(): void {\n if (!this.#loaded) this.#load();\n else if (!this.onceValue) this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\n this.#loaded = true;\n this.element.setAttribute(\"src\", this.urlValue);\n this.element.setAttribute(\"data-lazy-loaded\", \"true\");\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n if (this.onceValue) this.#stopObserving();\n }\n\n /** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */\n #reload(): void {\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Only signal `load` when a reload actually happens. On a non-`<turbo-frame>`\n // host (no `reload()`), firing it would announce a load that never occurred.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n }\n\n #stopObserving(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n }\n}\n"]}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible select-only listbox behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--listbox`):
|
|
7
|
+
* <div data-controller="stimeo--listbox">
|
|
8
|
+
* <span id="lb-label">Favorite fruit</span>
|
|
9
|
+
* <button type="button" role="combobox" aria-haspopup="listbox"
|
|
10
|
+
* aria-expanded="false" aria-controls="lb-list" aria-activedescendant=""
|
|
11
|
+
* aria-labelledby="lb-label lb-value"
|
|
12
|
+
* data-stimeo--listbox-target="trigger"
|
|
13
|
+
* data-action="click->stimeo--listbox#toggle
|
|
14
|
+
* keydown->stimeo--listbox#onTriggerKeydown">
|
|
15
|
+
* <span id="lb-value" data-stimeo--listbox-target="value">Choose…</span>
|
|
16
|
+
* </button>
|
|
17
|
+
* <ul id="lb-list" role="listbox" aria-label="Options" hidden
|
|
18
|
+
* data-stimeo--listbox-target="list">
|
|
19
|
+
* <li id="opt-1" role="option" aria-selected="false" data-value="1"
|
|
20
|
+
* data-stimeo--listbox-target="option"
|
|
21
|
+
* data-action="click->stimeo--listbox#select">Option 1</li>
|
|
22
|
+
* <!-- more options -->
|
|
23
|
+
* </ul>
|
|
24
|
+
* <input type="hidden" data-stimeo--listbox-target="field" />
|
|
25
|
+
* </div>
|
|
26
|
+
*
|
|
27
|
+
* Implements the WAI-ARIA APG **Listbox** pattern in its collapsed
|
|
28
|
+
* (Select-Only Combobox) form. Focus stays on the trigger; the active option is
|
|
29
|
+
* tracked with `aria-activedescendant` rather than by moving DOM focus. For a
|
|
30
|
+
* text-filtered popup use {@link ComboboxController | Combobox}; for multiple
|
|
31
|
+
* selection use Multi-Select.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Behavior only. Static placement is the consumer's CSS; dynamic placement is
|
|
35
|
+
* delegated to the opt-in `stimeo-ui/positioning` module. The look is keyed off
|
|
36
|
+
* `aria-selected` / `data-active`. Because `role="combobox"` is not named by its
|
|
37
|
+
* contents, give the trigger an accessible name via `aria-labelledby` (a visible
|
|
38
|
+
* label plus the value span) or `aria-label`.
|
|
39
|
+
*
|
|
40
|
+
* Behavior provided:
|
|
41
|
+
* - Open/close the list, syncing `aria-expanded` and the list's `hidden`.
|
|
42
|
+
* - `ArrowDown`/`ArrowUp` (wrapping), `Home`/`End`, and printable-character
|
|
43
|
+
* typeahead move the active option; opening picks the selected option (else
|
|
44
|
+
* the first).
|
|
45
|
+
* - Single selection syncs `aria-selected`, reflects the label into the trigger
|
|
46
|
+
* value and the field's value, and dispatches `stimeo--listbox:change` plus a
|
|
47
|
+
* native bubbling `change` on the field when its value actually changed.
|
|
48
|
+
* Making the field a validatable mirror (`<input type="text" hidden required>`
|
|
49
|
+
* instead of `type="hidden"`) lets `stimeo--form-validation` enforce native
|
|
50
|
+
* constraints on the committed value with no extra JavaScript.
|
|
51
|
+
* - `Enter`/`Space` select and close; `Escape` and outside click / `Tab` close;
|
|
52
|
+
* closing via select/Escape returns focus to the trigger.
|
|
53
|
+
*/
|
|
54
|
+
declare class ListboxController extends Controller<HTMLElement> {
|
|
55
|
+
#private;
|
|
56
|
+
static targets: string[];
|
|
57
|
+
static actions: readonly ["close", "onTriggerKeydown", "open", "select", "toggle"];
|
|
58
|
+
static events: readonly ["change"];
|
|
59
|
+
readonly triggerTarget: HTMLElement;
|
|
60
|
+
readonly valueTarget: HTMLElement;
|
|
61
|
+
readonly listTarget: HTMLElement;
|
|
62
|
+
readonly optionTargets: HTMLElement[];
|
|
63
|
+
readonly fieldTarget: HTMLInputElement;
|
|
64
|
+
readonly hasTriggerTarget: boolean;
|
|
65
|
+
readonly hasValueTarget: boolean;
|
|
66
|
+
readonly hasListTarget: boolean;
|
|
67
|
+
readonly hasFieldTarget: boolean;
|
|
68
|
+
/** Starts closed and registers the outside-click listener. */
|
|
69
|
+
connect(): void;
|
|
70
|
+
/** Removes the document listener and clears the typeahead timer. */
|
|
71
|
+
disconnect(): void;
|
|
72
|
+
/**
|
|
73
|
+
* Toggles the list on a real mouse click. Keyboard activation of the
|
|
74
|
+
* `<button>` also fires a click (`detail === 0`); the keydown handler already
|
|
75
|
+
* drives that, so the synthetic click is ignored to avoid double-toggling.
|
|
76
|
+
*/
|
|
77
|
+
toggle(event: MouseEvent): void;
|
|
78
|
+
/** Routes trigger keyboard interaction per the APG select-only model. */
|
|
79
|
+
onTriggerKeydown(event: KeyboardEvent): void;
|
|
80
|
+
/** Selects the clicked option and closes, returning focus to the trigger. */
|
|
81
|
+
select(event: Event): void;
|
|
82
|
+
/** Opens the list and activates the selected option (else the first). */
|
|
83
|
+
open(): void;
|
|
84
|
+
/** Closes the list, clears the active option, and resets the typeahead buffer. */
|
|
85
|
+
close(): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { ListboxController };
|