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