@vvedantb/ui 0.2.2 → 0.2.3
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/README.md +9 -3
- package/package.json +9 -1
- package/shell/src/SessionsHoverCard.tsx +81 -30
- package/shell/src/index.ts +1 -0
- package/src/audio/audioFloor.ts +37 -0
- package/src/audio/index.ts +7 -0
- package/src/audio/playChime.ts +4 -62
- package/src/audio/playTone.ts +73 -0
- package/src/sonner.ts +2 -0
- package/src/ui/sonner.tsx +23 -7
- package/tokens/examples/toaster/Fixture.tsx +6 -1
- package/tokens/examples/toaster/README.md +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
5
5
|
| Import | What it is |
|
|
6
6
|
| --- | --- |
|
|
7
7
|
| `@vvedantb/ui` | Primitives (Radix-based), AI elements, `playChime` and `useReplyChime`. |
|
|
8
|
+
| `@vvedantb/ui/sonner` | `SonnerToaster`, its helpers and `toast`. |
|
|
9
|
+
| `@vvedantb/ui/audio` | `playChime`, `useReplyChime`, `playTone` and the shared audio floor. |
|
|
8
10
|
| `@vvedantb/ui/cn` | `cn` class helper (clsx + tailwind-merge). |
|
|
9
11
|
| `@vvedantb/ui/shell` | Router-free `SidebarHeader`, `PillTabs` and `SessionsHoverCard`. |
|
|
10
12
|
| `@vvedantb/ui/app` | Alt-click confirm skip, update watcher, settings chrome, `useStableAuth`. See `app/README.md` in the repository. |
|
|
@@ -29,6 +31,9 @@ useReplyChime({ enabled: soundOn, isStreaming, threadId });
|
|
|
29
31
|
```
|
|
30
32
|
|
|
31
33
|
- `playChime()`: soft rising two-note chime (WebAudio). One shared `AudioContext`, created on first use. It is a no-op on the server and when autoplay blocks audio. Calls less than 1 second apart play once.
|
|
34
|
+
- `playTone(tone | tones)`: plays `{ frequency, startOffsetS?, durationS?, peakGain?, type? }` through the same floor.
|
|
35
|
+
- Audio floor: every package sound shares one 1 second minimum gap. Route your own sounds through it with `withAudioFloor(() => play())` or `if (tryAcquireAudioFloor()) play()`.
|
|
36
|
+
- These are also available from `@vvedantb/ui/audio`, which does not load the rest of the root barrel.
|
|
32
37
|
- `useReplyChime`: chimes once when `isStreaming` goes from `true` to `false` for the same `threadId`. It is silent on the first sight of a thread, when `threadId` changes, and when `threadId` is null or undefined. While `enabled` is `false` it still tracks the edge, so enabling mid-turn does not replay a finish.
|
|
33
38
|
|
|
34
39
|
## Sessions hover card
|
|
@@ -45,13 +50,13 @@ import { SessionsHoverCard } from "@vvedantb/ui/shell";
|
|
|
45
50
|
</SessionsHoverCard>
|
|
46
51
|
```
|
|
47
52
|
|
|
48
|
-
`children` is the trigger. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. Fixture: `shell/examples/sessions-hover-card/`.
|
|
53
|
+
`children` is the trigger. It is rendered with `asChild` by default, so it keeps its own handlers (such as a context menu). Extra props and the ref go to it. Use `asChild={false}` for a default trigger element. The content portals to `document.body` by default, so `overflow:hidden` ancestors do not clip it. Pass `portal={false}` to opt out. `surface` is `"overlay"` (default), `"surface"` (`rounded-surface`) or `"glass"` (`glass-panel-strong`, `rounded-surface`). `className` merges over the surface without `!`. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. Fixture: `shell/examples/sessions-hover-card/`.
|
|
49
54
|
|
|
50
55
|
## Toaster
|
|
51
56
|
|
|
52
57
|
```tsx
|
|
53
58
|
import { toast } from "sonner";
|
|
54
|
-
import { SonnerToaster } from "@vvedantb/ui";
|
|
59
|
+
import { SonnerToaster } from "@vvedantb/ui"; // or "@vvedantb/ui/sonner"
|
|
55
60
|
|
|
56
61
|
<SonnerToaster
|
|
57
62
|
theme={theme}
|
|
@@ -73,7 +78,8 @@ Opt-in props (all default to the current look):
|
|
|
73
78
|
|
|
74
79
|
| Prop | Values | Effect |
|
|
75
80
|
| --- | --- | --- |
|
|
76
|
-
| `toastRadius` | `"lg"` (default), `"surface"` | `rounded-lg
|
|
81
|
+
| `toastRadius` | `"lg"` (default), `"surface"`, `"control"` | `rounded-lg`, `rounded-surface` (from `tokens/css/utilities.css`) or `rounded-field` (the input radius). |
|
|
82
|
+
| `density` | `"default"`, `"compact"` | Current padding and text, or `gap-2 px-3 py-2 text-xs` with `text-xs` descriptions. |
|
|
77
83
|
| `toastIcons` | `"default"`, `"filled24"` | Sonner icons or 24px filled success, info, warning and error icons, a matching loader and a 14px close cross. |
|
|
78
84
|
| `closeButtonStyle` | `"default"`, `"hoverChip"` | Text-style close button or a round chip at the top-right, shown on hover and always on small screens. It turns on `closeButton` unless you set it. |
|
|
79
85
|
| `sonnerVars` | `false` (default), `true` | Sets `--normal-bg`, `--normal-border`, `--normal-text`, `--normal-bg-hover` and `--normal-border-hover` from `--color-card`, `--color-border`, `--color-foreground` and `--color-muted`, and `font-family: inherit`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: primitives, shell pieces, app helpers, design tokens, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
"types": "./src/utils/cn.ts",
|
|
22
22
|
"default": "./src/utils/cn.ts"
|
|
23
23
|
},
|
|
24
|
+
"./sonner": {
|
|
25
|
+
"types": "./src/sonner.ts",
|
|
26
|
+
"default": "./src/sonner.ts"
|
|
27
|
+
},
|
|
28
|
+
"./audio": {
|
|
29
|
+
"types": "./src/audio/index.ts",
|
|
30
|
+
"default": "./src/audio/index.ts"
|
|
31
|
+
},
|
|
24
32
|
"./shell": {
|
|
25
33
|
"types": "./shell/src/index.ts",
|
|
26
34
|
"default": "./shell/src/index.ts"
|
|
@@ -1,17 +1,49 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
HoverCardTrigger,
|
|
7
|
-
} from "../../src/ui/hover-card";
|
|
1
|
+
import { forwardRef, useEffect, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, ComponentRef, ReactNode } from "react";
|
|
3
|
+
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
|
4
|
+
import { HoverCard, HoverCardTrigger } from "../../src/ui/hover-card";
|
|
5
|
+
import { floatingSurfaceAnimateClass, floatingSurfaceClass } from "../../src/ui/_menu-classes";
|
|
8
6
|
import { cn } from "../../src/utils/cn";
|
|
9
7
|
|
|
10
8
|
export type SessionsHoverCardContext = { open: boolean };
|
|
11
9
|
|
|
12
|
-
export type
|
|
13
|
-
|
|
10
|
+
export type SessionsHoverCardSurface = "overlay" | "surface" | "glass";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Surface classes. Only `overlay` carries the shared floating surface, so the
|
|
14
|
+
* others need no `!` overrides, and `className` merges over all of them.
|
|
15
|
+
*/
|
|
16
|
+
const surfaceClasses: Record<SessionsHoverCardSurface, string> = {
|
|
17
|
+
overlay: floatingSurfaceClass,
|
|
18
|
+
surface: "rounded-surface bg-overlay text-overlay-foreground smooth-shadow-ring-lg",
|
|
19
|
+
glass: "rounded-surface glass-panel-strong text-foreground smooth-shadow-ring-lg",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SessionsHoverCardProps = Omit<
|
|
23
|
+
ComponentPropsWithoutRef<typeof HoverCardTrigger>,
|
|
24
|
+
"className" | "children"
|
|
25
|
+
> & {
|
|
26
|
+
/**
|
|
27
|
+
* The trigger. With `asChild` (default) it is the element that triggers the
|
|
28
|
+
* card, such as a list row. Trigger props and the ref go to that element,
|
|
29
|
+
* which keeps its own handlers. With `asChild={false}` the children render
|
|
30
|
+
* inside a default trigger element.
|
|
31
|
+
*/
|
|
14
32
|
children: ReactNode;
|
|
33
|
+
/** Default `true`. */
|
|
34
|
+
asChild?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Render the content in a portal on `document.body`, so `overflow:hidden`
|
|
37
|
+
* ancestors do not clip it. Default `true`. Use `false` to keep the content
|
|
38
|
+
* in place in the DOM.
|
|
39
|
+
*/
|
|
40
|
+
portal?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* `"overlay"` (default): floating overlay, `rounded-lg`. `"surface"`: same
|
|
43
|
+
* fill with `rounded-surface`. `"glass"`: `glass-panel-strong` with
|
|
44
|
+
* `rounded-surface`.
|
|
45
|
+
*/
|
|
46
|
+
surface?: SessionsHoverCardSurface;
|
|
15
47
|
header?: ReactNode;
|
|
16
48
|
/**
|
|
17
49
|
* Body slot. A function runs only while the card is open, so it can start
|
|
@@ -34,7 +66,7 @@ export type SessionsHoverCardProps = {
|
|
|
34
66
|
side?: "top" | "right" | "bottom" | "left";
|
|
35
67
|
align?: "start" | "center" | "end";
|
|
36
68
|
sideOffset?: number;
|
|
37
|
-
/** Merged over the default `w-64 p-3
|
|
69
|
+
/** Merged over the default `w-64 p-3` and the surface classes. */
|
|
38
70
|
className?: string;
|
|
39
71
|
};
|
|
40
72
|
|
|
@@ -86,8 +118,14 @@ function RenderBody({
|
|
|
86
118
|
* position, width and padding, and gives header, body and footer slots. The
|
|
87
119
|
* app supplies the content.
|
|
88
120
|
*/
|
|
89
|
-
export
|
|
121
|
+
export const SessionsHoverCard = forwardRef<
|
|
122
|
+
ComponentRef<typeof HoverCardTrigger>,
|
|
123
|
+
SessionsHoverCardProps
|
|
124
|
+
>(function SessionsHoverCard({
|
|
90
125
|
children,
|
|
126
|
+
asChild = true,
|
|
127
|
+
portal = true,
|
|
128
|
+
surface = "overlay",
|
|
91
129
|
header,
|
|
92
130
|
body,
|
|
93
131
|
loadContent,
|
|
@@ -102,7 +140,8 @@ export function SessionsHoverCard({
|
|
|
102
140
|
align = "start",
|
|
103
141
|
sideOffset = 8,
|
|
104
142
|
className,
|
|
105
|
-
|
|
143
|
+
...triggerProps
|
|
144
|
+
}, ref) {
|
|
106
145
|
const [openState, setOpenState] = useState(defaultOpen);
|
|
107
146
|
const open = openProp ?? openState;
|
|
108
147
|
|
|
@@ -119,6 +158,31 @@ export function SessionsHoverCard({
|
|
|
119
158
|
body
|
|
120
159
|
);
|
|
121
160
|
|
|
161
|
+
const content = (
|
|
162
|
+
<HoverCardPrimitive.Content
|
|
163
|
+
side={side}
|
|
164
|
+
align={align}
|
|
165
|
+
sideOffset={sideOffset}
|
|
166
|
+
className={cn(
|
|
167
|
+
"z-50 outline-none max-sm:max-h-[85dvh] max-sm:max-w-[calc(100vw-2rem)] max-sm:overflow-y-auto",
|
|
168
|
+
surfaceClasses[surface],
|
|
169
|
+
floatingSurfaceAnimateClass,
|
|
170
|
+
"w-64 p-3",
|
|
171
|
+
className,
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{header ? (
|
|
175
|
+
<div className="mb-2 text-sm font-medium text-foreground">{header}</div>
|
|
176
|
+
) : null}
|
|
177
|
+
{bodyNode != null ? (
|
|
178
|
+
<div className="text-sm text-muted-foreground">{bodyNode}</div>
|
|
179
|
+
) : null}
|
|
180
|
+
{footer ? (
|
|
181
|
+
<div className="mt-2 text-xs text-muted-foreground">{footer}</div>
|
|
182
|
+
) : null}
|
|
183
|
+
</HoverCardPrimitive.Content>
|
|
184
|
+
);
|
|
185
|
+
|
|
122
186
|
return (
|
|
123
187
|
<HoverCard
|
|
124
188
|
open={open}
|
|
@@ -126,23 +190,10 @@ export function SessionsHoverCard({
|
|
|
126
190
|
openDelay={openDelay}
|
|
127
191
|
closeDelay={closeDelay}
|
|
128
192
|
>
|
|
129
|
-
<HoverCardTrigger asChild
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
sideOffset={sideOffset}
|
|
134
|
-
className={cn("w-64 p-3", className)}
|
|
135
|
-
>
|
|
136
|
-
{header ? (
|
|
137
|
-
<div className="mb-2 text-sm font-medium text-foreground">{header}</div>
|
|
138
|
-
) : null}
|
|
139
|
-
{bodyNode != null ? (
|
|
140
|
-
<div className="text-sm text-muted-foreground">{bodyNode}</div>
|
|
141
|
-
) : null}
|
|
142
|
-
{footer ? (
|
|
143
|
-
<div className="mt-2 text-xs text-muted-foreground">{footer}</div>
|
|
144
|
-
) : null}
|
|
145
|
-
</HoverCardContent>
|
|
193
|
+
<HoverCardTrigger ref={ref} asChild={asChild} {...triggerProps}>
|
|
194
|
+
{children}
|
|
195
|
+
</HoverCardTrigger>
|
|
196
|
+
{portal ? <HoverCardPrimitive.Portal>{content}</HoverCardPrimitive.Portal> : content}
|
|
146
197
|
</HoverCard>
|
|
147
198
|
);
|
|
148
|
-
}
|
|
199
|
+
});
|
package/shell/src/index.ts
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Calls closer together than this are dropped, so overlapping events sound once. */
|
|
2
|
+
export const AUDIO_FLOOR_MIN_GAP_S = 1;
|
|
3
|
+
|
|
4
|
+
let lastAcquiredAtS: number | null = null;
|
|
5
|
+
|
|
6
|
+
function nowS(): number {
|
|
7
|
+
return performance.now() / 1000;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Takes the shared audio floor. Returns `true` when at least 1 second has
|
|
12
|
+
* passed since the last sound that took it, and starts a new gap. Returns
|
|
13
|
+
* `false` otherwise, and the caller should stay silent.
|
|
14
|
+
*
|
|
15
|
+
* Every package sound (`playChime`, `playTone`) goes through this one floor.
|
|
16
|
+
* Apps can route their own sounds through it too.
|
|
17
|
+
*/
|
|
18
|
+
export function tryAcquireAudioFloor(): boolean {
|
|
19
|
+
const now = nowS();
|
|
20
|
+
if (lastAcquiredAtS !== null && now - lastAcquiredAtS < AUDIO_FLOOR_MIN_GAP_S) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
lastAcquiredAtS = now;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Runs `play` only if the shared floor is free. Returns whether it ran. */
|
|
28
|
+
export function withAudioFloor(play: () => void): boolean {
|
|
29
|
+
if (!tryAcquireAudioFloor()) return false;
|
|
30
|
+
play();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Clears the floor. For tests. */
|
|
35
|
+
export function resetAudioFloor(): void {
|
|
36
|
+
lastAcquiredAtS = null;
|
|
37
|
+
}
|
package/src/audio/index.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
export { playChime } from "./playChime";
|
|
2
|
+
export { playTone } from "./playTone";
|
|
3
|
+
export type { Tone } from "./playTone";
|
|
4
|
+
export {
|
|
5
|
+
AUDIO_FLOOR_MIN_GAP_S,
|
|
6
|
+
tryAcquireAudioFloor,
|
|
7
|
+
withAudioFloor,
|
|
8
|
+
} from "./audioFloor";
|
|
2
9
|
export { useReplyChime, nextReplyChimeState } from "./useReplyChime";
|
|
3
10
|
export type { ReplyChimeInput, ReplyChimeWatch } from "./useReplyChime";
|
package/src/audio/playChime.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
type Tone
|
|
2
|
-
frequency: number;
|
|
3
|
-
startOffsetS: number;
|
|
4
|
-
durationS: number;
|
|
5
|
-
peakGain: number;
|
|
6
|
-
};
|
|
1
|
+
import { playTone, type Tone } from "./playTone";
|
|
7
2
|
|
|
8
3
|
/** A soft rising interval (A5 then D6). The second note overlaps the first. */
|
|
9
4
|
const REPLY_CHIME: readonly Tone[] = [
|
|
@@ -11,66 +6,13 @@ const REPLY_CHIME: readonly Tone[] = [
|
|
|
11
6
|
{ frequency: 1174.66, startOffsetS: 0.09, durationS: 0.28, peakGain: 0.14 },
|
|
12
7
|
];
|
|
13
8
|
|
|
14
|
-
/** Long enough to avoid a click on attack. */
|
|
15
|
-
const ATTACK_S = 0.012;
|
|
16
|
-
|
|
17
|
-
/** Calls closer together than this are dropped, so overlapping events sound once. */
|
|
18
|
-
const MIN_INTERVAL_S = 1;
|
|
19
|
-
|
|
20
|
-
let sharedContext: AudioContext | null = null;
|
|
21
|
-
let lastPlayedAtS: number | null = null;
|
|
22
|
-
|
|
23
|
-
function getAudioContext(): AudioContext | null {
|
|
24
|
-
if (sharedContext) return sharedContext;
|
|
25
|
-
if (typeof window === "undefined" || !window.AudioContext) return null;
|
|
26
|
-
try {
|
|
27
|
-
sharedContext = new window.AudioContext();
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
return sharedContext;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
9
|
/**
|
|
35
10
|
* Plays a soft rising two-note chime with WebAudio.
|
|
36
11
|
*
|
|
37
12
|
* The context is created on first use and shared. The call is a no-op on the
|
|
38
|
-
* server, without WebAudio, or while autoplay policy blocks audio.
|
|
13
|
+
* server, without WebAudio, or while autoplay policy blocks audio. It shares
|
|
14
|
+
* the 1 second floor with every other package sound.
|
|
39
15
|
*/
|
|
40
16
|
export function playChime(): void {
|
|
41
|
-
|
|
42
|
-
if (!context) return;
|
|
43
|
-
|
|
44
|
-
if (context.state === "suspended") {
|
|
45
|
-
try {
|
|
46
|
-
context.resume().catch(() => undefined);
|
|
47
|
-
} catch {
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const start = context.currentTime;
|
|
53
|
-
if (lastPlayedAtS !== null && start - lastPlayedAtS < MIN_INTERVAL_S) return;
|
|
54
|
-
lastPlayedAtS = start;
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
for (const tone of REPLY_CHIME) {
|
|
58
|
-
const oscillator = context.createOscillator();
|
|
59
|
-
const envelope = context.createGain();
|
|
60
|
-
oscillator.type = "sine";
|
|
61
|
-
oscillator.frequency.value = tone.frequency;
|
|
62
|
-
|
|
63
|
-
const toneStart = start + tone.startOffsetS;
|
|
64
|
-
const toneEnd = toneStart + tone.durationS;
|
|
65
|
-
envelope.gain.setValueAtTime(0, toneStart);
|
|
66
|
-
envelope.gain.linearRampToValueAtTime(tone.peakGain, toneStart + ATTACK_S);
|
|
67
|
-
envelope.gain.exponentialRampToValueAtTime(0.0001, toneEnd);
|
|
68
|
-
|
|
69
|
-
oscillator.connect(envelope).connect(context.destination);
|
|
70
|
-
oscillator.start(toneStart);
|
|
71
|
-
oscillator.stop(toneEnd);
|
|
72
|
-
}
|
|
73
|
-
} catch {
|
|
74
|
-
// Audio is optional. Stay silent on any failure.
|
|
75
|
-
}
|
|
17
|
+
playTone(REPLY_CHIME);
|
|
76
18
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { tryAcquireAudioFloor } from "./audioFloor";
|
|
2
|
+
|
|
3
|
+
export type Tone = {
|
|
4
|
+
/** Pitch in Hz. */
|
|
5
|
+
frequency: number;
|
|
6
|
+
/** Delay from the start of the call, in seconds. Default 0. */
|
|
7
|
+
startOffsetS?: number;
|
|
8
|
+
/** Length in seconds. Default 0.28. */
|
|
9
|
+
durationS?: number;
|
|
10
|
+
/** Peak gain from 0 to 1. Default 0.14. */
|
|
11
|
+
peakGain?: number;
|
|
12
|
+
/** Default "sine". */
|
|
13
|
+
type?: OscillatorType;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Long enough to avoid a click on attack. */
|
|
17
|
+
const ATTACK_S = 0.012;
|
|
18
|
+
|
|
19
|
+
let sharedContext: AudioContext | null = null;
|
|
20
|
+
|
|
21
|
+
function getAudioContext(): AudioContext | null {
|
|
22
|
+
if (sharedContext) return sharedContext;
|
|
23
|
+
if (typeof window === "undefined" || !window.AudioContext) return null;
|
|
24
|
+
try {
|
|
25
|
+
sharedContext = new window.AudioContext();
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return sharedContext;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Plays one tone or a sequence of overlapping tones with WebAudio, through the
|
|
34
|
+
* shared 1 second floor (see `tryAcquireAudioFloor`). The context is created on
|
|
35
|
+
* first use and shared. The call is a no-op on the server, without WebAudio,
|
|
36
|
+
* or while autoplay policy blocks audio.
|
|
37
|
+
*/
|
|
38
|
+
export function playTone(tones: Tone | readonly Tone[]): void {
|
|
39
|
+
const context = getAudioContext();
|
|
40
|
+
if (!context) return;
|
|
41
|
+
|
|
42
|
+
if (context.state === "suspended") {
|
|
43
|
+
try {
|
|
44
|
+
context.resume().catch(() => undefined);
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!tryAcquireAudioFloor()) return;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const start = context.currentTime;
|
|
54
|
+
for (const tone of Array.isArray(tones) ? tones : [tones as Tone]) {
|
|
55
|
+
const oscillator = context.createOscillator();
|
|
56
|
+
const envelope = context.createGain();
|
|
57
|
+
oscillator.type = tone.type ?? "sine";
|
|
58
|
+
oscillator.frequency.value = tone.frequency;
|
|
59
|
+
|
|
60
|
+
const toneStart = start + (tone.startOffsetS ?? 0);
|
|
61
|
+
const toneEnd = toneStart + (tone.durationS ?? 0.28);
|
|
62
|
+
envelope.gain.setValueAtTime(0, toneStart);
|
|
63
|
+
envelope.gain.linearRampToValueAtTime(tone.peakGain ?? 0.14, toneStart + ATTACK_S);
|
|
64
|
+
envelope.gain.exponentialRampToValueAtTime(0.0001, toneEnd);
|
|
65
|
+
|
|
66
|
+
oscillator.connect(envelope).connect(context.destination);
|
|
67
|
+
oscillator.start(toneStart);
|
|
68
|
+
oscillator.stop(toneEnd);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Audio is optional. Stay silent on any failure.
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/sonner.ts
ADDED
package/src/ui/sonner.tsx
CHANGED
|
@@ -26,8 +26,13 @@ type ToastClassNames = NonNullable<
|
|
|
26
26
|
type ToasterStyle = CSSProperties & Record<`--${string}`, string>;
|
|
27
27
|
|
|
28
28
|
type SonnerToasterOptions = {
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `"lg"` (default) uses `rounded-lg`. `"surface"` uses `rounded-surface`.
|
|
31
|
+
* `"control"` uses `rounded-field`, the radius of inputs and selects.
|
|
32
|
+
*/
|
|
33
|
+
toastRadius?: "lg" | "surface" | "control";
|
|
34
|
+
/** `"default"` keeps the current padding and text size. `"compact"` tightens padding, gap and text. */
|
|
35
|
+
density?: "default" | "compact";
|
|
31
36
|
/** `"default"` keeps sonner's icons. `"filled24"` uses 24px filled icons. */
|
|
32
37
|
toastIcons?: "default" | "filled24";
|
|
33
38
|
/**
|
|
@@ -48,22 +53,31 @@ const hoverChipCloseButtonClassName =
|
|
|
48
53
|
const defaultCloseButtonClassName =
|
|
49
54
|
"rounded-lg bg-default px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-default/78";
|
|
50
55
|
|
|
56
|
+
const toastRadiusClasses = {
|
|
57
|
+
lg: "rounded-lg",
|
|
58
|
+
surface: "rounded-surface",
|
|
59
|
+
control: "rounded-field",
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
51
62
|
/** Default `classNames`, with the opt-in variants applied. No options gives the 0.2.1 defaults. */
|
|
52
63
|
function createToasterClassNames({
|
|
53
64
|
toastRadius = "lg",
|
|
65
|
+
density = "default",
|
|
54
66
|
closeButtonStyle = "default",
|
|
55
67
|
descriptionMuted = false,
|
|
56
68
|
}: Pick<
|
|
57
69
|
SonnerToasterOptions,
|
|
58
|
-
"toastRadius" | "closeButtonStyle" | "descriptionMuted"
|
|
70
|
+
"toastRadius" | "density" | "closeButtonStyle" | "descriptionMuted"
|
|
59
71
|
> = {}): ToastClassNames {
|
|
60
|
-
const radius = toastRadius
|
|
72
|
+
const radius = toastRadiusClasses[toastRadius];
|
|
73
|
+
const compact = density === "compact";
|
|
74
|
+
const descriptionSize = compact ? "text-xs" : "text-[13px]";
|
|
61
75
|
return {
|
|
62
|
-
toast: `group toast glass-panel-strong flex w-full items-center gap-3 ${radius} px-4 py-3 text-sm text-foreground smooth-shadow-ring-md`,
|
|
76
|
+
toast: `group toast glass-panel-strong flex w-full items-center ${compact ? "gap-2" : "gap-3"} ${radius} ${compact ? "px-3 py-2 text-xs" : "px-4 py-3 text-sm"} text-foreground smooth-shadow-ring-md`,
|
|
63
77
|
title: "font-medium leading-snug",
|
|
64
78
|
description: descriptionMuted
|
|
65
|
-
?
|
|
66
|
-
:
|
|
79
|
+
? `${descriptionSize} leading-snug text-muted-foreground!`
|
|
80
|
+
: `${descriptionSize} leading-snug text-muted-foreground`,
|
|
67
81
|
actionButton:
|
|
68
82
|
"rounded-lg bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90",
|
|
69
83
|
cancelButton:
|
|
@@ -129,6 +143,7 @@ function mergeToastOptions(
|
|
|
129
143
|
|
|
130
144
|
function SonnerToaster({
|
|
131
145
|
toastRadius,
|
|
146
|
+
density,
|
|
132
147
|
toastIcons = "default",
|
|
133
148
|
closeButtonStyle = "default",
|
|
134
149
|
sonnerVars = false,
|
|
@@ -157,6 +172,7 @@ function SonnerToaster({
|
|
|
157
172
|
unstyled: true,
|
|
158
173
|
classNames: createToasterClassNames({
|
|
159
174
|
toastRadius,
|
|
175
|
+
density,
|
|
160
176
|
closeButtonStyle,
|
|
161
177
|
descriptionMuted,
|
|
162
178
|
}),
|
|
@@ -7,6 +7,10 @@ function initialDark(): boolean {
|
|
|
7
7
|
return new URLSearchParams(window.location.search).get("theme") === "dark";
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
const compact =
|
|
11
|
+
typeof window !== "undefined" &&
|
|
12
|
+
new URLSearchParams(window.location.search).get("variant") === "compact";
|
|
13
|
+
|
|
10
14
|
function fire() {
|
|
11
15
|
toast.success("Changes saved", {
|
|
12
16
|
description: "Your settings were updated and will apply to new sessions.",
|
|
@@ -42,7 +46,8 @@ export function Fixture() {
|
|
|
42
46
|
</main>
|
|
43
47
|
<SonnerToaster
|
|
44
48
|
theme={dark ? "dark" : "light"}
|
|
45
|
-
toastRadius="surface"
|
|
49
|
+
toastRadius={compact ? "control" : "surface"}
|
|
50
|
+
density={compact ? "compact" : "default"}
|
|
46
51
|
toastIcons="filled24"
|
|
47
52
|
closeButtonStyle="hoverChip"
|
|
48
53
|
sonnerVars
|
|
@@ -15,4 +15,4 @@ Run it from this folder (it is not a workspace member, and React resolves from t
|
|
|
15
15
|
pnpm install --ignore-workspace && pnpm dev
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Open the printed URL plus `/tokens.html` or `/compat.html`. Add `?theme=dark` for dark mode, or use "Toggle theme". Hover the toast to see the close chip. `Fixture.tsx` is not part of the package typecheck.
|
|
18
|
+
Open the printed URL plus `/tokens.html` or `/compat.html`. Add `?variant=compact` for the compact density with `toastRadius="control"`. Add `?theme=dark` for dark mode (combine with `&`), or use "Toggle theme". Hover the toast to see the close chip. `Fixture.tsx` is not part of the package typecheck.
|