@stackoverflow/stacks 2.7.3 → 2.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.MD +9 -9
- package/README.md +158 -180
- package/dist/css/stacks.css +6 -0
- package/dist/js/stacks.min.js +1 -1
- package/lib/atomic/border.less +139 -139
- package/lib/atomic/color.less +36 -36
- package/lib/atomic/flex.less +426 -426
- package/lib/atomic/gap.less +44 -44
- package/lib/atomic/grid.less +139 -139
- package/lib/atomic/misc.less +374 -374
- package/lib/atomic/spacing.less +98 -98
- package/lib/atomic/typography.less +266 -264
- package/lib/atomic/width-height.less +194 -194
- package/lib/base/body.less +44 -44
- package/lib/base/configuration-static.less +61 -61
- package/lib/base/fieldset.less +5 -5
- package/lib/base/icon.less +11 -11
- package/lib/base/internal.less +220 -220
- package/lib/base/reset-meyer.less +64 -64
- package/lib/base/reset-normalize.less +449 -449
- package/lib/base/reset.less +20 -20
- package/lib/components/activity-indicator/activity-indicator.less +53 -53
- package/lib/components/avatar/avatar.less +108 -108
- package/lib/components/award-bling/award-bling.less +31 -31
- package/lib/components/banner/banner.less +44 -44
- package/lib/components/banner/banner.ts +149 -149
- package/lib/components/block-link/block-link.less +82 -82
- package/lib/components/breadcrumbs/breadcrumbs.less +41 -41
- package/lib/components/button-group/button-group.less +82 -82
- package/lib/components/card/card.less +37 -37
- package/lib/components/check-control/check-control.less +17 -17
- package/lib/components/check-group/check-group.less +19 -19
- package/lib/components/checkbox_radio/checkbox_radio.less +159 -159
- package/lib/components/code-block/code-block.fixtures.ts +88 -88
- package/lib/components/code-block/code-block.less +116 -116
- package/lib/components/description/description.less +9 -9
- package/lib/components/empty-state/empty-state.less +16 -16
- package/lib/components/expandable/expandable.less +118 -118
- package/lib/components/input-fill/input-fill.less +35 -35
- package/lib/components/input-icon/input-icon.less +45 -45
- package/lib/components/input-message/input-message.less +49 -49
- package/lib/components/label/label.less +110 -110
- package/lib/components/link-preview/link-preview.less +148 -148
- package/lib/components/menu/menu.less +41 -41
- package/lib/components/modal/modal.less +118 -118
- package/lib/components/modal/modal.ts +383 -383
- package/lib/components/navigation/navigation.less +136 -136
- package/lib/components/navigation/navigation.ts +128 -128
- package/lib/components/page-title/page-title.less +51 -51
- package/lib/components/popover/popover.less +159 -159
- package/lib/components/popover/popover.ts +651 -651
- package/lib/components/post-summary/post-summary.less +457 -457
- package/lib/components/progress-bar/progress-bar.less +291 -291
- package/lib/components/prose/prose.less +452 -452
- package/lib/components/select/select.less +138 -138
- package/lib/components/spinner/spinner.less +103 -103
- package/lib/components/table/table.ts +296 -296
- package/lib/components/table-container/table-container.less +4 -4
- package/lib/components/tag/tag.less +186 -186
- package/lib/components/toast/toast.less +35 -35
- package/lib/components/toast/toast.ts +357 -357
- package/lib/components/toggle-switch/toggle-switch.less +104 -104
- package/lib/components/topbar/topbar.less +553 -553
- package/lib/components/uploader/uploader.less +205 -205
- package/lib/components/user-card/user-card.less +129 -129
- package/lib/controllers.ts +33 -33
- package/lib/exports/color-mixins.less +283 -283
- package/lib/exports/constants-helpers.less +108 -108
- package/lib/exports/constants-type.less +155 -155
- package/lib/exports/exports.less +15 -15
- package/lib/exports/mixins.less +334 -333
- package/lib/exports/spacing-mixins.less +67 -67
- package/lib/index.ts +32 -32
- package/lib/input-utils.less +41 -41
- package/lib/stacks-dynamic.less +24 -24
- package/lib/stacks-static.less +93 -93
- package/lib/stacks.less +13 -13
- package/lib/test/assertions.ts +36 -36
- package/lib/test/less-test-utils.ts +28 -28
- package/lib/test/open-wc-testing-patch.d.ts +26 -26
- package/lib/tsconfig.build.json +4 -4
- package/lib/tsconfig.json +17 -17
- package/package.json +26 -22
|
@@ -1,357 +1,357 @@
|
|
|
1
|
-
import * as Stacks from "../../stacks";
|
|
2
|
-
|
|
3
|
-
export class ToastController extends Stacks.StacksController {
|
|
4
|
-
static targets = ["toast", "initialFocus"];
|
|
5
|
-
|
|
6
|
-
declare readonly toastTarget: HTMLElement;
|
|
7
|
-
declare readonly initialFocusTargets: HTMLElement[];
|
|
8
|
-
|
|
9
|
-
private _boundClickFn!: (event: MouseEvent) => void;
|
|
10
|
-
private _boundKeypressFn!: (event: KeyboardEvent) => void;
|
|
11
|
-
|
|
12
|
-
private activeTimeout!: number;
|
|
13
|
-
private returnElement!: HTMLElement;
|
|
14
|
-
|
|
15
|
-
connect() {
|
|
16
|
-
this.validate();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Disconnects all added event listeners on controller disconnect
|
|
21
|
-
*/
|
|
22
|
-
disconnect() {
|
|
23
|
-
this.unbindDocumentEvents();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Toggles the visibility of the toast
|
|
28
|
-
*/
|
|
29
|
-
toggle(dispatcher: Event | Element | null = null) {
|
|
30
|
-
this._toggle(undefined, dispatcher);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Shows the toast
|
|
35
|
-
*/
|
|
36
|
-
show(dispatcher: Event | Element | null = null) {
|
|
37
|
-
this._toggle(true, dispatcher);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Hides the toast
|
|
42
|
-
*/
|
|
43
|
-
hide(dispatcher: Event | Element | null = null) {
|
|
44
|
-
this._toggle(false, dispatcher);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Validates the toast settings and attempts to set necessary internal variables
|
|
49
|
-
*/
|
|
50
|
-
private validate() {
|
|
51
|
-
// check for returnElement support
|
|
52
|
-
const returnElementSelector = this.data.get("return-element");
|
|
53
|
-
if (returnElementSelector) {
|
|
54
|
-
this.returnElement = <HTMLElement>(
|
|
55
|
-
document.querySelector(returnElementSelector)
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
if (!this.returnElement) {
|
|
59
|
-
throw (
|
|
60
|
-
"Unable to find element by return-element selector: " +
|
|
61
|
-
returnElementSelector
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Toggles the visibility of the toast element
|
|
69
|
-
* @param show Optional parameter that force shows/hides the element or toggles it if left undefined
|
|
70
|
-
*/
|
|
71
|
-
private _toggle(
|
|
72
|
-
show?: boolean | undefined,
|
|
73
|
-
dispatcher: Event | Element | null = null
|
|
74
|
-
) {
|
|
75
|
-
let toShow = show;
|
|
76
|
-
const isVisible =
|
|
77
|
-
this.toastTarget.getAttribute("aria-hidden") === "false";
|
|
78
|
-
|
|
79
|
-
// if we're letting the class toggle, we need to figure out if the toast is visible manually
|
|
80
|
-
if (typeof toShow === "undefined") {
|
|
81
|
-
toShow = !isVisible;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// if the state matches the disired state, return without changing anything
|
|
85
|
-
if ((toShow && isVisible) || (!toShow && !isVisible)) {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const dispatchingElement = this.getDispatcher(dispatcher);
|
|
90
|
-
|
|
91
|
-
// show/hide events trigger before toggling the class
|
|
92
|
-
const triggeredEvent = this.triggerEvent(
|
|
93
|
-
toShow ? "show" : "hide",
|
|
94
|
-
{
|
|
95
|
-
returnElement: this.returnElement,
|
|
96
|
-
dispatcher: this.getDispatcher(dispatchingElement),
|
|
97
|
-
},
|
|
98
|
-
this.toastTarget
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
// if this pre-show/hide event was prevented, don't attempt to continue changing the toast state
|
|
102
|
-
if (triggeredEvent.defaultPrevented) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
this.returnElement = triggeredEvent.detail.returnElement;
|
|
107
|
-
this.toastTarget.setAttribute("aria-hidden", toShow ? "false" : "true");
|
|
108
|
-
|
|
109
|
-
if (toShow) {
|
|
110
|
-
this.bindDocumentEvents();
|
|
111
|
-
this.hideAfterTimeout();
|
|
112
|
-
|
|
113
|
-
if (this.data.get("prevent-focus-capture") !== "true") {
|
|
114
|
-
this.focusInsideToast();
|
|
115
|
-
}
|
|
116
|
-
} else {
|
|
117
|
-
this.unbindDocumentEvents();
|
|
118
|
-
this.focusReturnElement();
|
|
119
|
-
this.removeToastOnHide();
|
|
120
|
-
this.clearActiveTimeout();
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// check for transitionend support
|
|
124
|
-
const supportsTransitionEnd =
|
|
125
|
-
this.toastTarget.ontransitionend !== undefined;
|
|
126
|
-
|
|
127
|
-
// shown/hidden events trigger after toggling the class
|
|
128
|
-
if (supportsTransitionEnd) {
|
|
129
|
-
// wait until after the toast finishes transitioning to fire the event
|
|
130
|
-
this.toastTarget.addEventListener(
|
|
131
|
-
"transitionend",
|
|
132
|
-
() => {
|
|
133
|
-
//TODO this is firing waaay to soon?
|
|
134
|
-
this.triggerEvent(
|
|
135
|
-
toShow ? "shown" : "hidden",
|
|
136
|
-
{
|
|
137
|
-
dispatcher: dispatchingElement,
|
|
138
|
-
},
|
|
139
|
-
this.toastTarget
|
|
140
|
-
);
|
|
141
|
-
},
|
|
142
|
-
{ once: true }
|
|
143
|
-
);
|
|
144
|
-
} else {
|
|
145
|
-
this.triggerEvent(
|
|
146
|
-
toShow ? "shown" : "hidden",
|
|
147
|
-
{
|
|
148
|
-
dispatcher: dispatchingElement,
|
|
149
|
-
},
|
|
150
|
-
this.toastTarget
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Listens for the s-toast:hidden event and focuses the returnElement when it is fired
|
|
157
|
-
*/
|
|
158
|
-
private focusReturnElement() {
|
|
159
|
-
if (!this.returnElement) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
this.toastTarget.addEventListener(
|
|
164
|
-
"s-toast:hidden",
|
|
165
|
-
() => {
|
|
166
|
-
// double check the element still exists when the event is called
|
|
167
|
-
if (
|
|
168
|
-
this.returnElement &&
|
|
169
|
-
document.body.contains(this.returnElement)
|
|
170
|
-
) {
|
|
171
|
-
this.returnElement.focus();
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
{ once: true }
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Remove the element on hide if the `remove-when-hidden` flag is set
|
|
180
|
-
*/
|
|
181
|
-
private removeToastOnHide() {
|
|
182
|
-
if (this.data.get("remove-when-hidden") !== "true") {
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
this.toastTarget.addEventListener(
|
|
187
|
-
"s-toast:hidden",
|
|
188
|
-
() => {
|
|
189
|
-
this.element.remove();
|
|
190
|
-
},
|
|
191
|
-
{ once: true }
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Hide the element after a delay
|
|
197
|
-
*/
|
|
198
|
-
private hideAfterTimeout() {
|
|
199
|
-
if (
|
|
200
|
-
this.data.get("prevent-auto-hide") === "true" ||
|
|
201
|
-
this.data.get("hide-after-timeout") === "0"
|
|
202
|
-
) {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const timeout =
|
|
207
|
-
parseInt(this.data.get("hide-after-timeout") as string, 10) || 3000;
|
|
208
|
-
|
|
209
|
-
this.activeTimeout = window.setTimeout(() => this.hide(), timeout);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Cancels the activeTimeout
|
|
214
|
-
*/
|
|
215
|
-
clearActiveTimeout() {
|
|
216
|
-
clearTimeout(this.activeTimeout);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Gets all elements within the toast that could receive keyboard focus.
|
|
221
|
-
*/
|
|
222
|
-
private getAllTabbables() {
|
|
223
|
-
return Array.from(
|
|
224
|
-
this.toastTarget.querySelectorAll<HTMLElement>(
|
|
225
|
-
"[href], input, select, textarea, button, [tabindex]"
|
|
226
|
-
)
|
|
227
|
-
).filter((el: Element) =>
|
|
228
|
-
el.matches(":not([disabled]):not([tabindex='-1'])")
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Returns the first visible element in an array or `undefined` if no elements are visible.
|
|
234
|
-
*/
|
|
235
|
-
private firstVisible(elements?: HTMLElement[]) {
|
|
236
|
-
// https://stackoverflow.com/a/21696585
|
|
237
|
-
return elements?.find((el) => el.offsetParent !== null);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Attempts to shift keyboard focus into the toast.
|
|
242
|
-
* If elements with `data-s-toast-target="initialFocus"` are present and visible, one of those will be selected.
|
|
243
|
-
* Otherwise, the first visible focusable element will receive focus.
|
|
244
|
-
*/
|
|
245
|
-
private focusInsideToast() {
|
|
246
|
-
this.toastTarget.addEventListener(
|
|
247
|
-
"s-toast:shown",
|
|
248
|
-
() => {
|
|
249
|
-
const initialFocus =
|
|
250
|
-
this.firstVisible(this.initialFocusTargets) ??
|
|
251
|
-
this.firstVisible(this.getAllTabbables());
|
|
252
|
-
initialFocus?.focus();
|
|
253
|
-
},
|
|
254
|
-
{ once: true }
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Binds global events to the document for hiding toasts on user interaction
|
|
259
|
-
*/
|
|
260
|
-
private bindDocumentEvents() {
|
|
261
|
-
// in order for removeEventListener to remove the right event, this bound function needs a constant reference
|
|
262
|
-
this._boundClickFn =
|
|
263
|
-
this._boundClickFn || this.hideOnOutsideClick.bind(this);
|
|
264
|
-
this._boundKeypressFn =
|
|
265
|
-
this._boundKeypressFn || this.hideOnEscapePress.bind(this);
|
|
266
|
-
|
|
267
|
-
document.addEventListener("mousedown", this._boundClickFn);
|
|
268
|
-
document.addEventListener("keyup", this._boundKeypressFn);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Unbinds global events to the document for hiding toasts on user interaction
|
|
273
|
-
*/
|
|
274
|
-
private unbindDocumentEvents() {
|
|
275
|
-
document.removeEventListener("mousedown", this._boundClickFn);
|
|
276
|
-
document.removeEventListener("keyup", this._boundKeypressFn);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Forces the toast to hide if a user clicks outside of it or its reference element
|
|
281
|
-
*/
|
|
282
|
-
private hideOnOutsideClick(e: Event) {
|
|
283
|
-
const target = <Node>e.target;
|
|
284
|
-
// check if the document was clicked inside either the toggle element or the toast itself
|
|
285
|
-
// note: .contains also returns true if the node itself matches the target element
|
|
286
|
-
if (
|
|
287
|
-
!this.toastTarget?.contains(target) &&
|
|
288
|
-
document.body.contains(target) &&
|
|
289
|
-
this.data.get("hide-on-outside-click") !== "false"
|
|
290
|
-
) {
|
|
291
|
-
this._toggle(false, e);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Forces the toast to hide if the user presses escape while it, one of its childen, or the reference element are focused
|
|
297
|
-
*/
|
|
298
|
-
private hideOnEscapePress(e: KeyboardEvent) {
|
|
299
|
-
// if the ESC key (27) wasn't pressed or if no toasts are showing, return
|
|
300
|
-
if (
|
|
301
|
-
e.which !== 27 ||
|
|
302
|
-
this.toastTarget.getAttribute("aria-hidden") === "true"
|
|
303
|
-
) {
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
this._toggle(false, e);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Determines the correct dispatching element from a potential input
|
|
312
|
-
* @param dispatcher The event or element to get the dispatcher from
|
|
313
|
-
*/
|
|
314
|
-
private getDispatcher(dispatcher: Event | Element | null = null): Element {
|
|
315
|
-
if (dispatcher instanceof Event) {
|
|
316
|
-
return <Element>dispatcher.target;
|
|
317
|
-
} else if (dispatcher instanceof Element) {
|
|
318
|
-
return dispatcher;
|
|
319
|
-
} else {
|
|
320
|
-
return this.element;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Helper to manually show an s-toast element via external JS
|
|
327
|
-
* @param element the element the `data-controller="s-toast"` attribute is on
|
|
328
|
-
*/
|
|
329
|
-
export function showToast(element: HTMLElement) {
|
|
330
|
-
toggleToast(element, true);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Helper to manually hide an s-toast element via external JS
|
|
335
|
-
* @param element the element the `data-controller="s-toast"` attribute is on
|
|
336
|
-
*/
|
|
337
|
-
export function hideToast(element: HTMLElement) {
|
|
338
|
-
toggleToast(element, false);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Helper to manually show an s-toast element via external JS
|
|
343
|
-
* @param element the element the `data-controller="s-toast"` attribute is on
|
|
344
|
-
* @param show whether to force show/hide the toast; toggles the toast if left undefined
|
|
345
|
-
*/
|
|
346
|
-
function toggleToast(element: HTMLElement, show?: boolean | undefined) {
|
|
347
|
-
const controller = Stacks.application.getControllerForElementAndIdentifier(
|
|
348
|
-
element,
|
|
349
|
-
"s-toast"
|
|
350
|
-
) as ToastController;
|
|
351
|
-
|
|
352
|
-
if (!controller) {
|
|
353
|
-
throw "Unable to get s-toast controller from element";
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
show ? controller.show() : controller.hide();
|
|
357
|
-
}
|
|
1
|
+
import * as Stacks from "../../stacks";
|
|
2
|
+
|
|
3
|
+
export class ToastController extends Stacks.StacksController {
|
|
4
|
+
static targets = ["toast", "initialFocus"];
|
|
5
|
+
|
|
6
|
+
declare readonly toastTarget: HTMLElement;
|
|
7
|
+
declare readonly initialFocusTargets: HTMLElement[];
|
|
8
|
+
|
|
9
|
+
private _boundClickFn!: (event: MouseEvent) => void;
|
|
10
|
+
private _boundKeypressFn!: (event: KeyboardEvent) => void;
|
|
11
|
+
|
|
12
|
+
private activeTimeout!: number;
|
|
13
|
+
private returnElement!: HTMLElement;
|
|
14
|
+
|
|
15
|
+
connect() {
|
|
16
|
+
this.validate();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Disconnects all added event listeners on controller disconnect
|
|
21
|
+
*/
|
|
22
|
+
disconnect() {
|
|
23
|
+
this.unbindDocumentEvents();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Toggles the visibility of the toast
|
|
28
|
+
*/
|
|
29
|
+
toggle(dispatcher: Event | Element | null = null) {
|
|
30
|
+
this._toggle(undefined, dispatcher);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Shows the toast
|
|
35
|
+
*/
|
|
36
|
+
show(dispatcher: Event | Element | null = null) {
|
|
37
|
+
this._toggle(true, dispatcher);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hides the toast
|
|
42
|
+
*/
|
|
43
|
+
hide(dispatcher: Event | Element | null = null) {
|
|
44
|
+
this._toggle(false, dispatcher);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validates the toast settings and attempts to set necessary internal variables
|
|
49
|
+
*/
|
|
50
|
+
private validate() {
|
|
51
|
+
// check for returnElement support
|
|
52
|
+
const returnElementSelector = this.data.get("return-element");
|
|
53
|
+
if (returnElementSelector) {
|
|
54
|
+
this.returnElement = <HTMLElement>(
|
|
55
|
+
document.querySelector(returnElementSelector)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (!this.returnElement) {
|
|
59
|
+
throw (
|
|
60
|
+
"Unable to find element by return-element selector: " +
|
|
61
|
+
returnElementSelector
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Toggles the visibility of the toast element
|
|
69
|
+
* @param show Optional parameter that force shows/hides the element or toggles it if left undefined
|
|
70
|
+
*/
|
|
71
|
+
private _toggle(
|
|
72
|
+
show?: boolean | undefined,
|
|
73
|
+
dispatcher: Event | Element | null = null
|
|
74
|
+
) {
|
|
75
|
+
let toShow = show;
|
|
76
|
+
const isVisible =
|
|
77
|
+
this.toastTarget.getAttribute("aria-hidden") === "false";
|
|
78
|
+
|
|
79
|
+
// if we're letting the class toggle, we need to figure out if the toast is visible manually
|
|
80
|
+
if (typeof toShow === "undefined") {
|
|
81
|
+
toShow = !isVisible;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// if the state matches the disired state, return without changing anything
|
|
85
|
+
if ((toShow && isVisible) || (!toShow && !isVisible)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const dispatchingElement = this.getDispatcher(dispatcher);
|
|
90
|
+
|
|
91
|
+
// show/hide events trigger before toggling the class
|
|
92
|
+
const triggeredEvent = this.triggerEvent(
|
|
93
|
+
toShow ? "show" : "hide",
|
|
94
|
+
{
|
|
95
|
+
returnElement: this.returnElement,
|
|
96
|
+
dispatcher: this.getDispatcher(dispatchingElement),
|
|
97
|
+
},
|
|
98
|
+
this.toastTarget
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// if this pre-show/hide event was prevented, don't attempt to continue changing the toast state
|
|
102
|
+
if (triggeredEvent.defaultPrevented) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.returnElement = triggeredEvent.detail.returnElement;
|
|
107
|
+
this.toastTarget.setAttribute("aria-hidden", toShow ? "false" : "true");
|
|
108
|
+
|
|
109
|
+
if (toShow) {
|
|
110
|
+
this.bindDocumentEvents();
|
|
111
|
+
this.hideAfterTimeout();
|
|
112
|
+
|
|
113
|
+
if (this.data.get("prevent-focus-capture") !== "true") {
|
|
114
|
+
this.focusInsideToast();
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
this.unbindDocumentEvents();
|
|
118
|
+
this.focusReturnElement();
|
|
119
|
+
this.removeToastOnHide();
|
|
120
|
+
this.clearActiveTimeout();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// check for transitionend support
|
|
124
|
+
const supportsTransitionEnd =
|
|
125
|
+
this.toastTarget.ontransitionend !== undefined;
|
|
126
|
+
|
|
127
|
+
// shown/hidden events trigger after toggling the class
|
|
128
|
+
if (supportsTransitionEnd) {
|
|
129
|
+
// wait until after the toast finishes transitioning to fire the event
|
|
130
|
+
this.toastTarget.addEventListener(
|
|
131
|
+
"transitionend",
|
|
132
|
+
() => {
|
|
133
|
+
//TODO this is firing waaay to soon?
|
|
134
|
+
this.triggerEvent(
|
|
135
|
+
toShow ? "shown" : "hidden",
|
|
136
|
+
{
|
|
137
|
+
dispatcher: dispatchingElement,
|
|
138
|
+
},
|
|
139
|
+
this.toastTarget
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
{ once: true }
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
this.triggerEvent(
|
|
146
|
+
toShow ? "shown" : "hidden",
|
|
147
|
+
{
|
|
148
|
+
dispatcher: dispatchingElement,
|
|
149
|
+
},
|
|
150
|
+
this.toastTarget
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Listens for the s-toast:hidden event and focuses the returnElement when it is fired
|
|
157
|
+
*/
|
|
158
|
+
private focusReturnElement() {
|
|
159
|
+
if (!this.returnElement) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.toastTarget.addEventListener(
|
|
164
|
+
"s-toast:hidden",
|
|
165
|
+
() => {
|
|
166
|
+
// double check the element still exists when the event is called
|
|
167
|
+
if (
|
|
168
|
+
this.returnElement &&
|
|
169
|
+
document.body.contains(this.returnElement)
|
|
170
|
+
) {
|
|
171
|
+
this.returnElement.focus();
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{ once: true }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remove the element on hide if the `remove-when-hidden` flag is set
|
|
180
|
+
*/
|
|
181
|
+
private removeToastOnHide() {
|
|
182
|
+
if (this.data.get("remove-when-hidden") !== "true") {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.toastTarget.addEventListener(
|
|
187
|
+
"s-toast:hidden",
|
|
188
|
+
() => {
|
|
189
|
+
this.element.remove();
|
|
190
|
+
},
|
|
191
|
+
{ once: true }
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Hide the element after a delay
|
|
197
|
+
*/
|
|
198
|
+
private hideAfterTimeout() {
|
|
199
|
+
if (
|
|
200
|
+
this.data.get("prevent-auto-hide") === "true" ||
|
|
201
|
+
this.data.get("hide-after-timeout") === "0"
|
|
202
|
+
) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const timeout =
|
|
207
|
+
parseInt(this.data.get("hide-after-timeout") as string, 10) || 3000;
|
|
208
|
+
|
|
209
|
+
this.activeTimeout = window.setTimeout(() => this.hide(), timeout);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Cancels the activeTimeout
|
|
214
|
+
*/
|
|
215
|
+
clearActiveTimeout() {
|
|
216
|
+
clearTimeout(this.activeTimeout);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Gets all elements within the toast that could receive keyboard focus.
|
|
221
|
+
*/
|
|
222
|
+
private getAllTabbables() {
|
|
223
|
+
return Array.from(
|
|
224
|
+
this.toastTarget.querySelectorAll<HTMLElement>(
|
|
225
|
+
"[href], input, select, textarea, button, [tabindex]"
|
|
226
|
+
)
|
|
227
|
+
).filter((el: Element) =>
|
|
228
|
+
el.matches(":not([disabled]):not([tabindex='-1'])")
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Returns the first visible element in an array or `undefined` if no elements are visible.
|
|
234
|
+
*/
|
|
235
|
+
private firstVisible(elements?: HTMLElement[]) {
|
|
236
|
+
// https://stackoverflow.com/a/21696585
|
|
237
|
+
return elements?.find((el) => el.offsetParent !== null);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Attempts to shift keyboard focus into the toast.
|
|
242
|
+
* If elements with `data-s-toast-target="initialFocus"` are present and visible, one of those will be selected.
|
|
243
|
+
* Otherwise, the first visible focusable element will receive focus.
|
|
244
|
+
*/
|
|
245
|
+
private focusInsideToast() {
|
|
246
|
+
this.toastTarget.addEventListener(
|
|
247
|
+
"s-toast:shown",
|
|
248
|
+
() => {
|
|
249
|
+
const initialFocus =
|
|
250
|
+
this.firstVisible(this.initialFocusTargets) ??
|
|
251
|
+
this.firstVisible(this.getAllTabbables());
|
|
252
|
+
initialFocus?.focus();
|
|
253
|
+
},
|
|
254
|
+
{ once: true }
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Binds global events to the document for hiding toasts on user interaction
|
|
259
|
+
*/
|
|
260
|
+
private bindDocumentEvents() {
|
|
261
|
+
// in order for removeEventListener to remove the right event, this bound function needs a constant reference
|
|
262
|
+
this._boundClickFn =
|
|
263
|
+
this._boundClickFn || this.hideOnOutsideClick.bind(this);
|
|
264
|
+
this._boundKeypressFn =
|
|
265
|
+
this._boundKeypressFn || this.hideOnEscapePress.bind(this);
|
|
266
|
+
|
|
267
|
+
document.addEventListener("mousedown", this._boundClickFn);
|
|
268
|
+
document.addEventListener("keyup", this._boundKeypressFn);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Unbinds global events to the document for hiding toasts on user interaction
|
|
273
|
+
*/
|
|
274
|
+
private unbindDocumentEvents() {
|
|
275
|
+
document.removeEventListener("mousedown", this._boundClickFn);
|
|
276
|
+
document.removeEventListener("keyup", this._boundKeypressFn);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Forces the toast to hide if a user clicks outside of it or its reference element
|
|
281
|
+
*/
|
|
282
|
+
private hideOnOutsideClick(e: Event) {
|
|
283
|
+
const target = <Node>e.target;
|
|
284
|
+
// check if the document was clicked inside either the toggle element or the toast itself
|
|
285
|
+
// note: .contains also returns true if the node itself matches the target element
|
|
286
|
+
if (
|
|
287
|
+
!this.toastTarget?.contains(target) &&
|
|
288
|
+
document.body.contains(target) &&
|
|
289
|
+
this.data.get("hide-on-outside-click") !== "false"
|
|
290
|
+
) {
|
|
291
|
+
this._toggle(false, e);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Forces the toast to hide if the user presses escape while it, one of its childen, or the reference element are focused
|
|
297
|
+
*/
|
|
298
|
+
private hideOnEscapePress(e: KeyboardEvent) {
|
|
299
|
+
// if the ESC key (27) wasn't pressed or if no toasts are showing, return
|
|
300
|
+
if (
|
|
301
|
+
e.which !== 27 ||
|
|
302
|
+
this.toastTarget.getAttribute("aria-hidden") === "true"
|
|
303
|
+
) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
this._toggle(false, e);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Determines the correct dispatching element from a potential input
|
|
312
|
+
* @param dispatcher The event or element to get the dispatcher from
|
|
313
|
+
*/
|
|
314
|
+
private getDispatcher(dispatcher: Event | Element | null = null): Element {
|
|
315
|
+
if (dispatcher instanceof Event) {
|
|
316
|
+
return <Element>dispatcher.target;
|
|
317
|
+
} else if (dispatcher instanceof Element) {
|
|
318
|
+
return dispatcher;
|
|
319
|
+
} else {
|
|
320
|
+
return this.element;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Helper to manually show an s-toast element via external JS
|
|
327
|
+
* @param element the element the `data-controller="s-toast"` attribute is on
|
|
328
|
+
*/
|
|
329
|
+
export function showToast(element: HTMLElement) {
|
|
330
|
+
toggleToast(element, true);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Helper to manually hide an s-toast element via external JS
|
|
335
|
+
* @param element the element the `data-controller="s-toast"` attribute is on
|
|
336
|
+
*/
|
|
337
|
+
export function hideToast(element: HTMLElement) {
|
|
338
|
+
toggleToast(element, false);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Helper to manually show an s-toast element via external JS
|
|
343
|
+
* @param element the element the `data-controller="s-toast"` attribute is on
|
|
344
|
+
* @param show whether to force show/hide the toast; toggles the toast if left undefined
|
|
345
|
+
*/
|
|
346
|
+
function toggleToast(element: HTMLElement, show?: boolean | undefined) {
|
|
347
|
+
const controller = Stacks.application.getControllerForElementAndIdentifier(
|
|
348
|
+
element,
|
|
349
|
+
"s-toast"
|
|
350
|
+
) as ToastController;
|
|
351
|
+
|
|
352
|
+
if (!controller) {
|
|
353
|
+
throw "Unable to get s-toast controller from element";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
show ? controller.show() : controller.hide();
|
|
357
|
+
}
|