@gjsify/adwaita-web 0.40.0 → 0.42.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.
- package/README.md +98 -3
- package/dist/adwaita-web.css +142 -63
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-fonts.spec.d.ts +2 -0
- package/lib/types/breakpoints.d.ts +10 -1
- package/lib/types/connect-lifecycle.spec.d.ts +1 -0
- package/lib/types/elements/adw-about-dialog.d.ts +1 -0
- package/lib/types/elements/adw-action-row.d.ts +2 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +14 -2
- package/lib/types/elements/adw-carousel.d.ts +48 -0
- package/lib/types/elements/adw-dialog.d.ts +2 -7
- package/lib/types/elements/adw-icon.d.ts +17 -1
- package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
- package/lib/types/elements/adw-popover.d.ts +1 -0
- package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
- package/lib/types/elements/adw-switch.d.ts +10 -0
- package/lib/types/elements/adw-tab-view.d.ts +23 -0
- package/lib/types/elements/adw-toggle-group.d.ts +2 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
- package/lib/types/elements/modal-surface.d.ts +59 -0
- package/lib/types/elements/roving-focus.d.ts +26 -0
- package/lib/types/elements/row-activation.d.ts +26 -0
- package/lib/types/empty-sections.d.ts +15 -0
- package/lib/types/empty-sections.spec.d.ts +1 -0
- package/lib/types/fonts.d.ts +1 -0
- package/lib/types/icon-registry.d.ts +35 -0
- package/lib/types/icon-registry.spec.d.ts +18 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/scroll-shading.d.ts +56 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -1
- package/scss/_carousel.scss +15 -1
- package/scss/_checks.scss +8 -8
- package/scss/_combo_row.scss +1 -1
- package/scss/_drop_down.scss +1 -1
- package/scss/_expander_row.scss +1 -1
- package/scss/_headerbar.scss +35 -6
- package/scss/_icon.scss +47 -0
- package/scss/_icons.generated.scss +36 -6
- package/scss/_password_entry_row.scss +1 -1
- package/scss/_preferences_dialog.scss +3 -2
- package/scss/_progress_bar.scss +1 -1
- package/scss/_row.scss +1 -1
- package/scss/_scrolling.scss +107 -0
- package/scss/_status_page.scss +1 -1
- package/scss/_style_classes.scss +21 -8
- package/scss/_tab_view.scss +1 -1
- package/scss/_theme.scss +1 -0
- package/scss/_toast.scss +6 -24
- package/scss/_toolbar_view.scss +52 -22
- package/scss/_variables.scss +30 -3
- package/scss/_view_switcher.scss +80 -20
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-header-bar.spec.ts +92 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/adw-tab-view.spec.ts +151 -0
- package/src/bottom-sheet.spec.ts +87 -0
- package/src/breakpoints.ts +18 -4
- package/src/chrome.spec.ts +233 -0
- package/src/connect-lifecycle.spec.ts +351 -0
- package/src/elements/adw-about-dialog.ts +24 -18
- package/src/elements/adw-action-row.ts +14 -2
- package/src/elements/adw-alert-dialog.ts +39 -20
- package/src/elements/adw-button-row.ts +3 -0
- package/src/elements/adw-carousel.ts +121 -4
- package/src/elements/adw-clamp.ts +3 -2
- package/src/elements/adw-data-grid.ts +5 -2
- package/src/elements/adw-dialog.ts +16 -63
- package/src/elements/adw-expander-row.ts +18 -4
- package/src/elements/adw-header-bar.ts +2 -0
- package/src/elements/adw-icon.ts +32 -6
- package/src/elements/adw-inline-view-switcher.ts +12 -0
- package/src/elements/adw-menu-button.ts +17 -10
- package/src/elements/adw-navigation-split-view.ts +30 -7
- package/src/elements/adw-overlay-split-view.ts +15 -3
- package/src/elements/adw-popover.ts +11 -0
- package/src/elements/adw-preferences-dialog.ts +18 -16
- package/src/elements/adw-preferences-group.ts +7 -0
- package/src/elements/adw-sidebar.ts +15 -0
- package/src/elements/adw-status-page.ts +12 -4
- package/src/elements/adw-switch-row.ts +7 -0
- package/src/elements/adw-switch.ts +19 -1
- package/src/elements/adw-tab-view.ts +70 -7
- package/src/elements/adw-toast-overlay.ts +8 -1
- package/src/elements/adw-toggle-group.ts +94 -2
- package/src/elements/adw-toolbar-view.ts +28 -2
- package/src/elements/adw-view-switcher-bar.ts +18 -0
- package/src/elements/adw-view-switcher.ts +14 -0
- package/src/elements/modal-surface.ts +180 -0
- package/src/elements/roving-focus.ts +154 -0
- package/src/elements/row-activation.ts +92 -0
- package/src/empty-sections.spec.ts +81 -0
- package/src/empty-sections.ts +54 -0
- package/src/fonts.ts +25 -0
- package/src/icon-registry.spec.ts +240 -0
- package/src/icon-registry.ts +157 -0
- package/src/index.ts +30 -1
- package/src/keyboard-operable.spec.ts +755 -0
- package/src/scroll-shading.ts +255 -0
- package/src/source-view/theme.ts +15 -3
- package/src/split-button.spec.ts +6 -2
- package/src/style-classes.spec.ts +6 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +14 -0
- package/src/view-switcher.spec.ts +149 -0
|
@@ -57,6 +57,9 @@ export type AdwTabViewPageSpec = AdwTabPageSpec<HTMLElement>;
|
|
|
57
57
|
/** The live `<adw-tab-page>` properties, each mapped onto a state setter. */
|
|
58
58
|
const PAGE_ATTRIBUTES = ['title', 'tooltip', 'icon', 'indicator-icon', 'loading', 'needs-attention', 'pinned'];
|
|
59
59
|
|
|
60
|
+
/** `SPACING`: the slack `scroll_to_tab_full` allows before it scrolls (adw-tab-box.c:24). */
|
|
61
|
+
const TAB_SPACING = 5;
|
|
62
|
+
|
|
60
63
|
/**
|
|
61
64
|
* A single page. Declared as a child of <adw-tab-view>; the element itself becomes the
|
|
62
65
|
* page panel, so its attributes keep driving the tab after connect — a renderer that
|
|
@@ -497,10 +500,13 @@ export class AdwTabView extends HTMLElement {
|
|
|
497
500
|
|
|
498
501
|
tab.appendChild(createAdwIcon(null, 'adw-tab-indicator'));
|
|
499
502
|
|
|
500
|
-
// Close affordance — a small flat button drawn with a CSS "×" glyph
|
|
501
|
-
// `window-close`
|
|
502
|
-
//
|
|
503
|
-
//
|
|
503
|
+
// Close affordance — a small flat button drawn with a CSS "×" glyph. NOT because
|
|
504
|
+
// the glyph is unavailable: `window-close` is in the ICONS map and has a mask class
|
|
505
|
+
// (the comment that used to stand here said otherwise, and was read by two more
|
|
506
|
+
// widgets). It is a SIZING difference — upstream's close button is 24px around a
|
|
507
|
+
// 16px symbolic (adw-tab.ui:77), this strip's chip budgets 18px, and a 16px-grid
|
|
508
|
+
// symbolic scaled into it is a design change, not a rename.
|
|
509
|
+
// `can-focus=False` in C, so it stays out of the tab order here too.
|
|
504
510
|
const close = document.createElement('button');
|
|
505
511
|
close.type = 'button';
|
|
506
512
|
close.className = 'adw-tab-close';
|
|
@@ -581,6 +587,20 @@ export class AdwTabView extends HTMLElement {
|
|
|
581
587
|
this._refreshCloseVisibility(id);
|
|
582
588
|
}
|
|
583
589
|
|
|
590
|
+
/**
|
|
591
|
+
* `get_tab_position` in the BAR's scroll space: how far the chip sits from the
|
|
592
|
+
* strip's scroll origin, the one coordinate both `scroll_to_tab_full` and
|
|
593
|
+
* `update_visible` compare against the adjustment.
|
|
594
|
+
*
|
|
595
|
+
* Never `offsetLeft`. Nothing in this widget is positioned, so a chip's offsetParent
|
|
596
|
+
* is whichever positioned ancestor the HOST page happens to have; a tab view that
|
|
597
|
+
* merely sits indented then measured every chip against the wrong origin and
|
|
598
|
+
* reported all of them clipped.
|
|
599
|
+
*/
|
|
600
|
+
private _tabPosition(tab: HTMLElement): number {
|
|
601
|
+
return tab.getBoundingClientRect().left - this._barEl.getBoundingClientRect().left + this._barEl.scrollLeft;
|
|
602
|
+
}
|
|
603
|
+
|
|
584
604
|
/**
|
|
585
605
|
* `tabCloseVisible`, with its pinned gate. `dragging` is constantly false — tab
|
|
586
606
|
* drag-and-drop is compositor work and is not modelled — and `fullyVisible` is
|
|
@@ -595,8 +615,14 @@ export class AdwTabView extends HTMLElement {
|
|
|
595
615
|
if (!close) return;
|
|
596
616
|
|
|
597
617
|
const barLeft = this._barEl.scrollLeft;
|
|
598
|
-
const
|
|
599
|
-
|
|
618
|
+
const pos = this._tabPosition(tab);
|
|
619
|
+
// `update_visible` (adw-tab-box.c:769-797). C also demands SPACING of slack on
|
|
620
|
+
// both sides; that term is deliberately NOT transplanted, because it is not
|
|
621
|
+
// slack there either: the allocation loop starts the first tab at `pos = SPACING`
|
|
622
|
+
// and puts SPACING between tabs (:3270-3286), so `pos - SPACING >= value` is
|
|
623
|
+
// exactly "flush against the leading edge". This strip has no such gaps, so the
|
|
624
|
+
// same term would report the first chip clipped and hide its close button.
|
|
625
|
+
const fullyVisible = pos >= barLeft && pos + tab.offsetWidth <= barLeft + this._barEl.clientWidth;
|
|
600
626
|
close.hidden = !tabCloseVisible({
|
|
601
627
|
hovering: this._hovered.has(id),
|
|
602
628
|
fullyVisible,
|
|
@@ -608,6 +634,7 @@ export class AdwTabView extends HTMLElement {
|
|
|
608
634
|
|
|
609
635
|
private _onSelectionChange(change: TabViewSelectionChange): void {
|
|
610
636
|
this._applyActiveState();
|
|
637
|
+
this._scrollSelectedTabIntoBar();
|
|
611
638
|
this._reflectSelected();
|
|
612
639
|
this.dispatchEvent(
|
|
613
640
|
new CustomEvent('notify::selected-page', {
|
|
@@ -641,6 +668,34 @@ export class AdwTabView extends HTMLElement {
|
|
|
641
668
|
}
|
|
642
669
|
}
|
|
643
670
|
|
|
671
|
+
/**
|
|
672
|
+
* `scroll_to_tab_full` (adw-tab-box.c:928-961), called on every selection the way
|
|
673
|
+
* `select_page` calls it (:1728): bring the selected chip inside the BAR's own
|
|
674
|
+
* scroll window, padded by half of whichever is smaller, the chip or the leftover.
|
|
675
|
+
*
|
|
676
|
+
* The bar is the only thing C ever scrolls for a tab: `scroll_to_tab_full` writes
|
|
677
|
+
* `self->adjustment`, the strip's own adjustment, and nothing else. The DOM has no
|
|
678
|
+
* such narrow API: `focus()` and `scrollIntoView()` both walk EVERY scrollable
|
|
679
|
+
* ancestor up to the window. So this moves `scrollLeft` by hand, and the focus call
|
|
680
|
+
* below passes `preventScroll`.
|
|
681
|
+
*/
|
|
682
|
+
private _scrollSelectedTabIntoBar(): void {
|
|
683
|
+
const id = this._state.selectedId;
|
|
684
|
+
const tab = id === null ? undefined : this._tabs.get(id);
|
|
685
|
+
if (!tab) return;
|
|
686
|
+
const pageSize = this._barEl.clientWidth;
|
|
687
|
+
const width = tab.offsetWidth;
|
|
688
|
+
// Unmeasured (detached, or still inside connectedCallback): nothing to scroll to.
|
|
689
|
+
if (pageSize <= 0 || width <= 0) return;
|
|
690
|
+
|
|
691
|
+
const value = this._barEl.scrollLeft;
|
|
692
|
+
const pos = this._tabPosition(tab);
|
|
693
|
+
const padding = Math.min(width, pageSize - width) / 2;
|
|
694
|
+
if (pos - TAB_SPACING < value) this._barEl.scrollLeft = pos - padding;
|
|
695
|
+
else if (pos + width + TAB_SPACING > value + pageSize)
|
|
696
|
+
this._barEl.scrollLeft = pos + width + padding - pageSize;
|
|
697
|
+
}
|
|
698
|
+
|
|
644
699
|
private _applyBarVisibility(): void {
|
|
645
700
|
const revealed = tabsRevealed({
|
|
646
701
|
autohide: this.autohide,
|
|
@@ -710,9 +765,17 @@ export class AdwTabView extends HTMLElement {
|
|
|
710
765
|
event.preventDefault();
|
|
711
766
|
// Roving tabindex: the newly-active tab is the only focusable one, so
|
|
712
767
|
// focus has to travel with it or the next keypress goes nowhere.
|
|
768
|
+
//
|
|
769
|
+
// `preventScroll` because a bare `focus()` scrolls every scrollable ancestor
|
|
770
|
+
// up to and including the WINDOW, while C only ever moves the tab strip
|
|
771
|
+
// (`scroll_to_tab_full` writes `self->adjustment`, adw-tab-box.c:928-961).
|
|
772
|
+
// Measured in a long documentation page: one ArrowRight took the window from
|
|
773
|
+
// y=1800 to y=8367, i.e. switching a tab yanked the whole document to the widget.
|
|
774
|
+
// `_scrollSelectedTabIntoBar` (already run by the selection change) is the
|
|
775
|
+
// strip-local scroll that is supposed to happen instead.
|
|
713
776
|
if (inBar) {
|
|
714
777
|
const id = this._state.selectedId;
|
|
715
|
-
if (id !== null) this._tabs.get(id)?.focus();
|
|
778
|
+
if (id !== null) this._tabs.get(id)?.focus({ preventScroll: true });
|
|
716
779
|
}
|
|
717
780
|
}
|
|
718
781
|
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// queue/auto-dismiss state machine composed from @gjsify/adwaita-core.
|
|
19
19
|
|
|
20
20
|
import { AdwToast, AdwToastQueue } from '@gjsify/adwaita-core';
|
|
21
|
+
|
|
22
|
+
import { createAdwIcon } from './adw-icon.js';
|
|
21
23
|
import type { ToastScheduler, ToastTimerHandle } from '@gjsify/adwaita-core';
|
|
22
24
|
|
|
23
25
|
// The timing seam is re-exported so a consumer can type a custom scheduler for
|
|
@@ -183,11 +185,16 @@ export class AdwToastOverlay extends HTMLElement {
|
|
|
183
185
|
el.appendChild(button);
|
|
184
186
|
}
|
|
185
187
|
|
|
186
|
-
// Close affordance
|
|
188
|
+
// Close affordance — the real `window-close` glyph, which is what upstream puts
|
|
189
|
+
// here (adw-toast-widget.ui:64) and what this port drew with two rotated CSS bars
|
|
190
|
+
// under a comment claiming no such icon shipped. It does, and the toast is the one
|
|
191
|
+
// close button in the package already sized the way upstream sizes it: a 16px
|
|
192
|
+
// symbolic in a 24px circle, so the swap is the name and nothing else.
|
|
187
193
|
const close = document.createElement('button');
|
|
188
194
|
close.type = 'button';
|
|
189
195
|
close.className = 'adw-toast-close-button';
|
|
190
196
|
close.setAttribute('aria-label', 'Close');
|
|
197
|
+
close.appendChild(createAdwIcon('window-close'));
|
|
191
198
|
close.addEventListener('click', () => this._queue.dismiss());
|
|
192
199
|
el.appendChild(close);
|
|
193
200
|
|
|
@@ -7,7 +7,44 @@
|
|
|
7
7
|
// The SELECTION state machine (the segment list plus the guarded, no-op-on-same active
|
|
8
8
|
// index) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
|
|
9
9
|
// {@link ToggleGroupState}; this element keeps only the DOM half — the buttons, the active
|
|
10
|
-
// pill /
|
|
10
|
+
// pill / checked reflection, the keyboard and the event.
|
|
11
|
+
//
|
|
12
|
+
// THE ROLE IS A RADIO GROUP, not a tab list, and the element had neither. Upstream
|
|
13
|
+
// declares `GTK_ACCESSIBLE_ROLE_RADIO_GROUP` (adw-toggle-group.c:1191) and reads the
|
|
14
|
+
// group's CURRENT role when it builds each toggle: a group already declared a tab list
|
|
15
|
+
// gets `GTK_ACCESSIBLE_ROLE_TAB` children, everything else `GTK_ACCESSIBLE_ROLE_RADIO`
|
|
16
|
+
// (:857-865, under the comment "Special case for AdwInlineViewSwitcher" — that switcher
|
|
17
|
+
// builds exactly this widget with `TAB_LIST`, adw-inline-view-switcher.c:702). Both
|
|
18
|
+
// branches are ported, in the same order, because a consumer that declares the tab list
|
|
19
|
+
// upstream sanctions would otherwise get radios inside it.
|
|
20
|
+
//
|
|
21
|
+
// `aria-pressed` was the state before, and it belongs to NEITHER role — it is the
|
|
22
|
+
// toolbar toggle-button pattern, where each button is independent. The state of one of
|
|
23
|
+
// N mutually exclusive choices is `aria-checked` under `radio` and `aria-selected` under
|
|
24
|
+
// `tab`, so a screen reader was told these were three separate on/off buttons.
|
|
25
|
+
//
|
|
26
|
+
// THE KEYBOARD is one tab stop and arrows inside, which is the roving tabindex
|
|
27
|
+
// `elements/roving-focus.ts` implements — and `adw_toggle_group_focus`
|
|
28
|
+
// (adw-toggle-group.c:1045) is the citation that module is built on. Tab in either
|
|
29
|
+
// direction is PROPAGATED (:1059-1060), i.e. focus leaves the group; every other
|
|
30
|
+
// direction goes to `adw_widget_focus_child` (:1062), i.e. moves within it. Which button
|
|
31
|
+
// Tab enters on is `adw_toggle_group_grab_focus` (:1066): the ACTIVE toggle's button,
|
|
32
|
+
// never the first. The web element had three plain tab stops and no arrow keys at all,
|
|
33
|
+
// so it stayed operable — this change turns those three stops into one, which is visible
|
|
34
|
+
// to anything that tabs through a toggle group.
|
|
35
|
+
//
|
|
36
|
+
// HORIZONTAL ONLY, and the reason is not the missing `orientation` property. The C looks
|
|
37
|
+
// orientation-blind — `adw_toggle_group_focus` filters the two Tab directions and hands
|
|
38
|
+
// every other one on without consulting `self->orientation` — but the axis is decided
|
|
39
|
+
// one level down, GEOMETRICALLY. `adw_widget_focus_child` is `focus_move`
|
|
40
|
+
// (adw-widget-utils.c:428), which sorts through `focus_sort` (:388), which dispatches
|
|
41
|
+
// UP/DOWN into `focus_sort_up_down` (:298). That sorter DELETES every sibling with no
|
|
42
|
+
// horizontal overlap with the focused child (:339-342) — which, in a row of toggles, is
|
|
43
|
+
// all of them. The list is left holding only the focused button, `gtk_widget_child_focus`
|
|
44
|
+
// on a leaf that already has focus returns FALSE, and the press PROPAGATES: upstream,
|
|
45
|
+
// ArrowDown in a horizontal toggle group leaves the group. So `'horizontal'` is what the
|
|
46
|
+
// C does for the only layout this element has, it matches the three sibling tab lists,
|
|
47
|
+
// and it leaves ArrowUp/ArrowDown to the page instead of swallowing a scroll.
|
|
11
48
|
//
|
|
12
49
|
// Reference: refs/libadwaita/src/adw-toggle-group.c (AdwToggleGroup behaviour)
|
|
13
50
|
// Reference: refs/libadwaita/src/stylesheet/widgets/_toggle-group.scss
|
|
@@ -20,6 +57,7 @@
|
|
|
20
57
|
import { ToggleGroupState } from '@gjsify/adwaita-core';
|
|
21
58
|
|
|
22
59
|
import { createAdwIcon } from './adw-icon.js';
|
|
60
|
+
import { attachRovingFocus } from './roving-focus.js';
|
|
23
61
|
|
|
24
62
|
/** A single toggle. Children of <adw-toggle-group>; consumed at connect time. */
|
|
25
63
|
export class AdwToggle extends HTMLElement {
|
|
@@ -34,6 +72,8 @@ export class AdwToggleGroup extends HTMLElement {
|
|
|
34
72
|
/** The headless segment list + guarded selection state machine (ADR 0004). */
|
|
35
73
|
private readonly _state = new ToggleGroupState();
|
|
36
74
|
private _initialized = false;
|
|
75
|
+
/** `aria-checked` under `radio`, `aria-selected` under `tab` — decided at connect. */
|
|
76
|
+
private _stateAttr: 'aria-checked' | 'aria-selected' = 'aria-checked';
|
|
37
77
|
|
|
38
78
|
static get observedAttributes() {
|
|
39
79
|
return ['active', 'flat', 'round'];
|
|
@@ -56,13 +96,48 @@ export class AdwToggleGroup extends HTMLElement {
|
|
|
56
96
|
// subtree — their label / icon-name become the rendered buttons.
|
|
57
97
|
const toggles = Array.from(this.querySelectorAll('adw-toggle')) as AdwToggle[];
|
|
58
98
|
|
|
99
|
+
// `gtk_widget_class_set_accessible_role` sets a CLASS DEFAULT that an instance's
|
|
100
|
+
// construct-time `accessible-role` overrides, and `add_toggle` then reads
|
|
101
|
+
// whatever the instance actually has. So a declared role is kept — only an
|
|
102
|
+
// undeclared one is filled in — and `tablist` is the single branch, exactly as
|
|
103
|
+
// in the C: any other role still gets radio children.
|
|
104
|
+
// `|| null`, not `?? null`: `role=""` is not a declared role, and treating it as
|
|
105
|
+
// one left the group with no role at all while its children were radios.
|
|
106
|
+
const declared = this.getAttribute('role')?.trim() || null;
|
|
107
|
+
if (declared === null) this.setAttribute('role', 'radiogroup');
|
|
108
|
+
const isTabList = declared === 'tablist';
|
|
109
|
+
const toggleRole = isTabList ? 'tab' : 'radio';
|
|
110
|
+
this._stateAttr = isTabList ? 'aria-selected' : 'aria-checked';
|
|
111
|
+
// Read ONCE, which is what upstream allows: `GtkAccessible:accessible-role` is
|
|
112
|
+
// writable but documented "The accessible role cannot be changed once set", and
|
|
113
|
+
// the only public setter is the CLASS-level `gtk_widget_class_set_accessible_role`
|
|
114
|
+
// — there is no instance setter at all. So a later `role=` change has no upstream
|
|
115
|
+
// meaning, and re-labelling the toggles on it would be behaviour this port
|
|
116
|
+
// invented. Declare it in markup, or before the element is connected.
|
|
117
|
+
//
|
|
118
|
+
// (It is NOT construct-only, which an earlier draft of this comment claimed:
|
|
119
|
+
// `Gtk-4.0.gir` marks the property `writable` with no construct flag at all. The
|
|
120
|
+
// rule is the documented one above, which is the stronger citation anyway.)
|
|
121
|
+
|
|
59
122
|
this._innerEl = document.createElement('div');
|
|
60
123
|
this._innerEl.className = 'adw-toggle-group-inner';
|
|
124
|
+
// The toggles are this element's own children upstream; the wrapper is a web-only
|
|
125
|
+
// flex box with no counterpart in the C at all. Without a role it is a `generic`
|
|
126
|
+
// sitting between a radio group and its radios, so it declares itself away.
|
|
127
|
+
//
|
|
128
|
+
// NO upstream precedent is claimed for this, and an earlier draft of this comment
|
|
129
|
+
// invented one: every `GTK_ACCESSIBLE_ROLE_PRESENTATION` in libadwaita is a leaf
|
|
130
|
+
// decoration (an icon, a gizmo), never a layout container between a role-bearing
|
|
131
|
+
// parent and role-bearing children — because GTK has no such container here to
|
|
132
|
+
// annotate. That is exactly why the attribute is needed on this side and nowhere
|
|
133
|
+
// in the C.
|
|
134
|
+
this._innerEl.setAttribute('role', 'none');
|
|
61
135
|
|
|
62
136
|
this._buttons = toggles.map((toggle, index) => {
|
|
63
137
|
const btn = document.createElement('button');
|
|
64
138
|
btn.type = 'button';
|
|
65
139
|
btn.className = 'adw-toggle';
|
|
140
|
+
btn.setAttribute('role', toggleRole);
|
|
66
141
|
|
|
67
142
|
const label = toggle.getAttribute('label') ?? '';
|
|
68
143
|
const icon = toggle.getAttribute('icon-name') ?? '';
|
|
@@ -87,6 +162,20 @@ export class AdwToggleGroup extends HTMLElement {
|
|
|
87
162
|
this._state.subscribe(() => this._render());
|
|
88
163
|
this._state.setSelected(this._readActiveAttr());
|
|
89
164
|
this._render();
|
|
165
|
+
|
|
166
|
+
// No `disabled`/`hidden` filter, because no `<adw-toggle>` attribute can produce
|
|
167
|
+
// either — upstream's per-toggle `enabled` (adw-toggle-group.c:871) has no web
|
|
168
|
+
// counterpart yet, and a filter for a state this element cannot reach would be
|
|
169
|
+
// untestable. `AdwToggle.observedAttributes` is pinned in
|
|
170
|
+
// `keyboard-operable.spec.ts` so the day it grows, this decision has to be
|
|
171
|
+
// revisited instead of silently becoming a focus trap.
|
|
172
|
+
attachRovingFocus({
|
|
173
|
+
host: this,
|
|
174
|
+
orientation: 'horizontal',
|
|
175
|
+
items: () => this._buttons,
|
|
176
|
+
// Same path a click takes, so an arrow key cannot drift from a press.
|
|
177
|
+
select: (item) => this._selectIndex(this._buttons.findIndex((btn) => btn === item)),
|
|
178
|
+
});
|
|
90
179
|
}
|
|
91
180
|
|
|
92
181
|
attributeChangedCallback(name: string) {
|
|
@@ -124,7 +213,10 @@ export class AdwToggleGroup extends HTMLElement {
|
|
|
124
213
|
this._buttons.forEach((btn, index) => {
|
|
125
214
|
const isActive = index === active;
|
|
126
215
|
btn.classList.toggle('active', isActive);
|
|
127
|
-
btn.setAttribute(
|
|
216
|
+
btn.setAttribute(this._stateAttr, String(isActive));
|
|
217
|
+
// The roving tabindex: Tab enters on the ACTIVE toggle, the way
|
|
218
|
+
// `adw_toggle_group_grab_focus` grabs it, and leaves the group from there.
|
|
219
|
+
btn.tabIndex = isActive ? 0 : -1;
|
|
128
220
|
});
|
|
129
221
|
}
|
|
130
222
|
}
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
parseToolbarStyle,
|
|
31
31
|
toolbarViewClasses,
|
|
32
32
|
} from '@gjsify/adwaita-core';
|
|
33
|
+
import { bindEmptySections } from '../empty-sections.js';
|
|
34
|
+
import { AdwScrollShading } from '../scroll-shading.js';
|
|
33
35
|
|
|
34
36
|
export class AdwToolbarView extends HTMLElement {
|
|
35
37
|
private _topEl!: HTMLDivElement;
|
|
@@ -37,6 +39,14 @@ export class AdwToolbarView extends HTMLElement {
|
|
|
37
39
|
private _bottomEl!: HTMLDivElement;
|
|
38
40
|
private _initialized = false;
|
|
39
41
|
private _resize: ResizeObserver | null = null;
|
|
42
|
+
/**
|
|
43
|
+
* The scroll indicators for every scroller under this view.
|
|
44
|
+
*
|
|
45
|
+
* Owned HERE and not by a scroller widget, because upstream scopes them here:
|
|
46
|
+
* `toolbarview.undershoot-top scrolledwindow` shades whatever scrolls inside,
|
|
47
|
+
* however deeply nested — a split view's two panes included.
|
|
48
|
+
*/
|
|
49
|
+
private _shading: AdwScrollShading | null = null;
|
|
40
50
|
|
|
41
51
|
static get observedAttributes() {
|
|
42
52
|
return ['top-bar-style', 'bottom-bar-style', 'extend-content-to-top-edge', 'extend-content-to-bottom-edge'];
|
|
@@ -79,14 +89,21 @@ export class AdwToolbarView extends HTMLElement {
|
|
|
79
89
|
this._bottomEl.className = 'adw-toolbar-view-bottom';
|
|
80
90
|
for (const child of bottomChildren) this._bottomEl.appendChild(child);
|
|
81
91
|
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
// A bar appended imperatively — which `topBar` invites — is a childList
|
|
93
|
+
// change, not an attribute one, so the empty/non-empty decision cannot be a
|
|
94
|
+
// one-shot read here. The ResizeObserver below then sees the bar go 0 → 48
|
|
95
|
+
// and the undershoot classes follow on their own.
|
|
96
|
+
bindEmptySections(this._topEl, this._bottomEl);
|
|
84
97
|
|
|
85
98
|
this.replaceChildren(this._topEl, this._contentEl, this._bottomEl);
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
// `update_undershoots` runs from size_allocate, so the classes have to
|
|
89
102
|
// follow the bars' real heights rather than a one-shot read.
|
|
103
|
+
// Before `_syncClasses`, which hands it the two undershoot flags it gates on.
|
|
104
|
+
this._shading = new AdwScrollShading(this._contentEl);
|
|
105
|
+
this._shading.connect();
|
|
106
|
+
|
|
90
107
|
this._resize = new ResizeObserver(() => this._syncClasses());
|
|
91
108
|
this._resize.observe(this._topEl);
|
|
92
109
|
this._resize.observe(this._bottomEl);
|
|
@@ -96,6 +113,8 @@ export class AdwToolbarView extends HTMLElement {
|
|
|
96
113
|
disconnectedCallback() {
|
|
97
114
|
this._resize?.disconnect();
|
|
98
115
|
this._resize = null;
|
|
116
|
+
this._shading?.disconnect();
|
|
117
|
+
this._shading = null;
|
|
99
118
|
}
|
|
100
119
|
|
|
101
120
|
attributeChangedCallback() {
|
|
@@ -117,6 +136,13 @@ export class AdwToolbarView extends HTMLElement {
|
|
|
117
136
|
this._topEl.classList.toggle(cls, topBar.includes(cls));
|
|
118
137
|
this._bottomEl.classList.toggle(cls, bottomBar.includes(cls));
|
|
119
138
|
}
|
|
139
|
+
|
|
140
|
+
// The same two flags reach the scrollers, where the shade is actually
|
|
141
|
+
// drawn — a raised bar brings its own shadow and must not get a second one.
|
|
142
|
+
this._shading?.setUndershootEdges({
|
|
143
|
+
top: view.includes('undershoot-top'),
|
|
144
|
+
bottom: view.includes('undershoot-bottom'),
|
|
145
|
+
});
|
|
120
146
|
}
|
|
121
147
|
}
|
|
122
148
|
|
|
@@ -24,6 +24,7 @@ import { ViewSwitcherBarState, buildViewSwitcherButtons, viewSwitcherPagesFromSt
|
|
|
24
24
|
import type { AdwViewStack } from './adw-view-stack.js';
|
|
25
25
|
import { applyDescription, applyIndicator, applySwitcherIcon } from './view-switcher-dom.js';
|
|
26
26
|
import { type AdwIcon, createAdwIcon } from './adw-icon.js';
|
|
27
|
+
import { attachRovingFocus } from './roving-focus.js';
|
|
27
28
|
|
|
28
29
|
/** The DOM nodes one bar button owns — the bar paints the derived model onto them. */
|
|
29
30
|
interface BarButtonNodes {
|
|
@@ -88,6 +89,20 @@ export class AdwViewSwitcherBar extends HTMLElement {
|
|
|
88
89
|
this._barEl.className = 'adw-view-switcher-bar-box';
|
|
89
90
|
this._barEl.setAttribute('role', 'tablist');
|
|
90
91
|
this.replaceChildren(this._barEl);
|
|
92
|
+
// What makes the roving tabindex below navigable: without it the unselected
|
|
93
|
+
// buttons are reachable by no key at all.
|
|
94
|
+
attachRovingFocus({
|
|
95
|
+
host: this,
|
|
96
|
+
orientation: 'horizontal',
|
|
97
|
+
items: () => this._nodes.map((nodes) => nodes.button).filter((button) => !button.hidden),
|
|
98
|
+
select: (item) => {
|
|
99
|
+
const stack = this._stack;
|
|
100
|
+
if (!stack) return;
|
|
101
|
+
const index = Number(item.dataset.pageIndex);
|
|
102
|
+
// Re-assigning the current index would re-run the stack's transition.
|
|
103
|
+
if (stack.visibleChildIndex !== index) stack.visibleChildIndex = index;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
91
106
|
}
|
|
92
107
|
this._barState.setReveal(this._readReveal());
|
|
93
108
|
|
|
@@ -208,6 +223,9 @@ export class AdwViewSwitcherBar extends HTMLElement {
|
|
|
208
223
|
button.type = 'button';
|
|
209
224
|
button.className = 'adw-view-switcher-bar-button';
|
|
210
225
|
button.setAttribute('role', 'tab');
|
|
226
|
+
// The page index, so a keyboard move resolves an item back to a page without
|
|
227
|
+
// recomputing the mapping — the spelling `<adw-inline-view-switcher>` uses.
|
|
228
|
+
button.dataset.pageIndex = String(pageIndex);
|
|
211
229
|
|
|
212
230
|
const icon = createAdwIcon(null, 'adw-view-switcher-bar-icon');
|
|
213
231
|
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
readSwitcherPage,
|
|
56
56
|
} from './view-switcher-dom.js';
|
|
57
57
|
import { type AdwIcon, createAdwIcon } from './adw-icon.js';
|
|
58
|
+
import { attachRovingFocus } from './roving-focus.js';
|
|
58
59
|
|
|
59
60
|
/** The DOM nodes one page owns — the switcher paints the derived model onto them. */
|
|
60
61
|
interface PageNodes {
|
|
@@ -171,6 +172,16 @@ export class AdwViewSwitcher extends HTMLElement {
|
|
|
171
172
|
this._barEl = document.createElement('div');
|
|
172
173
|
this._barEl.className = 'adw-view-switcher-bar';
|
|
173
174
|
this._barEl.setAttribute('role', 'tablist');
|
|
175
|
+
// What makes the roving tabindex below navigable: without it the unselected
|
|
176
|
+
// buttons are reachable by no key at all.
|
|
177
|
+
attachRovingFocus({
|
|
178
|
+
host: this,
|
|
179
|
+
orientation: 'horizontal',
|
|
180
|
+
items: () => this._nodes.map((nodes) => nodes.button).filter((button) => !button.hidden),
|
|
181
|
+
select: (item) => {
|
|
182
|
+
this._state.setSelected(Number(item.dataset.pageIndex));
|
|
183
|
+
},
|
|
184
|
+
});
|
|
174
185
|
|
|
175
186
|
this._contentEl = document.createElement('div');
|
|
176
187
|
this._contentEl.className = 'adw-view-switcher-content';
|
|
@@ -254,6 +265,9 @@ export class AdwViewSwitcher extends HTMLElement {
|
|
|
254
265
|
button.type = 'button';
|
|
255
266
|
button.className = 'adw-view-switcher-button';
|
|
256
267
|
button.setAttribute('role', 'tab');
|
|
268
|
+
// The page index, so a keyboard move resolves an item back to a page without
|
|
269
|
+
// recomputing the mapping — the spelling `<adw-inline-view-switcher>` uses.
|
|
270
|
+
button.dataset.pageIndex = String(index);
|
|
257
271
|
|
|
258
272
|
// Both children always exist, as in AdwViewSwitcherButton's template — the icon
|
|
259
273
|
// carries `image-missing` rather than disappearing.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The one focus trap behind every `aria-modal="true"` surface in this package.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT
|
|
4
|
+
//
|
|
5
|
+
// Four elements set `aria-modal="true"`. One of them honoured it. Measured in Firefox
|
|
6
|
+
// on a page whose next tabbable element was a view-switcher button: focus the last
|
|
7
|
+
// control inside `<adw-alert-dialog>`, press Tab once, and focus landed on
|
|
8
|
+
// `.adw-view-switcher-bar-button` — outside the dialog, behind its own scrim, on a
|
|
9
|
+
// control the user cannot see. `<adw-about-dialog>` and `<adw-preferences-dialog>` did
|
|
10
|
+
// the same. Closing the dialog then left focus there: none of the three returned it to
|
|
11
|
+
// whatever opened them, so a keyboard user finished the interaction somewhere they never
|
|
12
|
+
// asked to be. `aria-modal` tells assistive technology the rest of the page is inert; it
|
|
13
|
+
// changes NOTHING about where the browser sends Tab. Only `<adw-dialog>` implemented the
|
|
14
|
+
// half that is not free, and this module is that half, lifted out of it verbatim.
|
|
15
|
+
//
|
|
16
|
+
// WHY IT IS A MODULE AND NOT A COPY
|
|
17
|
+
//
|
|
18
|
+
// `aria-modal` — the attribute and the reflected `.ariaModal` property alike — now lives
|
|
19
|
+
// HERE and nowhere else, held by the `[trap]` arm of
|
|
20
|
+
// `scripts/check-adwaita-keyboard-contract.mjs`. That is what makes a fifth modal surface
|
|
21
|
+
// impossible to add without a trap: the declaration and the behaviour it promises are
|
|
22
|
+
// the same call.
|
|
23
|
+
//
|
|
24
|
+
// Reference: refs/libadwaita/src/adw-dialog.c — GTK_ACCESSIBLE_ROLE_DIALOG (:1290) and
|
|
25
|
+
// the dialog's own focus handling, which cannot leave the widget because GTK confines
|
|
26
|
+
// focus to the presented dialog by construction. The DOM has no such confinement, so it
|
|
27
|
+
// is spelled out.
|
|
28
|
+
// Reference: refs/libadwaita/src/adw-alert-dialog.c:1103 — GTK_ACCESSIBLE_ROLE_ALERT_DIALOG.
|
|
29
|
+
// Reference: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ — the Tab/Shift+Tab
|
|
30
|
+
// wrap and the return-focus rule, for the part upstream leaves to the platform.
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Everything the browser will hand focus to on Tab. `[tabindex="-1"]` is excluded
|
|
34
|
+
* deliberately: it is programmatically focusable and NOT in the tab order, so treating
|
|
35
|
+
* it as a trap boundary would make Tab stop on a scrim.
|
|
36
|
+
*/
|
|
37
|
+
const FOCUSABLE_SELECTOR = [
|
|
38
|
+
'a[href]',
|
|
39
|
+
'button:not([disabled])',
|
|
40
|
+
'input:not([disabled])',
|
|
41
|
+
'select:not([disabled])',
|
|
42
|
+
'textarea:not([disabled])',
|
|
43
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
44
|
+
].join(',');
|
|
45
|
+
|
|
46
|
+
/** The two roles a modal surface in this package declares. */
|
|
47
|
+
export type AdwModalRole = 'dialog' | 'alertdialog';
|
|
48
|
+
|
|
49
|
+
export interface AdwModalSurfaceInit {
|
|
50
|
+
/**
|
|
51
|
+
* The custom element itself. It receives the keydown listener, because a dialog that
|
|
52
|
+
* moves focus to its HOST — `<adw-preferences-dialog>` does — would otherwise press
|
|
53
|
+
* Tab against a listener on a descendant that never sees the event.
|
|
54
|
+
*/
|
|
55
|
+
host: HTMLElement;
|
|
56
|
+
/** The element carrying the role, `aria-modal`, and the boundary focus may not cross. */
|
|
57
|
+
surface: HTMLElement;
|
|
58
|
+
/** The WAI-ARIA role for {@link AdwModalSurfaceInit.surface}. */
|
|
59
|
+
role: AdwModalRole;
|
|
60
|
+
/** Whether the dialog is presented right now. */
|
|
61
|
+
isOpen: () => boolean;
|
|
62
|
+
/** Escape while open. Dismissal is the element's own business (`can-close`, close-response). */
|
|
63
|
+
onEscape: () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Where focus goes on present, when the element wants something other than the first
|
|
66
|
+
* focusable control — `<adw-alert-dialog>` starts on its DEFAULT response, not on the
|
|
67
|
+
* leftmost button. It is handed {@link AdwModalSurface.focusables}, so a rule like
|
|
68
|
+
* "the first control in the content area" is a `find` over that list rather than a
|
|
69
|
+
* second copy of the filter.
|
|
70
|
+
*
|
|
71
|
+
* Returning nothing — or an element that is not in that list — falls back to the
|
|
72
|
+
* first focusable, then to the surface itself.
|
|
73
|
+
*/
|
|
74
|
+
initialFocus?: (focusables: readonly HTMLElement[]) => HTMLElement | null | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A modal surface: it declares `aria-modal`, keeps Tab inside itself, and gives focus
|
|
79
|
+
* back to whatever had it when the dialog appeared.
|
|
80
|
+
*
|
|
81
|
+
* The element drives it with two calls — {@link present} when it opens and
|
|
82
|
+
* {@link dismiss} when it closes — and keeps its own dismissal rules.
|
|
83
|
+
*/
|
|
84
|
+
export class AdwModalSurface {
|
|
85
|
+
private readonly _init: AdwModalSurfaceInit;
|
|
86
|
+
/** Focused before `present()`, restored by `dismiss()`. */
|
|
87
|
+
private _previouslyFocused: HTMLElement | null = null;
|
|
88
|
+
|
|
89
|
+
constructor(init: AdwModalSurfaceInit) {
|
|
90
|
+
this._init = init;
|
|
91
|
+
init.surface.setAttribute('role', init.role);
|
|
92
|
+
init.surface.setAttribute('aria-modal', 'true');
|
|
93
|
+
// The surface is the fallback focus target, so it has to be able to hold focus
|
|
94
|
+
// even when it contains no control at all. Not in the tab order: -1.
|
|
95
|
+
if (!init.surface.hasAttribute('tabindex')) init.surface.tabIndex = -1;
|
|
96
|
+
init.host.addEventListener('keydown', (event) => this._onKeyDown(event));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The controls inside the surface, in tab order, that a `focus()` can actually reach. */
|
|
100
|
+
focusables(): HTMLElement[] {
|
|
101
|
+
return Array.from(this._init.surface.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
102
|
+
// `getClientRects()` and not `hidden`: `<adw-about-dialog>` keeps every
|
|
103
|
+
// navigation page in the DOM and shows one, so the non-visible pages'
|
|
104
|
+
// buttons match the selector, are not `[hidden]`, and cannot take focus.
|
|
105
|
+
// Tabbing onto one silently leaves focus where it was — a trap that lets go.
|
|
106
|
+
(el) => !el.hasAttribute('disabled') && !el.hidden && el.getClientRects().length > 0,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remember where focus was, then move it inside.
|
|
112
|
+
*
|
|
113
|
+
* SYNCHRONOUS, and that was measured rather than assumed. `<adw-about-dialog>` used to
|
|
114
|
+
* defer this by a `requestAnimationFrame`, which reads as "the sheet is not laid out
|
|
115
|
+
* yet" — it is not. Every element here toggles its `open` class before calling this,
|
|
116
|
+
* so the surface is already displayed; reading `document.activeElement` in the same
|
|
117
|
+
* task that sets `open` finds it inside the dialog for all four. A retry frame would
|
|
118
|
+
* be a branch nothing can reach, and a spec asserting focus one frame late would pass
|
|
119
|
+
* over a dialog that never focused anything at all.
|
|
120
|
+
*/
|
|
121
|
+
present(): void {
|
|
122
|
+
const active = document.activeElement;
|
|
123
|
+
this._previouslyFocused = active instanceof HTMLElement && active !== this._init.host ? active : null;
|
|
124
|
+
const focusables = this.focusables();
|
|
125
|
+
// A `??` chain falls through when the element asks for NOTHING, never when it asks
|
|
126
|
+
// for something the browser refuses — so a request is honoured only if it is in the
|
|
127
|
+
// list, the same test the trap uses for its edges. Measured in Firefox before this
|
|
128
|
+
// filter: an `<adw-alert-dialog>` whose default response was DISABLED handed that
|
|
129
|
+
// button back, `focus()` on it was a no-op, and the dialog opened with
|
|
130
|
+
// `document.activeElement` still on the opener OUTSIDE it. The keydown listener
|
|
131
|
+
// sits on the host, so focus never entered, no key ever reached the trap, and a
|
|
132
|
+
// real Escape left `open` true — unreachable and undismissable at once.
|
|
133
|
+
const wanted = this._init.initialFocus?.(focusables);
|
|
134
|
+
const target =
|
|
135
|
+
(wanted && focusables.includes(wanted) ? wanted : undefined) ?? focusables[0] ?? this._init.surface;
|
|
136
|
+
target.focus();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Give focus back to whatever had it before `present()`. */
|
|
140
|
+
dismiss(): void {
|
|
141
|
+
const restore = this._previouslyFocused;
|
|
142
|
+
this._previouslyFocused = null;
|
|
143
|
+
if (restore && restore.isConnected) restore.focus();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private _onKeyDown(event: KeyboardEvent): void {
|
|
147
|
+
if (!this._init.isOpen()) return;
|
|
148
|
+
if (event.key === 'Escape') {
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
this._init.onEscape();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (event.key !== 'Tab') return;
|
|
154
|
+
|
|
155
|
+
// The INNERMOST open surface owns Tab: a dialog presented from inside another one
|
|
156
|
+
// bubbles its keydown to the outer host, which would re-run this wrap against its
|
|
157
|
+
// own edges and undo the move that just happened.
|
|
158
|
+
event.stopPropagation();
|
|
159
|
+
|
|
160
|
+
const focusables = this.focusables();
|
|
161
|
+
if (focusables.length === 0) {
|
|
162
|
+
// Nothing tabbable inside — Tab would leave immediately, so it goes nowhere.
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
this._init.surface.focus();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const first = focusables[0];
|
|
168
|
+
const last = focusables[focusables.length - 1];
|
|
169
|
+
const active = document.activeElement;
|
|
170
|
+
// The wrap is asserted at the EDGES only: in between, the browser's own tab order
|
|
171
|
+
// is already correct and intercepting it would break Shift+Tab through a form.
|
|
172
|
+
if (event.shiftKey && (active === first || active === this._init.surface || active === this._init.host)) {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
last.focus();
|
|
175
|
+
} else if (!event.shiftKey && (active === last || active === this._init.host)) {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
first.focus();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|