@boyernick/standard-ui-react 0.1.1-canary.2 → 0.1.1-canary.22

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 (56) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +1 -1
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/autocomplete.tsx +12 -11
  5. package/src/badge.tsx +73 -9
  6. package/src/brand.tsx +2 -2
  7. package/src/breadcrumb.tsx +38 -20
  8. package/src/button.tsx +2 -2
  9. package/src/calendar.tsx +22 -11
  10. package/src/card.tsx +57 -14
  11. package/src/carousel.tsx +163 -6
  12. package/src/checkbox.tsx +35 -13
  13. package/src/collapsible.tsx +4 -2
  14. package/src/combobox.tsx +13 -12
  15. package/src/command.tsx +38 -14
  16. package/src/context-menu.tsx +6 -3
  17. package/src/date-picker.tsx +174 -0
  18. package/src/dialog.tsx +33 -10
  19. package/src/drawer.tsx +1 -1
  20. package/src/empty.tsx +4 -4
  21. package/src/field.tsx +1 -1
  22. package/src/filter-group.tsx +139 -0
  23. package/src/icons.tsx +39 -0
  24. package/src/illustrations.tsx +219 -141
  25. package/src/image-modal.tsx +440 -49
  26. package/src/index.ts +188 -0
  27. package/src/input.tsx +1 -1
  28. package/src/kbd.tsx +49 -0
  29. package/src/lib/motion.ts +128 -18
  30. package/src/lib/popup.ts +45 -0
  31. package/src/menu.tsx +7 -4
  32. package/src/menubar.tsx +5 -0
  33. package/src/meter.tsx +1 -1
  34. package/src/minimap.tsx +267 -0
  35. package/src/navigation-menu.tsx +336 -67
  36. package/src/number-field.tsx +327 -61
  37. package/src/pagination.tsx +258 -42
  38. package/src/popover.tsx +3 -1
  39. package/src/preview-card.tsx +3 -1
  40. package/src/progress.tsx +6 -1
  41. package/src/questionnaire.tsx +279 -0
  42. package/src/scroll-area.tsx +1 -1
  43. package/src/select.tsx +8 -6
  44. package/src/sidebar.tsx +109 -28
  45. package/src/slider.tsx +64 -6
  46. package/src/sounds.tsx +6 -10
  47. package/src/spinner.tsx +105 -32
  48. package/src/switch.tsx +2 -2
  49. package/src/table.tsx +82 -15
  50. package/src/tabs.tsx +184 -38
  51. package/src/text-animate.tsx +71 -28
  52. package/src/textarea.tsx +1 -1
  53. package/src/timeline.tsx +321 -0
  54. package/src/toast.tsx +156 -23
  55. package/src/tooltip.tsx +43 -3
  56. package/src/video-player.tsx +296 -120
package/src/carousel.tsx CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  useCallback,
9
9
  useContext,
10
10
  useEffect,
11
+ useRef,
11
12
  useSyncExternalStore,
12
13
  type ComponentProps,
13
14
  type KeyboardEvent,
@@ -21,6 +22,9 @@ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
21
22
  type CarouselOptions = UseCarouselParameters[0]
22
23
  type CarouselPlugin = UseCarouselParameters[1]
23
24
 
