stimeo-ui 0.2.1 → 0.3.0
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 +4 -4
- data/CHANGELOG.md +104 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +46 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +27 -3
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +32 -6
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stick_to_bottom_controller.js +2 -1
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +3514 -689
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc8f7abf12a14555f6b1182d81844594d3bf86c772f115338d931e15eb35a43e
|
|
4
|
+
data.tar.gz: fee179d507f73e693b718e8563e3239d7a3fcad20d42d6db4624bdcbdf2a353c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e68c6fd65748f2e637ddd8bd77364f74f4336549fdd2bb47803d5bd0e300c7838c0037850154d040f32aadb7ccab53879dc754acf8bbe99430ddedeb3b102b8f
|
|
7
|
+
data.tar.gz: 833febb4eb631e61d0d700e56a13cfb38fa59465757a4d0c5f9430a8f87967ef9a8cc993e3272922b4794ec88b9ab38c8244a1c7614a904cc1d539bc40cd3e81
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
|
|
8
8
|
change between releases.
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-08-09
|
|
11
|
+
|
|
12
|
+
Minor release focused on consistent keyboard and selection behavior, resilient
|
|
13
|
+
runtime DOM changes, and substantially stronger Inspector diagnostics. No
|
|
14
|
+
controller, target, value, action, event, or package export was removed. The
|
|
15
|
+
Inspector manifest advances from schema v5 to v8, so tools that read the raw
|
|
16
|
+
manifest must accept the new schema and fields.
|
|
17
|
+
|
|
18
|
+
### Upgrade notes
|
|
19
|
+
|
|
20
|
+
- Run `stimeo check` against existing views before deploying. New structural
|
|
21
|
+
checks can report errors for incomplete optional target groups, missing
|
|
22
|
+
companion controllers, undeclared role-bearing targets, and invalid
|
|
23
|
+
cardinality. New ARIA checks can also add warnings without failing the command.
|
|
24
|
+
- In RTL containers, horizontal arrow keys now follow logical inline direction
|
|
25
|
+
across composite widgets. LTR behavior is unchanged.
|
|
26
|
+
- Slider, range-slider, and color-picker pointer/arrow mirroring remains opt-in:
|
|
27
|
+
set their new `logicalTrack` value only when the consuming CSS mirrors the
|
|
28
|
+
track in RTL.
|
|
29
|
+
- calendar's `stimeo--calendar:monthchange` no longer fires while the grid
|
|
30
|
+
settles on its initial month, so code that ran from that first event must now
|
|
31
|
+
run once at initialization. Listeners that re-apply per-date `aria-disabled`
|
|
32
|
+
marks are the common case.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- breadcrumb: a public `update` action for reconciling collapsible state after
|
|
37
|
+
application-driven DOM changes.
|
|
38
|
+
- tree-view: a public `toggle` action for explicitly expanding or collapsing an
|
|
39
|
+
item from application markup.
|
|
40
|
+
- scrollspy: a `focusSection` value for opting into focus movement when a link
|
|
41
|
+
activates a section.
|
|
42
|
+
- slider, range-slider, color-picker: the `logicalTrack` value described above.
|
|
43
|
+
- Inspector schema v6-v8: conditional target requirements and the new
|
|
44
|
+
`missing-companion`, `undeclared-target`, `cardinality-violation`,
|
|
45
|
+
`forbidden-aria`, and `missing-conditional-target` diagnostics. Rules can now
|
|
46
|
+
depend on controller values, element/document conditions, contained targets,
|
|
47
|
+
and ARIA requirement severity.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- accordion, carousel, calendar, combobox, command-palette, context-menu,
|
|
52
|
+
data-grid, date-range-picker, listbox, menu, menubar, multi-select,
|
|
53
|
+
navigation-menu, OTP, radio-group, roving, tabs, tags-input, time-picker,
|
|
54
|
+
toggle-group, toolbar, tree-view, and related horizontal controls now resolve
|
|
55
|
+
Left/Right behavior from the effective text direction. Sortable geometry also
|
|
56
|
+
follows logical inline direction.
|
|
57
|
+
- Arrow-key handlers yield modified browser shortcuts, and composite handlers
|
|
58
|
+
yield events already consumed by a nested widget. IME composition continues to
|
|
59
|
+
take precedence over component shortcuts.
|
|
60
|
+
- Selection widgets normalize controller-owned `aria-selected` state, preserve a
|
|
61
|
+
single active/selected item where the pattern requires one, and reconcile that
|
|
62
|
+
state when options are inserted, removed, or replaced at runtime.
|
|
63
|
+
- menu and overflow-menu delegate item interaction from their containers, so
|
|
64
|
+
dynamically added items work without per-item actions. Existing action markup
|
|
65
|
+
remains compatible.
|
|
66
|
+
- `aria-disabled="true"` items remain discoverable by composite keyboard
|
|
67
|
+
navigation while activation, expansion, and consumer actions are suppressed.
|
|
68
|
+
- Outside pointer listeners for dismissible composites — click and context-menu
|
|
69
|
+
alike — run in the capture phase so nested application code cannot leave an
|
|
70
|
+
open layer stuck by stopping bubbling.
|
|
71
|
+
- Inspector accessible-name diagnostics now follow ARIA's Required, Recommended,
|
|
72
|
+
and conditional levels instead of treating every role alike.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- combobox, command-palette, listbox, and multi-select no longer retain stale
|
|
77
|
+
active IDs, selection, chips, or hidden fields after Turbo morphs and runtime
|
|
78
|
+
target replacement. A disconnect/reconnect in the same task can no longer let
|
|
79
|
+
an old reconciliation microtask consume the fresh generation's pass.
|
|
80
|
+
- breadcrumb fails open when its optional disclosure targets are incomplete and
|
|
81
|
+
restores items that leave the collapsible set instead of leaving them hidden.
|
|
82
|
+
- menu, menubar, navigation-menu, overflow-menu, pagination, and tree-view now
|
|
83
|
+
recover focus consistently across empty lists, disabled fieldsets, hidden
|
|
84
|
+
ancestors, item banking, and runtime item removal.
|
|
85
|
+
- toolbar restores its single tab stop when an enclosing `<fieldset>` is
|
|
86
|
+
re-enabled, so unlocking a form brings the group back into the Tab sequence.
|
|
87
|
+
- multi-select keeps chip focus when the selection was made in an order other
|
|
88
|
+
than the option order. The chips are reconciled against the selected value set
|
|
89
|
+
rather than position by position, and their labels are paired by value.
|
|
90
|
+
- overflow-menu measures items adopted from server-rendered overflow in the bar
|
|
91
|
+
rather than inside the closed menu, so an already-overflowed bar no longer
|
|
92
|
+
returns every item to the bar on first paint.
|
|
93
|
+
- combobox commits a clicked option after its input target is removed instead of
|
|
94
|
+
throwing, matching the tolerance the popup already had for that state.
|
|
95
|
+
- color-picker falls back to the per-channel range when a slider omits
|
|
96
|
+
`aria-valuemin` / `aria-valuemax`, instead of pinning the channel at zero.
|
|
97
|
+
- calendar maintains exactly one roving tab stop while selection and available
|
|
98
|
+
days change.
|
|
99
|
+
- calendar reports `monthchange` from the month it paints, so a malformed
|
|
100
|
+
`month` value can no longer put a raw non-`YYYY-MM` string in `detail.month`.
|
|
101
|
+
The report is driven by the repaint rather than by the `month` value, so a
|
|
102
|
+
fallback month moved by `selected` or by selecting a neighbouring month's cell
|
|
103
|
+
is announced as it happens instead of arriving on a later repaint. Re-applying
|
|
104
|
+
the same month, as a Turbo cache restore does, stays silent.
|
|
105
|
+
- scrollspy re-evaluates links and sections after every supported entry path;
|
|
106
|
+
intersection-based controllers align fallback thresholds, hidden-layout
|
|
107
|
+
handling, and reduced-motion behavior.
|
|
108
|
+
- `stimeo catalog --json` and large `stimeo check --json` reports are no longer
|
|
109
|
+
truncated when stdout is piped; the CLI now lets buffered output flush before
|
|
110
|
+
exiting, and the build audit exercises the real piped catalog.
|
|
111
|
+
|
|
10
112
|
## [0.2.1] - 2026-07-26
|
|
11
113
|
|
|
12
114
|
Patch release from a review of the disclosure and layout components, plus a
|
|
@@ -234,6 +336,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
234
336
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
235
337
|
RubyGems.
|
|
236
338
|
|
|
339
|
+
[0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
|
|
340
|
+
[0.2.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.1
|
|
237
341
|
[0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
|
|
238
342
|
[0.1.0-beta.3]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.3
|
|
239
343
|
[0.1.0-beta.2]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.2
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/accordion_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/arrow_step.ts
|
|
6
|
+
function isReservedArrowChord(event, allow = []) {
|
|
7
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
8
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
// src/controllers/accordion_controller.ts
|
|
4
12
|
var AccordionController = class extends Controller {
|
|
5
13
|
static targets = ["trigger", "panel"];
|
|
@@ -39,6 +47,8 @@ var AccordionController = class extends Controller {
|
|
|
39
47
|
* `.focus()` lands on nothing and navigation appears to stall.
|
|
40
48
|
*/
|
|
41
49
|
onKeydown(event) {
|
|
50
|
+
if (event.defaultPrevented) return;
|
|
51
|
+
if (isReservedArrowChord(event)) return;
|
|
42
52
|
const current = event.currentTarget;
|
|
43
53
|
if (this.triggerTargets.indexOf(current) === -1) return;
|
|
44
54
|
const navigable = this.triggerTargets.filter((trigger) => trigger.closest("[hidden]") === null);
|
|
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/breadcrumb_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/blur_deferral.ts
|
|
6
|
+
var BlurDeferral = class {
|
|
7
|
+
/** Elements currently holding an update back, mapped to their `blur` listener. */
|
|
8
|
+
#pending = /* @__PURE__ */ new Map();
|
|
9
|
+
/** Called after a pending element blurs and has been detached. */
|
|
10
|
+
#onRelease;
|
|
11
|
+
/** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
|
|
12
|
+
constructor(onRelease) {
|
|
13
|
+
this.#onRelease = onRelease;
|
|
14
|
+
}
|
|
15
|
+
/** Number of elements currently holding an update back. */
|
|
16
|
+
get size() {
|
|
17
|
+
return this.#pending.size;
|
|
18
|
+
}
|
|
19
|
+
/** Snapshot of the pending elements, safe to iterate while releasing them. */
|
|
20
|
+
get elements() {
|
|
21
|
+
return [...this.#pending.keys()];
|
|
22
|
+
}
|
|
23
|
+
/** Whether `element` is currently holding an update back. */
|
|
24
|
+
has(element) {
|
|
25
|
+
return this.#pending.has(element);
|
|
26
|
+
}
|
|
27
|
+
/** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
|
|
28
|
+
defer(element) {
|
|
29
|
+
if (this.#pending.has(element)) return;
|
|
30
|
+
const onBlur = () => {
|
|
31
|
+
this.#detach(element);
|
|
32
|
+
this.#onRelease(element);
|
|
33
|
+
};
|
|
34
|
+
this.#pending.set(element, onBlur);
|
|
35
|
+
element.addEventListener("blur", onBlur);
|
|
36
|
+
}
|
|
37
|
+
/** Defers `element` as the only pending entry, cancelling any others. */
|
|
38
|
+
deferOnly(element) {
|
|
39
|
+
for (const pending of this.elements) {
|
|
40
|
+
if (pending !== element) this.#detach(pending);
|
|
41
|
+
}
|
|
42
|
+
this.defer(element);
|
|
43
|
+
}
|
|
44
|
+
/** Cancels `element`'s deferral without completing it; no-ops when not pending. */
|
|
45
|
+
release(element) {
|
|
46
|
+
this.#detach(element);
|
|
47
|
+
}
|
|
48
|
+
/** Cancels every deferral without completing any of them. */
|
|
49
|
+
releaseAll() {
|
|
50
|
+
for (const element of this.elements) this.#detach(element);
|
|
51
|
+
}
|
|
52
|
+
/** Removes the `blur` listener for `element` and forgets it. */
|
|
53
|
+
#detach(element) {
|
|
54
|
+
const onBlur = this.#pending.get(element);
|
|
55
|
+
if (onBlur) element.removeEventListener("blur", onBlur);
|
|
56
|
+
this.#pending.delete(element);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
5
60
|
// src/utils/layout_observer.ts
|
|
6
61
|
var LayoutObserver = class {
|
|
7
62
|
#callback;
|
|
@@ -59,37 +114,152 @@ var LayoutObserver = class {
|
|
|
59
114
|
};
|
|
60
115
|
|
|
61
116
|
// src/controllers/breadcrumb_controller.ts
|
|
117
|
+
var OVERFLOW_EPSILON = 1;
|
|
62
118
|
var BreadcrumbController = class extends Controller {
|
|
63
119
|
static targets = ["list", "collapsible", "ellipsis", "trigger"];
|
|
64
|
-
static actions = ["toggle"];
|
|
120
|
+
static actions = ["toggle", "update"];
|
|
65
121
|
static events = ["toggle"];
|
|
122
|
+
/** Guards target callbacks that can fire outside the connected window. */
|
|
123
|
+
#connected = false;
|
|
66
124
|
/** Whether the trail currently overflows its container. */
|
|
67
125
|
#overflowing = false;
|
|
68
126
|
/** Whether the user has expanded the collapsed items via the disclosure. */
|
|
69
127
|
#expanded = false;
|
|
70
|
-
|
|
128
|
+
/** The list element currently observed (resize + mutations), if any. */
|
|
129
|
+
#observedList = null;
|
|
130
|
+
#listMutations = null;
|
|
131
|
+
#layout = new LayoutObserver(() => this.update());
|
|
132
|
+
#onListMutation = () => {
|
|
133
|
+
this.update();
|
|
134
|
+
};
|
|
135
|
+
/** Holds the ellipsis hide back while it contains focus; re-renders on blur. */
|
|
136
|
+
#deferredHide = new BlurDeferral(() => {
|
|
137
|
+
this.#render();
|
|
138
|
+
});
|
|
71
139
|
/** Starts observing for overflow and renders the initial state. */
|
|
72
140
|
connect() {
|
|
73
|
-
|
|
141
|
+
this.#connected = true;
|
|
142
|
+
this.#expanded = this.#initialExpanded();
|
|
74
143
|
this.#layout.observeViewport();
|
|
75
|
-
this.#
|
|
144
|
+
this.#syncListObservation();
|
|
145
|
+
this.update();
|
|
76
146
|
}
|
|
77
|
-
/** Releases the resize observation (Turbo navigation included). */
|
|
147
|
+
/** Releases the resize/mutation observation (Turbo navigation included). */
|
|
78
148
|
disconnect() {
|
|
149
|
+
this.#connected = false;
|
|
150
|
+
this.#deferredHide.releaseAll();
|
|
151
|
+
this.#stopObservingList();
|
|
79
152
|
this.#layout.disconnect();
|
|
80
153
|
}
|
|
81
|
-
|
|
154
|
+
listTargetConnected() {
|
|
155
|
+
this.#resync();
|
|
156
|
+
}
|
|
157
|
+
listTargetDisconnected() {
|
|
158
|
+
this.#resync();
|
|
159
|
+
}
|
|
160
|
+
collapsibleTargetConnected() {
|
|
161
|
+
this.#resync();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Re-measures when the disclosure set gains or loses a member. The set is a
|
|
165
|
+
* precondition for collapsing at all, so it needs the same watching the list
|
|
166
|
+
* and items get: without these callbacks a swap that breaks or completes the
|
|
167
|
+
* set would only take effect at the next resize or list mutation, which may
|
|
168
|
+
* never come.
|
|
169
|
+
*/
|
|
170
|
+
ellipsisTargetConnected() {
|
|
171
|
+
this.#resync();
|
|
172
|
+
}
|
|
173
|
+
ellipsisTargetDisconnected() {
|
|
174
|
+
this.#resync();
|
|
175
|
+
}
|
|
176
|
+
triggerTargetConnected() {
|
|
177
|
+
this.#resync();
|
|
178
|
+
}
|
|
179
|
+
triggerTargetDisconnected() {
|
|
180
|
+
this.#resync();
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Hands `hidden` back when an element stops being a `collapsible` target.
|
|
184
|
+
*
|
|
185
|
+
* The marker is the source of truth for what may be collapsed, so an element
|
|
186
|
+
* that loses it while the controller is live is an always-visible item again —
|
|
187
|
+
* and `#render` only walks the *current* targets, so nothing else would ever
|
|
188
|
+
* clear the `hidden` this controller put there.
|
|
189
|
+
*
|
|
190
|
+
* The `#connected` guard is load-bearing, not defensive. Stimulus fires this
|
|
191
|
+
* callback for **every** target during teardown as well (`Context.disconnect()`
|
|
192
|
+
* stops the target observer after `disconnect()` has run), and there the
|
|
193
|
+
* collapsed DOM must survive untouched so Turbo's cache restores the trail in
|
|
194
|
+
* the state the user left it (see the ownership note in {@link BreadcrumbController}).
|
|
195
|
+
*
|
|
196
|
+
* @param element - the element that just left the `collapsible` target set
|
|
197
|
+
*/
|
|
198
|
+
collapsibleTargetDisconnected(element) {
|
|
199
|
+
if (this.#connected) element.hidden = false;
|
|
200
|
+
this.#resync();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Expands or re-collapses the trail and dispatches `toggle`.
|
|
204
|
+
*
|
|
205
|
+
* No-op while the trail fits: there is nothing collapsed to reveal, and the
|
|
206
|
+
* (hidden) trigger would otherwise keep a stale `aria-expanded="true"` that
|
|
207
|
+
* surfaces pre-expanded the next time the trail overflows. No event is
|
|
208
|
+
* dispatched in that case.
|
|
209
|
+
*/
|
|
82
210
|
toggle() {
|
|
211
|
+
if (!this.#connected || !this.#overflowing) return;
|
|
83
212
|
this.#expanded = !this.#expanded;
|
|
84
213
|
this.#render();
|
|
85
214
|
this.dispatch("toggle", { detail: { expanded: this.#expanded } });
|
|
86
215
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Re-measures overflow and re-renders. Wired to resizes and list mutations;
|
|
218
|
+
* exposed as an action so consumers can re-measure after a change the
|
|
219
|
+
* observers cannot see (e.g. a web font swap that only alters text width).
|
|
220
|
+
*/
|
|
221
|
+
update() {
|
|
222
|
+
if (!this.#connected) return;
|
|
89
223
|
this.#overflowing = this.#measureOverflow();
|
|
90
224
|
if (!this.#overflowing) this.#expanded = false;
|
|
91
225
|
this.#render();
|
|
92
226
|
}
|
|
227
|
+
/** Re-attaches the list observation (targets may have been swapped) and re-measures. */
|
|
228
|
+
#resync() {
|
|
229
|
+
if (!this.#connected) return;
|
|
230
|
+
this.#syncListObservation();
|
|
231
|
+
this.update();
|
|
232
|
+
}
|
|
233
|
+
/** Reads the expanded state back from its DOM home (the trigger's `aria-expanded`). */
|
|
234
|
+
#initialExpanded() {
|
|
235
|
+
return this.hasTriggerTarget && this.triggerTarget.getAttribute("aria-expanded") === "true";
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Points the resize + mutation observation at the current `list` target.
|
|
239
|
+
*
|
|
240
|
+
* The list is re-resolved rather than captured at connect so a target swapped
|
|
241
|
+
* at runtime (Turbo Stream replacement of the `<ol>`) is observed too. Only
|
|
242
|
+
* `childList` / `subtree` / `characterData` are watched — deliberately **not**
|
|
243
|
+
* `attributes`, which the controller's own `hidden` writes would re-trigger.
|
|
244
|
+
*/
|
|
245
|
+
#syncListObservation() {
|
|
246
|
+
const next = this.hasListTarget ? this.listTarget : null;
|
|
247
|
+
if (next === this.#observedList) return;
|
|
248
|
+
this.#stopObservingList();
|
|
249
|
+
if (!next) return;
|
|
250
|
+
this.#observedList = next;
|
|
251
|
+
this.#layout.observe(next);
|
|
252
|
+
if (typeof MutationObserver !== "undefined") {
|
|
253
|
+
this.#listMutations = new MutationObserver(this.#onListMutation);
|
|
254
|
+
this.#listMutations.observe(next, { childList: true, subtree: true, characterData: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
#stopObservingList() {
|
|
258
|
+
if (this.#observedList) this.#layout.unobserve(this.#observedList);
|
|
259
|
+
this.#observedList = null;
|
|
260
|
+
this.#listMutations?.disconnect();
|
|
261
|
+
this.#listMutations = null;
|
|
262
|
+
}
|
|
93
263
|
/**
|
|
94
264
|
* Measures overflow against the **fully expanded** layout so hiding items does
|
|
95
265
|
* not make the condition oscillate. Reveals every item, then compares the list's
|
|
@@ -100,21 +270,63 @@ var BreadcrumbController = class extends Controller {
|
|
|
100
270
|
* check is independent of any padding/border on the host — comparing against the
|
|
101
271
|
* host's `clientWidth` would over-report the available space by its padding and
|
|
102
272
|
* miss real overflow in a padded container.
|
|
273
|
+
*
|
|
274
|
+
* With no `collapsible` items there is nothing to collapse, so the trail is
|
|
275
|
+
* never reported as overflowing (which also keeps the disclosure out of the
|
|
276
|
+
* tab order — a button controlling nothing).
|
|
277
|
+
*
|
|
278
|
+
* The full disclosure set is a precondition: collapsing without an `ellipsis`
|
|
279
|
+
* *and* a `trigger` would hide items behind a control that does not exist, so
|
|
280
|
+
* an incomplete set reports "not overflowing" and `#render` degrades the trail
|
|
281
|
+
* to a plain breadcrumb. Losing layout is recoverable; losing the path is not.
|
|
103
282
|
*/
|
|
104
283
|
#measureOverflow() {
|
|
105
|
-
if (!this.hasListTarget) return false;
|
|
284
|
+
if (!this.hasListTarget || this.collapsibleTargets.length === 0) return false;
|
|
285
|
+
if (!this.hasEllipsisTarget || !this.hasTriggerTarget) return false;
|
|
106
286
|
for (const item of this.collapsibleTargets) item.hidden = false;
|
|
107
|
-
|
|
108
|
-
return this.listTarget.scrollWidth > this.listTarget.clientWidth;
|
|
287
|
+
this.ellipsisTarget.hidden = true;
|
|
288
|
+
return this.listTarget.scrollWidth > this.listTarget.clientWidth + OVERFLOW_EPSILON;
|
|
109
289
|
}
|
|
110
290
|
/** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */
|
|
111
291
|
#render() {
|
|
112
292
|
const collapsed = this.#overflowing && !this.#expanded;
|
|
293
|
+
const showEllipsis = this.#overflowing && this.collapsibleTargets.length > 0;
|
|
294
|
+
const rescueFocus = collapsed && this.hasTriggerTarget && this.collapsibleTargets.some((i) => this.#holdsFocus(i));
|
|
113
295
|
for (const item of this.collapsibleTargets) item.hidden = collapsed;
|
|
114
|
-
if (this.hasEllipsisTarget) this
|
|
296
|
+
if (this.hasEllipsisTarget) this.#applyEllipsisVisibility(showEllipsis);
|
|
115
297
|
if (this.hasTriggerTarget) {
|
|
116
|
-
|
|
298
|
+
const expanded = this.#overflowing && this.#expanded;
|
|
299
|
+
this.triggerTarget.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
300
|
+
}
|
|
301
|
+
if (rescueFocus) this.triggerTarget.focus();
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Shows or hides the ellipsis item, deferring a hide that would blur the user.
|
|
305
|
+
*
|
|
306
|
+
* Hiding the element that currently holds focus drops focus to `<body>`, so the
|
|
307
|
+
* hide waits for that element's `blur` and is re-applied then (the shared
|
|
308
|
+
* `BlurDeferral` registry).
|
|
309
|
+
*/
|
|
310
|
+
#applyEllipsisVisibility(show) {
|
|
311
|
+
const ellipsis = this.ellipsisTarget;
|
|
312
|
+
if (show) {
|
|
313
|
+
this.#deferredHide.releaseAll();
|
|
314
|
+
ellipsis.hidden = false;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const active = document.activeElement;
|
|
318
|
+
if (active instanceof HTMLElement && ellipsis.contains(active)) {
|
|
319
|
+
ellipsis.hidden = false;
|
|
320
|
+
this.#deferredHide.deferOnly(active);
|
|
321
|
+
return;
|
|
117
322
|
}
|
|
323
|
+
this.#deferredHide.releaseAll();
|
|
324
|
+
ellipsis.hidden = true;
|
|
325
|
+
}
|
|
326
|
+
/** Whether `element` contains (or is) the currently focused element. */
|
|
327
|
+
#holdsFocus(element) {
|
|
328
|
+
const active = document.activeElement;
|
|
329
|
+
return active instanceof HTMLElement && element.contains(active);
|
|
118
330
|
}
|
|
119
331
|
};
|
|
120
332
|
|
|
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/calendar_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowKey(key, element) {
|
|
12
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
13
|
+
if (!isRtl(element)) return key;
|
|
14
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
15
|
+
}
|
|
16
|
+
function isReservedArrowChord(event, allow = []) {
|
|
17
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
18
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
// src/utils/dates.ts
|
|
6
22
|
function toISODateString(date) {
|
|
7
23
|
const year = date.getFullYear();
|
|
@@ -32,6 +48,9 @@ function parseISOMonthString(monthStr) {
|
|
|
32
48
|
if (month < 1 || month > 12) return null;
|
|
33
49
|
return { year: Number(y), month };
|
|
34
50
|
}
|
|
51
|
+
function toISOMonthString(date) {
|
|
52
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
53
|
+
}
|
|
35
54
|
|
|
36
55
|
// src/utils/safe_timeout.ts
|
|
37
56
|
var TimerRegistry = class {
|
|
@@ -87,6 +106,7 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
87
106
|
};
|
|
88
107
|
|
|
89
108
|
// src/controllers/calendar_controller.ts
|
|
109
|
+
var OWNED_DISABLED = "data-stimeo--calendar-owns-disabled";
|
|
90
110
|
var CalendarController = class extends Controller {
|
|
91
111
|
static targets = ["label", "grid", "day"];
|
|
92
112
|
static values = {
|
|
@@ -107,6 +127,13 @@ var CalendarController = class extends Controller {
|
|
|
107
127
|
* controller never steals focus after the element leaves the DOM (Turbo).
|
|
108
128
|
*/
|
|
109
129
|
#focusTimer = new SafeTimeout();
|
|
130
|
+
/**
|
|
131
|
+
* The painted month the last `monthchange` reported, or `null` before the first
|
|
132
|
+
* paint. Settling on the initial month — whether it comes from the attribute or
|
|
133
|
+
* is derived here — is the grid describing itself, not a navigation, so it must
|
|
134
|
+
* not reach a listener that refetches inventory or pushes history.
|
|
135
|
+
*/
|
|
136
|
+
#announcedMonth = null;
|
|
110
137
|
connect() {
|
|
111
138
|
if (!this.monthValue) {
|
|
112
139
|
const today = /* @__PURE__ */ new Date();
|
|
@@ -129,7 +156,6 @@ var CalendarController = class extends Controller {
|
|
|
129
156
|
if (!this.monthValue) return;
|
|
130
157
|
this.#syncFocusedDateWithMonth();
|
|
131
158
|
this.render();
|
|
132
|
-
this.dispatch("monthchange", { detail: { month: this.monthValue } });
|
|
133
159
|
}
|
|
134
160
|
/**
|
|
135
161
|
* Stimulus lifecycle callback triggered automatically when the selectedValue changes.
|
|
@@ -156,25 +182,25 @@ var CalendarController = class extends Controller {
|
|
|
156
182
|
}
|
|
157
183
|
/** Handles day selection when a gridcell is clicked. */
|
|
158
184
|
selectByClick(event) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
const dayElement = event.target?.closest(
|
|
186
|
+
"[data-stimeo--calendar-target='day']"
|
|
187
|
+
);
|
|
162
188
|
if (!dayElement) return;
|
|
163
189
|
this.selectDayElement(dayElement);
|
|
164
190
|
}
|
|
165
191
|
/** Handles grid cell keyboard navigation and triggers selection. */
|
|
166
192
|
onKeydown(event) {
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
const dayElement = target
|
|
193
|
+
if (event.defaultPrevented) return;
|
|
194
|
+
if (isReservedArrowChord(event)) return;
|
|
195
|
+
const dayElement = event.target?.closest(
|
|
196
|
+
"[data-stimeo--calendar-target='day']"
|
|
197
|
+
);
|
|
170
198
|
if (!dayElement) return;
|
|
171
|
-
const
|
|
172
|
-
if (!dateStr) return;
|
|
173
|
-
const date = parseISODateString(dateStr);
|
|
199
|
+
const date = parseISODateString(dayElement.getAttribute("data-date") ?? "");
|
|
174
200
|
if (!date) return;
|
|
175
201
|
let handled = true;
|
|
176
202
|
let nextDate = new Date(date);
|
|
177
|
-
switch (event.key) {
|
|
203
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
178
204
|
case "ArrowLeft":
|
|
179
205
|
nextDate.setDate(nextDate.getDate() - 1);
|
|
180
206
|
break;
|
|
@@ -209,6 +235,10 @@ var CalendarController = class extends Controller {
|
|
|
209
235
|
break;
|
|
210
236
|
case "t":
|
|
211
237
|
case "T": {
|
|
238
|
+
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
239
|
+
handled = false;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
212
242
|
const now = /* @__PURE__ */ new Date();
|
|
213
243
|
nextDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
214
244
|
break;
|
|
@@ -227,25 +257,50 @@ var CalendarController = class extends Controller {
|
|
|
227
257
|
this.#focusAndNavigateToDate(nextDate);
|
|
228
258
|
}
|
|
229
259
|
}
|
|
230
|
-
/**
|
|
231
|
-
|
|
260
|
+
/**
|
|
261
|
+
* First day of the month the grid paints: the `month` Value when it parses,
|
|
262
|
+
* otherwise the focused date's month.
|
|
263
|
+
*
|
|
264
|
+
* A malformed `month` falls back instead of stopping the paint: every cell
|
|
265
|
+
* keeps its `aria-selected` and the grid keeps its tab stop, so a Value typo
|
|
266
|
+
* still leaves the grid reachable by Tab and the author can see what they
|
|
267
|
+
* typed. Every consumer of "which month is on screen" reads it from here, so
|
|
268
|
+
* the paint and the `monthchange` report cannot name different months.
|
|
269
|
+
*/
|
|
270
|
+
#paintedMonthStart() {
|
|
232
271
|
const monthInfo = parseISOMonthString(this.monthValue);
|
|
233
|
-
|
|
234
|
-
|
|
272
|
+
return monthInfo ? new Date(monthInfo.year, monthInfo.month - 1, 1) : new Date(this.focusedDate.getFullYear(), this.focusedDate.getMonth(), 1);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Renders the grid days and updates the month/year label, then reports the
|
|
276
|
+
* painted month once it differs from the one already announced.
|
|
277
|
+
*
|
|
278
|
+
* The report belongs to the paint, not to the `month` Value: the Value is only
|
|
279
|
+
* one of the things that decide the month on screen. While a malformed `month`
|
|
280
|
+
* falls back, changing `selected` and selecting a neighbouring month's cell
|
|
281
|
+
* move the painted month too, and both repaint from here. Reading the report
|
|
282
|
+
* off the paint keeps the event naming the month the grid shows, in the
|
|
283
|
+
* `YYYY-MM` the detail contract promises, whichever route repainted.
|
|
284
|
+
*/
|
|
285
|
+
render() {
|
|
286
|
+
const monthStart = this.#paintedMonthStart();
|
|
287
|
+
const year = monthStart.getFullYear();
|
|
288
|
+
const month = monthStart.getMonth() + 1;
|
|
235
289
|
if (this.hasLabelTarget) {
|
|
236
290
|
const lang = document.documentElement.lang || "en";
|
|
237
|
-
const labelDate = new Date(year, month - 1, 1);
|
|
238
291
|
const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
|
|
239
|
-
this.labelTarget.textContent = formatter.format(
|
|
292
|
+
this.labelTarget.textContent = formatter.format(monthStart);
|
|
240
293
|
}
|
|
241
294
|
const days = this.#calculateGridDays(year, month);
|
|
242
295
|
const dayElements = this.dayTargets;
|
|
243
|
-
for (
|
|
244
|
-
const el = dayElements[
|
|
296
|
+
for (const [index, date] of days.entries()) {
|
|
297
|
+
const el = dayElements[index];
|
|
245
298
|
if (!el) continue;
|
|
246
|
-
const date = days[i];
|
|
247
|
-
if (!date) continue;
|
|
248
299
|
const dateStr = toISODateString(date);
|
|
300
|
+
if (el.getAttribute("data-date") !== dateStr) {
|
|
301
|
+
el.removeAttribute("aria-disabled");
|
|
302
|
+
el.removeAttribute(OWNED_DISABLED);
|
|
303
|
+
}
|
|
249
304
|
el.setAttribute("data-date", dateStr);
|
|
250
305
|
el.textContent = String(date.getDate());
|
|
251
306
|
const isOutside = date.getFullYear() !== year || date.getMonth() !== month - 1;
|
|
@@ -258,11 +313,23 @@ var CalendarController = class extends Controller {
|
|
|
258
313
|
el.setAttribute("tabindex", isFocused ? "0" : "-1");
|
|
259
314
|
const isDisabled = this.#isDateOutOfBounds(dateStr);
|
|
260
315
|
if (isDisabled) {
|
|
316
|
+
if (!el.hasAttribute("aria-disabled")) el.setAttribute(OWNED_DISABLED, "");
|
|
261
317
|
el.setAttribute("aria-disabled", "true");
|
|
262
|
-
} else {
|
|
318
|
+
} else if (el.hasAttribute(OWNED_DISABLED)) {
|
|
263
319
|
el.removeAttribute("aria-disabled");
|
|
320
|
+
el.removeAttribute(OWNED_DISABLED);
|
|
264
321
|
}
|
|
265
322
|
}
|
|
323
|
+
if (!dayElements.some((el) => el.getAttribute("tabindex") === "0")) {
|
|
324
|
+
const fallback = dayElements.find((el) => el.getAttribute("data-outside") === "false") ?? dayElements[0];
|
|
325
|
+
fallback?.setAttribute("tabindex", "0");
|
|
326
|
+
}
|
|
327
|
+
const painted = toISOMonthString(monthStart);
|
|
328
|
+
const previous = this.#announcedMonth;
|
|
329
|
+
this.#announcedMonth = painted;
|
|
330
|
+
if (previous !== null && previous !== painted) {
|
|
331
|
+
this.dispatch("monthchange", { detail: { month: painted } });
|
|
332
|
+
}
|
|
266
333
|
}
|
|
267
334
|
selectDayElement(dayElement) {
|
|
268
335
|
if (dayElement.getAttribute("aria-disabled") === "true") return;
|