@raylang/elements 0.0.0-stage → 0.3.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.
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +12 -2
- package/base.css +111 -0
- package/components/accordion/accordion.js +197 -0
- package/components/alert/alert.js +112 -0
- package/components/app-bar/app-bar.js +168 -0
- package/components/app-shell/app-shell.js +198 -0
- package/components/aspect-ratio/aspect-ratio.js +59 -0
- package/components/attachment/attachment.js +333 -0
- package/components/avatar/avatar.js +96 -0
- package/components/badge/badge.js +63 -0
- package/components/blockquote/blockquote.js +128 -0
- package/components/bottom-nav/bottom-nav.js +309 -0
- package/components/breadcrumbs/breadcrumbs.js +175 -0
- package/components/button/button.js +283 -0
- package/components/button-group/button-group.js +72 -0
- package/components/card/card.js +73 -0
- package/components/carousel/carousel.js +435 -0
- package/components/center/center.js +26 -0
- package/components/checkbox/checkbox.js +152 -0
- package/components/checkbox-group/checkbox-group.js +152 -0
- package/components/code/code.js +574 -0
- package/components/color-picker/color-picker.js +775 -0
- package/components/combobox/combobox.js +563 -0
- package/components/command-palette/command-palette.js +510 -0
- package/components/container/container.js +41 -0
- package/components/date-picker/date-picker.js +594 -0
- package/components/dialog/dialog.js +329 -0
- package/components/drawer/drawer.js +139 -0
- package/components/empty-state/empty-state.js +66 -0
- package/components/file-upload/file-upload.js +589 -0
- package/components/grid/grid.js +96 -0
- package/components/group/group.js +40 -0
- package/components/icon/icon.js +58 -0
- package/components/icon-button/icon-button.js +30 -0
- package/components/indicator/indicator.js +112 -0
- package/components/input/input.js +85 -0
- package/components/kbd/kbd.js +60 -0
- package/components/menu/menu.js +296 -0
- package/components/nav-link/nav-link.js +159 -0
- package/components/notification/notification.js +591 -0
- package/components/number-input/number-input.js +241 -0
- package/components/pagination/pagination.js +194 -0
- package/components/password-input/password-input.js +85 -0
- package/components/path-input/path-input.js +137 -0
- package/components/popover/popover.js +85 -0
- package/components/progress/progress.js +109 -0
- package/components/radio/radio.js +253 -0
- package/components/scroll-area/scroll-area.js +158 -0
- package/components/search/search.js +157 -0
- package/components/segmented-control/segmented-control.js +190 -0
- package/components/select/select.js +506 -0
- package/components/separator/separator.js +58 -0
- package/components/sidebar/sidebar.js +252 -0
- package/components/skeleton/skeleton.js +90 -0
- package/components/slider/slider.js +155 -0
- package/components/spinner/spinner.js +39 -0
- package/components/split/split.js +221 -0
- package/components/stack/stack.js +46 -0
- package/components/stat/stat.js +79 -0
- package/components/statusbar/statusbar.js +110 -0
- package/components/stepper/stepper.js +239 -0
- package/components/switch/switch.js +45 -0
- package/components/table/table.js +328 -0
- package/components/tabs/tabs.js +224 -0
- package/components/textarea/textarea.js +105 -0
- package/components/theme-toggle/theme-toggle.js +78 -0
- package/components/timeline/timeline.js +150 -0
- package/components/toggle/toggle.js +130 -0
- package/components/toggle-group/toggle-group.js +216 -0
- package/components/tooltip/tooltip.js +101 -0
- package/core/charts.js +107 -0
- package/core/color.js +110 -0
- package/core/core.js +556 -0
- package/core/dismiss.js +69 -0
- package/core/floating.js +195 -0
- package/core/focus.js +70 -0
- package/core/form.js +235 -0
- package/core/host.js +200 -0
- package/core/hotkey.js +40 -0
- package/core/icons.js +104 -0
- package/core/motion.js +73 -0
- package/core/position.js +91 -0
- package/core/raylang.js +167 -0
- package/core/scroll.js +58 -0
- package/core/styles.js +211 -0
- package/core.js +16 -0
- package/custom-elements.json +18545 -0
- package/index.js +74 -0
- package/package.json +312 -4
- package/ray-elements.d.ts +2570 -0
- package/tokens.css +796 -0
- package/tokens.json +2083 -0
- package/vscode.html-custom-data.json +5499 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
// <ray-notification>: a message card (title, text, status icon, loader, close).
|
|
2
|
+
// <ray-toaster>: the stack notify() shows them in — a polite live region in
|
|
3
|
+
// the top layer (Popover API), so toasts stay visible above dialogs.
|
|
4
|
+
//
|
|
5
|
+
// API (Mantine's notifications):
|
|
6
|
+
// const id = notify({ title, message, color, icon, loading, autoClose, closable, action: { label, onClick }, id })
|
|
7
|
+
// updateNotification(id, { … }) hideNotification(id) clearNotifications()
|
|
8
|
+
|
|
9
|
+
import { RayElement, css, define } from "../../core/core.js";
|
|
10
|
+
import { motionAllowed, play } from "../../core/motion.js";
|
|
11
|
+
import { applyColor } from "../../core/styles.js";
|
|
12
|
+
|
|
13
|
+
const notificationStyles = css`
|
|
14
|
+
:host { display: block; }
|
|
15
|
+
.card {
|
|
16
|
+
position: relative;
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: flex-start;
|
|
19
|
+
gap: var(--ray-space-3);
|
|
20
|
+
padding: var(--ray-space-3) var(--ray-space-3) var(--ray-space-3) var(--ray-space-4);
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
color: var(--ray-popover-foreground);
|
|
23
|
+
background: var(--ray-popover);
|
|
24
|
+
border: 1px solid transparent;
|
|
25
|
+
border-radius: var(--ray-radius-lg);
|
|
26
|
+
box-shadow: var(--ray-shadow-lg);
|
|
27
|
+
}
|
|
28
|
+
:host([with-border]) .card { border-color: var(--ray-border); }
|
|
29
|
+
/* Behind the front toast of a collapsed stack: only its edge shows. */
|
|
30
|
+
.card > * { transition: opacity var(--ray-duration-base) var(--ray-easing-standard); }
|
|
31
|
+
:host([data-behind]) .card > * { opacity: 0; }
|
|
32
|
+
.card[data-icon], .card[data-loading], .card[data-glyph] { padding-left: var(--ray-space-3); }
|
|
33
|
+
.icon {
|
|
34
|
+
display: none;
|
|
35
|
+
flex: none;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
width: 1.75rem;
|
|
39
|
+
height: 1.75rem;
|
|
40
|
+
color: var(--_contrast);
|
|
41
|
+
background: var(--_filled);
|
|
42
|
+
border-radius: 50%;
|
|
43
|
+
}
|
|
44
|
+
.card[data-icon] .icon { display: inline-flex; }
|
|
45
|
+
/* The loader: an SVG ring drawn like the status icons' circle (r 9 of the
|
|
46
|
+
same 24 grid, same stroke), so it is not larger or heavier than a check
|
|
47
|
+
or a cross. A CSS border could not match: browsers round its width. */
|
|
48
|
+
.loader {
|
|
49
|
+
display: none;
|
|
50
|
+
flex: none;
|
|
51
|
+
color: var(--_light-color);
|
|
52
|
+
--ray-icon-size: var(--ray-notification-icon-size, 1.5rem);
|
|
53
|
+
--ray-icon-stroke: var(--ray-notification-icon-stroke, 1.5);
|
|
54
|
+
}
|
|
55
|
+
.loader svg {
|
|
56
|
+
width: var(--ray-icon-size);
|
|
57
|
+
height: var(--ray-icon-size);
|
|
58
|
+
fill: none;
|
|
59
|
+
stroke: currentColor;
|
|
60
|
+
stroke-width: var(--ray-icon-stroke);
|
|
61
|
+
stroke-linecap: round;
|
|
62
|
+
animation: ray-spin 0.75s linear infinite;
|
|
63
|
+
}
|
|
64
|
+
@keyframes ray-spin { to { transform: rotate(360deg); } }
|
|
65
|
+
@media (prefers-reduced-motion: reduce) {
|
|
66
|
+
.loader svg { animation-duration: 1.5s; }
|
|
67
|
+
}
|
|
68
|
+
/* A colored notification's status icon, where the loader goes. The
|
|
69
|
+
light-color shade: legible on the card in both schemes (yellow too).
|
|
70
|
+
Larger and thinner than inline icons; tuned with --ray-notification-icon-size
|
|
71
|
+
and --ray-notification-icon-stroke. */
|
|
72
|
+
.glyph {
|
|
73
|
+
display: none;
|
|
74
|
+
flex: none;
|
|
75
|
+
color: var(--_light-color);
|
|
76
|
+
--ray-icon-size: var(--ray-notification-icon-size, 1.5rem);
|
|
77
|
+
--ray-icon-stroke: var(--ray-notification-icon-stroke, 1.5);
|
|
78
|
+
}
|
|
79
|
+
.card[data-glyph] .glyph { display: inline-flex; }
|
|
80
|
+
.card[data-loading] .glyph, .card[data-icon] .glyph { display: none !important; }
|
|
81
|
+
.card[data-loading] .loader { display: inline-flex; }
|
|
82
|
+
.card[data-loading] .icon { display: none; }
|
|
83
|
+
::slotted(svg) { width: 1rem; height: 1rem; }
|
|
84
|
+
.body { flex: 1; min-width: 0; }
|
|
85
|
+
.title { margin: 0 0 0.125rem; font-size: var(--ray-font-size-sm); font-weight: var(--ray-font-weight-semibold); }
|
|
86
|
+
.title:empty { display: none; }
|
|
87
|
+
.message { font-size: var(--ray-font-size-sm); color: var(--ray-muted-foreground); overflow-wrap: anywhere; }
|
|
88
|
+
.title:empty + .message { color: var(--ray-popover-foreground); }
|
|
89
|
+
.close {
|
|
90
|
+
flex: none;
|
|
91
|
+
display: inline-flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
width: 1.75rem;
|
|
95
|
+
height: 1.75rem;
|
|
96
|
+
padding: 0;
|
|
97
|
+
color: var(--ray-muted-foreground);
|
|
98
|
+
background: transparent;
|
|
99
|
+
border: 0;
|
|
100
|
+
border-radius: var(--ray-radius-sm);
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
}
|
|
103
|
+
.close:hover { background: var(--ray-accent); color: var(--ray-foreground); }
|
|
104
|
+
.action {
|
|
105
|
+
flex: none;
|
|
106
|
+
align-self: center;
|
|
107
|
+
height: 1.75rem;
|
|
108
|
+
padding: 0 var(--ray-space-3);
|
|
109
|
+
font: inherit;
|
|
110
|
+
font-size: var(--ray-font-size-xs);
|
|
111
|
+
font-weight: var(--ray-font-weight-medium);
|
|
112
|
+
color: var(--ray-foreground);
|
|
113
|
+
background: var(--ray-default);
|
|
114
|
+
border: 1px solid var(--ray-border);
|
|
115
|
+
border-radius: var(--ray-radius-default);
|
|
116
|
+
cursor: pointer;
|
|
117
|
+
}
|
|
118
|
+
.action:hover { background: var(--ray-default-hover); }
|
|
119
|
+
.card:has(.action:not([hidden])) .close { align-self: center; }
|
|
120
|
+
.close svg { width: 1rem; height: 1rem; }
|
|
121
|
+
[hidden] { display: none !important; }
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
/** The status icon of a color: success, error, warning, else info. */
|
|
125
|
+
export function statusIcon(color) {
|
|
126
|
+
if (color === "green" || color === "teal") return "circle-check";
|
|
127
|
+
if (color === "red" || color === "pink") return "circle-x";
|
|
128
|
+
if (color === "yellow" || color === "orange") return "alert-triangle";
|
|
129
|
+
return "info";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class RayNotification extends RayElement {
|
|
133
|
+
static properties = {
|
|
134
|
+
label: { type: String },
|
|
135
|
+
color: { type: String, reflect: true },
|
|
136
|
+
loading: { type: Boolean, reflect: true },
|
|
137
|
+
hideClose: { type: Boolean, reflect: true },
|
|
138
|
+
withBorder: { type: Boolean, reflect: true },
|
|
139
|
+
actionLabel: { type: String },
|
|
140
|
+
icon: { type: String },
|
|
141
|
+
};
|
|
142
|
+
static styles = [notificationStyles];
|
|
143
|
+
static template = `
|
|
144
|
+
<div class="card" part="card">
|
|
145
|
+
<span class="icon" part="icon"><slot name="icon"></slot></span>
|
|
146
|
+
<span class="loader" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-dasharray="42.4 14.1"/></svg></span>
|
|
147
|
+
<span class="glyph" part="status-icon" aria-hidden="true"></span>
|
|
148
|
+
<div class="body">
|
|
149
|
+
<p class="title" part="title"></p>
|
|
150
|
+
<div class="message" part="message"><slot></slot></div>
|
|
151
|
+
</div>
|
|
152
|
+
<button class="action focus-ring" part="action" type="button" hidden></button>
|
|
153
|
+
<button class="close focus-ring" part="close" type="button" aria-label="Close notification"><span data-ray-icon="close" aria-hidden="true"></span></button>
|
|
154
|
+
</div>`;
|
|
155
|
+
|
|
156
|
+
constructor() {
|
|
157
|
+
super();
|
|
158
|
+
this.$(".close").addEventListener("click", () => this.emit("ray-close"));
|
|
159
|
+
this.$(".action").addEventListener("click", () => this.emit("ray-action"));
|
|
160
|
+
const icon = this.$('slot[name="icon"]');
|
|
161
|
+
icon.addEventListener("slotchange", () => this.$(".card").toggleAttribute("data-icon", icon.assignedNodes().length > 0));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
update() {
|
|
165
|
+
const card = this.$(".card");
|
|
166
|
+
applyColor(card, this.color);
|
|
167
|
+
card.toggleAttribute("data-loading", Boolean(this.loading));
|
|
168
|
+
// Status icon: the `icon` given, else one that fits the color.
|
|
169
|
+
const glyph = this.icon === "none" ? "" : (this.icon || (this.color ? statusIcon(this.color) : ""));
|
|
170
|
+
card.toggleAttribute("data-glyph", Boolean(glyph));
|
|
171
|
+
const g = this.$(".glyph");
|
|
172
|
+
if (glyph && g.getAttribute("data-ray-icon") !== glyph) {
|
|
173
|
+
g.setAttribute("data-ray-icon", glyph);
|
|
174
|
+
this.paintIcons(card);
|
|
175
|
+
}
|
|
176
|
+
this.$(".title").textContent = this.label ?? "";
|
|
177
|
+
this.$(".close").hidden = Boolean(this.hideClose);
|
|
178
|
+
const action = this.$(".action");
|
|
179
|
+
action.hidden = !this.actionLabel;
|
|
180
|
+
action.textContent = this.actionLabel ?? "";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const toasterStyles = css`
|
|
185
|
+
:host {
|
|
186
|
+
position: fixed;
|
|
187
|
+
inset: auto;
|
|
188
|
+
z-index: var(--ray-z-toast);
|
|
189
|
+
box-sizing: content-box;
|
|
190
|
+
display: block;
|
|
191
|
+
width: min(24rem, calc(100vw - 2rem));
|
|
192
|
+
margin: 0;
|
|
193
|
+
padding: var(--ray-space-4);
|
|
194
|
+
overflow: visible;
|
|
195
|
+
color: inherit;
|
|
196
|
+
background: transparent;
|
|
197
|
+
border: 0;
|
|
198
|
+
pointer-events: none;
|
|
199
|
+
transition: height var(--ray-duration-base) var(--ray-easing-standard);
|
|
200
|
+
}
|
|
201
|
+
:host(:not(:popover-open)) { display: none; }
|
|
202
|
+
:host([position^="top"]) { top: 0; }
|
|
203
|
+
:host([position^="bottom"]), :host(:not([position])) { bottom: 0; }
|
|
204
|
+
:host([position$="right"]), :host(:not([position])) { right: 0; }
|
|
205
|
+
:host([position$="left"]) { left: 0; }
|
|
206
|
+
:host([position$="center"]) { left: 50%; transform: translateX(-50%); }
|
|
207
|
+
/* Toasts are placed by layout(): absolute, stacked from the edge. */
|
|
208
|
+
::slotted(*) {
|
|
209
|
+
position: absolute;
|
|
210
|
+
left: var(--ray-space-4);
|
|
211
|
+
right: var(--ray-space-4);
|
|
212
|
+
pointer-events: auto;
|
|
213
|
+
touch-action: pan-y;
|
|
214
|
+
transform-origin: 50% 100%;
|
|
215
|
+
transition:
|
|
216
|
+
transform var(--ray-duration-base) var(--ray-easing-standard),
|
|
217
|
+
opacity var(--ray-duration-base) var(--ray-easing-standard),
|
|
218
|
+
height var(--ray-duration-base) var(--ray-easing-standard);
|
|
219
|
+
}
|
|
220
|
+
:host([position^="bottom"]) ::slotted(*), :host(:not([position])) ::slotted(*) { bottom: var(--ray-space-4); }
|
|
221
|
+
:host([position^="top"]) ::slotted(*) { top: var(--ray-space-4); transform-origin: 50% 0; }
|
|
222
|
+
::slotted([data-swiping]) { transition: none; }
|
|
223
|
+
/* Phones: full width at the bottom (or top), wherever the desktop position is. */
|
|
224
|
+
@media (max-width: 40em) {
|
|
225
|
+
:host { width: auto; left: 0; right: 0; transform: none; padding: var(--ray-space-3); }
|
|
226
|
+
:host([position^="top"]) { top: 0; bottom: auto; }
|
|
227
|
+
:host(:not([position^="top"])) { bottom: 0; top: auto; }
|
|
228
|
+
::slotted(*) { left: var(--ray-space-3); right: var(--ray-space-3); }
|
|
229
|
+
:host(:not([position^="top"])) ::slotted(*) { bottom: var(--ray-space-3); }
|
|
230
|
+
:host([position^="top"]) ::slotted(*) { top: var(--ray-space-3); }
|
|
231
|
+
}
|
|
232
|
+
@media (prefers-reduced-motion: reduce) {
|
|
233
|
+
:host, ::slotted(*) { transition: none; }
|
|
234
|
+
}
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
// A modal <dialog> makes everything outside it inert, toasts included. While
|
|
238
|
+
// modal ray-elements overlays are open, the toaster lives in the topmost one (its
|
|
239
|
+
// "overlay" slot renders inside the <dialog>), and goes back to <body> after.
|
|
240
|
+
const openDialogs = [];
|
|
241
|
+
// Any modal ray-elements overlay with an "overlay" slot (dialog, drawer, palette).
|
|
242
|
+
const hostsToasts = (el) => Boolean(el.shadowRoot?.querySelector('slot[name="overlay"]') && el.dialog?.matches?.(":modal"));
|
|
243
|
+
document.addEventListener("ray-open", (e) => {
|
|
244
|
+
if (hostsToasts(e.target) && !openDialogs.includes(e.target)) {
|
|
245
|
+
openDialogs.push(e.target);
|
|
246
|
+
rehome();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
document.addEventListener("ray-close", (e) => {
|
|
250
|
+
const i = openDialogs.indexOf(e.target);
|
|
251
|
+
if (i >= 0) {
|
|
252
|
+
openDialogs.splice(i, 1);
|
|
253
|
+
rehome();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
function rehome() {
|
|
258
|
+
const t = document.querySelector("ray-toaster");
|
|
259
|
+
if (!t) return;
|
|
260
|
+
const host = openDialogs.filter((d) => d.isConnected).at(-1) ?? document.body;
|
|
261
|
+
if (t.parentElement === host) return;
|
|
262
|
+
if (host === document.body) t.removeAttribute("slot");
|
|
263
|
+
else t.slot = "overlay";
|
|
264
|
+
host.append(t);
|
|
265
|
+
t.raise();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Collapsed stack: how much of each toast behind the front one shows, and
|
|
269
|
+
// how much smaller it gets.
|
|
270
|
+
const PEEK = 14;
|
|
271
|
+
const SHRINK = 0.05;
|
|
272
|
+
|
|
273
|
+
export class RayToaster extends RayElement {
|
|
274
|
+
static properties = {
|
|
275
|
+
position: { type: String, reflect: true, default: "bottom-right" },
|
|
276
|
+
limit: { type: Number, default: 5 },
|
|
277
|
+
// Toasts visible in the collapsed stack (the rest wait, invisible).
|
|
278
|
+
visible: { type: Number, default: 3 },
|
|
279
|
+
// Always expanded: a list instead of a stack.
|
|
280
|
+
expand: { type: Boolean, reflect: true },
|
|
281
|
+
gap: { type: Number, default: 12 },
|
|
282
|
+
};
|
|
283
|
+
static styles = [toasterStyles];
|
|
284
|
+
static template = `<slot></slot>`;
|
|
285
|
+
|
|
286
|
+
#expanded = false;
|
|
287
|
+
#ro = null;
|
|
288
|
+
#swipe = null;
|
|
289
|
+
|
|
290
|
+
connectedCallback() {
|
|
291
|
+
super.connectedCallback();
|
|
292
|
+
this.setAttribute("popover", "manual");
|
|
293
|
+
this.setAttribute("role", "region");
|
|
294
|
+
this.setAttribute("aria-label", this.getAttribute("aria-label") ?? "Notifications");
|
|
295
|
+
this.setAttribute("aria-live", "polite");
|
|
296
|
+
if (this._wired) return;
|
|
297
|
+
this._wired = true;
|
|
298
|
+
// Mouse: expand while hovered. Keyboard: while focus is inside.
|
|
299
|
+
this.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") this.expanded = true; });
|
|
300
|
+
this.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse" && !this.contains(document.activeElement)) this.expanded = false; });
|
|
301
|
+
this.addEventListener("focusin", () => { this.expanded = true; });
|
|
302
|
+
this.addEventListener("focusout", (e) => { if (!this.contains(e.relatedTarget) && !this.matches(":hover")) this.expanded = false; });
|
|
303
|
+
// Touch (no hover): a tap on the collapsed stack expands it — the tap is
|
|
304
|
+
// consumed so it does not press the front toast's buttons —, a tap
|
|
305
|
+
// outside collapses it. A horizontal swipe dismisses a toast.
|
|
306
|
+
this.addEventListener("pointerdown", (e) => this._pointerDown(e));
|
|
307
|
+
this.addEventListener("click", (e) => {
|
|
308
|
+
if (this._consumeClick) {
|
|
309
|
+
this._consumeClick = false;
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
e.stopPropagation();
|
|
312
|
+
}
|
|
313
|
+
}, true);
|
|
314
|
+
this._outside = (e) => {
|
|
315
|
+
if (this.#expanded && e.pointerType !== "mouse" && !e.composedPath().includes(this)) this.expanded = false;
|
|
316
|
+
};
|
|
317
|
+
document.addEventListener("pointerdown", this._outside, true);
|
|
318
|
+
this.#ro = new ResizeObserver(() => this.layout());
|
|
319
|
+
this.shadowRoot.querySelector("slot").addEventListener("slotchange", () => {
|
|
320
|
+
// Observe each card (its natural height): the host's height is set by
|
|
321
|
+
// layout() for toasts behind, and observing it would loop.
|
|
322
|
+
for (const el of this.children) {
|
|
323
|
+
const card = el.shadowRoot?.querySelector(".card");
|
|
324
|
+
if (card) this.#ro.observe(card);
|
|
325
|
+
}
|
|
326
|
+
this.layout();
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
disconnectedCallback() {
|
|
331
|
+
super.disconnectedCallback();
|
|
332
|
+
document.removeEventListener("pointerdown", this._outside, true);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Whether the stack is spread out (hover, focus, tap, or the expand attribute). */
|
|
336
|
+
get expanded() {
|
|
337
|
+
return this.#expanded || Boolean(this.expand);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
set expanded(on) {
|
|
341
|
+
on = Boolean(on);
|
|
342
|
+
if (on === this.#expanded) return;
|
|
343
|
+
this.#expanded = on;
|
|
344
|
+
this.toggleAttribute("data-expanded", on);
|
|
345
|
+
pauseAll(this, on);
|
|
346
|
+
this.layout();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
update() {
|
|
350
|
+
this.layout();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The toasts, newest first. */
|
|
354
|
+
get toasts() {
|
|
355
|
+
return [...this.children].filter((el) => el.localName === "ray-notification" && !el._leaving).reverse();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Places every toast: a stack (collapsed) or a list (expanded). */
|
|
359
|
+
layout() {
|
|
360
|
+
const toasts = this.toasts;
|
|
361
|
+
const top = (this.position ?? "bottom-right").startsWith("top");
|
|
362
|
+
const dir = top ? 1 : -1;
|
|
363
|
+
const heights = toasts.map((el) => el.shadowRoot?.querySelector(".card")?.offsetHeight || el.offsetHeight || 0);
|
|
364
|
+
const front = heights[0] ?? 0;
|
|
365
|
+
const open = this.expanded;
|
|
366
|
+
const shown = Math.min(toasts.length, Math.max(1, this.visible ?? 3));
|
|
367
|
+
let offset = 0;
|
|
368
|
+
toasts.forEach((el, i) => {
|
|
369
|
+
el.style.zIndex = String(toasts.length - i);
|
|
370
|
+
el.toggleAttribute("data-behind", !open && i > 0);
|
|
371
|
+
el.toggleAttribute("data-front", i === 0);
|
|
372
|
+
const swiping = el === this.#swipe?.el;
|
|
373
|
+
const swipe = swiping ? ` translateX(${this.#swipe.dx}px)` : "";
|
|
374
|
+
if (open) {
|
|
375
|
+
el.style.transform = `translateY(${dir * offset}px)${swipe}`;
|
|
376
|
+
if (!swiping) el.style.opacity = "";
|
|
377
|
+
offset += heights[i] + (this.gap ?? 12);
|
|
378
|
+
} else {
|
|
379
|
+
const scale = 1 - i * SHRINK;
|
|
380
|
+
// Behind toasts: the front one's height, so the stack edges line up.
|
|
381
|
+
el.style.transform = `translateY(${dir * i * PEEK}px) scale(${scale})${swipe}`;
|
|
382
|
+
if (!swiping) el.style.opacity = i < shown ? "" : "0";
|
|
383
|
+
}
|
|
384
|
+
el.style.height = !open && i > 0 && front ? `${front}px` : "";
|
|
385
|
+
el.style.overflow = !open && i > 0 ? "hidden" : "";
|
|
386
|
+
el.inert = !open && i >= shown;
|
|
387
|
+
});
|
|
388
|
+
const total = open ? Math.max(0, offset - (this.gap ?? 12)) : front + (shown - 1) * PEEK;
|
|
389
|
+
this.style.height = `${Math.max(0, total)}px`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
_pointerDown(e) {
|
|
393
|
+
if (e.pointerType !== "mouse" && !this.expanded && this.toasts.length > 1) {
|
|
394
|
+
this.expanded = true;
|
|
395
|
+
this._consumeClick = true;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const el = e.target.closest?.("ray-notification");
|
|
399
|
+
if (!el || e.button > 0) return;
|
|
400
|
+
const start = { x: e.clientX, y: e.clientY };
|
|
401
|
+
let moving = false;
|
|
402
|
+
const move = (ev) => {
|
|
403
|
+
const dx = ev.clientX - start.x;
|
|
404
|
+
if (!moving) {
|
|
405
|
+
if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(ev.clientY - start.y)) return;
|
|
406
|
+
moving = true;
|
|
407
|
+
el.setAttribute("data-swiping", "");
|
|
408
|
+
try {
|
|
409
|
+
el.setPointerCapture(ev.pointerId);
|
|
410
|
+
} catch {
|
|
411
|
+
// Not an active pointer (synthetic events): the window listeners still track it.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this.#swipe = { el, dx };
|
|
415
|
+
el.style.opacity = String(Math.max(0.2, 1 - Math.abs(dx) / 240));
|
|
416
|
+
this.layout();
|
|
417
|
+
};
|
|
418
|
+
const up = () => {
|
|
419
|
+
window.removeEventListener("pointermove", move);
|
|
420
|
+
window.removeEventListener("pointerup", up);
|
|
421
|
+
window.removeEventListener("pointercancel", up);
|
|
422
|
+
el.removeAttribute("data-swiping");
|
|
423
|
+
const dx = this.#swipe?.el === el ? this.#swipe.dx : 0;
|
|
424
|
+
this.#swipe = null;
|
|
425
|
+
if (moving) this._consumeClick = true;
|
|
426
|
+
if (Math.abs(dx) > 64) {
|
|
427
|
+
el._swipeOut = Math.sign(dx);
|
|
428
|
+
hideNotification(el.dataset.notificationId);
|
|
429
|
+
} else {
|
|
430
|
+
el.style.opacity = "";
|
|
431
|
+
this.layout();
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
window.addEventListener("pointermove", move);
|
|
435
|
+
window.addEventListener("pointerup", up);
|
|
436
|
+
window.addEventListener("pointercancel", up);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Brings the stack above everything else in the top layer (e.g. a dialog opened later). */
|
|
440
|
+
raise() {
|
|
441
|
+
if (this.matches(":popover-open")) this.hidePopover();
|
|
442
|
+
if (this.children.length) this.showPopover();
|
|
443
|
+
this.layout();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
define("ray-notification", RayNotification);
|
|
448
|
+
define("ray-toaster", RayToaster);
|
|
449
|
+
|
|
450
|
+
// --- notify() ---------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
let counter = 0;
|
|
453
|
+
const timers = new Map();
|
|
454
|
+
|
|
455
|
+
function toaster() {
|
|
456
|
+
let t = document.querySelector("ray-toaster");
|
|
457
|
+
if (!t) {
|
|
458
|
+
t = document.createElement("ray-toaster");
|
|
459
|
+
document.body.append(t);
|
|
460
|
+
rehome();
|
|
461
|
+
}
|
|
462
|
+
return t;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function find(id) {
|
|
466
|
+
return document.querySelector(`ray-notification[data-notification-id="${CSS.escape(id)}"]`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function apply(el, o) {
|
|
470
|
+
if ("action" in o) {
|
|
471
|
+
el.actionLabel = o.action?.label ?? "";
|
|
472
|
+
el._onAction = o.action?.onClick;
|
|
473
|
+
}
|
|
474
|
+
if ("title" in o) el.label = o.title ?? "";
|
|
475
|
+
if ("message" in o) el.textContent = o.message ?? "";
|
|
476
|
+
if ("color" in o) el.color = o.color || undefined;
|
|
477
|
+
if ("icon" in o) el.icon = o.icon || undefined;
|
|
478
|
+
if ("loading" in o) el.loading = Boolean(o.loading);
|
|
479
|
+
if ("closable" in o) el.hideClose = o.closable === false;
|
|
480
|
+
if ("withBorder" in o) el.withBorder = Boolean(o.withBorder);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function schedule(id, ms) {
|
|
484
|
+
clearTimeout(timers.get(id)?.handle);
|
|
485
|
+
if (!ms) {
|
|
486
|
+
timers.delete(id);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const entry = { remaining: ms, start: Date.now(), handle: setTimeout(() => hideNotification(id), ms) };
|
|
490
|
+
timers.set(id, entry);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function pauseAll(t, pause) {
|
|
494
|
+
for (const el of t.querySelectorAll("ray-notification[data-notification-id]")) {
|
|
495
|
+
const id = el.dataset.notificationId;
|
|
496
|
+
const entry = timers.get(id);
|
|
497
|
+
if (!entry) continue;
|
|
498
|
+
if (pause) {
|
|
499
|
+
clearTimeout(entry.handle);
|
|
500
|
+
entry.remaining -= Date.now() - entry.start;
|
|
501
|
+
} else {
|
|
502
|
+
schedule(id, Math.max(entry.remaining, 1000));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Shows a notification. Returns its id.
|
|
509
|
+
* @param {{ id?: string, title?: string, message?: string, color?: string, loading?: boolean,
|
|
510
|
+
* autoClose?: number | false, closable?: boolean, withBorder?: boolean,
|
|
511
|
+
* action?: { label: string, onClick?: () => void }, onClose?: () => void }} options
|
|
512
|
+
* autoClose: ms (default 4000; false = stays). action: a button (e.g. "Undo") that runs onClick and closes it.
|
|
513
|
+
*/
|
|
514
|
+
export function notify(options = {}) {
|
|
515
|
+
const id = options.id ?? `n${++counter}`;
|
|
516
|
+
if (find(id)) {
|
|
517
|
+
updateNotification(id, options);
|
|
518
|
+
return id;
|
|
519
|
+
}
|
|
520
|
+
const t = toaster();
|
|
521
|
+
const el = document.createElement("ray-notification");
|
|
522
|
+
el.dataset.notificationId = id;
|
|
523
|
+
apply(el, { closable: true, withBorder: true, ...options });
|
|
524
|
+
el.addEventListener("ray-close", () => hideNotification(id));
|
|
525
|
+
el.addEventListener("ray-action", () => {
|
|
526
|
+
el._onAction?.();
|
|
527
|
+
hideNotification(id);
|
|
528
|
+
});
|
|
529
|
+
el._onClose = options.onClose;
|
|
530
|
+
t.append(el);
|
|
531
|
+
// Enter from the edge: start off-screen, then let layout() transition it in.
|
|
532
|
+
const top = (t.position ?? "bottom-right").startsWith("top");
|
|
533
|
+
el.style.transition = "none";
|
|
534
|
+
el.style.opacity = "0";
|
|
535
|
+
el.style.transform = `translateY(${top ? -100 : 100}%)`;
|
|
536
|
+
el.getBoundingClientRect();
|
|
537
|
+
el.style.transition = "";
|
|
538
|
+
el.style.opacity = "";
|
|
539
|
+
// Over the limit: drop the oldest.
|
|
540
|
+
const all = t.querySelectorAll("ray-notification[data-notification-id]");
|
|
541
|
+
for (let i = 0; i < all.length - (t.limit ?? 5); i++) hideNotification(all[i].dataset.notificationId);
|
|
542
|
+
t.raise();
|
|
543
|
+
schedule(id, options.loading ? 0 : (options.autoClose ?? 4000));
|
|
544
|
+
return id;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Changes a shown notification (e.g. loading -> done). Restarts its auto-close. */
|
|
548
|
+
export function updateNotification(id, options = {}) {
|
|
549
|
+
const el = find(id);
|
|
550
|
+
if (!el) return false;
|
|
551
|
+
apply(el, options);
|
|
552
|
+
if ("onClose" in options) el._onClose = options.onClose;
|
|
553
|
+
const loading = "loading" in options ? options.loading : el.loading;
|
|
554
|
+
schedule(id, loading ? 0 : (options.autoClose ?? 4000));
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function hideNotification(id) {
|
|
559
|
+
const el = find(id);
|
|
560
|
+
schedule(id, 0);
|
|
561
|
+
if (!el || el._leaving) return Boolean(el);
|
|
562
|
+
const t = el.parentElement;
|
|
563
|
+
const done = () => {
|
|
564
|
+
el.remove();
|
|
565
|
+
el._onClose?.();
|
|
566
|
+
if (t instanceof RayToaster) {
|
|
567
|
+
if (!t.children.length && t.matches(":popover-open")) t.hidePopover();
|
|
568
|
+
if (t.toasts.length < 2) t.expanded = false;
|
|
569
|
+
t.layout();
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
if (!motionAllowed(el)) {
|
|
573
|
+
done();
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
// Leave (sideways after a swipe, else towards the edge) while the others
|
|
577
|
+
// move into place.
|
|
578
|
+
el._leaving = true;
|
|
579
|
+
el.style.pointerEvents = "none";
|
|
580
|
+
if (t instanceof RayToaster) t.layout();
|
|
581
|
+
const from = el.style.transform || "none";
|
|
582
|
+
const top = t instanceof RayToaster && (t.position ?? "").startsWith("top");
|
|
583
|
+
const to = el._swipeOut ? `${from} translateX(${el._swipeOut * 110}%)` : `${from} translateY(${top ? -40 : 40}%)`;
|
|
584
|
+
const exit = play(el, [{ opacity: Number(el.style.opacity || 1), transform: from }, { opacity: 0, transform: to }], { speed: "fast" });
|
|
585
|
+
exit.finished.then(done);
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function clearNotifications() {
|
|
590
|
+
for (const el of document.querySelectorAll("ray-notification[data-notification-id]")) hideNotification(el.dataset.notificationId);
|
|
591
|
+
}
|