@jinaan/uikit 0.1.3 → 0.1.5

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/src/dialog.tsx CHANGED
@@ -1,46 +1,289 @@
1
1
  import * as DialogPrimitive from "@radix-ui/react-dialog";
2
2
  import { X } from "@phosphor-icons/react";
3
3
  import {
4
+ createContext,
4
5
  forwardRef,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useRef,
10
+ useState,
5
11
  type ComponentPropsWithoutRef,
6
12
  type ElementRef,
7
13
  type HTMLAttributes,
8
14
  } from "react";
9
15
 
16
+ import { computeDialogFlight, type DialogOriginRect } from "./dialog-motion";
10
17
  import { cn } from "./utils";
11
18
 
12
- export const Dialog = DialogPrimitive.Root;
13
- export const DialogTrigger = DialogPrimitive.Trigger;
19
+ const SOURCE_SELECTOR =
20
+ 'button, a, [role="button"], [role="menuitem"], [data-dialog-origin]';
21
+
22
+ let lastSource: { rect: DialogOriginRect; at: number } | null = null;
23
+ let trackerInstalled = false;
24
+
25
+ function rectOf(element: Element): DialogOriginRect | null {
26
+ const rect = element.getBoundingClientRect();
27
+ if (rect.width <= 0 || rect.height <= 0) return null;
28
+ return {
29
+ left: rect.left,
30
+ top: rect.top,
31
+ width: rect.width,
32
+ height: rect.height,
33
+ };
34
+ }
35
+
36
+ function rememberSource(target: EventTarget | null) {
37
+ if (!(target instanceof Element)) return;
38
+ const element = target.closest(SOURCE_SELECTOR);
39
+ if (!element) return;
40
+ const rect = rectOf(element);
41
+ if (rect) lastSource = { rect, at: Date.now() };
42
+ }
43
+
44
+ function installSourceTracker() {
45
+ if (trackerInstalled || typeof document === "undefined") return;
46
+ trackerInstalled = true;
47
+ document.addEventListener(
48
+ "pointerdown",
49
+ (event) => rememberSource(event.target),
50
+ true,
51
+ );
52
+ document.addEventListener(
53
+ "keydown",
54
+ (event) => {
55
+ if (event.key !== "Enter" && event.key !== " ") return;
56
+ rememberSource(document.activeElement);
57
+ },
58
+ true,
59
+ );
60
+ }
61
+
62
+ function resolveOrigin(content: HTMLElement): DialogOriginRect | null {
63
+ if (lastSource && Date.now() - lastSource.at < 2000) {
64
+ const rect = lastSource.rect;
65
+ lastSource = null;
66
+ return rect;
67
+ }
68
+ lastSource = null;
69
+
70
+ const active = document.activeElement;
71
+ if (
72
+ active instanceof Element &&
73
+ active !== content &&
74
+ !content.contains(active) &&
75
+ active.matches(SOURCE_SELECTOR)
76
+ ) {
77
+ return rectOf(active);
78
+ }
79
+ return null;
80
+ }
81
+
82
+ const DIALOG_CLOSE_MS = 250;
83
+ const DialogClosingContext = createContext(false);
84
+
85
+ /** Dialog root; owns the delayed close so the fly-out can finish. */
86
+ export function Dialog({
87
+ open: controlledOpen,
88
+ defaultOpen,
89
+ onOpenChange,
90
+ children,
91
+ ...props
92
+ }: ComponentPropsWithoutRef<typeof DialogPrimitive.Root>) {
93
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(
94
+ defaultOpen ?? false,
95
+ );
96
+ const [closing, setClosing] = useState(false);
97
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
98
+ const open = controlledOpen ?? uncontrolledOpen;
99
+ const openRef = useRef(open);
100
+ openRef.current = open;
101
+ const controlled = controlledOpen !== undefined;
102
+
103
+ useEffect(() => installSourceTracker(), []);
104
+
105
+ useEffect(() => {
106
+ if (controlledOpen) setClosing(false);
107
+ }, [controlledOpen]);
108
+
109
+ useEffect(
110
+ () => () => {
111
+ if (closeTimer.current) clearTimeout(closeTimer.current);
112
+ },
113
+ [],
114
+ );
115
+
116
+ const handleOpenChange = (next: boolean) => {
117
+ if (next) {
118
+ if (closeTimer.current) clearTimeout(closeTimer.current);
119
+ setClosing(false);
120
+ if (!controlled) setUncontrolledOpen(true);
121
+ onOpenChange?.(true);
122
+ return;
123
+ }
124
+ if (!open || closing) return;
125
+
126
+ const reduced =
127
+ typeof window !== "undefined" &&
128
+ typeof window.matchMedia === "function" &&
129
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
130
+ if (reduced) {
131
+ if (!controlled) setUncontrolledOpen(false);
132
+ onOpenChange?.(false);
133
+ return;
134
+ }
135
+
136
+ setClosing(true);
137
+ closeTimer.current = setTimeout(() => {
138
+ if (!controlled) setUncontrolledOpen(false);
139
+ onOpenChange?.(false);
140
+ requestAnimationFrame(() => {
141
+ // Restore the overlay only when a controlled parent vetoed the close.
142
+ if (openRef.current) setClosing(false);
143
+ });
144
+ }, DIALOG_CLOSE_MS);
145
+ };
146
+
147
+ return (
148
+ <DialogClosingContext.Provider value={closing}>
149
+ <DialogPrimitive.Root
150
+ open={open}
151
+ onOpenChange={handleOpenChange}
152
+ {...props}
153
+ >
154
+ {children}
155
+ </DialogPrimitive.Root>
156
+ </DialogClosingContext.Provider>
157
+ );
158
+ }
159
+
160
+ export const DialogTrigger = forwardRef<
161
+ ElementRef<typeof DialogPrimitive.Trigger>,
162
+ ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
163
+ >(({ onClick, ...props }, ref) => (
164
+ <DialogPrimitive.Trigger
165
+ ref={ref}
166
+ onClick={(event) => {
167
+ rememberSource(event.currentTarget);
168
+ onClick?.(event);
169
+ }}
170
+ {...props}
171
+ />
172
+ ));
173
+ DialogTrigger.displayName = "DialogTrigger";
174
+
14
175
  export const DialogClose = DialogPrimitive.Close;
