@boyernick/standard-ui-react 0.1.1-canary.8 → 0.2.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.
Files changed (83) 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 +4 -3
  6. package/src/block-editor.tsx +1747 -0
  7. package/src/brand.tsx +2 -2
  8. package/src/breadcrumb.tsx +14 -7
  9. package/src/button.tsx +22 -11
  10. package/src/calendar.tsx +6 -3
  11. package/src/carousel.tsx +220 -53
  12. package/src/checkbox.tsx +3 -2
  13. package/src/code-block.tsx +66 -5
  14. package/src/collapsible.tsx +4 -2
  15. package/src/combobox.tsx +4 -3
  16. package/src/command.tsx +41 -13
  17. package/src/date-picker.tsx +7 -2
  18. package/src/dialog.tsx +33 -10
  19. package/src/empty.tsx +4 -4
  20. package/src/field.tsx +4 -3
  21. package/src/filter-group.tsx +143 -0
  22. package/src/icons.tsx +28 -1
  23. package/src/illustrations.tsx +219 -141
  24. package/src/index.ts +245 -18
  25. package/src/input.tsx +8 -5
  26. package/src/kbd.tsx +49 -0
  27. package/src/lib/focus.ts +41 -0
  28. package/src/lib/popup.ts +1 -1
  29. package/src/lifeline/company-icon.tsx +71 -0
  30. package/src/lifeline/index.ts +42 -0
  31. package/src/lifeline/lifeline-data.ts +97 -0
  32. package/src/lifeline/lifeline-desktop.tsx +204 -0
  33. package/src/lifeline/lifeline-event.tsx +108 -0
  34. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  35. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  36. package/src/lifeline/lifeline-icons.tsx +39 -0
  37. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  38. package/src/lifeline/lifeline-labels.tsx +24 -0
  39. package/src/lifeline/lifeline-layout.ts +1 -0
  40. package/src/lifeline/lifeline-legend.tsx +31 -0
  41. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  42. package/src/lifeline/lifeline-marker.tsx +183 -0
  43. package/src/lifeline/lifeline-people.tsx +99 -0
  44. package/src/lifeline/lifeline-photos.tsx +366 -0
  45. package/src/lifeline/lifeline-shell.tsx +135 -0
  46. package/src/lifeline/lifeline-utils.ts +83 -0
  47. package/src/lifeline/lifeline-vertical.tsx +482 -0
  48. package/src/lifeline/lifeline.tsx +69 -0
  49. package/src/lifeline/types.ts +112 -0
  50. package/src/lifeline/use-lifeline-intro.ts +130 -0
  51. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  52. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  53. package/src/menubar.tsx +9 -2
  54. package/src/minimap.tsx +296 -0
  55. package/src/modal.tsx +608 -0
  56. package/src/navigation-menu.tsx +338 -67
  57. package/src/number-field.tsx +336 -61
  58. package/src/otp-field.tsx +4 -3
  59. package/src/pagination.tsx +262 -42
  60. package/src/password-protection.tsx +345 -0
  61. package/src/popover.tsx +1 -1
  62. package/src/progress.tsx +5 -0
  63. package/src/questionnaire.tsx +284 -0
  64. package/src/radio.tsx +4 -2
  65. package/src/scroll-area.tsx +4 -1
  66. package/src/select.tsx +5 -2
  67. package/src/sidebar.tsx +113 -28
  68. package/src/slider.tsx +66 -6
  69. package/src/sounds.tsx +6 -10
  70. package/src/spinner.tsx +105 -32
  71. package/src/switch.tsx +4 -1
  72. package/src/table.tsx +82 -15
  73. package/src/tabs.tsx +189 -38
  74. package/src/text-animate.tsx +71 -28
  75. package/src/textarea.tsx +6 -1
  76. package/src/timeline.tsx +378 -0
  77. package/src/toast.tsx +214 -35
  78. package/src/toggle.tsx +4 -2
  79. package/src/toolbar.tsx +12 -7
  80. package/src/tooltip.tsx +43 -3
  81. package/src/video-player.tsx +396 -127
  82. package/src/image-modal.tsx +0 -110
  83. package/src/markdown-editor.tsx +0 -302
@@ -4,6 +4,10 @@ import { useState } from "react"
4
4
  import { highlight, type LanguageName } from "sugar-high"
5
5
  import { Button } from "./button"
6
6
  import { IconCheckmark1, IconSquareBehindSquare6 } from "./icons"
7
+ import { cn } from "./lib/cn"
8
+
9
+ /** 1-based line number, or an inclusive `[start, end]` range. */
10
+ export type CodeBlockHighlightLine = number | readonly [number, number]
7
11
 
