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
|
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/command_palette_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/active_option.ts
|
|
6
|
+
function syncActiveOption(options, active) {
|
|
7
|
+
let written = 0;
|
|
8
|
+
for (const option of options) {
|
|
9
|
+
const next = option === active ? "true" : "false";
|
|
10
|
+
if (option.getAttribute("aria-selected") === next) continue;
|
|
11
|
+
option.setAttribute("aria-selected", next);
|
|
12
|
+
written += 1;
|
|
13
|
+
}
|
|
14
|
+
return written;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/utils/arrow_step.ts
|
|
18
|
+
function isReservedArrowChord(event, allow = []) {
|
|
19
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
20
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
// src/utils/composition_tracker.ts
|
|
6
24
|
var CompositionTracker = class {
|
|
7
25
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -304,6 +322,39 @@ var FocusTrap = class {
|
|
|
304
322
|
}
|
|
305
323
|
};
|
|
306
324
|
|
|
325
|
+
// src/utils/microtask_coalescer.ts
|
|
326
|
+
var MicrotaskCoalescer = class {
|
|
327
|
+
#run;
|
|
328
|
+
#queued = false;
|
|
329
|
+
#active = false;
|
|
330
|
+
#generation = 0;
|
|
331
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
332
|
+
constructor(run) {
|
|
333
|
+
this.#run = run;
|
|
334
|
+
}
|
|
335
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
336
|
+
activate() {
|
|
337
|
+
this.#active = true;
|
|
338
|
+
}
|
|
339
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
340
|
+
cancel() {
|
|
341
|
+
this.#active = false;
|
|
342
|
+
this.#queued = false;
|
|
343
|
+
this.#generation += 1;
|
|
344
|
+
}
|
|
345
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
346
|
+
schedule() {
|
|
347
|
+
if (!this.#active || this.#queued) return;
|
|
348
|
+
this.#queued = true;
|
|
349
|
+
const generation = this.#generation;
|
|
350
|
+
queueMicrotask(() => {
|
|
351
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
352
|
+
this.#queued = false;
|
|
353
|
+
this.#run();
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
307
358
|
// src/controllers/command_palette_controller.ts
|
|
308
359
|
var CommandPaletteController = class _CommandPaletteController extends Controller {
|
|
309
360
|
static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
|
|
@@ -323,8 +374,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
323
374
|
"toggle"
|
|
324
375
|
];
|
|
325
376
|
static events = ["select"];
|
|
326
|
-
/**
|
|
327
|
-
#
|
|
377
|
+
/** Stable ID of the active option; the live target is resolved before every use. */
|
|
378
|
+
#activeId = null;
|
|
379
|
+
/** Visible/selectable target ID order captured for removal fallback. */
|
|
380
|
+
#activeOrder = [];
|
|
381
|
+
#connected = false;
|
|
382
|
+
/** Collapses one mutation batch of target callbacks into a single pass. */
|
|
383
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
|
|
328
384
|
/** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
|
|
329
385
|
#composition = new CompositionTracker({
|
|
330
386
|
onEnd: () => {
|
|
@@ -358,9 +414,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
358
414
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
359
415
|
this.#resetToClosedState();
|
|
360
416
|
if (shouldOpen) this.open();
|
|
417
|
+
this.#connected = true;
|
|
418
|
+
this.#reconcile.activate();
|
|
361
419
|
}
|
|
362
420
|
/** Tears down the global hotkey listener and reverts the modal side effects. */
|
|
363
421
|
disconnect() {
|
|
422
|
+
this.#connected = false;
|
|
423
|
+
this.#reconcile.cancel();
|
|
364
424
|
document.removeEventListener("keydown", this.#onGlobalKeydown);
|
|
365
425
|
this.#composition.disconnect();
|
|
366
426
|
this.#trap.deactivate({ restoreFocus: false });
|
|
@@ -369,10 +429,20 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
369
429
|
/** Initializes semantics for an option added before or after the controller connects. */
|
|
370
430
|
optionTargetConnected(option) {
|
|
371
431
|
this.#syncOptionSemanticsFor(option);
|
|
432
|
+
option.setAttribute("aria-selected", "false");
|
|
433
|
+
option.removeAttribute("data-active");
|
|
434
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
435
|
+
}
|
|
436
|
+
/** Clears active residue on the old node and reconciles the surviving targets. */
|
|
437
|
+
optionTargetDisconnected(option) {
|
|
438
|
+
option.setAttribute("aria-selected", "false");
|
|
439
|
+
option.removeAttribute("data-active");
|
|
440
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
372
441
|
}
|
|
373
442
|
/** Tracks an input added initially or after connect without extra consumer actions. */
|
|
374
443
|
inputTargetConnected(input) {
|
|
375
444
|
this.#composition.observe(input);
|
|
445
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
376
446
|
}
|
|
377
447
|
/** Removes controller-owned listeners when the input target is replaced or removed. */
|
|
378
448
|
inputTargetDisconnected(input) {
|
|
@@ -412,7 +482,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
412
482
|
const matches = searchText.includes(query);
|
|
413
483
|
if (matches) {
|
|
414
484
|
option.removeAttribute("hidden");
|
|
415
|
-
if (
|
|
485
|
+
if (this.#isNavigable(option)) hasSelectableMatch = true;
|
|
416
486
|
} else {
|
|
417
487
|
option.setAttribute("hidden", "true");
|
|
418
488
|
}
|
|
@@ -427,8 +497,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
427
497
|
const target = event.currentTarget;
|
|
428
498
|
if (!target) return;
|
|
429
499
|
const option = target.closest("[role='option']");
|
|
430
|
-
if (option
|
|
431
|
-
|
|
500
|
+
if (!option || !this.optionTargets.includes(option)) return;
|
|
501
|
+
this.#syncOptionSemanticsFor(option);
|
|
502
|
+
if (!option.hasAttribute("hidden") && !this.#isDisabled(option)) {
|
|
432
503
|
this.#confirmSelection(option);
|
|
433
504
|
}
|
|
434
505
|
}
|
|
@@ -443,7 +514,10 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
443
514
|
* focus — not only the input.
|
|
444
515
|
*/
|
|
445
516
|
onKeydown(event) {
|
|
517
|
+
if (event.defaultPrevented) return;
|
|
518
|
+
if (isReservedArrowChord(event)) return;
|
|
446
519
|
if (this.#composition.isComposing(event) || !this.#isOpen) return;
|
|
520
|
+
this.#reconcileActive();
|
|
447
521
|
switch (event.key) {
|
|
448
522
|
case "ArrowDown":
|
|
449
523
|
event.preventDefault();
|
|
@@ -463,7 +537,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
463
537
|
break;
|
|
464
538
|
case "Enter": {
|
|
465
539
|
event.preventDefault();
|
|
466
|
-
const
|
|
540
|
+
const visible = this.#visibleOptions;
|
|
541
|
+
const activeIndex = this.#findActiveIndex(visible);
|
|
542
|
+
const activeOption = activeIndex < 0 ? void 0 : visible[activeIndex];
|
|
467
543
|
if (activeOption) this.#confirmSelection(activeOption);
|
|
468
544
|
break;
|
|
469
545
|
}
|
|
@@ -472,7 +548,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
472
548
|
#navigate(direction) {
|
|
473
549
|
const visible = this.#visibleOptions;
|
|
474
550
|
if (visible.length === 0) return;
|
|
475
|
-
let newIndex = this.#
|
|
551
|
+
let newIndex = this.#findActiveIndex(visible) + direction;
|
|
476
552
|
if (newIndex >= visible.length) newIndex = 0;
|
|
477
553
|
if (newIndex < 0) newIndex = visible.length - 1;
|
|
478
554
|
this.#setActiveIndex(newIndex);
|
|
@@ -481,16 +557,16 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
481
557
|
this.#syncOptionSemantics();
|
|
482
558
|
const visible = this.#visibleOptions;
|
|
483
559
|
const activeOption = visible[index] ?? null;
|
|
484
|
-
this
|
|
560
|
+
syncActiveOption(this.optionTargets, activeOption);
|
|
485
561
|
for (const option of this.optionTargets) {
|
|
486
|
-
option.setAttribute("
|
|
487
|
-
option.removeAttribute("data-active");
|
|
562
|
+
if (option === activeOption) option.setAttribute("data-active", "true");
|
|
563
|
+
else option.removeAttribute("data-active");
|
|
488
564
|
}
|
|
489
565
|
if (activeOption) {
|
|
490
|
-
activeOption.setAttribute("aria-selected", "true");
|
|
491
|
-
activeOption.setAttribute("data-active", "true");
|
|
492
566
|
activeOption.scrollIntoView({ block: "nearest" });
|
|
493
567
|
}
|
|
568
|
+
this.#activeId = activeOption?.id || null;
|
|
569
|
+
this.#activeOrder = activeOption ? visible.map((option) => option.id).filter(Boolean) : [];
|
|
494
570
|
if (this.hasInputTarget) {
|
|
495
571
|
if (activeOption?.id) {
|
|
496
572
|
this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
|
|
@@ -499,7 +575,75 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
499
575
|
}
|
|
500
576
|
}
|
|
501
577
|
}
|
|
578
|
+
/** Re-resolves active identity and component-specific fallback against live targets. */
|
|
579
|
+
#reconcileActive() {
|
|
580
|
+
if (!this.#isOpen) {
|
|
581
|
+
this.#setActiveIndex(-1);
|
|
582
|
+
this.#reflectEmptyState();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const visible = this.#visibleOptions;
|
|
586
|
+
const currentIndex = this.#findActiveIndex(visible);
|
|
587
|
+
if (currentIndex >= 0) {
|
|
588
|
+
const active = visible[currentIndex] ?? null;
|
|
589
|
+
const options = this.optionTargets;
|
|
590
|
+
const marked = options.filter((option) => option.hasAttribute("data-active"));
|
|
591
|
+
const selected = options.filter((option) => option.getAttribute("aria-selected") === "true");
|
|
592
|
+
const stateMatches = this.#activeId === (active?.id || null) && marked.length === 1 && marked[0] === active && selected.length === 1 && selected[0] === active && (!this.hasInputTarget || this.inputTarget.getAttribute("aria-activedescendant") === (active?.id || null));
|
|
593
|
+
if (stateMatches) {
|
|
594
|
+
this.#activeOrder = visible.map((option) => option.id).filter(Boolean);
|
|
595
|
+
} else {
|
|
596
|
+
this.#setActiveIndex(currentIndex);
|
|
597
|
+
}
|
|
598
|
+
this.#reflectEmptyState();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
|
|
602
|
+
this.#setActiveIndex(activeId ? this.#findFallbackIndex(visible, activeId) : -1);
|
|
603
|
+
this.#reflectEmptyState();
|
|
604
|
+
}
|
|
605
|
+
/** Resolves stable ID first, then active markers for the existing ID-less case. */
|
|
606
|
+
#findActiveIndex(options) {
|
|
607
|
+
const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
|
|
608
|
+
if (activeId) return options.findIndex((option) => option.id === activeId);
|
|
609
|
+
return options.findIndex(
|
|
610
|
+
(option) => option.hasAttribute("data-active") || option.getAttribute("aria-selected") === "true"
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
/** Chooses a surviving former successor, then predecessor, from selectable targets only. */
|
|
614
|
+
#findFallbackIndex(options, activeId) {
|
|
615
|
+
const oldIndex = this.#activeOrder.indexOf(activeId);
|
|
616
|
+
if (oldIndex < 0) return -1;
|
|
617
|
+
const indexesById = new Map(options.map((option, index) => [option.id, index]));
|
|
618
|
+
for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
|
|
619
|
+
const fallback = indexesById.get(this.#activeOrder[index] ?? "");
|
|
620
|
+
if (fallback !== void 0) return fallback;
|
|
621
|
+
}
|
|
622
|
+
for (let index = oldIndex - 1; index >= 0; index -= 1) {
|
|
623
|
+
const fallback = indexesById.get(this.#activeOrder[index] ?? "");
|
|
624
|
+
if (fallback !== void 0) return fallback;
|
|
625
|
+
}
|
|
626
|
+
return -1;
|
|
627
|
+
}
|
|
628
|
+
/** Reflects whether any visible, navigable command remains. */
|
|
629
|
+
#reflectEmptyState() {
|
|
630
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = this.#visibleOptions.length > 0;
|
|
631
|
+
}
|
|
632
|
+
/** Coalesces all target callbacks from one MutationObserver batch. */
|
|
633
|
+
#queueOptionReconciliation() {
|
|
634
|
+
this.#reconcile.schedule();
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Runs `option`: dispatches `select` and closes.
|
|
638
|
+
*
|
|
639
|
+
* **Activation is refused here, not at the call sites.** Every path that runs a
|
|
640
|
+
* command funnels through this method, so one check covers keyboard, pointer,
|
|
641
|
+
* and anything added later. Per-call-site guards would leave `aria-disabled`
|
|
642
|
+
* options — which stay within the keyboard's reach — runnable from whichever
|
|
643
|
+
* path forgets to consult the predicate.
|
|
644
|
+
*/
|
|
502
645
|
#confirmSelection(option) {
|
|
646
|
+
if (this.#isDisabled(option)) return;
|
|
503
647
|
const value = option.dataset.value || option.textContent || "";
|
|
504
648
|
this.dispatch("select", { detail: { value, option } });
|
|
505
649
|
this.close();
|
|
@@ -510,14 +654,33 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
510
654
|
option.removeAttribute("hidden");
|
|
511
655
|
}
|
|
512
656
|
const hasSelectableOption = this.#visibleOptions.length > 0;
|
|
513
|
-
|
|
657
|
+
this.#reflectEmptyState();
|
|
514
658
|
this.#setActiveIndex(hasSelectableOption ? 0 : -1);
|
|
515
659
|
}
|
|
516
660
|
get #visibleOptions() {
|
|
517
661
|
return this.optionTargets.filter(
|
|
518
|
-
(option) => !option.hasAttribute("hidden") &&
|
|
662
|
+
(option) => !option.hasAttribute("hidden") && this.#isNavigable(option)
|
|
519
663
|
);
|
|
520
664
|
}
|
|
665
|
+
/**
|
|
666
|
+
* Whether virtual focus may land on `option`.
|
|
667
|
+
*
|
|
668
|
+
* Only `data-disabled` disqualifies. That attribute is this controller's own —
|
|
669
|
+
* authors reach for it to mark a row that is not a destination at all, such as
|
|
670
|
+
* a group heading — so it can mean "skip entirely" without contradicting ARIA.
|
|
671
|
+
*
|
|
672
|
+
* **`aria-disabled` does not disqualify.** It marks a command that must stay
|
|
673
|
+
* *discoverable*: `aria-activedescendant` names it, so a reader announces it as
|
|
674
|
+
* unavailable rather than hiding its existence. Virtual focus does
|
|
675
|
+
* not change that — pointing `aria-activedescendant` at a disabled option is
|
|
676
|
+
* ordinary ARIA. Keeping the two attributes distinct is what leaves both
|
|
677
|
+
* meanings expressible; collapsing them would take "show it, say it is
|
|
678
|
+
* unavailable, do not run it" away from consumers.
|
|
679
|
+
*/
|
|
680
|
+
#isNavigable(option) {
|
|
681
|
+
return option.dataset.disabled !== "true";
|
|
682
|
+
}
|
|
683
|
+
/** Whether activating `option` may run. Suppressed for either disabled marker. */
|
|
521
684
|
#isDisabled(option) {
|
|
522
685
|
return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
|
|
523
686
|
}
|
|
@@ -525,9 +688,18 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
525
688
|
#syncOptionSemantics() {
|
|
526
689
|
for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
|
|
527
690
|
}
|
|
528
|
-
/**
|
|
691
|
+
/**
|
|
692
|
+
* Reflects `data-disabled` to ARIA without losing a pre-existing authored value,
|
|
693
|
+
* and puts `aria-selected` back to its baseline.
|
|
694
|
+
*
|
|
695
|
+
* Here `aria-selected` marks the *active* option, not a committed choice — the
|
|
696
|
+
* palette runs a command and keeps nothing selected — so an authored value is
|
|
697
|
+
* meaningless and is overwritten rather than preserved. Every caller either
|
|
698
|
+
* establishes the active option straight after (`filter`, `#setActiveIndex`) or
|
|
699
|
+
* is handling an option that cannot be the active one yet
|
|
700
|
+
* (`optionTargetConnected`).
|
|
701
|
+
*/
|
|
529
702
|
#syncOptionSemanticsFor(option) {
|
|
530
|
-
if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
|
|
531
703
|
const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
|
|
532
704
|
const originalValue = option.getAttribute(originalAttribute);
|
|
533
705
|
if (option.dataset.disabled === "true") {
|
|
@@ -581,8 +753,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
581
753
|
}
|
|
582
754
|
/** Resets transient open state so reconnect starts from a predictable closed snapshot. */
|
|
583
755
|
#resetToClosedState() {
|
|
584
|
-
this.#
|
|
756
|
+
this.#activeId = null;
|
|
757
|
+
this.#activeOrder = [];
|
|
585
758
|
this.openValue = false;
|
|
759
|
+
for (const option of this.optionTargets) {
|
|
760
|
+
option.setAttribute("aria-selected", "false");
|
|
761
|
+
option.removeAttribute("data-active");
|
|
762
|
+
}
|
|
586
763
|
if (this.hasDialogTarget) {
|
|
587
764
|
this.dialogTarget.hidden = true;
|
|
588
765
|
}
|
|
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/context_menu_controller.ts
|
|
4
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
|
+
|
|
5
11
|
// src/utils/escape_layer.ts
|
|
6
12
|
function claimsWhileFocusWithin(element) {
|
|
7
13
|
return () => {
|
|
@@ -156,16 +162,16 @@ var ContextMenuController = class extends Controller {
|
|
|
156
162
|
connect() {
|
|
157
163
|
this.#closeMenu();
|
|
158
164
|
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
159
|
-
document.addEventListener("click", this.#onOutsidePointer);
|
|
160
|
-
document.addEventListener("contextmenu", this.#onOutsidePointer);
|
|
165
|
+
document.addEventListener("click", this.#onOutsidePointer, true);
|
|
166
|
+
document.addEventListener("contextmenu", this.#onOutsidePointer, true);
|
|
161
167
|
}
|
|
162
168
|
/** Releases the listeners, stack membership, and pending Tab-close task. */
|
|
163
169
|
disconnect() {
|
|
164
170
|
this.#timers.clearAll();
|
|
165
171
|
this.#escapeLayer.deactivate();
|
|
166
172
|
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
167
|
-
document.removeEventListener("click", this.#onOutsidePointer);
|
|
168
|
-
document.removeEventListener("contextmenu", this.#onOutsidePointer);
|
|
173
|
+
document.removeEventListener("click", this.#onOutsidePointer, true);
|
|
174
|
+
document.removeEventListener("contextmenu", this.#onOutsidePointer, true);
|
|
169
175
|
}
|
|
170
176
|
/**
|
|
171
177
|
* Opens the menu from a `contextmenu` event: suppresses the native menu and
|
|
@@ -177,6 +183,7 @@ var ContextMenuController = class extends Controller {
|
|
|
177
183
|
}
|
|
178
184
|
/** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */
|
|
179
185
|
onRegionKeydown(event) {
|
|
186
|
+
if (event.defaultPrevented) return;
|
|
180
187
|
const isContextKey = event.key === "ContextMenu" || event.key === "F10" && event.shiftKey;
|
|
181
188
|
if (!isContextKey) return;
|
|
182
189
|
event.preventDefault();
|
|
@@ -185,6 +192,8 @@ var ContextMenuController = class extends Controller {
|
|
|
185
192
|
}
|
|
186
193
|
/** Roving focus and closing keys inside the menu. */
|
|
187
194
|
onItemKeydown(event) {
|
|
195
|
+
if (event.defaultPrevented) return;
|
|
196
|
+
if (isReservedArrowChord(event)) return;
|
|
188
197
|
const items = this.#navigableItems;
|
|
189
198
|
const currentIndex = items.indexOf(event.currentTarget);
|
|
190
199
|
switch (event.key) {
|
|
@@ -247,7 +256,13 @@ var ContextMenuController = class extends Controller {
|
|
|
247
256
|
this.#closeMenu();
|
|
248
257
|
if (this.hasRegionTarget) this.regionTarget.focus();
|
|
249
258
|
}
|
|
250
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Closes when a click or context-menu invocation lands outside this instance.
|
|
261
|
+
* Both subscriptions observe in the capture phase, so the target is judged
|
|
262
|
+
* against the tree the user actually pressed even when a consumer handler
|
|
263
|
+
* detaches it, and application code that stops bubbling cannot leave the menu
|
|
264
|
+
* open.
|
|
265
|
+
*/
|
|
251
266
|
#onOutsidePointer = (event) => {
|
|
252
267
|
if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
|
|
253
268
|
};
|
|
@@ -265,18 +280,25 @@ var ContextMenuController = class extends Controller {
|
|
|
265
280
|
event.preventDefault();
|
|
266
281
|
event.stopImmediatePropagation();
|
|
267
282
|
};
|
|
268
|
-
/** Menu items eligible for roving focus (excludes
|
|
283
|
+
/** Menu items eligible for roving focus (excludes hidden / natively disabled). */
|
|
269
284
|
get #navigableItems() {
|
|
270
285
|
return this.itemTargets.filter((item) => this.#isNavigable(item));
|
|
271
286
|
}
|
|
272
287
|
/**
|
|
273
|
-
* An item can take roving focus unless it is `hidden
|
|
274
|
-
*
|
|
275
|
-
*
|
|
288
|
+
* An item can take roving focus unless it is `hidden` or a natively `disabled`
|
|
289
|
+
* form control. CSS-only visibility is not detectable here and is the
|
|
290
|
+
* consumer's responsibility.
|
|
291
|
+
*
|
|
292
|
+
* **`aria-disabled="true"` stays reachable.** APG separates the two attributes
|
|
293
|
+
* by intent: `disabled` is for controls whose existence can be inferred from a
|
|
294
|
+
* neighbour (a greyed Next next to a Prev), while `aria-disabled` marks a
|
|
295
|
+
* control that must stay *discoverable* — and it names menu items as the
|
|
296
|
+
* example. Skipping it would hide the command's existence from a keyboard user
|
|
297
|
+
* entirely. Activation is suppressed separately, so the item announces itself
|
|
298
|
+
* and does nothing.
|
|
276
299
|
*/
|
|
277
300
|
#isNavigable(item) {
|
|
278
301
|
if (item.hasAttribute("hidden")) return false;
|
|
279
|
-
if (item.getAttribute("aria-disabled") === "true") return false;
|
|
280
302
|
return !item.disabled;
|
|
281
303
|
}
|
|
282
304
|
/** Whether the menu is currently visible. */
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/data_grid_controller.ts
|
|
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
|
+
|
|
3
21
|
// src/controllers/data_grid_controller.ts
|
|
4
22
|
var SORT_CYCLE = ["none", "ascending", "descending"];
|
|
5
23
|
function nextSortDirection(current) {
|
|
@@ -14,11 +32,27 @@ var DataGridController = class extends Controller {
|
|
|
14
32
|
};
|
|
15
33
|
static actions = ["onKeydown", "sort", "toggleSelect"];
|
|
16
34
|
static events = ["selectionchange", "sort"];
|
|
17
|
-
/**
|
|
35
|
+
/** Gates the row callback so it does not re-walk every row once per authored row on mount. */
|
|
36
|
+
#connected = false;
|
|
37
|
+
/**
|
|
38
|
+
* Establishes a single tab stop across all navigable cells/headers and brings
|
|
39
|
+
* the rows to their baseline.
|
|
40
|
+
*
|
|
41
|
+
* Normalizing here rather than leaving it to {@link selectionValueChanged}
|
|
42
|
+
* guarantees exactly one pass per mount: a re-attached element reuses its
|
|
43
|
+
* cached Stimulus context, whose value observer already knows the `selection`
|
|
44
|
+
* attribute, so the Value callback does not fire a second time.
|
|
45
|
+
*/
|
|
18
46
|
connect() {
|
|
19
47
|
const cells = this.#navigableCells();
|
|
20
48
|
const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
|
|
21
49
|
this.#setActiveCell(active, { focus: false });
|
|
50
|
+
this.#normalizeSelection();
|
|
51
|
+
this.#connected = true;
|
|
52
|
+
}
|
|
53
|
+
/** Reopens the row callback for the next mount. */
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#connected = false;
|
|
22
56
|
}
|
|
23
57
|
/**
|
|
24
58
|
* Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
|
|
@@ -27,6 +61,45 @@ var DataGridController = class extends Controller {
|
|
|
27
61
|
*/
|
|
28
62
|
selectionValueChanged() {
|
|
29
63
|
this.#syncSelectable();
|
|
64
|
+
this.#normalizeSelection();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Re-establishes the row baseline for a row added after connect.
|
|
68
|
+
*
|
|
69
|
+
* Each pass walks every row, and Stimulus reports the authored rows one by one
|
|
70
|
+
* before `connect()`, so the mount is gated to keep it linear in the row count
|
|
71
|
+
* rather than quadratic; `connect()` runs the single baseline pass instead.
|
|
72
|
+
*/
|
|
73
|
+
rowTargetConnected() {
|
|
74
|
+
if (!this.#connected) return;
|
|
75
|
+
this.#normalizeSelection();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Brings the authored rows to the shape the APG requires, without changing
|
|
79
|
+
* which rows the author chose.
|
|
80
|
+
*
|
|
81
|
+
* Every selectable row gets an explicit value — an absent `aria-selected` means
|
|
82
|
+
* "not selectable" in ARIA, so a forgotten attribute hides a selectable row —
|
|
83
|
+
* and a single-select grid keeps at most one `true`, first in DOM order.
|
|
84
|
+
* A grid that declares `selection="none"` has no selectable rows, so the
|
|
85
|
+
* attribute is removed rather than written: in ARIA its absence is what "not
|
|
86
|
+
* selectable" looks like.
|
|
87
|
+
*/
|
|
88
|
+
#normalizeSelection() {
|
|
89
|
+
const rows = this.rowTargets;
|
|
90
|
+
if (this.selectionValue === "none") {
|
|
91
|
+
for (const row of rows) row.removeAttribute("aria-selected");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const single = this.selectionValue === "single";
|
|
95
|
+
const first = rows.find((row) => row.getAttribute("aria-selected") === "true");
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
if (single) {
|
|
98
|
+
row.setAttribute("aria-selected", row === first ? "true" : "false");
|
|
99
|
+
} else if (row.getAttribute("aria-selected") !== "true") {
|
|
100
|
+
row.setAttribute("aria-selected", "false");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
30
103
|
}
|
|
31
104
|
/**
|
|
32
105
|
* Mirrors `selection="multiple"` onto `aria-multiselectable` (APG Grid) so SRs
|
|
@@ -53,11 +126,14 @@ var DataGridController = class extends Controller {
|
|
|
53
126
|
}
|
|
54
127
|
/** Toggles selection of the row owning the event target. Bound optionally. */
|
|
55
128
|
toggleSelect(event) {
|
|
129
|
+
if (this.selectionValue === "none") return;
|
|
56
130
|
const row = event.currentTarget.closest("[role='row']");
|
|
57
131
|
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
58
132
|
}
|
|
59
133
|
/** Grid navigation + sort/select activation. Bound to cells and headers. */
|
|
60
134
|
onKeydown(event) {
|
|
135
|
+
if (event.defaultPrevented) return;
|
|
136
|
+
if (isReservedArrowChord(event)) return;
|
|
61
137
|
const cell = event.currentTarget;
|
|
62
138
|
const matrix = this.#matrix();
|
|
63
139
|
const position = this.#locate(matrix, cell);
|
|
@@ -65,7 +141,7 @@ var DataGridController = class extends Controller {
|
|
|
65
141
|
const [row, col] = position;
|
|
66
142
|
const rowCells = matrix[row] ?? [];
|
|
67
143
|
let target;
|
|
68
|
-
switch (event.key) {
|
|
144
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
69
145
|
case "ArrowRight":
|
|
70
146
|
target = this.#cellInRow(matrix, row, col + 1);
|
|
71
147
|
break;
|
|
@@ -118,8 +194,10 @@ var DataGridController = class extends Controller {
|
|
|
118
194
|
/** Toggles a row's `aria-selected`, honoring single vs. multiple selection. */
|
|
119
195
|
#toggleRow(row) {
|
|
120
196
|
const selected = row.getAttribute("aria-selected") === "true";
|
|
121
|
-
if (this.selectionValue === "single"
|
|
122
|
-
for (const other of this.rowTargets)
|
|
197
|
+
if (this.selectionValue === "single") {
|
|
198
|
+
for (const other of this.rowTargets) {
|
|
199
|
+
if (other !== row) other.setAttribute("aria-selected", "false");
|
|
200
|
+
}
|
|
123
201
|
}
|
|
124
202
|
row.setAttribute("aria-selected", selected ? "false" : "true");
|
|
125
203
|
const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
|
|
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/date_range_picker_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();
|
|
@@ -119,6 +135,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
119
135
|
const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
|
|
120
136
|
this.#focusedDate = anchor;
|
|
121
137
|
this.#viewMonth = toISOMonthString(anchor);
|
|
138
|
+
if (this.hasGridTarget) this.gridTarget.setAttribute("aria-multiselectable", "true");
|
|
122
139
|
this.#render();
|
|
123
140
|
}
|
|
124
141
|
/** Cancels any pending deferred focus so it never fires on a detached element. */
|
|
@@ -184,6 +201,8 @@ var DateRangePickerController = class extends Controller {
|
|
|
184
201
|
}
|
|
185
202
|
/** Grid keyboard navigation, selection (Enter/Space), and Escape-to-cancel. */
|
|
186
203
|
onKeydown(event) {
|
|
204
|
+
if (event.defaultPrevented) return;
|
|
205
|
+
if (isReservedArrowChord(event)) return;
|
|
187
206
|
const cell = this.#cellFrom(event.target);
|
|
188
207
|
if (!cell) return;
|
|
189
208
|
const dateStr = cell.getAttribute("data-date");
|
|
@@ -195,7 +214,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
195
214
|
return;
|
|
196
215
|
}
|
|
197
216
|
if (event.key === "Escape") {
|
|
198
|
-
if (this.#pendingStart && !event.
|
|
217
|
+
if (this.#pendingStart && !event.isComposing) {
|
|
199
218
|
event.preventDefault();
|
|
200
219
|
this.#pendingStart = "";
|
|
201
220
|
this.#previewDate = "";
|
|
@@ -204,7 +223,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
204
223
|
return;
|
|
205
224
|
}
|
|
206
225
|
let next = null;
|
|
207
|
-
switch (event.key) {
|
|
226
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
208
227
|
case "ArrowLeft":
|
|
209
228
|
next = addDays(date, -1);
|
|
210
229
|
break;
|
|
@@ -318,7 +337,12 @@ var DateRangePickerController = class extends Controller {
|
|
|
318
337
|
el.toggleAttribute("data-range-start", isStart);
|
|
319
338
|
el.toggleAttribute("data-range-end", isEnd);
|
|
320
339
|
el.toggleAttribute("data-in-range", inside);
|
|
321
|
-
el.setAttribute(
|
|
340
|
+
el.setAttribute(
|
|
341
|
+
"aria-selected",
|
|
342
|
+
String(
|
|
343
|
+
!!this.#startDate && iso === this.#startDate || !!this.#endDate && iso === this.#endDate
|
|
344
|
+
)
|
|
345
|
+
);
|
|
322
346
|
}
|
|
323
347
|
}
|
|
324
348
|
/** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
|
|
@@ -107,6 +107,7 @@ var EditableController = class extends Controller {
|
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
if (event.key === "Enter") {
|
|
110
|
+
if (event.defaultPrevented) return;
|
|
110
111
|
if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
|
|
111
112
|
event.preventDefault();
|
|
112
113
|
this.#save(true);
|
|
@@ -216,7 +216,7 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
218
218
|
* Where focus should land for an invalid control. A visible control is focused
|
|
219
|
-
* directly
|
|
219
|
+
* directly — the case for native fields and radios. A validatable mirror
|
|
220
220
|
* (the `hidden` attribute) cannot receive focus, so focus is delegated to the
|
|
221
221
|
* visible widget: the owning field's `control` target when it is itself
|
|
222
222
|
* focusable, else its first focusable descendant (e.g. a roving-tabindex
|