15
176
 
177
+ function startDialogMotion(content: HTMLDivElement, retry = true) {
178
+ if (content.dataset.dialogMotionStarted === "true") return;
179
+ if (
180
+ typeof window !== "undefined" &&
181
+ typeof window.matchMedia === "function" &&
182
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
183
+ ) {
184
+ return;
185
+ }
186
+
187
+ const target = rectOf(content);
188
+ if (!target) {
189
+ if (retry) {
190
+ window.requestAnimationFrame(() => startDialogMotion(content, false));
191
+ }
192
+ return;
193
+ }
194
+
195
+ content.dataset.dialogMotionStarted = "true";
196
+ const origin = resolveOrigin(content);
197
+ const flight = origin ? computeDialogFlight(origin, target) : null;
198
+ if (flight) {
199
+ content.style.setProperty("--dialog-dx", `${flight.dx}px`);
200
+ content.style.setProperty("--dialog-dy", `${flight.dy}px`);
201
+ content.style.setProperty("--dialog-sx", String(flight.sx));
202
+ content.style.setProperty("--dialog-sy", String(flight.sy));
203
+ content.classList.add("dialog-fly-in");
204
+ } else {
205
+ content.classList.add("dialog-pop-in");
206
+ }
207
+
208
+ const clear = () => {
209
+ content.classList.remove("dialog-fly-in", "dialog-pop-in");
210
+ content.style.removeProperty("--dialog-dx");
211
+ content.style.removeProperty("--dialog-dy");
212
+ content.style.removeProperty("--dialog-sx");
213
+ content.style.removeProperty("--dialog-sy");
214
+ };
215
+ const onEnd = (event: AnimationEvent) => {
216
+ if (event.target !== content) return;
217
+ if (
218
+ event.animationName !== "dialog-fly-in" &&
219
+ event.animationName !== "dialog-pop-in"
220
+ ) {
221
+ return;
222
+ }
223
+ content.removeEventListener("animationend", onEnd);
224
+ clear();
225
+ };
226
+ content.addEventListener("animationend", onEnd);
227
+ }
228
+
16
229
  export const DialogContent = forwardRef<
17
230
  ElementRef<typeof DialogPrimitive.Content>,
18
231
  ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
