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