@vvedantb/ui 0.3.0 → 0.3.1

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 CHANGED
@@ -142,7 +142,7 @@ import { SessionsHoverCard } from "@vvedantb/ui/shell";
142
142
  </SessionsHoverCard>
143
143
  ```
144
144
 
145
- `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/`.
145
+ `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`. The card opens on pointer hover and on keyboard focus (`:focus-visible`) only. Focus that follows a click or a context menu close does not open it. If pointer-driven focus left it stuck open, pointer leave force-closes it; otherwise Radix handles close (including moving onto the content). Fixture: `shell/examples/sessions-hover-card/`.
146
146
 
147
147
  ## Toaster
148
148
 
@@ -176,6 +176,11 @@ Opt-in props (all default to the current look):
176
176
  | `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. |
177
177
  | `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`. |
178
178
  | `descriptionMuted` | `false` (default), `true` | Description uses `text-muted-foreground!`, which beats sonner's dark-theme rule. |
179
+ | `titleLeading` | `"snug"` (default), `"5"` | Title `leading-snug` (19.25px at `text-sm`) or `leading-5` (20px). |
180
+ | `loadingIcon` | node (default none) | Custom loading icon, merged into `icons`. A caller `icons.loading` wins. `icons={{ loading }}` works with both `toastIcons` values. |
181
+ | `hideStackedContent` | `false` (default), `true` | Adds `[&[data-expanded=false][data-front=false]>*]:opacity-0` to the toast, so content behind the front toast is hidden while the stack is collapsed. `sonner.css` applies the same rule when imported. |
182
+
183
+ With `closeButtonStyle="hoverChip"`, hover changes the text colour, and sonner may still change the background. The border stays `border-border` (pinned with `hover:border-border!` over sonner's dark hover border), so the ring does not brighten in dark mode.
179
184
 
180
185
  Helpers: `createToasterClassNames`, `mergeToastClassNames`, `mergeToastOptions`, `mergeToasterStyle`, `filledToastIcons`. Fixture: `tokens/examples/toaster/`.
181
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
@@ -70,6 +70,15 @@ export type SessionsHoverCardProps = Omit<
70
70
  className?: string;
71
71
  };
72
72
 
73
+ /** `:focus-visible` is true for keyboard focus only. Treat unsupported engines as keyboard. */
74
+ function isFocusVisible(el: Element): boolean {
75
+ try {
76
+ return el.matches(":focus-visible");
77
+ } catch {
78
+ return true;
79
+ }
80
+ }
81
+
73
82
  type LoadState = { status: "loading" } | { status: "ready"; node: ReactNode };
74
83
 
75
84
  /** Mounts only while the card is open, so `loadContent` never runs while closed. */
@@ -140,6 +149,8 @@ export const SessionsHoverCard = forwardRef<
140
149
  align = "start",
141
150
  sideOffset = 8,
142
151
  className,
152
+ onFocus,
153
+ onPointerLeave,
143
154
  ...triggerProps
144
155
  }, ref) {
145
156
  const [openState, setOpenState] = useState(defaultOpen);
@@ -150,6 +161,24 @@ export const SessionsHoverCard = forwardRef<
150
161
  onOpenChange?.(next);
151
162
  };
152
163
 
