@astralui/core 0.1.9 → 0.1.11

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/dist/index.cjs CHANGED
@@ -173,48 +173,139 @@ function AstralThemeProvider({ colors, children }) {
173
173
  children
174
174
  ] });
175
175
  }
176
- function useDismiss(opened, onClose) {
176
+ var dialogSeq = 0;
177
+ var dialogStack = [];
178
+ var FOCUSABLE = [
179
+ "a[href]",
180
+ "area[href]",
181
+ "button:not([disabled])",
182
+ 'input:not([disabled]):not([type="hidden"])',
183
+ "select:not([disabled])",
184
+ "textarea:not([disabled])",
185
+ "iframe",
186
+ "object",
187
+ "embed",
188
+ "[contenteditable]",
189
+ '[tabindex]:not([tabindex="-1"])'
190
+ ].join(",");
191
+ function useDialog(opened, onClose) {
192
+ const panelRef = react.useRef(null);
193
+ const idRef = react.useRef("");
194
+ if (!idRef.current) idRef.current = "au-dialog-title-" + String(++dialogSeq);
195
+ const onCloseRef = react.useRef(onClose);
196
+ onCloseRef.current = onClose;
177
197
  react.useEffect(() => {
178
198
  if (!opened) return;
199
+ const panel = panelRef.current;
200
+ if (!panel) return;
201
+ const restoreTo = document.activeElement;
202
+ dialogStack.push(panel);
203
+ const isTop = () => dialogStack[dialogStack.length - 1] === panel;
204
+ const focusables = () => Array.from(panel.querySelectorAll(FOCUSABLE)).filter(
205
+ (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement
206
+ );
207
+ const raf = requestAnimationFrame(() => {
208
+ if (panel.contains(document.activeElement)) return;
209
+ const list = focusables();
210
+ const target = list.find((el) => !el.classList.contains("au-modal-x")) ?? list[0] ?? panel;
211
+ target.focus();
212
+ });
179
213
  const onKey = (e) => {
180
- if (e.key === "Escape") onClose();
214
+ if (!isTop()) return;
215
+ if (e.key === "Escape") {
216
+ onCloseRef.current();
217
+ return;
218
+ }
219
+ if (e.key !== "Tab") return;
220
+ const list = focusables();
221
+ if (!list.length) {
222
+ e.preventDefault();
223
+ panel.focus();
224
+ return;
225
+ }
226
+ const first = list[0];
227
+ const last = list[list.length - 1];
228
+ const active = document.activeElement;
229
+ if (!panel.contains(active)) {
230
+ e.preventDefault();
231
+ (e.shiftKey ? last : first).focus();
232
+ return;
233
+ }
234
+ if (e.shiftKey && active === first) {
235
+ e.preventDefault();
236
+ last.focus();
237
+ } else if (!e.shiftKey && active === last) {
238
+ e.preventDefault();
239
+ first.focus();
240
+ }
181
241
  };
182
- document.addEventListener("keydown", onKey);
183
- const prev = document.body.style.overflow;
242
+ document.addEventListener("keydown", onKey, true);
243
+ const prevOverflow = document.body.style.overflow;
184
244
  document.body.style.overflow = "hidden";
185
245
  return () => {
186
- document.removeEventListener("keydown", onKey);
187
- document.body.style.overflow = prev;
246
+ cancelAnimationFrame(raf);
247
+ document.removeEventListener("keydown", onKey, true);
248
+ const at = dialogStack.indexOf(panel);
249
+ if (at !== -1) dialogStack.splice(at, 1);
250
+ document.body.style.overflow = prevOverflow;
251
+ if (restoreTo && document.contains(restoreTo)) restoreTo.focus();
188
252
  };
189
- }, [opened, onClose]);
253
+ }, [opened]);
254
+ return { panelRef, titleId: idRef.current };
190
255
  }
