@boyernick/standard-ui-react 0.1.1-canary.3 → 0.1.1-canary.34

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.
Files changed (89) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +5 -2
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/attachment.tsx +5 -2
  5. package/src/autocomplete.tsx +12 -9
  6. package/src/badge.tsx +73 -9
  7. package/src/block-editor.tsx +1747 -0
  8. package/src/brand.tsx +2 -2
  9. package/src/breadcrumb.tsx +46 -21
  10. package/src/button.tsx +23 -12
  11. package/src/calendar.tsx +25 -11
  12. package/src/card.tsx +57 -14
  13. package/src/carousel.tsx +339 -15
  14. package/src/checkbox.tsx +36 -13
  15. package/src/code-block.tsx +66 -5
  16. package/src/collapsible.tsx +7 -3
  17. package/src/combobox.tsx +17 -15
  18. package/src/command.tsx +41 -14
  19. package/src/context-menu.tsx +6 -3
  20. package/src/date-picker.tsx +174 -0
  21. package/src/dialog.tsx +33 -10
  22. package/src/empty.tsx +4 -4
  23. package/src/field.tsx +4 -3
  24. package/src/filter-group.tsx +143 -0
  25. package/src/icons.tsx +55 -1
  26. package/src/illustrations.tsx +219 -141
  27. package/src/index.ts +263 -18
  28. package/src/input.tsx +8 -5
  29. package/src/kbd.tsx +49 -0
  30. package/src/lib/focus.ts +41 -0
  31. package/src/lib/motion.ts +16 -1
  32. package/src/lib/popup.ts +45 -0
  33. package/src/lifeline/company-icon.tsx +71 -0
  34. package/src/lifeline/index.ts +42 -0
  35. package/src/lifeline/lifeline-data.ts +97 -0
  36. package/src/lifeline/lifeline-desktop.tsx +204 -0
  37. package/src/lifeline/lifeline-event.tsx +108 -0
  38. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  39. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  40. package/src/lifeline/lifeline-icons.tsx +39 -0
  41. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  42. package/src/lifeline/lifeline-labels.tsx +24 -0
  43. package/src/lifeline/lifeline-layout.ts +1 -0
  44. package/src/lifeline/lifeline-legend.tsx +31 -0
  45. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  46. package/src/lifeline/lifeline-marker.tsx +183 -0
  47. package/src/lifeline/lifeline-people.tsx +99 -0
  48. package/src/lifeline/lifeline-photos.tsx +366 -0
  49. package/src/lifeline/lifeline-shell.tsx +135 -0
  50. package/src/lifeline/lifeline-utils.ts +83 -0
  51. package/src/lifeline/lifeline-vertical.tsx +482 -0
  52. package/src/lifeline/lifeline.tsx +69 -0
  53. package/src/lifeline/types.ts +112 -0
  54. package/src/lifeline/use-lifeline-intro.ts +130 -0
  55. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  56. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  57. package/src/menu.tsx +7 -4
  58. package/src/menubar.tsx +9 -2
  59. package/src/minimap.tsx +296 -0
  60. package/src/modal.tsx +608 -0
  61. package/src/navigation-menu.tsx +340 -67
  62. package/src/number-field.tsx +336 -61
  63. package/src/otp-field.tsx +4 -3
  64. package/src/pagination.tsx +262 -42
  65. package/src/password-protection.tsx +345 -0
  66. package/src/popover.tsx +3 -1
  67. package/src/preview-card.tsx +3 -1
  68. package/src/progress.tsx +5 -0
  69. package/src/questionnaire.tsx +284 -0
  70. package/src/radio.tsx +4 -2
  71. package/src/scroll-area.tsx +4 -1
  72. package/src/select.tsx +12 -7
  73. package/src/sidebar.tsx +113 -28
  74. package/src/slider.tsx +66 -6
  75. package/src/sounds.tsx +6 -10
  76. package/src/spinner.tsx +105 -32
  77. package/src/switch.tsx +4 -1
  78. package/src/table.tsx +82 -15
  79. package/src/tabs.tsx +189 -38
  80. package/src/text-animate.tsx +71 -28
  81. package/src/textarea.tsx +6 -1
  82. package/src/timeline.tsx +378 -0
  83. package/src/toast.tsx +214 -35
  84. package/src/toggle.tsx +4 -2
  85. package/src/toolbar.tsx +12 -7
  86. package/src/tooltip.tsx +43 -3
  87. package/src/video-player.tsx +396 -127
  88. package/src/image-modal.tsx +0 -110
  89. package/src/markdown-editor.tsx +0 -302
