-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathxds-motion.js
More file actions
220 lines (193 loc) · 7.47 KB
/
Copy pathxds-motion.js
File metadata and controls
220 lines (193 loc) · 7.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
/**
* XDS motion — sliding hover highlight
*
* Recreates the xds.pages.twitter.biz sidebar interaction: a single
* translucent pill glides between items as the pointer moves across a
* list, instead of each item flashing its own background.
*
* One fixed-position pill serves every registered container (sidebar,
* table of contents, content tab bars). Moving within a container
* slides the pill; entering a different container or arriving fresh
* fades it in at rest; leaving fades it out. The CSS hover backgrounds
* remain as a no-JS fallback and are suppressed under html.xds-motion.
*
* Honors prefers-reduced-motion by not initializing at all.
*/
(function () {
if (window.__xdsMotion) return;
window.__xdsMotion = true;
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
var CONTAINERS = [
{ root: '#sidebar-content', item: 'a, button', inset: 1, radius: 8 },
{ root: '#table-of-contents', item: 'a', inset: 1, radius: 8 },
// Tab buttons carry asymmetric built-in padding (pt-3 pb-2.5,
// per-variant px), so a raw-rect pill sits lopsided around the
// label. contentBox mode measures computed padding and draws a
// symmetric pill around the text instead.
{ root: '[data-component-part="tabs-list"]', item: '[data-component-part="tab-button"]', contentBox: true, padX: 10, padY: 4, radius: 8 }
];
var pill = null;
var activeRoot = null;
var hideTimer = null;
function ensurePill() {
if (pill && document.body.contains(pill)) return pill;
pill = document.createElement('div');
pill.id = 'xds-hover-pill';
pill.setAttribute('aria-hidden', 'true');
document.body.appendChild(pill);
return pill;
}
function findTarget(node) {
for (var i = 0; i < CONTAINERS.length; i++) {
var c = CONTAINERS[i];
var root = node.closest && node.closest(c.root);
if (!root) continue;
var item = node.closest(c.item);
if (item && root.contains(item)) {
return { item: item, root: root, cfg: c };
}
}
return null;
}
function pillRect(item, cfg) {
var r = item.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return null;
if (!cfg.contentBox) {
var inset = cfg.inset || 0;
return { left: r.left, top: r.top + inset, width: r.width, height: r.height - inset * 2 };
}
// Symmetric pill around the content box (text), ignoring the
// element's own asymmetric padding.
var cs = window.getComputedStyle(item);
var pl = parseFloat(cs.paddingLeft) || 0;
var pr = parseFloat(cs.paddingRight) || 0;
var pt = parseFloat(cs.paddingTop) || 0;
var pb = parseFloat(cs.paddingBottom) || 0;
var bb = parseFloat(cs.borderBottomWidth) || 0;
return {
left: r.left + pl - cfg.padX,
top: r.top + pt - cfg.padY,
width: r.width - pl - pr + cfg.padX * 2,
height: r.height - pt - pb - bb + cfg.padY * 2
};
}
function moveTo(target) {
var p = ensurePill();
var r = pillRect(target.item, target.cfg);
if (!r) return;
var slide = activeRoot === target.root && p.style.opacity === '1';
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
// Fresh appearance (or container switch): jump into place, then fade in.
if (!slide) {
p.style.transition = 'opacity 120ms ease';
} else {
p.style.transition =
'transform 180ms cubic-bezier(0.4, 0, 0.2, 1), ' +
'width 180ms cubic-bezier(0.4, 0, 0.2, 1), ' +
'height 180ms cubic-bezier(0.4, 0, 0.2, 1), ' +
'opacity 120ms ease';
}
p.style.width = r.width + 'px';
p.style.height = r.height + 'px';
p.style.borderRadius = target.cfg.radius + 'px';
p.style.transform = 'translate(' + r.left + 'px, ' + r.top + 'px)';
p.style.opacity = '1';
activeRoot = target.root;
}
function hide() {
if (!pill) return;
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(function () {
pill.style.opacity = '0';
activeRoot = null;
}, 40);
}
document.addEventListener('mouseover', function (e) {
if (!e.target || e.target.nodeType !== 1) return;
var target = findTarget(e.target);
if (target) {
moveTo(target);
} else {
hide();
}
}, true);
// Positions go stale the moment anything scrolls or resizes.
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
/* ----------------------------------------------------------------
Sliding active-tab underline (XDS Tabs: "an indicator that
slides as the selection changes").
FLIP handoff: on click, measure the outgoing active tab and the
clicked tab, slide a fixed 2px bar between the two underline
positions, then remove it and let the static CSS indicator on
the newly active button take over. While animating, the list
carries .xds-tab-animating so the static borders stay hidden
and only the moving bar shows.
---------------------------------------------------------------- */
var TAB_LIST = '[data-component-part="tabs-list"]';
var TAB_BTN = '[data-component-part="tab-button"]';
var indicator = null;
var indicatorTimer = null;
function clearIndicator() {
if (indicatorTimer) {
clearTimeout(indicatorTimer);
indicatorTimer = null;
}
if (indicator && indicator.parentNode) {
indicator.parentNode.removeChild(indicator);
}
indicator = null;
var animating = document.querySelectorAll('.xds-tab-animating');
for (var i = 0; i < animating.length; i++) {
animating[i].classList.remove('xds-tab-animating');
}
}
document.addEventListener('click', function (e) {
if (!e.target || e.target.nodeType !== 1) return;
var btn = e.target.closest && e.target.closest(TAB_BTN);
if (!btn) return;
var list = btn.closest(TAB_LIST);
if (!list) return;
// The outgoing tab is still marked active at click time —
// React flips data-active after this handler runs.
var from = list.querySelector(TAB_BTN + '[data-active="true"]');
if (!from || from === btn) return;
clearIndicator();
var a = from.getBoundingClientRect();
var b = btn.getBoundingClientRect();
if (a.width === 0 || b.width === 0) return;
list.classList.add('xds-tab-animating');
indicator = document.createElement('div');
indicator.id = 'xds-tab-indicator';
indicator.setAttribute('aria-hidden', 'true');
indicator.style.width = a.width + 'px';
indicator.style.transform = 'translate(' + a.left + 'px, ' + (a.bottom - 2) + 'px)';
document.body.appendChild(indicator);
// Next frame: slide to the incoming tab's underline position.
requestAnimationFrame(function () {
requestAnimationFrame(function () {
if (!indicator) return;
indicator.style.width = b.width + 'px';
indicator.style.transform = 'translate(' + b.left + 'px, ' + (b.bottom - 2) + 'px)';
});
});
indicatorTimer = setTimeout(clearIndicator, 240);
}, true);
// Clicks swap panels and re-render lists beneath a stationary
// cursor without firing mouseover — hide the pill so it can't
// linger over stale geometry. It reappears on the next hover.
document.addEventListener('click', function () {
if (pill) {
pill.style.opacity = '0';
activeRoot = null;
}
}, true);
window.addEventListener('scroll', clearIndicator, true);
window.addEventListener('resize', clearIndicator);
document.documentElement.classList.add('xds-motion');
})();