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,128 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/radio_group_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/roving_tabindex.ts
|
|
6
|
+
var RovingTabindex = class {
|
|
7
|
+
/** Returns the current ordered item elements; called on every operation. */
|
|
8
|
+
#getItems;
|
|
9
|
+
/**
|
|
10
|
+
* @param getItems - Returns the current ordered item elements. Called on every
|
|
11
|
+
* operation so the live target list is always used.
|
|
12
|
+
*/
|
|
13
|
+
constructor(getItems) {
|
|
14
|
+
this.#getItems = getItems;
|
|
15
|
+
}
|
|
16
|
+
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
17
|
+
get activeIndex() {
|
|
18
|
+
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
22
|
+
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
23
|
+
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
24
|
+
* "nothing is currently tabbable".
|
|
25
|
+
*
|
|
26
|
+
* @param index - Position of the item to make tabbable.
|
|
27
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item.
|
|
28
|
+
*/
|
|
29
|
+
setActive(index, { focus = false } = {}) {
|
|
30
|
+
const items = this.#getItems();
|
|
31
|
+
items.forEach((item, i) => {
|
|
32
|
+
item.tabIndex = i === index ? 0 : -1;
|
|
33
|
+
});
|
|
34
|
+
if (focus) items[index]?.focus();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
function rovingMove(current, length, delta, wrap) {
|
|
38
|
+
if (length === 0) return -1;
|
|
39
|
+
const next = current + delta;
|
|
40
|
+
return (next + length) % length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/controllers/radio_group_controller.ts
|
|
44
|
+
var RadioGroupController = class extends Controller {
|
|
45
|
+
static targets = ["radio", "field"];
|
|
46
|
+
static actions = ["onKeydown", "select"];
|
|
47
|
+
static events = ["change"];
|
|
48
|
+
#roving = new RovingTabindex(() => this.radioTargets);
|
|
49
|
+
/** Establishes the roving entry point and reflects the initial selection. */
|
|
50
|
+
connect() {
|
|
51
|
+
const selected = this.#selectedIndex();
|
|
52
|
+
this.#roving.setActive(selected === -1 ? 0 : selected);
|
|
53
|
+
if (selected !== -1) this.#reflectField(this.radioTargets[selected], { silent: true });
|
|
54
|
+
}
|
|
55
|
+
/** Selects the clicked radio. Bound via `data-action` (click). */
|
|
56
|
+
select(event) {
|
|
57
|
+
const index = this.radioTargets.indexOf(event.currentTarget);
|
|
58
|
+
if (index !== -1) this.#selectIndex(index, { focus: false });
|
|
59
|
+
}
|
|
60
|
+
/** Arrow/Home/End/Space navigation with selection-follows-focus. */
|
|
61
|
+
onKeydown(event) {
|
|
62
|
+
const current = this.radioTargets.indexOf(event.currentTarget);
|
|
63
|
+
if (current === -1) return;
|
|
64
|
+
let next = null;
|
|
65
|
+
switch (event.key) {
|
|
66
|
+
case "ArrowDown":
|
|
67
|
+
case "ArrowRight":
|
|
68
|
+
next = rovingMove(current, this.radioTargets.length, 1);
|
|
69
|
+
break;
|
|
70
|
+
case "ArrowUp":
|
|
71
|
+
case "ArrowLeft":
|
|
72
|
+
next = rovingMove(current, this.radioTargets.length, -1);
|
|
73
|
+
break;
|
|
74
|
+
case "Home":
|
|
75
|
+
next = 0;
|
|
76
|
+
break;
|
|
77
|
+
case "End":
|
|
78
|
+
next = this.radioTargets.length - 1;
|
|
79
|
+
break;
|
|
80
|
+
case " ":
|
|
81
|
+
next = current;
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
this.#selectIndex(next, { focus: true });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Checks the radio at `index`, clears the rest, updates the roving Tab stop and
|
|
91
|
+
* the hidden field, and dispatches `change`.
|
|
92
|
+
*/
|
|
93
|
+
#selectIndex(index, { focus }) {
|
|
94
|
+
const radio = this.radioTargets[index];
|
|
95
|
+
if (!radio) return;
|
|
96
|
+
this.radioTargets.forEach((item, i) => {
|
|
97
|
+
item.setAttribute("aria-checked", i === index ? "true" : "false");
|
|
98
|
+
});
|
|
99
|
+
this.#roving.setActive(index, { focus });
|
|
100
|
+
this.#reflectField(radio);
|
|
101
|
+
this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
|
|
102
|
+
}
|
|
103
|
+
/** Index of the currently checked radio, or `-1` if none. */
|
|
104
|
+
#selectedIndex() {
|
|
105
|
+
return this.radioTargets.findIndex((radio) => radio.getAttribute("aria-checked") === "true");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Mirrors a radio's `data-value` onto the hidden field, when present. Fires a
|
|
109
|
+
* native bubbling `change` on a real value change (matching `listbox`, so
|
|
110
|
+
* `auto-submit` and other native-`change` consumers react) — unless `silent`
|
|
111
|
+
* (the initial connect reflection, which is not a user edit).
|
|
112
|
+
*/
|
|
113
|
+
#reflectField(radio, { silent = false } = {}) {
|
|
114
|
+
if (!this.hasFieldTarget || !radio) return;
|
|
115
|
+
const value = this.#radioValue(radio);
|
|
116
|
+
if (this.fieldTarget.value === value) return;
|
|
117
|
+
this.fieldTarget.value = value;
|
|
118
|
+
if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
|
|
119
|
+
}
|
|
120
|
+
/** A radio's submitted value (`data-value`, defaulting to empty). */
|
|
121
|
+
#radioValue(radio) {
|
|
122
|
+
return radio.getAttribute("data-value") ?? "";
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export { RadioGroupController };
|
|
127
|
+
//# sourceMappingURL=radio_group_controller.js.map
|
|
128
|
+
//# sourceMappingURL=radio_group_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/roving_tabindex.ts","../../src/controllers/radio_group_controller.ts"],"names":[],"mappings":"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","file":"radio_group_controller.js","sourcesContent":["/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { RovingTabindex, rovingMove } from \"../utils/roving_tabindex\";\n\n/**\n * Headless, accessible radio-group behavior for **custom** (non-native) radios.\n *\n * Markup contract (identifier: `stimeo--radio-group`):\n * <div data-controller=\"stimeo--radio-group\" role=\"radiogroup\" aria-label=\"Plan\">\n * <div role=\"radio\" aria-checked=\"true\" tabindex=\"0\" data-value=\"basic\"\n * data-stimeo--radio-group-target=\"radio\"\n * data-action=\"click->stimeo--radio-group#select\n * keydown->stimeo--radio-group#onKeydown\">Basic</div>\n * <!-- more radios; exactly one tabindex=0 -->\n * <input type=\"hidden\" data-stimeo--radio-group-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Radio Group** pattern. Use this only for custom\n * radios (e.g. button-styled cards); native `<input type=\"radio\">` already does\n * this and should be preferred when its look suffices.\n *\n * @remarks\n * Behavior only — selection is exposed through `aria-checked`, the single Tab\n * stop through roving `tabindex` ({@link RovingTabindex}); the consumer styles\n * off `[role=\"radio\"][aria-checked=\"true\"]`. Per APG, **selection follows\n * focus**: arrow keys move focus and select in one step.\n *\n * Behavior provided:\n * - Click selects a radio.\n * - `ArrowDown`/`ArrowRight` select the next, `ArrowUp`/`ArrowLeft` the previous\n * (wrapping); `Home`/`End` the first/last; `Space` selects the focused radio.\n * - The selected radio's `data-value` is mirrored to the optional hidden `field`,\n * and `stimeo--radio-group:change` is dispatched on every change.\n */\nexport class RadioGroupController extends Controller<HTMLElement> {\n static override targets = [\"radio\", \"field\"];\n static actions = [\"onKeydown\", \"select\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly radioTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasFieldTarget: boolean;\n\n readonly #roving = new RovingTabindex(() => this.radioTargets);\n\n /** Establishes the roving entry point and reflects the initial selection. */\n override connect(): void {\n const selected = this.#selectedIndex();\n // With no selection, the first radio is the (unchecked) Tab entry point.\n this.#roving.setActive(selected === -1 ? 0 : selected);\n // Reflect the server-rendered selection without firing a native `change`:\n // connect is not a user edit, and the field already carries this value.\n if (selected !== -1) this.#reflectField(this.radioTargets[selected], { silent: true });\n }\n\n /** Selects the clicked radio. Bound via `data-action` (click). */\n select(event: Event): void {\n const index = this.radioTargets.indexOf(event.currentTarget as HTMLElement);\n if (index !== -1) this.#selectIndex(index, { focus: false });\n }\n\n /** Arrow/Home/End/Space navigation with selection-follows-focus. */\n onKeydown(event: KeyboardEvent): void {\n const current = this.radioTargets.indexOf(event.currentTarget as HTMLElement);\n if (current === -1) return;\n\n let next: number | null = null;\n switch (event.key) {\n case \"ArrowDown\":\n case \"ArrowRight\":\n next = rovingMove(current, this.radioTargets.length, 1, \"wrap\");\n break;\n case \"ArrowUp\":\n case \"ArrowLeft\":\n next = rovingMove(current, this.radioTargets.length, -1, \"wrap\");\n break;\n case \"Home\":\n next = 0;\n break;\n case \"End\":\n next = this.radioTargets.length - 1;\n break;\n case \" \":\n next = current;\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.#selectIndex(next, { focus: true });\n }\n\n /**\n * Checks the radio at `index`, clears the rest, updates the roving Tab stop and\n * the hidden field, and dispatches `change`.\n */\n #selectIndex(index: number, { focus }: { focus: boolean }): void {\n const radio = this.radioTargets[index];\n if (!radio) return;\n this.radioTargets.forEach((item, i) => {\n item.setAttribute(\"aria-checked\", i === index ? \"true\" : \"false\");\n });\n this.#roving.setActive(index, { focus });\n this.#reflectField(radio);\n this.dispatch(\"change\", { detail: { value: this.#radioValue(radio), radio } });\n }\n\n /** Index of the currently checked radio, or `-1` if none. */\n #selectedIndex(): number {\n return this.radioTargets.findIndex((radio) => radio.getAttribute(\"aria-checked\") === \"true\");\n }\n\n /**\n * Mirrors a radio's `data-value` onto the hidden field, when present. Fires a\n * native bubbling `change` on a real value change (matching `listbox`, so\n * `auto-submit` and other native-`change` consumers react) — unless `silent`\n * (the initial connect reflection, which is not a user edit).\n */\n #reflectField(\n radio: HTMLElement | undefined,\n { silent = false }: { silent?: boolean } = {},\n ): void {\n if (!this.hasFieldTarget || !radio) return;\n const value = this.#radioValue(radio);\n if (this.fieldTarget.value === value) return;\n this.fieldTarget.value = value;\n if (!silent) this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n\n /** A radio's submitted value (`data-value`, defaulting to empty). */\n #radioValue(radio: HTMLElement): string {\n return radio.getAttribute(\"data-value\") ?? \"\";\n }\n}\n"]}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible rating (star) behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--rating`):
|
|
7
|
+
* <div data-controller="stimeo--rating" role="radiogroup" aria-label="Rating"
|
|
8
|
+
* data-stimeo--rating-value-value="3" data-stimeo--rating-max-value="5">
|
|
9
|
+
* <span role="radio" aria-checked="false" aria-label="1 star" tabindex="-1"
|
|
10
|
+
* data-rating-value="1" data-stimeo--rating-target="symbol"
|
|
11
|
+
* data-action="click->stimeo--rating#select
|
|
12
|
+
* mouseenter->stimeo--rating#preview
|
|
13
|
+
* mouseleave->stimeo--rating#endPreview
|
|
14
|
+
* focus->stimeo--rating#preview
|
|
15
|
+
* blur->stimeo--rating#endPreview
|
|
16
|
+
* keydown->stimeo--rating#onKeydown">★</span>
|
|
17
|
+
* <!-- symbols 2..max; the selected one (or the first when unrated) tabindex=0 -->
|
|
18
|
+
* <input type="hidden" data-stimeo--rating-target="field" />
|
|
19
|
+
* </div>
|
|
20
|
+
*
|
|
21
|
+
* Implements the WAI-ARIA APG **Radio Group** pattern (an ordinal scale): exactly
|
|
22
|
+
* one symbol is `aria-checked`. Unlike a generic radio group it deliberately does
|
|
23
|
+
* **not** wrap — being ordinal, arrows clamp at the bounds.
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* Behavior only — symbols are styled by the consumer off `[aria-checked]` and the
|
|
27
|
+
* `data-rating-hover` fill hook. In `readonly` mode the group becomes
|
|
28
|
+
* `role="img"`: because Stimeo never emits human-readable prose, the accessible
|
|
29
|
+
* name (e.g. "Rated 3 of 5") is the consumer's `aria-label`/`aria-labelledby`.
|
|
30
|
+
*
|
|
31
|
+
* Behavior provided:
|
|
32
|
+
* - Click selects a symbol; clicking the selected symbol clears to 0 when
|
|
33
|
+
* `clearable`.
|
|
34
|
+
* - `ArrowRight`/`ArrowUp` raise and `ArrowLeft`/`ArrowDown` lower the value
|
|
35
|
+
* (clamped, down to 0 when `clearable`); `Home`/`End` jump to min/max;
|
|
36
|
+
* `Space`/`Enter` select the focused symbol.
|
|
37
|
+
* - Hover/focus previews a fill range via `data-rating-hover`; leaving restores
|
|
38
|
+
* the selected range. `stimeo--rating:change` is dispatched on every change.
|
|
39
|
+
*/
|
|
40
|
+
declare class RatingController extends Controller<HTMLElement> {
|
|
41
|
+
#private;
|
|
42
|
+
static targets: string[];
|
|
43
|
+
static values: {
|
|
44
|
+
value: {
|
|
45
|
+
type: NumberConstructor;
|
|
46
|
+
default: number;
|
|
47
|
+
};
|
|
48
|
+
max: {
|
|
49
|
+
type: NumberConstructor;
|
|
50
|
+
default: number;
|
|
51
|
+
};
|
|
52
|
+
clearable: {
|
|
53
|
+
type: BooleanConstructor;
|
|
54
|
+
default: boolean;
|
|
55
|
+
};
|
|
56
|
+
readonly: {
|
|
57
|
+
type: BooleanConstructor;
|
|
58
|
+
default: boolean;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
static actions: readonly ["endPreview", "onKeydown", "preview", "select"];
|
|
62
|
+
static events: readonly ["change"];
|
|
63
|
+
readonly symbolTargets: HTMLElement[];
|
|
64
|
+
readonly fieldTarget: HTMLInputElement;
|
|
65
|
+
readonly hasFieldTarget: boolean;
|
|
66
|
+
valueValue: number;
|
|
67
|
+
maxValue: number;
|
|
68
|
+
clearableValue: boolean;
|
|
69
|
+
readonlyValue: boolean;
|
|
70
|
+
/** Reflects the initial value, or switches to the non-interactive readonly view. */
|
|
71
|
+
connect(): void;
|
|
72
|
+
/** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
|
|
73
|
+
select(event: Event): void;
|
|
74
|
+
/** Previews a fill range on hover/focus. Bound via `data-action` (mouseenter/focus). */
|
|
75
|
+
preview(event: Event): void;
|
|
76
|
+
/** Restores the fill range to the selected value. Bound via `data-action` (mouseleave/blur). */
|
|
77
|
+
endPreview(): void;
|
|
78
|
+
/** Arrow/Home/End/Space keyboard control, clamped (no wrap). */
|
|
79
|
+
onKeydown(event: KeyboardEvent): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { RatingController };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/rating_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/roving_tabindex.ts
|
|
6
|
+
var RovingTabindex = class {
|
|
7
|
+
/** Returns the current ordered item elements; called on every operation. */
|
|
8
|
+
#getItems;
|
|
9
|
+
/**
|
|
10
|
+
* @param getItems - Returns the current ordered item elements. Called on every
|
|
11
|
+
* operation so the live target list is always used.
|
|
12
|
+
*/
|
|
13
|
+
constructor(getItems) {
|
|
14
|
+
this.#getItems = getItems;
|
|
15
|
+
}
|
|
16
|
+
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
17
|
+
get activeIndex() {
|
|
18
|
+
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
22
|
+
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
23
|
+
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
24
|
+
* "nothing is currently tabbable".
|
|
25
|
+
*
|
|
26
|
+
* @param index - Position of the item to make tabbable.
|
|
27
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item.
|
|
28
|
+
*/
|
|
29
|
+
setActive(index, { focus = false } = {}) {
|
|
30
|
+
const items = this.#getItems();
|
|
31
|
+
items.forEach((item, i) => {
|
|
32
|
+
item.tabIndex = i === index ? 0 : -1;
|
|
33
|
+
});
|
|
34
|
+
if (focus) items[index]?.focus();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// src/controllers/rating_controller.ts
|
|
39
|
+
var RatingController = class extends Controller {
|
|
40
|
+
static targets = ["symbol", "field"];
|
|
41
|
+
static values = {
|
|
42
|
+
value: { type: Number, default: 0 },
|
|
43
|
+
max: { type: Number, default: 5 },
|
|
44
|
+
clearable: { type: Boolean, default: true },
|
|
45
|
+
readonly: { type: Boolean, default: false }
|
|
46
|
+
};
|
|
47
|
+
static actions = ["endPreview", "onKeydown", "preview", "select"];
|
|
48
|
+
static events = ["change"];
|
|
49
|
+
#roving = new RovingTabindex(() => this.symbolTargets);
|
|
50
|
+
/** Reflects the initial value, or switches to the non-interactive readonly view. */
|
|
51
|
+
connect() {
|
|
52
|
+
if (this.readonlyValue) {
|
|
53
|
+
this.#applyReadonly();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.#apply(this.#clamp(this.valueValue), { focus: false });
|
|
57
|
+
}
|
|
58
|
+
/** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
|
|
59
|
+
select(event) {
|
|
60
|
+
if (this.readonlyValue) return;
|
|
61
|
+
const value = this.#clamp(this.#symbolValue(event.currentTarget));
|
|
62
|
+
if (this.clearableValue && value === this.valueValue) {
|
|
63
|
+
this.#render(0, { focus: true });
|
|
64
|
+
} else {
|
|
65
|
+
this.#render(value, { focus: false });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Previews a fill range on hover/focus. Bound via `data-action` (mouseenter/focus). */
|
|
69
|
+
preview(event) {
|
|
70
|
+
if (this.readonlyValue) return;
|
|
71
|
+
this.#setFillRange(this.#clamp(this.#symbolValue(event.currentTarget)));
|
|
72
|
+
}
|
|
73
|
+
/** Restores the fill range to the selected value. Bound via `data-action` (mouseleave/blur). */
|
|
74
|
+
endPreview() {
|
|
75
|
+
if (this.readonlyValue) return;
|
|
76
|
+
this.#setFillRange(this.valueValue);
|
|
77
|
+
}
|
|
78
|
+
/** Arrow/Home/End/Space keyboard control, clamped (no wrap). */
|
|
79
|
+
onKeydown(event) {
|
|
80
|
+
if (this.readonlyValue) return;
|
|
81
|
+
let next = null;
|
|
82
|
+
switch (event.key) {
|
|
83
|
+
case "ArrowRight":
|
|
84
|
+
case "ArrowUp":
|
|
85
|
+
next = this.valueValue + 1;
|
|
86
|
+
break;
|
|
87
|
+
case "ArrowLeft":
|
|
88
|
+
case "ArrowDown":
|
|
89
|
+
next = this.valueValue - 1;
|
|
90
|
+
break;
|
|
91
|
+
case "Home":
|
|
92
|
+
next = this.#minValue;
|
|
93
|
+
break;
|
|
94
|
+
case "End":
|
|
95
|
+
next = this.maxValue;
|
|
96
|
+
break;
|
|
97
|
+
case " ":
|
|
98
|
+
case "Enter":
|
|
99
|
+
next = this.#symbolValue(event.currentTarget);
|
|
100
|
+
break;
|
|
101
|
+
default:
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
this.#render(this.#clamp(next), { focus: true });
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Applies `value` (already clamped) everywhere, then dispatches `change`.
|
|
109
|
+
* Use for user-driven changes; on connect call `#apply` directly so
|
|
110
|
+
* initialization mirrors state without emitting an event.
|
|
111
|
+
*/
|
|
112
|
+
#render(value, { focus }) {
|
|
113
|
+
this.#apply(value, { focus });
|
|
114
|
+
this.dispatch("change", { detail: { value } });
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Stores `value`, syncs `aria-checked` and the roving Tab stop, the hidden
|
|
118
|
+
* field, and the fill range — without dispatching `change`. Idempotent and
|
|
119
|
+
* safe on connect (and across Turbo morphing).
|
|
120
|
+
*/
|
|
121
|
+
#apply(value, { focus }) {
|
|
122
|
+
this.valueValue = value;
|
|
123
|
+
this.symbolTargets.forEach((symbol) => {
|
|
124
|
+
symbol.setAttribute(
|
|
125
|
+
"aria-checked",
|
|
126
|
+
value > 0 && this.#symbolValue(symbol) === value ? "true" : "false"
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
this.#roving.setActive(value > 0 ? value - 1 : 0, { focus });
|
|
130
|
+
if (this.hasFieldTarget) this.fieldTarget.value = String(value);
|
|
131
|
+
this.#setFillRange(value);
|
|
132
|
+
}
|
|
133
|
+
/** Marks symbols up to `range` with `data-rating-hover` (the consumer's fill hook). */
|
|
134
|
+
#setFillRange(range) {
|
|
135
|
+
for (const symbol of this.symbolTargets) {
|
|
136
|
+
if (this.#symbolValue(symbol) <= range && range > 0) {
|
|
137
|
+
symbol.setAttribute("data-rating-hover", "");
|
|
138
|
+
} else {
|
|
139
|
+
symbol.removeAttribute("data-rating-hover");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Turns the group into a non-interactive `role="img"` snapshot of the value. */
|
|
144
|
+
#applyReadonly() {
|
|
145
|
+
const value = this.#clamp(this.valueValue);
|
|
146
|
+
this.valueValue = value;
|
|
147
|
+
this.element.setAttribute("role", "img");
|
|
148
|
+
for (const symbol of this.symbolTargets) {
|
|
149
|
+
symbol.removeAttribute("role");
|
|
150
|
+
symbol.setAttribute("aria-hidden", "true");
|
|
151
|
+
symbol.tabIndex = -1;
|
|
152
|
+
}
|
|
153
|
+
if (this.hasFieldTarget) this.fieldTarget.value = String(value);
|
|
154
|
+
this.#setFillRange(value);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Clamps `value` to `[min, max]` (min is 0 when clearable, else 1). The upper
|
|
158
|
+
* bound is also capped at the number of symbols so the roving Tab stop
|
|
159
|
+
* (`value - 1`) always maps to a real symbol — even if the consumer's `max`
|
|
160
|
+
* value and rendered symbol count disagree, the tabbable item is never lost.
|
|
161
|
+
*/
|
|
162
|
+
#clamp(value) {
|
|
163
|
+
const max = Math.min(this.maxValue, this.symbolTargets.length);
|
|
164
|
+
return Math.min(max, Math.max(this.#minValue, value));
|
|
165
|
+
}
|
|
166
|
+
/** Lowest selectable value: 0 when clearable, otherwise 1. */
|
|
167
|
+
get #minValue() {
|
|
168
|
+
return this.clearableValue ? 0 : 1;
|
|
169
|
+
}
|
|
170
|
+
/** A symbol's ordinal value (`data-rating-value`, defaulting to its position). */
|
|
171
|
+
#symbolValue(symbol) {
|
|
172
|
+
const raw = Number(symbol.getAttribute("data-rating-value"));
|
|
173
|
+
return Number.isFinite(raw) && raw > 0 ? raw : this.symbolTargets.indexOf(symbol) + 1;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export { RatingController };
|
|
178
|
+
//# sourceMappingURL=rating_controller.js.map
|
|
179
|
+
//# sourceMappingURL=rating_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/roving_tabindex.ts","../../src/controllers/rating_controller.ts"],"names":[],"mappings":";;;;;AAqBO,IAAM,iBAAN,MAAqB;AAAA;AAAA,EAEjB,SAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMT,YAAY,QAAA,EAA+B;AACzC,IAAA,IAAA,CAAK,SAAA,GAAY,QAAA;AAAA,EACnB;AAAA;AAAA,EAGA,IAAI,WAAA,GAAsB;AACxB,IAAA,OAAO,IAAA,CAAK,WAAU,CAAE,SAAA,CAAU,CAAC,IAAA,KAAS,IAAA,CAAK,aAAa,CAAC,CAAA;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,UAAU,KAAA,EAAe,EAAE,QAAQ,KAAA,EAAM,GAAyB,EAAC,EAAS;AAC1E,IAAA,MAAM,KAAA,GAAQ,KAAK,SAAA,EAAU;AAC7B,IAAA,KAAA,CAAM,OAAA,CAAQ,CAAC,IAAA,EAAM,CAAA,KAAM;AACzB,MAAA,IAAA,CAAK,QAAA,GAAW,CAAA,KAAM,KAAA,GAAQ,CAAA,GAAI,EAAA;AAAA,IACpC,CAAC,CAAA;AACD,IAAA,IAAI,KAAA,EAAO,KAAA,CAAM,KAAK,CAAA,EAAG,KAAA,EAAM;AAAA,EACjC;AACF,CAAA;;;ACdO,IAAM,gBAAA,GAAN,cAA+B,UAAA,CAAwB;AAAA,EAC5D,OAAgB,OAAA,GAAU,CAAC,QAAA,EAAU,OAAO,CAAA;AAAA,EAC5C,OAAgB,MAAA,GAAS;AAAA,IACvB,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAClC,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAChC,SAAA,EAAW,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,IAAA,EAAK;AAAA,IAC1C,QAAA,EAAU,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA;AAAM,GAC5C;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,YAAA,EAAc,WAAA,EAAa,WAAW,QAAQ,CAAA;AAAA,EAChE,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA,EAUhB,OAAA,GAAU,IAAI,cAAA,CAAe,MAAM,KAAK,aAAa,CAAA;AAAA;AAAA,EAGrD,OAAA,GAAgB;AACvB,IAAA,IAAI,KAAK,aAAA,EAAe;AACtB,MAAA,IAAA,CAAK,cAAA,EAAe;AACpB,MAAA;AAAA,IACF;AACA,IAAA,IAAA,CAAK,MAAA,CAAO,KAAK,MAAA,CAAO,IAAA,CAAK,UAAU,CAAA,EAAG,EAAE,KAAA,EAAO,KAAA,EAAO,CAAA;AAAA,EAC5D;AAAA;AAAA,EAGA,OAAO,KAAA,EAAoB;AACzB,IAAA,IAAI,KAAK,aAAA,EAAe;AAGxB,IAAA,MAAM,QAAQ,IAAA,CAAK,MAAA,CAAO,KAAK,YAAA,CAAa,KAAA,CAAM,aAA4B,CAAC,CAAA;AAC/E,IAAA,IAAI,IAAA,CAAK,cAAA,IAAkB,KAAA,KAAU,IAAA,CAAK,UAAA,EAAY;AAEpD,MAAA,IAAA,CAAK,OAAA,CAAQ,CAAA,EAAG,EAAE,KAAA,EAAO,MAAM,CAAA;AAAA,IACjC,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,OAAA,CAAQ,KAAA,EAAO,EAAE,KAAA,EAAO,OAAO,CAAA;AAAA,IACtC;AAAA,EACF;AAAA;AAAA,EAGA,QAAQ,KAAA,EAAoB;AAC1B,IAAA,IAAI,KAAK,aAAA,EAAe;AACxB,IAAA,IAAA,CAAK,aAAA,CAAc,KAAK,MAAA,CAAO,IAAA,CAAK,aAAa,KAAA,CAAM,aAA4B,CAAC,CAAC,CAAA;AAAA,EACvF;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,IAAI,KAAK,aAAA,EAAe;AACxB,IAAA,IAAA,CAAK,aAAA,CAAc,KAAK,UAAU,CAAA;AAAA,EACpC;AAAA;AAAA,EAGA,UAAU,KAAA,EAA4B;AACpC,IAAA,IAAI,KAAK,aAAA,EAAe;AACxB,IAAA,IAAI,IAAA,GAAsB,IAAA;AAC1B,IAAA,QAAQ,MAAM,GAAA;AAAK,MACjB,KAAK,YAAA;AAAA,MACL,KAAK,SAAA;AACH,QAAA,IAAA,GAAO,KAAK,UAAA,GAAa,CAAA;AACzB,QAAA;AAAA,MACF,KAAK,WAAA;AAAA,MACL,KAAK,WAAA;AACH,QAAA,IAAA,GAAO,KAAK,UAAA,GAAa,CAAA;AACzB,QAAA;AAAA,MACF,KAAK,MAAA;AACH,QAAA,IAAA,GAAO,IAAA,CAAK,SAAA;AACZ,QAAA;AAAA,MACF,KAAK,KAAA;AACH,QAAA,IAAA,GAAO,IAAA,CAAK,QAAA;AACZ,QAAA;AAAA,MACF,KAAK,GAAA;AAAA,MACL,KAAK,OAAA;AACH,QAAA,IAAA,GAAO,IAAA,CAAK,YAAA,CAAa,KAAA,CAAM,aAA4B,CAAA;AAC3D,QAAA;AAAA,MACF;AACE,QAAA;AAAA;AAEJ,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAA,CAAK,OAAA,CAAQ,KAAK,MAAA,CAAO,IAAI,GAAG,EAAE,KAAA,EAAO,MAAM,CAAA;AAAA,EACjD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,OAAA,CAAQ,KAAA,EAAe,EAAE,KAAA,EAAM,EAA6B;AAC1D,IAAA,IAAA,CAAK,MAAA,CAAO,KAAA,EAAO,EAAE,KAAA,EAAO,CAAA;AAC5B,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU,EAAE,QAAQ,EAAE,KAAA,IAAS,CAAA;AAAA,EAC/C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAA,CAAO,KAAA,EAAe,EAAE,KAAA,EAAM,EAA6B;AACzD,IAAA,IAAA,CAAK,UAAA,GAAa,KAAA;AAClB,IAAA,IAAA,CAAK,aAAA,CAAc,OAAA,CAAQ,CAAC,MAAA,KAAW;AACrC,MAAA,MAAA,CAAO,YAAA;AAAA,QACL,cAAA;AAAA,QACA,QAAQ,CAAA,IAAK,IAAA,CAAK,aAAa,MAAM,CAAA,KAAM,QAAQ,MAAA,GAAS;AAAA,OAC9D;AAAA,IACF,CAAC,CAAA;AAED,IAAA,IAAA,CAAK,OAAA,CAAQ,UAAU,KAAA,GAAQ,CAAA,GAAI,QAAQ,CAAA,GAAI,CAAA,EAAG,EAAE,KAAA,EAAO,CAAA;AAC3D,IAAA,IAAI,KAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,KAAA,GAAQ,OAAO,KAAK,CAAA;AAC9D,IAAA,IAAA,CAAK,cAAc,KAAK,CAAA;AAAA,EAC1B;AAAA;AAAA,EAGA,cAAc,KAAA,EAAqB;AACjC,IAAA,KAAA,MAAW,MAAA,IAAU,KAAK,aAAA,EAAe;AACvC,MAAA,IAAI,KAAK,YAAA,CAAa,MAAM,CAAA,IAAK,KAAA,IAAS,QAAQ,CAAA,EAAG;AACnD,QAAA,MAAA,CAAO,YAAA,CAAa,qBAAqB,EAAE,CAAA;AAAA,MAC7C,CAAA,MAAO;AACL,QAAA,MAAA,CAAO,gBAAgB,mBAAmB,CAAA;AAAA,MAC5C;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,cAAA,GAAuB;AACrB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,IAAA,CAAK,UAAU,CAAA;AACzC,IAAA,IAAA,CAAK,UAAA,GAAa,KAAA;AAClB,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,MAAA,EAAQ,KAAK,CAAA;AACvC,IAAA,KAAA,MAAW,MAAA,IAAU,KAAK,aAAA,EAAe;AAGvC,MAAA,MAAA,CAAO,gBAAgB,MAAM,CAAA;AAC7B,MAAA,MAAA,CAAO,YAAA,CAAa,eAAe,MAAM,CAAA;AACzC,MAAA,MAAA,CAAO,QAAA,GAAW,EAAA;AAAA,IACpB;AACA,IAAA,IAAI,KAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,KAAA,GAAQ,OAAO,KAAK,CAAA;AAC9D,IAAA,IAAA,CAAK,cAAc,KAAK,CAAA;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,OAAO,KAAA,EAAuB;AAC5B,IAAA,MAAM,MAAM,IAAA,CAAK,GAAA,CAAI,KAAK,QAAA,EAAU,IAAA,CAAK,cAAc,MAAM,CAAA;AAC7D,IAAA,OAAO,IAAA,CAAK,IAAI,GAAA,EAAK,IAAA,CAAK,IAAI,IAAA,CAAK,SAAA,EAAW,KAAK,CAAC,CAAA;AAAA,EACtD;AAAA;AAAA,EAGA,IAAI,SAAA,GAAoB;AACtB,IAAA,OAAO,IAAA,CAAK,iBAAiB,CAAA,GAAI,CAAA;AAAA,EACnC;AAAA;AAAA,EAGA,aAAa,MAAA,EAA6B;AACxC,IAAA,MAAM,GAAA,GAAM,MAAA,CAAO,MAAA,CAAO,YAAA,CAAa,mBAAmB,CAAC,CAAA;AAC3D,IAAA,OAAO,MAAA,CAAO,QAAA,CAAS,GAAG,CAAA,IAAK,GAAA,GAAM,CAAA,GAAI,GAAA,GAAM,IAAA,CAAK,aAAA,CAAc,OAAA,CAAQ,MAAM,CAAA,GAAI,CAAA;AAAA,EACtF;AACF","file":"rating_controller.js","sourcesContent":["/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { RovingTabindex } from \"../utils/roving_tabindex\";\n\n/**\n * Headless, accessible rating (star) behavior.\n *\n * Markup contract (identifier: `stimeo--rating`):\n * <div data-controller=\"stimeo--rating\" role=\"radiogroup\" aria-label=\"Rating\"\n * data-stimeo--rating-value-value=\"3\" data-stimeo--rating-max-value=\"5\">\n * <span role=\"radio\" aria-checked=\"false\" aria-label=\"1 star\" tabindex=\"-1\"\n * data-rating-value=\"1\" data-stimeo--rating-target=\"symbol\"\n * data-action=\"click->stimeo--rating#select\n * mouseenter->stimeo--rating#preview\n * mouseleave->stimeo--rating#endPreview\n * focus->stimeo--rating#preview\n * blur->stimeo--rating#endPreview\n * keydown->stimeo--rating#onKeydown\">★</span>\n * <!-- symbols 2..max; the selected one (or the first when unrated) tabindex=0 -->\n * <input type=\"hidden\" data-stimeo--rating-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Radio Group** pattern (an ordinal scale): exactly\n * one symbol is `aria-checked`. Unlike a generic radio group it deliberately does\n * **not** wrap — being ordinal, arrows clamp at the bounds.\n *\n * @remarks\n * Behavior only — symbols are styled by the consumer off `[aria-checked]` and the\n * `data-rating-hover` fill hook. In `readonly` mode the group becomes\n * `role=\"img\"`: because Stimeo never emits human-readable prose, the accessible\n * name (e.g. \"Rated 3 of 5\") is the consumer's `aria-label`/`aria-labelledby`.\n *\n * Behavior provided:\n * - Click selects a symbol; clicking the selected symbol clears to 0 when\n * `clearable`.\n * - `ArrowRight`/`ArrowUp` raise and `ArrowLeft`/`ArrowDown` lower the value\n * (clamped, down to 0 when `clearable`); `Home`/`End` jump to min/max;\n * `Space`/`Enter` select the focused symbol.\n * - Hover/focus previews a fill range via `data-rating-hover`; leaving restores\n * the selected range. `stimeo--rating:change` is dispatched on every change.\n */\nexport class RatingController extends Controller<HTMLElement> {\n static override targets = [\"symbol\", \"field\"];\n static override values = {\n value: { type: Number, default: 0 },\n max: { type: Number, default: 5 },\n clearable: { type: Boolean, default: true },\n readonly: { type: Boolean, default: false },\n };\n static actions = [\"endPreview\", \"onKeydown\", \"preview\", \"select\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly symbolTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasFieldTarget: boolean;\n declare valueValue: number;\n declare maxValue: number;\n declare clearableValue: boolean;\n declare readonlyValue: boolean;\n\n readonly #roving = new RovingTabindex(() => this.symbolTargets);\n\n /** Reflects the initial value, or switches to the non-interactive readonly view. */\n override connect(): void {\n if (this.readonlyValue) {\n this.#applyReadonly();\n return;\n }\n this.#apply(this.#clamp(this.valueValue), { focus: false });\n }\n\n /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */\n select(event: Event): void {\n if (this.readonlyValue) return;\n // Clamp the clicked value so a malformed data-rating-value can never push the\n // roving Tab stop out of range.\n const value = this.#clamp(this.#symbolValue(event.currentTarget as HTMLElement));\n if (this.clearableValue && value === this.valueValue) {\n // Clicking the selected symbol clears the rating; focus returns to the first.\n this.#render(0, { focus: true });\n } else {\n this.#render(value, { focus: false });\n }\n }\n\n /** Previews a fill range on hover/focus. Bound via `data-action` (mouseenter/focus). */\n preview(event: Event): void {\n if (this.readonlyValue) return;\n this.#setFillRange(this.#clamp(this.#symbolValue(event.currentTarget as HTMLElement)));\n }\n\n /** Restores the fill range to the selected value. Bound via `data-action` (mouseleave/blur). */\n endPreview(): void {\n if (this.readonlyValue) return;\n this.#setFillRange(this.valueValue);\n }\n\n /** Arrow/Home/End/Space keyboard control, clamped (no wrap). */\n onKeydown(event: KeyboardEvent): void {\n if (this.readonlyValue) return;\n let next: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = this.valueValue + 1;\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = this.valueValue - 1;\n break;\n case \"Home\":\n next = this.#minValue;\n break;\n case \"End\":\n next = this.maxValue;\n break;\n case \" \":\n case \"Enter\":\n next = this.#symbolValue(event.currentTarget as HTMLElement);\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#render(this.#clamp(next), { focus: true });\n }\n\n /**\n * Applies `value` (already clamped) everywhere, then dispatches `change`.\n * Use for user-driven changes; on connect call `#apply` directly so\n * initialization mirrors state without emitting an event.\n */\n #render(value: number, { focus }: { focus: boolean }): void {\n this.#apply(value, { focus });\n this.dispatch(\"change\", { detail: { value } });\n }\n\n /**\n * Stores `value`, syncs `aria-checked` and the roving Tab stop, the hidden\n * field, and the fill range — without dispatching `change`. Idempotent and\n * safe on connect (and across Turbo morphing).\n */\n #apply(value: number, { focus }: { focus: boolean }): void {\n this.valueValue = value;\n this.symbolTargets.forEach((symbol) => {\n symbol.setAttribute(\n \"aria-checked\",\n value > 0 && this.#symbolValue(symbol) === value ? \"true\" : \"false\",\n );\n });\n // The selected symbol is the Tab stop; when unrated, the first symbol is.\n this.#roving.setActive(value > 0 ? value - 1 : 0, { focus });\n if (this.hasFieldTarget) this.fieldTarget.value = String(value);\n this.#setFillRange(value);\n }\n\n /** Marks symbols up to `range` with `data-rating-hover` (the consumer's fill hook). */\n #setFillRange(range: number): void {\n for (const symbol of this.symbolTargets) {\n if (this.#symbolValue(symbol) <= range && range > 0) {\n symbol.setAttribute(\"data-rating-hover\", \"\");\n } else {\n symbol.removeAttribute(\"data-rating-hover\");\n }\n }\n }\n\n /** Turns the group into a non-interactive `role=\"img\"` snapshot of the value. */\n #applyReadonly(): void {\n const value = this.#clamp(this.valueValue);\n this.valueValue = value;\n this.element.setAttribute(\"role\", \"img\");\n for (const symbol of this.symbolTargets) {\n // Drop the interactive radio role so the img has no nested interactive\n // descendants; aria-hidden removes them from the tree entirely.\n symbol.removeAttribute(\"role\");\n symbol.setAttribute(\"aria-hidden\", \"true\");\n symbol.tabIndex = -1;\n }\n if (this.hasFieldTarget) this.fieldTarget.value = String(value);\n this.#setFillRange(value);\n }\n\n /**\n * Clamps `value` to `[min, max]` (min is 0 when clearable, else 1). The upper\n * bound is also capped at the number of symbols so the roving Tab stop\n * (`value - 1`) always maps to a real symbol — even if the consumer's `max`\n * value and rendered symbol count disagree, the tabbable item is never lost.\n */\n #clamp(value: number): number {\n const max = Math.min(this.maxValue, this.symbolTargets.length);\n return Math.min(max, Math.max(this.#minValue, value));\n }\n\n /** Lowest selectable value: 0 when clearable, otherwise 1. */\n get #minValue(): number {\n return this.clearableValue ? 0 : 1;\n }\n\n /** A symbol's ordinal value (`data-rating-value`, defaulting to its position). */\n #symbolValue(symbol: HTMLElement): number {\n const raw = Number(symbol.getAttribute(\"data-rating-value\"));\n return Number.isFinite(raw) && raw > 0 ? raw : this.symbolTargets.indexOf(symbol) + 1;\n }\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless relative-time behavior: renders an absolute timestamp as "3 minutes
|
|
5
|
+
* ago" / "in 2 days" and keeps it fresh. No dedicated APG pattern; it follows
|
|
6
|
+
* the HTML `<time>` semantics.
|
|
7
|
+
*
|
|
8
|
+
* Markup contract (identifier: `stimeo--relative-time`):
|
|
9
|
+
* <time data-controller="stimeo--relative-time"
|
|
10
|
+
* datetime="2026-05-30T12:00:00+09:00" title="2026-05-30 12:00"
|
|
11
|
+
* data-stimeo--relative-time-locale-value="ja">2026-05-30 12:00</time>
|
|
12
|
+
*
|
|
13
|
+
* Computes the difference from `datetime` to now and formats it with
|
|
14
|
+
* `Intl.RelativeTimeFormat` (a browser standard — no added dependency). The
|
|
15
|
+
* polling interval widens as the timestamp ages (seconds → minutes → hours →
|
|
16
|
+
* days). Past a `threshold`, it falls back to the authored absolute text.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Behavior only. The machine-readable `datetime` attribute is left untouched
|
|
20
|
+
* while only the visible text updates, and the element is intentionally **not**
|
|
21
|
+
* a live region (silent updates, no announcement interruptions). The polling
|
|
22
|
+
* timer is owned by {@link SafeTimeout} and torn down on `disconnect()` (Turbo
|
|
23
|
+
* navigation included).
|
|
24
|
+
*/
|
|
25
|
+
declare class RelativeTimeController extends Controller<HTMLElement> {
|
|
26
|
+
#private;
|
|
27
|
+
static values: {
|
|
28
|
+
locale: {
|
|
29
|
+
type: StringConstructor;
|
|
30
|
+
default: string;
|
|
31
|
+
};
|
|
32
|
+
threshold: {
|
|
33
|
+
type: NumberConstructor;
|
|
34
|
+
default: number;
|
|
35
|
+
};
|
|
36
|
+
tickInterval: {
|
|
37
|
+
type: NumberConstructor;
|
|
38
|
+
default: number;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
localeValue: string;
|
|
42
|
+
thresholdValue: number;
|
|
43
|
+
tickIntervalValue: number;
|
|
44
|
+
connect(): void;
|
|
45
|
+
disconnect(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { RelativeTimeController };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/relative_time_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/relative_time_controller.ts
|
|
59
|
+
var YEAR_SCALE = { limit: Number.POSITIVE_INFINITY, unit: "year", ms: 315576e5 };
|
|
60
|
+
var UNITS = [
|
|
61
|
+
{ limit: 60, unit: "second", ms: 1e3 },
|
|
62
|
+
{ limit: 3600, unit: "minute", ms: 6e4 },
|
|
63
|
+
{ limit: 86400, unit: "hour", ms: 36e5 },
|
|
64
|
+
{ limit: 604800, unit: "day", ms: 864e5 },
|
|
65
|
+
{ limit: 2629800, unit: "week", ms: 6048e5 },
|
|
66
|
+
{ limit: 31557600, unit: "month", ms: 26298e5 },
|
|
67
|
+
YEAR_SCALE
|
|
68
|
+
];
|
|
69
|
+
var RelativeTimeController = class extends Controller {
|
|
70
|
+
static values = {
|
|
71
|
+
locale: { type: String, default: "" },
|
|
72
|
+
threshold: { type: Number, default: 0 },
|
|
73
|
+
tickInterval: { type: Number, default: 6e4 }
|
|
74
|
+
};
|
|
75
|
+
#timers = new SafeTimeout();
|
|
76
|
+
/** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */
|
|
77
|
+
#targetMs = Number.NaN;
|
|
78
|
+
/** The authored absolute text, restored when the threshold fallback kicks in. */
|
|
79
|
+
#absoluteText = "";
|
|
80
|
+
connect() {
|
|
81
|
+
if (this.element.getAttribute("data-state") !== "relative") {
|
|
82
|
+
this.#absoluteText = (this.element.textContent ?? "").trim();
|
|
83
|
+
}
|
|
84
|
+
this.#targetMs = Date.parse(this.element.getAttribute("datetime") ?? "");
|
|
85
|
+
if (Number.isNaN(this.#targetMs)) return;
|
|
86
|
+
this.#schedule();
|
|
87
|
+
}
|
|
88
|
+
disconnect() {
|
|
89
|
+
this.#timers.clearAll();
|
|
90
|
+
}
|
|
91
|
+
/** Renders the current representation and reschedules unless it is now absolute. */
|
|
92
|
+
#schedule() {
|
|
93
|
+
const nextDelay = this.#applyAndComputeDelay();
|
|
94
|
+
if (nextDelay !== null) {
|
|
95
|
+
this.#timers.set(() => this.#schedule(), nextDelay);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Updates the visible text and returns the next poll delay (ms), or `null`
|
|
100
|
+
* once the absolute fallback is shown (which never changes, so stop polling).
|
|
101
|
+
*/
|
|
102
|
+
#applyAndComputeDelay() {
|
|
103
|
+
const deltaMs = this.#targetMs - Date.now();
|
|
104
|
+
const absSeconds = Math.abs(deltaMs) / 1e3;
|
|
105
|
+
if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
|
|
106
|
+
this.element.textContent = this.#absoluteText;
|
|
107
|
+
this.element.setAttribute("data-state", "absolute");
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
|
|
111
|
+
const value = Math.round(deltaMs / scale.ms);
|
|
112
|
+
this.element.textContent = this.#formatter.format(value, scale.unit);
|
|
113
|
+
this.element.setAttribute("data-state", "relative");
|
|
114
|
+
const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
|
|
115
|
+
return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
116
|
+
}
|
|
117
|
+
/** A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`). */
|
|
118
|
+
get #formatter() {
|
|
119
|
+
return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
|
|
120
|
+
}
|
|
121
|
+
/** Locale precedence: the value, then the element's `lang`, then the document's. */
|
|
122
|
+
get #locale() {
|
|
123
|
+
return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export { RelativeTimeController };
|
|
128
|
+
//# sourceMappingURL=relative_time_controller.js.map
|
|
129
|
+
//# sourceMappingURL=relative_time_controller.js.map
|