@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.
- package/components/ui/.gitkeep +0 -0
- package/components/ui/accordion.stories.tsx +231 -0
- package/components/ui/accordion.tsx +250 -0
- package/components/ui/app-shell.stories.tsx +270 -0
- package/components/ui/app-shell.tsx +491 -0
- package/components/ui/avatar.stories.tsx +174 -0
- package/components/ui/avatar.tsx +257 -0
- package/components/ui/badge.stories.tsx +127 -0
- package/components/ui/badge.tsx +146 -0
- package/components/ui/breadcrumb.stories.tsx +92 -0
- package/components/ui/breadcrumb.tsx +302 -0
- package/components/ui/button.stories.tsx +186 -0
- package/components/ui/button.tsx +128 -0
- package/components/ui/card.stories.tsx +279 -0
- package/components/ui/card.tsx +250 -0
- package/components/ui/checkbox.stories.tsx +93 -0
- package/components/ui/checkbox.tsx +131 -0
- package/components/ui/combobox.stories.tsx +489 -0
- package/components/ui/combobox.tsx +874 -0
- package/components/ui/context-menu.stories.tsx +202 -0
- package/components/ui/context-menu.tsx +309 -0
- package/components/ui/data-table.stories.tsx +227 -0
- package/components/ui/data-table.tsx +539 -0
- package/components/ui/date-picker.stories.tsx +225 -0
- package/components/ui/date-picker.tsx +597 -0
- package/components/ui/dialog.stories.tsx +193 -0
- package/components/ui/dialog.tsx +262 -0
- package/components/ui/divider.stories.tsx +84 -0
- package/components/ui/divider.tsx +135 -0
- package/components/ui/drawer.stories.tsx +218 -0
- package/components/ui/drawer.tsx +329 -0
- package/components/ui/dropdown-menu.stories.tsx +270 -0
- package/components/ui/dropdown-menu.tsx +353 -0
- package/components/ui/empty-state.stories.tsx +121 -0
- package/components/ui/empty-state.tsx +289 -0
- package/components/ui/field-group.stories.tsx +201 -0
- package/components/ui/field-group.tsx +276 -0
- package/components/ui/form.stories.tsx +219 -0
- package/components/ui/form.tsx +542 -0
- package/components/ui/input.stories.tsx +154 -0
- package/components/ui/input.tsx +208 -0
- package/components/ui/label.stories.tsx +84 -0
- package/components/ui/label.tsx +98 -0
- package/components/ui/page-header.stories.tsx +136 -0
- package/components/ui/page-header.tsx +315 -0
- package/components/ui/pagination.stories.tsx +136 -0
- package/components/ui/pagination.tsx +427 -0
- package/components/ui/popover.stories.tsx +212 -0
- package/components/ui/popover.tsx +167 -0
- package/components/ui/radio-group.stories.tsx +96 -0
- package/components/ui/radio-group.tsx +250 -0
- package/components/ui/select.stories.tsx +203 -0
- package/components/ui/select.tsx +318 -0
- package/components/ui/sidebar.stories.tsx +186 -0
- package/components/ui/sidebar.tsx +623 -0
- package/components/ui/skeleton.stories.tsx +131 -0
- package/components/ui/skeleton.tsx +311 -0
- package/components/ui/switch.stories.tsx +74 -0
- package/components/ui/switch.tsx +186 -0
- package/components/ui/table.stories.tsx +107 -0
- package/components/ui/table.tsx +285 -0
- package/components/ui/tabs.stories.tsx +222 -0
- package/components/ui/tabs.tsx +287 -0
- package/components/ui/textarea.stories.tsx +96 -0
- package/components/ui/textarea.tsx +182 -0
- package/components/ui/toast.stories.tsx +169 -0
- package/components/ui/toast.tsx +250 -0
- package/components/ui/tooltip.stories.tsx +146 -0
- package/components/ui/tooltip.tsx +156 -0
- package/components/ui/top-bar.stories.tsx +182 -0
- package/components/ui/top-bar.tsx +155 -0
- package/dist/components/ui/accordion.d.ts +45 -0
- package/dist/components/ui/accordion.d.ts.map +1 -0
- package/dist/components/ui/accordion.js +99 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/app-shell.d.ts +70 -0
- package/dist/components/ui/app-shell.d.ts.map +1 -0
- package/dist/components/ui/app-shell.js +199 -0
- package/dist/components/ui/app-shell.js.map +1 -0
- package/dist/components/ui/avatar.d.ts +41 -0
- package/dist/components/ui/avatar.d.ts.map +1 -0
- package/dist/components/ui/avatar.js +104 -0
- package/dist/components/ui/avatar.js.map +1 -0
- package/dist/components/ui/badge.d.ts +27 -0
- package/dist/components/ui/badge.d.ts.map +1 -0
- package/dist/components/ui/badge.js +65 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/breadcrumb.d.ts +35 -0
- package/dist/components/ui/breadcrumb.d.ts.map +1 -0
- package/dist/components/ui/breadcrumb.js +88 -0
- package/dist/components/ui/breadcrumb.js.map +1 -0
- package/dist/components/ui/button.d.ts +26 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +73 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/card.d.ts +52 -0
- package/dist/components/ui/card.d.ts.map +1 -0
- package/dist/components/ui/card.js +96 -0
- package/dist/components/ui/card.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +18 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/components/ui/checkbox.js +59 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/combobox.d.ts +194 -0
- package/dist/components/ui/combobox.d.ts.map +1 -0
- package/dist/components/ui/combobox.js +361 -0
- package/dist/components/ui/combobox.js.map +1 -0
- package/dist/components/ui/context-menu.d.ts +46 -0
- package/dist/components/ui/context-menu.d.ts.map +1 -0
- package/dist/components/ui/context-menu.js +95 -0
- package/dist/components/ui/context-menu.js.map +1 -0
- package/dist/components/ui/data-table.d.ts +53 -0
- package/dist/components/ui/data-table.d.ts.map +1 -0
- package/dist/components/ui/data-table.js +163 -0
- package/dist/components/ui/data-table.js.map +1 -0
- package/dist/components/ui/date-picker.d.ts +103 -0
- package/dist/components/ui/date-picker.d.ts.map +1 -0
- package/dist/components/ui/date-picker.js +306 -0
- package/dist/components/ui/date-picker.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +40 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +110 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/divider.d.ts +30 -0
- package/dist/components/ui/divider.d.ts.map +1 -0
- package/dist/components/ui/divider.js +62 -0
- package/dist/components/ui/divider.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +56 -0
- package/dist/components/ui/drawer.d.ts.map +1 -0
- package/dist/components/ui/drawer.js +147 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +63 -0
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist/components/ui/dropdown-menu.js +116 -0
- package/dist/components/ui/dropdown-menu.js.map +1 -0
- package/dist/components/ui/empty-state.d.ts +43 -0
- package/dist/components/ui/empty-state.d.ts.map +1 -0
- package/dist/components/ui/empty-state.js +128 -0
- package/dist/components/ui/empty-state.js.map +1 -0
- package/dist/components/ui/field-group.d.ts +38 -0
- package/dist/components/ui/field-group.d.ts.map +1 -0
- package/dist/components/ui/field-group.js +107 -0
- package/dist/components/ui/field-group.js.map +1 -0
- package/dist/components/ui/form.d.ts +67 -0
- package/dist/components/ui/form.d.ts.map +1 -0
- package/dist/components/ui/form.js +286 -0
- package/dist/components/ui/form.js.map +1 -0
- package/dist/components/ui/input.d.ts +36 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +99 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/label.d.ts +37 -0
- package/dist/components/ui/label.d.ts.map +1 -0
- package/dist/components/ui/label.js +34 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/page-header.d.ts +65 -0
- package/dist/components/ui/page-header.d.ts.map +1 -0
- package/dist/components/ui/page-header.js +140 -0
- package/dist/components/ui/page-header.js.map +1 -0
- package/dist/components/ui/pagination.d.ts +67 -0
- package/dist/components/ui/pagination.d.ts.map +1 -0
- package/dist/components/ui/pagination.js +109 -0
- package/dist/components/ui/pagination.js.map +1 -0
- package/dist/components/ui/popover.d.ts +28 -0
- package/dist/components/ui/popover.d.ts.map +1 -0
- package/dist/components/ui/popover.js +85 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/radio-group.d.ts +35 -0
- package/dist/components/ui/radio-group.d.ts.map +1 -0
- package/dist/components/ui/radio-group.js +103 -0
- package/dist/components/ui/radio-group.js.map +1 -0
- package/dist/components/ui/select.d.ts +42 -0
- package/dist/components/ui/select.d.ts.map +1 -0
- package/dist/components/ui/select.js +86 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/sidebar.d.ts +59 -0
- package/dist/components/ui/sidebar.d.ts.map +1 -0
- package/dist/components/ui/sidebar.js +189 -0
- package/dist/components/ui/sidebar.js.map +1 -0
- package/dist/components/ui/skeleton.d.ts +77 -0
- package/dist/components/ui/skeleton.d.ts.map +1 -0
- package/dist/components/ui/skeleton.js +115 -0
- package/dist/components/ui/skeleton.js.map +1 -0
- package/dist/components/ui/switch.d.ts +26 -0
- package/dist/components/ui/switch.d.ts.map +1 -0
- package/dist/components/ui/switch.js +84 -0
- package/dist/components/ui/switch.js.map +1 -0
- package/dist/components/ui/table.d.ts +52 -0
- package/dist/components/ui/table.d.ts.map +1 -0
- package/dist/components/ui/table.js +109 -0
- package/dist/components/ui/table.js.map +1 -0
- package/dist/components/ui/tabs.d.ts +42 -0
- package/dist/components/ui/tabs.d.ts.map +1 -0
- package/dist/components/ui/tabs.js +163 -0
- package/dist/components/ui/tabs.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +26 -0
- package/dist/components/ui/textarea.d.ts.map +1 -0
- package/dist/components/ui/textarea.js +96 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/toast.d.ts +77 -0
- package/dist/components/ui/toast.d.ts.map +1 -0
- package/dist/components/ui/toast.js +141 -0
- package/dist/components/ui/toast.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +31 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/components/ui/tooltip.js +71 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/components/ui/top-bar.d.ts +30 -0
- package/dist/components/ui/top-bar.d.ts.map +1 -0
- package/dist/components/ui/top-bar.js +64 -0
- package/dist/components/ui/top-bar.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/lib/utils.ts +6 -0
- package/package.json +112 -0
- 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
|
+
};
|