@vvedantb/ui 0.6.1 → 0.7.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
@@ -385,7 +385,7 @@ function PanelWidth() {
385
385
 
386
386
  Shadcn-style building blocks. The prop-driven components above are thin compositions of them and render the same DOM.
387
387
 
388
- Convention: compound parts are static members of a namespace object, `<Namespace>.<Part>`. There are three namespaces: `ConfirmDialog.*`, `AppShell.*` and `Sidebar.*`. `ConfirmDialog` and `AppShell` stay callable with their legacy props. `Sidebar` is a plain object. Prop types are exported flat, for example `ConfirmDialogRootProps`, `AppShellMainProps` and `SidebarRailItemProps`. Every part that renders an element merges `className` last and forwards native props.
388
+ Convention: compound parts are static members of a namespace object, `<Namespace>.<Part>`. There are four namespaces: `ConfirmDialog.*`, `AppShell.*`, `Sidebar.*` and (0.7.0) `EmptyState.*` (see [Spinner, empty state, tabs, badge and kbd](#spinner-empty-state-tabs-badge-and-kbd-070)). `ConfirmDialog` and `AppShell` stay callable with their legacy props. `Sidebar` is a plain object. Prop types are exported flat, for example `ConfirmDialogRootProps`, `AppShellMainProps` and `SidebarRailItemProps`. Every part that renders an element merges `className` last and forwards native props.
389
389
 
390
390
  ### `ConfirmDialog.*`
391
391
 
@@ -559,6 +559,65 @@ A full shell:
559
559
  </AppShell.Root>
560
560
  ```
561
561
 
562
+ ## Spinner, empty state, tabs, badge and kbd (0.7.0)
563
+
564
+ All of these are opt-in. Without the new props, components render as in 0.6.1.
565
+
566
+ ```tsx
567
+ import {
568
+ Badge, Button, EmptyState, Kbd, Spinner, Tabs, TabsList, TabsTrigger,
569
+ } from "@vvedantb/ui";
570
+
571
+ <Button disabled={saving}>
572
+ {saving ? <Spinner size={14} tone="inherit" decorative className="mr-1.5" /> : null}
573
+ Save
574
+ </Button>
575
+
576
+ <EmptyState.Root className="h-full">
577
+ <EmptyState.Icon icon={IconFolder} />
578
+ <EmptyState.Description>No files yet.</EmptyState.Description>
579
+ </EmptyState.Root>
580
+
581
+ <EmptyState.Root variant="panel">
582
+ <EmptyState.Icon icon={IconLink} />
583
+ <EmptyState.Title>No linked items</EmptyState.Title>
584
+ <EmptyState.Description>Link one to see it here.</EmptyState.Description>
585
+ <EmptyState.Actions><Button size="sm">Link</Button></EmptyState.Actions>
586
+ </EmptyState.Root>
587
+
588
+ <Tabs value={active}>
589
+ <TabsList>
590
+ <TabsTrigger value="keys" asChild>
591
+ <Link to="/settings/keys">Keys</Link>
592
+ </TabsTrigger>
593
+ </TabsList>
594
+ </Tabs>
595
+
596
+ <Tabs value={feature} onValueChange={setFeature}>
597
+ <TabsList variant="soft" aria-label="Features">
598
+ <TabsTrigger value="notes"><IconNotes size={15} />Notes</TabsTrigger>
599
+ </TabsList>
600
+ </Tabs>
601
+
602
+ <Badge variant="destructive-soft">Revoked</Badge>
603
+ <Kbd variant="subtle">Esc</Kbd>
604
+ ```
605
+
606
+ - `Spinner` `size`: `"sm"`, `"md"` (default) and `"lg"` add a `size-*` class; a number is passed to the icon as its pixel size and adds no size class. `stroke` sets the stroke width. `tone`: `"muted"` (default) adds `text-muted-foreground`; `"inherit"` adds no colour class, so the spinner takes the current text colour. A colour class in `className` also replaces the muted default. `decorative` renders `aria-hidden="true"` with no `role="status"` or `aria-label`; use it where the parent already has a label, such as a busy button. Without it, `aria-label` in props replaces `"Loading"`.
607
+ - `EmptyState.*`: composable "nothing here yet" block. Parts read the Root `variant`.
608
+
609
+ | Part | Purpose |
610
+ | --- | --- |
611
+ | `Root` | `<div>`, centred column. `variant`: `"default"` (`px-4 py-20`, open) or `"panel"` (`rounded-lg bg-surface-secondary px-4 py-10`). |
612
+ | `Icon` | `icon` component (for example a Tabler icon). `default`: the bare icon, `size` 40, `mb-3 text-muted-foreground`. `panel`: the icon (`size` 22, `stroke` 1.5, `text-muted-foreground`) in an `h-11 w-11 rounded-lg bg-surface-tertiary` tile. `size` and `stroke` override; `className` goes on the outer element (icon or tile). |
613
+ | `Title` | `<p>`, `text-sm font-medium text-foreground`; `default` adds `mb-1`. `asChild` renders your element (for example an `h3`) instead. |
614
+ | `Description` | `<p>`, `text-sm text-muted-foreground`; `panel` adds `mt-1 max-w-[240px]`. |
615
+ | `Actions` | `<div>`, `mt-4`. |
616
+
617
+ - `TabsList` `variant="soft"`: borderless pills that wrap and centre (`flex flex-wrap items-center justify-center gap-1.5`). Triggers inside it are `rounded-full px-4 py-2 text-sm`, muted until active, filled with `bg-surface-secondary` when active or hovered, and `relative overflow-hidden` so a child such as a progress bar can sit on the bottom edge. It uses no `t-tabs` CSS and has no focus ring of its own. `pills` and `segmented` are unchanged. Link tabs work with any variant: `TabsTrigger asChild` around the router link (Radix sets `role="tab"`, `data-state` and roving focus on it). The package does not depend on a router.
618
+ - `Badge` `variant="destructive-soft"`: neutral border with a faint danger tint (`border-border bg-danger/10 text-danger`). Resolves to the same classes as a default badge with `className="bg-danger/10 text-danger"`. `variant="success"` already resolves to the same classes as a default badge with `className="border-success/25 bg-success/12 text-success"`.
619
+ - `Kbd` `variant="subtle"`: a plain filled key for inline hints, `rounded bg-surface-secondary px-1.5 py-0.5 text-foreground`. No fixed height, font or size, so it follows the surrounding text.
620
+
562
621
  ## Toaster
563
622
 
564
623
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export * from "./ui/dialog";
21
21
  export * from "./ui/alert-dialog";
22
22
  export * from "./ui/confirm-dialog";
23
23
  export * from "./ui/dropdown-menu";
24
+ export * from "./ui/empty-state";
24
25
  export * from "./ui/hover-card";
25
26
  export * from "./ui/input";
26
27
  export * from "./ui/label";
package/src/ui/badge.tsx CHANGED
@@ -9,6 +9,8 @@ const badgeVariants = cva(
9
9
  default: "border-border bg-default text-foreground",
10
10
  secondary: "border-separator bg-default text-default-foreground",
11
11
  destructive: "border-danger/25 bg-danger/12 text-danger",
12
+ // Neutral border with a faint danger tint, e.g. a revoked or expired status.
13
+ "destructive-soft": "border-border bg-danger/10 text-danger",
12
14
  outline: "border-separator bg-transparent text-muted-foreground",
13
15
  success: "border-success/25 bg-success/12 text-success",
14
16
  warning: "border-warning/25 bg-warning/12 text-warning",
@@ -0,0 +1,119 @@
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cn } from "../utils/cn";
4
+
5
+ // `default`: open centred block, large bare icon, one muted line (a page or list body).
6
+ // `panel`: filled rounded block with the icon in a tile (a detail panel or modal section).
7
+ export type EmptyStateVariant = "default" | "panel";
8
+
9
+ const EmptyStateContext = React.createContext<EmptyStateVariant>("default");
10
+
11
+ export type EmptyStateRootProps = React.ComponentProps<"div"> & {
12
+ /** Layout for the other parts. Default `"default"`. */
13
+ variant?: EmptyStateVariant;
14
+ };
15
+
16
+ function Root({ variant = "default", className, ...props }: EmptyStateRootProps) {
17
+ return (
18
+ <EmptyStateContext value={variant}>
19
+ <div
20
+ {...props}
21
+ className={cn(
22
+ "flex flex-col items-center justify-center text-center",
23
+ variant === "panel" ? "rounded-lg bg-surface-secondary px-4 py-10" : "px-4 py-20",
24
+ className,
25
+ )}
26
+ />
27
+ </EmptyStateContext>
28
+ );
29
+ }
30
+ Root.displayName = "EmptyState.Root";
31
+
32
+ export type EmptyStateIconProps = {
33
+ /** Icon component, e.g. a Tabler icon. */
34
+ icon: React.ComponentType<{ size?: number | string; stroke?: number | string; className?: string }>;
35
+ /** Pixel size. Default 40 (`default`) or 22 (`panel`). */
36
+ size?: number;
37
+ /** Stroke width. Default the icon's own (`default`) or 1.5 (`panel`). */
38
+ stroke?: number;
39
+ /** Classes for the outer element: the icon (`default`) or its tile (`panel`). */
40
+ className?: string;
41
+ };
42
+
43
+ function Icon({ icon: IconComponent, size, stroke, className }: EmptyStateIconProps) {
44
+ if (React.use(EmptyStateContext) === "panel") {
45
+ return (
46
+ <div
47
+ className={cn(
48
+ "mb-3 flex h-11 w-11 items-center justify-center rounded-lg bg-surface-tertiary",
49
+ className,
50
+ )}
51
+ >
52
+ <IconComponent size={size ?? 22} className="text-muted-foreground" stroke={stroke ?? 1.5} />
53
+ </div>
54
+ );
55
+ }
56
+ return (
57
+ <IconComponent
58
+ size={size ?? 40}
59
+ stroke={stroke}
60
+ className={cn("mb-3 text-muted-foreground", className)}
61
+ />
62
+ );
63
+ }
64
+ Icon.displayName = "EmptyState.Icon";
65
+
66
+ export type EmptyStateTitleProps = React.ComponentProps<"p"> & {
67
+ /** Render the child element (e.g. an `h3`) with the title classes. */
68
+ asChild?: boolean;
69
+ };
70
+
71
+ function Title({ asChild = false, className, ...props }: EmptyStateTitleProps) {
72
+ const Comp = asChild ? Slot : "p";
73
+ return (
74
+ <Comp
75
+ {...props}
76
+ className={cn(
77
+ "text-sm font-medium text-foreground",
78
+ React.use(EmptyStateContext) === "default" && "mb-1",
79
+ className,
80
+ )}
81
+ />
82
+ );
83
+ }
84
+ Title.displayName = "EmptyState.Title";
85
+
86
+ export type EmptyStateDescriptionProps = React.ComponentProps<"p">;
87
+
88
+ function Description({ className, ...props }: EmptyStateDescriptionProps) {
89
+ return (
90
+ <p
91
+ {...props}
92
+ className={cn(
93
+ React.use(EmptyStateContext) === "panel" && "mt-1 max-w-[240px]",
94
+ "text-sm text-muted-foreground",
95
+ className,
96
+ )}
97
+ />
98
+ );
99
+ }
100
+ Description.displayName = "EmptyState.Description";
101
+
102
+ export type EmptyStateActionsProps = React.ComponentProps<"div">;
103
+
104
+ function Actions({ className, ...props }: EmptyStateActionsProps) {
105
+ return <div {...props} className={cn("mt-4", className)} />;
106
+ }
107
+ Actions.displayName = "EmptyState.Actions";
108
+
109
+ /**
110
+ * Composable "nothing here yet" block: `EmptyState.Root` sets the variant, the
111
+ * other parts read it. Render only the parts you need.
112
+ */
113
+ export const EmptyState = {
114
+ Root,
115
+ Icon,
116
+ Title,
117
+ Description,
118
+ Actions,
119
+ };
package/src/ui/kbd.tsx CHANGED
@@ -1,14 +1,24 @@
1
1
  import * as React from "react";
2
2
  import { cn } from "../utils/cn";
3
3
 
4
- const Kbd = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
5
- ({ className, ...props }, ref) => (
4
+ // `subtle`: plain filled key for inline hints; size and font come from the
5
+ // surrounding text.
6
+ const kbdVariantClasses = {
7
+ default:
8
+ "pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded-md bg-surface-secondary px-1.5 font-mono text-[10px] font-medium text-muted-foreground",
9
+ subtle: "rounded bg-surface-secondary px-1.5 py-0.5 text-foreground",
10
+ };
11
+
12
+ export type KbdProps = React.HTMLAttributes<HTMLElement> & {
13
+ /** Default `"default"`. */
14
+ variant?: keyof typeof kbdVariantClasses;
15
+ };
16
+
17
+ const Kbd = React.forwardRef<HTMLElement, KbdProps>(
18
+ ({ className, variant = "default", ...props }, ref) => (
6
19
  <kbd
7
20
  ref={ref}
8
- className={cn(
9
- "pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded-md bg-surface-secondary px-1.5 font-mono text-[10px] font-medium text-muted-foreground",
10
- className,
11
- )}
21
+ className={cn(kbdVariantClasses[variant], className)}
12
22
  {...props}
13
23
  />
14
24
  ),
@@ -7,18 +7,34 @@ const sizeClasses = {
7
7
  lg: "size-8",
8
8
  };
9
9
 
10
+ export type SpinnerProps = Omit<React.ComponentPropsWithoutRef<"svg">, "size" | "stroke"> & {
11
+ /** Named size (a `size-*` class) or a pixel size passed to the icon (no size class). Default `"md"`. */
12
+ size?: "sm" | "md" | "lg" | number;
13
+ /** Icon stroke width. Default the icon's own (2). */
14
+ stroke?: number | string;
15
+ /** `"muted"` adds `text-muted-foreground`; `"inherit"` adds no colour class, so the icon uses `currentColor`. Default `"muted"`. */
16
+ tone?: "muted" | "inherit";
17
+ /** Hide from assistive tech (`aria-hidden`, no `role="status"` or label), e.g. inside a labelled button. Default `false`. */
18
+ decorative?: boolean;
19
+ };
20
+
10
21
  function Spinner({
11
22
  size = "md",
23
+ tone = "muted",
24
+ decorative = false,
12
25
  className,
13
26
  ...props
14
- }: Omit<React.ComponentPropsWithoutRef<"svg">, "size" | "stroke"> & {
15
- size?: "sm" | "md" | "lg";
16
- }) {
27
+ }: SpinnerProps) {
17
28
  return (
18
29
  <IconLoader2
19
- role="status"
20
- aria-label="Loading"
21
- className={cn("animate-spin text-muted-foreground", sizeClasses[size], className)}
30
+ {...(decorative ? { "aria-hidden": true } : { role: "status", "aria-label": "Loading" })}
31
+ size={typeof size === "number" ? size : undefined}
32
+ className={cn(
33
+ "animate-spin",
34
+ tone === "muted" && "text-muted-foreground",
35
+ typeof size === "number" ? undefined : sizeClasses[size],
36
+ className,
37
+ )}
22
38
  {...props}
23
39
  />
24
40
  );
package/src/ui/tabs.tsx CHANGED
@@ -8,7 +8,11 @@ const Tabs = TabsPrimitive.Root;
8
8
 
9
9
  // `pills` (default): isolated rounded pills with a gap, no shared track.
10
10
  // `segmented`: connected bar with a sliding pill, kept for marketing surfaces.
11
- type TabsListVariant = "pills" | "segmented";
11
+ // `soft`: borderless wrapping pills, the active one filled. Uses no `t-tabs`
12
+ // CSS, so triggers can hold an absolutely placed child (e.g. a progress bar).
13
+ type TabsListVariant = "pills" | "segmented" | "soft";
14
+
15
+ const TabsListVariantContext = React.createContext<TabsListVariant>("pills");
12
16
 
13
17
  const TabsList = React.forwardRef<
14
18
  React.ComponentRef<typeof TabsPrimitive.List>,
@@ -82,21 +86,26 @@ const TabsList = React.forwardRef<
82
86
  }, [variant]);
83
87
 
84
88
  return (
85
- <TabsPrimitive.List
86
- ref={mergedRef}
87
- className={cn(
88
- "t-tabs max-sm:max-w-full max-sm:justify-center-safe max-sm:overflow-x-auto max-sm:scrollbar-none",
89
- variant === "segmented" ? "t-tabs--segmented" : "t-tabs--pills",
90
- className,
91
- )}
92
- data-variant={variant}
93
- {...props}
94
- >
95
- {variant === "segmented" ? (
96
- <span ref={pillRef} className="t-tabs-pill" aria-hidden="true" />
97
- ) : null}
98
- {children}
99
- </TabsPrimitive.List>
89
+ <TabsListVariantContext value={variant}>
90
+ <TabsPrimitive.List
91
+ ref={mergedRef}
92
+ className={cn(
93
+ variant === "soft"
94
+ ? "flex flex-wrap items-center justify-center gap-1.5"
95
+ : "t-tabs max-sm:max-w-full max-sm:justify-center-safe max-sm:overflow-x-auto max-sm:scrollbar-none",
96
+ variant === "segmented" && "t-tabs--segmented",
97
+ variant === "pills" && "t-tabs--pills",
98
+ className,
99
+ )}
100
+ data-variant={variant}
101
+ {...props}
102
+ >
103
+ {variant === "segmented" ? (
104
+ <span ref={pillRef} className="t-tabs-pill" aria-hidden="true" />
105
+ ) : null}
106
+ {children}
107
+ </TabsPrimitive.List>
108
+ </TabsListVariantContext>
100
109
  );
101
110
  });
102
111
  TabsList.displayName = TabsPrimitive.List.displayName;
@@ -108,7 +117,9 @@ const TabsTrigger = React.forwardRef<
108
117
  <TabsPrimitive.Trigger
109
118
  ref={ref}
110
119
  className={cn(
111
- "t-tab inline-flex items-center justify-center gap-1.5 whitespace-nowrap text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
120
+ React.use(TabsListVariantContext) === "soft"
121
+ ? "relative flex items-center gap-2 overflow-hidden rounded-full px-4 py-2 text-sm text-muted-foreground transition-[transform,background-color] hover:bg-surface-secondary active:scale-[0.97] data-[state=active]:bg-surface-secondary data-[state=active]:text-foreground"
122
+ : "t-tab inline-flex items-center justify-center gap-1.5 whitespace-nowrap text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
112
123
  className,
113
124
  )}
114
125
  {...props}