19
- >(({ className, children, ...props }, ref) => (
20
- <DialogPrimitive.Portal>
21
- <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-overlay backdrop-blur-[1px] data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0" />
22
- <DialogPrimitive.Content
23
- ref={ref}
24
- className={cn(
25
- "fixed left-1/2 top-1/2 z-50 flex max-h-[min(85vh,44rem)] w-full max-w-[22.4rem] -translate-x-1/2 -translate-y-1/2 flex-col gap-0 overflow-hidden",
26
- "rounded-2xl border border-[var(--dialog-shell-border)] bg-[var(--dialog-shell-bg)] pb-3 shadow-[var(--dialog-shell-shadow)] sm:pb-4",
27
- "data-[state=open]:animate-in data-[state=closed]:animate-out",
28
- "data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
29
- "data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
30
- "data-[state=open]:slide-in-from-top-2 duration-200 ease-out",
31
- "focus:outline-none",
32
- className,
33
- )}
34
- {...props}
35
- >
36
- {children}
37
- <DialogPrimitive.Close className="absolute right-3.5 top-3.5 z-10 rounded-sm text-subtle transition-colors hover:text-fg focus-visible:outline-none">
38
- <X className="size-4" />
39
- <span className="sr-only">Close</span>
40
- </DialogPrimitive.Close>
41
- </DialogPrimitive.Content>
42
- </DialogPrimitive.Portal>
43
- ));
232
+ >(({ className, children, ...props }, ref) => {
233
+ const closing = useContext(DialogClosingContext);
234
+ const contentRef = useRef<HTMLDivElement>(null);
235
+ const setRef = useCallback(
236
+ (node: HTMLDivElement | null) => {
237
+ contentRef.current = node;
238
+ if (node) startDialogMotion(node);
239
+ if (typeof ref === "function") {
240
+ ref(node);
241
+ return;
242
+ }
243
+ if (ref) {
244
+ (ref as { current: HTMLDivElement | null }).current = node;
245
+ }
246
+ },
247
+ [ref],
248
+ );
249
+
250
+ return (
251
+ <DialogPrimitive.Portal>
252
+ <DialogPrimitive.Overlay
253
+ className={cn(
254
+ "fixed inset-0 z-50 overflow-hidden bg-overlay data-[state=open]:animate-in data-[state=open]:fade-in-0",
255
+ !closing &&
256
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
257
+ closing && "opacity-0 transition-opacity duration-200",
258
+ )}
259
+ >
260
+ <span
261
+ aria-hidden="true"
262
+ className="dialog-noise pointer-events-none absolute inset-0"
263
+ />
264
+ </DialogPrimitive.Overlay>
265
+ <div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
266
+ <DialogPrimitive.Content
267
+ ref={setRef}
268
+ className={cn(
269
+ "dialog-content pointer-events-auto relative flex max-h-[min(85vh,44rem)] w-full max-w-[22.4rem] flex-col gap-0 overflow-hidden will-change-[transform,opacity]",
270
+ "rounded-2xl border border-(--dialog-shell-border) bg-(--dialog-shell-bg) pb-3 shadow-(--dialog-shell-shadow) sm:pb-4",
271
+ closing && "dialog-closing",
272
+ "focus:outline-none",
273
+ className,
274
+ )}
275
+ {...props}
276
+ >
277
+ {children}
278
+ <DialogPrimitive.Close className="absolute right-3.5 top-3.5 z-10 rounded-sm text-subtle transition-colors hover:text-fg focus-visible:outline-none">
279
+ <X className="size-4" />
280
+ <span className="sr-only">Close</span>
281
+ </DialogPrimitive.Close>
282
+ </DialogPrimitive.Content>
283
+ </div>
284
+ </DialogPrimitive.Portal>
285
+ );
286
+ });
44
287
  DialogContent.displayName = "DialogContent";
45
288
 
46
289
  /**
@@ -48,12 +291,15 @@ DialogContent.displayName = "DialogContent";
48
291
  * and right edges (the shell clips its top corners); the footer stays below on
49
292
  * the shell so the two surfaces read as distinct layers.
50
293
  */
