uibee 2.5.7 → 2.5.8

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.
@@ -14,5 +14,5 @@ export { default as PageContainer } from './container/page';
14
14
  export { default as Highlight } from './container/highlight';
15
15
  export { default as VersionTag } from './version/version';
16
16
  export { default as LoginPage } from './login/loginPage';
17
- export { default as Toaster, addToast } from './toast/toaster';
17
+ export { default as Toaster, toast } from './toast/toaster';
18
18
  export { default as Button } from './buttons/button';
@@ -20,6 +20,6 @@ export { default as Highlight } from './container/highlight';
20
20
  // Other components
21
21
  export { default as VersionTag } from './version/version';
22
22
  export { default as LoginPage } from './login/loginPage';
23
- export { default as Toaster, addToast } from './toast/toaster';
23
+ export { default as Toaster, toast } from './toast/toaster';
24
24
  // Buttons
25
25
  export { default as Button } from './buttons/button';
@@ -0,0 +1,2 @@
1
+ import type { ToastProps } from 'uibee/components';
2
+ export default function Toast({ toast, index, expanded, onRemove, onHeight, offset, frontHeight }: ToastProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
3
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ export default function Toast({ toast, index, expanded, onRemove, onHeight, offset, frontHeight }) {
5
+ const [mounted, setMounted] = useState(false);
6
+ const ref = useRef(null);
7
+ useEffect(() => {
8
+ requestAnimationFrame(() => setMounted(true));
9
+ }, []);
10
+ useLayoutEffect(() => {
11
+ if (ref.current) {
12
+ onHeight(toast.id, ref.current.offsetHeight);
13
+ }
14
+ }, [toast.message, onHeight, toast.id, expanded]);
15
+ const isVisible = mounted && !toast.exiting;
16
+ const isFront = index === 0;
17
+ const collapsedOffset = index * 10 + (index > 0 ? (frontHeight - 60) : 0);
18
+ return (_jsx("li", { ref: ref, className: 'absolute bottom-0 right-0 w-full transition-all duration-300 ease-out pointer-events-auto', style: {
19
+ transform: isVisible
20
+ ? `translateY(${expanded ? -offset : -collapsedOffset}px) scale(${expanded ? 1 : 1 - index * 0.05})`
21
+ : 'translateY(20px) scale(0.9)',
22
+ opacity: isVisible ? 1 : 0,
23
+ zIndex: toast.id
24
+ }, children: _jsxs("div", { className: 'flex items-center space-x-4 rounded-lg p-4 shadow-lg border-2 border-login-400 bg-login-700', children: [_jsx("div", { className: 'shrink-0', children: Icon(toast.type) }), _jsx("p", { className: `flex-1 text-sm font-semibold text-login-800 dark:text-login-100 min-w-0
25
+ ${!expanded && !isFront ? 'truncate' : ''}`, children: toast.message }), _jsx("button", { onClick: onRemove, className: 'hover:text-login-200 text-login-400', children: _jsx(X, { className: 'h-5 w-5' }) })] }) }));
26
+ }
27
+ function Icon(type) {
28
+ switch (type) {
29
+ case 'info':
30
+ return _jsx(Info, { className: 'h-6 w-6 text-blue-500' });
31
+ case 'success':
32
+ return _jsx(CheckCircle, { className: 'h-6 w-6 text-green-500' });
33
+ case 'warning':
34
+ return _jsx(AlertTriangle, { className: 'h-6 w-6 text-yellow-500' });
35
+ case 'error':
36
+ return _jsx(AlertCircle, { className: 'h-6 w-6 text-red-500' });
37
+ }
38
+ }
@@ -1,3 +1,3 @@
1
- import { ToastType } from 'uibee/components';
2
- export declare function addToast(type: ToastType, title: string, description?: string): void;
1
+ import type { ToastType } from 'uibee/components';
2
+ export declare function toast(message: string, type: ToastType, duration?: number): void;
3
3
  export default function Toaster(): import("react/jsx-runtime").JSX.Element;
@@ -1,89 +1,82 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { CircleAlert, CircleCheck, CircleX, Info } from 'lucide-react';
4
- import { useEffect, useState, useRef } from 'react';
5
- const observers = [];
6
- export function addToast(type, title, description = '') {
7
- observers.forEach((observer) => observer({ title, description, type }));
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useState, useEffect, useMemo } from 'react';
4
+ import ToastItem from 'toastItem';
5
+ const listeners = new Set();
6
+ let idCounter = 0;
7
+ export function toast(message, type, duration = 4000) {
8
+ const id = ++idCounter;
9
+ listeners.forEach(listener => listener({ id, message, type, expiresAt: Date.now() + duration }));
8
10
  }
9
11
  export default function Toaster() {
10
12
  const [toasts, setToasts] = useState([]);
11
- const timers = useRef({});
12
- const [isHovered, setIsHovered] = useState(false);
13
- const pauseTimes = useRef({});
14
- const mainToastRef = useRef(null);
15
- const [mainToastPosition, setMainToastPosition] = useState(null);
13
+ const [expanded, setExpanded] = useState(false);
14
+ const [heights, setHeights] = useState({});
16
15
  useEffect(() => {
17
- const listener = ({ type, title, description }) => {
18
- const id = Date.now();
19
- setToasts(prev => {
20
- const newToasts = prev.concat({ id, type, title, description, remaining: 3000, created: Date.now() }).slice(-3);
21
- return newToasts;
22
- });
23
- };
24
- observers.push(listener);
16
+ function addToast(toast) {
17
+ setToasts(prev => [toast, ...prev]);
18
+ }
19
+ listeners.add(addToast);
25
20
  return () => {
26
- const idx = observers.indexOf(listener);
27
- if (idx > -1)
28
- observers.splice(idx, 1);
29
- Object.values(timers.current).forEach(clearTimeout);
21
+ listeners.delete(addToast);
30
22
  };
31
23
  }, []);
32
24
  useEffect(() => {
33
- if (isHovered) {
34
- toasts.forEach(toast => {
35
- if (timers.current[toast.id]) {
36
- clearTimeout(timers.current[toast.id]);
37
- const elapsed = Date.now() - toast.created;
38
- pauseTimes.current[toast.id] = toast.remaining - elapsed > 0 ? toast.remaining - elapsed : 0;
39
- setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, remaining: pauseTimes.current[toast.id] } : t));
40
- delete timers.current[toast.id];
41
- }
42
- });
43
- }
44
- else {
45
- toasts.forEach(toast => {
46
- if (!timers.current[toast.id] && toast.remaining > 0) {
47
- timers.current[toast.id] = setTimeout(() => {
48
- setToasts(prev => prev.filter(t => t.id !== toast.id));
49
- delete timers.current[toast.id];
50
- }, toast.remaining);
51
- setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, created: Date.now() } : t));
52
- }
53
- });
54
- }
55
- }, [isHovered, toasts]);
56
- // Track main toast position for stacking
25
+ const now = Date.now();
26
+ setToasts(prev => prev.map(toast => {
27
+ if (expanded)
28
+ return { ...toast, pausedAt: toast.pausedAt || now };
29
+ if (!toast.pausedAt)
30
+ return toast;
31
+ return { ...toast, expiresAt: toast.expiresAt + (now - toast.pausedAt), pausedAt: undefined };
32
+ }));
33
+ }, [expanded]);
34
+ function removeToast(id) {
35
+ setToasts(prev => prev.map(toast => toast.id === id ? { ...toast, exiting: true } : toast));
36
+ setTimeout(() => {
37
+ setToasts(prev => prev.filter(toast => toast.id !== id));
38
+ }, 300);
39
+ }
57
40
  useEffect(() => {
58
- if (mainToastRef.current && toasts.length > 0) {
59
- const rect = mainToastRef.current.getBoundingClientRect();
60
- setMainToastPosition({
61
- top: rect.top,
62
- right: window.innerWidth - rect.right
41
+ const timer = setInterval(() => {
42
+ if (expanded)
43
+ return;
44
+ const now = Date.now();
45
+ setToasts(prev => {
46
+ const toastsToExit = prev.filter(toast => !toast.exiting && !toast.pausedAt && toast.expiresAt <= now);
47
+ if (toastsToExit.length === 0)
48
+ return prev;
49
+ toastsToExit.forEach(toast => {
50
+ setTimeout(() => {
51
+ setToasts(current => current.filter(item => item.id !== toast.id));
52
+ }, 300);
53
+ });
54
+ return prev.map(toast => toastsToExit.find(exitToast => exitToast.id === toast.id) ? { ...toast, exiting: true } : toast);
63
55
  });
64
- }
65
- }, [toasts, isHovered]);
66
- const bgClasses = ['bg-login-600', 'bg-login-700', 'bg-login-800'];
67
- return (_jsx("div", { className: `fixed bottom-4 right-4 z-50 flex ${isHovered ? 'flex-col-reverse items-end gap-2' : 'flex-col items-end'}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: toasts.slice().reverse().map((toast, idx) => (_jsxs("div", { ref: idx === 0 ? mainToastRef : null, className: 'p-2 rounded-lg text-login-50 animate-fade-in-down transition-all w-sm flex items-center gap-2 ' +
68
- (bgClasses[idx] || bgClasses[2]), style: isHovered ? {} : idx === 0 ? {
69
- zIndex: 100,
70
- } : {
71
- position: 'fixed',
72
- top: mainToastPosition ? `${mainToastPosition.top - idx * 8}px` : 'auto',
73
- zIndex: 100 - idx,
74
- transform: `scale(${1 - idx * 0.05})`,
75
- }, children: [_jsx("span", { className: 'shrink-0 w-10 h-10 flex items-center justify-center', children: _jsx(ToastIcon, { type: toast.type }) }), _jsxs("div", { className: 'pr-1 pb-1', children: [_jsx("span", { className: 'font-bold', children: toast.title }), (idx === 0 || isHovered) &&
76
- _jsx("span", { className: 'text-sm line-clamp-3', children: toast.description })] })] }, `${toast.id}-${idx}`))) }));
77
- }
78
- function ToastIcon({ type }) {
79
- switch (type) {
80
- case 'success':
81
- return _jsx(CircleCheck, { className: 'text-green-300/70' });
82
- case 'warning':
83
- return _jsx(CircleAlert, { className: 'text-yellow-300/70' });
84
- case 'error':
85
- return _jsx(CircleX, { className: 'text-red-300/70' });
86
- case 'info':
87
- return _jsx(Info, { className: 'text-blue-300/70' });
56
+ }, 100);
57
+ return () => clearInterval(timer);
58
+ }, [expanded]);
59
+ function onHeight(id, height) {
60
+ setHeights(prev => {
61
+ if (prev[id] === height)
62
+ return prev;
63
+ return { ...prev, [id]: height };
64
+ });
88
65
  }
66
+ const visibleToasts = toasts.slice(0, expanded ? 10 : 3);
67
+ const frontHeight = heights[visibleToasts[0]?.id] || 60;
68
+ const offsets = useMemo(() => {
69
+ let currentOffset = 0;
70
+ return visibleToasts.map(toast => {
71
+ const height = heights[toast.id] || 60;
72
+ const offset = currentOffset;
73
+ currentOffset += height + 16;
74
+ return offset;
75
+ });
76
+ }, [visibleToasts, heights]);
77
+ const totalHeight = offsets.length > 0
78
+ ? offsets[offsets.length - 1] + (heights[visibleToasts[visibleToasts.length - 1]?.id] || 60)
79
+ : 0;
80
+ return (_jsx("ul", { className: `fixed bottom-4 right-4 z-9999 w-full max-w-sm flex flex-col items-end transition-all duration-300 ease-out
81
+ ${expanded ? 'pointer-events-auto' : 'pointer-events-none'}`, style: { height: expanded ? totalHeight + 'px' : 'auto' }, onMouseEnter: () => setExpanded(true), onMouseLeave: () => setExpanded(false), children: visibleToasts.map((toast, index) => (_jsx(ToastItem, { toast: toast, index: index, expanded: expanded, onRemove: () => removeToast(toast.id), onHeight: onHeight, offset: offsets[index], frontHeight: frontHeight }, toast.id))) }));
89
82
  }
@@ -7,13 +7,12 @@
7
7
  "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
8
8
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
9
9
  "Courier New", monospace;
10
- --color-red-300: oklch(80.8% 0.114 19.571);
11
10
  --color-red-500: oklch(63.7% 0.237 25.331);
12
11
  --color-red-700: oklch(50.5% 0.213 27.518);
13
12
  --color-red-800: oklch(44.4% 0.177 26.899);
14
- --color-yellow-300: oklch(90.5% 0.182 98.111);
15
- --color-green-300: oklch(87.1% 0.15 154.449);
16
- --color-blue-300: oklch(80.9% 0.105 251.813);
13
+ --color-yellow-500: oklch(79.5% 0.184 86.047);
14
+ --color-green-500: oklch(72.3% 0.219 149.579);
15
+ --color-blue-500: oklch(62.3% 0.214 259.815);
17
16
  --color-gray-400: oklch(70.7% 0.022 261.325);
18
17
  --color-black: #000;
19
18
  --color-white: #fff;
@@ -58,8 +57,10 @@
58
57
  --color-login-700: #1a1a1a;
59
58
  --color-login-600: #212121;
60
59
  --color-login-500: #323232;
60
+ --color-login-400: #424242;
61
61
  --color-login-300: #5e5e5e;
62
62
  --color-login-200: #727272;
63
+ --color-login-100: #b0b0b0;
63
64
  --color-login-50: #ededed;
64
65
  }
65
66
  }
@@ -326,6 +327,9 @@
326
327
  .right-4 {
327
328
  right: calc(var(--spacing) * 4);
328
329
  }
330
+ .bottom-0 {
331
+ bottom: calc(var(--spacing) * 0);
332
+ }
329
333
  .bottom-4 {
330
334
  bottom: calc(var(--spacing) * 4);
331
335
  }
@@ -353,6 +357,9 @@
353
357
  .z-900 {
354
358
  z-index: 900;
355
359
  }
360
+ .z-9999 {
361
+ z-index: 9999;
362
+ }
356
363
  .col-start-3 {
357
364
  grid-column-start: 3;
358
365
  }
@@ -402,12 +409,6 @@
402
409
  padding-top: 2rem;
403
410
  }
404
411
  }
405
- .line-clamp-3 {
406
- overflow: hidden;
407
- display: -webkit-box;
408
- -webkit-box-orient: vertical;
409
- -webkit-line-clamp: 3;
410
- }
411
412
  .block {
412
413
  display: block;
413
414
  }
@@ -432,15 +433,15 @@
432
433
  .h-4 {
433
434
  height: calc(var(--spacing) * 4);
434
435
  }
436
+ .h-5 {
437
+ height: calc(var(--spacing) * 5);
438
+ }
435
439
  .h-6 {
436
440
  height: calc(var(--spacing) * 6);
437
441
  }
438
442
  .h-8 {
439
443
  height: calc(var(--spacing) * 8);
440
444
  }
441
- .h-10 {
442
- height: calc(var(--spacing) * 10);
443
- }
444
445
  .h-11 {
445
446
  height: calc(var(--spacing) * 11);
446
447
  }
@@ -513,9 +514,6 @@
513
514
  .w-max {
514
515
  width: max-content;
515
516
  }
516
- .w-sm {
517
- width: var(--container-sm);
518
- }
519
517
  .max-w-2xl {
520
518
  max-width: var(--container-2xl);
521
519
  }
@@ -534,9 +532,15 @@
534
532
  .max-w-md {
535
533
  max-width: var(--container-md);
536
534
  }
535
+ .max-w-sm {
536
+ max-width: var(--container-sm);
537
+ }
537
538
  .max-w-xs {
538
539
  max-width: var(--container-xs);
539
540
  }
541
+ .min-w-0 {
542
+ min-width: calc(var(--spacing) * 0);
543
+ }
540
544
  .min-w-\[6ch\] {
541
545
  min-width: 6ch;
542
546
  }
@@ -621,9 +625,6 @@
621
625
  .flex-col {
622
626
  flex-direction: column;
623
627
  }
624
- .flex-col-reverse {
625
- flex-direction: column-reverse;
626
- }
627
628
  .flex-row {
628
629
  flex-direction: row;
629
630
  }
@@ -666,6 +667,13 @@
666
667
  .gap-4 {
667
668
  gap: calc(var(--spacing) * 4);
668
669
  }
670
+ .space-x-4 {
671
+ :where(& > :not(:last-child)) {
672
+ --tw-space-x-reverse: 0;
673
+ margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));
674
+ margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
675
+ }
676
+ }
669
677
  .truncate {
670
678
  overflow: hidden;
671
679
  text-overflow: ellipsis;
@@ -709,6 +717,10 @@
709
717
  border-style: var(--tw-border-style);
710
718
  border-width: 0px;
711
719
  }
720
+ .border-2 {
721
+ border-style: var(--tw-border-style);
722
+ border-width: 2px;
723
+ }
712
724
  .border-\[0\.10rem\] {
713
725
  border-style: var(--tw-border-style);
714
726
  border-width: 0.10rem;
@@ -724,6 +736,9 @@
724
736
  .border-login-200 {
725
737
  border-color: var(--color-login-200);
726
738
  }
739
+ .border-login-400 {
740
+ border-color: var(--color-login-400);
741
+ }
727
742
  .bg-\[\#181818f0\] {
728
743
  background-color: #181818f0;
729
744
  }
@@ -823,6 +838,9 @@
823
838
  .p-3 {
824
839
  padding: calc(var(--spacing) * 3);
825
840
  }
841
+ .p-4 {
842
+ padding: calc(var(--spacing) * 4);
843
+ }
826
844
  .p-8 {
827
845
  padding: calc(var(--spacing) * 8);
828
846
  }
@@ -874,15 +892,9 @@
874
892
  .pt-4 {
875
893
  padding-top: calc(var(--spacing) * 4);
876
894
  }
877
- .pr-1 {
878
- padding-right: calc(var(--spacing) * 1);
879
- }
880
895
  .pr-4 {
881
896
  padding-right: calc(var(--spacing) * 4);
882
897
  }
883
- .pb-1 {
884
- padding-bottom: calc(var(--spacing) * 1);
885
- }
886
898
  .pb-2\.5 {
887
899
  padding-bottom: calc(var(--spacing) * 2.5);
888
900
  }
@@ -961,29 +973,23 @@
961
973
  .whitespace-nowrap {
962
974
  white-space: nowrap;
963
975
  }
964
- .text-blue-300\/70 {
965
- color: color-mix(in srgb, oklch(80.9% 0.105 251.813) 70%, transparent);
966
- @supports (color: color-mix(in lab, red, red)) {
967
- color: color-mix(in oklab, var(--color-blue-300) 70%, transparent);
968
- }
976
+ .text-blue-500 {
977
+ color: var(--color-blue-500);
969
978
  }
970
- .text-green-300\/70 {
971
- color: color-mix(in srgb, oklch(87.1% 0.15 154.449) 70%, transparent);
972
- @supports (color: color-mix(in lab, red, red)) {
973
- color: color-mix(in oklab, var(--color-green-300) 70%, transparent);
974
- }
979
+ .text-green-500 {
980
+ color: var(--color-green-500);
975
981
  }
976
982
  .text-login {
977
983
  color: var(--color-login);
978
984
  }
979
- .text-login-50 {
980
- color: var(--color-login-50);
985
+ .text-login-400 {
986
+ color: var(--color-login-400);
981
987
  }
982
- .text-red-300\/70 {
983
- color: color-mix(in srgb, oklch(80.8% 0.114 19.571) 70%, transparent);
984
- @supports (color: color-mix(in lab, red, red)) {
985
- color: color-mix(in oklab, var(--color-red-300) 70%, transparent);
986
- }
988
+ .text-login-800 {
989
+ color: var(--color-login-800);
990
+ }
991
+ .text-red-500 {
992
+ color: var(--color-red-500);
987
993
  }
988
994
  .text-red-500\/50 {
989
995
  color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 50%, transparent);
@@ -997,11 +1003,8 @@
997
1003
  .text-white {
998
1004
  color: var(--color-white);
999
1005
  }
1000
- .text-yellow-300\/70 {
1001
- color: color-mix(in srgb, oklch(90.5% 0.182 98.111) 70%, transparent);
1002
- @supports (color: color-mix(in lab, red, red)) {
1003
- color: color-mix(in oklab, var(--color-yellow-300) 70%, transparent);
1004
- }
1006
+ .text-yellow-500 {
1007
+ color: var(--color-yellow-500);
1005
1008
  }
1006
1009
  .italic {
1007
1010
  font-style: italic;
@@ -1271,6 +1274,13 @@
1271
1274
  }
1272
1275
  }
1273
1276
  }
1277
+ .hover\:text-login-200 {
1278
+ &:hover {
1279
+ @media (hover: hover) {
1280
+ color: var(--color-login-200);
1281
+ }
1282
+ }
1283
+ }
1274
1284
  .hover\:text-red-800 {
1275
1285
  &:hover {
1276
1286
  @media (hover: hover) {
@@ -1355,6 +1365,11 @@
1355
1365
  gap: calc(var(--spacing) * 6);
1356
1366
  }
1357
1367
  }
1368
+ .dark\:text-login-100 {
1369
+ @media (prefers-color-scheme: dark) {
1370
+ color: var(--color-login-100);
1371
+ }
1372
+ }
1358
1373
  }
1359
1374
  :root {
1360
1375
  transition: background 0.4s,
@@ -1449,6 +1464,11 @@
1449
1464
  syntax: "*";
1450
1465
  inherits: false;
1451
1466
  }
1467
+ @property --tw-space-x-reverse {
1468
+ syntax: "*";
1469
+ inherits: false;
1470
+ initial-value: 0;
1471
+ }
1452
1472
  @property --tw-border-style {
1453
1473
  syntax: "*";
1454
1474
  inherits: false;
@@ -1599,6 +1619,7 @@
1599
1619
  --tw-rotate-z: initial;
1600
1620
  --tw-skew-x: initial;
1601
1621
  --tw-skew-y: initial;
1622
+ --tw-space-x-reverse: 0;
1602
1623
  --tw-border-style: solid;
1603
1624
  --tw-leading: initial;
1604
1625
  --tw-font-weight: initial;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uibee",
3
- "version": "2.5.7",
3
+ "version": "2.5.8",
4
4
  "description": "Shared components, functions and hooks for reuse across Login projects",
5
5
  "homepage": "https://github.com/Login-Linjeforening-for-IT/uibee#readme",
6
6
  "bugs": {
@@ -25,7 +25,7 @@ export { default as Highlight } from './container/highlight'
25
25
  // Other components
26
26
  export { default as VersionTag } from './version/version'
27
27
  export { default as LoginPage } from './login/loginPage'
28
- export { default as Toaster, addToast } from './toast/toaster'
28
+ export { default as Toaster, toast } from './toast/toaster'
29
29
 
30
30
  // Buttons
31
31
  export { default as Button } from './buttons/button'
@@ -0,0 +1,61 @@
1
+ import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react'
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import type { Toast, ToastType, ToastProps } from 'uibee/components'
4
+
5
+ export default function Toast({ toast, index, expanded, onRemove, onHeight, offset, frontHeight }: ToastProps) {
6
+ const [mounted, setMounted] = useState(false)
7
+ const ref = useRef<HTMLLIElement>(null)
8
+
9
+ useEffect(() => {
10
+ requestAnimationFrame(() => setMounted(true))
11
+ }, [])
12
+
13
+ useLayoutEffect(() => {
14
+ if (ref.current) {
15
+ onHeight(toast.id, ref.current.offsetHeight)
16
+ }
17
+ }, [toast.message, onHeight, toast.id, expanded])
18
+
19
+ const isVisible = mounted && !toast.exiting
20
+ const isFront = index === 0
21
+ const collapsedOffset = index * 10 + (index > 0 ? (frontHeight - 60) : 0)
22
+
23
+ return (
24
+ <li
25
+ ref={ref}
26
+ className='absolute bottom-0 right-0 w-full transition-all duration-300 ease-out pointer-events-auto'
27
+ style={{
28
+ transform: isVisible
29
+ ? `translateY(${expanded ? -offset : -collapsedOffset}px) scale(${expanded ? 1 : 1 - index * 0.05})`
30
+ : 'translateY(20px) scale(0.9)',
31
+ opacity: isVisible ? 1 : 0,
32
+ zIndex: toast.id
33
+ }}
34
+ >
35
+ <div className='flex items-center space-x-4 rounded-lg p-4 shadow-lg border-2 border-login-400 bg-login-700'>
36
+ <div className='shrink-0'>{Icon(toast.type)}</div>
37
+ <p className={`flex-1 text-sm font-semibold text-login-800 dark:text-login-100 min-w-0
38
+ ${!expanded && !isFront ? 'truncate' : ''}`}
39
+ >
40
+ {toast.message}
41
+ </p>
42
+ <button onClick={onRemove} className='hover:text-login-200 text-login-400'>
43
+ <X className='h-5 w-5' />
44
+ </button>
45
+ </div>
46
+ </li>
47
+ )
48
+ }
49
+
50
+ function Icon(type: ToastType) {
51
+ switch (type) {
52
+ case 'info':
53
+ return <Info className='h-6 w-6 text-blue-500' />
54
+ case 'success':
55
+ return <CheckCircle className='h-6 w-6 text-green-500' />
56
+ case 'warning':
57
+ return <AlertTriangle className='h-6 w-6 text-yellow-500' />
58
+ case 'error':
59
+ return <AlertCircle className='h-6 w-6 text-red-500' />
60
+ }
61
+ }
@@ -1,123 +1,114 @@
1
1
  'use client'
2
2
 
3
- import { CircleAlert, CircleCheck, CircleX, Info } from 'lucide-react'
4
- import { useEffect, useState, useRef } from 'react'
5
- import { ToastProps, ToastType, ToastObserverProps } from 'uibee/components'
3
+ import { useState, useEffect, useMemo } from 'react'
4
+ import type { Toast, ToastType } from 'uibee/components'
5
+ import ToastItem from '@components/toast/toastItem'
6
6
 
7
+ const listeners = new Set<(toast: Toast) => void>()
8
+ let idCounter = 0
7
9
 
8
- const observers: ToastObserverProps[] = []
9
-
10
- export function addToast(type: ToastType, title: string, description: string = '') {
11
- observers.forEach((observer) => observer({ title, description, type }))
10
+ export function toast(message: string, type: ToastType, duration = 4000) {
11
+ const id = ++idCounter
12
+ listeners.forEach(listener => listener({ id, message, type, expiresAt: Date.now() + duration }))
12
13
  }
13
14
 
14
15
  export default function Toaster() {
15
- const [toasts, setToasts] = useState<Array<ToastProps & { remaining: number; created: number }>>([])
16
- const timers = useRef<{ [id: number]: NodeJS.Timeout }>({})
17
- const [isHovered, setIsHovered] = useState(false)
18
- const pauseTimes = useRef<{ [id: number]: number }>({})
19
- const mainToastRef = useRef<HTMLDivElement>(null)
20
- const [mainToastPosition, setMainToastPosition] = useState<{ top: number; right: number } | null>(null)
16
+ const [toasts, setToasts] = useState<Toast[]>([])
17
+ const [expanded, setExpanded] = useState(false)
18
+ const [heights, setHeights] = useState<Record<number, number>>({})
21
19
 
22
20
  useEffect(() => {
23
- const listener: ToastObserverProps = ({ type, title, description }) => {
24
- const id = Date.now()
25
- setToasts(prev => {
26
- const newToasts = prev.concat({ id, type, title, description, remaining: 3000, created: Date.now() }).slice(-3)
27
- return newToasts
28
- })
21
+ function addToast(toast: Toast) {
22
+ setToasts(prev => [toast, ...prev])
29
23
  }
30
- observers.push(listener)
24
+
25
+ listeners.add(addToast)
26
+
31
27
  return () => {
32
- const idx = observers.indexOf(listener)
33
- if (idx > -1) observers.splice(idx, 1)
34
- Object.values(timers.current).forEach(clearTimeout)
28
+ listeners.delete(addToast)
35
29
  }
36
30
  }, [])
37
31
 
38
32
  useEffect(() => {
39
- if (isHovered) {
40
- toasts.forEach(toast => {
41
- if (timers.current[toast.id]) {
42
- clearTimeout(timers.current[toast.id])
43
- const elapsed = Date.now() - toast.created
44
- pauseTimes.current[toast.id] = toast.remaining - elapsed > 0 ? toast.remaining - elapsed : 0
45
- setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, remaining: pauseTimes.current[toast.id] } : t))
46
- delete timers.current[toast.id]
47
- }
48
- })
49
- } else {
50
- toasts.forEach(toast => {
51
- if (!timers.current[toast.id] && toast.remaining > 0) {
52
- timers.current[toast.id] = setTimeout(() => {
53
- setToasts(prev => prev.filter(t => t.id !== toast.id))
54
- delete timers.current[toast.id]
55
- }, toast.remaining)
56
- setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, created: Date.now() } : t))
57
- }
58
- })
59
- }
60
- }, [isHovered, toasts])
33
+ const now = Date.now()
34
+ setToasts(prev => prev.map(toast => {
35
+ if (expanded) return { ...toast, pausedAt: toast.pausedAt || now }
36
+ if (!toast.pausedAt) return toast
37
+ return { ...toast, expiresAt: toast.expiresAt + (now - toast.pausedAt), pausedAt: undefined }
38
+ }))
39
+ }, [expanded])
40
+
41
+ function removeToast(id: number) {
42
+ setToasts(prev => prev.map(toast => toast.id === id ? { ...toast, exiting: true } : toast))
43
+ setTimeout(() => {
44
+ setToasts(prev => prev.filter(toast => toast.id !== id))
45
+ }, 300)
46
+ }
61
47
 
62
- // Track main toast position for stacking
63
48
  useEffect(() => {
64
- if (mainToastRef.current && toasts.length > 0) {
65
- const rect = mainToastRef.current.getBoundingClientRect()
66
- setMainToastPosition({
67
- top: rect.top,
68
- right: window.innerWidth - rect.right
49
+ const timer = setInterval(() => {
50
+ if (expanded) return
51
+ const now = Date.now()
52
+ setToasts(prev => {
53
+ const toastsToExit = prev.filter(toast => !toast.exiting && !toast.pausedAt && toast.expiresAt <= now)
54
+ if (toastsToExit.length === 0) return prev
55
+
56
+ toastsToExit.forEach(toast => {
57
+ setTimeout(() => {
58
+ setToasts(current => current.filter(item => item.id !== toast.id))
59
+ }, 300)
60
+ })
61
+
62
+ return prev.map(toast => toastsToExit.find(exitToast => exitToast.id === toast.id) ? { ...toast, exiting: true } : toast)
69
63
  })
70
- }
71
- }, [toasts, isHovered])
64
+ }, 100)
65
+ return () => clearInterval(timer)
66
+ }, [expanded])
67
+
68
+ function onHeight(id: number, height: number) {
69
+ setHeights(prev => {
70
+ if (prev[id] === height) return prev
71
+ return { ...prev, [id]: height }
72
+ })
73
+ }
74
+
75
+ const visibleToasts = toasts.slice(0, expanded ? 10 : 3)
76
+ const frontHeight = heights[visibleToasts[0]?.id] || 60
77
+
78
+ const offsets = useMemo(() => {
79
+ let currentOffset = 0
80
+ return visibleToasts.map(toast => {
81
+ const height = heights[toast.id] || 60
82
+ const offset = currentOffset
83
+ currentOffset += height + 16
84
+ return offset
85
+ })
86
+ }, [visibleToasts, heights])
87
+
88
+ const totalHeight = offsets.length > 0
89
+ ? offsets[offsets.length - 1] + (heights[visibleToasts[visibleToasts.length - 1]?.id] || 60)
90
+ : 0
72
91
 
73
- const bgClasses = ['bg-login-600', 'bg-login-700', 'bg-login-800']
74
92
  return (
75
- <div
76
- className={`fixed bottom-4 right-4 z-50 flex ${isHovered ? 'flex-col-reverse items-end gap-2' : 'flex-col items-end'}`}
77
- onMouseEnter={() => setIsHovered(true)}
78
- onMouseLeave={() => setIsHovered(false)}
93
+ <ul
94
+ className={`fixed bottom-4 right-4 z-9999 w-full max-w-sm flex flex-col items-end transition-all duration-300 ease-out
95
+ ${expanded ? 'pointer-events-auto' : 'pointer-events-none'}`}
96
+ style={{ height: expanded ? totalHeight + 'px' : 'auto' }}
97
+ onMouseEnter={() => setExpanded(true)}
98
+ onMouseLeave={() => setExpanded(false)}
79
99
  >
80
- {toasts.slice().reverse().map((toast, idx) => (
81
- <div
82
- key={`${toast.id}-${idx}`}
83
- ref={idx === 0 ? mainToastRef : null}
84
- className={
85
- 'p-2 rounded-lg text-login-50 animate-fade-in-down transition-all w-sm flex items-center gap-2 ' +
86
- (bgClasses[idx] || bgClasses[2])
87
- }
88
- style={isHovered ? {} : idx === 0 ? {
89
- zIndex: 100,
90
- } : {
91
- position: 'fixed',
92
- top: mainToastPosition ? `${mainToastPosition.top - idx * 8}px` : 'auto',
93
- zIndex: 100 - idx,
94
- transform: `scale(${1 - idx * 0.05})`,
95
- }}
96
- >
97
- <span className='shrink-0 w-10 h-10 flex items-center justify-center'>
98
- <ToastIcon type={toast.type} />
99
- </span>
100
- <div className='pr-1 pb-1'>
101
- <span className='font-bold'>{toast.title}</span>
102
- {(idx === 0 || isHovered) &&
103
- <span className='text-sm line-clamp-3'>{toast.description}</span>
104
- }
105
- </div>
106
- </div>
100
+ {visibleToasts.map((toast, index) => (
101
+ <ToastItem
102
+ key={toast.id}
103
+ toast={toast}
104
+ index={index}
105
+ expanded={expanded}
106
+ onRemove={() => removeToast(toast.id)}
107
+ onHeight={onHeight}
108
+ offset={offsets[index]}
109
+ frontHeight={frontHeight}
110
+ />
107
111
  ))}
108
- </div>
112
+ </ul>
109
113
  )
110
114
  }
111
-
112
- function ToastIcon({ type }: { type?: ToastType }) {
113
- switch (type) {
114
- case 'success':
115
- return <CircleCheck className='text-green-300/70' />
116
- case 'warning':
117
- return <CircleAlert className='text-yellow-300/70' />
118
- case 'error':
119
- return <CircleX className='text-red-300/70' />
120
- case 'info':
121
- return <Info className='text-blue-300/70' />
122
- }
123
- }
@@ -9,28 +9,32 @@ declare module 'uibee/components' {
9
9
  handleSubmit?: (formData: FormData) => void
10
10
  }
11
11
 
12
- export type ToastType = 'success' | 'error' | 'info' | 'warning'
13
- export interface ToastProps {
14
- id: number
15
- type: ToastType
16
- title: string
17
- description?: string
18
- }
12
+ export type ToastType = 'info' | 'success' | 'warning' | 'error'
19
13
 
20
- export interface ToastEventProps {
14
+ interface Toast {
15
+ id: number
16
+ message: string
21
17
  type: ToastType
22
- title: string
23
- description?: string
18
+ expiresAt: number
19
+ pausedAt?: number
20
+ exiting?: boolean
24
21
  }
25
22
 
26
- export interface ToastObserverProps {
27
- (event: ToastEventProps): void
23
+ interface ToastProps {
24
+ toast: Toast,
25
+ index: number,
26
+ expanded: boolean,
27
+ onRemove: () => void,
28
+ onHeight: (id: number, height: number) => void,
29
+ offset: number,
30
+ frontHeight: number
28
31
  }
29
32
 
30
33
  export type Language = 'no' | 'en'
31
34
 
32
35
  export default function LoginPage(props: LoginPageProps): JSX.Element;
33
- export default function Toaster(props: { toasts: ToastProps[] }): JSX.Element;
36
+ export default function Toaster(): JSX.Element;
37
+ export default function toast(message: string, type: ToastType, duration?: number): void;
34
38
  export default function LanguageToggle(props: { lang: Language }): JSX.Element;
35
39
  export default function ThemeSwitch(props: { className?: string }): JSX.Element;
36
40
  }