191
256
  function AstralModal({ opened, onClose, title, brand, width = 460, zIndex, children }) {
192
- useDismiss(opened, onClose);
257
+ const { panelRef, titleId } = useDialog(opened, onClose);
193
258
  if (!opened) return null;
194
259
  const style = { maxWidth: width, ...brand ? { "--au-ic": brand } : {} };
195
260
  return reactDom.createPortal(
196
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-modal-overlay", onMouseDown: onClose, style: zIndex != null ? { zIndex } : void 0, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-modal-panel", style, onMouseDown: (e) => e.stopPropagation(), children: [
197
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-modal-head", children: [
198
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "au-modal-title", children: title }),
199
- /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "au-modal-x", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconX, {}) })
200
- ] }),
201
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-modal-body", children })
202
- ] }) }),
261
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-modal-overlay", onMouseDown: onClose, style: zIndex != null ? { zIndex } : void 0, children: /* @__PURE__ */ jsxRuntime.jsxs(
262
+ "div",
263
+ {
264
+ ref: panelRef,
265
+ className: "au-modal-panel",
266
+ style,
267
+ onMouseDown: (e) => e.stopPropagation(),
268
+ role: "dialog",
269
+ "aria-modal": "true",
270
+ "aria-labelledby": title ? titleId : void 0,
271
+ tabIndex: -1,
272
+ children: [
273
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-modal-head", children: [
274
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "au-modal-title", id: titleId, children: title }),
275
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "au-modal-x", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconX, {}) })
276
+ ] }),
277
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-modal-body", children })
278
+ ]
279
+ }
280
+ ) }),
203
281
  document.body
204
282
  );
205
283
  }
206
284
  function AstralDrawer({ opened, onClose, title, brand, width = 440, zIndex, children }) {
207
- useDismiss(opened, onClose);
285
+ const { panelRef, titleId } = useDialog(opened, onClose);
208
286
  if (!opened) return null;
209
287
  const style = { width, ...brand ? { "--au-ic": brand } : {} };
210
288
  return reactDom.createPortal(
211
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-drawer-overlay", onMouseDown: onClose, style: zIndex != null ? { zIndex } : void 0, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-drawer-panel", style, onMouseDown: (e) => e.stopPropagation(), children: [
212
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-drawer-head", children: [
213
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "au-drawer-title", children: title }),
214
- /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "au-modal-x", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconX, {}) })
215
- ] }),
216
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-drawer-body", children })
217
- ] }) }),
289
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-drawer-overlay", onMouseDown: onClose, style: zIndex != null ? { zIndex } : void 0, children: /* @__PURE__ */ jsxRuntime.jsxs(
290
+ "div",
291
+ {
292
+ ref: panelRef,
293
+ className: "au-drawer-panel",
294
+ style,
295
+ onMouseDown: (e) => e.stopPropagation(),
296
+ role: "dialog",
297
+ "aria-modal": "true",
298
+ "aria-labelledby": title ? titleId : void 0,
299
+ tabIndex: -1,
300
+ children: [
301
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-drawer-head", children: [
302
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "au-drawer-title", id: titleId, children: title }),
303
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "au-modal-x", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconX, {}) })
304
+ ] }),
305
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-drawer-body", children })
306
+ ]
307
+ }
308
+ ) }),
218
309
  document.body
219
310
  );
220
311
  }
@@ -454,6 +545,8 @@ function AstralMenu({ trigger, items, align = "end", width = 220 }) {
454
545
  )
455
546
  ] });
456
547
  }
548
+ var DEFAULT_LIMIT = 4;
549
+ var EXIT_MS = 220;
457
550
  var toasts = [];
458
551
  var listeners = /* @__PURE__ */ new Set();
459
552
  var timers = /* @__PURE__ */ new Map();