164
+ // Open on keyboard focus only. Focus that returns after a context menu or a
165
+ // click is pointer-driven, so it must not open the card.
166
+ const handleFocus = (event: React.FocusEvent<HTMLElement>) => {
167
+ onFocus?.(event as never);
168
+ if (event.defaultPrevented) return;
169
+ if (!isFocusVisible(event.currentTarget)) event.preventDefault();
170
+ };
171
+
172
+ // Radix handles hover close, including closeDelay and moving onto the
173
+ // content. Force-close only when the card is stuck open by pointer-driven
174
+ // focus on the trigger (focused, but not `:focus-visible`).
175
+ const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {
176
+ onPointerLeave?.(event as never);
177
+ if (event.pointerType === "touch" || !open) return;
178
+ const el = event.currentTarget;
179
+ if (el.ownerDocument.activeElement === el && !isFocusVisible(el)) handleOpenChange(false);
180
+ };
181
+
153
182
  const bodyNode = loadContent ? (
154
183
  <LoadedBody loadContent={loadContent} fallback={loadingFallback} />
155
184
  ) : typeof body === "function" ? (
@@ -190,7 +219,13 @@ export const SessionsHoverCard = forwardRef<
190
219
  openDelay={openDelay}
191
220
  closeDelay={closeDelay}
192
221
  >
193
- <HoverCardTrigger ref={ref} asChild={asChild} {...triggerProps}>
222
+ <HoverCardTrigger
223
+ ref={ref}
224
+ asChild={asChild}
225
+ {...triggerProps}
226
+ onFocus={handleFocus}
227
+ onPointerLeave={handlePointerLeave}
228
+ >
194
229
  {children}
195
230
  </HoverCardTrigger>
196
231
  {portal ? <HoverCardPrimitive.Portal>{content}</HoverCardPrimitive.Portal> : content}
package/src/ui/sonner.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { CSSProperties } from "react";
1
+ import type { CSSProperties, ReactNode } from "react";
2
2
  import { Toaster, type ToasterProps } from "sonner";
3
3
  import { filledToastIcons } from "./sonner-icons";
4
4
  import "./sonner.css";
@@ -45,10 +45,22 @@ type SonnerToasterOptions = {
45
45
  sonnerVars?: boolean;
46
46
  /** When true, descriptions use `text-muted-foreground!` so sonner's dark rule cannot win. */
47
47
  descriptionMuted?: boolean;
48
+ /** `"snug"` (default) keeps `leading-snug`. `"5"` uses `leading-5` (20px at `text-sm`). */
49
+ titleLeading?: "snug" | "5";
50
+ /** Custom loading icon. Merged into `icons`. A caller `icons.loading` wins. */
51
+ loadingIcon?: ReactNode;
52
+ /**
53
+ * When true, adds a class that hides the content of toasts behind the front toast
54
+ * while the stack is collapsed. Default false. `sonner.css` has the same rule.
55
+ */
56
+ hideStackedContent?: boolean;
48
57
  };
49
58
 
59
+ const hideStackedContentClassName =
60
+ "[&[data-expanded=false][data-front=false]>*]:opacity-0";
61
+
50
62
  const hoverChipCloseButtonClassName =
51
- "pointer-events-none absolute -right-2 -top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-card/95 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover:pointer-events-auto group-hover:opacity-100 max-sm:pointer-events-auto max-sm:opacity-100 max-sm:hit-target";
63
+ "pointer-events-none absolute -right-2 -top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-card/95 text-muted-foreground opacity-0 transition-opacity hover:border-border! hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover:pointer-events-auto group-hover:opacity-100 max-sm:pointer-events-auto max-sm:opacity-100 max-sm:hit-target";
52
64
 
53
65
  const defaultCloseButtonClassName =
54
66
  "rounded-lg bg-default px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-default/78";
@@ -65,16 +77,23 @@ function createToasterClassNames({
65
77
  density = "default",
66
78
  closeButtonStyle = "default",
67
79
  descriptionMuted = false,
80
+ titleLeading = "snug",
81
+ hideStackedContent = false,
68
82
  }: Pick<
69
83
  SonnerToasterOptions,
70
- "toastRadius" | "density" | "closeButtonStyle" | "descriptionMuted"
84
+ | "toastRadius"
85
+ | "density"
86
+ | "closeButtonStyle"
87
+ | "descriptionMuted"
88
+ | "titleLeading"
89
+ | "hideStackedContent"
71
90
  > = {}): ToastClassNames {
72
91
  const radius = toastRadiusClasses[toastRadius];
73
92
  const compact = density === "compact";
74
93
  const descriptionSize = compact ? "text-xs" : "text-[13px]";
75
94
  return {
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`,
77
- title: "font-medium leading-snug",
95
+ 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${hideStackedContent ? ` ${hideStackedContentClassName}` : ""}`,
96
+ title: titleLeading === "5" ? "font-medium leading-5" : "font-medium leading-snug",
78
97
  description: descriptionMuted
79
98
  ? `${descriptionSize} leading-snug text-muted-foreground!`
80
99
  : `${descriptionSize} leading-snug text-muted-foreground`,
@@ -148,6 +167,9 @@ function SonnerToaster({
148
167
  closeButtonStyle = "default",
149
168
  sonnerVars = false,
150
169
  descriptionMuted,
170
+ titleLeading,
171
+ loadingIcon,
172
+ hideStackedContent,
151
173
  toastOptions,
152
174
  icons,
153
175
  style,
@@ -155,8 +177,14 @@ function SonnerToaster({
155
177
  closeButton,
156
178
  ...props
157
179
  }: ToasterProps & SonnerToasterOptions) {
180
+ const loadingOverride =
181
+ loadingIcon != null ? { loading: loadingIcon } : undefined;
158
182
  const mergedIcons =
159
- toastIcons === "filled24" ? { ...filledToastIcons, ...icons } : icons;
183
+ toastIcons === "filled24"
184
+ ? { ...filledToastIcons, ...loadingOverride, ...icons }
185
+ : loadingOverride
186
+ ? { ...loadingOverride, ...icons }
187
+ : icons;
160
188
  return (
161
189
  <Toaster
162
190
  expand={false}
@@ -175,6 +203,8 @@ function SonnerToaster({
175
203
  density,
176
204
  closeButtonStyle,
177
205
  descriptionMuted,
206
+ titleLeading,
207
+ hideStackedContent,
178
208
  }),
179
209
  },
180
210
  toastOptions,