@vvedantb/ui 0.33.0 → 0.35.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/README.md CHANGED
@@ -292,7 +292,7 @@ import {
292
292
  - `Button`: variants `flat`, `success` and `warning` (each is `ghost` plus an override). `loading` disables the button, sets `data-loading="true"` and shows a spinner in place of `startContent`; without `loading` there is no `data-loading` attribute. `endContent` renders after the children. Both are ignored with `asChild` (`loading` still disables).
293
293
  - `DropdownMenuContent`: `fitViewport` caps the height on small screens and scrolls. It reads the optional `--vv-height` CSS variable, which the app may set to the visual viewport height (falls back to `70dvh`). `suppressAltKey` stops a bare Alt press from moving focus. `restoreScrollLockOnClose` calls `restoreModalScrollLock` on close (now and on the next tick). `ContextMenuContent` takes `suppressAltKey` too.
294
294
  - `DropdownMenuItem` `endContent` and `ContextMenuItem` `startContent`, `description` and `endContent`: the label moves into a growing column (`min-w-0 flex-1`), the description sits under it and `endContent` sits at the end. `DropdownMenuSubTrigger` and `ContextMenuSubTrigger` take `startContent`.
295
- - `PopoverContent` `fitViewport`: grows from the trigger (`--radix-popper-transform-origin`), defaults `collisionPadding` to 8 (your value wins) and, on small screens, caps width to `100vw - 1.5rem` and height as above.
295
+ - `PopoverContent` `fitViewport`: grows from the trigger (`--radix-popper-transform-origin`) and, on small screens, caps width to `100vw - 1.5rem` and height as above.
296
296
  - `CommandDialog`: `title` and `description` add a visually hidden dialog header. `className` merges onto the dialog content. `shouldFilter` goes to `Command`. `listLayout` is `"pinned"` (default, fixed list height) or `"auto"` (the list sizes itself and items keep your styling).
297
297
  - `restoreModalScrollLock()`: clears body `pointer-events`, and, when no dialog or alert dialog is open, the body and root `overflow` and body `padding-right` left by a modal menu.
298
298
  - `CardHeader`: `flex flex-col space-y-1.5 p-6`.
@@ -1434,9 +1434,10 @@ New props and exports:
1434
1434
 
1435
1435
  - `DialogContent` `viewportAware` (default `true`): the bottom sheet on compact viewports, visual-viewport sizing, keyboard-aware scrolling and the body pointer-events fix. `false` keeps a centred dialog at every size and leaves the viewport and body alone.
1436
1436
  - `PopoverAnchor` (Radix `Popover.Anchor`).
1437
- - `HoverCardContent` and `TooltipContent` `portal`: render into `document.body`.
1438
- - `ContextMenuSubContent` `portal`.
1439
- - Config `Select.triggerSlot`: adds `data-slot="select-trigger"`, which the `ButtonGroup` selectors read.
1437
+ - `HoverCardContent` and `TooltipContent` `portal`: render into `document.body`. Default `true`; `false` renders inline.
1438
+ - `ContextMenuSubContent` `portal`. Default `true` (also `ContextMenu.subContentPortal`).
1439
+ - Config `Select.triggerSlot`: adds `data-slot="select-trigger"`, which the `ButtonGroup` selectors read. Default `true`.
1440
+ - `PopoverContent`, `DropdownMenuContent`, `ContextMenuContent` and `SelectContent` default `collisionPadding` to 8 (a prop or config value wins).
1440
1441
  - Config `Command.emptyClassNameReplaces`: a `className` on `CommandEmpty` (even `""`) replaces its classes instead of merging.
1441
1442
 
1442
1443
  `DialogTrigger`, `PopoverTrigger`, `DropdownMenuTrigger` and `ContextMenuTrigger` are now thin wrappers that read the config; `HoverCard` and `TooltipProvider` are function components that read the configured delays. Same DOM and props without config.
@@ -1541,14 +1542,14 @@ const config = {
1541
1542
 
1542
1543
  Outside a `StickToBottom` (for example a page outline) use `JumpRail` with `items` (`{ id, title, preview?, level? }`), a `scrollRef` or `getScrollElement`, and `targetAttribute="data-toc-id"` or `resolveTarget`. `onReveal` runs when a target is not in the DOM; `onBeforeScroll` runs just before it. `onSelect(id)` runs after a click or Enter/Space pick, once the scroll has started or `onReveal` has run, so an app can move focus to the target for keyboard users; `ChatJumpRail` accepts it too. `observeKey` re-observes when the rendered window changes. The geometry helpers (`resolveJumpRailTopPercent`, `resolveJumpRailIndexFromPointer`, `resolveJumpRailHeightStyle`, `resolveJumpRailTickWidthClass`, `resolveJumpRailTickLevelStyle`) are exported.
1543
1544
  - `Message`: `classes.root`, `classes.user`, `classes.assistant` (the role classes of `Message`), `classes.content` (all `MessageContent` classes), `classes.branchSelector`, `classes.branchIcon` (a class in place of the arrows' `size={14}`), and `lazyActionTooltips`: a `MessageAction` mounts its tooltip on first hover or focus (focus opens it), not with the button.
1544
- - `PromptInput`: `classes.group` (the input group, default `overflow-hidden`), `classes.groupIdle` / `classes.groupDragOver` (added while no drag / an accepted drag is over the form), `classes.groupDefault` (added when no `containerClassName` is given), `classes.textarea`, `classes.footer`, `classes.submit`, `classes.tabItem`; `submitIcons.<send|submitted|streaming|error>`, `submitCrossfade` (cross-fade the submit icon on status change) and `menuTriggerAsChild` (`PromptInputActionMenuTrigger` becomes the Radix trigger instead of a button inside it). New `PromptInput` props: `dataDrop` (`{ type, onDrop }`: a non-file drag of that MIME type counts as a drag over the form and `onDrop` gets its string payload) and `inputGroupClassName` (same as `containerClassName`). The attachments context adds `replace(id, file)`; `accept` now matches extension patterns such as `.html` by file name; `useOptionalPromptInputController` is exported.
1545
+ - `PromptInput`: `classes.group` (the input group, default `overflow-hidden`), `classes.groupIdle` / `classes.groupDragOver` (added while no drag / an accepted drag is over the form), `classes.groupDefault` (added when no `containerClassName` is given), `classes.textarea`, `classes.footer`, `classes.submit`, `classes.tabItem`; `submitIcons.<send|submitted|streaming|error>`, `submitCrossfade` (cross-fade the submit icon on status change) and `menuTriggerAsChild` (default `true`: `PromptInputActionMenuTrigger` is the Radix trigger; `false` nests a button inside it). New `PromptInput` props: `dataDrop` (`{ type, onDrop }`: a non-file drag of that MIME type counts as a drag over the form and `onDrop` gets its string payload) and `inputGroupClassName` (same as `containerClassName`). The attachments context adds `replace(id, file)`; `accept` now matches extension patterns such as `.html` by file name; `useOptionalPromptInputController` is exported.
1545
1546
  - `PromptInputSubmit`: new prop `queued` (0.30.0). Set it when a send would join a queue (the agent is still working). The send icon becomes `SendQueueIcon`, an up arrow that morphs into a stack of bars while `queued` is `true`. Leave it unset to keep `submitIcons.send`. `SendQueueIcon` (`queued`, `className` default `size-4`) is exported for other send buttons. It uses `m`, so `queued` needs the app's `LazyMotion`.
1546
1547
  - `PromptInputSettings` (new): an overflow menu with a "Model" submenu (one radio list, or a submenu per provider). Entry: `providerLabel(provider)` (default the id), `providerIcon(provider)` (default none), `classes.trigger`.
1547
1548
  - `TraitsPanel` (new, with `isUltrathinkPrompt`, `applyUltrathinkPrefix`): reasoning, speed, context and thinking segments for a model picker; inside a `PromptInputProvider` it offers "Ultrathink" by editing the prompt. Entry: `classes.label`, `classes.empty`, `classes.option`, `classes.optionSelected`, `classes.optionIdle`, `classes.note`.
1548
1549
  - `PromptInputSpeech`, `useSpeechRecognition`, `getSpeechRecognition` (new): Web Speech API dictation; the button renders nothing without the API.
1549
1550
  - `Queue`: one family, used in one of two ways. The queued-messages widget is unchanged: `Queue` with `count`, `QueueHeader`, `QueueList` (`open`) and `QueueItem` with `text` (`model`, `personalityName`, `personaName`, `personaImage`, `onEdit`, `onRemove`). The composable parts (the AI Elements `Queue`, new): `Queue` without `count` (a plain root), `QueueSection`, `QueueSectionTrigger`, `QueueSectionLabel`, `QueueSectionContent`, `QueueList` (inside a composable `Queue`: a list capped at `max-h-48`), `QueueItem` without `text` (an `li`, takes `ref`), `QueueItemIndicator`, `QueueItemContent`, `QueueItemActions`, `QueueItemAction`. The composable parts render no `motion.*`, so they work under `<LazyMotion strict>`. Entry `Queue`: `classes.sectionTrigger`.
1550
1551
  - `Shimmer`: `mode`, `as`, `classes.base`, `classes.overlay`, `classes.band`, `classes.copy`. New prop `mode`: `"gradient"` (default) or `"mask"`, which sweeps a masked copy of the text with transforms only (no repaint, paused offscreen). `mask` needs the app to define `.shimmer-overlay`, `.shimmer-band` and `.shimmer-copy` (or the `classes` it names).
1551
- - `CodeBlock`: `plain`, `copyButtonSize`, `copyLabel` (a screen-reader label on the copy button), `copyEveryClick` (the copy button copies on every click, also while it shows the check; by default those clicks do nothing until it resets after `timeout`), `classes.container`, `classes.pre`, `classes.copyButton`, `classes.copyIcon` (a class in place of `size={14}`). New props: `plain` (no shiki: a `pre` with the code, and `children` replace that `pre` instead of sitting above it); `language` is now optional.
1552
+ - `CodeBlock`: `plain`, `copyButtonSize`, `copyLabel` (a screen-reader label on the copy button; without it the icon-only button gets `aria-label="Copy code"`), `copyEveryClick` (the copy button copies on every click, also while it shows the check; by default those clicks do nothing until it resets after `timeout`), `classes.container`, `classes.pre`, `classes.copyButton`, `classes.copyIcon` (a class in place of `size={14}`). New props: `plain` (no shiki: a `pre` with the code, and `children` replace that `pre` instead of sitting above it); `language` is now optional.
1552
1553
  - `Artifact`: `classes.root`, `classes.header`, `classes.title`, `classes.description`, `classes.actions`, `classes.action` (`ArtifactAction` and `ArtifactClose`), `classes.actionIcon`, `classes.content`, `actionSize`, `actionLabel` (`"sr-only"` names actions with a hidden span instead of `aria-label`). `ArtifactAction`: `tooltip` is optional (no tooltip, no wrapper) and `icon` also takes an icon component (`icon={IconCopy}`). New `ArtifactClose` (an action with an X, `label` default `"Close"`).
1553
1554
  - `Attachments`: `classes.item.<variant>`, `classes.preview.<variant>`, `classes.remove.<variant>` replace the variant classes of `Attachment`, `AttachmentPreview` and `AttachmentRemove`.
1554
1555
  - `FileChip` (new, no entry): a file name chip that opens the file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one main JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), a client entry without @vvedantb/server, convex or Clerk for Chrome extensions, design-token CSS, shared Tailwind v4 CSS, a Tailwind preset and packaged Instrument Sans fonts.",
6
6
  "type": "module",
@@ -41,7 +41,7 @@ export interface ActivityMarkdownProps {
41
41
  export interface ActivityTasksConfig {
42
42
  /** Agent name in the streaming header ("<name> is thinking..."). Default "Agent"; the `name` prop wins. */
43
43
  name?: string;
44
- /** Renders reasoning and mid-turn reply text. Default: `Streamdown`. */
44
+ /** Renders reasoning and mid-turn reply text. Default: the package `Markdown` (smooth streaming). */
45
45
  markdown?: ComponentType<ActivityMarkdownProps>;
46
46
  /**
47
47
  * An image for `ActivityThinkingIcon` (for example the app's logo mark),
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { createContext, memo, useCallback, useContext, useRef, useState } from "react";
4
- import type { ComponentProps, ReactNode, Ref } from "react";
4
+ import type { ComponentProps, ComponentType, ReactNode, Ref } from "react";
5
5
  import { flushSync } from "react-dom";
6
6
 
7
7
  import { Collapsible, CollapsibleContent } from "../ui/collapsible";
@@ -14,11 +14,7 @@ import {
14
14
  IconSearch,
15
15
  IconTerminal2,
16
16
  } from "@tabler/icons-react";
17
- import { cjk } from "@streamdown/cjk";
18
- import { code } from "@streamdown/code";
19
- import { math } from "@streamdown/math";
20
- import { mermaid } from "@streamdown/mermaid";
21
- import { Streamdown } from "streamdown";
17
+ import { Markdown as PackageMarkdown } from "../markdown/Markdown";
22
18
  import { useCn, useUiConfig } from "../ui/ui-config";
23
19
  import { Spinner } from "../ui/spinner";
24
20
  import { CrossfadeIconSlot } from "../ui/crossfade-icon";
@@ -65,19 +61,9 @@ import {
65
61
  import { useStreamingDisclosure } from "./use-streaming-disclosure";
66
62
  import { RollingNumber } from "../ui/rolling-number";
67
63
 
68
- const streamdownPlugins = { cjk, code, math, mermaid };
69
-
70
- function StreamdownMarkdown({ className, mode, children }: ActivityMarkdownProps) {
71
- return (
72
- <Streamdown className={className} mode={mode} plugins={streamdownPlugins}>
73
- {children}
74
- </Streamdown>
75
- );
76
- }
77
-
78
- /** The app's Markdown renderer (`UiConfig.ActivityTasks.markdown`), or Streamdown. */
79
- function useActivityMarkdown() {
80
- return useUiConfig("ActivityTasks")?.markdown ?? StreamdownMarkdown;
64
+ /** The app's Markdown renderer (`UiConfig.ActivityTasks.markdown`), or the package `Markdown`. */
65
+ function useActivityMarkdown(): ComponentType<ActivityMarkdownProps> {
66
+ return useUiConfig("ActivityTasks")?.markdown ?? PackageMarkdown;
81
67
  }
82
68
 
83
69
  const ActivityStreamingHeader = memo(function ActivityStreamingHeader({
@@ -317,12 +317,12 @@ export const AttachmentRemove = ({
317
317
  remove === undefined && variant === "grid" && [
318
318
  "absolute top-2 right-2 size-6 rounded-full p-0",
319
319
  cn(flatPanelSubtleClass, flatElevatedSurfaceHoverClass),
320
- "opacity-0 transition-opacity group-hover:opacity-100",
320
+ "reveal-on-hover transition-opacity",
321
321
  "[&>svg]:size-3",
322
322
  ],
323
323
  remove === undefined && variant === "inline" && [
324
324
  "size-5 rounded p-0",
325
- "opacity-0 transition-opacity group-hover:opacity-100",
325
+ "reveal-on-hover transition-opacity",
326
326
  "[&>svg]:size-2.5",
327
327
  ],
328
328
  remove === undefined && variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
@@ -101,7 +101,8 @@ type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
101
101
  /**
102
102
  * `UiConfig.CodeBlock`: `plain` default, and classes that replace the
103
103
  * container, the plain-mode `pre`, the copy button and its icon. A
104
- * `copyLabel` adds a screen-reader label to the copy button.
104
+ * `copyLabel` adds a screen-reader label to the copy button. Without it, the
105
+ * icon-only button gets `aria-label="Copy code"`.
105
106
  * `copyEveryClick` makes the copy button copy again while it still shows
106
107
  * the check; by default those clicks do nothing until it resets.
107
108
  */
@@ -482,6 +483,7 @@ export const CodeBlockCopyButton = ({
482
483
  onClick={copyToClipboard}
483
484
  size={config?.copyButtonSize ?? "icon"}
484
485
  variant="ghost"
486
+ aria-label={config?.copyLabel || children !== undefined ? undefined : "Copy code"}
485
487
  {...props}
486
488
  >
487
489
  {children ?? (iconClass === undefined ? <Icon size={14} /> : <Icon className={iconClass} />)}
@@ -101,7 +101,7 @@ export const ConversationEmptyState = ({
101
101
  );
102
102
  };
103
103
  export type ConversationScrollButtonProps = ComponentProps<typeof Button> & {
104
- /** Text after the icon; also the button's `aria-label` and `title`. */
104
+ /** Text after the icon; also the button's `aria-label` and `title`. Without it, the icon-only button's `aria-label` is "Scroll to bottom". */
105
105
  label?: string;
106
106
  /** Replaces the arrow icon. */
107
107
  icon?: ReactNode;
@@ -184,7 +184,9 @@ export const ConversationScrollButton = ({
184
184
  hide();
185
185
  scrollToBottom();
186
186
  };
187
- const motionClass = classes?.scrollButtonMotion ?? "absolute bottom-4 left-0 right-0 z-10 flex justify-center";
187
+ // The full-width row lets clicks through to the messages; the button takes them.
188
+ const motionClass =
189
+ classes?.scrollButtonMotion ?? "pointer-events-none absolute bottom-4 left-0 right-0 z-10 flex justify-center";
188
190
  const presence = (
189
191
  <LazyMotion features={domAnimation}>
190
192
  <AnimatePresence initial={false}>
@@ -198,12 +200,16 @@ export const ConversationScrollButton = ({
198
200
  transition={defaults?.transition ?? fastTransition}
199
201
  >
200
202
  <Button
201
- className={merge(classes?.scrollButton ?? [flatInteractiveClass, "rounded-full"], className)}
203
+ className={merge(
204
+ classes?.scrollButton ?? [flatInteractiveClass, "rounded-full"],
205
+ "pointer-events-auto",
206
+ className,
207
+ )}
202
208
  onClick={handleScrollToBottom}
203
209
  size={size ?? defaults?.size ?? "icon"}
204
210
  type="button"
205
211
  variant="outline"
206
- {...(label ? { "aria-label": label, title: label } : {})}
212
+ {...(label ? { "aria-label": label, title: label } : { "aria-label": "Scroll to bottom" })}
207
213
  {...props}
208
214
  >
209
215
  {icon ?? <IconArrowDown className="size-4" />}
@@ -105,8 +105,9 @@ const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
105
105
  * (added when no `containerClassName` is given), `textarea`, `footer`,
106
106
  * `submit` and `tabItem`. `submitIcons` replace the submit
107
107
  * button's icon per status; `submitCrossfade` cross-fades between them.
108
- * `menuTriggerAsChild` makes `PromptInputActionMenuTrigger` the trigger
109
- * itself rather than a button inside the menu's trigger button.
108
+ * `menuTriggerAsChild` (default `true`) makes `PromptInputActionMenuTrigger`
109
+ * the trigger itself; `false` nests its button inside the menu's trigger
110
+ * button (invalid HTML).
110
111
  */
111
112
  export interface PromptInputConfig {
112
113
  classes?: {
@@ -823,7 +824,7 @@ export const PromptInputFooter = ({
823
824
  return (
824
825
  <InputGroupAddon
825
826
  align="block-end"
826
- className={merge(footer ?? "justify-between gap-1", className)}
827
+ className={merge(footer ?? "justify-between gap-1 max-sm:flex-wrap", className)}
827
828
  {...props}
828
829
  />
829
830
  );
@@ -895,7 +896,7 @@ export const PromptInputActionMenuTrigger = ({
895
896
  children,
896
897
  ...props
897
898
  }: PromptInputActionMenuTriggerProps) => {
898
- const asChild = useUiConfig("PromptInput")?.menuTriggerAsChild;
899
+ const asChild = useUiConfig("PromptInput")?.menuTriggerAsChild ?? true;
899
900
  const button = (
900
901
  <PromptInputButton className={className} {...props}>
901
902
  {children ?? <IconPlus className="size-4" />}
@@ -25,7 +25,7 @@ export interface MenuConfig {
25
25
  /** `RadioItem` indicator. */
26
26
  radio?: React.ReactNode;
27
27
  };
28
- /** Default for `Content` `collisionPadding`. */
28
+ /** Default for `Content` `collisionPadding` (built-in: `8`). */
29
29
  collisionPadding?: number | Partial<Record<"top" | "right" | "bottom" | "left", number>>;
30
30
  }
31
31
 
@@ -16,7 +16,7 @@ export interface ButtonGroupConfig {
16
16
  }
17
17
 
18
18
  const buttonGroupBaseClasses =
19
- "flex w-fit items-stretch focus-visible:*:z-10 focus-visible:*:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=select-trigger]:last-of-type]:has-[select[aria-hidden=true]:last-child]:rounded-r-md has-[>[data-slot=button-group]]:gap-2";
19
+ "flex w-fit items-stretch focus-visible:*:z-10 focus-visible:*:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=select-trigger]:last-of-type]:has-[select[aria-hidden=true]:last-child]:rounded-r-md has-[>[data-slot=button-group]]:gap-2 max-sm:[&>*]:after:content-none";
20
20
 
21
21
  const buttonGroupOrientationClasses = {
22
22
  horizontal:
@@ -49,10 +49,10 @@ export interface ClearInputProps extends Omit<
49
49
  }
50
50
 
51
51
  const inputClasses =
52
- "flex h-10 w-full rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50";
52
+ "flex h-10 w-full max-sm:min-w-0 rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:text-sm transition-[border-color,box-shadow,background-color] duration-200 ease-smooth placeholder:text-field-placeholder disabled:cursor-not-allowed disabled:opacity-50";
53
53
 
54
54
  const buttonClasses =
55
- "absolute right-2 top-1/2 z-4 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color,transform] duration-100 ease-out active:scale-[0.92] hover:bg-surface-tertiary/50 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring";
55
+ "absolute right-2 top-1/2 z-4 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color,transform] duration-100 ease-out active:scale-[0.92] hover:bg-surface-tertiary/50 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring max-sm:hit-target";
56
56
 
57
57
  const ClearInput = React.forwardRef<HTMLInputElement, ClearInputProps>(
58
58
  ({ animation, clearButton, classes, ...props }, ref) => {
@@ -150,7 +150,7 @@ const CommandList = React.forwardRef<
150
150
  return (
151
151
  <CommandPrimitive.List
152
152
  ref={ref}
153
- className={merge(list ?? "max-h-80 overflow-y-auto overflow-x-hidden pb-2", className)}
153
+ className={merge(list ?? "max-h-80 max-sm:max-h-[60dvh] overflow-y-auto overflow-x-hidden pb-2", className)}
154
154
  {...props}
155
155
  />
156
156
  );
@@ -225,7 +225,7 @@ const CommandItem = React.forwardRef<
225
225
  ref={ref}
226
226
  className={merge(
227
227
  item ??
228
- "relative flex cursor-default gap-2 select-none items-center rounded-menu-item px-2.5 py-2 text-sm outline-none transition-colors data-[disabled=true]:pointer-events-none data-[selected=true]:bg-surface-tertiary/80 data-[selected=true]:text-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
228
+ "relative flex cursor-default gap-2 select-none items-center rounded-menu-item px-2.5 py-2 max-sm:min-h-10 text-sm outline-none transition-colors data-[disabled=true]:pointer-events-none data-[selected=true]:bg-surface-tertiary/80 data-[selected=true]:text-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
229
229
  className,
230
230
  )}
231
231
  {...props}
@@ -24,7 +24,7 @@ import { useCn, useUiConfig } from "./ui-config";
24
24
 
25
25
  /** App defaults for `ContextMenu*` (see `UiConfigProvider`). */
26
26
  export interface ContextMenuConfig extends MenuConfig {
27
- /** Default for `ContextMenuSubContent` `portal`. */
27
+ /** Default for `ContextMenuSubContent` `portal` (built-in: `true`). */
28
28
  subContentPortal?: boolean;
29
29
  }
30
30
 
@@ -88,7 +88,7 @@ const ContextMenuSubContent = React.forwardRef<
88
88
  {...props}
89
89
  />
90
90
  );
91
- return (portal ?? config?.subContentPortal) ? (
91
+ return (portal ?? config?.subContentPortal ?? true) ? (
92
92
  <ContextMenuPrimitive.Portal>{content}</ContextMenuPrimitive.Portal>
93
93
  ) : (
94
94
  content
@@ -109,7 +109,7 @@ const ContextMenuContent = React.forwardRef<
109
109
  <ContextMenuPrimitive.Portal>
110
110
  <ContextMenuPrimitive.Content
111
111
  ref={ref}
112
- collisionPadding={collisionPadding ?? config?.collisionPadding}
112
+ collisionPadding={collisionPadding ?? config?.collisionPadding ?? 8}
113
113
  className={merge(config?.classes?.content ?? [menuContentClass, "w-40"], className)}
114
114
  onKeyDown={suppressAltKey ? suppressAltKeyDown(onKeyDown) : onKeyDown}
115
115
  {...props}
@@ -127,7 +127,7 @@ const DropdownMenuContent = React.forwardRef<
127
127
  <DropdownMenuPrimitive.Content
128
128
  ref={ref}
129
129
  sideOffset={sideOffset ?? config?.sideOffset ?? 4}
130
- collisionPadding={collisionPadding ?? config?.collisionPadding}
130
+ collisionPadding={collisionPadding ?? config?.collisionPadding ?? 8}
131
131
  className={merge(
132
132
  config?.classes?.content ?? menuContentClass,
133
133
  fitViewport && fitViewportClass,
@@ -16,7 +16,7 @@ export interface HoverCardConfig {
16
16
  closeDelay?: number;
17
17
  /** Default for `HoverCardContent` `sideOffset`. */
18
18
  sideOffset?: number;
19
- /** Default for `HoverCardContent` `portal`. */
19
+ /** Default for `HoverCardContent` `portal` (built-in: `true`). */
20
20
  portal?: boolean;
21
21
  }
22
22
 
@@ -40,7 +40,7 @@ const HoverCardTrigger = HoverCardPrimitive.Trigger;
40
40
  const HoverCardContent = React.forwardRef<
41
41
  React.ComponentRef<typeof HoverCardPrimitive.Content>,
42
42
  React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {
43
- /** Render into `document.body` so a clipping parent cannot cut it off. */
43
+ /** Render into `document.body` so a clipping parent cannot cut it off (default `true`). */
44
44
  portal?: boolean;
45
45
  }
46
46
  >(({ className, align = "center", sideOffset, portal, ...props }, ref) => {
@@ -62,7 +62,7 @@ const HoverCardContent = React.forwardRef<
62
62
  {...props}
63
63
  />
64
64
  );
65
- return (portal ?? config?.portal) ? (
65
+ return (portal ?? config?.portal ?? true) ? (
66
66
  <HoverCardPrimitive.Portal>{content}</HoverCardPrimitive.Portal>
67
67
  ) : (
68
68
  content
@@ -116,10 +116,10 @@ function InputGroupAddon({
116
116
  const inputGroupButtonBaseClasses = "text-sm shadow-none flex gap-2 items-center";
117
117
 
118
118
  const inputGroupButtonSizeClasses = {
119
- xs: "h-7 gap-1 rounded-lg px-2 text-xs [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
120
- sm: "h-8 rounded-lg px-2.5 gap-1.5 text-xs has-[>svg]:px-2.5",
121
- "icon-xs": "size-7 rounded-lg p-0 has-[>svg]:p-0",
122
- "icon-sm": "size-8 rounded-lg p-0 has-[>svg]:p-0",
119
+ xs: "h-7 gap-1 rounded-lg px-2 text-xs [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2 max-sm:hit-target",
120
+ sm: "h-8 rounded-lg px-2.5 gap-1.5 text-xs has-[>svg]:px-2.5 max-sm:hit-target",
121
+ "icon-xs": "size-7 rounded-lg p-0 has-[>svg]:p-0 max-sm:hit-target",
122
+ "icon-sm": "size-8 rounded-lg p-0 has-[>svg]:p-0 max-sm:hit-target",
123
123
  };
124
124
 
125
125
  const inputGroupButtonVariants = cva(inputGroupButtonBaseClasses, {
@@ -15,7 +15,7 @@ export interface PopoverConfig {
15
15
  classes?: { trigger?: string; content?: string };
16
16
  /** Default for `PopoverContent` `sideOffset`. */
17
17
  sideOffset?: number;
18
- /** Default for `PopoverContent` `collisionPadding`. */
18
+ /** Default for `PopoverContent` `collisionPadding` (built-in: `8`). */
19
19
  collisionPadding?: PopoverContentProps["collisionPadding"];
20
20
  }
21
21
 
@@ -40,22 +40,20 @@ const PopoverContent = React.forwardRef<
40
40
  React.ComponentRef<typeof PopoverPrimitive.Content>,
41
41
  PopoverContentProps & {
42
42
  /**
43
- * Grow from the trigger, keep 8px from the viewport edge (unless
44
- * `collisionPadding` is set) and, on small screens, cap width and height.
43
+ * Grow from the trigger and, on small screens, cap width and height.
45
44
  */
46
45
  fitViewport?: boolean;
47
46
  }
48
47
  >(({ className, align = "center", sideOffset, fitViewport, collisionPadding, ...props }, ref) => {
49
48
  const config = useUiConfig("Popover");
50
49
  const merge = useCn();
51
- const padding = collisionPadding ?? config?.collisionPadding;
52
50
  return (
53
51
  <PopoverPrimitive.Portal>
54
52
  <PopoverPrimitive.Content
55
53
  ref={ref}
56
54
  align={align}
57
55
  sideOffset={sideOffset ?? config?.sideOffset ?? 6}
58
- collisionPadding={fitViewport ? (padding ?? 8) : padding}
56
+ collisionPadding={collisionPadding ?? config?.collisionPadding ?? 8}
59
57
  className={merge(
60
58
  config?.classes?.content ?? [
61
59
  "z-50 w-72 max-sm:max-w-[calc(100vw-2rem)] max-sm:max-h-[85dvh] max-sm:overflow-y-auto p-4 outline-none",
@@ -7,7 +7,7 @@ import { Kbd } from "./kbd";
7
7
  import { useCn, useUiConfig } from "./ui-config";
8
8
 
9
9
  const sizeClasses = {
10
- sm: { input: "h-8 pl-8 text-sm", icon: "left-2" },
10
+ sm: { input: "h-8 pl-8 text-base sm:text-sm", icon: "left-2" },
11
11
  md: { input: "h-10 pl-9", icon: "left-3" },
12
12
  };
13
13
 
package/src/ui/select.tsx CHANGED
@@ -33,9 +33,9 @@ export interface SelectConfig {
33
33
  /** `SelectItem` indicator. */
34
34
  check?: React.ReactNode;
35
35
  };
36
- /** Adds `data-slot="select-trigger"` to the trigger, for parent selectors such as `ButtonGroup`'s. */
36
+ /** Adds `data-slot="select-trigger"` to the trigger, for parent selectors such as `ButtonGroup`'s (built-in: `true`). */
37
37
  triggerSlot?: boolean;
38
- /** Default for `SelectContent` `collisionPadding`. */
38
+ /** Default for `SelectContent` `collisionPadding` (built-in: `8`). */
39
39
  collisionPadding?: number | Partial<Record<"top" | "right" | "bottom" | "left", number>>;
40
40
  }
41
41
 
@@ -52,7 +52,7 @@ const SelectTrigger = React.forwardRef<
52
52
  return (
53
53
  <SelectPrimitive.Trigger
54
54
  ref={ref}
55
- data-slot={config?.triggerSlot ? "select-trigger" : undefined}
55
+ data-slot={(config?.triggerSlot ?? true) ? "select-trigger" : undefined}
56
56
  className={merge(
57
57
  config?.classes?.trigger ?? [
58
58
  "flex h-10 w-full max-sm:min-w-0 items-center justify-between whitespace-nowrap rounded-field border border-border bg-field-background px-3.5 py-2 text-base sm:text-sm ring-offset-background placeholder:text-field-placeholder transition-[border-color,box-shadow,background-color] duration-200 ease-smooth disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
@@ -127,10 +127,10 @@ const SelectContent = React.forwardRef<
127
127
  <SelectPrimitive.Portal>
128
128
  <SelectPrimitive.Content
129
129
  ref={ref}
130
- collisionPadding={collisionPadding ?? config?.collisionPadding}
130
+ collisionPadding={collisionPadding ?? config?.collisionPadding ?? 8}
131
131
  className={merge(
132
132
  classes?.content ?? [
133
- "relative z-50 max-h-96 min-w-32 overflow-hidden outline-none",
133
+ "relative z-50 max-h-96 max-sm:max-h-[85dvh] min-w-32 overflow-hidden outline-none",
134
134
  floatingSurfaceClass,
135
135
  floatingSurfaceAnimateClass,
136
136
  ],
package/src/ui/sonner.tsx CHANGED
@@ -135,6 +135,9 @@ function createToasterClassNames({
135
135
  // `unstyled` drops sonner's `[data-content]` grow, so the text column grows
136
136
  // here and the action and cancel buttons sit at the right edge.
137
137
  content: "flex min-w-0 grow flex-col",
138
+ // `unstyled` also drops sonner's `position: relative` on `[data-icon]`, so
139
+ // the absolutely positioned `.sonner-loader` anchors here, not to the toast.
140
+ icon: "relative",
138
141
  title: titleLeading === "5" ? "font-medium leading-5" : "font-medium leading-snug",
139
142
  description: descriptionMuted
140
143
  ? `${descriptionSize} leading-snug text-muted-foreground!`
@@ -10,7 +10,7 @@ export interface TooltipConfig {
10
10
  delayDuration?: number;
11
11
  /** Default for `TooltipContent` `sideOffset`. */
12
12
  sideOffset?: number;
13
- /** Default for `TooltipContent` `portal`. */
13
+ /** Default for `TooltipContent` `portal` (built-in: `true`). */
14
14
  portal?: boolean;
15
15
  }
16
16
 
@@ -32,7 +32,7 @@ const TooltipTrigger = TooltipPrimitive.Trigger;
32
32
  const TooltipContent = React.forwardRef<
33
33
  React.ComponentRef<typeof TooltipPrimitive.Content>,
34
34
  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
35
- /** Render into `document.body` so a clipping parent cannot cut it off. */
35
+ /** Render into `document.body` so a clipping parent cannot cut it off (default `true`). */
36
36
  portal?: boolean;
37
37
  }
38
38
  >(({ className, sideOffset, portal, ...props }, ref) => {
@@ -50,7 +50,7 @@ const TooltipContent = React.forwardRef<
50
50
  {...props}
51
51
  />
52
52
  );
53
- return (portal ?? config?.portal) ? (
53
+ return (portal ?? config?.portal ?? true) ? (
54
54
  <TooltipPrimitive.Portal>{content}</TooltipPrimitive.Portal>
55
55
  ) : (
56
56
  content
package/tokens/README.md CHANGED
@@ -13,7 +13,7 @@ Design tokens and shared Tailwind v4 CSS for the vvedantb web apps. The CSS and
13
13
  | `@vvedantb/ui/tokens/css/modal.css` | `src/css/modal.css` | `.t-modal`, `.is-open`, `.is-closing` and `t-modal-presence-wait` (`@layer components`). |
14
14
  | `@vvedantb/ui/tokens/css/tabs.css` | `src/css/tabs.css` | `.t-tabs` container (0.15.0), `.t-tab` with its press scale, active colour, `.t-tabs-pill`, small-screen `.t-tabs` scrolling. |
15
15
  | `@vvedantb/ui/tokens/css/tabs-pills.css` | `src/css/tabs-pills.css` | `--tabs-*` colour variables, `.t-tabs--segmented`, `.t-tabs--pills` and pill hover / active fills. |
16
- | `@vvedantb/ui/tokens/css/utilities.css` | `src/css/utilities.css` | `@utility hit-target` and `@utility reveal-on-hover`. `rounded-surface` moved to `tokens.css` in 0.7.1. |
16
+ | `@vvedantb/ui/tokens/css/utilities.css` | `src/css/utilities.css` | `@utility hit-target`, `reveal-on-hover`, `motion-press`, `motion-base`, `motion-emphasized` and `scrollbar-none`. The motion utilities read `--motion-fast`, `--motion-base`, `--motion-ease-out` and `--motion-ease-emphasized`, with defaults in the utility. `rounded-surface` moved to `tokens.css` in 0.7.1. |
17
17
  | `@vvedantb/ui/tokens/css/scrollbar-thin.css` | `src/css/scrollbar-thin.css` | `.scrollbar-thin` (`@layer utilities`, always emitted). Imported by `tokens.css` from 0.15.0. |
18
18
  | `@vvedantb/ui/tokens/css/clear-input.css` | `src/css/clear-input.css` | `ClearInput` layers (`.t-clear*`) and the `--clear-*` / `--glow-*` timings. Imported by `tokens.css`. From 0.15.1 the layers clip in `.t-clear-clip`, so `.t-clear` keeps the focus ring, and the placeholder layer only shows during `.is-clearing`. |
19
19
  | `@vvedantb/ui/tokens/css/motion.css` | `src/css/motion.css` | `.ui-page-enter`, `.ui-enter`, `.ui-enter-up`, `.ui-stagger`, their keyframes and reduced-motion rules, and the `html[data-page-motion="off"]` overrides for `PageMotionProvider`. Imported by `tokens.css`. |
@@ -72,7 +72,7 @@ Tailwind inlines an `@import` where it sits, even in the middle of a file (it mu
72
72
  | `modal.css` | `--modal-open-dur`, `--modal-close-dur`, `--modal-scale`, `--modal-scale-close`, `--modal-ease` | tokens.css (0.15.0), or app `:root` with the compat file |
73
73
  | `tabs.css` | `--tabs-dur`, `--tabs-ease` (tokens.css, or app `:root` with the compat file); `--tabs-text-muted`, `--tabs-text-active`, `--tabs-pill-bg` | colours: tabs-pills.css or the app theme |
74
74
  | `tabs-pills.css` | `--muted`, `--segment`, `--segment-foreground`, `--default` | tokens.css or the app theme |
75
- | `utilities.css` | `--breakpoint-sm` (Tailwind default or app theme) | Tailwind |
75
+ | `utilities.css` | `--breakpoint-sm` (Tailwind default or app theme); optional `--motion-*` | Tailwind |
76
76
  | `scrollbar-thin.css` | `--scrollbar` | tokens.css or the app theme |
77
77
  | `clear-input.css`, `motion.css` | none (own `:root` defaults) | |
78
78
  | `type-scale.css` | none (own `@theme` values) | |
@@ -81,6 +81,16 @@
81
81
  border-radius: clamp(0.75rem, var(--radius), 1rem);
82
82
  }
83
83
 
84
+ @utility rounded-t-surface {
85
+ border-radius: clamp(0.75rem, var(--radius), 1rem)
86
+ clamp(0.75rem, var(--radius), 1rem) 0 0;
87
+ }
88
+
89
+ @utility rounded-b-surface {
90
+ border-radius: 0 0 clamp(0.75rem, var(--radius), 1rem)
91
+ clamp(0.75rem, var(--radius), 1rem);
92
+ }
93
+
84
94
  @utility rounded-menu-item {
85
95
  border-radius: min(var(--radius), 0.5rem);
86
96
  }
@@ -1,6 +1,13 @@
1
1
  /*
2
2
  * @vvedantb/ui/tokens/css/utilities.css: Tailwind `@utility` helpers. Generated
3
3
  * only when used, and they take variants (e.g. `max-sm:hit-target`).
4
+ *
5
+ * The motion utilities read these variables. Set them on :root to change
6
+ * (defaults shown):
7
+ * --motion-fast: 150ms (press feedback)
8
+ * --motion-base: 220ms (colour, shadow, opacity)
9
+ * --motion-ease-out: cubic-bezier(0.22, 1, 0.36, 1)
10
+ * --motion-ease-emphasized: cubic-bezier(0.2, 0.8, 0.2, 1)
4
11
  */
5
12
 
6
13
  /* Extends sub-40px controls to a 40x40px tap target without changing layout.
@@ -32,3 +39,52 @@
32
39
  }
33
40
  }
34
41
  }
42
+
43
+ /* Each motion utility lists `scale`, `translate` and `rotate` as well as
44
+ `transform`. Tailwind v4 compiles `scale-[0.98]` to `scale: 0.98` and
45
+ `translate-x-*` to `translate: ...`, the individual transform properties.
46
+ `transition-property: transform` does not match them, so without them a
47
+ press or slide would not animate. */
48
+
49
+ /* Hover and state changes: colour, shadow, opacity and transforms. */
50
+ @utility motion-base {
51
+ transition-property:
52
+ color, background-color, border-color, box-shadow, transform, scale,
53
+ translate, rotate, opacity;
54
+ transition-duration: var(--motion-base, 220ms);
55
+ transition-timing-function: var(--motion-ease-out, cubic-bezier(0.22, 1, 0.36, 1));
56
+ }
57
+
58
+ /* Movement on screen: transforms, opacity and shadow on the emphasized curve. */
59
+ @utility motion-emphasized {
60
+ transition-property: transform, scale, translate, rotate, opacity, box-shadow;
61
+ transition-duration: var(--motion-base, 220ms);
62
+ transition-timing-function: var(--motion-ease-emphasized, cubic-bezier(0.2, 0.8, 0.2, 1));
63
+ }
64
+
65
+ /* Pressable controls: pair with `active:scale-[0.97]`. The press runs on
66
+ --motion-fast and colour trails on --motion-base. It covers colour too,
67
+ because Tailwind's `transition-colors` comes later in the sheet at the same
68
+ specificity and would replace this whole declaration. So call sites need no
69
+ separate `transition-*` class. */
70
+ @utility motion-press {
71
+ transition-property:
72
+ transform, scale, translate, rotate, color, background-color, border-color,
73
+ box-shadow, opacity, fill, stroke;
74
+ transition-duration:
75
+ var(--motion-fast, 150ms), var(--motion-fast, 150ms),
76
+ var(--motion-fast, 150ms), var(--motion-fast, 150ms),
77
+ var(--motion-base, 220ms), var(--motion-base, 220ms),
78
+ var(--motion-base, 220ms), var(--motion-base, 220ms),
79
+ var(--motion-base, 220ms), var(--motion-base, 220ms),
80
+ var(--motion-base, 220ms);
81
+ transition-timing-function: var(--motion-ease-out, cubic-bezier(0.22, 1, 0.36, 1));
82
+ }
83
+
84
+ /* Hides the scrollbar. The element still scrolls (wheel, drag, script). */
85
+ @utility scrollbar-none {
86
+ scrollbar-width: none;
87
+ &::-webkit-scrollbar {
88
+ display: none;
89
+ }
90
+ }
@@ -118,6 +118,16 @@
118
118
  border-radius: clamp(0.75rem, var(--radius), 1rem);
119
119
  }
120
120
 
121
+ @utility rounded-t-surface {
122
+ border-radius: clamp(0.75rem, var(--radius), 1rem)
123
+ clamp(0.75rem, var(--radius), 1rem) 0 0;
124
+ }
125
+
126
+ @utility rounded-b-surface {
127
+ border-radius: 0 0 clamp(0.75rem, var(--radius), 1rem)
128
+ clamp(0.75rem, var(--radius), 1rem);
129
+ }
130
+
121
131
  @utility rounded-menu-item {
122
132
  border-radius: min(var(--radius), 0.5rem);
123
133
  }