@emara/ui 1.1.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.
Files changed (218) hide show
  1. package/components/ui/.gitkeep +0 -0
  2. package/components/ui/accordion.stories.tsx +231 -0
  3. package/components/ui/accordion.tsx +250 -0
  4. package/components/ui/app-shell.stories.tsx +270 -0
  5. package/components/ui/app-shell.tsx +491 -0
  6. package/components/ui/avatar.stories.tsx +174 -0
  7. package/components/ui/avatar.tsx +257 -0
  8. package/components/ui/badge.stories.tsx +127 -0
  9. package/components/ui/badge.tsx +146 -0
  10. package/components/ui/breadcrumb.stories.tsx +92 -0
  11. package/components/ui/breadcrumb.tsx +302 -0
  12. package/components/ui/button.stories.tsx +186 -0
  13. package/components/ui/button.tsx +128 -0
  14. package/components/ui/card.stories.tsx +279 -0
  15. package/components/ui/card.tsx +250 -0
  16. package/components/ui/checkbox.stories.tsx +93 -0
  17. package/components/ui/checkbox.tsx +131 -0
  18. package/components/ui/combobox.stories.tsx +489 -0
  19. package/components/ui/combobox.tsx +874 -0
  20. package/components/ui/context-menu.stories.tsx +202 -0
  21. package/components/ui/context-menu.tsx +309 -0
  22. package/components/ui/data-table.stories.tsx +227 -0
  23. package/components/ui/data-table.tsx +539 -0
  24. package/components/ui/date-picker.stories.tsx +225 -0
  25. package/components/ui/date-picker.tsx +597 -0
  26. package/components/ui/dialog.stories.tsx +193 -0
  27. package/components/ui/dialog.tsx +262 -0
  28. package/components/ui/divider.stories.tsx +84 -0
  29. package/components/ui/divider.tsx +135 -0
  30. package/components/ui/drawer.stories.tsx +218 -0
  31. package/components/ui/drawer.tsx +329 -0
  32. package/components/ui/dropdown-menu.stories.tsx +270 -0
  33. package/components/ui/dropdown-menu.tsx +353 -0
  34. package/components/ui/empty-state.stories.tsx +121 -0
  35. package/components/ui/empty-state.tsx +289 -0
  36. package/components/ui/field-group.stories.tsx +201 -0
  37. package/components/ui/field-group.tsx +276 -0
  38. package/components/ui/form.stories.tsx +219 -0
  39. package/components/ui/form.tsx +542 -0
  40. package/components/ui/input.stories.tsx +154 -0
  41. package/components/ui/input.tsx +208 -0
  42. package/components/ui/label.stories.tsx +84 -0
  43. package/components/ui/label.tsx +98 -0
  44. package/components/ui/page-header.stories.tsx +136 -0
  45. package/components/ui/page-header.tsx +315 -0
  46. package/components/ui/pagination.stories.tsx +136 -0
  47. package/components/ui/pagination.tsx +427 -0
  48. package/components/ui/popover.stories.tsx +212 -0
  49. package/components/ui/popover.tsx +167 -0
  50. package/components/ui/radio-group.stories.tsx +96 -0
  51. package/components/ui/radio-group.tsx +250 -0
  52. package/components/ui/select.stories.tsx +203 -0
  53. package/components/ui/select.tsx +318 -0
  54. package/components/ui/sidebar.stories.tsx +186 -0
  55. package/components/ui/sidebar.tsx +623 -0
  56. package/components/ui/skeleton.stories.tsx +131 -0
  57. package/components/ui/skeleton.tsx +311 -0
  58. package/components/ui/switch.stories.tsx +74 -0
  59. package/components/ui/switch.tsx +186 -0
  60. package/components/ui/table.stories.tsx +107 -0
  61. package/components/ui/table.tsx +285 -0
  62. package/components/ui/tabs.stories.tsx +222 -0
  63. package/components/ui/tabs.tsx +287 -0
  64. package/components/ui/textarea.stories.tsx +96 -0
  65. package/components/ui/textarea.tsx +182 -0
  66. package/components/ui/toast.stories.tsx +169 -0
  67. package/components/ui/toast.tsx +250 -0
  68. package/components/ui/tooltip.stories.tsx +146 -0
  69. package/components/ui/tooltip.tsx +156 -0
  70. package/components/ui/top-bar.stories.tsx +182 -0
  71. package/components/ui/top-bar.tsx +155 -0
  72. package/dist/components/ui/accordion.d.ts +45 -0
  73. package/dist/components/ui/accordion.d.ts.map +1 -0
  74. package/dist/components/ui/accordion.js +99 -0
  75. package/dist/components/ui/accordion.js.map +1 -0
  76. package/dist/components/ui/app-shell.d.ts +70 -0
  77. package/dist/components/ui/app-shell.d.ts.map +1 -0
  78. package/dist/components/ui/app-shell.js +199 -0
  79. package/dist/components/ui/app-shell.js.map +1 -0
  80. package/dist/components/ui/avatar.d.ts +41 -0
  81. package/dist/components/ui/avatar.d.ts.map +1 -0
  82. package/dist/components/ui/avatar.js +104 -0
  83. package/dist/components/ui/avatar.js.map +1 -0
  84. package/dist/components/ui/badge.d.ts +27 -0
  85. package/dist/components/ui/badge.d.ts.map +1 -0
  86. package/dist/components/ui/badge.js +65 -0
  87. package/dist/components/ui/badge.js.map +1 -0
  88. package/dist/components/ui/breadcrumb.d.ts +35 -0
  89. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  90. package/dist/components/ui/breadcrumb.js +88 -0
  91. package/dist/components/ui/breadcrumb.js.map +1 -0
  92. package/dist/components/ui/button.d.ts +26 -0
  93. package/dist/components/ui/button.d.ts.map +1 -0
  94. package/dist/components/ui/button.js +73 -0
  95. package/dist/components/ui/button.js.map +1 -0
  96. package/dist/components/ui/card.d.ts +52 -0
  97. package/dist/components/ui/card.d.ts.map +1 -0
  98. package/dist/components/ui/card.js +96 -0
  99. package/dist/components/ui/card.js.map +1 -0
  100. package/dist/components/ui/checkbox.d.ts +18 -0
  101. package/dist/components/ui/checkbox.d.ts.map +1 -0
  102. package/dist/components/ui/checkbox.js +59 -0
  103. package/dist/components/ui/checkbox.js.map +1 -0
  104. package/dist/components/ui/combobox.d.ts +194 -0
  105. package/dist/components/ui/combobox.d.ts.map +1 -0
  106. package/dist/components/ui/combobox.js +361 -0
  107. package/dist/components/ui/combobox.js.map +1 -0
  108. package/dist/components/ui/context-menu.d.ts +46 -0
  109. package/dist/components/ui/context-menu.d.ts.map +1 -0
  110. package/dist/components/ui/context-menu.js +95 -0
  111. package/dist/components/ui/context-menu.js.map +1 -0
  112. package/dist/components/ui/data-table.d.ts +53 -0
  113. package/dist/components/ui/data-table.d.ts.map +1 -0
  114. package/dist/components/ui/data-table.js +163 -0
  115. package/dist/components/ui/data-table.js.map +1 -0
  116. package/dist/components/ui/date-picker.d.ts +103 -0
  117. package/dist/components/ui/date-picker.d.ts.map +1 -0
  118. package/dist/components/ui/date-picker.js +306 -0
  119. package/dist/components/ui/date-picker.js.map +1 -0
  120. package/dist/components/ui/dialog.d.ts +40 -0
  121. package/dist/components/ui/dialog.d.ts.map +1 -0
  122. package/dist/components/ui/dialog.js +110 -0
  123. package/dist/components/ui/dialog.js.map +1 -0
  124. package/dist/components/ui/divider.d.ts +30 -0
  125. package/dist/components/ui/divider.d.ts.map +1 -0
  126. package/dist/components/ui/divider.js +62 -0
  127. package/dist/components/ui/divider.js.map +1 -0
  128. package/dist/components/ui/drawer.d.ts +56 -0
  129. package/dist/components/ui/drawer.d.ts.map +1 -0
  130. package/dist/components/ui/drawer.js +147 -0
  131. package/dist/components/ui/drawer.js.map +1 -0
  132. package/dist/components/ui/dropdown-menu.d.ts +63 -0
  133. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  134. package/dist/components/ui/dropdown-menu.js +116 -0
  135. package/dist/components/ui/dropdown-menu.js.map +1 -0
  136. package/dist/components/ui/empty-state.d.ts +43 -0
  137. package/dist/components/ui/empty-state.d.ts.map +1 -0
  138. package/dist/components/ui/empty-state.js +128 -0
  139. package/dist/components/ui/empty-state.js.map +1 -0
  140. package/dist/components/ui/field-group.d.ts +38 -0
  141. package/dist/components/ui/field-group.d.ts.map +1 -0
  142. package/dist/components/ui/field-group.js +107 -0
  143. package/dist/components/ui/field-group.js.map +1 -0
  144. package/dist/components/ui/form.d.ts +67 -0
  145. package/dist/components/ui/form.d.ts.map +1 -0
  146. package/dist/components/ui/form.js +286 -0
  147. package/dist/components/ui/form.js.map +1 -0
  148. package/dist/components/ui/input.d.ts +36 -0
  149. package/dist/components/ui/input.d.ts.map +1 -0
  150. package/dist/components/ui/input.js +99 -0
  151. package/dist/components/ui/input.js.map +1 -0
  152. package/dist/components/ui/label.d.ts +37 -0
  153. package/dist/components/ui/label.d.ts.map +1 -0
  154. package/dist/components/ui/label.js +34 -0
  155. package/dist/components/ui/label.js.map +1 -0
  156. package/dist/components/ui/page-header.d.ts +65 -0
  157. package/dist/components/ui/page-header.d.ts.map +1 -0
  158. package/dist/components/ui/page-header.js +140 -0
  159. package/dist/components/ui/page-header.js.map +1 -0
  160. package/dist/components/ui/pagination.d.ts +67 -0
  161. package/dist/components/ui/pagination.d.ts.map +1 -0
  162. package/dist/components/ui/pagination.js +109 -0
  163. package/dist/components/ui/pagination.js.map +1 -0
  164. package/dist/components/ui/popover.d.ts +28 -0
  165. package/dist/components/ui/popover.d.ts.map +1 -0
  166. package/dist/components/ui/popover.js +85 -0
  167. package/dist/components/ui/popover.js.map +1 -0
  168. package/dist/components/ui/radio-group.d.ts +35 -0
  169. package/dist/components/ui/radio-group.d.ts.map +1 -0
  170. package/dist/components/ui/radio-group.js +103 -0
  171. package/dist/components/ui/radio-group.js.map +1 -0
  172. package/dist/components/ui/select.d.ts +42 -0
  173. package/dist/components/ui/select.d.ts.map +1 -0
  174. package/dist/components/ui/select.js +86 -0
  175. package/dist/components/ui/select.js.map +1 -0
  176. package/dist/components/ui/sidebar.d.ts +59 -0
  177. package/dist/components/ui/sidebar.d.ts.map +1 -0
  178. package/dist/components/ui/sidebar.js +189 -0
  179. package/dist/components/ui/sidebar.js.map +1 -0
  180. package/dist/components/ui/skeleton.d.ts +77 -0
  181. package/dist/components/ui/skeleton.d.ts.map +1 -0
  182. package/dist/components/ui/skeleton.js +115 -0
  183. package/dist/components/ui/skeleton.js.map +1 -0
  184. package/dist/components/ui/switch.d.ts +26 -0
  185. package/dist/components/ui/switch.d.ts.map +1 -0
  186. package/dist/components/ui/switch.js +84 -0
  187. package/dist/components/ui/switch.js.map +1 -0
  188. package/dist/components/ui/table.d.ts +52 -0
  189. package/dist/components/ui/table.d.ts.map +1 -0
  190. package/dist/components/ui/table.js +109 -0
  191. package/dist/components/ui/table.js.map +1 -0
  192. package/dist/components/ui/tabs.d.ts +42 -0
  193. package/dist/components/ui/tabs.d.ts.map +1 -0
  194. package/dist/components/ui/tabs.js +163 -0
  195. package/dist/components/ui/tabs.js.map +1 -0
  196. package/dist/components/ui/textarea.d.ts +26 -0
  197. package/dist/components/ui/textarea.d.ts.map +1 -0
  198. package/dist/components/ui/textarea.js +96 -0
  199. package/dist/components/ui/textarea.js.map +1 -0
  200. package/dist/components/ui/toast.d.ts +77 -0
  201. package/dist/components/ui/toast.d.ts.map +1 -0
  202. package/dist/components/ui/toast.js +141 -0
  203. package/dist/components/ui/toast.js.map +1 -0
  204. package/dist/components/ui/tooltip.d.ts +31 -0
  205. package/dist/components/ui/tooltip.d.ts.map +1 -0
  206. package/dist/components/ui/tooltip.js +71 -0
  207. package/dist/components/ui/tooltip.js.map +1 -0
  208. package/dist/components/ui/top-bar.d.ts +30 -0
  209. package/dist/components/ui/top-bar.d.ts.map +1 -0
  210. package/dist/components/ui/top-bar.js +64 -0
  211. package/dist/components/ui/top-bar.js.map +1 -0
  212. package/dist/lib/utils.d.ts +3 -0
  213. package/dist/lib/utils.d.ts.map +1 -0
  214. package/dist/lib/utils.js +6 -0
  215. package/dist/lib/utils.js.map +1 -0
  216. package/lib/utils.ts +6 -0
  217. package/package.json +112 -0
  218. package/styles/globals.css +685 -0
