@stackoverflow/stacks 2.7.3 → 2.7.4
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/LICENSE.MD +9 -9
- package/README.md +158 -180
- package/dist/css/stacks.css +6 -0
- package/dist/js/stacks.min.js +1 -1
- package/lib/atomic/border.less +139 -139
- package/lib/atomic/color.less +36 -36
- package/lib/atomic/flex.less +426 -426
- package/lib/atomic/gap.less +44 -44
- package/lib/atomic/grid.less +139 -139
- package/lib/atomic/misc.less +374 -374
- package/lib/atomic/spacing.less +98 -98
- package/lib/atomic/typography.less +266 -264
- package/lib/atomic/width-height.less +194 -194
- package/lib/base/body.less +44 -44
- package/lib/base/configuration-static.less +61 -61
- package/lib/base/fieldset.less +5 -5
- package/lib/base/icon.less +11 -11
- package/lib/base/internal.less +220 -220
- package/lib/base/reset-meyer.less +64 -64
- package/lib/base/reset-normalize.less +449 -449
- package/lib/base/reset.less +20 -20
- package/lib/components/activity-indicator/activity-indicator.less +53 -53
- package/lib/components/avatar/avatar.less +108 -108
- package/lib/components/award-bling/award-bling.less +31 -31
- package/lib/components/banner/banner.less +44 -44
- package/lib/components/banner/banner.ts +149 -149
- package/lib/components/block-link/block-link.less +82 -82
- package/lib/components/breadcrumbs/breadcrumbs.less +41 -41
- package/lib/components/button-group/button-group.less +82 -82
- package/lib/components/card/card.less +37 -37
- package/lib/components/check-control/check-control.less +17 -17
- package/lib/components/check-group/check-group.less +19 -19
- package/lib/components/checkbox_radio/checkbox_radio.less +159 -159
- package/lib/components/code-block/code-block.fixtures.ts +88 -88
- package/lib/components/code-block/code-block.less +116 -116
- package/lib/components/description/description.less +9 -9
- package/lib/components/empty-state/empty-state.less +16 -16
- package/lib/components/expandable/expandable.less +118 -118
- package/lib/components/input-fill/input-fill.less +35 -35
- package/lib/components/input-icon/input-icon.less +45 -45
- package/lib/components/input-message/input-message.less +49 -49
- package/lib/components/label/label.less +110 -110
- package/lib/components/link-preview/link-preview.less +148 -148
- package/lib/components/menu/menu.less +41 -41
- package/lib/components/modal/modal.less +118 -118
- package/lib/components/modal/modal.ts +383 -383
- package/lib/components/navigation/navigation.less +136 -136
- package/lib/components/navigation/navigation.ts +128 -128
- package/lib/components/page-title/page-title.less +51 -51
- package/lib/components/popover/popover.less +159 -159
- package/lib/components/popover/popover.ts +651 -651
- package/lib/components/post-summary/post-summary.less +457 -457
- package/lib/components/progress-bar/progress-bar.less +291 -291
- package/lib/components/prose/prose.less +452 -452
- package/lib/components/select/select.less +138 -138
- package/lib/components/spinner/spinner.less +103 -103
- package/lib/components/table/table.ts +296 -296
- package/lib/components/table-container/table-container.less +4 -4
- package/lib/components/tag/tag.less +186 -186
- package/lib/components/toast/toast.less +35 -35
- package/lib/components/toast/toast.ts +357 -357
- package/lib/components/toggle-switch/toggle-switch.less +104 -104
- package/lib/components/topbar/topbar.less +553 -553
- package/lib/components/uploader/uploader.less +205 -205
- package/lib/components/user-card/user-card.less +129 -129
- package/lib/controllers.ts +33 -33
- package/lib/exports/color-mixins.less +283 -283
- package/lib/exports/constants-helpers.less +108 -108
- package/lib/exports/constants-type.less +155 -155
- package/lib/exports/exports.less +15 -15
- package/lib/exports/mixins.less +334 -333
- package/lib/exports/spacing-mixins.less +67 -67
- package/lib/index.ts +32 -32
- package/lib/input-utils.less +41 -41
- package/lib/stacks-dynamic.less +24 -24
- package/lib/stacks-static.less +93 -93
- package/lib/stacks.less +13 -13
- package/lib/test/assertions.ts +36 -36
- package/lib/test/less-test-utils.ts +28 -28
- package/lib/test/open-wc-testing-patch.d.ts +26 -26
- package/lib/tsconfig.build.json +4 -4
- package/lib/tsconfig.json +17 -17
- package/package.json +26 -22
|
@@ -1,651 +1,651 @@
|
|
|
1
|
-
import { createPopper, Placement } from "@popperjs/core";
|
|
2
|
-
import type * as Popper from "@popperjs/core";
|
|
3
|
-
import * as Stacks from "../../stacks";
|
|
4
|
-
|
|
5
|
-
type OutsideClickBehavior =
|
|
6
|
-
| "always"
|
|
7
|
-
| "never"
|
|
8
|
-
| "if-in-viewport"
|
|
9
|
-
| "after-dismissal";
|
|
10
|
-
|
|
11
|
-
export abstract class BasePopoverController extends Stacks.StacksController {
|
|
12
|
-
private popper!: Popper.Instance;
|
|
13
|
-
|
|
14
|
-
protected popoverElement!: HTMLElement;
|
|
15
|
-
|
|
16
|
-
protected referenceElement!: HTMLElement;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* An attribute containing the ID of the popover element to render, e.g. aria-controls or aria-describedby.
|
|
20
|
-
*/
|
|
21
|
-
protected abstract popoverSelectorAttribute: string;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Binds events to the document on element show
|
|
25
|
-
*/
|
|
26
|
-
protected abstract bindDocumentEvents(): void;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Unbinds events on the document on element hide
|
|
30
|
-
*/
|
|
31
|
-
protected abstract unbindDocumentEvents(): void;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Returns true if the if the popover is currently visible.
|
|
35
|
-
*/
|
|
36
|
-
get isVisible() {
|
|
37
|
-
const popoverElement = this.popoverElement;
|
|
38
|
-
return popoverElement
|
|
39
|
-
? popoverElement.classList.contains("is-visible")
|
|
40
|
-
: false;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Gets whether the element is visible in the browser's viewport.
|
|
45
|
-
*/
|
|
46
|
-
get isInViewport() {
|
|
47
|
-
const element = this.popoverElement;
|
|
48
|
-
if (!this.isVisible || !element) {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// From https://stackoverflow.com/a/5354536. Theoretically, this could be calculated using Popper's detectOverflow function,
|
|
53
|
-
// but it's unclear how to access that with our current configuration.
|
|
54
|
-
|
|
55
|
-
const rect = element.getBoundingClientRect();
|
|
56
|
-
const viewHeight = Math.max(
|
|
57
|
-
document.documentElement.clientHeight,
|
|
58
|
-
window.innerHeight
|
|
59
|
-
);
|
|
60
|
-
const viewWidth = Math.max(
|
|
61
|
-
document.documentElement.clientWidth,
|
|
62
|
-
window.innerWidth
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
rect.bottom > 0 &&
|
|
67
|
-
rect.top < viewHeight &&
|
|
68
|
-
rect.right > 0 &&
|
|
69
|
-
rect.left < viewWidth
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
protected get shouldHideOnOutsideClick() {
|
|
74
|
-
const hideBehavior = <OutsideClickBehavior>(
|
|
75
|
-
this.data.get("hide-on-outside-click")
|
|
76
|
-
);
|
|
77
|
-
switch (hideBehavior) {
|
|
78
|
-
case "after-dismissal":
|
|
79
|
-
case "never":
|
|
80
|
-
return false;
|
|
81
|
-
case "if-in-viewport":
|
|
82
|
-
return this.isInViewport;
|
|
83
|
-
default:
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Initializes and validates controller variables
|
|
90
|
-
*/
|
|
91
|
-
connect() {
|
|
92
|
-
super.connect();
|
|
93
|
-
this.validate();
|
|
94
|
-
if (this.isVisible) {
|
|
95
|
-
// just call initialize here, not show. This keeps already visible popovers from adding/firing document events
|
|
96
|
-
this.initializePopper();
|
|
97
|
-
} else if (this.data.get("auto-show") === "true") {
|
|
98
|
-
this.show(null);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
this.data.delete("auto-show");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Cleans up popper.js elements and disconnects all added event listeners
|
|
106
|
-
*/
|
|
107
|
-
disconnect() {
|
|
108
|
-
this.hide();
|
|
109
|
-
if (this.popper) {
|
|
110
|
-
this.popper.destroy();
|
|
111
|
-
// eslint-disable-next-line
|
|
112
|
-
// @ts-ignore The operand of a 'delete' operator must be optional .ts(2790)
|
|
113
|
-
delete this.popper;
|
|
114
|
-
}
|
|
115
|
-
super.disconnect();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Toggles the visibility of the popover
|
|
120
|
-
*/
|
|
121
|
-
toggle(dispatcher: Event | Element | null = null) {
|
|
122
|
-
this.isVisible ? this.hide(dispatcher) : this.show(dispatcher);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Shows the popover if not already visible
|
|
127
|
-
*/
|
|
128
|
-
show(dispatcher: Event | Element | null = null) {
|
|
129
|
-
if (this.isVisible) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const dispatcherElement = this.getDispatcher(dispatcher);
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
this.triggerEvent("show", {
|
|
137
|
-
dispatcher: dispatcherElement,
|
|
138
|
-
}).defaultPrevented
|
|
139
|
-
) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (!this.popper) {
|
|
144
|
-
this.initializePopper();
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
this.popoverElement.classList.add("is-visible");
|
|
148
|
-
|
|
149
|
-
// ensure the popper has been positioned correctly
|
|
150
|
-
this.scheduleUpdate();
|
|
151
|
-
|
|
152
|
-
this.shown(dispatcherElement);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Hides the popover if not already hidden
|
|
157
|
-
*/
|
|
158
|
-
hide(dispatcher: Event | Element | null = null) {
|
|
159
|
-
if (!this.isVisible) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const dispatcherElement = this.getDispatcher(dispatcher);
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
this.triggerEvent("hide", {
|
|
167
|
-
dispatcher: dispatcherElement,
|
|
168
|
-
}).defaultPrevented
|
|
169
|
-
) {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
this.popoverElement.classList.remove("is-visible");
|
|
174
|
-
|
|
175
|
-
if (this.popper) {
|
|
176
|
-
// completely destroy the popper on hide; this is in line with Popper.js's performance recommendations
|
|
177
|
-
this.popper.destroy();
|
|
178
|
-
// eslint-disable-next-line
|
|
179
|
-
// @ts-ignore The operand of a 'delete' operator must be optional .ts(2790)
|
|
180
|
-
delete this.popper;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// on first interaction, hide-on-outside-click with value "after-dismissal" reverts to the default behavior
|
|
184
|
-
if (
|
|
185
|
-
<OutsideClickBehavior>this.data.get("hide-on-outside-click") ===
|
|
186
|
-
"after-dismissal"
|
|
187
|
-
) {
|
|
188
|
-
this.data.delete("hide-on-outside-click");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
this.hidden(dispatcherElement);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Binds document events for this popover and fires the shown event
|
|
196
|
-
*/
|
|
197
|
-
protected shown(dispatcher: Element | null = null) {
|
|
198
|
-
this.bindDocumentEvents();
|
|
199
|
-
this.triggerEvent("shown", {
|
|
200
|
-
dispatcher: dispatcher,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Unbinds document events for this popover and fires the hidden event
|
|
206
|
-
*/
|
|
207
|
-
protected hidden(dispatcher: Element | null = null) {
|
|
208
|
-
this.unbindDocumentEvents();
|
|
209
|
-
this.triggerEvent("hidden", {
|
|
210
|
-
dispatcher: dispatcher,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Generates the popover if not found during initialization
|
|
216
|
-
*/
|
|
217
|
-
protected generatePopover(): HTMLElement | null {
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Initializes the Popper for this instance
|
|
223
|
-
*/
|
|
224
|
-
private initializePopper() {
|
|
225
|
-
this.popper = createPopper(this.referenceElement, this.popoverElement, {
|
|
226
|
-
placement: (this.data.get("placement") as Placement) || "bottom",
|
|
227
|
-
modifiers: [
|
|
228
|
-
{
|
|
229
|
-
name: "offset",
|
|
230
|
-
options: {
|
|
231
|
-
offset: [0, 10], // The entire popover should be 10px away from the element
|
|
232
|
-
},
|
|
233
|
-
},
|
|
234
|
-
{
|
|
235
|
-
name: "arrow",
|
|
236
|
-
options: {
|
|
237
|
-
element: ".s-popover--arrow",
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
],
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Validates the popover settings and attempts to set necessary internal variables
|
|
246
|
-
*/
|
|
247
|
-
private validate() {
|
|
248
|
-
const referenceSelector = this.data.get("reference-selector");
|
|
249
|
-
|
|
250
|
-
this.referenceElement = <HTMLElement>this.element;
|
|
251
|
-
|
|
252
|
-
// if there is an alternative reference selector and that element exists, use it (and throw if it isn't found)
|
|
253
|
-
if (referenceSelector) {
|
|
254
|
-
this.referenceElement = <HTMLElement>(
|
|
255
|
-
this.element.querySelector(referenceSelector)
|
|
256
|
-
);
|
|
257
|
-
|
|
258
|
-
if (!this.referenceElement) {
|
|
259
|
-
throw (
|
|
260
|
-
"Unable to find element by reference selector: " +
|
|
261
|
-
referenceSelector
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const popoverId = this.referenceElement.getAttribute(
|
|
267
|
-
this.popoverSelectorAttribute
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
let popoverElement: HTMLElement | null = null;
|
|
271
|
-
|
|
272
|
-
// if the popover is named, attempt to fetch it (and throw an error if it doesn't exist)
|
|
273
|
-
if (popoverId) {
|
|
274
|
-
popoverElement = document.getElementById(popoverId);
|
|
275
|
-
|
|
276
|
-
if (!popoverElement) {
|
|
277
|
-
throw `[${this.popoverSelectorAttribute}="{POPOVER_ID}"] required`;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// if the popover isn't named, attempt to generate it
|
|
281
|
-
else {
|
|
282
|
-
popoverElement = this.generatePopover();
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (!popoverElement) {
|
|
286
|
-
throw "unable to find or generate popover element";
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
this.popoverElement = popoverElement;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Determines the correct dispatching element from a potential input
|
|
294
|
-
* @param dispatcher The event or element to get the dispatcher from
|
|
295
|
-
*/
|
|
296
|
-
protected getDispatcher(
|
|
297
|
-
dispatcher: Event | Element | null = null
|
|
298
|
-
): Element {
|
|
299
|
-
if (dispatcher instanceof Event) {
|
|
300
|
-
return <Element>dispatcher.target;
|
|
301
|
-
} else if (dispatcher instanceof Element) {
|
|
302
|
-
return dispatcher;
|
|
303
|
-
} else {
|
|
304
|
-
return this.element;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Schedules the popover to update on the next animation frame if visible
|
|
310
|
-
*/
|
|
311
|
-
protected scheduleUpdate() {
|
|
312
|
-
if (this.popper && this.isVisible) {
|
|
313
|
-
void this.popper.update();
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export class PopoverController extends BasePopoverController {
|
|
319
|
-
static targets = [];
|
|
320
|
-
|
|
321
|
-
protected popoverSelectorAttribute = "aria-controls";
|
|
322
|
-
|
|
323
|
-
private boundHideOnOutsideClick!: (event: MouseEvent) => void;
|
|
324
|
-
private boundHideOnEscapePress!: (event: KeyboardEvent) => void;
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Toggles optional classes and accessibility attributes in addition to BasePopoverController.shown
|
|
328
|
-
*/
|
|
329
|
-
protected override shown(dispatcher: Element | null = null) {
|
|
330
|
-
this.toggleOptionalClasses(true);
|
|
331
|
-
this.toggleAccessibilityAttributes(true);
|
|
332
|
-
super.shown(dispatcher);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Toggles optional classes and accessibility attributes in addition to BasePopoverController.hidden
|
|
337
|
-
*/
|
|
338
|
-
protected override hidden(dispatcher: Element | null = null) {
|
|
339
|
-
this.toggleOptionalClasses(false);
|
|
340
|
-
this.toggleAccessibilityAttributes(false);
|
|
341
|
-
super.hidden(dispatcher);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Initializes accessibility attributes in addition to BasePopoverController.connect
|
|
346
|
-
*/
|
|
347
|
-
public override connect(): void {
|
|
348
|
-
super.connect();
|
|
349
|
-
|
|
350
|
-
this.toggleAccessibilityAttributes();
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Binds global events to the document for hiding popovers on user interaction
|
|
355
|
-
*/
|
|
356
|
-
protected bindDocumentEvents() {
|
|
357
|
-
this.boundHideOnOutsideClick =
|
|
358
|
-
this.boundHideOnOutsideClick || this.hideOnOutsideClick.bind(this);
|
|
359
|
-
this.boundHideOnEscapePress =
|
|
360
|
-
this.boundHideOnEscapePress || this.hideOnEscapePress.bind(this);
|
|
361
|
-
|
|
362
|
-
document.addEventListener("mousedown", this.boundHideOnOutsideClick);
|
|
363
|
-
document.addEventListener("keyup", this.boundHideOnEscapePress);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Unbinds global events to the document for hiding popovers on user interaction
|
|
368
|
-
*/
|
|
369
|
-
protected unbindDocumentEvents() {
|
|
370
|
-
document.removeEventListener("mousedown", this.boundHideOnOutsideClick);
|
|
371
|
-
document.removeEventListener("keyup", this.boundHideOnEscapePress);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Forces the popover to hide if a user clicks outside of it or its reference element
|
|
376
|
-
* @param {Event} e - The document click event
|
|
377
|
-
*/
|
|
378
|
-
private hideOnOutsideClick(e: MouseEvent) {
|
|
379
|
-
const target = <Node>e.target;
|
|
380
|
-
// check if the document was clicked inside either the reference element or the popover itself
|
|
381
|
-
// note: .contains also returns true if the node itself matches the target element
|
|
382
|
-
if (
|
|
383
|
-
this.shouldHideOnOutsideClick &&
|
|
384
|
-
!this.referenceElement.contains(target) &&
|
|
385
|
-
!this.popoverElement.contains(target) &&
|
|
386
|
-
document.body.contains(target)
|
|
387
|
-
) {
|
|
388
|
-
this.hide(e);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Forces the popover to hide if the user presses escape while it, one of its childen, or the reference element are focused
|
|
394
|
-
* @param {Event} e - The document keyup event
|
|
395
|
-
*/
|
|
396
|
-
private hideOnEscapePress(e: KeyboardEvent) {
|
|
397
|
-
// if the ESC key (27) wasn't pressed or if no popovers are showing, return
|
|
398
|
-
if (e.which !== 27 || !this.isVisible) {
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// check if the target was inside the popover element and refocus the triggering element
|
|
403
|
-
// note: .contains also returns true if the node itself matches the target element
|
|
404
|
-
if (this.popoverElement.contains(<Node>e.target)) {
|
|
405
|
-
this.referenceElement.focus();
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
this.hide(e);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Toggles all classes on the originating element based on the `class-toggle` data
|
|
413
|
-
* @param {boolean=} show - A boolean indicating whether this is being triggered by a show or hide.
|
|
414
|
-
*/
|
|
415
|
-
private toggleOptionalClasses(show?: boolean) {
|
|
416
|
-
if (!this.data.has("toggle-class")) {
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const toggleClass = this.data.get("toggle-class") || "";
|
|
421
|
-
const cl = this.referenceElement.classList;
|
|
422
|
-
toggleClass.split(/\s+/).forEach(function (cls: string) {
|
|
423
|
-
cl.toggle(cls, show);
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Toggles accessibility attributes based on whether the popover is shown or not
|
|
429
|
-
* @param {boolean=} show - A boolean indicating whether this is being triggered by a show or hide.
|
|
430
|
-
*/
|
|
431
|
-
private toggleAccessibilityAttributes(show?: boolean) {
|
|
432
|
-
const expandedValue =
|
|
433
|
-
show?.toString() || this.referenceElement.ariaExpanded || "false";
|
|
434
|
-
this.referenceElement.ariaExpanded = expandedValue;
|
|
435
|
-
this.referenceElement.setAttribute("aria-expanded", expandedValue);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Helper to manually show an s-popover element via external JS
|
|
441
|
-
* @param element the element the `data-controller="s-popover"` attribute is on
|
|
442
|
-
*/
|
|
443
|
-
export function showPopover(element: HTMLElement) {
|
|
444
|
-
const { isPopover, controller } = getPopover(element);
|
|
445
|
-
if (controller) {
|
|
446
|
-
controller.show();
|
|
447
|
-
} else if (isPopover) {
|
|
448
|
-
element.setAttribute("data-s-popover-auto-show", "true");
|
|
449
|
-
} else {
|
|
450
|
-
throw `element does not have data-controller="s-popover"`;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Helper to manually hide an s-popover element via external JS
|
|
456
|
-
* @param element the element the `data-controller="s-popover"` attribute is on
|
|
457
|
-
*/
|
|
458
|
-
export function hidePopover(element: Element) {
|
|
459
|
-
const { isPopover, controller, popover } = getPopover(element);
|
|
460
|
-
|
|
461
|
-
if (controller) {
|
|
462
|
-
controller.hide();
|
|
463
|
-
} else if (isPopover) {
|
|
464
|
-
element.removeAttribute("data-s-popover-auto-show");
|
|
465
|
-
if (popover) {
|
|
466
|
-
popover.classList.remove("is-visible");
|
|
467
|
-
}
|
|
468
|
-
} else {
|
|
469
|
-
throw `element does not have data-controller="s-popover"`;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Options to use when attaching a popover via `Stacks.attachPopover`.
|
|
475
|
-
* @see Stacks.attachPopover
|
|
476
|
-
*/
|
|
477
|
-
export interface PopoverOptions {
|
|
478
|
-
/**
|
|
479
|
-
* When true, the `click->s-popover#toggle` action will be attached to the controller element or reference element.
|
|
480
|
-
*/
|
|
481
|
-
toggleOnClick?: boolean;
|
|
482
|
-
/**
|
|
483
|
-
* When set, `data-s-popover-placement` will be set to this value on the controller element.
|
|
484
|
-
*/
|
|
485
|
-
placement?: string;
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* When true, the popover will appear immediately when the controller connects.
|
|
489
|
-
*/
|
|
490
|
-
autoShow?: boolean;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Attaches a popover to an element and performs additional configuration.
|
|
495
|
-
* @param element the element that will receive the `data-controller="s-popover"` attribute.
|
|
496
|
-
* @param popover an element with the `.s-popover` class or HTML string containing a single element with the `.s-popover` class.
|
|
497
|
-
* If the popover does not have a parent element, it will be inserted as a immediately after the reference element.
|
|
498
|
-
* @param options an optional collection of options to use when configuring the popover.
|
|
499
|
-
*/
|
|
500
|
-
export function attachPopover(
|
|
501
|
-
element: Element,
|
|
502
|
-
popover: Element | string,
|
|
503
|
-
options?: PopoverOptions
|
|
504
|
-
) {
|
|
505
|
-
const { referenceElement, popover: existingPopover } = getPopover(element);
|
|
506
|
-
|
|
507
|
-
if (existingPopover) {
|
|
508
|
-
throw `element already has popover with id="${existingPopover.id}"`;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (!referenceElement) {
|
|
512
|
-
throw `element has invalid data-s-popover-reference-selector attribute`;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (typeof popover === "string") {
|
|
516
|
-
// eslint-disable-next-line no-unsanitized/method
|
|
517
|
-
const elements = document
|
|
518
|
-
.createRange()
|
|
519
|
-
.createContextualFragment(popover).children;
|
|
520
|
-
if (elements.length !== 1) {
|
|
521
|
-
throw "popover should contain a single element";
|
|
522
|
-
}
|
|
523
|
-
popover = elements[0];
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const existingId = referenceElement.getAttribute("aria-controls");
|
|
527
|
-
let popoverId = popover.id;
|
|
528
|
-
|
|
529
|
-
if (!popover.classList.contains("s-popover")) {
|
|
530
|
-
throw `popover should have the "s-popover" class but had class="${popover.className}"`;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
if (existingId && existingId !== popoverId) {
|
|
534
|
-
throw `element has aria-controls="${existingId}" but popover has id="${popoverId}"`;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
if (!popoverId) {
|
|
538
|
-
popoverId =
|
|
539
|
-
"--stacks-s-popover-" + Math.random().toString(36).substring(2, 10);
|
|
540
|
-
popover.id = popoverId;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (!existingId) {
|
|
544
|
-
referenceElement.setAttribute("aria-controls", popoverId);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
if (!popover.parentElement && element.parentElement) {
|
|
548
|
-
referenceElement.insertAdjacentElement("afterend", popover);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
toggleController(element, "s-popover", true);
|
|
552
|
-
|
|
553
|
-
if (options) {
|
|
554
|
-
if (options.toggleOnClick) {
|
|
555
|
-
referenceElement.setAttribute(
|
|
556
|
-
"data-action",
|
|
557
|
-
"click->s-popover#toggle"
|
|
558
|
-
);
|
|
559
|
-
}
|
|
560
|
-
if (options.placement) {
|
|
561
|
-
element.setAttribute("data-s-popover-placement", options.placement);
|
|
562
|
-
}
|
|
563
|
-
if (options.autoShow) {
|
|
564
|
-
element.setAttribute("data-s-popover-auto-show", "true");
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Removes the popover controller from an element and removes the popover from the DOM.
|
|
571
|
-
* @param element the element that has the `data-controller="s-popover"` attribute.
|
|
572
|
-
* @returns The popover that was attached to the element.
|
|
573
|
-
*/
|
|
574
|
-
export function detachPopover(element: Element) {
|
|
575
|
-
const { isPopover, controller, referenceElement, popover } =
|
|
576
|
-
getPopover(element);
|
|
577
|
-
|
|
578
|
-
// Hide the popover so its events fire.
|
|
579
|
-
controller?.hide();
|
|
580
|
-
|
|
581
|
-
// Remove the popover if it exists
|
|
582
|
-
popover?.remove();
|
|
583
|
-
|
|
584
|
-
// Remove the popover controller and the aria-controls attributes.
|
|
585
|
-
if (isPopover) {
|
|
586
|
-
toggleController(element, "s-popover", false);
|
|
587
|
-
if (referenceElement) {
|
|
588
|
-
referenceElement.removeAttribute("aria-controls");
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
return popover;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
interface GetPopoverResult {
|
|
596
|
-
/** indicates whether or not the element has s-popover in its `data-controller` class */
|
|
597
|
-
isPopover: boolean;
|
|
598
|
-
/** element's existing `PopoverController` or null it it has not been configured yet */
|
|
599
|
-
controller: PopoverController | null;
|
|
600
|
-
/** popover's reference element as would live in `referenceSelector` or null if invalid */
|
|
601
|
-
referenceElement: Element | null;
|
|
602
|
-
/** popover currently associated with the controller, or null if one does not exist in the DOM */
|
|
603
|
-
popover: HTMLElement | null;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* Gets the current state of an element that may be or is intended to be an s-popover controller
|
|
608
|
-
* so it can be configured either directly or via the DOM.
|
|
609
|
-
* @param element An element that may have `data-controller="s-popover"`.
|
|
610
|
-
*/
|
|
611
|
-
function getPopover(element: Element): GetPopoverResult {
|
|
612
|
-
const isPopover =
|
|
613
|
-
element.getAttribute("data-controller")?.includes("s-popover") || false;
|
|
614
|
-
const controller = Stacks.application.getControllerForElementAndIdentifier(
|
|
615
|
-
element,
|
|
616
|
-
"s-popover"
|
|
617
|
-
) as PopoverController;
|
|
618
|
-
const referenceSelector = element.getAttribute(
|
|
619
|
-
"data-s-popover-reference-selector"
|
|
620
|
-
);
|
|
621
|
-
const referenceElement = referenceSelector
|
|
622
|
-
? element.querySelector(referenceSelector)
|
|
623
|
-
: element;
|
|
624
|
-
const popoverId = referenceElement
|
|
625
|
-
? referenceElement.getAttribute("aria-controls")
|
|
626
|
-
: null;
|
|
627
|
-
const popover = popoverId ? document.getElementById(popoverId) : null;
|
|
628
|
-
return { isPopover, controller, referenceElement, popover };
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Adds or removes the controller from an element's [data-controller] attribute without altering existing entries
|
|
633
|
-
* @param el The element to alter
|
|
634
|
-
* @param controllerName The name of the controller to add/remove
|
|
635
|
-
* @param include Whether to add the controllerName value
|
|
636
|
-
*/
|
|
637
|
-
function toggleController(
|
|
638
|
-
el: Element,
|
|
639
|
-
controllerName: string,
|
|
640
|
-
include: boolean
|
|
641
|
-
) {
|
|
642
|
-
const controllers = new Set(
|
|
643
|
-
el.getAttribute("data-controller")?.split(/\s+/)
|
|
644
|
-
);
|
|
645
|
-
if (include) {
|
|
646
|
-
controllers.add(controllerName);
|
|
647
|
-
} else {
|
|
648
|
-
controllers.delete(controllerName);
|
|
649
|
-
}
|
|
650
|
-
el.setAttribute("data-controller", Array.from(controllers).join(" "));
|
|
651
|
-
}
|
|
1
|
+
import { createPopper, Placement } from "@popperjs/core";
|
|
2
|
+
import type * as Popper from "@popperjs/core";
|
|
3
|
+
import * as Stacks from "../../stacks";
|
|
4
|
+
|
|
5
|
+
type OutsideClickBehavior =
|
|
6
|
+
| "always"
|
|
7
|
+
| "never"
|
|
8
|
+
| "if-in-viewport"
|
|
9
|
+
| "after-dismissal";
|
|
10
|
+
|
|
11
|
+
export abstract class BasePopoverController extends Stacks.StacksController {
|
|
12
|
+
private popper!: Popper.Instance;
|
|
13
|
+
|
|
14
|
+
protected popoverElement!: HTMLElement;
|
|
15
|
+
|
|
16
|
+
protected referenceElement!: HTMLElement;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An attribute containing the ID of the popover element to render, e.g. aria-controls or aria-describedby.
|
|
20
|
+
*/
|
|
21
|
+
protected abstract popoverSelectorAttribute: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Binds events to the document on element show
|
|
25
|
+
*/
|
|
26
|
+
protected abstract bindDocumentEvents(): void;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Unbinds events on the document on element hide
|
|
30
|
+
*/
|
|
31
|
+
protected abstract unbindDocumentEvents(): void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns true if the if the popover is currently visible.
|
|
35
|
+
*/
|
|
36
|
+
get isVisible() {
|
|
37
|
+
const popoverElement = this.popoverElement;
|
|
38
|
+
return popoverElement
|
|
39
|
+
? popoverElement.classList.contains("is-visible")
|
|
40
|
+
: false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Gets whether the element is visible in the browser's viewport.
|
|
45
|
+
*/
|
|
46
|
+
get isInViewport() {
|
|
47
|
+
const element = this.popoverElement;
|
|
48
|
+
if (!this.isVisible || !element) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// From https://stackoverflow.com/a/5354536. Theoretically, this could be calculated using Popper's detectOverflow function,
|
|
53
|
+
// but it's unclear how to access that with our current configuration.
|
|
54
|
+
|
|
55
|
+
const rect = element.getBoundingClientRect();
|
|
56
|
+
const viewHeight = Math.max(
|
|
57
|
+
document.documentElement.clientHeight,
|
|
58
|
+
window.innerHeight
|
|
59
|
+
);
|
|
60
|
+
const viewWidth = Math.max(
|
|
61
|
+
document.documentElement.clientWidth,
|
|
62
|
+
window.innerWidth
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
rect.bottom > 0 &&
|
|
67
|
+
rect.top < viewHeight &&
|
|
68
|
+
rect.right > 0 &&
|
|
69
|
+
rect.left < viewWidth
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
protected get shouldHideOnOutsideClick() {
|
|
74
|
+
const hideBehavior = <OutsideClickBehavior>(
|
|
75
|
+
this.data.get("hide-on-outside-click")
|
|
76
|
+
);
|
|
77
|
+
switch (hideBehavior) {
|
|
78
|
+
case "after-dismissal":
|
|
79
|
+
case "never":
|
|
80
|
+
return false;
|
|
81
|
+
case "if-in-viewport":
|
|
82
|
+
return this.isInViewport;
|
|
83
|
+
default:
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Initializes and validates controller variables
|
|
90
|
+
*/
|
|
91
|
+
connect() {
|
|
92
|
+
super.connect();
|
|
93
|
+
this.validate();
|
|
94
|
+
if (this.isVisible) {
|
|
95
|
+
// just call initialize here, not show. This keeps already visible popovers from adding/firing document events
|
|
96
|
+
this.initializePopper();
|
|
97
|
+
} else if (this.data.get("auto-show") === "true") {
|
|
98
|
+
this.show(null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.data.delete("auto-show");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Cleans up popper.js elements and disconnects all added event listeners
|
|
106
|
+
*/
|
|
107
|
+
disconnect() {
|
|
108
|
+
this.hide();
|
|
109
|
+
if (this.popper) {
|
|
110
|
+
this.popper.destroy();
|
|
111
|
+
// eslint-disable-next-line
|
|
112
|
+
// @ts-ignore The operand of a 'delete' operator must be optional .ts(2790)
|
|
113
|
+
delete this.popper;
|
|
114
|
+
}
|
|
115
|
+
super.disconnect();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Toggles the visibility of the popover
|
|
120
|
+
*/
|
|
121
|
+
toggle(dispatcher: Event | Element | null = null) {
|
|
122
|
+
this.isVisible ? this.hide(dispatcher) : this.show(dispatcher);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Shows the popover if not already visible
|
|
127
|
+
*/
|
|
128
|
+
show(dispatcher: Event | Element | null = null) {
|
|
129
|
+
if (this.isVisible) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const dispatcherElement = this.getDispatcher(dispatcher);
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
this.triggerEvent("show", {
|
|
137
|
+
dispatcher: dispatcherElement,
|
|
138
|
+
}).defaultPrevented
|
|
139
|
+
) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!this.popper) {
|
|
144
|
+
this.initializePopper();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.popoverElement.classList.add("is-visible");
|
|
148
|
+
|
|
149
|
+
// ensure the popper has been positioned correctly
|
|
150
|
+
this.scheduleUpdate();
|
|
151
|
+
|
|
152
|
+
this.shown(dispatcherElement);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Hides the popover if not already hidden
|
|
157
|
+
*/
|
|
158
|
+
hide(dispatcher: Event | Element | null = null) {
|
|
159
|
+
if (!this.isVisible) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const dispatcherElement = this.getDispatcher(dispatcher);
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
this.triggerEvent("hide", {
|
|
167
|
+
dispatcher: dispatcherElement,
|
|
168
|
+
}).defaultPrevented
|
|
169
|
+
) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this.popoverElement.classList.remove("is-visible");
|
|
174
|
+
|
|
175
|
+
if (this.popper) {
|
|
176
|
+
// completely destroy the popper on hide; this is in line with Popper.js's performance recommendations
|
|
177
|
+
this.popper.destroy();
|
|
178
|
+
// eslint-disable-next-line
|
|
179
|
+
// @ts-ignore The operand of a 'delete' operator must be optional .ts(2790)
|
|
180
|
+
delete this.popper;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// on first interaction, hide-on-outside-click with value "after-dismissal" reverts to the default behavior
|
|
184
|
+
if (
|
|
185
|
+
<OutsideClickBehavior>this.data.get("hide-on-outside-click") ===
|
|
186
|
+
"after-dismissal"
|
|
187
|
+
) {
|
|
188
|
+
this.data.delete("hide-on-outside-click");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.hidden(dispatcherElement);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Binds document events for this popover and fires the shown event
|
|
196
|
+
*/
|
|
197
|
+
protected shown(dispatcher: Element | null = null) {
|
|
198
|
+
this.bindDocumentEvents();
|
|
199
|
+
this.triggerEvent("shown", {
|
|
200
|
+
dispatcher: dispatcher,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Unbinds document events for this popover and fires the hidden event
|
|
206
|
+
*/
|
|
207
|
+
protected hidden(dispatcher: Element | null = null) {
|
|
208
|
+
this.unbindDocumentEvents();
|
|
209
|
+
this.triggerEvent("hidden", {
|
|
210
|
+
dispatcher: dispatcher,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Generates the popover if not found during initialization
|
|
216
|
+
*/
|
|
217
|
+
protected generatePopover(): HTMLElement | null {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Initializes the Popper for this instance
|
|
223
|
+
*/
|
|
224
|
+
private initializePopper() {
|
|
225
|
+
this.popper = createPopper(this.referenceElement, this.popoverElement, {
|
|
226
|
+
placement: (this.data.get("placement") as Placement) || "bottom",
|
|
227
|
+
modifiers: [
|
|
228
|
+
{
|
|
229
|
+
name: "offset",
|
|
230
|
+
options: {
|
|
231
|
+
offset: [0, 10], // The entire popover should be 10px away from the element
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: "arrow",
|
|
236
|
+
options: {
|
|
237
|
+
element: ".s-popover--arrow",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Validates the popover settings and attempts to set necessary internal variables
|
|
246
|
+
*/
|
|
247
|
+
private validate() {
|
|
248
|
+
const referenceSelector = this.data.get("reference-selector");
|
|
249
|
+
|
|
250
|
+
this.referenceElement = <HTMLElement>this.element;
|
|
251
|
+
|
|
252
|
+
// if there is an alternative reference selector and that element exists, use it (and throw if it isn't found)
|
|
253
|
+
if (referenceSelector) {
|
|
254
|
+
this.referenceElement = <HTMLElement>(
|
|
255
|
+
this.element.querySelector(referenceSelector)
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (!this.referenceElement) {
|
|
259
|
+
throw (
|
|
260
|
+
"Unable to find element by reference selector: " +
|
|
261
|
+
referenceSelector
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const popoverId = this.referenceElement.getAttribute(
|
|
267
|
+
this.popoverSelectorAttribute
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
let popoverElement: HTMLElement | null = null;
|
|
271
|
+
|
|
272
|
+
// if the popover is named, attempt to fetch it (and throw an error if it doesn't exist)
|
|
273
|
+
if (popoverId) {
|
|
274
|
+
popoverElement = document.getElementById(popoverId);
|
|
275
|
+
|
|
276
|
+
if (!popoverElement) {
|
|
277
|
+
throw `[${this.popoverSelectorAttribute}="{POPOVER_ID}"] required`;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// if the popover isn't named, attempt to generate it
|
|
281
|
+
else {
|
|
282
|
+
popoverElement = this.generatePopover();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!popoverElement) {
|
|
286
|
+
throw "unable to find or generate popover element";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
this.popoverElement = popoverElement;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Determines the correct dispatching element from a potential input
|
|
294
|
+
* @param dispatcher The event or element to get the dispatcher from
|
|
295
|
+
*/
|
|
296
|
+
protected getDispatcher(
|
|
297
|
+
dispatcher: Event | Element | null = null
|
|
298
|
+
): Element {
|
|
299
|
+
if (dispatcher instanceof Event) {
|
|
300
|
+
return <Element>dispatcher.target;
|
|
301
|
+
} else if (dispatcher instanceof Element) {
|
|
302
|
+
return dispatcher;
|
|
303
|
+
} else {
|
|
304
|
+
return this.element;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Schedules the popover to update on the next animation frame if visible
|
|
310
|
+
*/
|
|
311
|
+
protected scheduleUpdate() {
|
|
312
|
+
if (this.popper && this.isVisible) {
|
|
313
|
+
void this.popper.update();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export class PopoverController extends BasePopoverController {
|
|
319
|
+
static targets = [];
|
|
320
|
+
|
|
321
|
+
protected popoverSelectorAttribute = "aria-controls";
|
|
322
|
+
|
|
323
|
+
private boundHideOnOutsideClick!: (event: MouseEvent) => void;
|
|
324
|
+
private boundHideOnEscapePress!: (event: KeyboardEvent) => void;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Toggles optional classes and accessibility attributes in addition to BasePopoverController.shown
|
|
328
|
+
*/
|
|
329
|
+
protected override shown(dispatcher: Element | null = null) {
|
|
330
|
+
this.toggleOptionalClasses(true);
|
|
331
|
+
this.toggleAccessibilityAttributes(true);
|
|
332
|
+
super.shown(dispatcher);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Toggles optional classes and accessibility attributes in addition to BasePopoverController.hidden
|
|
337
|
+
*/
|
|
338
|
+
protected override hidden(dispatcher: Element | null = null) {
|
|
339
|
+
this.toggleOptionalClasses(false);
|
|
340
|
+
this.toggleAccessibilityAttributes(false);
|
|
341
|
+
super.hidden(dispatcher);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Initializes accessibility attributes in addition to BasePopoverController.connect
|
|
346
|
+
*/
|
|
347
|
+
public override connect(): void {
|
|
348
|
+
super.connect();
|
|
349
|
+
|
|
350
|
+
this.toggleAccessibilityAttributes();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Binds global events to the document for hiding popovers on user interaction
|
|
355
|
+
*/
|
|
356
|
+
protected bindDocumentEvents() {
|
|
357
|
+
this.boundHideOnOutsideClick =
|
|
358
|
+
this.boundHideOnOutsideClick || this.hideOnOutsideClick.bind(this);
|
|
359
|
+
this.boundHideOnEscapePress =
|
|
360
|
+
this.boundHideOnEscapePress || this.hideOnEscapePress.bind(this);
|
|
361
|
+
|
|
362
|
+
document.addEventListener("mousedown", this.boundHideOnOutsideClick);
|
|
363
|
+
document.addEventListener("keyup", this.boundHideOnEscapePress);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Unbinds global events to the document for hiding popovers on user interaction
|
|
368
|
+
*/
|
|
369
|
+
protected unbindDocumentEvents() {
|
|
370
|
+
document.removeEventListener("mousedown", this.boundHideOnOutsideClick);
|
|
371
|
+
document.removeEventListener("keyup", this.boundHideOnEscapePress);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Forces the popover to hide if a user clicks outside of it or its reference element
|
|
376
|
+
* @param {Event} e - The document click event
|
|
377
|
+
*/
|
|
378
|
+
private hideOnOutsideClick(e: MouseEvent) {
|
|
379
|
+
const target = <Node>e.target;
|
|
380
|
+
// check if the document was clicked inside either the reference element or the popover itself
|
|
381
|
+
// note: .contains also returns true if the node itself matches the target element
|
|
382
|
+
if (
|
|
383
|
+
this.shouldHideOnOutsideClick &&
|
|
384
|
+
!this.referenceElement.contains(target) &&
|
|
385
|
+
!this.popoverElement.contains(target) &&
|
|
386
|
+
document.body.contains(target)
|
|
387
|
+
) {
|
|
388
|
+
this.hide(e);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Forces the popover to hide if the user presses escape while it, one of its childen, or the reference element are focused
|
|
394
|
+
* @param {Event} e - The document keyup event
|
|
395
|
+
*/
|
|
396
|
+
private hideOnEscapePress(e: KeyboardEvent) {
|
|
397
|
+
// if the ESC key (27) wasn't pressed or if no popovers are showing, return
|
|
398
|
+
if (e.which !== 27 || !this.isVisible) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// check if the target was inside the popover element and refocus the triggering element
|
|
403
|
+
// note: .contains also returns true if the node itself matches the target element
|
|
404
|
+
if (this.popoverElement.contains(<Node>e.target)) {
|
|
405
|
+
this.referenceElement.focus();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.hide(e);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Toggles all classes on the originating element based on the `class-toggle` data
|
|
413
|
+
* @param {boolean=} show - A boolean indicating whether this is being triggered by a show or hide.
|
|
414
|
+
*/
|
|
415
|
+
private toggleOptionalClasses(show?: boolean) {
|
|
416
|
+
if (!this.data.has("toggle-class")) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const toggleClass = this.data.get("toggle-class") || "";
|
|
421
|
+
const cl = this.referenceElement.classList;
|
|
422
|
+
toggleClass.split(/\s+/).forEach(function (cls: string) {
|
|
423
|
+
cl.toggle(cls, show);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Toggles accessibility attributes based on whether the popover is shown or not
|
|
429
|
+
* @param {boolean=} show - A boolean indicating whether this is being triggered by a show or hide.
|
|
430
|
+
*/
|
|
431
|
+
private toggleAccessibilityAttributes(show?: boolean) {
|
|
432
|
+
const expandedValue =
|
|
433
|
+
show?.toString() || this.referenceElement.ariaExpanded || "false";
|
|
434
|
+
this.referenceElement.ariaExpanded = expandedValue;
|
|
435
|
+
this.referenceElement.setAttribute("aria-expanded", expandedValue);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Helper to manually show an s-popover element via external JS
|
|
441
|
+
* @param element the element the `data-controller="s-popover"` attribute is on
|
|
442
|
+
*/
|
|
443
|
+
export function showPopover(element: HTMLElement) {
|
|
444
|
+
const { isPopover, controller } = getPopover(element);
|
|
445
|
+
if (controller) {
|
|
446
|
+
controller.show();
|
|
447
|
+
} else if (isPopover) {
|
|
448
|
+
element.setAttribute("data-s-popover-auto-show", "true");
|
|
449
|
+
} else {
|
|
450
|
+
throw `element does not have data-controller="s-popover"`;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Helper to manually hide an s-popover element via external JS
|
|
456
|
+
* @param element the element the `data-controller="s-popover"` attribute is on
|
|
457
|
+
*/
|
|
458
|
+
export function hidePopover(element: Element) {
|
|
459
|
+
const { isPopover, controller, popover } = getPopover(element);
|
|
460
|
+
|
|
461
|
+
if (controller) {
|
|
462
|
+
controller.hide();
|
|
463
|
+
} else if (isPopover) {
|
|
464
|
+
element.removeAttribute("data-s-popover-auto-show");
|
|
465
|
+
if (popover) {
|
|
466
|
+
popover.classList.remove("is-visible");
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
throw `element does not have data-controller="s-popover"`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Options to use when attaching a popover via `Stacks.attachPopover`.
|
|
475
|
+
* @see Stacks.attachPopover
|
|
476
|
+
*/
|
|
477
|
+
export interface PopoverOptions {
|
|
478
|
+
/**
|
|
479
|
+
* When true, the `click->s-popover#toggle` action will be attached to the controller element or reference element.
|
|
480
|
+
*/
|
|
481
|
+
toggleOnClick?: boolean;
|
|
482
|
+
/**
|
|
483
|
+
* When set, `data-s-popover-placement` will be set to this value on the controller element.
|
|
484
|
+
*/
|
|
485
|
+
placement?: string;
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* When true, the popover will appear immediately when the controller connects.
|
|
489
|
+
*/
|
|
490
|
+
autoShow?: boolean;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Attaches a popover to an element and performs additional configuration.
|
|
495
|
+
* @param element the element that will receive the `data-controller="s-popover"` attribute.
|
|
496
|
+
* @param popover an element with the `.s-popover` class or HTML string containing a single element with the `.s-popover` class.
|
|
497
|
+
* If the popover does not have a parent element, it will be inserted as a immediately after the reference element.
|
|
498
|
+
* @param options an optional collection of options to use when configuring the popover.
|
|
499
|
+
*/
|
|
500
|
+
export function attachPopover(
|
|
501
|
+
element: Element,
|
|
502
|
+
popover: Element | string,
|
|
503
|
+
options?: PopoverOptions
|
|
504
|
+
) {
|
|
505
|
+
const { referenceElement, popover: existingPopover } = getPopover(element);
|
|
506
|
+
|
|
507
|
+
if (existingPopover) {
|
|
508
|
+
throw `element already has popover with id="${existingPopover.id}"`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (!referenceElement) {
|
|
512
|
+
throw `element has invalid data-s-popover-reference-selector attribute`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (typeof popover === "string") {
|
|
516
|
+
// eslint-disable-next-line no-unsanitized/method
|
|
517
|
+
const elements = document
|
|
518
|
+
.createRange()
|
|
519
|
+
.createContextualFragment(popover).children;
|
|
520
|
+
if (elements.length !== 1) {
|
|
521
|
+
throw "popover should contain a single element";
|
|
522
|
+
}
|
|
523
|
+
popover = elements[0];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const existingId = referenceElement.getAttribute("aria-controls");
|
|
527
|
+
let popoverId = popover.id;
|
|
528
|
+
|
|
529
|
+
if (!popover.classList.contains("s-popover")) {
|
|
530
|
+
throw `popover should have the "s-popover" class but had class="${popover.className}"`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (existingId && existingId !== popoverId) {
|
|
534
|
+
throw `element has aria-controls="${existingId}" but popover has id="${popoverId}"`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!popoverId) {
|
|
538
|
+
popoverId =
|
|
539
|
+
"--stacks-s-popover-" + Math.random().toString(36).substring(2, 10);
|
|
540
|
+
popover.id = popoverId;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!existingId) {
|
|
544
|
+
referenceElement.setAttribute("aria-controls", popoverId);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!popover.parentElement && element.parentElement) {
|
|
548
|
+
referenceElement.insertAdjacentElement("afterend", popover);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
toggleController(element, "s-popover", true);
|
|
552
|
+
|
|
553
|
+
if (options) {
|
|
554
|
+
if (options.toggleOnClick) {
|
|
555
|
+
referenceElement.setAttribute(
|
|
556
|
+
"data-action",
|
|
557
|
+
"click->s-popover#toggle"
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (options.placement) {
|
|
561
|
+
element.setAttribute("data-s-popover-placement", options.placement);
|
|
562
|
+
}
|
|
563
|
+
if (options.autoShow) {
|
|
564
|
+
element.setAttribute("data-s-popover-auto-show", "true");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Removes the popover controller from an element and removes the popover from the DOM.
|
|
571
|
+
* @param element the element that has the `data-controller="s-popover"` attribute.
|
|
572
|
+
* @returns The popover that was attached to the element.
|
|
573
|
+
*/
|
|
574
|
+
export function detachPopover(element: Element) {
|
|
575
|
+
const { isPopover, controller, referenceElement, popover } =
|
|
576
|
+
getPopover(element);
|
|
577
|
+
|
|
578
|
+
// Hide the popover so its events fire.
|
|
579
|
+
controller?.hide();
|
|
580
|
+
|
|
581
|
+
// Remove the popover if it exists
|
|
582
|
+
popover?.remove();
|
|
583
|
+
|
|
584
|
+
// Remove the popover controller and the aria-controls attributes.
|
|
585
|
+
if (isPopover) {
|
|
586
|
+
toggleController(element, "s-popover", false);
|
|
587
|
+
if (referenceElement) {
|
|
588
|
+
referenceElement.removeAttribute("aria-controls");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return popover;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
interface GetPopoverResult {
|
|
596
|
+
/** indicates whether or not the element has s-popover in its `data-controller` class */
|
|
597
|
+
isPopover: boolean;
|
|
598
|
+
/** element's existing `PopoverController` or null it it has not been configured yet */
|
|
599
|
+
controller: PopoverController | null;
|
|
600
|
+
/** popover's reference element as would live in `referenceSelector` or null if invalid */
|
|
601
|
+
referenceElement: Element | null;
|
|
602
|
+
/** popover currently associated with the controller, or null if one does not exist in the DOM */
|
|
603
|
+
popover: HTMLElement | null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Gets the current state of an element that may be or is intended to be an s-popover controller
|
|
608
|
+
* so it can be configured either directly or via the DOM.
|
|
609
|
+
* @param element An element that may have `data-controller="s-popover"`.
|
|
610
|
+
*/
|
|
611
|
+
function getPopover(element: Element): GetPopoverResult {
|
|
612
|
+
const isPopover =
|
|
613
|
+
element.getAttribute("data-controller")?.includes("s-popover") || false;
|
|
614
|
+
const controller = Stacks.application.getControllerForElementAndIdentifier(
|
|
615
|
+
element,
|
|
616
|
+
"s-popover"
|
|
617
|
+
) as PopoverController;
|
|
618
|
+
const referenceSelector = element.getAttribute(
|
|
619
|
+
"data-s-popover-reference-selector"
|
|
620
|
+
);
|
|
621
|
+
const referenceElement = referenceSelector
|
|
622
|
+
? element.querySelector(referenceSelector)
|
|
623
|
+
: element;
|
|
624
|
+
const popoverId = referenceElement
|
|
625
|
+
? referenceElement.getAttribute("aria-controls")
|
|
626
|
+
: null;
|
|
627
|
+
const popover = popoverId ? document.getElementById(popoverId) : null;
|
|
628
|
+
return { isPopover, controller, referenceElement, popover };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Adds or removes the controller from an element's [data-controller] attribute without altering existing entries
|
|
633
|
+
* @param el The element to alter
|
|
634
|
+
* @param controllerName The name of the controller to add/remove
|
|
635
|
+
* @param include Whether to add the controllerName value
|
|
636
|
+
*/
|
|
637
|
+
function toggleController(
|
|
638
|
+
el: Element,
|
|
639
|
+
controllerName: string,
|
|
640
|
+
include: boolean
|
|
641
|
+
) {
|
|
642
|
+
const controllers = new Set(
|
|
643
|
+
el.getAttribute("data-controller")?.split(/\s+/)
|
|
644
|
+
);
|
|
645
|
+
if (include) {
|
|
646
|
+
controllers.add(controllerName);
|
|
647
|
+
} else {
|
|
648
|
+
controllers.delete(controllerName);
|
|
649
|
+
}
|
|
650
|
+
el.setAttribute("data-controller", Array.from(controllers).join(" "));
|
|
651
|
+
}
|