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,67 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless **bulk select / batch action bar** (no dedicated APG pattern — a
|
|
5
|
+
* composition of a checkbox group and a toolbar). Mirrors the Gmail/admin UX where
|
|
6
|
+
* checking one or more rows reveals a sticky action bar with the selected count,
|
|
7
|
+
* select-all, and clear. Data Grid owns per-row `aria-selected`; this is the
|
|
8
|
+
* contextual action-bar layer on top.
|
|
9
|
+
*
|
|
10
|
+
* Markup contract (identifier: `stimeo--bulk-select`):
|
|
11
|
+
* <div data-controller="stimeo--bulk-select"
|
|
12
|
+
* data-stimeo--bulk-select-total-count-value="128">
|
|
13
|
+
* <input type="checkbox" data-stimeo--bulk-select-target="all">
|
|
14
|
+
* <!-- rows (may be added dynamically; handled via event delegation) -->
|
|
15
|
+
* <input type="checkbox" data-stimeo--bulk-select-target="item">
|
|
16
|
+
* <input type="checkbox" data-stimeo--bulk-select-target="item">
|
|
17
|
+
* <div data-stimeo--bulk-select-target="bar" hidden role="toolbar" aria-live="polite">
|
|
18
|
+
* <span data-stimeo--bulk-select-target="count"></span> selected
|
|
19
|
+
* <button data-stimeo--bulk-select-target="selectAllPages"
|
|
20
|
+
* data-action="click->stimeo--bulk-select#selectAllPages">Select all</button>
|
|
21
|
+
* <button data-action="click->stimeo--bulk-select#clear">Clear</button>
|
|
22
|
+
* </div>
|
|
23
|
+
* </div>
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* Behavior only — it never runs the batch action (that is the consumer's
|
|
27
|
+
* form/Turbo) nor fetches/pages rows. Selection lives **only** in each checkbox's
|
|
28
|
+
* `checked` (no module-scope set), so `connect()` recomputes idempotently from the
|
|
29
|
+
* DOM after a Turbo swap. Row `change` is handled by **delegation** on the
|
|
30
|
+
* container, so dynamically-added rows work without per-row `data-action`.
|
|
31
|
+
* Showing the bar never steals focus
|
|
32
|
+
* (WCAG 2.2 2.4.3); the count rides the bar's own `aria-live` region (WCAG 2.2
|
|
33
|
+
* 4.1.3) — the bar is revealed *before* the count text is written so the change is
|
|
34
|
+
* observed and announced. The delegated listener is removed on `disconnect()`.
|
|
35
|
+
*/
|
|
36
|
+
declare class BulkSelectController extends Controller<HTMLElement> {
|
|
37
|
+
#private;
|
|
38
|
+
static targets: string[];
|
|
39
|
+
static values: {
|
|
40
|
+
totalCount: {
|
|
41
|
+
type: NumberConstructor;
|
|
42
|
+
default: number;
|
|
43
|
+
};
|
|
44
|
+
announce: {
|
|
45
|
+
type: BooleanConstructor;
|
|
46
|
+
default: boolean;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
static actions: readonly ["clear", "selectAllPages"];
|
|
50
|
+
static events: readonly ["change"];
|
|
51
|
+
readonly allTarget: HTMLInputElement;
|
|
52
|
+
readonly barTarget: HTMLElement;
|
|
53
|
+
readonly countTarget: HTMLElement;
|
|
54
|
+
readonly hasAllTarget: boolean;
|
|
55
|
+
readonly hasBarTarget: boolean;
|
|
56
|
+
readonly hasCountTarget: boolean;
|
|
57
|
+
totalCountValue: number;
|
|
58
|
+
announceValue: boolean;
|
|
59
|
+
connect(): void;
|
|
60
|
+
disconnect(): void;
|
|
61
|
+
/** Clears every selection (rows + select-all) and exits all-pages mode. */
|
|
62
|
+
clear(): void;
|
|
63
|
+
/** Enters "select all across pages" mode (count shows `totalCount`). */
|
|
64
|
+
selectAllPages(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { BulkSelectController };
|
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/bulk_select_controller.ts"],"names":[],"mappings":"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","file":"bulk_select_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **bulk select / batch action bar** (no dedicated APG pattern — a\n * composition of a checkbox group and a toolbar). Mirrors the Gmail/admin UX where\n * checking one or more rows reveals a sticky action bar with the selected count,\n * select-all, and clear. Data Grid owns per-row `aria-selected`; this is the\n * contextual action-bar layer on top.\n *\n * Markup contract (identifier: `stimeo--bulk-select`):\n * <div data-controller=\"stimeo--bulk-select\"\n * data-stimeo--bulk-select-total-count-value=\"128\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"all\">\n * <!-- rows (may be added dynamically; handled via event delegation) -->\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <div data-stimeo--bulk-select-target=\"bar\" hidden role=\"toolbar\" aria-live=\"polite\">\n * <span data-stimeo--bulk-select-target=\"count\"></span> selected\n * <button data-stimeo--bulk-select-target=\"selectAllPages\"\n * data-action=\"click->stimeo--bulk-select#selectAllPages\">Select all</button>\n * <button data-action=\"click->stimeo--bulk-select#clear\">Clear</button>\n * </div>\n * </div>\n *\n * @remarks\n * Behavior only — it never runs the batch action (that is the consumer's\n * form/Turbo) nor fetches/pages rows. Selection lives **only** in each checkbox's\n * `checked` (no module-scope set), so `connect()` recomputes idempotently from the\n * DOM after a Turbo swap. Row `change` is handled by **delegation** on the\n * container, so dynamically-added rows work without per-row `data-action`.\n * Showing the bar never steals focus\n * (WCAG 2.2 2.4.3); the count rides the bar's own `aria-live` region (WCAG 2.2\n * 4.1.3) — the bar is revealed *before* the count text is written so the change is\n * observed and announced. The delegated listener is removed on `disconnect()`.\n */\nexport class BulkSelectController extends Controller<HTMLElement> {\n static override targets = [\"all\", \"item\", \"bar\", \"count\", \"selectAllPages\"];\n static override values = {\n totalCount: { type: Number, default: 0 },\n announce: { type: Boolean, default: true },\n };\n static actions = [\"clear\", \"selectAllPages\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly allTarget: HTMLInputElement;\n declare readonly barTarget: HTMLElement;\n declare readonly countTarget: HTMLElement;\n declare readonly hasAllTarget: boolean;\n declare readonly hasBarTarget: boolean;\n declare readonly hasCountTarget: boolean;\n\n declare totalCountValue: number;\n declare announceValue: boolean;\n\n /** All-pages mode is a transient UI state; mirrored to `data-all-pages` so it\n * survives a Turbo swap and `connect()` can rehydrate it. */\n #allPagesMode = false;\n /** Last emitted figures, so a recompute dispatches `change` only on real change. */\n #lastCount = -1;\n #lastAllPages = false;\n\n /** Delegated `change` handler covering the select-all box and every row. */\n readonly #onChange = (event: Event): void => {\n const target = event.target as HTMLElement | null;\n if (!target) return;\n if (this.hasAllTarget && target === this.allTarget) {\n this.#applyAll();\n } else if (target.matches('[data-stimeo--bulk-select-target=\"item\"]')) {\n this.#exitAllPages();\n this.#recompute(true);\n }\n };\n\n override connect(): void {\n this.#allPagesMode = this.element.dataset.allPages === \"true\";\n this.element.addEventListener(\"change\", this.#onChange);\n this.#recompute(false);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"change\", this.#onChange);\n }\n\n /** Clears every selection (rows + select-all) and exits all-pages mode. */\n clear(): void {\n for (const item of this.#items) item.checked = false;\n if (this.hasAllTarget) {\n this.allTarget.checked = false;\n this.allTarget.indeterminate = false;\n }\n this.#exitAllPages();\n this.#recompute(true);\n }\n\n /** Enters \"select all across pages\" mode (count shows `totalCount`). */\n selectAllPages(): void {\n this.#allPagesMode = true;\n this.#recompute(true);\n }\n\n /** Mirrors the select-all box to every row, then recomputes. */\n #applyAll(): void {\n if (!this.hasAllTarget) return;\n const { checked } = this.allTarget;\n for (const item of this.#items) item.checked = checked;\n this.#exitAllPages();\n this.#recompute(true);\n }\n\n #exitAllPages(): void {\n this.#allPagesMode = false;\n }\n\n /**\n * Recomputes the count, the select-all checked/indeterminate state, and the bar\n * visibility from the current DOM. Dispatches `change` (when `notify`) only if\n * the emitted count or all-pages flag actually changed.\n */\n #recompute(notify: boolean): void {\n const items = this.#items;\n const total = items.length;\n const checked = items.filter((item) => item.checked).length;\n const allPages = this.#allPagesMode;\n\n if (this.hasAllTarget) {\n this.allTarget.checked = total > 0 && checked === total;\n this.allTarget.indeterminate = checked > 0 && checked < total;\n }\n\n const count = allPages ? this.totalCountValue : checked;\n const show = allPages || checked > 0;\n\n // Reveal the bar BEFORE writing the count so its aria-live region observes the\n // text change (a region revealed after its content changed may not announce).\n if (this.hasBarTarget) {\n this.barTarget.hidden = !show;\n this.barTarget.setAttribute(\"aria-live\", this.announceValue ? \"polite\" : \"off\");\n }\n if (this.hasCountTarget) this.countTarget.textContent = String(count);\n\n this.element.setAttribute(\"data-selected-count\", String(checked));\n if (allPages) this.element.setAttribute(\"data-all-pages\", \"true\");\n else this.element.removeAttribute(\"data-all-pages\");\n\n const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;\n this.#lastCount = count;\n this.#lastAllPages = allPages;\n if (notify && changed) {\n this.dispatch(\"change\", { detail: { count, allPages } });\n }\n }\n\n /** Live list of row checkboxes, queried from the DOM so dynamic rows count. */\n get #items(): HTMLInputElement[] {\n return Array.from(\n this.element.querySelectorAll<HTMLInputElement>('[data-stimeo--bulk-select-target=\"item\"]'),\n );\n }\n}\n"]}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, highly accessible calendar grid behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--calendar`):
|
|
7
|
+
* <div data-controller="stimeo--calendar"
|
|
8
|
+
* data-stimeo--calendar-month-value="2026-05"
|
|
9
|
+
* data-stimeo--calendar-selected-value="2026-05-31">
|
|
10
|
+
* <button data-action="stimeo--calendar#prev">Previous</button>
|
|
11
|
+
* <span data-stimeo--calendar-target="label">May 2026</span>
|
|
12
|
+
* <button data-action="stimeo--calendar#next">Next</button>
|
|
13
|
+
* <table role="grid">
|
|
14
|
+
* <tbody data-stimeo--calendar-target="grid"
|
|
15
|
+
* data-action="keydown->stimeo--calendar#onKeydown
|
|
16
|
+
* click->stimeo--calendar#selectByClick">
|
|
17
|
+
* <!-- Markup must contain exactly 42 day targets (7 days x 6 rows) -->
|
|
18
|
+
* <tr role="row">
|
|
19
|
+
* <td role="gridcell" data-stimeo--calendar-target="day" tabindex="-1"></td>
|
|
20
|
+
* </tr>
|
|
21
|
+
* </tbody>
|
|
22
|
+
* </table>
|
|
23
|
+
* </div>
|
|
24
|
+
*
|
|
25
|
+
* Implements the WAI-ARIA APG **Date Picker Dialog** grid navigation pattern:
|
|
26
|
+
* - Local-aware month/year labels using native `Intl.DateTimeFormat`.
|
|
27
|
+
* - Roving tabindex focus tracking (exactly one focusable day at any time).
|
|
28
|
+
* - Full grid keyboard controls (arrows, PageUp/Down, Home/End, Shift+PageUp/Down).
|
|
29
|
+
* - Automatic month wrapping and date clamping for missing dates (e.g. leap years, 31st to 30th).
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* Behavior only. The controller updates classes, attributes (aria-selected, data-outside, etc.),
|
|
33
|
+
* and text contents dynamically on 42 pre-allocated `day` targets.
|
|
34
|
+
*/
|
|
35
|
+
declare class CalendarController extends Controller<HTMLElement> {
|
|
36
|
+
#private;
|
|
37
|
+
static targets: string[];
|
|
38
|
+
static values: {
|
|
39
|
+
month: {
|
|
40
|
+
type: StringConstructor;
|
|
41
|
+
default: string;
|
|
42
|
+
};
|
|
43
|
+
selected: {
|
|
44
|
+
type: StringConstructor;
|
|
45
|
+
default: string;
|
|
46
|
+
};
|
|
47
|
+
min: {
|
|
48
|
+
type: StringConstructor;
|
|
49
|
+
default: string;
|
|
50
|
+
};
|
|
51
|
+
max: {
|
|
52
|
+
type: StringConstructor;
|
|
53
|
+
default: string;
|
|
54
|
+
};
|
|
55
|
+
weekStart: {
|
|
56
|
+
type: NumberConstructor;
|
|
57
|
+
default: number;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
static actions: readonly ["next", "onKeydown", "prev", "selectByClick"];
|
|
61
|
+
static events: readonly ["monthchange", "select"];
|
|
62
|
+
readonly labelTarget: HTMLElement;
|
|
63
|
+
readonly gridTarget: HTMLElement;
|
|
64
|
+
readonly dayTargets: HTMLElement[];
|
|
65
|
+
readonly hasLabelTarget: boolean;
|
|
66
|
+
readonly hasGridTarget: boolean;
|
|
67
|
+
monthValue: string;
|
|
68
|
+
selectedValue: string;
|
|
69
|
+
minValue: string;
|
|
70
|
+
maxValue: string;
|
|
71
|
+
weekStartValue: number;
|
|
72
|
+
/** The date currently receiving focus in the grid (local time). */
|
|
73
|
+
focusedDate: Date;
|
|
74
|
+
connect(): void;
|
|
75
|
+
/** Cancels any pending deferred focus so it never fires on a detached element. */
|
|
76
|
+
disconnect(): void;
|
|
77
|
+
/**
|
|
78
|
+
* Stimulus lifecycle callback triggered automatically when the monthValue changes.
|
|
79
|
+
* Forces a re-render of the date grid and updates labels.
|
|
80
|
+
*/
|
|
81
|
+
monthValueChanged(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Stimulus lifecycle callback triggered automatically when the selectedValue changes.
|
|
84
|
+
* Re-renders grid cells to update `aria-selected` indicators.
|
|
85
|
+
*/
|
|
86
|
+
selectedValueChanged(): void;
|
|
87
|
+
/** Navigates to the previous month. */
|
|
88
|
+
prev(event?: Event): void;
|
|
89
|
+
/** Navigates to the next month. */
|
|
90
|
+
next(event?: Event): void;
|
|
91
|
+
/** Handles day selection when a gridcell is clicked. */
|
|
92
|
+
selectByClick(event: MouseEvent): void;
|
|
93
|
+
/** Handles grid cell keyboard navigation and triggers selection. */
|
|
94
|
+
onKeydown(event: KeyboardEvent): void;
|
|
95
|
+
/** Renders the grid days and updates the month/year label. */
|
|
96
|
+
render(): void;
|
|
97
|
+
selectDayElement(dayElement: HTMLElement): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { CalendarController };
|
|
@@ -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
|