8
12
  export type CodeBlockProps = {
9
13
  code: string
@@ -13,12 +17,18 @@ export type CodeBlockProps = {
13
17
  showHeader?: boolean
14
18
  /** Removes border and radius chrome for nesting inside another frame. */
15
19
  bare?: boolean
20
+ /** 1-based lines (or ranges) to emphasize inside the block. */
21
+ highlightLines?: readonly CodeBlockHighlightLine[]
22
+ /** 1-based lines added in a review (green). */
23
+ addedLines?: readonly CodeBlockHighlightLine[]
24
+ /** 1-based lines removed in a review (red). */
25
+ removedLines?: readonly CodeBlockHighlightLine[]
16
26
  className?: string
17
27
  }
18
28
 
19
29
  const sizeClass = {
20
- sm: "text-2xs p-3",
21
- md: "text-xs p-4",
30
+ sm: "text-2xs py-3",
31
+ md: "text-xs py-4",
22
32
  } as const
23
33
 
24
34
  const languages = new Set<LanguageName>([
@@ -59,17 +69,60 @@ const getHighlightLanguage = (lang: string): LanguageName => {
59
69
  return "typescript"
60
70
  }
61
71
 
72
+ const toHighlightedLines = (
73
+ lines?: readonly CodeBlockHighlightLine[],
74
+ ): Set<number> => {
75
+ const set = new Set<number>()
76
+ if (!lines) return set
77
+
78
+ for (const entry of lines) {
79
+ if (typeof entry === "number") {
80
+ set.add(entry)
81
+ continue
82
+ }
83
+
84
+ const [start, end] = entry
85
+ const from = Math.min(start, end)
86
+ const to = Math.max(start, end)
87
+ for (let line = from; line <= to; line += 1) set.add(line)
88
+ }
89
+
90
+ return set
91
+ }
92
+
62
93
  export const CodeBlock = ({
63
94
  code: rawCode,
64
95
  lang = "tsx",
65
96
  size = "md",
66
97
  showHeader = true,
67
98
  bare = false,
99
+ highlightLines,
100
+ addedLines,
101
+ removedLines,
68
102
  className = "",
69
103
  }: CodeBlockProps) => {
70
104
  const [copied, setCopied] = useState(false)
71
105
  const code = rawCode.replace(/^\n/, "").replace(/\n$/, "")
72
- const html = highlight(code, { lang: getHighlightLanguage(lang) })
106
+ const focused = toHighlightedLines(highlightLines)
107
+ const added = toHighlightedLines(addedLines)
108
+ const removed = toHighlightedLines(removedLines)
109
+ const html = highlight(code, {
110
+ lang: getHighlightLanguage(lang),
111
+ markLine: (line) => {
112
+ const number = line.index + 1
113
+ if (added.has(number)) {
114
+ line.className += " sh__line--added"
115
+ return
116
+ }
117
+ if (removed.has(number)) {
118
+ line.className += " sh__line--removed"
119
+ return
120
+ }
121
+ if (focused.has(number)) {
122
+ line.className += " sh__line--highlighted"
123
+ }
124
+ },
125
+ })
73
126
  const frameClass =
74
127
  !showHeader && !bare
75
128
  ? "rounded-lg border border-border-primary bg-surface"
@@ -87,7 +140,11 @@ export const CodeBlock = ({
87
140
 
88
141
  const codeContent = (
89
142
  <pre
90
- className={`sh-code overflow-x-auto font-mono ${sizeClass[size]} ${frameClass}`}
143
+ className={cn(
144
+ "sh-code overflow-x-auto font-mono",
145
+ sizeClass[size],
146
+ frameClass,
147
+ )}
91
148
  dangerouslySetInnerHTML={{ __html: html }}
92
149
  />
93
150
  )
@@ -98,7 +155,11 @@ export const CodeBlock = ({
98
155
 
99
156
  return (
100
157
  <div
101
- className={`overflow-hidden rounded-xl border border-border-primary bg-surface ${className}`}
158
+ data-slot="code-block"
159
+ className={cn(
160
+ "overflow-hidden rounded-xl border border-border-primary bg-surface",
161
+ className,
162
+ )}
102
163
  >
103
164
  <div className="flex items-center justify-between border-b border-border-primary bg-background-secondary px-3 py-1.5 pl-4">
104
165
  <p className="text-xs font-mono text-fg-quaternary">{lang}</p>
@@ -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
- "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}
package/src/combobox.tsx CHANGED
@@ -4,6 +4,7 @@ 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"
8
9
  import { popupInset, popupItem, popupLabel, popupMessage, popupSurface } from "./lib/popup"
9
10
 
@@ -77,9 +78,9 @@ export const ComboboxInputGroup = ({
77
78
  }: ComboboxInputGroupProps) => (
78
79
  <BaseCombobox.InputGroup
79
80
  className={cn(
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]",
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,
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-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)]",
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
  )}
@@ -173,9 +179,10 @@ export const CommandClear = ({
173
179
  <button
174
180
  type="button"
175
181
  className={cn(
176
- "inline-flex h-8 cursor-pointer items-center rounded-full border border-transparent px-3 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",
177
183
  motion.colors,
178
- "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",
184
+ focusRing,
185
+ "hover:bg-background-tertiary hover:text-fg-primary data-[active=true]:text-fg-primary",
179
186
  className,
180
187
  )}
181
188
  {...props}
@@ -202,9 +209,10 @@ export const CommandClose = ({
202
209
  }: CommandCloseProps) => (
203
210
  <BaseDialog.Close
204
211
  className={cn(
205
- "inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-transparent 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",
206
213
  motion.colors,
207
- "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",
214
+ focusRing,
215
+ "hover:bg-background-tertiary",
208
216
  className,
209
217
  )}
210
218
  aria-label={props["aria-label"] ?? "Close"}
@@ -222,7 +230,7 @@ export const CommandContent = ({
222
230
  }: CommandContentProps) => (
223
231
  <div
224
232
  className={cn(
225
- "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",
226
234
  className,
227
235
  )}
228
236
  {...props}
@@ -236,7 +244,8 @@ export const CommandFilters = ({
236
244
  <ul
237
245
  role="list"
238
246
  className={cn(
239
- "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",
240
249
  className,
241
250
  )}
242
251
  {...props}
@@ -254,11 +263,10 @@ export const CommandFilter = ({
254
263
  type="button"
255
264
  aria-pressed={selected}
256
265
  data-selected={selected || undefined}
266
+ data-pressed={selected || undefined}
257
267
  className={cn(
258
- "inline-flex h-9 cursor-pointer items-center rounded-full border border-transparent px-4 text-sm text-fg-quaternary outline-none",
268
+ filterItemVariants({ variant: "pill", size: "md" }),
259
269
  motion.colors,
260
- "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",
261
- "data-selected:bg-background-tertiary data-selected:text-fg-primary",
262
270
  className,
263
271
  )}
264
272
  {...props}
@@ -276,7 +284,7 @@ export const CommandList = ({ className, ...props }: CommandListProps) => {
276
284
  id={listboxId}
277
285
  role="listbox"
278
286
  className={cn(
279
- "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",
280
288
  className,
281
289
  )}
282
290
  {...props}
@@ -294,12 +302,21 @@ export const CommandItem = ({
294
302
  onClick,
295
303
  ...props
296
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
+
297
314
  const itemClassName = cn(
298
- "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",
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",
299
316
  motion.colors,
300
317
  "hover:bg-background-tertiary data-selected:bg-background-tertiary",
301
318
  "data-disabled:cursor-not-allowed data-disabled:opacity-50",
302
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
319
+ focusRing,
303
320
  className,
304
321
  )
305
322
 
@@ -307,6 +324,7 @@ export const CommandItem = ({
307
324
  <li role="presentation" className="m-0">
308
325
  {href ? (
309
326
  <a
327
+ ref={ref}
310
328
  id={id}
311
329
  href={disabled ? undefined : href}
312
330
  role="option"
@@ -322,6 +340,7 @@ export const CommandItem = ({
322
340
  </a>
323
341
  ) : (
324
342
  <button
343
+ ref={ref}
325
344
  id={id}
326
345
  type="button"
327
346
  role="option"
@@ -368,6 +387,15 @@ export const CommandTitle = ({ className, children }: CommandTitleProps) => (
368
387
  <span className={cn("min-w-0 truncate", className)}>{children}</span>
369
388
  )
370
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
+
371
399
  export const CommandDialogTitle = ({
372
400
  className,
373
401
  ...props
@@ -62,11 +62,16 @@ const DatePickerTrigger = ({
62
62
  render={
63
63
  <Button
64
64
  type="button"
65
- size="sm"
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"
66
68
  variant="outline"
67
69
  disabled={disabled}
68
70
  prefix={<IconCalendar1 aria-hidden />}
69
- className={cn("w-56 justify-start", className)}
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)}
70
75
  />
71
76
  }
72
77
  >
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
  )}
@@ -0,0 +1,143 @@
1
+ "use client"
2
+
3
+ import { Toggle as BaseToggle } from "@base-ui/react/toggle"
4
+ import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+ import { createContext, type ComponentProps, useContext } from "react"
7
+ import { cn } from "./lib/cn"
8
+ import { focusRing } from "./lib/focus"
9
+ import { motion } from "./lib/motion"
10
+
11
+ const filterGroupVariants = cva("flex w-fit items-center", {
12
+ variants: {
13
+ variant: {
14
+ pill: "flex-wrap gap-1",
15
+ segmented: "gap-0.5 rounded-lg bg-background-tertiary p-0.5",
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ variant: "pill",
20
+ },
21
+ })
22
+
23
+ const filterItemVariants = cva(
24
+ cn(
25
+ "group/filter-item inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap border border-transparent text-fg-tertiary hover:text-fg-primary data-disabled:cursor-not-allowed data-disabled:opacity-50 data-pressed:text-fg-primary",
26
+ focusRing,
27
+ ),
28
+ {
29
+ variants: {
30
+ variant: {
31
+ pill: "rounded-full hover:bg-background-tertiary data-pressed:bg-background-tertiary",
32
+ segmented:
33
+ "rounded-md hover:bg-background-quaternary data-pressed:bg-surface-raised data-pressed:shadow-hairline",
34
+ },
35
+ size: {
36
+ sm: "text-xs",
37
+ md: "text-sm",
38
+ lg: "text-sm",
39
+ },
40
+ },
41
+ compoundVariants: [
42
+ { variant: "pill", size: "sm", class: "h-8 px-3" },
43
+ { variant: "pill", size: "md", class: "h-9 px-4" },
44
+ { variant: "pill", size: "lg", class: "h-10 px-5" },
45
+ { variant: "segmented", size: "sm", class: "h-7 px-2.5" },
46
+ { variant: "segmented", size: "md", class: "h-8 px-3" },
47
+ { variant: "segmented", size: "lg", class: "h-9 px-3.5" },
48
+ ],
49
+ defaultVariants: {
50
+ variant: "pill",
51
+ size: "md",
52
+ },
53
+ },
54
+ )
55
+
56
+ export type FilterGroupVariant = NonNullable<
57
+ VariantProps<typeof filterGroupVariants>["variant"]
58
+ >
59
+ export type FilterGroupSize = NonNullable<
60
+ VariantProps<typeof filterItemVariants>["size"]
61
+ >
62
+
63
+ type FilterGroupStyleContextValue = {
64
+ variant: FilterGroupVariant
65
+ size: FilterGroupSize
66
+ }
67
+
68
+ const FilterGroupStyleContext = createContext<FilterGroupStyleContextValue>({
69
+ variant: "pill",
70
+ size: "md",
71
+ })
72
+
73
+ export type FilterGroupProps = ComponentProps<typeof BaseToggleGroup> &
74
+ VariantProps<typeof filterGroupVariants> & {
75
+ /** Whether pressing the active item can clear a single-select group. */
76
+ allowEmpty?: boolean
77
+ size?: FilterGroupSize
78
+ }
79
+ export type FilterItemProps = ComponentProps<typeof BaseToggle> &
80
+ VariantProps<typeof filterItemVariants>
81
+ export type FilterCountProps = ComponentProps<"span">
82
+
83
+ export const FilterGroup = ({
84
+ className,
85
+ variant = "pill",
86
+ size = "md",
87
+ allowEmpty = false,
88
+ multiple = false,
89
+ onValueChange,
90
+ ...props
91
+ }: FilterGroupProps) => (
92
+ <FilterGroupStyleContext.Provider value={{ variant: variant ?? "pill", size }}>
93
+ <BaseToggleGroup
94
+ className={cn(filterGroupVariants({ variant }), className)}
95
+ multiple={multiple}
96
+ onValueChange={(value, eventDetails) => {
97
+ if (!multiple && !allowEmpty && value.length === 0) {
98
+ eventDetails.cancel()
99
+ return
100
+ }
101
+ onValueChange?.(value, eventDetails)
102
+ }}
103
+ {...props}
104
+ />
105
+ </FilterGroupStyleContext.Provider>
106
+ )
107
+
108
+ export const FilterItem = ({
109
+ className,
110
+ variant,
111
+ size,
112
+ type = "button",
113
+ ...props
114
+ }: FilterItemProps) => {
115
+ const styles = useContext(FilterGroupStyleContext)
116
+
117
+ return (
118
+ <BaseToggle
119
+ type={type}
120
+ className={cn(
121
+ filterItemVariants({
122
+ variant: variant ?? styles.variant,
123
+ size: size ?? styles.size,
124
+ }),
125
+ motion.colors,
126
+ className,
127
+ )}
128
+ {...props}
129
+ />
130
+ )
131
+ }
132
+
133
+ export const FilterCount = ({ className, ...props }: FilterCountProps) => (
134
+ <span
135
+ className={cn(
136
+ "text-2xs-strong inline-flex min-w-4 items-center justify-center rounded-full bg-background-quaternary px-1 py-0.5 text-fg-secondary tabular-nums group-data-pressed/filter-item:bg-surface",
137
+ className,
138
+ )}
139
+ {...props}
140
+ />
141
+ )
142
+
143
+ export { filterGroupVariants, filterItemVariants }