25
+ const TRACKPAD_SWIPE_THRESHOLD = 24
26
+ const TRACKPAD_GESTURE_END_DELAY = 140
27
+
24
28
  type CarouselContextValue = {
25
29
  carouselRef: ReturnType<typeof useEmblaCarousel>[0]
26
30
  api: CarouselApi
@@ -46,6 +50,8 @@ export type CarouselProps = ComponentProps<"div"> & {
46
50
  plugins?: CarouselPlugin
47
51
  orientation?: "horizontal" | "vertical"
48
52
  setApi?: (api: CarouselApi) => void
53
+ /** Adds directional edge fades wherever more slides are available. */
54
+ fade?: boolean
49
55
  }
50
56
 
51
57
  export type CarouselContentProps = ComponentProps<"div">
@@ -53,16 +59,54 @@ export type CarouselItemProps = ComponentProps<"div">
53
59
  export type CarouselPreviousProps = ComponentProps<typeof Button>
54
60
  export type CarouselNextProps = ComponentProps<typeof Button>
55
61
 
62
+ const CarouselEdgeFades = () => {
63
+ const { orientation, canScrollPrev, canScrollNext } = useCarousel()
64
+ const horizontal = orientation === "horizontal"
65
+ const sharedClassName =
66
+ "pointer-events-none absolute z-10 opacity-0 transition-opacity duration-[var(--duration-lg)] ease-enter data-[visible=true]:opacity-100 motion-reduce:transition-none"
67
+
68
+ return (
69
+ <>
70
+ <div
71
+ aria-hidden="true"
72
+ data-slot={horizontal ? "carousel-fade-left" : "carousel-fade-top"}
73
+ data-visible={canScrollPrev}
74
+ className={cn(
75
+ sharedClassName,
76
+ horizontal
77
+ ? "-top-px -bottom-px -left-px w-20 bg-linear-to-r from-surface via-surface/60 via-40% to-transparent"
78
+ : "-top-px -right-px -left-px h-20 bg-linear-to-b from-surface via-surface/60 via-40% to-transparent",
79
+ )}
80
+ />
81
+ <div
82
+ aria-hidden="true"
83
+ data-slot={horizontal ? "carousel-fade-right" : "carousel-fade-bottom"}
84
+ data-visible={canScrollNext}
85
+ className={cn(
86
+ sharedClassName,
87
+ horizontal
88
+ ? "-top-px -right-px -bottom-px w-20 bg-linear-to-l from-surface via-surface/60 via-40% to-transparent"
89
+ : "-right-px -bottom-px -left-px h-20 bg-linear-to-t from-surface via-surface/60 via-40% to-transparent",
90
+ )}
91
+ />
92
+ </>
93
+ )
94
+ }
95
+
56
96
  export const Carousel = ({
57
97
  orientation = "horizontal",
98
+ fade = false,
58
99
  opts,
59
100
  setApi,
60
101
  plugins,
61
102
  className,
62
103
  children,
63
104
  onKeyDownCapture,
105
+ tabIndex,
106
+ ref: forwardedRef,
64
107
  ...props
65
108
  }: CarouselProps) => {
109
+ const carouselRootRef = useRef<HTMLDivElement | null>(null)
66
110
  const [carouselRef, api] = useEmblaCarousel(
67
111
  {
68
112
  ...opts,
@@ -107,6 +151,35 @@ export const Carousel = ({
107
151
  api?.scrollNext()
108
152
  }, [api])
109
153
 
154
+ const canScrollPrevRef = useRef(canScrollPrev)
155
+ const canScrollNextRef = useRef(canScrollNext)
156
+ const scrollPrevRef = useRef(scrollPrev)
157
+ const scrollNextRef = useRef(scrollNext)
158
+
159
+ useEffect(() => {
160
+ canScrollPrevRef.current = canScrollPrev
161
+ canScrollNextRef.current = canScrollNext
162
+ scrollPrevRef.current = scrollPrev
163
+ scrollNextRef.current = scrollNext
164
+ }, [canScrollNext, canScrollPrev, scrollNext, scrollPrev])
165
+
166
+ const trackpadDelta = useRef(0)
167
+ const trackpadGestureHandled = useRef(false)
168
+ const trackpadGestureTimeout = useRef<ReturnType<typeof setTimeout> | null>(
169
+ null,
170
+ )
171
+
172
+ const scheduleTrackpadGestureEnd = useCallback(() => {
173
+ if (trackpadGestureTimeout.current) {
174
+ clearTimeout(trackpadGestureTimeout.current)
175
+ }
176
+ trackpadGestureTimeout.current = setTimeout(() => {
177
+ trackpadDelta.current = 0
178
+ trackpadGestureHandled.current = false
179
+ trackpadGestureTimeout.current = null
180
+ }, TRACKPAD_GESTURE_END_DELAY)
181
+ }, [])
182
+
110
183
  const handleKeyDown = useCallback(
111
184
  (event: KeyboardEvent<HTMLDivElement>) => {
112
185
  onKeyDownCapture?.(event)
@@ -122,6 +195,72 @@ export const Carousel = ({
122
195
  [onKeyDownCapture, scrollNext, scrollPrev],
123
196
  )
124
197
 
198
+ const handleWheel = useCallback(
199
+ (event: globalThis.WheelEvent) => {
200
+ if (event.defaultPrevented || orientation !== "horizontal") return
201
+
202
+ const deltaMultiplier =
203
+ event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1
204
+ const horizontalDelta = event.deltaX * deltaMultiplier
205
+ const verticalDelta = event.deltaY * deltaMultiplier
206
+
207
+ if (
208
+ Math.abs(horizontalDelta) <= Math.abs(verticalDelta) ||
209
+ Math.abs(horizontalDelta) < 1
210
+ ) {
211
+ return
212
+ }
213
+
214
+ if (trackpadGestureHandled.current) {
215
+ event.preventDefault()
216
+ scheduleTrackpadGestureEnd()
217
+ return
218
+ }
219
+
220
+ const canScrollInDirection =
221
+ horizontalDelta > 0
222
+ ? canScrollNextRef.current
223
+ : canScrollPrevRef.current
224
+ if (!canScrollInDirection) return
225
+
226
+ event.preventDefault()
227
+ trackpadDelta.current += horizontalDelta
228
+ scheduleTrackpadGestureEnd()
229
+
230
+ if (Math.abs(trackpadDelta.current) < TRACKPAD_SWIPE_THRESHOLD) return
231
+
232
+ trackpadGestureHandled.current = true
233
+ trackpadDelta.current = 0
234
+ if (horizontalDelta > 0) scrollNextRef.current()
235
+ else scrollPrevRef.current()
236
+ },
237
+ [orientation, scheduleTrackpadGestureEnd],
238
+ )
239
+
240
+ useEffect(() => {
241
+ const root = carouselRootRef.current
242
+ if (!root) return
243
+
244
+ root.addEventListener("wheel", handleWheel, { passive: false })
245
+ return () => {
246
+ root.removeEventListener("wheel", handleWheel)
247
+ if (trackpadGestureTimeout.current) {
248
+ clearTimeout(trackpadGestureTimeout.current)
249
+ }
250
+ trackpadDelta.current = 0
251
+ trackpadGestureHandled.current = false
252
+ }
253
+ }, [handleWheel])
254
+
255
+ const setCarouselRootRef = useCallback(
256
+ (node: HTMLDivElement | null) => {
257
+ carouselRootRef.current = node
258
+ if (typeof forwardedRef === "function") forwardedRef(node)
259
+ else if (forwardedRef) forwardedRef.current = node
260
+ },
261
+ [forwardedRef],
262
+ )
263
+
125
264
  useEffect(() => {
126
265
  if (!api || !setApi) return
127
266
  setApi(api)
@@ -140,14 +279,23 @@ export const Carousel = ({
140
279
  }}
141
280
  >
142
281
  <div
282
+ ref={setCarouselRootRef}
143
283
  role="region"
144
284
  aria-roledescription="carousel"
145
285
  data-slot="carousel"
146
- className={cn("relative", className)}
286
+ data-fade={fade || undefined}
287
+ className={cn(
288
+ "relative",
289
+ fade &&
290
+ "rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
291
+ className,
292
+ )}
147
293
  onKeyDownCapture={handleKeyDown}
294
+ tabIndex={tabIndex ?? (fade ? 0 : undefined)}
148
295
  {...props}
149
296
  >
150
297
  {children}
298
+ {fade ? <CarouselEdgeFades /> : null}
151
299
  </div>
152
300
  </CarouselContext.Provider>
153
301
  )
@@ -160,7 +308,16 @@ export const CarouselContent = ({
160
308
  const { carouselRef, orientation } = useCarousel()
161
309
 
162
310
  return (
163
- <div ref={carouselRef} className="overflow-hidden" data-slot="carousel-viewport">
311
+ <div
312
+ ref={carouselRef}
313
+ className={cn(
314
+ "overflow-hidden",
315
+ orientation === "horizontal"
316
+ ? "touch-pan-y overscroll-x-contain"
317
+ : "touch-pan-x overscroll-y-contain",
318
+ )}
319
+ data-slot="carousel-viewport"
320
+ >
164
321
  <div
165
322
  data-slot="carousel-content"
166
323
  className={cn(
@@ -194,7 +351,7 @@ export const CarouselItem = ({ className, ...props }: CarouselItemProps) => {
194
351
 
195
352
  export const CarouselPrevious = ({
196
353
  className,
197
- variant = "outline",
354
+ variant = "ghost",
198
355
  size = "md",
199
356
  ...props
200
357
  }: CarouselPreviousProps) => {
@@ -211,7 +368,7 @@ export const CarouselPrevious = ({
211
368
  aria-label="Previous slide"
212
369
  data-slot="carousel-previous"
213
370
  className={cn(
214
- "absolute size-9",
371
+ "absolute size-9 border-0 focus-visible:border-0",
215
372
  orientation === "horizontal"
216
373
  ? "top-1/2 -left-12 -translate-y-1/2 active:!-translate-y-1/2"
217
374
  : "-top-12 left-1/2 -translate-x-1/2 rotate-90 active:!-translate-x-1/2 active:!translate-y-0",
@@ -227,7 +384,7 @@ export const CarouselPrevious = ({
227
384
 
228
385
  export const CarouselNext = ({
229
386
  className,
230
- variant = "outline",
387
+ variant = "ghost",
231
388
  size = "md",
232
389
  ...props
233
390
  }: CarouselNextProps) => {
@@ -244,7 +401,7 @@ export const CarouselNext = ({
244
401
  aria-label="Next slide"
245
402
  data-slot="carousel-next"
246
403
  className={cn(
247
- "absolute size-9",
404
+ "absolute size-9 border-0 focus-visible:border-0",
248
405
  orientation === "horizontal"
249
406
  ? "top-1/2 -right-12 -translate-y-1/2 active:!-translate-y-1/2"
250
407
  : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90 active:!-translate-x-1/2 active:!translate-y-0",
package/src/checkbox.tsx CHANGED
@@ -4,28 +4,50 @@ import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"
4
4
  import type { ComponentProps } from "react"
5
5
  import { IconCheckmark1, IconMinus } from "./icons"
6
6
  import { cn } from "./lib/cn"
7
+ import { motion } from "./lib/motion"
7
8
 
8
9
  export type CheckboxProps = ComponentProps<typeof BaseCheckbox.Root>
9
10
 
10
11
  export const Checkbox = ({ className, ...props }: CheckboxProps) => (
11
12
  <BaseCheckbox.Root
12
13
  className={cn(
13
- "group flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-secondary bg-surface transition-colors duration-150 ease-out motion-reduce:transition-none 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-checked:border-brand-primary data-checked:bg-brand-primary data-indeterminate:border-brand-primary data-indeterminate:bg-brand-primary data-disabled:cursor-not-allowed data-disabled:opacity-50",
14
+ "group flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-secondary bg-surface outline-none",
15
+ // motion.all, not motion.colors: the filled state brings a box shadow
16
+ // with it, which has to fade in alongside the background.
17
+ motion.all,
18
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
19
+ // Ticked and mixed are both filled controls, so they carry the primary
20
+ // button's treatment: brand fill, the border token that pairs with it,
21
+ // and the top-edge highlight that makes a solid control read as raised.
22
+ "data-checked:border-brand-primary-border data-checked:bg-brand-primary data-checked:inset-shadow-solid-top",
23
+ "data-indeterminate:border-brand-primary-border data-indeterminate:bg-brand-primary data-indeterminate:inset-shadow-solid-top",
24
+ "data-checked:hover:bg-brand-primary-hover data-indeterminate:hover:bg-brand-primary-hover",
25
+ "data-checked:active:bg-brand-primary-active data-indeterminate:active:bg-brand-primary-active",
26
+ "data-disabled:cursor-not-allowed data-disabled:opacity-50",
14
27
  className,
15
28
  )}
16
29
  {...props}
17
30
  >
18
- <BaseCheckbox.Indicator className="flex text-brand-foreground data-unchecked:hidden">
19
- <IconCheckmark1
20
- size={12}
21
- className="size-3 group-data-indeterminate:hidden"
22
- aria-hidden
23
- />
24
- <IconMinus
25
- size={12}
26
- className="hidden size-3 group-data-indeterminate:block"
27
- aria-hidden
28
- />
29
- </BaseCheckbox.Indicator>
31
+ {/* Only the active glyph is mounted. Central Icons give every instance of
32
+ an icon the same mask id, so a CSS-hidden twin would claim that id for
33
+ the whole document; a `display: none` subtree renders no mask, leaving
34
+ the visible instance to paint its backing rect unmasked as a solid
35
+ block. Mounting one icon keeps every id in the document resolvable. */}
36
+ {/* No `data-unchecked:hidden` here. Base UI keeps the indicator mounted
37
+ through its exit and flags it with both `data-unchecked` and
38
+ `data-ending-style`; hiding on the former would cut the latter's
39
+ transition short and the mark would vanish instantly. */}
40
+ <BaseCheckbox.Indicator
41
+ className={cn("flex text-brand-foreground", motion.checkIndicator)}
42
+ render={(props, state) => (
43
+ <span {...props}>
44
+ {state.indeterminate ? (
45
+ <IconMinus size={12} className="size-3" aria-hidden />
46
+ ) : (
47
+ <IconCheckmark1 size={12} className="size-3" aria-hidden />
48
+ )}
49
+ </span>
50
+ )}
51
+ />
30
52
  </BaseCheckbox.Root>
31
53
  )
@@ -25,7 +25,7 @@ export const CollapsibleTrigger = ({
25
25
  className={cn(
26
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
27
  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",
28
+ "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
29
  className,
30
30
  )}
31
31
  {...props}
@@ -56,6 +56,8 @@ export const CollapsiblePanel = ({
56
56
  )}
57
57
  {...props}
58
58
  >
59
- <div className="pt-2 leading-relaxed">{children}</div>
59
+ {/* px-3 matches the trigger's own padding, so the panel copy lines up
60
+ under the trigger's label instead of starting at the panel edge. */}
61
+ <div className="px-3 pt-2 leading-relaxed">{children}</div>
60
62
  </BaseCollapsible.Panel>
61
63
  )
package/src/combobox.tsx CHANGED
@@ -5,6 +5,7 @@ import type { ComponentProps, ReactElement } from "react"
5
5
  import { IconChevronDownSmall, IconCrossSmall } from "./icons"
6
6
  import { cn } from "./lib/cn"
7
7
  import { motion } from "./lib/motion"
8
+ import { popupInset, popupItem, popupLabel, popupMessage, popupSurface } from "./lib/popup"
8
9
 
9
10
  export type ComboboxProps<
10
11
  Value,
@@ -77,7 +78,6 @@ export const ComboboxInputGroup = ({
77
78
  <BaseCombobox.InputGroup
78
79
  className={cn(
79
80
  "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
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
82
  "aria-invalid:border-destructive aria-invalid:focus-within:border-destructive aria-invalid:focus-within:ring-destructive/20",
83
83
  "has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:focus-within:border-destructive has-[[aria-invalid=true]]:focus-within:ring-destructive/20",
@@ -139,7 +139,7 @@ export const ComboboxClear = ({
139
139
  {...props}
140
140
  >
141
141
  {children ?? (
142
- <IconCrossSmall size={14} className="size-3.5" aria-hidden />
142
+ <IconCrossSmall size={16} className="size-4" aria-hidden />
143
143
  )}
144
144
  </BaseCombobox.Clear>
145
145
  )
@@ -160,7 +160,7 @@ export const ComboboxBackdrop = ({
160
160
  )
161
161
 
162
162
  export const ComboboxPositioner = ({
163
- sideOffset = 4,
163
+ sideOffset = 6,
164
164
  className,
165
165
  ...props
166
166
  }: ComboboxPositionerProps) => (
@@ -174,7 +174,8 @@ export const ComboboxPositioner = ({
174
174
  export const ComboboxPopup = ({ className, ...props }: ComboboxPopupProps) => (
175
175
  <BaseCombobox.Popup
176
176
  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",
177
+ "z-50 max-h-[min(24rem,var(--available-height))] w-[var(--anchor-width)] overflow-hidden",
178
+ popupSurface,
178
179
  motion.popupAnchor,
179
180
  className,
180
181
  )}
@@ -199,7 +200,7 @@ export const ComboboxArrow = ({ className, ...props }: ComboboxArrowProps) => (
199
200
 
200
201
  export const ComboboxList = ({ className, ...props }: ComboboxListProps) => (
201
202
  <BaseCombobox.List
202
- className={cn("max-h-[inherit] overflow-y-auto p-1 outline-none", className)}
203
+ className={cn("max-h-[inherit] overflow-y-auto outline-none", popupInset, className)}
203
204
  {...props}
204
205
  />
205
206
  )
@@ -207,7 +208,7 @@ export const ComboboxList = ({ className, ...props }: ComboboxListProps) => (
207
208
  export const ComboboxItem = ({ className, ...props }: ComboboxItemProps) => (
208
209
  <BaseCombobox.Item
209
210
  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",
211
+ popupItem,
211
212
  motion.colors,
212
213
  "data-disabled:cursor-not-allowed data-disabled:opacity-50 data-highlighted:bg-background-tertiary data-highlighted:text-fg-primary",
213
214
  className,
@@ -238,14 +239,14 @@ export const ComboboxItemIndicator = ({
238
239
 
239
240
  export const ComboboxEmpty = ({ className, ...props }: ComboboxEmptyProps) => (
240
241
  <BaseCombobox.Empty
241
- className={cn("px-2.5 py-2 text-sm text-fg-tertiary empty:hidden", className)}
242
+ className={cn(cn(popupMessage, "empty:hidden"), className)}
242
243
  {...props}
243
244
  />
244
245
  )
245
246
 
246
247
  export const ComboboxStatus = ({ className, ...props }: ComboboxStatusProps) => (
247
248
  <BaseCombobox.Status
248
- className={cn("px-2.5 py-2 text-sm text-fg-tertiary", className)}
249
+ className={cn(popupMessage, className)}
249
250
  {...props}
250
251
  />
251
252
  )
@@ -259,7 +260,7 @@ export const ComboboxGroupLabel = ({
259
260
  ...props
260
261
  }: ComboboxGroupLabelProps) => (
261
262
  <BaseCombobox.GroupLabel
262
- className={cn("px-2.5 py-1.5 text-xs text-fg-tertiary", className)}
263
+ className={cn(popupLabel, className)}
263
264
  {...props}
264
265
  />
265
266
  )
@@ -291,7 +292,7 @@ export const ComboboxChips = ({ className, ...props }: ComboboxChipsProps) => (
291
292
  export const ComboboxChip = ({ className, ...props }: ComboboxChipProps) => (
292
293
  <BaseCombobox.Chip
293
294
  className={cn(
294
- "inline-flex h-6 items-center gap-1 rounded-xs bg-background-tertiary px-1.5 text-xs text-fg-primary",
295
+ "inline-flex h-6 items-center gap-1 rounded-xs bg-background-tertiary px-1.5 text-xs-strong text-fg-primary",
295
296
  className,
296
297
  )}
297
298
  {...props}
@@ -305,13 +306,13 @@ export const ComboboxChipRemove = ({
305
306
  }: ComboboxChipRemoveProps) => (
306
307
  <BaseCombobox.ChipRemove
307
308
  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",
309
+ "inline-flex size-3.5 cursor-pointer items-center justify-center text-fg-primary outline-none focus-visible:outline-none",
309
310
  className,
310
311
  )}
311
312
  {...props}
312
313
  >
313
314
  {children ?? (
314
- <IconCrossSmall size={12} className="size-3" aria-hidden />
315
+ <IconCrossSmall size={14} className="size-3.5" aria-hidden />
315
316
  )}
316
317
  </BaseCombobox.ChipRemove>
317
318
  )
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,6 +15,7 @@ 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"
17
20
  import { motion } from "./lib/motion"
18
21
 
@@ -49,7 +52,9 @@ export type CommandClearProps = ButtonHTMLAttributes<HTMLButtonElement>
49
52
  export type CommandCloseProps = ComponentProps<typeof BaseDialog.Close>
50
53
  export type CommandDividerProps = HTMLAttributes<HTMLDivElement>
51
54
  export type CommandContentProps = HTMLAttributes<HTMLDivElement>
55
+ /** @deprecated Use FilterGroup for new filter controls. */
52
56
  export type CommandFiltersProps = HTMLAttributes<HTMLUListElement>
57
+ /** @deprecated Use FilterItem for new filter controls. */
53
58
  export type CommandFilterProps = ButtonHTMLAttributes<HTMLButtonElement> & {
54
59
  selected?: boolean
55
60
  }
@@ -108,7 +113,7 @@ export const CommandBackdrop = ({
108
113
  export const CommandPopup = ({ className, ...props }: CommandPopupProps) => (
109
114
  <BaseDialog.Popup
110
115
  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)]",
116
+ "fixed top-1/2 left-1/2 z-50 flex h-[min(80vh,32rem)] w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl bg-surface-raised shadow-ambient outline-none max-sm:max-w-[calc(100vw-2rem)]",
112
117
  motion.popupCenter,
113
118
  className,
114
119
  )}
@@ -147,7 +152,6 @@ export const CommandInput = ({
147
152
  aria-activedescendant={activeOptionId}
148
153
  className={cn(
149
154
  "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
155
  "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none",
152
156
  className,
153
157
  )}
@@ -174,9 +178,9 @@ export const CommandClear = ({
174
178
  <button
175
179
  type="button"
176
180
  className={cn(
177
- "cursor-pointer rounded-full px-3 py-1.5 text-sm text-fg-quaternary outline-none",
181
+ "inline-flex h-8 cursor-pointer items-center rounded-full border border-transparent px-3 text-sm text-fg-quaternary outline-none",
178
182
  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",
183
+ "hover:bg-background-tertiary hover:text-fg-primary 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-[active=true]:text-fg-primary",
180
184
  className,
181
185
  )}
182
186
  {...props}
@@ -203,9 +207,9 @@ export const CommandClose = ({
203
207
  }: CommandCloseProps) => (
204
208
  <BaseDialog.Close
205
209
  className={cn(
206
- "inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-fg-primary outline-none",
210
+ "inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-transparent text-fg-primary outline-none",
207
211
  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",
212
+ "hover:bg-background-tertiary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
209
213
  className,
210
214
  )}
211
215
  aria-label={props["aria-label"] ?? "Close"}
@@ -223,7 +227,7 @@ export const CommandContent = ({
223
227
  }: CommandContentProps) => (
224
228
  <div
225
229
  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",
230
+ "flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-4 pt-1 pb-5",
227
231
  className,
228
232
  )}
229
233
  {...props}
@@ -237,7 +241,8 @@ export const CommandFilters = ({
237
241
  <ul
238
242
  role="list"
239
243
  className={cn(
240
- "flex shrink-0 flex-wrap justify-start gap-1.5 py-1.5",
244
+ filterGroupVariants({ variant: "pill" }),
245
+ "w-full shrink-0 justify-start py-1.5",
241
246
  className,
242
247
  )}
243
248
  {...props}
@@ -255,11 +260,10 @@ export const CommandFilter = ({
255
260
  type="button"
256
261
  aria-pressed={selected}
257
262
  data-selected={selected || undefined}
263
+ data-pressed={selected || undefined}
258
264
  className={cn(
259
- "cursor-pointer rounded-full px-4 py-2 text-sm text-fg-quaternary outline-none",
265
+ filterItemVariants({ variant: "pill", size: "md" }),
260
266
  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
267
  className,
264
268
  )}
265
269
  {...props}
@@ -277,7 +281,7 @@ export const CommandList = ({ className, ...props }: CommandListProps) => {
277
281
  id={listboxId}
278
282
  role="listbox"
279
283
  className={cn(
280
- "grid min-h-0 flex-1 content-start gap-0 overflow-visible overscroll-contain py-0.5",
284
+ "grid min-h-0 flex-1 content-start gap-0 overflow-y-auto overscroll-contain p-1",
281
285
  className,
282
286
  )}
283
287
  {...props}
@@ -295,12 +299,21 @@ export const CommandItem = ({
295
299
  onClick,
296
300
  ...props
297
301
  }: CommandItemProps) => {
302
+ const ref = useRef<HTMLAnchorElement & HTMLButtonElement>(null)
303
+
304
+ // Keyboard selection moves the highlight without moving focus, so nothing
305
+ // scrolls the list on its own. `nearest` only acts when the row is actually
306
+ // out of view, which leaves hover-driven selection alone.
307
+ useEffect(() => {
308
+ if (selected) ref.current?.scrollIntoView({ block: "nearest" })
309
+ }, [selected])
310
+
298
311
  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",
312
+ "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 outline-none select-none",
300
313
  motion.colors,
301
314
  "hover:bg-background-tertiary data-selected:bg-background-tertiary",
302
315
  "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",
316
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
304
317
  className,
305
318
  )
306
319
 
@@ -308,6 +321,7 @@ export const CommandItem = ({
308
321
  <li role="presentation" className="m-0">
309
322
  {href ? (
310
323
  <a
324
+ ref={ref}
311
325
  id={id}
312
326
  href={disabled ? undefined : href}
313
327
  role="option"
@@ -323,6 +337,7 @@ export const CommandItem = ({
323
337
  </a>
324
338
  ) : (
325
339
  <button
340
+ ref={ref}
326
341
  id={id}
327
342
  type="button"
328
343
  role="option"
@@ -369,6 +384,15 @@ export const CommandTitle = ({ className, children }: CommandTitleProps) => (
369
384
  <span className={cn("min-w-0 truncate", className)}>{children}</span>
370
385
  )
371
386
 
387
+ export type CommandMetaProps = HTMLAttributes<HTMLSpanElement>
388
+
389
+ export const CommandMeta = ({ className, ...props }: CommandMetaProps) => (
390
+ <span
391
+ className={cn("shrink-0 text-xs font-normal text-fg-tertiary", className)}
392
+ {...props}
393
+ />
394
+ )
395
+
372
396
  export const CommandDialogTitle = ({
373
397
  className,
374
398
  ...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
  )