stimeo-ui 0.1.0.pre.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +72 -0
- data/dist/controllers/accordion_controller.js +76 -0
- data/dist/controllers/announcer_controller.js +184 -0
- data/dist/controllers/aspect_ratio_controller.js +36 -0
- data/dist/controllers/auto_submit_controller.js +147 -0
- data/dist/controllers/avatar_controller.js +66 -0
- data/dist/controllers/breadcrumb_controller.js +123 -0
- data/dist/controllers/bulk_select_controller.js +104 -0
- data/dist/controllers/calendar_controller.js +394 -0
- data/dist/controllers/character_counter_controller.js +179 -0
- data/dist/controllers/checkbox_controller.js +73 -0
- data/dist/controllers/combobox_controller.js +186 -0
- data/dist/controllers/command_palette_controller.js +381 -0
- data/dist/controllers/conditional_fields_controller.js +112 -0
- data/dist/controllers/confirm_controller.js +276 -0
- data/dist/controllers/context_menu_controller.js +112 -0
- data/dist/controllers/countdown_controller.js +202 -0
- data/dist/controllers/dialog_controller.js +207 -0
- data/dist/controllers/direct_upload_controller.js +212 -0
- data/dist/controllers/dirty_form_controller.js +128 -0
- data/dist/controllers/dropdown_controller.js +66 -0
- data/dist/controllers/empty_state_controller.js +67 -0
- data/dist/controllers/flash_controller.js +221 -0
- data/dist/controllers/focus_controller.js +216 -0
- data/dist/controllers/form_field_controller.js +154 -0
- data/dist/controllers/form_validation_controller.js +202 -0
- data/dist/controllers/frame_loading_controller.js +177 -0
- data/dist/controllers/highlight_controller.js +107 -0
- data/dist/controllers/hover_card_controller.js +165 -0
- data/dist/controllers/idle_controller.js +141 -0
- data/dist/controllers/input_mask_controller.js +166 -0
- data/dist/controllers/lazy_frame_controller.js +68 -0
- data/dist/controllers/listbox_controller.js +256 -0
- data/dist/controllers/local_time_controller.js +81 -0
- data/dist/controllers/menu_controller.js +134 -0
- data/dist/controllers/meter_controller.js +96 -0
- data/dist/controllers/nested_form_controller.js +131 -0
- data/dist/controllers/network_status_controller.js +126 -0
- data/dist/controllers/number_input_controller.js +306 -0
- data/dist/controllers/otp_controller.js +201 -0
- data/dist/controllers/overflow_indicator_controller.js +169 -0
- data/dist/controllers/overflow_menu_controller.js +274 -0
- data/dist/controllers/pagination_controller.js +89 -0
- data/dist/controllers/password_strength_controller.js +175 -0
- data/dist/controllers/persist_controller.js +259 -0
- data/dist/controllers/popover_controller.js +94 -0
- data/dist/controllers/portal_controller.js +63 -0
- data/dist/controllers/preview_guard_controller.js +69 -0
- data/dist/controllers/progress_controller.js +93 -0
- data/dist/controllers/radio_group_controller.js +128 -0
- data/dist/controllers/rating_controller.js +179 -0
- data/dist/controllers/relative_time_controller.js +129 -0
- data/dist/controllers/reset_before_cache_controller.js +62 -0
- data/dist/controllers/resizable_controller.js +163 -0
- data/dist/controllers/roving_controller.js +116 -0
- data/dist/controllers/scroll_area_controller.js +183 -0
- data/dist/controllers/scroll_visibility_controller.js +103 -0
- data/dist/controllers/scrollspy_controller.js +171 -0
- data/dist/controllers/skeleton_controller.js +125 -0
- data/dist/controllers/slider_controller.js +109 -0
- data/dist/controllers/spinner_controller.js +164 -0
- data/dist/controllers/step_indicator_controller.js +55 -0
- data/dist/controllers/stepper_controller.js +78 -0
- data/dist/controllers/stick_to_bottom_controller.js +100 -0
- data/dist/controllers/sticky_observer_controller.js +53 -0
- data/dist/controllers/submit_once_controller.js +206 -0
- data/dist/controllers/switch_controller.js +50 -0
- data/dist/controllers/tabs_controller.js +63 -0
- data/dist/controllers/textarea_autosize_controller.js +72 -0
- data/dist/controllers/theme_controller.js +154 -0
- data/dist/controllers/toast_controller.js +310 -0
- data/dist/controllers/toggle_group_controller.js +130 -0
- data/dist/controllers/toolbar_controller.js +113 -0
- data/dist/controllers/tooltip_controller.js +165 -0
- data/dist/controllers/transition_controller.js +203 -0
- data/dist/index.js +12241 -0
- data/dist/positioning/index.js +145 -0
- data/lib/generators/stimeo/install/install_generator.rb +114 -0
- data/lib/generators/stimeo/install/templates/stimeo.js +12 -0
- data/lib/stimeo/ui/version.rb +10 -0
- data/lib/stimeo/ui.rb +19 -0
- data/lib/stimeo-ui.rb +4 -0
- metadata +152 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/breadcrumb_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/layout_observer.ts
|
|
6
|
+
var LayoutObserver = class {
|
|
7
|
+
#callback;
|
|
8
|
+
#resizeObserverFactory;
|
|
9
|
+
#resizeObserver = null;
|
|
10
|
+
#observingViewport = false;
|
|
11
|
+
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
12
|
+
#handleViewportResize = () => {
|
|
13
|
+
this.#callback();
|
|
14
|
+
};
|
|
15
|
+
constructor(callback, options = {}) {
|
|
16
|
+
this.#callback = callback;
|
|
17
|
+
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Starts observing an element's size. Repeated calls observe additional
|
|
21
|
+
* elements through the same shared observer. No-ops when no
|
|
22
|
+
* `ResizeObserver` implementation is available.
|
|
23
|
+
*/
|
|
24
|
+
observe(element) {
|
|
25
|
+
if (!this.#resizeObserverFactory) return;
|
|
26
|
+
if (!this.#resizeObserver) {
|
|
27
|
+
this.#resizeObserver = this.#resizeObserverFactory(() => {
|
|
28
|
+
this.#callback();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
this.#resizeObserver.observe(element);
|
|
32
|
+
}
|
|
33
|
+
/** Stops observing a single element while leaving any others in place. */
|
|
34
|
+
unobserve(element) {
|
|
35
|
+
this.#resizeObserver?.unobserve(element);
|
|
36
|
+
}
|
|
37
|
+
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
38
|
+
observeViewport() {
|
|
39
|
+
if (this.#observingViewport) return;
|
|
40
|
+
this.#observingViewport = true;
|
|
41
|
+
window.addEventListener("resize", this.#handleViewportResize);
|
|
42
|
+
}
|
|
43
|
+
/** Stops observing viewport resizes without affecting element observation. */
|
|
44
|
+
unobserveViewport() {
|
|
45
|
+
if (!this.#observingViewport) return;
|
|
46
|
+
this.#observingViewport = false;
|
|
47
|
+
window.removeEventListener("resize", this.#handleViewportResize);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and
|
|
51
|
+
* removes the viewport listener. Safe to call multiple times. Call this from a
|
|
52
|
+
* controller's `disconnect()`.
|
|
53
|
+
*/
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#resizeObserver?.disconnect();
|
|
56
|
+
this.#resizeObserver = null;
|
|
57
|
+
this.unobserveViewport();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/controllers/breadcrumb_controller.ts
|
|
62
|
+
var BreadcrumbController = class extends Controller {
|
|
63
|
+
static targets = ["list", "collapsible", "ellipsis", "trigger"];
|
|
64
|
+
static actions = ["toggle"];
|
|
65
|
+
static events = ["toggle"];
|
|
66
|
+
/** Whether the trail currently overflows its container. */
|
|
67
|
+
#overflowing = false;
|
|
68
|
+
/** Whether the user has expanded the collapsed items via the disclosure. */
|
|
69
|
+
#expanded = false;
|
|
70
|
+
#layout = new LayoutObserver(() => this.#update());
|
|
71
|
+
/** Starts observing for overflow and renders the initial state. */
|
|
72
|
+
connect() {
|
|
73
|
+
if (this.hasListTarget) this.#layout.observe(this.listTarget);
|
|
74
|
+
this.#layout.observeViewport();
|
|
75
|
+
this.#update();
|
|
76
|
+
}
|
|
77
|
+
/** Releases the resize observation (Turbo navigation included). */
|
|
78
|
+
disconnect() {
|
|
79
|
+
this.#layout.disconnect();
|
|
80
|
+
}
|
|
81
|
+
/** Expands or re-collapses the trail and dispatches `toggle`. */
|
|
82
|
+
toggle() {
|
|
83
|
+
this.#expanded = !this.#expanded;
|
|
84
|
+
this.#render();
|
|
85
|
+
this.dispatch("toggle", { detail: { expanded: this.#expanded } });
|
|
86
|
+
}
|
|
87
|
+
/** Re-measures overflow and re-renders (e.g. on resize). */
|
|
88
|
+
#update() {
|
|
89
|
+
this.#overflowing = this.#measureOverflow();
|
|
90
|
+
if (!this.#overflowing) this.#expanded = false;
|
|
91
|
+
this.#render();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Measures overflow against the **fully expanded** layout so hiding items does
|
|
95
|
+
* not make the condition oscillate. Reveals every item, then compares the list's
|
|
96
|
+
* own scroll width to its own client width; `#render` immediately re-applies
|
|
97
|
+
* the correct hidden state afterward.
|
|
98
|
+
*
|
|
99
|
+
* Both widths are read from the list element itself (not the host `nav`) so the
|
|
100
|
+
* check is independent of any padding/border on the host — comparing against the
|
|
101
|
+
* host's `clientWidth` would over-report the available space by its padding and
|
|
102
|
+
* miss real overflow in a padded container.
|
|
103
|
+
*/
|
|
104
|
+
#measureOverflow() {
|
|
105
|
+
if (!this.hasListTarget) return false;
|
|
106
|
+
for (const item of this.collapsibleTargets) item.hidden = false;
|
|
107
|
+
if (this.hasEllipsisTarget) this.ellipsisTarget.hidden = true;
|
|
108
|
+
return this.listTarget.scrollWidth > this.listTarget.clientWidth;
|
|
109
|
+
}
|
|
110
|
+
/** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */
|
|
111
|
+
#render() {
|
|
112
|
+
const collapsed = this.#overflowing && !this.#expanded;
|
|
113
|
+
for (const item of this.collapsibleTargets) item.hidden = collapsed;
|
|
114
|
+
if (this.hasEllipsisTarget) this.ellipsisTarget.hidden = !this.#overflowing;
|
|
115
|
+
if (this.hasTriggerTarget) {
|
|
116
|
+
this.triggerTarget.setAttribute("aria-expanded", this.#expanded ? "true" : "false");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export { BreadcrumbController };
|
|
122
|
+
//# sourceMappingURL=breadcrumb_controller.js.map
|
|
123
|
+
//# sourceMappingURL=breadcrumb_controller.js.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/bulk_select_controller.ts
|
|
4
|
+
var BulkSelectController = class extends Controller {
|
|
5
|
+
static targets = ["all", "item", "bar", "count", "selectAllPages"];
|
|
6
|
+
static values = {
|
|
7
|
+
totalCount: { type: Number, default: 0 },
|
|
8
|
+
announce: { type: Boolean, default: true }
|
|
9
|
+
};
|
|
10
|
+
static actions = ["clear", "selectAllPages"];
|
|
11
|
+
static events = ["change"];
|
|
12
|
+
/** All-pages mode is a transient UI state; mirrored to `data-all-pages` so it
|
|
13
|
+
* survives a Turbo swap and `connect()` can rehydrate it. */
|
|
14
|
+
#allPagesMode = false;
|
|
15
|
+
/** Last emitted figures, so a recompute dispatches `change` only on real change. */
|
|
16
|
+
#lastCount = -1;
|
|
17
|
+
#lastAllPages = false;
|
|
18
|
+
/** Delegated `change` handler covering the select-all box and every row. */
|
|
19
|
+
#onChange = (event) => {
|
|
20
|
+
const target = event.target;
|
|
21
|
+
if (!target) return;
|
|
22
|
+
if (this.hasAllTarget && target === this.allTarget) {
|
|
23
|
+
this.#applyAll();
|
|
24
|
+
} else if (target.matches('[data-stimeo--bulk-select-target="item"]')) {
|
|
25
|
+
this.#exitAllPages();
|
|
26
|
+
this.#recompute(true);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
connect() {
|
|
30
|
+
this.#allPagesMode = this.element.dataset.allPages === "true";
|
|
31
|
+
this.element.addEventListener("change", this.#onChange);
|
|
32
|
+
this.#recompute(false);
|
|
33
|
+
}
|
|
34
|
+
disconnect() {
|
|
35
|
+
this.element.removeEventListener("change", this.#onChange);
|
|
36
|
+
}
|
|
37
|
+
/** Clears every selection (rows + select-all) and exits all-pages mode. */
|
|
38
|
+
clear() {
|
|
39
|
+
for (const item of this.#items) item.checked = false;
|
|
40
|
+
if (this.hasAllTarget) {
|
|
41
|
+
this.allTarget.checked = false;
|
|
42
|
+
this.allTarget.indeterminate = false;
|
|
43
|
+
}
|
|
44
|
+
this.#exitAllPages();
|
|
45
|
+
this.#recompute(true);
|
|
46
|
+
}
|
|
47
|
+
/** Enters "select all across pages" mode (count shows `totalCount`). */
|
|
48
|
+
selectAllPages() {
|
|
49
|
+
this.#allPagesMode = true;
|
|
50
|
+
this.#recompute(true);
|
|
51
|
+
}
|
|
52
|
+
/** Mirrors the select-all box to every row, then recomputes. */
|
|
53
|
+
#applyAll() {
|
|
54
|
+
if (!this.hasAllTarget) return;
|
|
55
|
+
const { checked } = this.allTarget;
|
|
56
|
+
for (const item of this.#items) item.checked = checked;
|
|
57
|
+
this.#exitAllPages();
|
|
58
|
+
this.#recompute(true);
|
|
59
|
+
}
|
|
60
|
+
#exitAllPages() {
|
|
61
|
+
this.#allPagesMode = false;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Recomputes the count, the select-all checked/indeterminate state, and the bar
|
|
65
|
+
* visibility from the current DOM. Dispatches `change` (when `notify`) only if
|
|
66
|
+
* the emitted count or all-pages flag actually changed.
|
|
67
|
+
*/
|
|
68
|
+
#recompute(notify) {
|
|
69
|
+
const items = this.#items;
|
|
70
|
+
const total = items.length;
|
|
71
|
+
const checked = items.filter((item) => item.checked).length;
|
|
72
|
+
const allPages = this.#allPagesMode;
|
|
73
|
+
if (this.hasAllTarget) {
|
|
74
|
+
this.allTarget.checked = total > 0 && checked === total;
|
|
75
|
+
this.allTarget.indeterminate = checked > 0 && checked < total;
|
|
76
|
+
}
|
|
77
|
+
const count = allPages ? this.totalCountValue : checked;
|
|
78
|
+
const show = allPages || checked > 0;
|
|
79
|
+
if (this.hasBarTarget) {
|
|
80
|
+
this.barTarget.hidden = !show;
|
|
81
|
+
this.barTarget.setAttribute("aria-live", this.announceValue ? "polite" : "off");
|
|
82
|
+
}
|
|
83
|
+
if (this.hasCountTarget) this.countTarget.textContent = String(count);
|
|
84
|
+
this.element.setAttribute("data-selected-count", String(checked));
|
|
85
|
+
if (allPages) this.element.setAttribute("data-all-pages", "true");
|
|
86
|
+
else this.element.removeAttribute("data-all-pages");
|
|
87
|
+
const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;
|
|
88
|
+
this.#lastCount = count;
|
|
89
|
+
this.#lastAllPages = allPages;
|
|
90
|
+
if (notify && changed) {
|
|
91
|
+
this.dispatch("change", { detail: { count, allPages } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Live list of row checkboxes, queried from the DOM so dynamic rows count. */
|
|
95
|
+
get #items() {
|
|
96
|
+
return Array.from(
|
|
97
|
+
this.element.querySelectorAll('[data-stimeo--bulk-select-target="item"]')
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export { BulkSelectController };
|
|
103
|
+
//# sourceMappingURL=bulk_select_controller.js.map
|
|
104
|
+
//# sourceMappingURL=bulk_select_controller.js.map
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/calendar_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/dates.ts
|
|
6
|
+
function toISODateString(date) {
|
|
7
|
+
const year = date.getFullYear();
|
|
8
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
9
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
10
|
+
return `${year}-${month}-${day}`;
|
|
11
|
+
}
|
|
12
|
+
function parseISODateString(dateStr) {
|
|
13
|
+
if (!dateStr) return null;
|
|
14
|
+
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
const [, y, m, d] = match;
|
|
17
|
+
const year = Number(y);
|
|
18
|
+
const month = Number(m);
|
|
19
|
+
const day = Number(d);
|
|
20
|
+
const date = new Date(year, month - 1, day);
|
|
21
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return date;
|
|
25
|
+
}
|
|
26
|
+
function parseISOMonthString(monthStr) {
|
|
27
|
+
if (!monthStr) return null;
|
|
28
|
+
const match = monthStr.match(/^(\d{4})-(\d{2})$/);
|
|
29
|
+
if (!match) return null;
|
|
30
|
+
const [, y, m] = match;
|
|
31
|
+
const month = Number(m);
|
|
32
|
+
if (month < 1 || month > 12) return null;
|
|
33
|
+
return { year: Number(y), month };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/utils/safe_timeout.ts
|
|
37
|
+
var TimerRegistry = class {
|
|
38
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
39
|
+
ids = /* @__PURE__ */ new Set();
|
|
40
|
+
/**
|
|
41
|
+
* Cancels a single tracked timer.
|
|
42
|
+
*
|
|
43
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
44
|
+
* registry), so callers can clear defensively without guarding.
|
|
45
|
+
*/
|
|
46
|
+
clear(id) {
|
|
47
|
+
if (this.ids.delete(id)) {
|
|
48
|
+
this.cancel(id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
53
|
+
* to guarantee no timer outlives the element.
|
|
54
|
+
*/
|
|
55
|
+
clearAll() {
|
|
56
|
+
for (const id of this.ids) {
|
|
57
|
+
this.cancel(id);
|
|
58
|
+
}
|
|
59
|
+
this.ids.clear();
|
|
60
|
+
}
|
|
61
|
+
/** Number of timers currently tracked (pending). */
|
|
62
|
+
get size() {
|
|
63
|
+
return this.ids.size;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
67
|
+
/**
|
|
68
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
69
|
+
*
|
|
70
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
71
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
72
|
+
*/
|
|
73
|
+
set(callback, delay) {
|
|
74
|
+
const id = this.schedule(() => {
|
|
75
|
+
this.ids.delete(id);
|
|
76
|
+
callback();
|
|
77
|
+
}, delay);
|
|
78
|
+
this.ids.add(id);
|
|
79
|
+
return id;
|
|
80
|
+
}
|
|
81
|
+
schedule(callback, delay) {
|
|
82
|
+
return window.setTimeout(callback, delay);
|
|
83
|
+
}
|
|
84
|
+
cancel(id) {
|
|
85
|
+
window.clearTimeout(id);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/controllers/calendar_controller.ts
|
|
90
|
+
var CalendarController = class extends Controller {
|
|
91
|
+
static targets = ["label", "grid", "day"];
|
|
92
|
+
static values = {
|
|
93
|
+
month: { type: String, default: "" },
|
|
94
|
+
selected: { type: String, default: "" },
|
|
95
|
+
min: { type: String, default: "" },
|
|
96
|
+
max: { type: String, default: "" },
|
|
97
|
+
weekStart: { type: Number, default: 0 }
|
|
98
|
+
// 0 = Sunday, 1 = Monday, etc.
|
|
99
|
+
};
|
|
100
|
+
static actions = ["next", "onKeydown", "prev", "selectByClick"];
|
|
101
|
+
static events = ["monthchange", "select"];
|
|
102
|
+
/** The date currently receiving focus in the grid (local time). */
|
|
103
|
+
focusedDate = /* @__PURE__ */ new Date();
|
|
104
|
+
/**
|
|
105
|
+
* Deferred focus moves scheduled after an asynchronous month transition.
|
|
106
|
+
* Tracked so {@link disconnect} can cancel any pending move and a detached
|
|
107
|
+
* controller never steals focus after the element leaves the DOM (Turbo).
|
|
108
|
+
*/
|
|
109
|
+
#focusTimer = new SafeTimeout();
|
|
110
|
+
connect() {
|
|
111
|
+
if (!this.monthValue) {
|
|
112
|
+
const today = /* @__PURE__ */ new Date();
|
|
113
|
+
const monthStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
|
|
114
|
+
this.monthValue = monthStr;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.#initializeFocusedDate();
|
|
118
|
+
this.render();
|
|
119
|
+
}
|
|
120
|
+
/** Cancels any pending deferred focus so it never fires on a detached element. */
|
|
121
|
+
disconnect() {
|
|
122
|
+
this.#focusTimer.clearAll();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Stimulus lifecycle callback triggered automatically when the monthValue changes.
|
|
126
|
+
* Forces a re-render of the date grid and updates labels.
|
|
127
|
+
*/
|
|
128
|
+
monthValueChanged() {
|
|
129
|
+
if (!this.monthValue) return;
|
|
130
|
+
this.#syncFocusedDateWithMonth();
|
|
131
|
+
this.render();
|
|
132
|
+
this.dispatch("monthchange", { detail: { month: this.monthValue } });
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Stimulus lifecycle callback triggered automatically when the selectedValue changes.
|
|
136
|
+
* Re-renders grid cells to update `aria-selected` indicators.
|
|
137
|
+
*/
|
|
138
|
+
selectedValueChanged() {
|
|
139
|
+
if (this.selectedValue) {
|
|
140
|
+
const selected = parseISODateString(this.selectedValue);
|
|
141
|
+
if (selected) {
|
|
142
|
+
this.focusedDate = selected;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.render();
|
|
146
|
+
}
|
|
147
|
+
/** Navigates to the previous month. */
|
|
148
|
+
prev(event) {
|
|
149
|
+
if (event) event.preventDefault();
|
|
150
|
+
this.#shiftMonth(-1);
|
|
151
|
+
}
|
|
152
|
+
/** Navigates to the next month. */
|
|
153
|
+
next(event) {
|
|
154
|
+
if (event) event.preventDefault();
|
|
155
|
+
this.#shiftMonth(1);
|
|
156
|
+
}
|
|
157
|
+
/** Handles day selection when a gridcell is clicked. */
|
|
158
|
+
selectByClick(event) {
|
|
159
|
+
const target = event.target;
|
|
160
|
+
if (!target) return;
|
|
161
|
+
const dayElement = target.closest("[data-stimeo--calendar-target='day']");
|
|
162
|
+
if (!dayElement) return;
|
|
163
|
+
this.selectDayElement(dayElement);
|
|
164
|
+
}
|
|
165
|
+
/** Handles grid cell keyboard navigation and triggers selection. */
|
|
166
|
+
onKeydown(event) {
|
|
167
|
+
const target = event.target;
|
|
168
|
+
if (!target) return;
|
|
169
|
+
const dayElement = target.closest("[data-stimeo--calendar-target='day']");
|
|
170
|
+
if (!dayElement) return;
|
|
171
|
+
const dateStr = dayElement.getAttribute("data-date");
|
|
172
|
+
if (!dateStr) return;
|
|
173
|
+
const date = parseISODateString(dateStr);
|
|
174
|
+
if (!date) return;
|
|
175
|
+
let handled = true;
|
|
176
|
+
let nextDate = new Date(date);
|
|
177
|
+
switch (event.key) {
|
|
178
|
+
case "ArrowLeft":
|
|
179
|
+
nextDate.setDate(nextDate.getDate() - 1);
|
|
180
|
+
break;
|
|
181
|
+
case "ArrowRight":
|
|
182
|
+
nextDate.setDate(nextDate.getDate() + 1);
|
|
183
|
+
break;
|
|
184
|
+
case "ArrowUp":
|
|
185
|
+
nextDate.setDate(nextDate.getDate() - 7);
|
|
186
|
+
break;
|
|
187
|
+
case "ArrowDown":
|
|
188
|
+
nextDate.setDate(nextDate.getDate() + 7);
|
|
189
|
+
break;
|
|
190
|
+
case "PageUp":
|
|
191
|
+
if (event.shiftKey) {
|
|
192
|
+
nextDate = this.#calculateShiftedYearDate(date, -1);
|
|
193
|
+
} else {
|
|
194
|
+
nextDate = this.#calculateShiftedMonthDate(date, -1);
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
case "PageDown":
|
|
198
|
+
if (event.shiftKey) {
|
|
199
|
+
nextDate = this.#calculateShiftedYearDate(date, 1);
|
|
200
|
+
} else {
|
|
201
|
+
nextDate = this.#calculateShiftedMonthDate(date, 1);
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
case "Home":
|
|
205
|
+
nextDate = this.#getStartOfWeekDate(date);
|
|
206
|
+
break;
|
|
207
|
+
case "End":
|
|
208
|
+
nextDate = this.#getEndOfWeekDate(date);
|
|
209
|
+
break;
|
|
210
|
+
case "t":
|
|
211
|
+
case "T": {
|
|
212
|
+
const now = /* @__PURE__ */ new Date();
|
|
213
|
+
nextDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case "Enter":
|
|
217
|
+
case " ":
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
this.selectDayElement(dayElement);
|
|
220
|
+
return;
|
|
221
|
+
default:
|
|
222
|
+
handled = false;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (handled) {
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
this.#focusAndNavigateToDate(nextDate);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Renders the grid days and updates the month/year label. */
|
|
231
|
+
render() {
|
|
232
|
+
const monthInfo = parseISOMonthString(this.monthValue);
|
|
233
|
+
if (!monthInfo) return;
|
|
234
|
+
const { year, month } = monthInfo;
|
|
235
|
+
if (this.hasLabelTarget) {
|
|
236
|
+
const lang = document.documentElement.lang || "en";
|
|
237
|
+
const labelDate = new Date(year, month - 1, 1);
|
|
238
|
+
const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
|
|
239
|
+
this.labelTarget.textContent = formatter.format(labelDate);
|
|
240
|
+
}
|
|
241
|
+
const days = this.#calculateGridDays(year, month);
|
|
242
|
+
const dayElements = this.dayTargets;
|
|
243
|
+
for (let i = 0; i < 42; i++) {
|
|
244
|
+
const el = dayElements[i];
|
|
245
|
+
if (!el) continue;
|
|
246
|
+
const date = days[i];
|
|
247
|
+
if (!date) continue;
|
|
248
|
+
const dateStr = toISODateString(date);
|
|
249
|
+
el.setAttribute("data-date", dateStr);
|
|
250
|
+
el.textContent = String(date.getDate());
|
|
251
|
+
const isOutside = date.getFullYear() !== year || date.getMonth() !== month - 1;
|
|
252
|
+
el.setAttribute("data-outside", String(isOutside));
|
|
253
|
+
const todayStr = toISODateString(/* @__PURE__ */ new Date());
|
|
254
|
+
el.setAttribute("data-today", String(dateStr === todayStr));
|
|
255
|
+
const isSelected = dateStr === this.selectedValue;
|
|
256
|
+
el.setAttribute("aria-selected", String(isSelected));
|
|
257
|
+
const isFocused = toISODateString(this.focusedDate) === dateStr;
|
|
258
|
+
el.setAttribute("tabindex", isFocused ? "0" : "-1");
|
|
259
|
+
const isDisabled = this.#isDateOutOfBounds(dateStr);
|
|
260
|
+
if (isDisabled) {
|
|
261
|
+
el.setAttribute("aria-disabled", "true");
|
|
262
|
+
} else {
|
|
263
|
+
el.removeAttribute("aria-disabled");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
selectDayElement(dayElement) {
|
|
268
|
+
if (dayElement.getAttribute("aria-disabled") === "true") return;
|
|
269
|
+
const dateStr = dayElement.getAttribute("data-date");
|
|
270
|
+
if (!dateStr) return;
|
|
271
|
+
this.selectedValue = dateStr;
|
|
272
|
+
const selected = parseISODateString(dateStr);
|
|
273
|
+
if (selected) this.focusedDate = selected;
|
|
274
|
+
this.render();
|
|
275
|
+
this.dispatch("select", { detail: { date: dateStr } });
|
|
276
|
+
}
|
|
277
|
+
#focusAndNavigateToDate(date) {
|
|
278
|
+
this.focusedDate = date;
|
|
279
|
+
const targetMonthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
280
|
+
const isMonthTransition = targetMonthStr !== this.monthValue;
|
|
281
|
+
if (isMonthTransition) {
|
|
282
|
+
this.monthValue = targetMonthStr;
|
|
283
|
+
} else {
|
|
284
|
+
this.render();
|
|
285
|
+
}
|
|
286
|
+
const focusTarget = () => {
|
|
287
|
+
const dateStr = toISODateString(date);
|
|
288
|
+
const targetEl = this.dayTargets.find((el) => el.getAttribute("data-date") === dateStr);
|
|
289
|
+
targetEl?.focus();
|
|
290
|
+
};
|
|
291
|
+
if (isMonthTransition) {
|
|
292
|
+
this.#focusTimer.set(focusTarget, 0);
|
|
293
|
+
} else {
|
|
294
|
+
focusTarget();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
#isDateOutOfBounds(dateStr) {
|
|
298
|
+
if (this.minValue && dateStr < this.minValue) return true;
|
|
299
|
+
if (this.maxValue && dateStr > this.maxValue) return true;
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
#shiftMonth(delta) {
|
|
303
|
+
const monthInfo = parseISOMonthString(this.monthValue);
|
|
304
|
+
if (!monthInfo) return;
|
|
305
|
+
const nextMonthDate = new Date(monthInfo.year, monthInfo.month - 1 + delta, 1);
|
|
306
|
+
this.monthValue = `${nextMonthDate.getFullYear()}-${String(nextMonthDate.getMonth() + 1).padStart(2, "0")}`;
|
|
307
|
+
}
|
|
308
|
+
#calculateShiftedMonthDate(baseDate, delta) {
|
|
309
|
+
const targetDate = new Date(baseDate.getFullYear(), baseDate.getMonth() + delta, 1);
|
|
310
|
+
const lastDayInTarget = new Date(
|
|
311
|
+
targetDate.getFullYear(),
|
|
312
|
+
targetDate.getMonth() + 1,
|
|
313
|
+
0
|
|
314
|
+
).getDate();
|
|
315
|
+
const clampedDay = Math.min(baseDate.getDate(), lastDayInTarget);
|
|
316
|
+
targetDate.setDate(clampedDay);
|
|
317
|
+
return targetDate;
|
|
318
|
+
}
|
|
319
|
+
#calculateShiftedYearDate(baseDate, delta) {
|
|
320
|
+
const targetDate = new Date(baseDate.getFullYear() + delta, baseDate.getMonth(), 1);
|
|
321
|
+
const lastDayInTarget = new Date(
|
|
322
|
+
targetDate.getFullYear(),
|
|
323
|
+
targetDate.getMonth() + 1,
|
|
324
|
+
0
|
|
325
|
+
).getDate();
|
|
326
|
+
const clampedDay = Math.min(baseDate.getDate(), lastDayInTarget);
|
|
327
|
+
targetDate.setDate(clampedDay);
|
|
328
|
+
return targetDate;
|
|
329
|
+
}
|
|
330
|
+
#getStartOfWeekDate(date) {
|
|
331
|
+
const currentDay = date.getDay();
|
|
332
|
+
const shift = (currentDay - this.weekStartValue + 7) % 7;
|
|
333
|
+
const target = new Date(date);
|
|
334
|
+
target.setDate(date.getDate() - shift);
|
|
335
|
+
return target;
|
|
336
|
+
}
|
|
337
|
+
#getEndOfWeekDate(date) {
|
|
338
|
+
const start = this.#getStartOfWeekDate(date);
|
|
339
|
+
const target = new Date(start);
|
|
340
|
+
target.setDate(start.getDate() + 6);
|
|
341
|
+
return target;
|
|
342
|
+
}
|
|
343
|
+
#initializeFocusedDate() {
|
|
344
|
+
if (this.selectedValue) {
|
|
345
|
+
const selected = parseISODateString(this.selectedValue);
|
|
346
|
+
if (selected) {
|
|
347
|
+
this.focusedDate = selected;
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const monthInfo = parseISOMonthString(this.monthValue);
|
|
352
|
+
if (monthInfo) {
|
|
353
|
+
const today = /* @__PURE__ */ new Date();
|
|
354
|
+
if (today.getFullYear() === monthInfo.year && today.getMonth() === monthInfo.month - 1) {
|
|
355
|
+
this.focusedDate = today;
|
|
356
|
+
} else {
|
|
357
|
+
this.focusedDate = new Date(monthInfo.year, monthInfo.month - 1, 1);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
#syncFocusedDateWithMonth() {
|
|
362
|
+
const monthInfo = parseISOMonthString(this.monthValue);
|
|
363
|
+
if (!monthInfo) return;
|
|
364
|
+
if (this.focusedDate.getFullYear() !== monthInfo.year || this.focusedDate.getMonth() !== monthInfo.month - 1) {
|
|
365
|
+
const today = /* @__PURE__ */ new Date();
|
|
366
|
+
if (today.getFullYear() === monthInfo.year && today.getMonth() === monthInfo.month - 1) {
|
|
367
|
+
this.focusedDate = today;
|
|
368
|
+
} else {
|
|
369
|
+
const targetDate = new Date(monthInfo.year, monthInfo.month - 1, 1);
|
|
370
|
+
const lastDayInTarget = new Date(monthInfo.year, monthInfo.month, 0).getDate();
|
|
371
|
+
const clampedDay = Math.min(this.focusedDate.getDate(), lastDayInTarget);
|
|
372
|
+
targetDate.setDate(clampedDay);
|
|
373
|
+
this.focusedDate = targetDate;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
#calculateGridDays(year, month) {
|
|
378
|
+
const firstDay = new Date(year, month - 1, 1);
|
|
379
|
+
const dayOfWeek = firstDay.getDay();
|
|
380
|
+
const offset = (dayOfWeek - this.weekStartValue + 7) % 7;
|
|
381
|
+
const days = [];
|
|
382
|
+
const current = new Date(firstDay);
|
|
383
|
+
current.setDate(firstDay.getDate() - offset);
|
|
384
|
+
for (let i = 0; i < 42; i++) {
|
|
385
|
+
days.push(new Date(current));
|
|
386
|
+
current.setDate(current.getDate() + 1);
|
|
387
|
+
}
|
|
388
|
+
return days;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
export { CalendarController };
|
|
393
|
+
//# sourceMappingURL=calendar_controller.js.map
|
|
394
|
+
//# sourceMappingURL=calendar_controller.js.map
|