51
- export function DialogPanel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
294
+ export function DialogPanel({
295
+ className,
296
+ ...props
297
+ }: HTMLAttributes<HTMLDivElement>) {
52
298
  return (
53
299
  <div
54
300
  data-slot="panel"
55
301
  className={cn(
56
- "flex min-h-0 flex-1 flex-col overflow-hidden rounded-b-2xl border-x-0 border-t-0 border-b border-[var(--dialog-panel-border)] bg-[var(--dialog-panel-bg)] shadow-[var(--dialog-panel-shadow)]",
302
+ "flex min-h-0 flex-1 flex-col overflow-hidden rounded-b-2xl border-x-0 border-t-0 border-b border-(--dialog-panel-border) bg-(--dialog-panel-bg) shadow-(--dialog-panel-shadow)",
57
303
  className,
58
304
  )}
59
305
  {...props}
@@ -62,7 +308,10 @@ export function DialogPanel({ className, ...props }: HTMLAttributes<HTMLDivEleme
62
308
  }
63
309
 
64
310
  /** Scrollable region inside `DialogPanel`; keeps the footer pinned. */
65
- export function DialogBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
311
+ export function DialogBody({
312
+ className,
313
+ ...props
314
+ }: HTMLAttributes<HTMLDivElement>) {
66
315
  return (
67
316
  <div
68
317
  data-slot="body"
@@ -72,12 +321,15 @@ export function DialogBody({ className, ...props }: HTMLAttributes<HTMLDivElemen
72
321
  );
73
322
  }
74
323
 
75
- export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
324
+ export function DialogHeader({
325
+ className,
326
+ ...props
327
+ }: HTMLAttributes<HTMLDivElement>) {
76
328
  return (
77
329
  <div
78
330
  data-slot="header"
79
331
  className={cn(
80
- "flex shrink-0 flex-col gap-1.5 border-b border-[var(--dialog-panel-border)] px-5 py-4",
332
+ "flex shrink-0 flex-col gap-1.5 border-b border-(--dialog-panel-border) px-5 py-4",
81
333
  className,
82
334
  )}
83
335
  {...props}
@@ -85,7 +337,10 @@ export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElem
85
337
  );
86
338
  }
87
339
 
88
- export function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
340
+ export function DialogFooter({
341
+ className,
342
+ ...props
343
+ }: HTMLAttributes<HTMLDivElement>) {
89
344
  return (
90
345
  <div
91
346
  data-slot="footer"
@@ -16,13 +16,13 @@ export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16
16
  export const DropdownMenuContent = forwardRef<
17
17
  ElementRef<typeof DropdownMenuPrimitive.Content>,
18
18
  ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
19
- >(({ className, sideOffset = 6, ...props }, ref) => (
19
+ >(({ className, sideOffset = 8, ...props }, ref) => (
20
20
  <DropdownMenuPrimitive.Portal>
21
21
  <DropdownMenuPrimitive.Content
22
22
  ref={ref}
23
23
  sideOffset={sideOffset}
24
24
  className={cn(
25
- "z-50 min-w-48 overflow-hidden rounded-lg border border-line bg-[var(--button-secondary-bg)] p-1 shadow-md",
25
+ "menu-surface btn-inner-border z-50 min-w-48 overflow-hidden rounded-lg border border-line bg-[var(--button-secondary-bg)] p-1",
26
26
  "data-[state=open]:animate-in data-[state=closed]:animate-out",
27
27
  "data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
28
28
  "data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
@@ -39,7 +39,9 @@ DropdownMenuContent.displayName = "DropdownMenuContent";
39
39
 
40
40
  export const DropdownMenuItem = forwardRef<
41
41
  ElementRef<typeof DropdownMenuPrimitive.Item>,
42
- ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
42
+ ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
43
+ inset?: boolean;
44
+ }
43
45
  >(({ className, inset, ...props }, ref) => (
44
46
  <DropdownMenuPrimitive.Item
45
47
  ref={ref}
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
- import { cn } from "./utils";
3
+ import { TableSurface } from "./table-surface";
4
4
 
5
5
  interface EmptyStateProps {
6
6
  icon?: ReactNode;
@@ -12,24 +12,21 @@ interface EmptyStateProps {
12
12
 
13
13
  export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
14
14
  return (
15
- <div
16
- className={cn(
17
- "flex flex-col items-center justify-center gap-4 rounded-card border border-line bg-surface px-6 py-16 text-center",
18
- className,
19
- )}
20
- >
21
- {icon ? (
22
- <div className="flex size-11 items-center justify-center rounded-xl border border-line bg-elevated text-subtle [&_svg]:size-6">
23
- {icon}
24
- </div>
25
- ) : null}
26
- <div className="space-y-1">
27
- <p className="text-sm font-medium text-fg">{title}</p>
28
- {description ? (
29
- <p className="mx-auto max-w-sm text-sm text-muted">{description}</p>
15
+ <TableSurface className={className}>
16
+ <div className="flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
17
+ {icon ? (
18
+ <div className="flex size-11 items-center justify-center rounded-xl border border-line bg-elevated text-subtle [&_svg]:size-6">
19
+ {icon}
20
+ </div>
30
21
  ) : null}
22
+ <div className="space-y-1">
23
+ <p className="text-sm font-medium text-fg">{title}</p>
24
+ {description ? (
25
+ <p className="mx-auto max-w-sm text-sm text-muted">{description}</p>
26
+ ) : null}
27
+ </div>
28
+ {action}
31
29
  </div>
32
- {action}
33
- </div>
30
+ </TableSurface>
34
31
  );
35
32
  }
package/src/label.tsx CHANGED
@@ -10,7 +10,7 @@ export const Label = forwardRef<
10
10
  <LabelPrimitive.Root
11
11
  ref={ref}
12
12
  className={cn(
13
- "text-sm font-medium text-fg leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
13
+ "mb-1 text-sm font-medium text-fg leading-none last:mb-0 peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
14
14
  className,
15
15
  )}
16
16
  {...props}
@@ -113,8 +113,8 @@ export function RadioCard({
113
113
  <span
114
114
  aria-hidden
115
115
  className={cn(
116
- "mt-0.5 grid size-4.5 shrink-0 place-items-center rounded-full border-2 border-transparent bg-line-strong transition-colors",
117
- "peer-checked:border-[5px] peer-checked:border-primary peer-checked:bg-primary-fg",
116
+ "mt-[5px] grid size-3.5 shrink-0 place-items-center rounded-full border-2 border-transparent bg-line-strong transition-colors",
117
+ "peer-checked:border-4 peer-checked:border-primary peer-checked:bg-primary-fg",
118
118
  "peer-focus-visible:ring-2 peer-focus-visible:ring-focus/40 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-canvas",
119
119
  )}
120
120
  />
package/src/select.tsx CHANGED
@@ -1,6 +1,10 @@
1
1
  import * as SelectPrimitive from "@radix-ui/react-select";
2
2
  import { CaretDown, Check } from "@phosphor-icons/react";
3
- import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
3
+ import {
4
+ forwardRef,
5
+ type ComponentPropsWithoutRef,
6
+ type ElementRef,
7
+ } from "react";
4
8
 
5
9
  import { cn } from "./utils";
6
10
 
@@ -8,16 +12,27 @@ export const Select = SelectPrimitive.Root;
8
12
  export const SelectGroup = SelectPrimitive.Group;
9
13
  export const SelectValue = SelectPrimitive.Value;
10
14
 
15
+ export interface SelectTriggerProps extends ComponentPropsWithoutRef<
16
+ typeof SelectPrimitive.Trigger
17
+ > {
18
+ /** `field` fills a form; `inline` mimics the compact app dropdown. */
19
+ variant?: "field" | "inline";
20
+ }
21
+
11
22
  export const SelectTrigger = forwardRef<
12
23
  ElementRef<typeof SelectPrimitive.Trigger>,
13
- ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
14
- >(({ className, children, ...props }, ref) => (
24
+ SelectTriggerProps
25
+ >(({ className, children, variant = "field", ...props }, ref) => (
15
26
  <SelectPrimitive.Trigger
16
27
  ref={ref}
17
28
  className={cn(
18
- "flex h-9 w-full items-center justify-between gap-2 rounded-md border border-line bg-surface px-3 text-sm text-fg",
19
- "focus:outline-none focus:border-primary focus:ring-2 focus:ring-focus/30 disabled:cursor-not-allowed disabled:opacity-50",
29
+ "flex items-center justify-between gap-2 text-sm text-fg",
30
+ "focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
20
31
  "[&>span]:truncate",
32
+ variant === "field" &&
33
+ "h-9 w-full rounded-md border border-line bg-surface px-3 focus:border-primary focus:ring-2 focus:ring-focus/30",
34
+ variant === "inline" &&
35
+ "btn-fill btn-inner-border btn-interactive h-8 w-auto rounded-(--control-radius) border-0 bg-(--button-secondary-bg) px-(--control-pad-x-sm) font-medium text-fg hover:bg-(--button-surface-hover) active:bg-(--button-surface-active) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-strong",
21
36
  className,
22
37
  )}
23
38
  {...props}
@@ -33,27 +48,35 @@ SelectTrigger.displayName = "SelectTrigger";
33
48
  export const SelectContent = forwardRef<
34
49
  ElementRef<typeof SelectPrimitive.Content>,
35
50
  ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
36
- >(({ className, children, position = "popper", ...props }, ref) => (
37
- <SelectPrimitive.Portal>
38
- <SelectPrimitive.Content
39
- ref={ref}
40
- position={position}
41
- className={cn(
42
- "z-50 min-w-[8rem] overflow-hidden rounded-lg border border-line bg-surface p-1 shadow-md",
43
- "data-[state=open]:animate-in data-[state=closed]:animate-out",
44
- "data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
45
- "data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
46
- "data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
47
- "data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
48
- "duration-150 ease-out",
49
- className,
50
- )}
51
- {...props}
52
- >
53
- <SelectPrimitive.Viewport className="p-0">{children}</SelectPrimitive.Viewport>
54
- </SelectPrimitive.Content>
55
- </SelectPrimitive.Portal>
56
- ));
51
+ >(
52
+ (
53
+ { className, children, position = "popper", sideOffset = 8, ...props },
54
+ ref,
55
+ ) => (
56
+ <SelectPrimitive.Portal>
57
+ <SelectPrimitive.Content
58
+ ref={ref}
59
+ position={position}
60
+ sideOffset={sideOffset}
61
+ className={cn(
62
+ "menu-surface btn-inner-border z-50 min-w-48 overflow-hidden rounded-lg border border-line bg-(--button-secondary-bg) p-1",
63
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
64
+ "data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
65
+ "data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
66
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
67
+ "data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
68
+ "duration-150 ease-out",
69
+ className,
70
+ )}
71
+ {...props}
72
+ >
73
+ <SelectPrimitive.Viewport className="p-0">
74
+ {children}
75
+ </SelectPrimitive.Viewport>
76
+ </SelectPrimitive.Content>
77
+ </SelectPrimitive.Portal>
78
+ ),
79
+ );
57
80
  SelectContent.displayName = "SelectContent";
58
81
 
59
82
  export const SelectItem = forwardRef<
@@ -63,7 +86,7 @@ export const SelectItem = forwardRef<
63
86
  <SelectPrimitive.Item
64
87
  ref={ref}
65
88
  className={cn(
66
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-fg outline-none focus:bg-elevated data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
89
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-fg outline-none focus:bg-(--menu-highlight) data-disabled:pointer-events-none data-disabled:opacity-50",
67
90
  className,
68
91
  )}
69
92
  {...props}
@@ -16,6 +16,10 @@ export interface TableSurfaceAction {
16
16
  }
17
17
 
18
18
  interface TableSurfaceProps {
19
+ /** Title above the header row. */
20
+ title?: ReactNode;
21
+ /** Supporting text under the title. */
22
+ description?: ReactNode;
19
23
  /** Header row content (a single row of column cells). */
20
24
  header?: ReactNode;
21
25
  /** Panel content: the body table, or an empty / loading / error state. */
@@ -65,6 +69,8 @@ function isActionConfig(
65
69
  * `tone` plus `headerClassName` / `panelClassName`.
66
70
  */
67
71
  export function TableSurface({
72
+ title,
73
+ description,
68
74
  header,
69
75
  children,
70
76
  action,
@@ -74,6 +80,7 @@ export function TableSurface({
74
80
  panelClassName,
75
81
  minHeight,
76
82
  }: TableSurfaceProps) {
83
+ const hasTitle = Boolean(title || description);
77
84
  const actionNode = isActionConfig(action) ? (
78
85
  <Button
79
86
  variant={action.variant ?? "primary"}
@@ -99,9 +106,24 @@ export function TableSurface({
99
106
  return (
100
107
  <TableContainer className={cn("relative", SHELL_TONE[tone], className)}>
101
108
  <div className="overflow-x-auto">
102
- {header ? (
109
+ {header || hasTitle ? (
103
110
  // Header rows must not inherit the body row hover surface.
104
111
  <div className={cn("[--table-row-hover:transparent]", headerClassName)}>
112
+ {hasTitle ? (
113
+ <div
114
+ className={cn(
115
+ "px-(--table-pad-x) pb-3 pt-4",
116
+ actionNode && "pr-28",
117
+ )}
118
+ >
119
+ {title ? (
120
+ <div className="text-sm font-semibold text-fg">{title}</div>
121
+ ) : null}
122
+ {description ? (
123
+ <div className="mt-0.5 text-sm text-muted">{description}</div>
124
+ ) : null}
125
+ </div>
126
+ ) : null}
105
127
  {header}
106
128
  </div>
107
129
  ) : null}