@@ -475,7 +568,7 @@ function scheduleAutoClose(t) {
475
568
  function show(opts) {
476
569
  const id = opts.id ?? `toast-${++counter}`;
477
570
  const existing = toasts.find((t) => t.id === id);
478
- const toast = { withCloseButton: true, ...existing, ...opts, id };
571
+ const toast = { withCloseButton: true, ...existing, ...opts, id, exiting: false };
479
572
  toasts = existing ? toasts.map((t) => t.id === id ? toast : t) : [...toasts, toast];
480
573
  emit();
481
574
  scheduleAutoClose(toast);
@@ -485,7 +578,7 @@ function update(opts) {
485
578
  if (!opts.id) return show(opts);
486
579
  const existing = toasts.find((t) => t.id === opts.id);
487
580
  if (!existing) return show(opts);
488
- const toast = { ...existing, ...opts, id: opts.id };
581
+ const toast = { ...existing, ...opts, id: opts.id, exiting: false };
489
582
  toasts = toasts.map((t) => t.id === opts.id ? toast : t);
490
583
  emit();
491
584
  scheduleAutoClose(toast);
@@ -493,10 +586,16 @@ function update(opts) {
493
586
  }
494
587
  function hide(id) {
495
588
  const t = toasts.find((x) => x.id === id);
589
+ if (!t || t.exiting) return;
496
590
  clearTimer(id);
497
- toasts = toasts.filter((x) => x.id !== id);
591
+ toasts = toasts.map((x) => x.id === id ? { ...x, exiting: true } : x);
498
592
  emit();
499
- t?.onClose?.();
593
+ timers.set(id, setTimeout(() => {
594
+ toasts = toasts.filter((x) => x.id !== id);
595
+ timers.delete(id);
596
+ emit();
597
+ t.onClose?.();
598
+ }, EXIT_MS));
500
599
  }
501
600
  function clean() {
502
601
  toasts.forEach((t) => clearTimer(t.id));
@@ -524,20 +623,29 @@ function Spinner() {
524
623
  function ToastCard({ t }) {
525
624
  const accent = t.color ? `var(--astral-color-${t.color}-6)` : "var(--astral-color-default-border)";
526
625
  const lead = t.loading ? /* @__PURE__ */ jsxRuntime.jsx(Spinner, {}) : t.icon ? t.icon : t.color && DEFAULT_ICON[t.color] || /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconInfoCircle, { size: 16 });
527
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-toast", style: { ["--au-toast-accent"]: accent }, role: "status", children: [
528
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "au-toast-lead", children: lead }),
529
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-toast-body", children: [
530
- t.title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-toast-title", children: t.title }),
531
- t.message != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-toast-msg", children: t.message })
532
- ] }),
533
- t.withCloseButton !== false && /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "au-toast-x", onClick: () => hide(t.id), "aria-label": "Close", children: /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconX, { size: 14 }) })
534
- ] });
626
+ return /* @__PURE__ */ jsxRuntime.jsxs(
627
+ "div",
628
+ {
629
+ className: t.exiting ? "au-toast au-toast-out" : "au-toast",
630
+ style: { ["--au-toast-accent"]: accent },
631
+ role: "status",
632
+ children: [
633
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "au-toast-lead", children: lead }),
634
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-toast-body", children: [
635
+ t.title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-toast-title", children: t.title }),
636
+ t.message != null && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-toast-msg", children: t.message })
637
+ ] }),
638
+ t.withCloseButton !== false && /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "au-toast-x", onClick: () => hide(t.id), "aria-label": "Close", children: /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconX, { size: 14 }) })
639
+ ]
640
+ }
641
+ );
535
642
  }
536
- function AstralToaster() {
643
+ function AstralToaster({ limit = DEFAULT_LIMIT } = {}) {
537
644
  const items = react.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
538
645
  if (typeof document === "undefined") return null;
646
+ const visible = limit > 0 ? items.filter((t, i) => t.exiting || i >= items.length - limit) : items;
539
647
  return reactDom.createPortal(
540
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-toast-host", role: "region", "aria-live": "polite", "aria-label": "Notifications", children: items.map((t) => /* @__PURE__ */ jsxRuntime.jsx(ToastCard, { t }, t.id)) }),
648
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-toast-host", role: "region", "aria-live": "polite", "aria-label": "Notifications", children: visible.map((t) => /* @__PURE__ */ jsxRuntime.jsx(ToastCard, { t }, t.id)) }),
541
649
  document.body
542
650
  );
543
651
  }