@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.
Files changed (83) hide show
  1. package/LICENSE.MD +9 -9
  2. package/README.md +158 -180
  3. package/dist/css/stacks.css +6 -0
  4. package/dist/js/stacks.min.js +1 -1
  5. package/lib/atomic/border.less +139 -139
  6. package/lib/atomic/color.less +36 -36
  7. package/lib/atomic/flex.less +426 -426
  8. package/lib/atomic/gap.less +44 -44
  9. package/lib/atomic/grid.less +139 -139
  10. package/lib/atomic/misc.less +374 -374
  11. package/lib/atomic/spacing.less +98 -98
  12. package/lib/atomic/typography.less +266 -264
  13. package/lib/atomic/width-height.less +194 -194
  14. package/lib/base/body.less +44 -44
  15. package/lib/base/configuration-static.less +61 -61
  16. package/lib/base/fieldset.less +5 -5
  17. package/lib/base/icon.less +11 -11
  18. package/lib/base/internal.less +220 -220
  19. package/lib/base/reset-meyer.less +64 -64
  20. package/lib/base/reset-normalize.less +449 -449
  21. package/lib/base/reset.less +20 -20
  22. package/lib/components/activity-indicator/activity-indicator.less +53 -53
  23. package/lib/components/avatar/avatar.less +108 -108
  24. package/lib/components/award-bling/award-bling.less +31 -31
  25. package/lib/components/banner/banner.less +44 -44
  26. package/lib/components/banner/banner.ts +149 -149
  27. package/lib/components/block-link/block-link.less +82 -82
  28. package/lib/components/breadcrumbs/breadcrumbs.less +41 -41
  29. package/lib/components/button-group/button-group.less +82 -82
  30. package/lib/components/card/card.less +37 -37
  31. package/lib/components/check-control/check-control.less +17 -17
  32. package/lib/components/check-group/check-group.less +19 -19
  33. package/lib/components/checkbox_radio/checkbox_radio.less +159 -159
  34. package/lib/components/code-block/code-block.fixtures.ts +88 -88
  35. package/lib/components/code-block/code-block.less +116 -116
  36. package/lib/components/description/description.less +9 -9
  37. package/lib/components/empty-state/empty-state.less +16 -16
  38. package/lib/components/expandable/expandable.less +118 -118
  39. package/lib/components/input-fill/input-fill.less +35 -35
  40. package/lib/components/input-icon/input-icon.less +45 -45
  41. package/lib/components/input-message/input-message.less +49 -49
  42. package/lib/components/label/label.less +110 -110
  43. package/lib/components/link-preview/link-preview.less +148 -148
  44. package/lib/components/menu/menu.less +41 -41
  45. package/lib/components/modal/modal.less +118 -118
  46. package/lib/components/modal/modal.ts +383 -383
  47. package/lib/components/navigation/navigation.less +136 -136
  48. package/lib/components/navigation/navigation.ts +128 -128
  49. package/lib/components/page-title/page-title.less +51 -51
  50. package/lib/components/popover/popover.less +159 -159
  51. package/lib/components/popover/popover.ts +651 -651
  52. package/lib/components/post-summary/post-summary.less +457 -457
  53. package/lib/components/progress-bar/progress-bar.less +291 -291
  54. package/lib/components/prose/prose.less +452 -452
  55. package/lib/components/select/select.less +138 -138
  56. package/lib/components/spinner/spinner.less +103 -103
  57. package/lib/components/table/table.ts +296 -296
  58. package/lib/components/table-container/table-container.less +4 -4
  59. package/lib/components/tag/tag.less +186 -186
  60. package/lib/components/toast/toast.less +35 -35
  61. package/lib/components/toast/toast.ts +357 -357
  62. package/lib/components/toggle-switch/toggle-switch.less +104 -104
  63. package/lib/components/topbar/topbar.less +553 -553
  64. package/lib/components/uploader/uploader.less +205 -205
  65. package/lib/components/user-card/user-card.less +129 -129
  66. package/lib/controllers.ts +33 -33
  67. package/lib/exports/color-mixins.less +283 -283
  68. package/lib/exports/constants-helpers.less +108 -108
  69. package/lib/exports/constants-type.less +155 -155
  70. package/lib/exports/exports.less +15 -15
  71. package/lib/exports/mixins.less +334 -333
  72. package/lib/exports/spacing-mixins.less +67 -67
  73. package/lib/index.ts +32 -32
  74. package/lib/input-utils.less +41 -41
  75. package/lib/stacks-dynamic.less +24 -24
  76. package/lib/stacks-static.less +93 -93
  77. package/lib/stacks.less +13 -13
  78. package/lib/test/assertions.ts +36 -36
  79. package/lib/test/less-test-utils.ts +28 -28
  80. package/lib/test/open-wc-testing-patch.d.ts +26 -26
  81. package/lib/tsconfig.build.json +4 -4
  82. package/lib/tsconfig.json +17 -17
  83. 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
+ }