akanjs 3.0.0-alpha.104 → 3.0.0-alpha.105

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": "akanjs",
3
- "version": "3.0.0-alpha.104",
3
+ "version": "3.0.0-alpha.105",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -165,7 +165,8 @@ export class RscPagePrompts {
165
165
  const status = (error as { statusCode?: unknown } | null)?.statusCode;
166
166
  if (status === 401 || status === 403)
167
167
  return { ok: false, reason: "forbidden", message: `A query the screen makes refused the caller (${status}).` };
168
- if (status === 404) return { ok: false, reason: "not-found", message: "A document the screen reads does not exist." };
168
+ if (status === 404)
169
+ return { ok: false, reason: "not-found", message: "A document the screen reads does not exist." };
169
170
  RscPagePrompts.logger.warn(
170
171
  `page prompt body threw: ${error instanceof Error ? (error.stack ?? error.message) : error}`,
171
172
  );
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from "react";
2
+ export type ToastType = "info" | "success" | "error" | "warning" | "loading";
3
+ export interface ToastMessage {
4
+ key: string;
5
+ type: ToastType;
6
+ content: ReactNode;
7
+ /** Seconds the message is shown before the shell starts its exit. */
8
+ duration: number;
9
+ /** The shell started this message's exit: play the leave animation, then call `onClosed`. */
10
+ leaving: boolean;
11
+ }
12
+ export interface ToastItemProps {
13
+ className?: string;
14
+ message: ToastMessage;
15
+ /** Dismisses early — starts the same exit the timer would. */
16
+ onClose: () => void;
17
+ /** Takes the message off the screen. Call it when the exit animation has finished. */
18
+ onClosed: () => void;
19
+ }
20
+ export declare const DefaultToastItem: ({ className, message, onClose, onClosed }: ToastItemProps) => import("react/jsx-runtime").JSX.Element;
21
+ export interface ToastProps {
22
+ className?: string;
23
+ /** Oldest first, already capped by the store. A replacement showing fewer slices the list itself. */
24
+ messages: ToastMessage[];
25
+ /** The device's top safe area in pixels — the stack starts below it. */
26
+ topSafeArea: number;
27
+ /** Starts a message's exit, as the dismiss button and the elapsed timer both do. */
28
+ onClose: (key: string) => void;
29
+ /** Takes a message off the screen once its exit animation has finished. */
30
+ onClosed: (key: string) => void;
31
+ }
32
+ export declare const DefaultToast: ({ className, messages, topSafeArea, onClose, onClosed }: ToastProps) => import("react/jsx-runtime").JSX.Element;
33
+ /**
34
+ * The toast surface. `Toast` (the stack) and `Toast.Item` (one card) each resolve to a route-scoped override
35
+ * when a `page/**\/_overrides.tsx` in the route's ancestry declares one (slots `Toast`, `ToastItem`).
36
+ *
37
+ * `System`'s `Messages` renders this and is not a slot: it keeps the `msg.*` wiring, the store read, the
38
+ * body-level portal and the dismiss timers, so a replacement re-skins the surface without re-implementing
39
+ * when a toast appears and goes away.
40
+ */
41
+ export declare const Toast: import("react").ComponentType<ToastProps> & {
42
+ Item: import("react").ComponentType<ToastItemProps>;
43
+ };
@@ -29,6 +29,7 @@ import type { ItemProps as RadioItemProps, RadioProps } from "../Radio.d.ts";
29
29
  import type { BadgeVariants, ButtonVariants, InputSurfaceVariants } from "../recipe.d.ts";
30
30
  import type { SelectProps } from "../Select.d.ts";
31
31
  import type { TableProps } from "../Table.d.ts";
32
+ import type { ToastItemProps, ToastProps } from "../Toast.d.ts";
32
33
  import type { MultiProps as ToggleSelectMultiProps, ToggleSelectProps } from "../ToggleSelect.d.ts";
33
34
  import type { TooltipProps } from "../Tooltip.d.ts";
34
35
  import type { UnauthorizedProps } from "../Unauthorized.d.ts";
@@ -79,6 +80,8 @@ export interface AkanUiOverrides {
79
80
  DatePicker: ComponentType<DatePickerProps>;
80
81
  DatePickerRangePicker: ComponentType<RangePickerProps>;
81
82
  DatePickerTimePicker: ComponentType<TimePickerProps>;
83
+ Toast: ComponentType<ToastProps>;
84
+ ToastItem: ComponentType<ToastItemProps>;
82
85
  ToggleSelect: ComponentType<ToggleSelectProps<string | number | boolean | null>>;
83
86
  ToggleSelectMulti: ComponentType<ToggleSelectMultiProps>;
84
87
  LoadingSpin: ComponentType<SpinProps>;
@@ -67,6 +67,7 @@ export { Switch, type SwitchProps } from "./Switch.d.ts";
67
67
  export { System, type WebAppManifest } from "./System.d.ts";
68
68
  export { Tab } from "./Tab.d.ts";
69
69
  export { Table } from "./Table.d.ts";
70
+ export { DefaultToast, DefaultToastItem, Toast, type ToastItemProps, type ToastMessage, type ToastProps, type ToastType, } from "./Toast.d.ts";
70
71
  export { ToggleSelect } from "./ToggleSelect.d.ts";
71
72
  export { Tooltip, type TooltipProps } from "./Tooltip.d.ts";
72
73
  export { type AkanModalComponent, type AkanUiOverrideManifest, type AkanUiOverrideName, type AkanUiOverrides, type AkanUiRecipes, createOverridable, override, UiOverrideProvider, type UiOverrideProviderProps, useUiOverride, useUiRecipe, } from "./UiOverride.d.ts";
@@ -1,132 +1,91 @@
1
1
  "use client";
2
- import { cn, msg, usePage } from "akanjs/client";
2
+ import { msg, usePage } from "akanjs/client";
3
3
  import { st } from "akanjs/store";
4
- import { type ReactNode, useEffect, useState } from "react";
4
+ import { useEffect, useRef, useState } from "react";
5
5
  import { createPortal } from "react-dom";
6
- import {
7
- AiOutlineCheckCircle,
8
- AiOutlineClose,
9
- AiOutlineExclamationCircle,
10
- AiOutlineInfoCircle,
11
- AiOutlineLoading3Quarters,
12
- AiOutlineWarning,
13
- } from "react-icons/ai";
14
6
 
15
- type MessageType = "success" | "error" | "info" | "warning" | "loading";
7
+ import { Toast, type ToastMessage } from "../Toast";
16
8
 
17
- interface MessageProps {
18
- content: ReactNode;
19
- type?: MessageType;
20
- duration: number;
21
- keyForMessage: string;
22
- }
23
-
24
- interface TimeOutType {
25
- key: string;
26
- timeoutId: NodeJS.Timeout;
27
- }
28
9
  interface MsgOption {
29
10
  key?: string;
30
11
  duration?: number;
31
12
  data?: Record<string, string | number>;
32
13
  }
33
14
 
34
- const messageTone: { [key in MessageType]: { card: string; chip: string; icon: ReactNode } } = {
35
- info: { card: "border-info/35", chip: "bg-info/15 text-info", icon: <AiOutlineInfoCircle /> },
36
- success: { card: "border-success/35", chip: "bg-success/15 text-success", icon: <AiOutlineCheckCircle /> },
37
- warning: { card: "border-warning/35", chip: "bg-warning/15 text-warning", icon: <AiOutlineWarning /> },
38
- error: {
39
- card: "border-destructive/35",
40
- chip: "bg-destructive/15 text-destructive",
41
- icon: <AiOutlineExclamationCircle />,
42
- },
43
- loading: {
44
- card: "border-border",
45
- chip: "bg-muted text-foreground/60",
46
- icon: <AiOutlineLoading3Quarters className="animate-spin" />,
47
- },
48
- };
49
-
50
- let timeOuts: TimeOutType[] = [];
51
-
52
- const Message = ({ content, type = "info" as MessageType, duration, keyForMessage }: MessageProps) => {
53
- const [preBlind, setPreBlind] = useState(false);
54
- useEffect(() => {
55
- if (!content) return;
56
-
57
- const existingTimeOut = timeOuts.find((item) => item.key === keyForMessage);
58
- if (existingTimeOut) {
59
- clearTimeout(existingTimeOut.timeoutId);
60
- removeTimeOut(keyForMessage);
61
- }
62
-
63
- const timeoutId = setTimeout(() => {
64
- setPreBlind(true);
65
- }, duration * 1000);
66
- addTimeOut(keyForMessage, timeoutId);
67
-
68
- return () => {
69
- clearTimeout(timeoutId);
70
- };
71
- }, [content, keyForMessage, type]);
72
-
73
- useEffect(() => {
74
- if (!preBlind) return;
75
- setTimeout(() => {
76
- st.do.hideMessage(keyForMessage);
77
- removeTimeOut(keyForMessage);
78
- }, 100);
79
- }, [preBlind]);
80
-
81
- const addTimeOut = (key: string, timeoutId: NodeJS.Timeout) => {
82
- const filteredTimeOuts = timeOuts.filter((item) => item.key !== key);
83
- timeOuts = [...filteredTimeOuts, { key, timeoutId }];
84
- };
85
-
86
- const removeTimeOut = (key: string) => {
87
- timeOuts = timeOuts.filter((item) => item.key !== key);
88
- };
15
+ interface ArmedMessage {
16
+ message: Omit<ToastMessage, "leaving">;
17
+ timeoutId: ReturnType<typeof setTimeout>;
18
+ }
89
19
 
90
- const tone = messageTone[type];
91
- return (
92
- <div
93
- data-state={preBlind}
94
- className="pointer-events-auto w-full animate-fadeInDown15-150ms data-[state=true]:animate-smaller"
95
- >
96
- <div
97
- className={cn(
98
- "flex w-full items-start gap-3 rounded-box border bg-popover/95 p-3 text-popover-foreground shadow-lg backdrop-blur-sm",
99
- tone.card,
100
- )}
101
- >
102
- <div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full text-base", tone.chip)}>
103
- {tone.icon}
104
- </div>
105
- <span className="min-w-0 flex-1 self-center break-words text-sm leading-snug">{content}</span>
106
- <button
107
- aria-label="Dismiss"
108
- className="-mr-1 shrink-0 self-center rounded-full p-1 text-foreground/30 transition-colors hover:text-foreground/70"
109
- onClick={() => {
110
- setPreBlind(true);
111
- }}
112
- type="button"
113
- >
114
- <AiOutlineClose className="text-sm" />
115
- </button>
116
- </div>
117
- </div>
118
- );
119
- };
20
+ const exitGraceMs = 2000;
120
21
 
121
22
  export const Messages = () => {
122
23
  const messages = st.use.messages({ agent: false });
123
24
  const pageState = st.use.pageState({ agent: false });
124
25
  const { l } = usePage();
125
26
  const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
27
+ const [leavingKeys, setLeavingKeys] = useState<string[]>([]);
28
+ const timers = useRef(new Map<string, ArmedMessage>());
29
+
30
+ const dropMessage = (key: string) => {
31
+ const armed = timers.current.get(key);
32
+ if (armed) {
33
+ clearTimeout(armed.timeoutId);
34
+ timers.current.delete(key);
35
+ }
36
+ setLeavingKeys((keys) => keys.filter((leavingKey) => leavingKey !== key));
37
+ st.do.hideMessage(key);
38
+ };
39
+
40
+ const startLeaving = (key: string) => {
41
+ const armed = timers.current.get(key);
42
+ if (armed) {
43
+ clearTimeout(armed.timeoutId);
44
+ timers.current.set(key, { message: armed.message, timeoutId: setTimeout(() => dropMessage(key), exitGraceMs) });
45
+ }
46
+ setLeavingKeys((keys) => (keys.includes(key) ? keys : [...keys, key]));
47
+ };
48
+
126
49
  useEffect(() => {
127
50
  if (typeof document === "undefined") return;
128
51
  setPortalElement(document.body);
129
52
  }, []);
53
+
54
+ useEffect(() => {
55
+ const running = timers.current;
56
+ const rearmed: string[] = [];
57
+ for (const message of messages) {
58
+ const armed = running.get(message.key);
59
+
60
+ if (armed?.message === message) continue;
61
+ if (armed) {
62
+ clearTimeout(armed.timeoutId);
63
+ rearmed.push(message.key);
64
+ }
65
+ running.set(message.key, {
66
+ message,
67
+ timeoutId: setTimeout(() => startLeaving(message.key), message.duration * 1000),
68
+ });
69
+ }
70
+ for (const [key, armed] of [...running]) {
71
+ if (messages.some((message) => message.key === key)) continue;
72
+ clearTimeout(armed.timeoutId);
73
+ running.delete(key);
74
+ }
75
+ setLeavingKeys((keys) => {
76
+ const alive = keys.filter((key) => !rearmed.includes(key) && messages.some((message) => message.key === key));
77
+ return alive.length === keys.length ? keys : alive;
78
+ });
79
+ }, [messages]);
80
+
81
+ useEffect(() => {
82
+ const running = timers.current;
83
+ return () => {
84
+ for (const armed of running.values()) clearTimeout(armed.timeoutId);
85
+ running.clear();
86
+ };
87
+ }, []);
88
+
130
89
  useEffect(() => {
131
90
  Object.assign(msg, {
132
91
  info: (msgKey: `${string}.${string}`, option = {} as MsgOption) => {
@@ -172,23 +131,18 @@ export const Messages = () => {
172
131
  });
173
132
  }, []);
174
133
  if (!messages.length || !portalElement) return null;
134
+ const toastMessages: ToastMessage[] = messages.map((message) => ({
135
+ ...message,
136
+ leaving: leavingKeys.includes(message.key),
137
+ }));
175
138
 
176
139
  return createPortal(
177
- <div
178
- id="toast"
179
- className="pointer-events-none fixed top-0 left-1/2 z-[100] flex h-fit w-full max-w-md -translate-x-1/2 flex-col items-center gap-2 px-4 pt-3"
180
- style={{ marginTop: pageState.topSafeArea }}
181
- >
182
- {messages.map((message) => (
183
- <Message
184
- content={message.content}
185
- type={message.type}
186
- duration={message.duration}
187
- key={message.key}
188
- keyForMessage={message.key}
189
- />
190
- ))}
191
- </div>,
140
+ <Toast
141
+ messages={toastMessages}
142
+ topSafeArea={pageState.topSafeArea}
143
+ onClose={startLeaving}
144
+ onClosed={dropMessage}
145
+ />,
192
146
  portalElement,
193
147
  );
194
148
  };
package/ui/Toast.tsx ADDED
@@ -0,0 +1,132 @@
1
+ "use client";
2
+ import { cn } from "akanjs/client";
3
+ import type { ReactNode } from "react";
4
+ import {
5
+ AiOutlineCheckCircle,
6
+ AiOutlineClose,
7
+ AiOutlineExclamationCircle,
8
+ AiOutlineInfoCircle,
9
+ AiOutlineLoading3Quarters,
10
+ AiOutlineWarning,
11
+ } from "react-icons/ai";
12
+
13
+ import { createOverridable } from "./UiOverride";
14
+
15
+ export type ToastType = "info" | "success" | "error" | "warning" | "loading";
16
+
17
+ export interface ToastMessage {
18
+ key: string;
19
+ type: ToastType;
20
+ content: ReactNode;
21
+ /** Seconds the message is shown before the shell starts its exit. */
22
+ duration: number;
23
+ /** The shell started this message's exit: play the leave animation, then call `onClosed`. */
24
+ leaving: boolean;
25
+ }
26
+
27
+ const messageTone: { [key in ToastType]: { card: string; chip: string; icon: ReactNode } } = {
28
+ info: { card: "border-info/35", chip: "bg-info/15 text-info", icon: <AiOutlineInfoCircle /> },
29
+ success: { card: "border-success/35", chip: "bg-success/15 text-success", icon: <AiOutlineCheckCircle /> },
30
+ warning: { card: "border-warning/35", chip: "bg-warning/15 text-warning", icon: <AiOutlineWarning /> },
31
+ error: {
32
+ card: "border-destructive/35",
33
+ chip: "bg-destructive/15 text-destructive",
34
+ icon: <AiOutlineExclamationCircle />,
35
+ },
36
+ loading: {
37
+ card: "border-border",
38
+ chip: "bg-muted text-foreground/60",
39
+ icon: <AiOutlineLoading3Quarters className="animate-spin" />,
40
+ },
41
+ };
42
+
43
+ export interface ToastItemProps {
44
+ className?: string;
45
+ message: ToastMessage;
46
+ /** Dismisses early — starts the same exit the timer would. */
47
+ onClose: () => void;
48
+ /** Takes the message off the screen. Call it when the exit animation has finished. */
49
+ onClosed: () => void;
50
+ }
51
+
52
+ export const DefaultToastItem = ({ className, message, onClose, onClosed }: ToastItemProps) => {
53
+ const tone = messageTone[message.type];
54
+ return (
55
+ <div
56
+ data-state={message.leaving ? "out" : "in"}
57
+ onAnimationEnd={() => {
58
+ if (message.leaving) onClosed();
59
+ }}
60
+ className={cn(
61
+ "pointer-events-auto w-full data-[state=in]:animate-fadeInDown15-150ms data-[state=out]:animate-smaller",
62
+ className,
63
+ )}
64
+ >
65
+ <div
66
+ className={cn(
67
+ "flex w-full items-start gap-3 rounded-box border bg-popover/95 p-3 text-popover-foreground shadow-lg backdrop-blur-sm",
68
+ tone.card,
69
+ )}
70
+ >
71
+ <div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full text-base", tone.chip)}>
72
+ {tone.icon}
73
+ </div>
74
+ <span className="min-w-0 flex-1 self-center break-words text-sm leading-snug">{message.content}</span>
75
+ <button
76
+ aria-label="Dismiss"
77
+ className="-mr-1 shrink-0 self-center rounded-full p-1 text-foreground/30 transition-colors hover:text-foreground/70"
78
+ onClick={onClose}
79
+ type="button"
80
+ >
81
+ <AiOutlineClose className="text-sm" />
82
+ </button>
83
+ </div>
84
+ </div>
85
+ );
86
+ };
87
+
88
+ export interface ToastProps {
89
+ className?: string;
90
+ /** Oldest first, already capped by the store. A replacement showing fewer slices the list itself. */
91
+ messages: ToastMessage[];
92
+ /** The device's top safe area in pixels — the stack starts below it. */
93
+ topSafeArea: number;
94
+ /** Starts a message's exit, as the dismiss button and the elapsed timer both do. */
95
+ onClose: (key: string) => void;
96
+ /** Takes a message off the screen once its exit animation has finished. */
97
+ onClosed: (key: string) => void;
98
+ }
99
+
100
+ export const DefaultToast = ({ className, messages, topSafeArea, onClose, onClosed }: ToastProps) => (
101
+ <div
102
+ id="toast"
103
+ className={cn(
104
+ "pointer-events-none fixed top-0 left-1/2 z-[100] flex h-fit w-full max-w-md -translate-x-1/2 flex-col items-center gap-2 px-4 pt-3",
105
+ className,
106
+ )}
107
+ style={{ marginTop: topSafeArea }}
108
+ >
109
+ {messages.map((message) => (
110
+ <Toast.Item
111
+ key={message.key}
112
+ message={message}
113
+ onClose={() => onClose(message.key)}
114
+ onClosed={() => onClosed(message.key)}
115
+ />
116
+ ))}
117
+ </div>
118
+ );
119
+
120
+ const ToastBase = createOverridable("Toast", DefaultToast);
121
+
122
+ /**
123
+ * The toast surface. `Toast` (the stack) and `Toast.Item` (one card) each resolve to a route-scoped override
124
+ * when a `page/**\/_overrides.tsx` in the route's ancestry declares one (slots `Toast`, `ToastItem`).
125
+ *
126
+ * `System`'s `Messages` renders this and is not a slot: it keeps the `msg.*` wiring, the store read, the
127
+ * body-level portal and the dismiss timers, so a replacement re-skins the surface without re-implementing
128
+ * when a toast appears and goes away.
129
+ */
130
+ export const Toast = Object.assign(ToastBase, {
131
+ Item: createOverridable("ToastItem", DefaultToastItem),
132
+ });
@@ -31,6 +31,7 @@ import type { ItemProps as RadioItemProps, RadioProps } from "../Radio";
31
31
  import type { BadgeVariants, ButtonVariants, InputSurfaceVariants } from "../recipe";
32
32
  import type { SelectProps } from "../Select";
33
33
  import type { TableProps } from "../Table";
34
+ import type { ToastItemProps, ToastProps } from "../Toast";
34
35
  import type { MultiProps as ToggleSelectMultiProps, ToggleSelectProps } from "../ToggleSelect";
35
36
  import type { TooltipProps } from "../Tooltip";
36
37
  import type { UnauthorizedProps } from "../Unauthorized";
@@ -90,6 +91,9 @@ export interface AkanUiOverrides {
90
91
  DatePickerRangePicker: ComponentType<RangePickerProps>;
91
92
  DatePickerTimePicker: ComponentType<TimePickerProps>;
92
93
 
94
+ Toast: ComponentType<ToastProps>;
95
+ ToastItem: ComponentType<ToastItemProps>;
96
+
93
97
  ToggleSelect: ComponentType<ToggleSelectProps<string | number | boolean | null>>;
94
98
  ToggleSelectMulti: ComponentType<ToggleSelectMultiProps>;
95
99
 
package/ui/index.ts CHANGED
@@ -108,6 +108,15 @@ export { Switch, type SwitchProps } from "./Switch";
108
108
  export { System, type WebAppManifest } from "./System";
109
109
  export { Tab } from "./Tab";
110
110
  export { Table } from "./Table";
111
+ export {
112
+ DefaultToast,
113
+ DefaultToastItem,
114
+ Toast,
115
+ type ToastItemProps,
116
+ type ToastMessage,
117
+ type ToastProps,
118
+ type ToastType,
119
+ } from "./Toast";
111
120
  export { ToggleSelect } from "./ToggleSelect";
112
121
  export { Tooltip, type TooltipProps } from "./Tooltip";
113
122