@apliteni/apliteni-ui 0.129.0 → 0.130.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.129.0",
3
+ "version": "0.130.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -585,6 +585,13 @@ or neutral. Notices disappear after five seconds, pausing while hovered or focus
585
585
  Danger notices are announced assertively; other tones are polite. An `action` with a `label` and
586
586
  `onClick` stays until selected or dismissed. New notices appear at the bottom.
587
587
 
588
+ Pass `compact: true` to show only the title, or `dismissible: false` to hide the
589
+ close button. Hiding the close button keeps the five-second timer for notices
590
+ without actions; notices with actions remain until their action is selected, so a
591
+ notice with an action and no close button has no other dismissal affordance.
592
+ Pass `timer: 12` to dismiss a notice after that many seconds. Pass `timer: false` or `timer: 0`
593
+ to keep a notice until it is dismissed.
594
+
588
595
  ```tsx
589
596
  function SaveButton() {
590
597
  const push = useToast();
@@ -778,6 +778,9 @@ declare const SearchField: react.ForwardRefExoticComponent<Omit<InputHTMLAttribu
778
778
 
779
779
  type ToastNotice = {
780
780
  tone?: 'success' | 'danger' | 'warn' | 'info' | 'neutral';
781
+ compact?: boolean;
782
+ dismissible?: boolean;
783
+ timer?: boolean | number;
781
784
  title: string;
782
785
  text?: string;
783
786
  action?: {
@@ -3432,11 +3432,13 @@ function Notice({ notice, remove }) {
3432
3432
  const root = useRef18(null);
3433
3433
  const [leaving2, setLeaving] = useState12(false);
3434
3434
  const dismissed = useRef18(false);
3435
- const remaining = useRef18(5e3);
3436
3435
  const [hovered, setHovered] = useState12(false);
3437
3436
  const [focused, setFocused] = useState12(false);
3438
3437
  const paused = hovered || focused;
3439
3438
  const tone = notice.tone ?? "success";
3439
+ const timed = !notice.action && notice.timer !== false && notice.timer !== 0;
3440
+ const duration = typeof notice.timer === "number" ? notice.timer * 1e3 : 5e3;
3441
+ const remaining = useRef18(duration);
3440
3442
  const dismiss = useCallback5(() => {
3441
3443
  if (dismissed.current) return;
3442
3444
  dismissed.current = true;
@@ -3458,19 +3460,19 @@ function Notice({ notice, remove }) {
3458
3460
  }
3459
3461
  }, [leaving2, remove]);
3460
3462
  useEffect12(() => {
3461
- if (leaving2 || notice.action || paused) return;
3463
+ if (leaving2 || !timed || paused) return;
3462
3464
  const started = Date.now();
3463
3465
  const timer = setTimeout(dismiss, remaining.current);
3464
3466
  return () => {
3465
3467
  clearTimeout(timer);
3466
3468
  remaining.current = Math.max(0, remaining.current - (Date.now() - started));
3467
3469
  };
3468
- }, [dismiss, leaving2, notice.action, paused]);
3470
+ }, [dismiss, leaving2, paused, timed]);
3469
3471
  return /* @__PURE__ */ jsxs27(
3470
3472
  "div",
3471
3473
  {
3472
3474
  ref: root,
3473
- className: `ui-toast ui-toast--${tone} ui-toast--soft${leaving2 ? " is-leaving" : ""}`,
3475
+ className: `ui-toast ui-toast--${tone} ui-toast--soft${notice.compact ? " ui-toast--compact" : ""}${leaving2 ? " is-leaving" : ""}`,
3474
3476
  role: tone === "danger" ? "alert" : "status",
3475
3477
  "aria-live": tone === "danger" ? "assertive" : "polite",
3476
3478
  onMouseEnter: () => setHovered(true),
@@ -3483,7 +3485,7 @@ function Notice({ notice, remove }) {
3483
3485
  /* @__PURE__ */ jsx32("span", { className: "ui-toast__icon", children: /* @__PURE__ */ jsx32(Icon, { name: glyphs[tone] }) }),
3484
3486
  /* @__PURE__ */ jsxs27("div", { className: "ui-toast__body", children: [
3485
3487
  /* @__PURE__ */ jsx32("div", { className: "ui-toast__title", children: notice.title }),
3486
- notice.text && /* @__PURE__ */ jsx32("div", { className: "ui-toast__text", children: notice.text })
3488
+ !notice.compact && notice.text && /* @__PURE__ */ jsx32("div", { className: "ui-toast__text", children: notice.text })
3487
3489
  ] }),
3488
3490
  notice.action && /* @__PURE__ */ jsx32(
3489
3491
  "button",
@@ -3500,13 +3502,13 @@ function Notice({ notice, remove }) {
3500
3502
  children: notice.action.label
3501
3503
  }
3502
3504
  ),
3503
- /* @__PURE__ */ jsx32("button", { type: "button", className: "ui-toast__close", "aria-label": "Dismiss", disabled: leaving2, onClick: dismiss, children: /* @__PURE__ */ jsx32(Icon, { name: "x" }) }),
3504
- !notice.action && /* @__PURE__ */ jsx32(
3505
+ notice.dismissible !== false && /* @__PURE__ */ jsx32("button", { type: "button", className: "ui-toast__close", "aria-label": "Dismiss", disabled: leaving2, onClick: dismiss, children: /* @__PURE__ */ jsx32(Icon, { name: "x" }) }),
3506
+ timed && /* @__PURE__ */ jsx32(
3505
3507
  "span",
3506
3508
  {
3507
3509
  className: "ui-toast__timer is-running",
3508
3510
  "aria-hidden": "true",
3509
- style: { animationPlayState: paused ? "paused" : "running" }
3511
+ style: { animationDuration: `${duration}ms`, animationPlayState: paused ? "paused" : "running" }
3510
3512
  }
3511
3513
  )
3512
3514
  ]
@@ -16,6 +16,9 @@ import { toast } from './index.js';
16
16
  const reduceMotion = () =>
17
17
  typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
18
18
 
19
+ const resolve = (container) =>
20
+ (typeof container === 'string' ? document.querySelector(container) : container) || null;
21
+
19
22
  // Slide-and-fade a toast out, then remove it. Idempotent.
20
23
  export function dismissToast(el) {
21
24
  if (!el || el.dataset.leaving) return;
@@ -36,20 +39,41 @@ function wireToast(el) {
36
39
  el.querySelector('.ui-toast__close')?.addEventListener('click', () => dismissToast(el));
37
40
 
38
41
  const bar = el.querySelector('[data-toast-timer]');
39
- if (bar && !reduceMotion()) {
42
+ if (bar) {
40
43
  bar.classList.add('is-running');
41
44
  const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
42
- let timer = setTimeout(() => dismissToast(el), dur * 1000);
43
- el.addEventListener('mouseenter', () => { clearTimeout(timer); bar.style.animationPlayState = 'paused'; });
44
- el.addEventListener('mouseleave', () => {
45
- bar.style.animationPlayState = 'running';
46
- const left = (parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7)) || 1);
47
- timer = setTimeout(() => dismissToast(el), dur * 1000 * left);
48
- });
49
- } else if (bar && reduceMotion()) {
50
- // No animated bar under reduced motion, but still auto-dismiss on time.
51
- const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
52
- setTimeout(() => dismissToast(el), dur * 1000);
45
+ let remaining = dur * 1000;
46
+ let started = Date.now();
47
+ let timer = setTimeout(() => dismissToast(el), remaining);
48
+ // The line's own pause lives in the sheet, on :hover and :focus-within, so
49
+ // it covers a keyboard reader too. What is left here is the countdown that
50
+ // has to stop with it. This runs even under reduced motion, where the line
51
+ // is hidden but the reader still gets the same hold.
52
+ let held = false;
53
+ const hold = () => {
54
+ if (held) return;
55
+ held = true;
56
+ clearTimeout(timer);
57
+ const visualLeft = !reduceMotion()
58
+ ? parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7))
59
+ : NaN;
60
+ remaining = Number.isFinite(visualLeft)
61
+ ? dur * 1000 * visualLeft
62
+ : Math.max(0, remaining - (Date.now() - started));
63
+ };
64
+ const release = () => {
65
+ if (!held || el.matches(':hover, :focus-within')) return;
66
+ held = false;
67
+ started = Date.now();
68
+ timer = setTimeout(() => dismissToast(el), remaining);
69
+ };
70
+ el.addEventListener('mouseenter', hold);
71
+ el.addEventListener('mouseleave', release);
72
+ el.addEventListener('focusin', hold);
73
+ // At focusout the old target has already blurred and the new one has not
74
+ // focused yet, so :focus-within reads false even for a move from the close
75
+ // button to the action. relatedTarget is the only thing that knows.
76
+ el.addEventListener('focusout', (e) => { if (!el.contains(e.relatedTarget)) release(); });
53
77
  }
54
78
 
55
79
  // Swipe-to-dismiss (ignore drags that start on a button).
@@ -77,7 +101,7 @@ function wireToast(el) {
77
101
 
78
102
  // Wire every toast currently inside a container (string selector or element).
79
103
  export function wireToastStack(container) {
80
- const root = typeof container === 'string' ? document.querySelector(container) : container;
104
+ const root = resolve(container);
81
105
  if (!root) return null;
82
106
  root.querySelectorAll('.ui-toast').forEach(wireToast);
83
107
  return root;
@@ -85,7 +109,7 @@ export function wireToastStack(container) {
85
109
 
86
110
  // Build a toast from opts, prepend it to a live stack (newest on top), wire it.
87
111
  export function pushToast(container, opts = {}) {
88
- const root = typeof container === 'string' ? document.querySelector(container) : container;
112
+ const root = resolve(container);
89
113
  if (!root) return null;
90
114
  root.insertAdjacentHTML('afterbegin', toast(opts));
91
115
  const el = root.firstElementChild;
@@ -93,9 +93,16 @@
93
93
  .ui-toast__close:hover { color: var(--text); }
94
94
  .ui-toast__close:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); border-radius: var(--radius-xs); }
95
95
 
96
- /* auto-dismiss progress bar — .is-running is added by wireToastStack() */
96
+ /* auto-dismiss progress line — .is-running is added by wireToastStack() */
97
97
  .ui-toast__timer { position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; background: var(--toast-accent); opacity: 0.85; transform-origin: left; }
98
- .ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the bar spends the toast's dismiss timer, which the caller sets */ }
98
+ .ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the line spends the toast's dismiss timer, which the caller sets */ }
99
+ /* The line spends the notice's own timer, so it stops exactly when the timer
100
+ does: under the pointer, and while focus is anywhere inside. Holding both
101
+ pauses here rather than in each face's event wiring keeps the line and the
102
+ countdown from drifting apart, and gives a keyboard reader the same reprieve
103
+ a pointer reader gets. */
104
+ .ui-toast:hover > .ui-toast__timer.is-running,
105
+ .ui-toast:focus-within > .ui-toast__timer.is-running { animation-play-state: paused; }
99
106
 
100
107
  /* Legacy style classes share the neutral card. */
101
108
  .ui-toast--soft, .ui-toast--solid, .ui-toast--outline { background: var(--surface); color: var(--text); }
@@ -110,7 +117,10 @@
110
117
 
111
118
  @media (prefers-reduced-motion: reduce) {
112
119
  .ui-toast, .ui-toast.is-leaving { animation: none; }
113
- .ui-toast__timer.is-running { animation: none; }
120
+ /* A line that cannot move sits full for the whole five seconds and reads as
121
+ "no time spent yet" — the opposite of what it is there to say. The notice
122
+ still expires on time; only the false readout goes. */
123
+ .ui-toast__timer { display: none; }
114
124
  }
115
125
 
116
126
  /* stack container for queued toasts */