@@ -4,6 +4,7 @@ import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible"
4
4
  import type { ComponentProps } from "react"
5
5
  import { IconChevronDownSmall } from "./icons"
6
6
  import { cn } from "./lib/cn"
7
+ import { focusRing } from "./lib/focus"
7
8
  import { motion } from "./lib/motion"
8
9
 
9
10
  export type CollapsibleProps = ComponentProps<typeof BaseCollapsible.Root>
@@ -23,9 +24,10 @@ export const CollapsibleTrigger = ({
23
24
  }: CollapsibleTriggerProps) => (
24
25
  <BaseCollapsible.Trigger
25
26
  className={cn(
26
- "group flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-border-secondary bg-surface px-3 text-sm text-fg-primary inset-shadow-outline-top outline-none",
27
+ "group flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-border-secondary bg-surface px-3 text-sm text-fg-primary inset-shadow-outline-top",
27
28
  motion.colors,
28
- "hover:bg-background-tertiary outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 data-disabled:cursor-not-allowed data-disabled:opacity-50",
29
+ focusRing,
30
+ "data-disabled:cursor-not-allowed data-disabled:opacity-50",
29
31
  className,
30
32
  )}
31
33
  {...props}
@@ -56,6 +58,8 @@ export const CollapsiblePanel = ({
56
58
  )}
57
59
  {...props}
58
60
  >
59
- <div className="pt-2 leading-relaxed">{children}</div>
61
+ {/* px-3 matches the trigger's own padding, so the panel copy lines up
62
+ under the trigger's label instead of starting at the panel edge. */}
63
+ <div className="px-3 pt-2 leading-relaxed">{children}</div>
60
64
  </BaseCollapsible.Panel>
61
65
  )
package/src/combobox.tsx CHANGED
@@ -4,7 +4,9 @@ import { Combobox as BaseCombobox } from "@base-ui/react/combobox"
4
4
  import type { ComponentProps, ReactElement } from "react"
5
5
  import { IconChevronDownSmall, IconCrossSmall } from "./icons"
6
6
  import { cn } from "./lib/cn"
7
+ import { focusRingWithin, focusRingInvalidWithin } from "./lib/focus"
7
8
  import { motion } from "./lib/motion"
9
+ import { popupInset, popupItem, popupLabel, popupMessage, popupSurface } from "./lib/popup"
8
10
 
9
11
  export type ComboboxProps<
10
12
  Value,
