@gjsify/adwaita-web 0.41.0 → 0.43.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 +30 -23
- 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 +8 -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-toggle-group.d.ts +2 -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/elements/swipe-drag.d.ts +63 -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 +1 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -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/_icon.scss +47 -0
- package/scss/_icons.generated.scss +18 -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/_status_page.scss +1 -1
- package/scss/_tab_view.scss +1 -1
- package/scss/_toast.scss +6 -24
- package/scss/_variables.scss +23 -3
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/breakpoints.ts +18 -4
- 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 +86 -5
- 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 +7 -4
- 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 +6 -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/elements/swipe-drag.ts +450 -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 +24 -1
- package/src/keyboard-operable.spec.ts +755 -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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Tab stop and Enter/Space activation for the rows that libadwaita makes activatable.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT
|
|
4
|
+
//
|
|
5
|
+
// Measured in Firefox on an `<adw-preferences-group>` holding an activatable
|
|
6
|
+
// `<adw-action-row>`, an `<adw-button-row>` and an `<adw-expander-row>`: Tab reached NONE
|
|
7
|
+
// of them. Every one reported `tabIndex` `-1` and `role` `null` while all three activated
|
|
8
|
+
// on click, so the whole group was operable by mouse and invisible to a keyboard. The only
|
|
9
|
+
// things Tab did reach inside a group were the NATIVE controls a row happens to contain —
|
|
10
|
+
// `<adw-switch-row>`'s bare checkbox and `<adw-combo-row>`'s `<select>` — i.e. the row was
|
|
11
|
+
// never the thing that took focus, even where upstream says it is.
|
|
12
|
+
//
|
|
13
|
+
// GTK gives this free and the web renderer does not inherit it: every one of these extends
|
|
14
|
+
// `GtkListBoxRow`, which is focusable by construction and activates on Enter and Space.
|
|
15
|
+
//
|
|
16
|
+
// WHICH ROWS, AND WHY EACH
|
|
17
|
+
//
|
|
18
|
+
// Derived from the C, not chosen — a row that is a tab stop it should not be turns every
|
|
19
|
+
// static label into a stop, and one that is not becomes unreachable:
|
|
20
|
+
//
|
|
21
|
+
// - `<adw-action-row>` only while `activatable`. A plain row is a label.
|
|
22
|
+
// - `<adw-button-row>` always: `adw-button-row.c:31` says "AdwButtonRow is always
|
|
23
|
+
// activatable", and the upstream template hardcodes it.
|
|
24
|
+
// - `<adw-expander-row>` always, on its HEADER — `adw_expander_row_grab_focus`
|
|
25
|
+
// (adw-expander-row.c:123) forwards focus to `priv->action_row`, so the header row is
|
|
26
|
+
// the focus target and the disclosure control at once.
|
|
27
|
+
// - `<adw-switch-row>` on the ROW, and its slider stops being a tab stop:
|
|
28
|
+
// `adw_switch_row_init` sets `gtk_widget_set_can_focus (self->slider, FALSE)`
|
|
29
|
+
// (adw-switch-row.c:159) and `gtk_list_box_row_set_activatable (…, TRUE)` (:160).
|
|
30
|
+
//
|
|
31
|
+
// `<adw-combo-row>` is deliberately NOT here. Its control is a native `<select>` stretched
|
|
32
|
+
// over the row: already a tab stop, already a combobox to the accessibility tree, already
|
|
33
|
+
// arrow-navigable, and already `disabled` at one option or fewer — which is exactly
|
|
34
|
+
// `gtk_list_box_row_set_activatable (…, n_items > 1)` (adw-combo-row.c:194) without a line
|
|
35
|
+
// of our own. Making the row the tab stop would take focus off it and put a SECOND
|
|
36
|
+
// combobox in the tree.
|
|
37
|
+
//
|
|
38
|
+
// The key set is `GtkListBoxRow`'s: Enter and Space. Space is prevented because the
|
|
39
|
+
// browser would otherwise scroll the page under the row the user just activated.
|
|
40
|
+
|
|
41
|
+
/** What a row needs to become the keyboard's idea of one activatable thing. */
|
|
42
|
+
export interface AdwRowActivationInit {
|
|
43
|
+
/** The element that becomes the tab stop and receives the keys. */
|
|
44
|
+
row: HTMLElement;
|
|
45
|
+
/** Re-read on every {@link AdwRowActivation.sync}: is the row activatable right now? */
|
|
46
|
+
activatable: () => boolean;
|
|
47
|
+
/**
|
|
48
|
+
* What Enter and Space do. Defaults to `row.click()` so a row keeps ONE activation
|
|
49
|
+
* path — the click handler it already has — rather than growing a second one that can
|
|
50
|
+
* drift from it.
|
|
51
|
+
*/
|
|
52
|
+
activate?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AdwRowActivation {
|
|
56
|
+
/** Re-derive the tab stop after anything that can change `activatable()`. */
|
|
57
|
+
sync(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Make `row` a tab stop while it is activatable, and activate it on Enter and Space.
|
|
62
|
+
*
|
|
63
|
+
* The listener is the element's OWN and is bound once, without teardown: it survives a
|
|
64
|
+
* re-parent and is collected with the element, so there is nothing here for
|
|
65
|
+
* `check-adwaita-connect-rebind.mjs` to hold. Only bindings that reach outside the element
|
|
66
|
+
* need releasing and re-arming.
|
|
67
|
+
*/
|
|
68
|
+
export function attachRowActivation(init: AdwRowActivationInit): AdwRowActivation {
|
|
69
|
+
const { row, activatable } = init;
|
|
70
|
+
const activate = init.activate ?? (() => row.click());
|
|
71
|
+
|
|
72
|
+
row.addEventListener('keydown', (event: KeyboardEvent) => {
|
|
73
|
+
// A child control owns its own keys: a `<select>`, an entry's `<input>` and a
|
|
74
|
+
// suffix button all use Enter or Space for something else, and swallowing them
|
|
75
|
+
// here would break the control to activate the row it sits in.
|
|
76
|
+
if (event.target !== row) return;
|
|
77
|
+
if (!activatable()) return;
|
|
78
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
activate();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const sync = () => {
|
|
84
|
+
// `removeAttribute`, not `tabIndex = -1`: a row that is not activatable is not
|
|
85
|
+
// focusable at all upstream, and writing `-1` would make every static label
|
|
86
|
+
// programmatically focusable instead.
|
|
87
|
+
if (activatable()) row.tabIndex = 0;
|
|
88
|
+
else row.removeAttribute('tabindex');
|
|
89
|
+
};
|
|
90
|
+
sync();
|
|
91
|
+
return { sync };
|
|
92
|
+
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Dragging a swipeable widget with the pointer — the DOM half of `AdwSwipeTracker`.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT
|
|
4
|
+
//
|
|
5
|
+
// Measured in Firefox on a bare `<adw-carousel>` with three 440 px pages: a 300 px mouse
|
|
6
|
+
// drag across it moved `position` 0 → 0 and `scrollLeft` 0 → 0. Nothing. A touchpad
|
|
7
|
+
// two-finger swipe moved 0 → 2 and writing `scrollLeft = 440` moved `position` to 1, so
|
|
8
|
+
// the strip was scrollable and the position feedback worked — the gesture was simply not
|
|
9
|
+
// implemented. Upstream it is not optional: `AdwCarousel` turns it on in `init`
|
|
10
|
+
// (`adw_swipe_tracker_set_allow_mouse_drag (self->tracker, TRUE)`, adw-carousel.c:1209)
|
|
11
|
+
// and exposes it as a public property whose default is TRUE (`allow-mouse-drag`, :1091),
|
|
12
|
+
// documented "If the value is FALSE, dragging is only available on touch."
|
|
13
|
+
//
|
|
14
|
+
// WHAT THIS DOES *NOT* TOUCH, and why that is the compatible answer
|
|
15
|
+
//
|
|
16
|
+
// Touch and touchpad swiping already work, because the widget is a real scroll container
|
|
17
|
+
// with `scroll-snap-type` and the browser owns those gestures — momentum, rubber-band and
|
|
18
|
+
// snapping included. GTK is likewise the platform there. Taking them over to run the
|
|
19
|
+
// tracker on every touchmove would replace a native gesture with an imitation of one, so
|
|
20
|
+
// this module handles the ONE input the browser gives no gesture for: a held mouse button.
|
|
21
|
+
//
|
|
22
|
+
// The consequence to know about is a real divergence and it is ledgered, not hidden: GTK
|
|
23
|
+
// runs its touchpad scroll through the same tracker, so `allow-long-swipes: false` bounds
|
|
24
|
+
// a touchpad flick to the adjacent page there and does not here — measured, a two-finger
|
|
25
|
+
// flick crossed two pages.
|
|
26
|
+
//
|
|
27
|
+
// WHY POINTER EVENTS AND NOT touch/mouse PAIRS
|
|
28
|
+
//
|
|
29
|
+
// `pointerType` is a fact about the event; `'ontouchstart' in window` is a fact about the
|
|
30
|
+
// device. A laptop with a touchscreen is both, so a listener set chosen from the device —
|
|
31
|
+
// the shape `@ribajs/extras`' touch-events service uses, ported from jQuery Mobile — picks
|
|
32
|
+
// one path for a machine that needs both. One set of pointer listeners and a check on
|
|
33
|
+
// `pointerType` cannot get that wrong.
|
|
34
|
+
//
|
|
35
|
+
// THE CLAIM IS A STATE MACHINE, NOT A BOOLEAN, and compressing it into one cost three
|
|
36
|
+
// behaviours. `drag_update_cb` (adw-swipe-tracker.c:640-750) runs in this order, and the
|
|
37
|
+
// order is the point:
|
|
38
|
+
//
|
|
39
|
+
// 1. `append_to_history` — UNCONDITIONAL (:670), ahead of everything below. A move made
|
|
40
|
+
// before the gesture is claimed still counts toward the velocity.
|
|
41
|
+
// 2. at STATE_NONE, the axis is checked and the gesture is DENIED on the wrong one
|
|
42
|
+
// (:673) — on the FIRST move, at any distance, not only once 16 px are travelled.
|
|
43
|
+
// 3. at STATE_PENDING, past the threshold, four more refusals before claiming: outside
|
|
44
|
+
// the swipe area, the wrong axis again (:715), a strip whose first and last snap
|
|
45
|
+
// point are the same (:720), and overshooting either end with overshoot disabled
|
|
46
|
+
// (:725-739) — which `AdwCarousel` always has.
|
|
47
|
+
// 4. only at STATE_SCROLLING does `gesture_update` apply the delta (:749).
|
|
48
|
+
//
|
|
49
|
+
// Skipping 3 is what made a backwards drag on page 0 swallow the click of the button
|
|
50
|
+
// under the cursor: the gesture claimed, clamped to the bound so nothing moved, and then
|
|
51
|
+
// suppressed the click anyway. Skipping 1 cost a whole event — with the first record
|
|
52
|
+
// discarded as the clock-starter, a two-move flick measured zero velocity and settled
|
|
53
|
+
// back instead of paging.
|
|
54
|
+
//
|
|
55
|
+
// HOW A DRAG BEATS A CLICK
|
|
56
|
+
//
|
|
57
|
+
// GTK does not blocklist widgets under the pointer: `should_suppress_drag`
|
|
58
|
+
// (adw-swipe-tracker.c:551) only refuses window handles, and a button inside a carousel
|
|
59
|
+
// loses its click because the drag gesture CLAIMS the event sequence once it passes
|
|
60
|
+
// `DRAG_THRESHOLD_DISTANCE`. This mirrors that: every `pointerdown` is tracked, nothing is
|
|
61
|
+
// pre-excluded, and the claim at 16 px is what suppresses the `click` that would otherwise
|
|
62
|
+
// follow. {@link NATIVELY_DRAGGABLE} is the one place a blocklist is unavoidable — a
|
|
63
|
+
// `<select>` or a range slider runs its own drag inside the browser, where there is no
|
|
64
|
+
// sequence to lose the race for.
|
|
65
|
+
|
|
66
|
+
import { ADW_SWIPE_DRAG_THRESHOLD, SwipeTracker } from '@gjsify/adwaita-core';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Controls that drag themselves, inside the browser, with no sequence for a claim to win.
|
|
70
|
+
*
|
|
71
|
+
* GTK needs no such list (see the header); this is the DOM's missing half of gesture
|
|
72
|
+
* claiming and nothing more. A `<button>` is deliberately ABSENT: losing its click to a
|
|
73
|
+
* deliberate drag is the upstream behaviour.
|
|
74
|
+
*/
|
|
75
|
+
const NATIVELY_DRAGGABLE = 'select, textarea, input[type="range"], [contenteditable=""], [contenteditable="true"]';
|
|
76
|
+
|
|
77
|
+
/** Which way the widget pages, and therefore which axis claims a drag. */
|
|
78
|
+
export type AdwSwipeOrientation = 'horizontal' | 'vertical';
|
|
79
|
+
|
|
80
|
+
export interface AdwSwipeDragInit {
|
|
81
|
+
/** The element the listeners sit on. Items rebuilt under it stay covered. */
|
|
82
|
+
host: HTMLElement;
|
|
83
|
+
orientation: AdwSwipeOrientation;
|
|
84
|
+
/** Re-read on every press: may a mouse drag start right now? */
|
|
85
|
+
enabled: () => boolean;
|
|
86
|
+
/** Page pitch in px. The raw pixel delta is divided by it to reach progress. */
|
|
87
|
+
pitch: () => number;
|
|
88
|
+
/** Snap points, ascending, as `adw_swipeable_get_snap_points` returns them. */
|
|
89
|
+
points: () => readonly number[];
|
|
90
|
+
/** Progress now — the gesture is seeded from it and bounded by it. */
|
|
91
|
+
progress: () => number;
|
|
92
|
+
/** `AdwCarousel:allow-long-swipes`. */
|
|
93
|
+
allowLongSwipes: () => boolean;
|
|
94
|
+
/**
|
|
95
|
+
* `adw_swipeable_get_cancel_progress` — where a CANCELLED gesture goes.
|
|
96
|
+
*
|
|
97
|
+
* Not optional by accident: the tracker's own fallback is where the gesture began,
|
|
98
|
+
* and `AdwCarousel`'s answer is `get_closest_snap_point` — the point nearest where
|
|
99
|
+
* the finger is NOW (adw-carousel.c:1294-1299). The two differ for exactly the
|
|
100
|
+
* gesture that matters, one cancelled past the halfway mark: upstream commits to the
|
|
101
|
+
* page under the cursor, the fallback yanks it back.
|
|
102
|
+
*/
|
|
103
|
+
cancelProgress: () => number;
|
|
104
|
+
/** Show this progress. Called on every move that the gesture owns. */
|
|
105
|
+
onUpdate: (progress: number) => void;
|
|
106
|
+
/**
|
|
107
|
+
* Whether a gesture may be claimed at all right now, on top of the refusals this
|
|
108
|
+
* module makes itself. `AdwCarousel` needs none — its `interactive` and
|
|
109
|
+
* `allow-mouse-drag` are {@link enabled} — but `is_in_swipe_area` lives here for the
|
|
110
|
+
* widgets that implement `get_swipe_area`, which the carousel does not.
|
|
111
|
+
*/
|
|
112
|
+
claimable?: (start: { x: number; y: number }) => boolean;
|
|
113
|
+
/** Settle on this progress. `velocity` is raw px/ms, for an animation duration. */
|
|
114
|
+
onEnd: (progress: number, velocity: number) => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What a claimed gesture had to override on the host, so releasing it can put it back. */
|
|
118
|
+
interface FrozenStyles {
|
|
119
|
+
scrollSnapType: string;
|
|
120
|
+
scrollBehavior: string;
|
|
121
|
+
userSelect: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Make `host` draggable with a mouse, deciding where it lands the way `AdwSwipeTracker`
|
|
126
|
+
* does.
|
|
127
|
+
*
|
|
128
|
+
* The listener is the element's own and is bound once, without teardown: it survives a
|
|
129
|
+
* re-parent and is collected with the element.
|
|
130
|
+
*/
|
|
131
|
+
/** What a caller gets back, so it can tell whether a gesture owns the widget. */
|
|
132
|
+
export interface AdwSwipeDrag {
|
|
133
|
+
/**
|
|
134
|
+
* Whether a gesture is currently claimed.
|
|
135
|
+
*
|
|
136
|
+
* A widget needs this to keep its OTHER inputs out of the offset while a drag owns
|
|
137
|
+
* it. Measured on `<adw-carousel>`: one wheel notch during a claimed drag ran the
|
|
138
|
+
* whole wheel path and jumped `scrollLeft` 80 → 440 in a frame, which the next move
|
|
139
|
+
* then yanked back. Upstream's `scroll_cb` is ungated too, but there the external
|
|
140
|
+
* request is a spring the next frame quietly overrides — here `claim()` sets
|
|
141
|
+
* `scroll-behavior: auto`, which turns it into an instant jump.
|
|
142
|
+
*/
|
|
143
|
+
readonly dragging: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function attachSwipeDrag(init: AdwSwipeDragInit): AdwSwipeDrag {
|
|
147
|
+
const tracker = new SwipeTracker({
|
|
148
|
+
points: init.points,
|
|
149
|
+
allowLongSwipes: init.allowLongSwipes,
|
|
150
|
+
cancelProgress: init.cancelProgress,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
let pointerId: number | null = null;
|
|
154
|
+
let startX = 0;
|
|
155
|
+
let startY = 0;
|
|
156
|
+
let previousOffset = 0;
|
|
157
|
+
/** Where the pointer is now, for the second half of the swipe-area test. */
|
|
158
|
+
let lastX = 0;
|
|
159
|
+
let lastY = 0;
|
|
160
|
+
/** `ADW_SWIPE_TRACKER_STATE_*`: nothing pressed, a press that may become one, ours. */
|
|
161
|
+
let state: 'none' | 'pending' | 'scrolling' = 'none';
|
|
162
|
+
let frozen: FrozenStyles | null = null;
|
|
163
|
+
/**
|
|
164
|
+
* Set when a claimed drag ends, cleared by the click it eats — or by the next press,
|
|
165
|
+
* because a drag that ended OUTSIDE the host produces no click at all and a one-shot
|
|
166
|
+
* listener would then sit armed and swallow the next real one.
|
|
167
|
+
*/
|
|
168
|
+
let suppressNextClick = false;
|
|
169
|
+
|
|
170
|
+
const axisOffset = (dx: number, dy: number) => (init.orientation === 'vertical' ? dy : dx);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Dragging LEFT must advance, so the offset is negated — `drag_update_cb`'s
|
|
174
|
+
* `offset = -offset`.
|
|
175
|
+
*
|
|
176
|
+
* LTR ONLY, deliberately. Upstream flips this sign for RTL through
|
|
177
|
+
* `adw_swipe_tracker_set_reversed` (`adw-carousel.c:455-465`), and taking a `reversed`
|
|
178
|
+
* flag here would be an afternoon's work — but the web carousel does not work in RTL
|
|
179
|
+
* at ALL yet: its offset model is `scrollLeft = position * distance`, and an RTL
|
|
180
|
+
* scroll container counts `scrollLeft` DOWN from 0, so even `scrollToPage(1)` moves
|
|
181
|
+
* nothing (measured). A reversed branch would compute a correct progress and write it
|
|
182
|
+
* to a container that ignores it. `status/open-todos.md` carries the whole gap; the
|
|
183
|
+
* flag belongs in the change that can test it.
|
|
184
|
+
*/
|
|
185
|
+
const progressOffset = (dx: number, dy: number) => -axisOffset(dx, dy);
|
|
186
|
+
|
|
187
|
+
const release = () => {
|
|
188
|
+
// Listeners off and state cleared BEFORE the capture is released, because
|
|
189
|
+
// `releasePointerCapture` fires `lostpointercapture` synchronously and that is
|
|
190
|
+
// one of the endings this listens for — doing it in the other order re-enters
|
|
191
|
+
// `finish` in the middle of itself.
|
|
192
|
+
const captured = pointerId;
|
|
193
|
+
pointerId = null;
|
|
194
|
+
state = 'none';
|
|
195
|
+
tracker.reset();
|
|
196
|
+
window.removeEventListener('pointermove', onMove, true);
|
|
197
|
+
window.removeEventListener('pointerup', onFinish, true);
|
|
198
|
+
window.removeEventListener('pointercancel', onCancel, true);
|
|
199
|
+
window.removeEventListener('lostpointercapture', onLostCapture, true);
|
|
200
|
+
|
|
201
|
+
if (captured !== null && init.host.hasPointerCapture(captured)) {
|
|
202
|
+
init.host.releasePointerCapture(captured);
|
|
203
|
+
}
|
|
204
|
+
if (frozen !== null) {
|
|
205
|
+
init.host.style.scrollSnapType = frozen.scrollSnapType;
|
|
206
|
+
init.host.style.scrollBehavior = frozen.scrollBehavior;
|
|
207
|
+
init.host.style.userSelect = frozen.userSelect;
|
|
208
|
+
frozen = null;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `:720-739` — the refusals that happen at the threshold, after the axis and before
|
|
214
|
+
* the claim. Returns whether the gesture may be claimed at all.
|
|
215
|
+
*/
|
|
216
|
+
const claimAllowed = (offset: number): boolean => {
|
|
217
|
+
const points = init.points();
|
|
218
|
+
if (points.length === 0) return false;
|
|
219
|
+
const first = points[0];
|
|
220
|
+
const last = points[points.length - 1];
|
|
221
|
+
// A strip with one snap point has nowhere to go (:720). The C cannot reach an
|
|
222
|
+
// EMPTY one — `adw_carousel_get_snap_points` returns `MAX (n, 1)` points
|
|
223
|
+
// (adw-carousel.c:1269) — so the length test above stands in for it.
|
|
224
|
+
if (Math.abs(first - last) < Number.EPSILON) return false;
|
|
225
|
+
|
|
226
|
+
const progress = init.progress();
|
|
227
|
+
// Overshooting an end with overshoot disabled, which AdwCarousel always is
|
|
228
|
+
// (:725-739). Without this a backwards drag on the first page claims, moves
|
|
229
|
+
// nothing, and still eats the click of whatever it started on.
|
|
230
|
+
const overshootingLower = offset < 0 && (Math.abs(progress - first) < Number.EPSILON || progress < first);
|
|
231
|
+
const overshootingUpper = offset > 0 && (Math.abs(progress - last) < Number.EPSILON || progress > last);
|
|
232
|
+
if (overshootingLower || overshootingUpper) return false;
|
|
233
|
+
|
|
234
|
+
// BOTH ends, as `is_in_swipe_area (start) || is_in_swipe_area (start + offset)`
|
|
235
|
+
// does (:709-712): a gesture that began outside the grabbable strip and has
|
|
236
|
+
// arrived inside it is still allowed.
|
|
237
|
+
if (init.claimable === undefined) return true;
|
|
238
|
+
return init.claimable({ x: startX, y: startY }) || init.claimable({ x: lastX, y: lastY });
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const claim = () => {
|
|
242
|
+
state = 'scrolling';
|
|
243
|
+
// Captured HERE and not on the press. With capture set at `pointerdown`, the
|
|
244
|
+
// `click` that follows is dispatched at the CAPTURING element — so it never
|
|
245
|
+
// reaches the button the user actually pressed, and measured, every button inside
|
|
246
|
+
// a carousel stopped working. From the claim on it is harmless, because a claimed
|
|
247
|
+
// gesture suppresses that click on purpose.
|
|
248
|
+
if (pointerId !== null) init.host.setPointerCapture(pointerId);
|
|
249
|
+
// Snap and smooth scrolling both fight a drag: with them on, every write to
|
|
250
|
+
// `scrollLeft` is a request the browser re-animates and re-snaps, so the strip
|
|
251
|
+
// lags the cursor and lands somewhere the tracker did not choose. They go back
|
|
252
|
+
// on before the settle, which is what makes the release animate.
|
|
253
|
+
frozen = {
|
|
254
|
+
scrollSnapType: init.host.style.scrollSnapType,
|
|
255
|
+
scrollBehavior: init.host.style.scrollBehavior,
|
|
256
|
+
userSelect: init.host.style.userSelect,
|
|
257
|
+
};
|
|
258
|
+
init.host.style.scrollSnapType = 'none';
|
|
259
|
+
init.host.style.scrollBehavior = 'auto';
|
|
260
|
+
init.host.style.userSelect = 'none';
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* MOVE AND UP ARE LISTENED FOR ON THE WINDOW, and this is not belt-and-braces.
|
|
265
|
+
*
|
|
266
|
+
* Measured: with the listeners on the host and `setPointerCapture` alone, a drag that
|
|
267
|
+
* travelled past the left edge of the viewport never delivered `pointerup` to the
|
|
268
|
+
* track at all — `scroll-snap-type` stayed `none` and the strip sat at `scrollLeft`
|
|
269
|
+
* 387 of a 440 px page, forever, with the gesture still believing it was in flight.
|
|
270
|
+
* That is worse than the missing feature was. Capture is kept as well, because it is
|
|
271
|
+
* what keeps the moves targeted while the pointer is still inside.
|
|
272
|
+
*/
|
|
273
|
+
const arm = () => {
|
|
274
|
+
window.addEventListener('pointermove', onMove, true);
|
|
275
|
+
window.addEventListener('pointerup', onFinish, true);
|
|
276
|
+
window.addEventListener('pointercancel', onCancel, true);
|
|
277
|
+
// The ending of last resort, for a CLAIMED gesture whose `pointerup` this never
|
|
278
|
+
// sees — the window losing focus mid-drag, an OS-level cancel. Without it such a
|
|
279
|
+
// gesture leaves the strip stopped mid-page with snapping switched off, which was
|
|
280
|
+
// measured and is worse than the feature being absent.
|
|
281
|
+
//
|
|
282
|
+
// It does NOT cover the host being removed from the document, which an earlier
|
|
283
|
+
// draft of this comment claimed: the implicit release dispatches
|
|
284
|
+
// `lostpointercapture` at the now-detached element, and an event dispatched in a
|
|
285
|
+
// detached tree never reaches `window`. Measured — the styles stayed frozen until
|
|
286
|
+
// a later `pointerup` cleaned up. The button check in `onMove` is the net that
|
|
287
|
+
// actually spans both, since a removed host stops delivering moves anyway.
|
|
288
|
+
window.addEventListener('lostpointercapture', onLostCapture, true);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
init.host.addEventListener('pointerdown', (event: PointerEvent) => {
|
|
292
|
+
// FIRST, before any guard below can return. A cancelled gesture produces no click
|
|
293
|
+
// to consume the flag, so leaving it set until the next SUCCESSFUL press meant the
|
|
294
|
+
// next click was eaten instead — measured: cancel a drag, then click a `<textarea>`
|
|
295
|
+
// inside the carousel (which `NATIVELY_DRAGGABLE` refuses to drag) and the click
|
|
296
|
+
// never arrived.
|
|
297
|
+
suppressNextClick = false;
|
|
298
|
+
|
|
299
|
+
// A gesture still open under the SAME pointer means its end was never seen: close
|
|
300
|
+
// it, or one lost `pointerup` disables dragging for good. A DIFFERENT pointer is
|
|
301
|
+
// the opposite case and upstream is explicit about it — `drag_begin_cb` denies the
|
|
302
|
+
// NEW sequence and keeps the one in flight (adw-swipe-tracker.c:617-620). Tearing
|
|
303
|
+
// the live gesture down instead left the strip rubber-banding back with no
|
|
304
|
+
// `onEnd` and no tracker decision at all.
|
|
305
|
+
if (pointerId !== null) {
|
|
306
|
+
if (event.pointerId !== pointerId) return;
|
|
307
|
+
release();
|
|
308
|
+
}
|
|
309
|
+
// Mouse only. Touch and touchpad are the browser's own gestures, and a pen
|
|
310
|
+
// behaves like touch on every platform that has one.
|
|
311
|
+
if (event.pointerType !== 'mouse' || event.button !== 0) return;
|
|
312
|
+
if (!init.enabled()) return;
|
|
313
|
+
if ((event.target as Element | null)?.closest(NATIVELY_DRAGGABLE) !== null) return;
|
|
314
|
+
|
|
315
|
+
pointerId = event.pointerId;
|
|
316
|
+
startX = event.clientX;
|
|
317
|
+
startY = event.clientY;
|
|
318
|
+
lastX = event.clientX;
|
|
319
|
+
lastY = event.clientY;
|
|
320
|
+
previousOffset = 0;
|
|
321
|
+
state = 'pending';
|
|
322
|
+
// No capture yet — see `claim`. The moves that DECIDE the claim come from the
|
|
323
|
+
// window listeners instead, which is why they are armed here and not there: a
|
|
324
|
+
// 16 px threshold near an edge is otherwise unreachable.
|
|
325
|
+
arm();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
function onMove(event: PointerEvent): void {
|
|
329
|
+
if (pointerId !== event.pointerId) return;
|
|
330
|
+
|
|
331
|
+
lastX = event.clientX;
|
|
332
|
+
lastY = event.clientY;
|
|
333
|
+
const dx = event.clientX - startX;
|
|
334
|
+
const dy = event.clientY - startY;
|
|
335
|
+
const offset = progressOffset(dx, dy);
|
|
336
|
+
const delta = offset - previousOffset;
|
|
337
|
+
previousOffset = offset;
|
|
338
|
+
// TRUNCATED to whole milliseconds, because the C's clock is a `guint32` and its
|
|
339
|
+
// `first_time == last_time` guard is a real low-pass filter: two events inside
|
|
340
|
+
// one millisecond yield NO velocity there. Left as a float, that guard is dead
|
|
341
|
+
// and two coalesced moves 0.1 ms apart read as tens of pages per second.
|
|
342
|
+
const time = Math.trunc(event.timeStamp);
|
|
343
|
+
// UNCONDITIONAL, and ahead of every refusal below — `append_to_history` at :670.
|
|
344
|
+
tracker.record(delta, time);
|
|
345
|
+
|
|
346
|
+
// NO BUTTON HELD means the press this is tracking ended somewhere unseen. Before
|
|
347
|
+
// the claim there is no pointer capture and therefore no `lostpointercapture`, so
|
|
348
|
+
// this is the only net that window: measured, one lost `pointerup` left the strip
|
|
349
|
+
// FOLLOWING a cursor with nothing pressed, snapping off, until the next click
|
|
350
|
+
// anywhere. Checked for every state, since a claimed gesture can lose its up too.
|
|
351
|
+
if ((event.buttons & 1) === 0) {
|
|
352
|
+
release();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (state === 'pending') {
|
|
357
|
+
// Whichever axis moved MORE decides whose gesture this is
|
|
358
|
+
// (`is_offset_vertical`, :661). Tested here and ONLY here: the C tests it at
|
|
359
|
+
// STATE_NONE, on the first move at any distance (:673), and again inside the
|
|
360
|
+
// threshold block (:715) — never at STATE_SCROLLING. One test in the pending
|
|
361
|
+
// branch covers both, because `dx`/`dy` are cumulative from the press.
|
|
362
|
+
//
|
|
363
|
+
// Hoisting it above this branch, as a first draft did, re-tested it on every
|
|
364
|
+
// claimed move — and `dx`/`dy` being cumulative means a drag that comes BACK
|
|
365
|
+
// toward its start ("changed my mind, with a little vertical wander") flips
|
|
366
|
+
// the dominant axis and cancelled itself mid-gesture.
|
|
367
|
+
const wantsVertical = Math.abs(dy) > Math.abs(dx);
|
|
368
|
+
if (wantsVertical !== (init.orientation === 'vertical')) {
|
|
369
|
+
release();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (Math.hypot(dx, dy) < ADW_SWIPE_DRAG_THRESHOLD) return;
|
|
373
|
+
if (!claimAllowed(offset)) {
|
|
374
|
+
release();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// `gesture_prepare` is the C's first-move step and sets `initial_progress`
|
|
378
|
+
// (:202-204). It runs here rather than at `pointerdown` because until a move
|
|
379
|
+
// arrives there is no direction, and until the axis passes there is no
|
|
380
|
+
// gesture to prepare.
|
|
381
|
+
tracker.prepare(init.progress());
|
|
382
|
+
claim();
|
|
383
|
+
// From the claim, not from the press: the threshold travel is what BOUGHT
|
|
384
|
+
// the gesture and must not also be applied as progress (`prev_offset = offset`
|
|
385
|
+
// at :744), or the content jumps 16 px the instant it follows the cursor.
|
|
386
|
+
previousOffset = offset;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (state !== 'scrolling') return;
|
|
391
|
+
// The standard signal that this move is consumed. Deliberately NOT credited with
|
|
392
|
+
// the two things it usually is: text selection is prevented by the `user-select`
|
|
393
|
+
// in `claim`, and a native image or link drag begins from `dragstart` BEFORE the
|
|
394
|
+
// claim, so this line has never run by then — measured, an image dragged inside a
|
|
395
|
+
// page is never refused here. Kept because consuming the move is what it is for,
|
|
396
|
+
// not because a specific default was observed to need it.
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
init.onUpdate(tracker.advance(delta, init.pitch()));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function finish(event: PointerEvent, cancelled: boolean): void {
|
|
402
|
+
if (pointerId !== event.pointerId) return;
|
|
403
|
+
if (state !== 'scrolling') {
|
|
404
|
+
release();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (cancelled) tracker.cancel();
|
|
408
|
+
const { to, velocity } = tracker.end(Math.trunc(event.timeStamp));
|
|
409
|
+
release();
|
|
410
|
+
// A deliberate drag must not also activate whatever it ended on — the DOM's
|
|
411
|
+
// stand-in for GTK's claimed event sequence.
|
|
412
|
+
suppressNextClick = true;
|
|
413
|
+
init.onEnd(to, velocity);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function onFinish(event: PointerEvent): void {
|
|
417
|
+
finish(event, false);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function onCancel(event: PointerEvent): void {
|
|
421
|
+
finish(event, true);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function onLostCapture(event: PointerEvent): void {
|
|
425
|
+
// Settled, not cancelled: the pointer is gone, so whatever the user did they are
|
|
426
|
+
// done doing it, and dropping them back where they started would undo a drag
|
|
427
|
+
// they can see on screen.
|
|
428
|
+
finish(event, false);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// CAPTURE phase and permanent, rather than a one-shot armed at the end of a drag: a
|
|
432
|
+
// listener that is only ever removed by the click it is waiting for outlives a drag
|
|
433
|
+
// that produced none.
|
|
434
|
+
init.host.addEventListener(
|
|
435
|
+
'click',
|
|
436
|
+
(event: Event) => {
|
|
437
|
+
if (!suppressNextClick) return;
|
|
438
|
+
suppressNextClick = false;
|
|
439
|
+
event.stopPropagation();
|
|
440
|
+
event.preventDefault();
|
|
441
|
+
},
|
|
442
|
+
{ capture: true },
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
get dragging(): boolean {
|
|
447
|
+
return state === 'scrolling';
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The `hidden` derivation itself, driven in both directions.
|
|
2
|
+
//
|
|
3
|
+
// `connect-lifecycle.spec.ts` drives the WIDGETS: it appends into every container a
|
|
4
|
+
// consumer can reach and requires the child to be laid out. That half alone does not
|
|
5
|
+
// hold the invariant — replacing the whole derivation with `section.hidden = false`
|
|
6
|
+
// left the full adwaita-web browser suite green, because nothing anywhere asserted that
|
|
7
|
+
// an EMPTY section is hidden or that it goes back to hidden when its last child leaves.
|
|
8
|
+
// An empty bar that re-enters the flow is what that mutant ships.
|
|
9
|
+
//
|
|
10
|
+
// Driven against the helper rather than a widget on purpose: the two elements whose
|
|
11
|
+
// sections are private fields (`<adw-alert-dialog>`, `<adw-status-page>`) have no
|
|
12
|
+
// container getter, so the widget driver cannot see them, and giving them one to satisfy
|
|
13
|
+
// a test would be public API invented for the test.
|
|
14
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
15
|
+
|
|
16
|
+
import { bindEmptySections } from './empty-sections.js';
|
|
17
|
+
|
|
18
|
+
/** A section mounted in the document, since `hidden` is only observable where there is layout. */
|
|
19
|
+
function section(): HTMLElement {
|
|
20
|
+
const el = document.createElement('div');
|
|
21
|
+
document.body.appendChild(el);
|
|
22
|
+
return el;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One microtask checkpoint — where a MutationObserver callback lands. */
|
|
26
|
+
const settle = () => Promise.resolve();
|
|
27
|
+
|
|
28
|
+
export const AdwEmptySectionsTest = async () => {
|
|
29
|
+
await describe('bindEmptySections', async () => {
|
|
30
|
+
await it('hides a section that starts empty', async () => {
|
|
31
|
+
const el = section();
|
|
32
|
+
bindEmptySections(el);
|
|
33
|
+
const hidden = el.hidden;
|
|
34
|
+
el.remove();
|
|
35
|
+
expect(`empty: hidden=${hidden}`).toBe('empty: hidden=true');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await it('shows it when a child lands and hides it again when the last one leaves', async () => {
|
|
39
|
+
const el = section();
|
|
40
|
+
bindEmptySections(el);
|
|
41
|
+
|
|
42
|
+
const child = document.createElement('span');
|
|
43
|
+
el.appendChild(child);
|
|
44
|
+
await settle();
|
|
45
|
+
const filled = el.hidden;
|
|
46
|
+
|
|
47
|
+
child.remove();
|
|
48
|
+
await settle();
|
|
49
|
+
const emptied = el.hidden;
|
|
50
|
+
|
|
51
|
+
el.remove();
|
|
52
|
+
expect(`filled: hidden=${filled} | emptied: hidden=${emptied}`).toBe(
|
|
53
|
+
'filled: hidden=false | emptied: hidden=true',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await it('counts element children, not child nodes', async () => {
|
|
58
|
+
const el = section();
|
|
59
|
+
bindEmptySections(el);
|
|
60
|
+
// The whitespace an indented `<div>\n </div>` carries is a text node, and a
|
|
61
|
+
// section full of indentation is still an empty section. This is also why the
|
|
62
|
+
// CSS `:empty` that this looks like cannot express the rule.
|
|
63
|
+
el.appendChild(document.createTextNode('\n '));
|
|
64
|
+
await settle();
|
|
65
|
+
const hidden = el.hidden;
|
|
66
|
+
el.remove();
|
|
67
|
+
expect(`whitespace only: hidden=${hidden}`).toBe('whitespace only: hidden=true');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await it('derives each section it is handed independently', async () => {
|
|
71
|
+
const empty = section();
|
|
72
|
+
const filled = section();
|
|
73
|
+
filled.appendChild(document.createElement('span'));
|
|
74
|
+
bindEmptySections(empty, filled);
|
|
75
|
+
const report = `empty=${empty.hidden} filled=${filled.hidden}`;
|
|
76
|
+
empty.remove();
|
|
77
|
+
filled.remove();
|
|
78
|
+
expect(report).toBe('empty=true filled=false');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
};
|