@@ -0,0 +1,169 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { Button } from "./button";
4
+ import { Toaster, toast } from "./toast";
5
+
6
+ const meta: Meta = {
7
+ title: "Overlays/Toast",
8
+ parameters: { layout: "centered" },
9
+ decorators: [
10
+ (Story) => (
11
+ <div>
12
+ <Story />
13
+ <Toaster />
14
+ </div>
15
+ ),
16
+ ],
17
+ };
18
+
19
+ export default meta;
20
+ type Story = StoryObj;
21
+
22
+ export const Default: Story = {
23
+ render: () => (
24
+ <Button onClick={() => toast("Saved your changes")}>Show toast</Button>
25
+ ),
26
+ };
27
+
28
+ export const Variants: Story = {
29
+ render: () => (
30
+ <div className="flex flex-wrap gap-2">
31
+ <Button onClick={() => toast("Plain notification")}>default</Button>
32
+ <Button
33
+ variant="success"
34
+ onClick={() => toast.success("Item saved", { description: "Saved 2 seconds ago." })}
35
+ >
36
+ success
37
+ </Button>
38
+ <Button
39
+ variant="warning"
40
+ onClick={() => toast.warning("Storage nearly full")}
41
+ >
42
+ warning
43
+ </Button>
44
+ <Button variant="info" onClick={() => toast.info("New release available")}>
45
+ info
46
+ </Button>
47
+ <Button
48
+ variant="destructive"
49
+ onClick={() => toast.error("Couldn't save — try again", { description: "Network error." })}
50
+ >
51
+ error
52
+ </Button>
53
+ <Button variant="outline" onClick={() => toast.loading("Saving…")}>
54
+ loading
55
+ </Button>
56
+ </div>
57
+ ),
58
+ };
59
+
60
+ export const WithAction: Story = {
61
+ render: () => (
62
+ <Button
63
+ onClick={() =>
64
+ toast("Email archived", {
65
+ description: "Moved to All Mail.",
66
+ action: { label: "Undo", onClick: () => toast.success("Restored") },
67
+ })
68
+ }
69
+ >
70
+ Toast with action
71
+ </Button>
72
+ ),
73
+ };
74
+
75
+ export const PromiseToast: Story = {
76
+ name: "Promise",
77
+ render: () => (
78
+ <Button
79
+ onClick={() => {
80
+ const p = new globalThis.Promise<{ id: string }>((resolve, reject) => {
81
+ setTimeout(() => {
82
+ if (Math.random() > 0.3) resolve({ id: "42" });
83
+ else reject(new Error("Network timeout"));
84
+ }, 1500);
85
+ });
86
+ toast.promise(p, {
87
+ loading: "Saving order…",
88
+ success: (data) => `Order #${data.id} placed`,
89
+ error: (err) => `Failed: ${(err as Error).message}`,
90
+ });
91
+ }}
92
+ >
93
+ Run promise
94
+ </Button>
95
+ ),
96
+ };
97
+
98
+ export const Dismissible: Story = {
99
+ render: () => (
100
+ <div className="flex flex-wrap gap-2">
101
+ <Button onClick={() => toast.info("Click the X to dismiss me")}>Default</Button>
102
+ <Button
103
+ variant="outline"
104
+ onClick={() =>
105
+ toast.info("Sticky — explicitly dismissible=false", { dismissible: false })
106
+ }
107
+ >
108
+ Sticky
109
+ </Button>
110
+ <Button variant="ghost" onClick={() => toast.dismiss()}>
111
+ Dismiss all
112
+ </Button>
113
+ </div>
114
+ ),
115
+ };
116
+
117
+ export const Positions: Story = {
118
+ decorators: [],
119
+ render: () => (
120
+ <div className="flex flex-wrap gap-2">
121
+ {(
122
+ [
123
+ "top-start",
124
+ "top-center",
125
+ "top-end",
126
+ "bottom-start",
127
+ "bottom-center",
128
+ "bottom-end",
129
+ ] as const
130
+ ).map((pos) => (
131
+ <div key={pos} className="rounded-md border border-border p-3 text-sm">
132
+ <p className="mb-2 text-xs text-muted-foreground">{pos}</p>
133
+ <Toaster position={pos} />
134
+ <Button
135
+ size="sm"
136
+ variant="outline"
137
+ onClick={() => toast.info(`Toast at ${pos}`)}
138
+ >
139
+ Show
140
+ </Button>
141
+ </div>
142
+ ))}
143
+ </div>
144
+ ),
145
+ };
146
+
147
+ export const RichColors: Story = {
148
+ decorators: [
149
+ (Story) => (
150
+ <div>
151
+ <Story />
152
+ <Toaster richColors />
153
+ </div>
154
+ ),
155
+ ],
156
+ render: () => (
157
+ <div className="flex flex-wrap gap-2">
158
+ <Button variant="success" onClick={() => toast.success("Looks great")}>
159
+ success
160
+ </Button>
161
+ <Button variant="destructive" onClick={() => toast.error("Looks worse")}>
162
+ error
163
+ </Button>
164
+ <Button variant="warning" onClick={() => toast.warning("Pay attention")}>
165
+ warning
166
+ </Button>
167
+ </div>
168
+ ),
169
+ };
@@ -0,0 +1,250 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import {
5
+ RiCheckLine,
6
+ RiCloseCircleLine,
7
+ RiErrorWarningLine,
8
+ RiInformationLine,
9
+ RiLoader2Line,
10
+ } from "@remixicon/react";
11
+ import {
12
+ Toaster as SonnerToaster,
13
+ toast as sonnerToast,
14
+ type ToasterProps as SonnerToasterProps,
15
+ } from "sonner";
16
+
17
+ import { cn } from "@/lib/utils";
18
+
19
+ // Per docs/emara-ui-phase-3-components.md §7. Wraps sonner with Emara
20
+ // variants, RTL-aware positions, and per-toast options.
21
+
22
+ // ----------------------------------------------------------------------------
23
+ // Toaster
24
+ // ----------------------------------------------------------------------------
25
+
26
+ type LogicalPosition =
27
+ | "top-start"
28
+ | "top-end"
29
+ | "top-center"
30
+ | "bottom-start"
31
+ | "bottom-end"
32
+ | "bottom-center";
33
+
34
+ const positionToSonner: Record<LogicalPosition, NonNullable<SonnerToasterProps["position"]>> = {
35
+ "top-start": "top-left",
36
+ "top-end": "top-right",
37
+ "top-center": "top-center",
38
+ "bottom-start": "bottom-left",
39
+ "bottom-end": "bottom-right",
40
+ "bottom-center": "bottom-center",
41
+ };
42
+
43
+ /**
44
+ * Sonner uses `left`/`right` physical sides for positions. In RTL,
45
+ * `top-start` should mean visually top-right, and `top-end` should mean
46
+ * top-left. We resolve at runtime based on `<html dir>`.
47
+ */
48
+ function resolveDir(): "ltr" | "rtl" {
49
+ if (typeof document === "undefined") return "ltr";
50
+ return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
51
+ }
52
+
53
+ function resolvePosition(p: LogicalPosition): NonNullable<SonnerToasterProps["position"]> {
54
+ const dir = resolveDir();
55
+ if (dir === "rtl") {
56
+ if (p === "top-start") return "top-right";
57
+ if (p === "top-end") return "top-left";
58
+ if (p === "bottom-start") return "bottom-right";
59
+ if (p === "bottom-end") return "bottom-left";
60
+ }
61
+ return positionToSonner[p];
62
+ }
63
+
64
+ type ToasterProps = Omit<SonnerToasterProps, "position"> & {
65
+ position?: LogicalPosition;
66
+ // ---- Emara additions ----
67
+ /**
68
+ * Whether hovering a toast pauses its dismiss timer. Sonner's default
69
+ * is `true`; that matches our spec default. **Known limitation:**
70
+ * Sonner doesn't expose a Toaster-level override today, so flipping
71
+ * this prop to `false` is currently a no-op. Tracked for v1.x.
72
+ */
73
+ pauseOnHover?: boolean;
74
+ /**
75
+ * Whether clicking a toast dismisses it. Spec default `false` matches
76
+ * Sonner's default (click is inert). **Known limitation:** same as
77
+ * `pauseOnHover` — flipping to `true` is currently a no-op.
78
+ */
79
+ closeOnClick?: boolean;
80
+ /**
81
+ * Whether touch-swipe gestures dismiss a toast. Sonner's default is
82
+ * `true` on touch, matching spec. **Known limitation:** flipping
83
+ * to `false` is currently a no-op.
84
+ */
85
+ swipeToDismiss?: boolean;
86
+ /** Cap on concurrent visible toasts. Maps to Sonner's `visibleToasts`. */
87
+ maxVisible?: number;
88
+ /** Default dismiss timer in ms. */
89
+ defaultDuration?: number;
90
+ };
91
+
92
+ function Toaster({
93
+ position = "bottom-end",
94
+ pauseOnHover = true,
95
+ closeOnClick = false,
96
+ swipeToDismiss = true,
97
+ maxVisible,
98
+ defaultDuration = 4000,
99
+ expand,
100
+ gap,
101
+ richColors,
102
+ className,
103
+ toastOptions,
104
+ ...rest
105
+ }: ToasterProps) {
106
+ // Sonner already handles pause-on-hover, no-close-on-click, and swipe on
107
+ // touch natively. These props are accepted in the public API for
108
+ // forward-compatibility but currently have no Sonner override. The voids
109
+ // declare intent without leaking the props through `...rest`.
110
+ void pauseOnHover;
111
+ void closeOnClick;
112
+ void swipeToDismiss;
113
+ // Re-resolve position on dir change. Sonner's `position` is set once at
114
+ // mount, but consumers may flip dir at runtime — re-mount the Toaster by
115
+ // keying on the resolved position.
116
+ const [resolved, setResolved] = useState(() => resolvePosition(position));
117
+
118
+ useEffect(() => {
119
+ setResolved(resolvePosition(position));
120
+ if (typeof document === "undefined") return;
121
+ const obs = new MutationObserver(() => setResolved(resolvePosition(position)));
122
+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ["dir"] });
123
+ return () => obs.disconnect();
124
+ }, [position]);
125
+
126
+ // exactOptionalPropertyTypes: don't forward optional props with `undefined`.
127
+ const passthrough: Partial<SonnerToasterProps> = {};
128
+ if (expand !== undefined) passthrough.expand = expand;
129
+ if (gap !== undefined) passthrough.gap = gap;
130
+ if (richColors !== undefined) passthrough.richColors = richColors;
131
+ if (maxVisible !== undefined) passthrough.visibleToasts = maxVisible;
132
+
133
+ return (
134
+ <SonnerToaster
135
+ key={resolved}
136
+ position={resolved}
137
+ duration={defaultDuration}
138
+ closeButton
139
+ // Anchor sonner's stack layer to the design-token --z-toast (1700) so
140
+ // toasts always sit above tooltips, popovers, and modals.
141
+ style={{ zIndex: "var(--z-toast)" } as React.CSSProperties}
142
+ // Emara-tinted defaults — colors track the active theme.
143
+ toastOptions={{
144
+ ...toastOptions,
145
+ className: cn(
146
+ "border border-border bg-popover text-popover-foreground shadow-md",
147
+ (toastOptions as { className?: string } | undefined)?.className,
148
+ ),
149
+ }}
150
+ className={cn(className)}
151
+ {...passthrough}
152
+ {...rest}
153
+ />
154
+ );
155
+ }
156
+
157
+ // ----------------------------------------------------------------------------
158
+ // toast() API — variant-keyed shortcuts + promise helper.
159
+ // ----------------------------------------------------------------------------
160
+
161
+ type EmaraToastOptions = {
162
+ id?: string | number;
163
+ description?: React.ReactNode;
164
+ duration?: number;
165
+ icon?: React.ReactNode;
166
+ action?: { label: string; onClick: () => void };
167
+ cancel?: { label: string; onClick?: () => void };
168
+ /**
169
+ * Show a countdown progress bar. Sonner's underlying option is `closeButton`
170
+ * and built-in progress; we expose the spec name.
171
+ */
172
+ progress?: boolean;
173
+ dismissible?: boolean;
174
+ };
175
+
176
+ function variantIcon(variant: "default" | "success" | "warning" | "info" | "error" | "loading") {
177
+ switch (variant) {
178
+ case "success":
179
+ return <RiCheckLine className="text-success size-4" />;
180
+ case "warning":
181
+ return <RiErrorWarningLine className="text-warning size-4" />;
182
+ case "info":
183
+ return <RiInformationLine className="text-info size-4" />;
184
+ case "error":
185
+ return <RiCloseCircleLine className="text-destructive size-4" />;
186
+ case "loading":
187
+ return <RiLoader2Line className="size-4 animate-spin" />;
188
+ default:
189
+ return undefined;
190
+ }
191
+ }
192
+
193
+ function toSonnerOptions(
194
+ options?: EmaraToastOptions,
195
+ variant?: "default" | "success" | "warning" | "info" | "error" | "loading",
196
+ ) {
197
+ if (!options && !variant) return undefined;
198
+ const out: Record<string, unknown> = {};
199
+ if (options?.id !== undefined) out.id = options.id;
200
+ if (options?.description !== undefined) out.description = options.description;
201
+ if (options?.duration !== undefined) out.duration = options.duration;
202
+ if (options?.action) out.action = options.action;
203
+ if (options?.cancel) out.cancel = options.cancel;
204
+ if (options?.dismissible !== undefined) out.dismissible = options.dismissible;
205
+ if (options?.icon !== undefined) {
206
+ out.icon = options.icon;
207
+ } else if (variant && variant !== "default") {
208
+ out.icon = variantIcon(variant);
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * `toast(message, options)` and its variant-shortcut siblings. Wraps sonner's
215
+ * imperative API so consumers don't import sonner directly.
216
+ */
217
+ function toastBase(message: React.ReactNode, options?: EmaraToastOptions) {
218
+ return sonnerToast(message as string, toSonnerOptions(options, "default"));
219
+ }
220
+
221
+ const toast = Object.assign(toastBase, {
222
+ success: (message: React.ReactNode, options?: EmaraToastOptions) =>
223
+ sonnerToast.success(message as string, toSonnerOptions(options, "success")),
224
+ warning: (message: React.ReactNode, options?: EmaraToastOptions) =>
225
+ sonnerToast.warning(message as string, toSonnerOptions(options, "warning")),
226
+ info: (message: React.ReactNode, options?: EmaraToastOptions) =>
227
+ sonnerToast.info(message as string, toSonnerOptions(options, "info")),
228
+ error: (message: React.ReactNode, options?: EmaraToastOptions) =>
229
+ sonnerToast.error(message as string, toSonnerOptions(options, "error")),
230
+ loading: (message: React.ReactNode, options?: EmaraToastOptions) =>
231
+ sonnerToast.loading(message as string, toSonnerOptions(options, "loading")),
232
+ promise: <T,>(
233
+ promise: Promise<T> | (() => Promise<T>),
234
+ msgs: {
235
+ loading: React.ReactNode;
236
+ success: React.ReactNode | ((data: T) => React.ReactNode);
237
+ error: React.ReactNode | ((err: unknown) => React.ReactNode);
238
+ },
239
+ ) =>
240
+ sonnerToast.promise(promise, {
241
+ loading: msgs.loading as string,
242
+ success: msgs.success as (data: T) => string,
243
+ error: msgs.error as (err: unknown) => string,
244
+ }),
245
+ dismiss: (id?: string | number) => sonnerToast.dismiss(id),
246
+ custom: sonnerToast.custom,
247
+ });
248
+
249
+ export { Toaster, toast };
250
+ export type { ToasterProps, EmaraToastOptions, LogicalPosition };
@@ -0,0 +1,146 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RiInformationLine } from "@remixicon/react";
3
+
4
+ import { Button } from "./button";
5
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
6
+
7
+ const meta: Meta = {
8
+ title: "Foundations/Tooltip",
9
+ decorators: [
10
+ (Story) => (
11
+ <TooltipProvider delayDuration={200}>
12
+ <Story />
13
+ </TooltipProvider>
14
+ ),
15
+ ],
16
+ parameters: { layout: "centered" },
17
+ };
18
+
19
+ export default meta;
20
+ type Story = StoryObj;
21
+
22
+ export const Default: Story = {
23
+ render: () => (
24
+ <Tooltip>
25
+ <TooltipTrigger asChild>
26
+ <Button variant="outline">Hover me</Button>
27
+ </TooltipTrigger>
28
+ <TooltipContent>Add to your library</TooltipContent>
29
+ </Tooltip>
30
+ ),
31
+ };
32
+
33
+ export const Variants: Story = {
34
+ render: () => (
35
+ <div className="flex gap-3">
36
+ <Tooltip>
37
+ <TooltipTrigger asChild>
38
+ <Button variant="outline" size="sm">
39
+ default
40
+ </Button>
41
+ </TooltipTrigger>
42
+ <TooltipContent variant="default">Dark bubble</TooltipContent>
43
+ </Tooltip>
44
+ <Tooltip>
45
+ <TooltipTrigger asChild>
46
+ <Button variant="outline" size="sm">
47
+ inverse
48
+ </Button>
49
+ </TooltipTrigger>
50
+ <TooltipContent variant="inverse">Light bubble</TooltipContent>
51
+ </Tooltip>
52
+ <Tooltip>
53
+ <TooltipTrigger asChild>
54
+ <Button variant="outline" size="sm">
55
+ kbd
56
+ </Button>
57
+ </TooltipTrigger>
58
+ <TooltipContent kbd={["⌘", "K"]}>Search</TooltipContent>
59
+ </Tooltip>
60
+ </div>
61
+ ),
62
+ };
63
+
64
+ export const Sides: Story = {
65
+ render: () => (
66
+ <div className="grid grid-cols-2 gap-8">
67
+ {(["top", "right", "bottom", "left"] as const).map((side) => (
68
+ <Tooltip key={side}>
69
+ <TooltipTrigger asChild>
70
+ <Button variant="outline" size="sm">
71
+ {side}
72
+ </Button>
73
+ </TooltipTrigger>
74
+ <TooltipContent side={side}>side={side}</TooltipContent>
75
+ </Tooltip>
76
+ ))}
77
+ </div>
78
+ ),
79
+ };
80
+
81
+ export const Multiline: Story = {
82
+ render: () => (
83
+ <Tooltip>
84
+ <TooltipTrigger asChild>
85
+ <Button variant="ghost" size="icon-sm" aria-label="More info">
86
+ <RiInformationLine />
87
+ </Button>
88
+ </TooltipTrigger>
89
+ <TooltipContent multiline maxWidth={220}>
90
+ This tooltip wraps over multiple lines so longer help text remains readable.
91
+ </TooltipContent>
92
+ </Tooltip>
93
+ ),
94
+ };
95
+
96
+ export const KeyboardShortcut: Story = {
97
+ render: () => (
98
+ <div className="flex flex-wrap items-center gap-3">
99
+ <Tooltip>
100
+ <TooltipTrigger asChild>
101
+ <Button>Save</Button>
102
+ </TooltipTrigger>
103
+ <TooltipContent kbd={["⌘", "S"]} />
104
+ </Tooltip>
105
+ <Tooltip>
106
+ <TooltipTrigger asChild>
107
+ <Button variant="outline">Open command palette</Button>
108
+ </TooltipTrigger>
109
+ <TooltipContent kbd={["⌘", "K"]}>Command palette</TooltipContent>
110
+ </Tooltip>
111
+ <Tooltip>
112
+ <TooltipTrigger asChild>
113
+ <Button variant="outline">Undo</Button>
114
+ </TooltipTrigger>
115
+ <TooltipContent kbd={["⌘", "Z"]}>Undo last action</TooltipContent>
116
+ </Tooltip>
117
+ </div>
118
+ ),
119
+ };
120
+
121
+ export const NoArrow: Story = {
122
+ render: () => (
123
+ <Tooltip>
124
+ <TooltipTrigger asChild>
125
+ <Button variant="outline">No arrow</Button>
126
+ </TooltipTrigger>
127
+ <TooltipContent arrow={false}>Plain bubble</TooltipContent>
128
+ </Tooltip>
129
+ ),
130
+ };
131
+
132
+ export const OpensOnFocus: Story = {
133
+ render: () => (
134
+ <div className="text-muted-foreground text-sm">
135
+ Tab to the button below — the tooltip should open on focus too.
136
+ <div className="mt-3">
137
+ <Tooltip>
138
+ <TooltipTrigger asChild>
139
+ <Button>Focus me with Tab</Button>
140
+ </TooltipTrigger>
141
+ <TooltipContent>Visible on hover AND keyboard focus.</TooltipContent>
142
+ </Tooltip>
143
+ </div>
144
+ </div>
145
+ ),
146
+ };