@@ -76,10 +78,9 @@ export const ComboboxInputGroup = ({
76
78
  }: ComboboxInputGroupProps) => (
77
79
  <BaseCombobox.InputGroup
78
80
  className={cn(
79
- "relative flex min-h-9 w-full items-center rounded-md border border-border-secondary bg-surface inset-shadow-outline-top outline-none transition-[color,box-shadow]",
80
- "has-[[data-popup-open]]:bg-background-tertiary/60",
81
- "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-offset-1 focus-within:ring-offset-background-primary focus-within:ring-ring/20",
82
- "aria-invalid:border-destructive aria-invalid:focus-within:border-destructive aria-invalid:focus-within:ring-destructive/20",
81
+ "relative flex min-h-9 w-full items-center rounded-md border border-border-secondary bg-surface inset-shadow-outline-top transition-[color,box-shadow]",
82
+ focusRingWithin,
83
+ focusRingInvalidWithin,
83
84
  "has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:focus-within:border-destructive has-[[aria-invalid=true]]:focus-within:ring-destructive/20",
84
85
  "has-[:disabled]:opacity-50",
85
86
  className,
@@ -139,7 +140,7 @@ export const ComboboxClear = ({
139
140
  {...props}
140
141
  >
141
142
  {children ?? (
142
- <IconCrossSmall size={14} className="size-3.5" aria-hidden />
143
+ <IconCrossSmall size={16} className="size-4" aria-hidden />
143
144
  )}
144
145
  </BaseCombobox.Clear>
145
146
  )
@@ -160,7 +161,7 @@ export const ComboboxBackdrop = ({
160
161
  )
161
162
 
162
163
  export const ComboboxPositioner = ({
163
- sideOffset = 4,
164
+ sideOffset = 6,
164
165
  className,
165
166
  ...props
166
167
  }: ComboboxPositionerProps) => (
@@ -174,7 +175,8 @@ export const ComboboxPositioner = ({
174
175
  export const ComboboxPopup = ({ className, ...props }: ComboboxPopupProps) => (
175
176
  <BaseCombobox.Popup
176
177
  className={cn(
177
- "z-50 max-h-[min(24rem,var(--available-height))] w-[var(--anchor-width)] overflow-hidden rounded-md border border-border-primary bg-surface shadow-md outline-none",
178
+ "z-50 max-h-[min(24rem,var(--available-height))] w-[var(--anchor-width)] overflow-hidden",
179
+ popupSurface,
178
180
  motion.popupAnchor,
179
181
  className,
180
182
  )}
@@ -199,7 +201,7 @@ export const ComboboxArrow = ({ className, ...props }: ComboboxArrowProps) => (
199
201
 
200
202
  export const ComboboxList = ({ className, ...props }: ComboboxListProps) => (
201
203
  <BaseCombobox.List
202
- className={cn("max-h-[inherit] overflow-y-auto p-1 outline-none", className)}
204
+ className={cn("max-h-[inherit] overflow-y-auto outline-none", popupInset, className)}
203
205
  {...props}
204
206
  />
205
207
  )
@@ -207,7 +209,7 @@ export const ComboboxList = ({ className, ...props }: ComboboxListProps) => (
207
209
  export const ComboboxItem = ({ className, ...props }: ComboboxItemProps) => (
208
210
  <BaseCombobox.Item
209
211
  className={cn(
210
- "flex min-h-8 cursor-default items-center gap-2 rounded-xs px-2.5 py-1.5 text-sm text-fg-primary outline-none select-none",
212
+ popupItem,
211
213
  motion.colors,
212
214
  "data-disabled:cursor-not-allowed data-disabled:opacity-50 data-highlighted:bg-background-tertiary data-highlighted:text-fg-primary",
213
215
  className,
@@ -238,14 +240,14 @@ export const ComboboxItemIndicator = ({
238
240
 
239
241
  export const ComboboxEmpty = ({ className, ...props }: ComboboxEmptyProps) => (
240
242
  <BaseCombobox.Empty
241
- className={cn("px-2.5 py-2 text-sm text-fg-tertiary empty:hidden", className)}
243
+ className={cn(cn(popupMessage, "empty:hidden"), className)}
242
244
  {...props}
243
245
  />
244
246
  )
245
247
 
246
248
  export const ComboboxStatus = ({ className, ...props }: ComboboxStatusProps) => (
247
249
  <BaseCombobox.Status
248
- className={cn("px-2.5 py-2 text-sm text-fg-tertiary", className)}
250
+ className={cn(popupMessage, className)}
249
251
  {...props}
250
252
  />
251
253
  )
@@ -259,7 +261,7 @@ export const ComboboxGroupLabel = ({
259
261
  ...props
260
262
  }: ComboboxGroupLabelProps) => (
261
263
  <BaseCombobox.GroupLabel
262
- className={cn("px-2.5 py-1.5 text-xs text-fg-tertiary", className)}
264
+ className={cn(popupLabel, className)}
263
265
  {...props}
264
266
  />
265
267
  )
@@ -291,7 +293,7 @@ export const ComboboxChips = ({ className, ...props }: ComboboxChipsProps) => (
291
293
  export const ComboboxChip = ({ className, ...props }: ComboboxChipProps) => (
292
294
  <BaseCombobox.Chip
293
295
  className={cn(
294
- "inline-flex h-6 items-center gap-1 rounded-xs bg-background-tertiary px-1.5 text-xs text-fg-primary",
296
+ "inline-flex h-6 items-center gap-1 rounded-xs bg-background-tertiary px-1.5 text-xs-strong text-fg-primary",
295
297
  className,
296
298
  )}
297
299
  {...props}
@@ -305,13 +307,13 @@ export const ComboboxChipRemove = ({
305
307
  }: ComboboxChipRemoveProps) => (
306
308
  <BaseCombobox.ChipRemove
307
309
  className={cn(
308
- "inline-flex size-3.5 cursor-pointer items-center justify-center text-fg-tertiary outline-none hover:text-fg-primary focus-visible:outline-none",
310
+ "inline-flex size-3.5 cursor-pointer items-center justify-center text-fg-primary outline-none focus-visible:outline-none",
309
311
  className,
310
312
  )}
311
313
  {...props}
312
314
  >
313
315
  {children ?? (
314
- <IconCrossSmall size={12} className="size-3" aria-hidden />
316
+ <IconCrossSmall size={14} className="size-3.5" aria-hidden />
315
317
  )}
316
318
  </BaseCombobox.ChipRemove>
317
319
  )
package/src/command.tsx CHANGED
@@ -4,8 +4,10 @@ import { Dialog as BaseDialog } from "@base-ui/react/dialog"
4
4
  import {
5
5
  createContext,
6
6
  useContext,
7
+ useEffect,
7
8
  useId,
8
9
  useMemo,
10
+ useRef,
9
11
  type ButtonHTMLAttributes,
10
12
  type ComponentProps,
11
13
  type HTMLAttributes,
@@ -13,7 +15,9 @@ import {
13
15
  type ReactNode,
14
16
  } from "react"
15
17
  import { IconCrossSmall, IconMagnifyingGlass } from "./icons"
18
+ import { filterGroupVariants, filterItemVariants } from "./filter-group"
16
19
  import { cn } from "./lib/cn"
20
+ import { focusRing } from "./lib/focus"
17
21
  import { motion } from "./lib/motion"
18
22
 
19
23
  type CommandContextValue = {
@@ -49,7 +53,9 @@ export type CommandClearProps = ButtonHTMLAttributes<HTMLButtonElement>
49
53
  export type CommandCloseProps = ComponentProps<typeof BaseDialog.Close>
50
54
  export type CommandDividerProps = HTMLAttributes<HTMLDivElement>
51
55
  export type CommandContentProps = HTMLAttributes<HTMLDivElement>
56
+ /** @deprecated Use FilterGroup for new filter controls. */
52
57
  export type CommandFiltersProps = HTMLAttributes<HTMLUListElement>
58
+ /** @deprecated Use FilterItem for new filter controls. */
53
59
  export type CommandFilterProps = ButtonHTMLAttributes<HTMLButtonElement> & {
54
60
  selected?: boolean
55
61
  }
@@ -108,7 +114,7 @@ export const CommandBackdrop = ({
108
114
  export const CommandPopup = ({ className, ...props }: CommandPopupProps) => (
109
115
  <BaseDialog.Popup
110
116
  className={cn(
111
- "fixed top-1/2 left-1/2 z-50 flex h-[min(80vh,32rem)] w-full max-w-xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-border-primary bg-surface-raised shadow-xl outline-none max-sm:max-w-[calc(100vw-2rem)]",
117
+ "fixed top-1/2 left-1/2 z-50 flex h-[min(80vh,32rem)] w-[calc(100vw-2rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl bg-surface-raised shadow-ambient outline-none",
112
118
  motion.popupCenter,
113
119
  className,
114
120
  )}
@@ -147,7 +153,6 @@ export const CommandInput = ({
147
153
  aria-activedescendant={activeOptionId}
148
154
  className={cn(
149
155
  "h-9 min-w-0 flex-1 cursor-text rounded-md bg-transparent px-2.5 text-base text-fg-primary outline-none placeholder:text-fg-quaternary",
150
- "focus-visible:bg-background-tertiary/60 focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
151
156
  "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none",
152
157
  className,
153
158
  )}
@@ -174,9 +179,10 @@ export const CommandClear = ({
174
179
  <button
175
180
  type="button"
176
181
  className={cn(
177
- "cursor-pointer rounded-full px-3 py-1.5 text-sm text-fg-quaternary outline-none",
182
+ "inline-flex h-8 cursor-pointer items-center rounded-full border border-transparent px-3 text-sm text-fg-quaternary",
178
183
  motion.colors,
179
- "hover:bg-background-tertiary hover:text-fg-primary focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 data-[active=true]:text-fg-primary",
184
+ focusRing,
185
+ "hover:bg-background-tertiary hover:text-fg-primary data-[active=true]:text-fg-primary",
180
186
  className,
181
187
  )}
182
188
  {...props}
@@ -203,9 +209,10 @@ export const CommandClose = ({
203
209
  }: CommandCloseProps) => (
204
210
  <BaseDialog.Close
205
211
  className={cn(
206
- "inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-fg-primary outline-none",
212
+ "inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-transparent text-fg-primary",
207
213
  motion.colors,
208
- "hover:bg-background-tertiary focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
214
+ focusRing,
215
+ "hover:bg-background-tertiary",
209
216
  className,
210
217
  )}
211
218
  aria-label={props["aria-label"] ?? "Close"}
@@ -223,7 +230,7 @@ export const CommandContent = ({
223
230
  }: CommandContentProps) => (
224
231
  <div
225
232
  className={cn(
226
- "flex min-h-0 flex-1 flex-col gap-3 overflow-x-visible overflow-y-auto overscroll-contain px-4 pt-1 pb-5",
233
+ "flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-4 pt-1 pb-5",
227
234
  className,
228
235
  )}
229
236
  {...props}
@@ -237,7 +244,8 @@ export const CommandFilters = ({
237
244
  <ul
238
245
  role="list"
239
246
  className={cn(
240
- "flex shrink-0 flex-wrap justify-start gap-1.5 py-1.5",
247
+ filterGroupVariants({ variant: "pill" }),
248
+ "w-full shrink-0 justify-start py-1.5",
241
249
  className,
242
250
  )}
243
251
  {...props}
@@ -255,11 +263,10 @@ export const CommandFilter = ({
255
263
  type="button"
256
264
  aria-pressed={selected}
257
265
  data-selected={selected || undefined}
266
+ data-pressed={selected || undefined}
258
267
  className={cn(
259
- "cursor-pointer rounded-full px-4 py-2 text-sm text-fg-quaternary outline-none",
268
+ filterItemVariants({ variant: "pill", size: "md" }),
260
269
  motion.colors,
261
- "hover:text-fg-primary focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
262
- "data-selected:bg-background-tertiary data-selected:text-fg-primary",
263
270
  className,
264
271
  )}
265
272
  {...props}
@@ -277,7 +284,7 @@ export const CommandList = ({ className, ...props }: CommandListProps) => {
277
284
  id={listboxId}
278
285
  role="listbox"
279
286
  className={cn(
280
- "grid min-h-0 flex-1 content-start gap-0 overflow-visible overscroll-contain py-0.5",
287
+ "grid min-h-0 flex-1 content-start gap-0 overflow-y-auto overscroll-contain p-1",
281
288
  className,
282
289
  )}
283
290
  {...props}
@@ -295,12 +302,21 @@ export const CommandItem = ({
295
302
  onClick,
296
303
  ...props
297
304
  }: CommandItemProps) => {
305
+ const ref = useRef<HTMLAnchorElement & HTMLButtonElement>(null)
306
+
307
+ // Keyboard selection moves the highlight without moving focus, so nothing
308
+ // scrolls the list on its own. `nearest` only acts when the row is actually
309
+ // out of view, which leaves hover-driven selection alone.
310
+ useEffect(() => {
311
+ if (selected) ref.current?.scrollIntoView({ block: "nearest" })
312
+ }, [selected])
313
+
298
314
  const itemClassName = cn(
299
- "flex h-15 min-h-15 w-full cursor-pointer items-center justify-between gap-4 rounded-lg px-4 text-left text-sm font-medium text-fg-primary outline-none select-none",
315
+ "flex h-15 min-h-15 w-full cursor-pointer items-center justify-between gap-4 rounded-lg border border-transparent px-4 text-left text-sm font-medium text-fg-primary select-none",
300
316
  motion.colors,
301
317
  "hover:bg-background-tertiary data-selected:bg-background-tertiary",
302
318
  "data-disabled:cursor-not-allowed data-disabled:opacity-50",
303
- "focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
319
+ focusRing,
304
320
  className,
305
321
  )
306
322
 
@@ -308,6 +324,7 @@ export const CommandItem = ({
308
324
  <li role="presentation" className="m-0">
309
325
  {href ? (
310
326
  <a
327
+ ref={ref}
311
328
  id={id}
312
329
  href={disabled ? undefined : href}
313
330
  role="option"
@@ -323,6 +340,7 @@ export const CommandItem = ({
323
340
  </a>
324
341
  ) : (
325
342
  <button
343
+ ref={ref}
326
344
  id={id}
327
345
  type="button"
328
346
  role="option"
@@ -369,6 +387,15 @@ export const CommandTitle = ({ className, children }: CommandTitleProps) => (
369
387
  <span className={cn("min-w-0 truncate", className)}>{children}</span>
370
388
  )
371
389
 
390
+ export type CommandMetaProps = HTMLAttributes<HTMLSpanElement>
391
+
392
+ export const CommandMeta = ({ className, ...props }: CommandMetaProps) => (
393
+ <span
394
+ className={cn("shrink-0 text-xs font-normal text-fg-tertiary", className)}
395
+ {...props}
396
+ />
397
+ )
398
+
372
399
  export const CommandDialogTitle = ({
373
400
  className,
374
401
  ...props
@@ -5,6 +5,7 @@ import type { ComponentProps } from "react"
5
5
  import { IconCheckmark1, IconChevronRightSmall } from "./icons"
6
6
  import { cn } from "./lib/cn"
7
7
  import { motion } from "./lib/motion"
8
+ import { popupInset, popupItem, popupLabel, popupSurface } from "./lib/popup"
8
9
 
9
10
  export type ContextMenuProps = ComponentProps<typeof BaseContextMenu.Root>
10
11
  export type ContextMenuTriggerProps = ComponentProps<
@@ -53,7 +54,7 @@ export type ContextMenuSubmenuTriggerProps = ComponentProps<
53
54
  >
54
55
 
55
56
  const itemClassName = cn(
56
- "flex min-h-8 cursor-default items-center gap-2 rounded-xs px-2.5 py-1.5 text-sm text-fg-primary outline-none select-none",
57
+ popupItem,
57
58
  motion.colors,
58
59
  "data-disabled:cursor-not-allowed data-disabled:opacity-50 data-highlighted:bg-background-tertiary",
59
60
  )
@@ -100,7 +101,9 @@ export const ContextMenuPopup = ({
100
101
  }: ContextMenuPopupProps) => (
101
102
  <BaseContextMenu.Popup
102
103
  className={cn(
103
- "z-50 min-w-40 overflow-hidden rounded-md border border-border-primary bg-surface p-1 shadow-md outline-none",
104
+ "z-50 min-w-40 overflow-hidden",
105
+ popupSurface,
106
+ popupInset,
104
107
  motion.popupAnchor,
105
108
  className,
106
109
  )}
@@ -165,7 +168,7 @@ export const ContextMenuGroupLabel = ({
165
168
  ...props
166
169
  }: ContextMenuGroupLabelProps) => (
167
170
  <BaseContextMenu.GroupLabel
168
- className={cn("px-2.5 py-1.5 text-xs text-fg-tertiary", className)}
171
+ className={cn(popupLabel, className)}
169
172
  {...props}
170
173
  />
171
174
  )
@@ -0,0 +1,174 @@
1
+ "use client"
2
+
3
+ import { useState, type ReactNode } from "react"
4
+ import type { DateRange, PropsBase } from "react-day-picker"
5
+ import { Button } from "./button"
6
+ import { Calendar } from "./calendar"
7
+ import { IconCalendar1 } from "./icons"
8
+ import { cn } from "./lib/cn"
9
+ import {
10
+ Popover,
11
+ PopoverPopup,
12
+ PopoverPortal,
13
+ PopoverPositioner,
14
+ PopoverTrigger,
15
+ } from "./popover"
16
+
17
+ const dateFormatter = new Intl.DateTimeFormat("en-US", {
18
+ month: "short",
19
+ day: "numeric",
20
+ year: "numeric",
21
+ })
22
+
23
+ const formatDate = (date: Date) => dateFormatter.format(date)
24
+
25
+ type DatePickerBaseProps = {
26
+ /** Options forwarded to Calendar, excluding its selection mode. */
27
+ calendarProps?: Omit<PropsBase, "mode" | "required">
28
+ /** Classes applied to the trigger button. */
29
+ className?: string
30
+ /** Disables the trigger button. */
31
+ disabled?: boolean
32
+ /** Placeholder shown when no date is selected. */
33
+ placeholder?: string
34
+ }
35
+
36
+ export type DatePickerSingleProps = DatePickerBaseProps & {
37
+ mode?: "single"
38
+ selected?: Date
39
+ onSelect?: (date: Date | undefined) => void
40
+ }
41
+
42
+ export type DatePickerRangeProps = DatePickerBaseProps & {
43
+ mode: "range"
44
+ selected?: DateRange
45
+ onSelect?: (range: DateRange | undefined) => void
46
+ }
47
+
48
+ export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps
49
+
50
+ const DatePickerTrigger = ({
51
+ className,
52
+ disabled,
53
+ empty,
54
+ label,
55
+ }: {
56
+ className?: string
57
+ disabled?: boolean
58
+ empty: boolean
59
+ label: string
60
+ }) => (
61
+ <PopoverTrigger
62
+ render={
63
+ <Button
64
+ type="button"
65
+ // Default md height, so the trigger lines up with every other field
66
+ // in a form rather than sitting 4px short of them.
67
+ size="md"
68
+ variant="outline"
69
+ disabled={disabled}
70
+ prefix={<IconCalendar1 aria-hidden />}
71
+ // The outline button fills on hover, which is right for a button and
72
+ // wrong here: this reads as a field, and fields in the system keep
73
+ // their surface. The border and the popover carry the state.
74
+ className={cn("w-56 justify-start hover:bg-surface", className)}
75
+ />
76
+ }
77
+ >
78
+ <span
79
+ className={cn(
80
+ "min-w-0 flex-1 truncate text-left",
81
+ empty && "text-fg-tertiary",
82
+ )}
83
+ >
84
+ {label}
85
+ </span>
86
+ </PopoverTrigger>
87
+ )
88
+
89
+ const DatePickerPopup = ({ children }: { children: ReactNode }) => (
90
+ <PopoverPortal>
91
+ <PopoverPositioner align="start" sideOffset={6}>
92
+ <PopoverPopup className="w-auto gap-0 p-0">{children}</PopoverPopup>
93
+ </PopoverPositioner>
94
+ </PopoverPortal>
95
+ )
96
+
97
+ const SingleDatePicker = ({
98
+ calendarProps,
99
+ className,
100
+ disabled,
101
+ onSelect,
102
+ placeholder = "Pick a date",
103
+ selected,
104
+ }: DatePickerSingleProps) => {
105
+ const [open, setOpen] = useState(false)
106
+
107
+ const handleSelect = (date: Date | undefined) => {
108
+ onSelect?.(date)
109
+ if (date) setOpen(false)
110
+ }
111
+
112
+ return (
113
+ <Popover open={open} onOpenChange={setOpen}>
114
+ <DatePickerTrigger
115
+ className={className}
116
+ disabled={disabled}
117
+ empty={!selected}
118
+ label={selected ? formatDate(selected) : placeholder}
119
+ />
120
+ <DatePickerPopup>
121
+ <Calendar
122
+ {...calendarProps}
123
+ className={cn("border-0", calendarProps?.className)}
124
+ mode="single"
125
+ selected={selected}
126
+ onSelect={handleSelect}
127
+ />
128
+ </DatePickerPopup>
129
+ </Popover>
130
+ )
131
+ }
132
+
133
+ const RangeDatePicker = ({
134
+ calendarProps,
135
+ className,
136
+ disabled,
137
+ onSelect,
138
+ placeholder = "Pick a date range",
139
+ selected,
140
+ }: DatePickerRangeProps) => {
141
+ const [open, setOpen] = useState(false)
142
+ const label = selected?.from
143
+ ? selected.to
144
+ ? `${formatDate(selected.from)} – ${formatDate(selected.to)}`
145
+ : `${formatDate(selected.from)} – Select end date`
146
+ : placeholder
147
+
148
+ return (
149
+ <Popover open={open} onOpenChange={setOpen}>
150
+ <DatePickerTrigger
151
+ className={className}
152
+ disabled={disabled}
153
+ empty={!selected?.from}
154
+ label={label}
155
+ />
156
+ <DatePickerPopup>
157
+ <Calendar
158
+ {...calendarProps}
159
+ className={cn("border-0", calendarProps?.className)}
160
+ mode="range"
161
+ selected={selected}
162
+ onSelect={onSelect}
163
+ />
164
+ </DatePickerPopup>
165
+ </Popover>
166
+ )
167
+ }
168
+
169
+ export const DatePicker = (props: DatePickerProps) =>
170
+ props.mode === "range" ? (
171
+ <RangeDatePicker {...props} />
172
+ ) : (
173
+ <SingleDatePicker {...props} />
174
+ )
package/src/dialog.tsx CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Dialog as BaseDialog } from "@base-ui/react/dialog"
4
4
  import type { ComponentProps, HTMLAttributes } from "react"
5
+ import { useRef } from "react"
5
6
  import { cn } from "./lib/cn"
6
7
  import { motion } from "./lib/motion"
7
8
 
@@ -41,16 +42,38 @@ export const DialogBackdrop = ({
41
42
  />
42
43
  )
43
44
 
44
- export const DialogPopup = ({ className, ...props }: DialogPopupProps) => (
45
- <BaseDialog.Popup
46
- className={cn(
47
- "fixed top-1/2 left-1/2 z-50 flex w-full max-w-md -translate-x-1/2 -translate-y-1/2 flex-col gap-6 rounded-xl border border-border-primary bg-surface-raised p-5 shadow-md outline-none max-sm:max-w-[calc(100vw-2rem)]",
48
- motion.popupCenter,
49
- className,
50
- )}
51
- {...props}
52
- />
53
- )
45
+ export const DialogPopup = ({
46
+ className,
47
+ initialFocus,
48
+ ref,
49
+ ...props
50
+ }: DialogPopupProps) => {
51
+ const popupRef = useRef<HTMLDivElement | null>(null)
52
+
53
+ const setRef = (node: HTMLDivElement | null) => {
54
+ popupRef.current = node
55
+ if (typeof ref === "function") ref(node)
56
+ else if (ref) ref.current = node
57
+ }
58
+
59
+ return (
60
+ <BaseDialog.Popup
61
+ ref={setRef}
62
+ // Base UI focuses the first tabbable element by default. In a dialog long
63
+ // enough to scroll that is usually the action at the bottom, and focusing
64
+ // it scrolls the body to the end before the reader has seen the start —
65
+ // so the popup takes focus itself, which is what its `tabindex="-1"` is
66
+ // for. Callers can still name their own target.
67
+ initialFocus={initialFocus ?? popupRef}
68
+ className={cn(
69
+ "fixed top-1/2 left-1/2 z-50 flex w-full max-w-md -translate-x-1/2 -translate-y-1/2 flex-col gap-6 rounded-xl border border-border-primary bg-surface-raised p-5 shadow-md outline-none max-h-[calc(100dvh-2rem)] overflow-y-auto max-sm:max-w-[calc(100vw-2rem)]",
70
+ motion.popupCenter,
71
+ className,
72
+ )}
73
+ {...props}
74
+ />
75
+ )
76
+ }
54
77
 
55
78
  export const DialogTitle = ({ className, ...props }: DialogTitleProps) => (
56
79
  <BaseDialog.Title
package/src/empty.tsx CHANGED
@@ -10,7 +10,7 @@ export type EmptyActionsProps = ComponentProps<"div">;
10
10
  export const Empty = ({ className, ...props }: EmptyProps) => (
11
11
  <div
12
12
  className={cn(
13
- "flex min-h-48 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border-primary bg-surface p-8 text-center",
13
+ "flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border-primary bg-surface p-8 text-center",
14
14
  className,
15
15
  )}
16
16
  {...props}
@@ -20,7 +20,7 @@ export const Empty = ({ className, ...props }: EmptyProps) => (
20
20
  export const EmptyIcon = ({ className, ...props }: EmptyIconProps) => (
21
21
  <div
22
22
  className={cn(
23
- "flex size-10 items-center justify-center rounded-lg bg-background-tertiary text-fg-secondary [&_svg]:size-5",
23
+ "mb-4 flex size-10 items-center justify-center rounded-lg bg-background-tertiary text-fg-secondary [&_svg]:size-5",
24
24
  className,
25
25
  )}
26
26
  {...props}
@@ -36,7 +36,7 @@ export const EmptyDescription = ({
36
36
  ...props
37
37
  }: EmptyDescriptionProps) => (
38
38
  <p
39
- className={cn("max-w-sm text-sm text-fg-secondary", className)}
39
+ className={cn("mt-1 max-w-sm text-sm text-fg-secondary", className)}
40
40
  {...props}
41
41
  />
42
42
  );
@@ -44,7 +44,7 @@ export const EmptyDescription = ({
44
44
  export const EmptyActions = ({ className, ...props }: EmptyActionsProps) => (
45
45
  <div
46
46
  className={cn(
47
- "mt-1 flex flex-wrap items-center justify-center gap-2",
47
+ "mt-4 flex flex-wrap items-center justify-center gap-2",
48
48
  className,
49
49
  )}
50
50
  {...props}
package/src/field.tsx CHANGED
@@ -3,6 +3,7 @@
3
3
  import { Field as BaseField } from "@base-ui/react/field"
4
4
  import type { ComponentProps } from "react"
5
5
  import { cn } from "./lib/cn"
6
+ import { focusRing, focusRingInvalid } from "./lib/focus"
6
7
 
7
8
  export type FieldProps = ComponentProps<typeof BaseField.Root>
8
9
  export type FieldLabelProps = ComponentProps<typeof BaseField.Label>
@@ -49,10 +50,10 @@ export const FieldError = ({ className, ...props }: FieldErrorProps) => (
49
50
  export const FieldControl = ({ className, ...props }: FieldControlProps) => (
50
51
  <BaseField.Control
51
52
  className={cn(
52
- "text-sm flex h-9 w-full cursor-text rounded-md border border-border-secondary bg-surface px-3 text-fg-primary inset-shadow-outline-top outline-none transition-[color,box-shadow] duration-[var(--duration-sm)] ease-enter placeholder:text-fg-quaternary",
53
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
53
+ "text-sm flex h-9 w-full cursor-text rounded-md border border-border-secondary bg-surface px-3 text-fg-primary inset-shadow-outline-top transition-[color,box-shadow] duration-[var(--duration-sm)] ease-enter placeholder:text-fg-quaternary",
54
+ focusRing,
55
+ focusRingInvalid,
54
56
  "data-invalid:border-destructive data-invalid:focus-visible:border-destructive data-invalid:focus-visible:ring-destructive/20",
55
- "aria-invalid:border-destructive aria-invalid:focus-visible:border-destructive aria-invalid:focus-visible:ring-destructive/20",
56
57
  "data-disabled:cursor-not-allowed data-disabled:opacity-50",
57
58
  className,
58
59
  )}