studio: fix toast close-button click and light-mode hover (#5597)
Two related issues on the chat toasts:
1. Close X did nothing. The lib/toast.ts wrapper defaulted every toast
to `dismissible: false` (originally to keep swipe capture from
stealing text selection). In sonner v2, `dismissible: false` makes
the close-button onClick a no-op, so the X looked clickable but
never dismissed the toast. The Toaster already sets
`swipeDirections={[]}` in components/ui/sonner.tsx, so the
per-toast swipe workaround is unnecessary and harmful. Replace the
wrapper with a thin re-export of sonner.
2. Close X hover collapsed to a near-black circle in light mode.
Sonner's default close-button styling uses fixed gray-scale tokens
(--gray2 hover, --gray12 text) that ignore the theme attribute.
Once the Toaster's inline style overrides --normal-bg with
var(--popover), the base background follows the app theme but the
hover state does not, so the hover bg lands on a color that has no
contrast with the X glyph. Pin both base and hover to theme tokens
(--popover, --muted, --popover-foreground, --border) so contrast
stays visible in both light and dark modes.
Repro: open chat, load any cached model, hover the X on the
"<name> loaded" toast in light mode -- before this change the circle
turned dark and the click did nothing; after, the circle stays light
and the click dismisses the toast.
This commit is contained in:
parent
ba710a783a
commit
c4908b7929
2 changed files with 12 additions and 56 deletions
|
|
@ -1172,11 +1172,18 @@
|
|||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* Override sonner's hardcoded top: 0 on the toast close button. */
|
||||
/* Override sonner top: 0 and pin to theme tokens (--gray2 hover ignores data-sonner-theme). */
|
||||
[data-sonner-toast][data-styled="true"] [data-close-button] {
|
||||
top: 8px !important;
|
||||
background: var(--popover) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
/* Bump the X stroke so it stays visible against dark backgrounds. */
|
||||
[data-sonner-toast][data-styled="true"] [data-close-button] svg {
|
||||
stroke-width: 2.25;
|
||||
}
|
||||
[data-sonner-toast][data-styled="true"]:hover [data-close-button]:hover {
|
||||
background: var(--muted) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,8 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// sonner `toast` wrapper that defaults `dismissible: false` so swipe
|
||||
// capture doesn't block text selection. Drop-in for `from "sonner"`.
|
||||
|
||||
import { toast as sonnerToast, type ExternalToast } from "sonner";
|
||||
|
||||
type AnyFn = (...args: unknown[]) => unknown;
|
||||
|
||||
function withDismissibleFalse<F extends AnyFn>(fn: F): F {
|
||||
return ((...args: unknown[]) => {
|
||||
// Branch by arity: React-element messages are objects too.
|
||||
if (args.length <= 1) {
|
||||
args.push({ dismissible: false } satisfies ExternalToast);
|
||||
} else {
|
||||
const lastIdx = args.length - 1;
|
||||
const last = args[lastIdx];
|
||||
if (last && typeof last === "object" && !Array.isArray(last)) {
|
||||
const opts = last as ExternalToast;
|
||||
if (!("dismissible" in opts)) {
|
||||
args[lastIdx] = { dismissible: false, ...opts };
|
||||
}
|
||||
}
|
||||
}
|
||||
return fn(...args);
|
||||
}) as F;
|
||||
}
|
||||
|
||||
const wrappedCallable = withDismissibleFalse(
|
||||
sonnerToast as unknown as AnyFn,
|
||||
) as typeof sonnerToast;
|
||||
|
||||
// `promise(p, data?)` carries `dismissible` at the top of `data`,
|
||||
// covering loading / success / error states. `dismiss`, `getHistory`,
|
||||
// `getToasts` take no options.
|
||||
const wrappedPromise: typeof sonnerToast.promise = ((promise, data) => {
|
||||
const merged =
|
||||
data && typeof data === "object" && !("dismissible" in data)
|
||||
? { dismissible: false, ...data }
|
||||
: (data ?? { dismissible: false });
|
||||
return sonnerToast.promise(promise, merged);
|
||||
}) as typeof sonnerToast.promise;
|
||||
|
||||
export const toast: typeof sonnerToast = Object.assign(wrappedCallable, {
|
||||
success: withDismissibleFalse(sonnerToast.success.bind(sonnerToast) as AnyFn) as typeof sonnerToast.success,
|
||||
info: withDismissibleFalse(sonnerToast.info.bind(sonnerToast) as AnyFn) as typeof sonnerToast.info,
|
||||
warning: withDismissibleFalse(sonnerToast.warning.bind(sonnerToast) as AnyFn) as typeof sonnerToast.warning,
|
||||
error: withDismissibleFalse(sonnerToast.error.bind(sonnerToast) as AnyFn) as typeof sonnerToast.error,
|
||||
message: withDismissibleFalse(sonnerToast.message.bind(sonnerToast) as AnyFn) as typeof sonnerToast.message,
|
||||
loading: withDismissibleFalse(sonnerToast.loading.bind(sonnerToast) as AnyFn) as typeof sonnerToast.loading,
|
||||
custom: withDismissibleFalse(sonnerToast.custom.bind(sonnerToast) as AnyFn) as typeof sonnerToast.custom,
|
||||
promise: wrappedPromise,
|
||||
dismiss: sonnerToast.dismiss.bind(sonnerToast) as typeof sonnerToast.dismiss,
|
||||
getHistory: sonnerToast.getHistory.bind(sonnerToast) as typeof sonnerToast.getHistory,
|
||||
getToasts: sonnerToast.getToasts.bind(sonnerToast) as typeof sonnerToast.getToasts,
|
||||
});
|
||||
// Re-export of sonner. Swipe blocking lives on the Toaster via
|
||||
// `swipeDirections={[]}`, so no per-toast dismissible override.
|
||||
|
||||
export { toast } from "sonner";
|
||||
export type { ExternalToast } from "sonner";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue