@pantoia/ui 0.13.0 → 0.14.1

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.
@@ -0,0 +1,3 @@
1
+ .group > * {
2
+ box-shadow: 0 0 0 2px var(--avatar-group-ring, var(--color-bg));
3
+ }
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { Children, cloneElement, isValidElement, type ComponentProps, type ReactElement, type ReactNode } from 'react'
4
+
5
+ import { avatar, type AvatarProps } from '../avatar'
6
+ import { tv } from '../tv'
7
+
8
+ import s from './index.module.css'
9
+
10
+ type Size = NonNullable<AvatarProps['size']>
11
+
12
+ export const avatarGroup = tv({
13
+ base: 'inline-flex items-center',
14
+
15
+ variants: {
16
+ size: {
17
+ xs: '-space-x-1',
18
+ sm: '-space-x-1',
19
+ md: '-space-x-1.5',
20
+ lg: '-space-x-2',
21
+ xl: '-space-x-3',
22
+ },
23
+ },
24
+
25
+ defaultVariants: { size: 'md' },
26
+ })
27
+
28
+ export type AvatarGroupProps = Omit<ComponentProps<'div'>, 'className' | 'children'> & {
29
+ max?: number
30
+ size?: Size
31
+ renderOverflow?: (count: number) => ReactNode
32
+ className?: string
33
+ children?: ReactNode
34
+ }
35
+
36
+ export function AvatarGroup({ max, size = 'md', renderOverflow, className, children, ...rest }: AvatarGroupProps) {
37
+ const people = Children.toArray(children).filter(isValidElement) as ReactElement<AvatarProps>[]
38
+ const shown = max !== undefined && people.length > max ? people.slice(0, max) : people
39
+ const hidden = people.length - shown.length
40
+
41
+ return (
42
+ <div role="group" {...rest} className={avatarGroup({ size, className: [s.group, className] })}>
43
+ {shown.map(person => cloneElement(person, { size }))}
44
+
45
+ {hidden > 0 && (
46
+ <span role="img" aria-label={`외 ${hidden}명`} className={avatar({ color: 'neutral', size })}>
47
+ {renderOverflow ? renderOverflow(hidden) : `+${hidden}`}
48
+ </span>
49
+ )}
50
+ </div>
51
+ )
52
+ }
@@ -3,6 +3,7 @@
3
3
  import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'
4
4
  import type { ComponentProps, ReactNode } from 'react'
5
5
 
6
+ import { Button } from '../button'
6
7
  import { tv } from '../tv'
7
8
 
8
9
  import s from './index.module.css'
@@ -62,11 +63,23 @@ export const bottomSheetHeader = tv({
62
63
  export interface BottomSheetHeaderProps {
63
64
  lead?: ReactNode
64
65
  trail?: ReactNode
66
+ closable?: boolean
67
+ closeLabel?: string
65
68
  className?: string
66
69
  children?: ReactNode
67
70
  }
68
71
 
69
- export function BottomSheetHeader({ lead, trail, className, children }: BottomSheetHeaderProps) {
72
+ function HeaderClose({ label }: { label: string }) {
73
+ return (
74
+ <DrawerPrimitive.Close render={<Button color="neutral" emphasis="ghost" shape="square" aria-label={label} />}>
75
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
76
+ <path d="M18 6 6 18M6 6l12 12" />
77
+ </svg>
78
+ </DrawerPrimitive.Close>
79
+ )
80
+ }
81
+
82
+ export function BottomSheetHeader({ lead, trail, closable = false, closeLabel = '닫기', className, children }: BottomSheetHeaderProps) {
70
83
  const ui = bottomSheetHeader()
71
84
 
72
85
  return (
@@ -74,6 +87,7 @@ export function BottomSheetHeader({ lead, trail, className, children }: BottomSh
74
87
  {lead && <div className={ui.lead()}>{lead}</div>}
75
88
  <div className={ui.body()}>{children}</div>
76
89
  {trail && <div className={ui.trail()}>{trail}</div>}
90
+ {closable && <div className={ui.trail()}><HeaderClose label={closeLabel} /></div>}
77
91
  </div>
78
92
  )
79
93
  }
@@ -135,7 +135,9 @@ export function ComboboxCancel({ label = '지우기', keepMounted = true, classN
135
135
  )
136
136
  }
137
137
 
138
- export type ComboboxContentProps = Omit<ComponentProps<typeof ComboboxPrimitive.Popup>, 'className' | 'children'> & {
138
+ type ComboboxPlacement = Pick<ComponentProps<typeof ComboboxPrimitive.Positioner>, 'sideOffset' | 'alignOffset' | 'collisionAvoidance'>
139
+
140
+ export type ComboboxContentProps = Omit<ComponentProps<typeof ComboboxPrimitive.Popup>, 'className' | 'children'> & ComboboxPlacement & {
139
141
  className?: string
140
142
  children?: ComboboxPrimitive.List.Props['children'] | ComboboxPrimitive.List.Props['children'][]
141
143
  }
@@ -148,7 +150,7 @@ const comboboxContent = tv({
148
150
 
149
151
  const isEmpty = (child: unknown) => isValidElement(child) && child.type === ComboboxEmpty
150
152
 
151
- export function ComboboxContent({ 'aria-label': label, className, children, ...rest }: ComboboxContentProps) {
153
+ export function ComboboxContent({ 'aria-label': label, sideOffset = 4, alignOffset, collisionAvoidance, className, children, ...rest }: ComboboxContentProps) {
152
154
  const nodes = Array.isArray(children) ? children : [children]
153
155
  const empty = nodes.filter(isEmpty) as ReactNode[]
154
156
  const list = nodes.filter(node => !isEmpty(node))
@@ -157,7 +159,9 @@ export function ComboboxContent({ 'aria-label': label, className, children, ...r
157
159
  <ComboboxPrimitive.Portal>
158
160
  <ComboboxPrimitive.Positioner
159
161
  align="start"
160
- sideOffset={4}
162
+ sideOffset={sideOffset}
163
+ alignOffset={alignOffset}
164
+ collisionAvoidance={collisionAvoidance}
161
165
  className="z-overlay min-w-max will-change-transform"
162
166
  >
163
167
  <ComboboxPrimitive.Popup {...rest} className={comboboxContent({ className })}>
@@ -1,12 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu'
4
- import type { ComponentProps } from 'react'
4
+ import type { ComponentProps, ReactNode } from 'react'
5
5
 
6
6
  import {
7
7
  DropdownMenuCheckboxItem,
8
8
  DropdownMenuGroup,
9
9
  DropdownMenuItem,
10
+ DropdownMenuItemDescription,
10
11
  DropdownMenuRadioGroup,
11
12
  DropdownMenuRadioItem,
12
13
  DropdownMenuSeparator,
@@ -37,14 +38,21 @@ export function ContextMenuTrigger(props: ContextMenuTriggerProps) {
37
38
  return <ContextMenuPrimitive.Trigger {...props} />
38
39
  }
39
40
 
40
- export type ContextMenuContentProps = Omit<ComponentProps<typeof ContextMenuPrimitive.Popup>, 'className'> & {
41
+ type ContextMenuPlacement = Pick<ComponentProps<typeof ContextMenuPrimitive.Positioner>, 'sideOffset' | 'alignOffset' | 'collisionAvoidance'>
42
+
43
+ export type ContextMenuContentProps = Omit<ComponentProps<typeof ContextMenuPrimitive.Popup>, 'className'> & ContextMenuPlacement & {
41
44
  className?: string
42
45
  }
43
46
 
44
- export function ContextMenuContent({ className, ...rest }: ContextMenuContentProps) {
47
+ export function ContextMenuContent({ sideOffset, alignOffset, collisionAvoidance, className, ...rest }: ContextMenuContentProps) {
45
48
  return (
46
49
  <ContextMenuPrimitive.Portal>
47
- <ContextMenuPrimitive.Positioner className="z-overlay min-w-max will-change-transform">
50
+ <ContextMenuPrimitive.Positioner
51
+ sideOffset={sideOffset}
52
+ alignOffset={alignOffset}
53
+ collisionAvoidance={collisionAvoidance}
54
+ className="z-overlay min-w-max will-change-transform"
55
+ >
48
56
  <ContextMenuPrimitive.Popup {...rest} className={menuPanel({ className })} />
49
57
  </ContextMenuPrimitive.Positioner>
50
58
  </ContextMenuPrimitive.Portal>
@@ -63,6 +71,10 @@ export function ContextMenuItem(props: ContextMenuItemProps) {
63
71
  return <DropdownMenuItem {...props} />
64
72
  }
65
73
 
74
+ export function ContextMenuItemDescription(props: { className?: string, children?: ReactNode }) {
75
+ return <DropdownMenuItemDescription {...props} />
76
+ }
77
+
66
78
  export type ContextMenuCheckboxItemProps = DropdownMenuCheckboxItemProps
67
79
 
68
80
  export function ContextMenuCheckboxItem(props: ContextMenuCheckboxItemProps) {
package/dialog/index.tsx CHANGED
@@ -3,6 +3,7 @@
3
3
  import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
4
4
  import type { ComponentProps, ReactNode } from 'react'
5
5
 
6
+ import { Button } from '../button'
6
7
  import { tv } from '../tv'
7
8
 
8
9
  export const dialog = tv({
@@ -92,11 +93,23 @@ export const dialogHeader = tv({
92
93
  export interface DialogHeaderProps {
93
94
  lead?: ReactNode
94
95
  trail?: ReactNode
96
+ closable?: boolean
97
+ closeLabel?: string
95
98
  className?: string
96
99
  children?: ReactNode
97
100
  }
98
101
 
99
- export function DialogHeader({ lead, trail, className, children }: DialogHeaderProps) {
102
+ function HeaderClose({ label }: { label: string }) {
103
+ return (
104
+ <DialogPrimitive.Close render={<Button color="neutral" emphasis="ghost" shape="square" aria-label={label} />}>
105
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
106
+ <path d="M18 6 6 18M6 6l12 12" />
107
+ </svg>
108
+ </DialogPrimitive.Close>
109
+ )
110
+ }
111
+
112
+ export function DialogHeader({ lead, trail, closable = false, closeLabel = '닫기', className, children }: DialogHeaderProps) {
100
113
  const ui = dialogHeader()
101
114
 
102
115
  return (
@@ -104,6 +117,7 @@ export function DialogHeader({ lead, trail, className, children }: DialogHeaderP
104
117
  {lead && <div className={ui.lead()}>{lead}</div>}
105
118
  <div className={ui.body()}>{children}</div>
106
119
  {trail && <div className={ui.trail()}>{trail}</div>}
120
+ {closable && <div className={ui.trail()}><HeaderClose label={closeLabel} /></div>}
107
121
  </div>
108
122
  )
109
123
  }
package/drawer/index.tsx CHANGED
@@ -3,6 +3,7 @@
3
3
  import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'
4
4
  import { createContext, use, type ComponentProps, type ReactNode } from 'react'
5
5
 
6
+ import { Button } from '../button'
6
7
  import { tv } from '../tv'
7
8
 
8
9
  import s from './index.module.css'
@@ -85,11 +86,23 @@ export const drawerHeader = tv({
85
86
  export interface DrawerHeaderProps {
86
87
  lead?: ReactNode
87
88
  trail?: ReactNode
89
+ closable?: boolean
90
+ closeLabel?: string
88
91
  className?: string
89
92
  children?: ReactNode
90
93
  }
91
94
 
92
- export function DrawerHeader({ lead, trail, className, children }: DrawerHeaderProps) {
95
+ function HeaderClose({ label }: { label: string }) {
96
+ return (
97
+ <DrawerPrimitive.Close render={<Button color="neutral" emphasis="ghost" shape="square" aria-label={label} />}>
98
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
99
+ <path d="M18 6 6 18M6 6l12 12" />
100
+ </svg>
101
+ </DrawerPrimitive.Close>
102
+ )
103
+ }
104
+
105
+ export function DrawerHeader({ lead, trail, closable = false, closeLabel = '닫기', className, children }: DrawerHeaderProps) {
93
106
  const ui = drawerHeader()
94
107
 
95
108
  return (
@@ -97,6 +110,7 @@ export function DrawerHeader({ lead, trail, className, children }: DrawerHeaderP
97
110
  {lead && <div className={ui.lead()}>{lead}</div>}
98
111
  <div className={ui.body()}>{children}</div>
99
112
  {trail && <div className={ui.trail()}>{trail}</div>}
113
+ {closable && <div className={ui.trail()}><HeaderClose label={closeLabel} /></div>}
100
114
  </div>
101
115
  )
102
116
  }
@@ -1,13 +1,14 @@
1
1
  'use client'
2
2
 
3
3
  import { Menu as MenuPrimitive } from '@base-ui/react/menu'
4
- import type { ComponentProps, ReactNode } from 'react'
4
+ import { createContext, use, useEffect, useId, useState, type ComponentProps, type ReactNode } from 'react'
5
5
 
6
6
  import { tv } from '../tv'
7
7
 
8
8
  export const menuItem = tv({
9
- base: 'flex h-9 cursor-default items-center gap-2 rounded-r8 px-2 menu-gutter:ps-8'
9
+ base: 'flex min-h-9 cursor-default items-center gap-2 rounded-r8 px-2 py-1.5 menu-gutter:ps-8'
10
10
  + ' [&_svg]:size-4 [&_svg]:shrink-0'
11
+ + ' described:items-start described:[&>.menu-lead]:mt-0.5 described:[&>svg]:mt-0.5'
11
12
  + ' text-label transition-state'
12
13
  + ' data-highlighted:outline-none data-disabled:pointer-events-none',
13
14
 
@@ -21,6 +22,20 @@ export const menuItem = tv({
21
22
  defaultVariants: { color: 'neutral' },
22
23
  })
23
24
 
25
+ interface DescriptionContext {
26
+ id: string
27
+ setDescribed: (described: boolean) => void
28
+ }
29
+
30
+ const ItemDescription = createContext<DescriptionContext | null>(null)
31
+
32
+ function useDescription() {
33
+ const id = useId()
34
+ const [described, setDescribed] = useState(false)
35
+
36
+ return { context: { id, setDescribed }, describedBy: described ? id : undefined }
37
+ }
38
+
24
39
  const PANEL = 'menu float text-fg rounded-r12 z-overlay'
25
40
  + ' flex min-w-44 flex-col gap-px p-1'
26
41
  + ' open:animate-enter closed:animate-exit'
@@ -38,7 +53,9 @@ export function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
38
53
  return <MenuPrimitive.Trigger {...props} />
39
54
  }
40
55
 
41
- export type DropdownMenuContentProps = Omit<ComponentProps<typeof MenuPrimitive.Popup>, 'className'> & {
56
+ type MenuPlacement = Pick<ComponentProps<typeof MenuPrimitive.Positioner>, 'anchor' | 'sideOffset' | 'alignOffset' | 'collisionAvoidance'>
57
+
58
+ export type DropdownMenuContentProps = Omit<ComponentProps<typeof MenuPrimitive.Popup>, 'className'> & MenuPlacement & {
42
59
  side?: 'top' | 'right' | 'bottom' | 'left'
43
60
  align?: 'start' | 'center' | 'end'
44
61
  arrow?: boolean
@@ -59,6 +76,10 @@ export function DropdownMenuContent({
59
76
  side = 'bottom',
60
77
  align = 'start',
61
78
  arrow = true,
79
+ anchor,
80
+ sideOffset,
81
+ alignOffset,
82
+ collisionAvoidance,
62
83
  className,
63
84
  children,
64
85
  ...rest
@@ -66,9 +87,12 @@ export function DropdownMenuContent({
66
87
  return (
67
88
  <MenuPrimitive.Portal>
68
89
  <MenuPrimitive.Positioner
90
+ anchor={anchor}
69
91
  side={side}
70
92
  align={align}
71
- sideOffset={arrow ? 14 : 8}
93
+ sideOffset={sideOffset ?? (arrow ? 14 : 8)}
94
+ alignOffset={alignOffset}
95
+ collisionAvoidance={collisionAvoidance}
72
96
  arrowPadding={16}
73
97
  className="z-overlay min-w-max will-change-transform"
74
98
  >
@@ -116,17 +140,21 @@ export type DropdownMenuItemProps = Omit<ComponentProps<typeof MenuPrimitive.Ite
116
140
  }
117
141
 
118
142
  export function DropdownMenuItem({ color, lead, trail, className, children, ...rest }: DropdownMenuItemProps) {
119
- return (
120
- <MenuPrimitive.Item {...rest} className={menuItem({ color, className })}>
121
- {lead && (
122
- <span className="menu-lead grid size-4 shrink-0 place-items-center">
123
- {lead}
124
- </span>
125
- )}
143
+ const { context, describedBy } = useDescription()
126
144
 
127
- <span className="min-w-0 grow">{children}</span>
128
- {trail}
129
- </MenuPrimitive.Item>
145
+ return (
146
+ <ItemDescription value={context}>
147
+ <MenuPrimitive.Item aria-describedby={describedBy} {...rest} className={menuItem({ color, className })}>
148
+ {lead && (
149
+ <span className="menu-lead grid size-4 shrink-0 place-items-center">
150
+ {lead}
151
+ </span>
152
+ )}
153
+
154
+ <span className="min-w-0 grow">{children}</span>
155
+ {trail}
156
+ </MenuPrimitive.Item>
157
+ </ItemDescription>
130
158
  )
131
159
  }
132
160
 
@@ -135,18 +163,22 @@ export type DropdownMenuCheckboxItemProps = Omit<ComponentProps<typeof MenuPrimi
135
163
  }
136
164
 
137
165
  export function DropdownMenuCheckboxItem({ className, children, ...rest }: DropdownMenuCheckboxItemProps) {
166
+ const { context, describedBy } = useDescription()
167
+
138
168
  return (
139
- <MenuPrimitive.CheckboxItem {...rest} className={menuItem({ className })}>
140
- <span className="menu-lead grid size-4 shrink-0 place-items-center">
141
- <MenuPrimitive.CheckboxItemIndicator>
142
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
143
- <path d="M20 6 9 17l-5-5" />
144
- </svg>
145
- </MenuPrimitive.CheckboxItemIndicator>
146
- </span>
147
-
148
- <span className="min-w-0 grow">{children}</span>
149
- </MenuPrimitive.CheckboxItem>
169
+ <ItemDescription value={context}>
170
+ <MenuPrimitive.CheckboxItem aria-describedby={describedBy} {...rest} className={menuItem({ className })}>
171
+ <span className="menu-lead grid size-4 shrink-0 place-items-center">
172
+ <MenuPrimitive.CheckboxItemIndicator>
173
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
174
+ <path d="M20 6 9 17l-5-5" />
175
+ </svg>
176
+ </MenuPrimitive.CheckboxItemIndicator>
177
+ </span>
178
+
179
+ <span className="min-w-0 grow">{children}</span>
180
+ </MenuPrimitive.CheckboxItem>
181
+ </ItemDescription>
150
182
  )
151
183
  }
152
184
 
@@ -161,16 +193,39 @@ export type DropdownMenuRadioItemProps = Omit<ComponentProps<typeof MenuPrimitiv
161
193
  }
162
194
 
163
195
  export function DropdownMenuRadioItem({ className, children, ...rest }: DropdownMenuRadioItemProps) {
196
+ const { context, describedBy } = useDescription()
197
+
164
198
  return (
165
- <MenuPrimitive.RadioItem {...rest} className={menuItem({ className })}>
166
- <span className="menu-lead grid size-4 shrink-0 place-items-center">
167
- <MenuPrimitive.RadioItemIndicator>
168
- <span className="bg-bg-neutral-solid in-disabled:bg-bg-neutral-solid-checked-disabled size-2 rounded-full" />
169
- </MenuPrimitive.RadioItemIndicator>
170
- </span>
171
-
172
- <span className="min-w-0 grow">{children}</span>
173
- </MenuPrimitive.RadioItem>
199
+ <ItemDescription value={context}>
200
+ <MenuPrimitive.RadioItem aria-describedby={describedBy} {...rest} className={menuItem({ className })}>
201
+ <span className="menu-lead grid size-4 shrink-0 place-items-center">
202
+ <MenuPrimitive.RadioItemIndicator>
203
+ <span className="bg-bg-neutral-solid in-disabled:bg-bg-neutral-solid-checked-disabled block size-2 rounded-full" />
204
+ </MenuPrimitive.RadioItemIndicator>
205
+ </span>
206
+
207
+ <span className="min-w-0 grow">{children}</span>
208
+ </MenuPrimitive.RadioItem>
209
+ </ItemDescription>
210
+ )
211
+ }
212
+
213
+ const menuItemDescription = tv({ base: 'text-caption text-fg-subtle block' })
214
+
215
+ export function DropdownMenuItemDescription({ className, children }: { className?: string, children?: ReactNode }) {
216
+ const ctx = use(ItemDescription)
217
+ const setDescribed = ctx?.setDescribed
218
+
219
+ useEffect(() => {
220
+ if (!setDescribed) return
221
+ setDescribed(true)
222
+ return () => setDescribed(false)
223
+ }, [setDescribed])
224
+
225
+ return (
226
+ <span id={ctx?.id} data-description aria-hidden="true" className={menuItemDescription({ className })}>
227
+ {children}
228
+ </span>
174
229
  )
175
230
  }
176
231
 
@@ -196,20 +251,24 @@ export type DropdownMenuSubTriggerProps = Omit<ComponentProps<typeof MenuPrimiti
196
251
  }
197
252
 
198
253
  export function DropdownMenuSubTrigger({ lead, className, children, ...rest }: DropdownMenuSubTriggerProps) {
199
- return (
200
- <MenuPrimitive.SubmenuTrigger {...rest} className={menuItem({ className })}>
201
- {lead && (
202
- <span className="menu-lead grid size-4 shrink-0 place-items-center">
203
- {lead}
204
- </span>
205
- )}
254
+ const { context, describedBy } = useDescription()
206
255
 
207
- <span className="min-w-0 grow">{children}</span>
208
-
209
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="text-fg-subtle size-4 shrink-0">
210
- <path d="m9 18 6-6-6-6" />
211
- </svg>
212
- </MenuPrimitive.SubmenuTrigger>
256
+ return (
257
+ <ItemDescription value={context}>
258
+ <MenuPrimitive.SubmenuTrigger aria-describedby={describedBy} {...rest} className={menuItem({ className })}>
259
+ {lead && (
260
+ <span className="menu-lead grid size-4 shrink-0 place-items-center">
261
+ {lead}
262
+ </span>
263
+ )}
264
+
265
+ <span className="min-w-0 grow">{children}</span>
266
+
267
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="text-fg-subtle size-4 shrink-0">
268
+ <path d="m9 18 6-6-6-6" />
269
+ </svg>
270
+ </MenuPrimitive.SubmenuTrigger>
271
+ </ItemDescription>
213
272
  )
214
273
  }
215
274
 
package/modal/index.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  BottomSheetClose,
10
10
  BottomSheetContent,
11
11
  BottomSheetDescription,
12
+ BottomSheetHeader,
12
13
  BottomSheetTitle,
13
14
  BottomSheetTrigger,
14
15
  } from '../bottom-sheet'
@@ -38,7 +39,11 @@ export const modalQuery: Record<ModalBreakpoint, string> = {
38
39
  md: '(min-width: 48rem)',
39
40
  }
40
41
 
41
- const ModalShape = createContext<Shape>('sheet')
42
+ const ModalShape = createContext<Shape | null>(null)
43
+
44
+ export function useModalShape(): Shape | null {
45
+ return use(ModalShape)
46
+ }
42
47
 
43
48
  const media = (query: string) => (typeof window.matchMedia === 'function' ? window.matchMedia(query) : null)
44
49
 
@@ -109,7 +114,7 @@ export function ModalContent({ size, ...rest }: ModalContentProps) {
109
114
  export type ModalHeaderProps = DialogHeaderProps
110
115
 
111
116
  export function ModalHeader(props: ModalHeaderProps) {
112
- return <DialogHeader {...props} />
117
+ return use(ModalShape) === 'dialog' ? <DialogHeader {...props} /> : <BottomSheetHeader {...props} />
113
118
  }
114
119
 
115
120
  export interface ModalTitleProps {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantoia/ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Pantoia Design System",
5
5
  "homepage": "https://design.pantoia.com",
6
6
  "repository": {
package/popover/index.tsx CHANGED
@@ -48,7 +48,9 @@ export function PopoverClose(props: PopoverCloseProps) {
48
48
  return <PopoverPrimitive.Close {...props} />
49
49
  }
50
50
 
51
- export type PopoverContentProps = Omit<ComponentProps<typeof PopoverPrimitive.Popup>, 'className'> & {
51
+ type PopoverPlacement = Pick<ComponentProps<typeof PopoverPrimitive.Positioner>, 'anchor' | 'sideOffset' | 'alignOffset' | 'collisionAvoidance'>
52
+
53
+ export type PopoverContentProps = Omit<ComponentProps<typeof PopoverPrimitive.Popup>, 'className'> & PopoverPlacement & {
52
54
  side?: 'top' | 'right' | 'bottom' | 'left'
53
55
  align?: 'start' | 'center' | 'end'
54
56
  arrow?: boolean
@@ -73,19 +75,25 @@ export function PopoverContent({
73
75
  side = 'bottom',
74
76
  align = 'center',
75
77
  arrow = true,
78
+ anchor,
79
+ sideOffset,
80
+ alignOffset,
81
+ collisionAvoidance,
76
82
  className,
77
83
  children,
78
84
  ...rest
79
85
  }: PopoverContentProps) {
80
- const [anchor] = useContext(AnchorContext)
86
+ const [anchorPart] = useContext(AnchorContext)
81
87
 
82
88
  return (
83
89
  <PopoverPrimitive.Portal>
84
90
  <PopoverPrimitive.Positioner
85
- anchor={anchor ?? undefined}
91
+ anchor={anchor ?? anchorPart ?? undefined}
86
92
  side={side}
87
93
  align={align}
88
- sideOffset={arrow ? 14 : 8}
94
+ sideOffset={sideOffset ?? (arrow ? 14 : 8)}
95
+ alignOffset={alignOffset}
96
+ collisionAvoidance={collisionAvoidance}
89
97
  arrowPadding={16}
90
98
  className="z-overlay min-w-max will-change-transform"
91
99
  >
package/registry.json CHANGED
@@ -87,6 +87,29 @@
87
87
  "layer": "ui"
88
88
  }
89
89
  },
90
+ {
91
+ "name": "avatar-group",
92
+ "type": "registry:ui",
93
+ "title": "AvatarGroup",
94
+ "files": [
95
+ {
96
+ "path": "avatar-group/index.tsx",
97
+ "type": "registry:ui"
98
+ },
99
+ {
100
+ "path": "avatar-group/index.module.css",
101
+ "type": "registry:ui"
102
+ }
103
+ ],
104
+ "dependencies": [],
105
+ "registryDependencies": [
106
+ "avatar",
107
+ "tv"
108
+ ],
109
+ "meta": {
110
+ "layer": "ui"
111
+ }
112
+ },
90
113
  {
91
114
  "name": "badge",
92
115
  "type": "registry:ui",
@@ -125,6 +148,7 @@
125
148
  "@base-ui/react"
126
149
  ],
127
150
  "registryDependencies": [
151
+ "button",
128
152
  "tv"
129
153
  ],
130
154
  "meta": {
@@ -295,6 +319,7 @@
295
319
  "@base-ui/react"
296
320
  ],
297
321
  "registryDependencies": [
322
+ "button",
298
323
  "tv"
299
324
  ],
300
325
  "meta": {
@@ -320,6 +345,7 @@
320
345
  "@base-ui/react"
321
346
  ],
322
347
  "registryDependencies": [
348
+ "button",
323
349
  "tv"
324
350
  ],
325
351
  "meta": {
@@ -615,6 +641,31 @@
615
641
  "layer": "ui"
616
642
  }
617
643
  },
644
+ {
645
+ "name": "swatch-picker",
646
+ "type": "registry:ui",
647
+ "title": "SwatchPicker",
648
+ "files": [
649
+ {
650
+ "path": "swatch-picker/index.tsx",
651
+ "type": "registry:ui"
652
+ },
653
+ {
654
+ "path": "swatch-picker/index.module.css",
655
+ "type": "registry:ui"
656
+ }
657
+ ],
658
+ "dependencies": [
659
+ "@base-ui/react"
660
+ ],
661
+ "registryDependencies": [
662
+ "tooltip",
663
+ "tv"
664
+ ],
665
+ "meta": {
666
+ "layer": "ui"
667
+ }
668
+ },
618
669
  {
619
670
  "name": "switch",
620
671
  "type": "registry:ui",
@@ -746,6 +797,30 @@
746
797
  ]
747
798
  }
748
799
  },
800
+ {
801
+ "name": "toggle-group",
802
+ "type": "registry:ui",
803
+ "title": "ToggleGroup",
804
+ "files": [
805
+ {
806
+ "path": "toggle-group/index.tsx",
807
+ "type": "registry:ui"
808
+ }
809
+ ],
810
+ "dependencies": [
811
+ "@base-ui/react"
812
+ ],
813
+ "registryDependencies": [
814
+ "toggle",
815
+ "tv"
816
+ ],
817
+ "meta": {
818
+ "layer": "ui",
819
+ "sizes": [
820
+ "ToggleGroupItem"
821
+ ]
822
+ }
823
+ },
749
824
  {
750
825
  "name": "tooltip",
751
826
  "type": "registry:ui",
package/select/index.tsx CHANGED
@@ -95,6 +95,7 @@ export type SelectProps = Omit<ComponentProps<typeof SelectPrimitive.Trigger>, '
95
95
  arrow?: boolean
96
96
  className?: string
97
97
  }
98
+ & Pick<ComponentProps<typeof SelectPrimitive.Positioner>, 'sideOffset' | 'alignOffset' | 'collisionAvoidance'>
98
99
 
99
100
  export function Select({
100
101
  value,
@@ -113,6 +114,9 @@ export function Select({
113
114
  surface,
114
115
  size,
115
116
  arrow = false,
117
+ sideOffset,
118
+ alignOffset,
119
+ collisionAvoidance,
116
120
  className,
117
121
  children,
118
122
  ...rest
@@ -166,7 +170,9 @@ export function Select({
166
170
  <SelectPrimitive.Positioner
167
171
  alignItemWithTrigger={false}
168
172
  align="start"
169
- sideOffset={arrow ? 14 : 8}
173
+ sideOffset={sideOffset ?? (arrow ? 14 : 8)}
174
+ alignOffset={alignOffset}
175
+ collisionAvoidance={collisionAvoidance}
170
176
  arrowPadding={16}
171
177
  className="z-overlay min-w-max will-change-transform"
172
178
  >
@@ -148,6 +148,7 @@ className 그 한 곳만 바뀐다
148
148
  | Accordion | accordion | Accordion, AccordionItem, AccordionTrigger, AccordionContent | - |
149
149
  | AlertDialog | alert-dialog | AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle, AlertDialogDescription, AlertDialogHeader, AlertDialogBody, AlertDialogFooter, AlertDialogAction, AlertDialogCancel | size |
150
150
  | Avatar | avatar | Avatar | color, size |
151
+ | AvatarGroup | avatar-group | AvatarGroup | size |
151
152
  | Badge | badge | Badge | color, emphasis, shape |
152
153
  | BottomSheet | bottom-sheet | BottomSheet, BottomSheetTrigger, BottomSheetClose, BottomSheetContent, BottomSheetHeader, BottomSheetTitle, BottomSheetDescription, BottomSheetBody, BottomSheetFooter | - |
153
154
  | Button | button | Button | color, emphasis, size, shape |
@@ -156,10 +157,10 @@ className 그 한 곳만 바뀐다
156
157
  | Collapsible | collapsible | Collapsible, CollapsibleTrigger, CollapsibleContent | - |
157
158
  | Combobox | combobox | Combobox, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxCancel, ComboboxContent, ComboboxViewport, ComboboxCollection, ComboboxItem, ComboboxItemText, ComboboxItemDescription, ComboboxGroup, ComboboxEmpty, ComboboxSeparator | surface, size |
158
159
  | CommandPalette | command-palette | CommandPalette | - |
159
- | ContextMenu | context-menu | ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent | color |
160
+ | ContextMenu | context-menu | ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuItemDescription, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent | color |
160
161
  | Dialog | dialog | Dialog, DialogTrigger, DialogClose, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogBody, DialogFooter | size |
161
162
  | Drawer | drawer | Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerBody, DrawerFooter | side |
162
- | DropdownMenu | dropdown-menu | DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent | color |
163
+ | DropdownMenu | dropdown-menu | DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemDescription, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent | color |
163
164
  | Field | field | Field, FieldLabel, FieldHelp, FieldError, FieldPending, FieldSuccess | layout |
164
165
  | Fieldset | fieldset | Fieldset, FieldsetLegend | - |
165
166
  | Input | input | Input | surface, size |
@@ -173,12 +174,14 @@ className 그 한 곳만 바뀐다
173
174
  | SegmentedControl | segmented-control | SegmentedControl, SegmentedControlItem | size, fill |
174
175
  | Select | select | Select, SelectGroup, SelectItem, SelectItemText, SelectItemDescription, SelectSeparator | surface, size |
175
176
  | Separator | separator | Separator | orientation |
177
+ | SwatchPicker | swatch-picker | SwatchPicker, SwatchPickerItem, SwatchPickerCustom | size |
176
178
  | Switch | switch | Switch | color |
177
179
  | Table | table | Table, TableHeader, TableHeadCell, TableBody, TableRow, TableCell | fixed, mono |
178
180
  | Tabs | tabs | Tabs, TabsList, TabsTrigger, TabsContent | - |
179
181
  | Textarea | textarea | Textarea | surface, size |
180
182
  | Toast | toast | Toast | - |
181
- | Toggle | toggle | Toggle | surface |
183
+ | Toggle | toggle | Toggle | size, shape, color, surface |
184
+ | ToggleGroup | toggle-group | ToggleGroup, ToggleGroupItem | size, shape, surface |
182
185
  | Tooltip | tooltip | TooltipProvider, Tooltip, TooltipTrigger, TooltipContent | - |
183
186
 
184
187
  컴포넌트를 고를 때 헷갈리는 짝은 이렇다.
@@ -196,6 +199,7 @@ Badge와 Button 누를 수 없으면 Badge, 누르면 Button
196
199
  Tabs와 Accordion 한 번에 하나만 보이면 Tabs, 여러 개를 펴 두면 Accordion
197
200
  SegmentedControl과 Tabs 같은 내용의 보기 방식을 바꾸면 SegmentedControl, 다른 내용으로 바꾸면 Tabs
198
201
  SegmentedControl과 Toggle 여럿 중 하나를 고르면 SegmentedControl, 하나를 켜고 끄면 Toggle
202
+ SegmentedControl과 ToggleGroup 이어진 트랙 안에서 고르면 SegmentedControl, 떨어진 pill 여러 개 중에 고르면 ToggleGroup
199
203
  Collapsible과 Accordion 한 영역을 접으면 Collapsible, 여러 영역을 함께 다루면 Accordion
200
204
  Tooltip과 Popover 짧은 설명이면 Tooltip, 안에서 조작하면 Popover
201
205
  ```
@@ -0,0 +1,8 @@
1
+ .swatch {
2
+ background-color: var(--swatch-picker-color);
3
+ }
4
+
5
+ .check {
6
+ color: white;
7
+ color: oklch(from var(--swatch-picker-color) clamp(0, (0.7 - l) * 1000, 1) 0 0);
8
+ }
@@ -0,0 +1,176 @@
1
+ 'use client'
2
+
3
+ import { Radio } from '@base-ui/react/radio'
4
+ import { RadioGroup } from '@base-ui/react/radio-group'
5
+ import { Children, createContext, isValidElement, use, useRef, useState, type CSSProperties, type ReactNode } from 'react'
6
+
7
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'
8
+ import { tv } from '../tv'
9
+
10
+ import s from './index.module.css'
11
+
12
+ type Size = 'sm' | 'md' | 'lg'
13
+
14
+ interface PickerContext {
15
+ size: Size
16
+ value: string | null
17
+ choose: (value: string) => void
18
+ known: ReadonlySet<string>
19
+ }
20
+
21
+ const Context = createContext<PickerContext>({
22
+ size: 'md',
23
+ value: null,
24
+ choose: () => {},
25
+ known: new Set(),
26
+ })
27
+
28
+ export const swatchPicker = tv({
29
+ slots: {
30
+ root: 'flex flex-wrap gap-2',
31
+ item: 'inline-flex shrink-0 items-center justify-center rounded-full cursor-pointer'
32
+ + ' inset-ring inset-ring-line transition-state press press-shrink'
33
+ + ' checked:ring-2 checked:ring-line-neutral-solid checked:ring-offset-2 checked:ring-offset-bg'
34
+ + ' focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-line-focus-ring'
35
+ + ' disabled:cursor-not-allowed disabled:opacity-40',
36
+ custom: 'text-fg-muted inline-flex shrink-0 items-center justify-center rounded-full cursor-pointer'
37
+ + ' ring-1 ring-inset ring-line-strong transition-state press press-shrink'
38
+ + ' enabled:hover:bg-bg-neutral-hover'
39
+ + ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus-ring'
40
+ + ' disabled:text-fg-disabled disabled:ring-line disabled:cursor-not-allowed',
41
+ icon: 'size-4 shrink-0',
42
+ },
43
+
44
+ variants: {
45
+ size: {
46
+ sm: { item: 'size-6', custom: 'size-6', icon: 'size-3.5' },
47
+ md: { item: 'size-8', custom: 'size-8' },
48
+ lg: { item: 'size-10', custom: 'size-10', icon: 'size-5' },
49
+ },
50
+ },
51
+
52
+ defaultVariants: { size: 'md' },
53
+ })
54
+
55
+ export type SwatchPickerProps = Omit<RadioGroup.Props<string | null>, 'className' | 'onValueChange'> & {
56
+ onValueChange?: (value: string) => void
57
+ size?: Size
58
+ className?: string
59
+ children?: ReactNode
60
+ }
61
+
62
+ export function SwatchPicker({ value, defaultValue = null, onValueChange, size = 'md', className, children, ...rest }: SwatchPickerProps) {
63
+ const [inner, setInner] = useState<string | null>(defaultValue)
64
+ const known = new Set(
65
+ Children.toArray(children)
66
+ .filter(child => isValidElement<SwatchPickerItemProps>(child) && child.type === SwatchPickerItem)
67
+ .map(child => (child as { props: SwatchPickerItemProps }).props.value),
68
+ )
69
+ const current = value === undefined ? inner : value
70
+
71
+ const choose = (next: string) => {
72
+ onValueChange?.(next)
73
+ if (value === undefined) setInner(next)
74
+ }
75
+
76
+ return (
77
+ <Context value={{ size, value: current, choose, known }}>
78
+ <RadioGroup<string | null>
79
+ {...rest}
80
+ value={current}
81
+ onValueChange={next => next !== null && choose(next)}
82
+ className={swatchPicker({ size }).root({ className })}
83
+ >
84
+ {children}
85
+ </RadioGroup>
86
+ </Context>
87
+ )
88
+ }
89
+
90
+ function Swatch({ value, label, disabled }: { value: string, label: string, disabled?: boolean }) {
91
+ const { size } = use(Context)
92
+ const ui = swatchPicker({ size })
93
+
94
+ return (
95
+ <Tooltip>
96
+ <TooltipTrigger
97
+ render={(
98
+ <Radio.Root
99
+ value={value}
100
+ disabled={disabled}
101
+ nativeButton
102
+ render={<button type="button" />}
103
+ aria-label={label}
104
+ style={{ '--swatch-picker-color': value } as CSSProperties}
105
+ className={ui.item({ className: s.swatch })}
106
+ />
107
+ )}
108
+ >
109
+ <Radio.Indicator className={`flex ${s.check}`}>
110
+ <svg className={ui.icon()} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
111
+ <path d="M20 6 9 17l-5-5" />
112
+ </svg>
113
+ </Radio.Indicator>
114
+ </TooltipTrigger>
115
+ <TooltipContent>{label}</TooltipContent>
116
+ </Tooltip>
117
+ )
118
+ }
119
+
120
+ export interface SwatchPickerItemProps {
121
+ value: string
122
+ label: string
123
+ disabled?: boolean
124
+ }
125
+
126
+ export function SwatchPickerItem(props: SwatchPickerItemProps) {
127
+ return <Swatch {...props} />
128
+ }
129
+
130
+ const HEX = /^#[0-9a-f]{6}$/i
131
+
132
+ export interface SwatchPickerCustomProps {
133
+ label?: string
134
+ disabled?: boolean
135
+ }
136
+
137
+ export function SwatchPickerCustom({ label = '직접 고르기', disabled }: SwatchPickerCustomProps) {
138
+ const { size, value, choose, known } = use(Context)
139
+ const ui = swatchPicker({ size })
140
+ const input = useRef<HTMLInputElement>(null)
141
+ const extra = value !== null && !known.has(value) ? value : null
142
+
143
+ const open = () => {
144
+ const el = input.current
145
+ if (!el) return
146
+ if (typeof el.showPicker === 'function') el.showPicker()
147
+ else el.click()
148
+ }
149
+
150
+ return (
151
+ <>
152
+ {extra && <Swatch value={extra} label={extra} disabled={disabled} />}
153
+
154
+ <Tooltip>
155
+ <TooltipTrigger
156
+ render={<button type="button" disabled={disabled} aria-label={label} onClick={open} className={ui.custom()} />}
157
+ >
158
+ <svg className={ui.icon()} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
159
+ <path d="M12 5v14M5 12h14" />
160
+ </svg>
161
+ </TooltipTrigger>
162
+ <TooltipContent>{label}</TooltipContent>
163
+ </Tooltip>
164
+
165
+ <input
166
+ ref={input}
167
+ type="color"
168
+ tabIndex={-1}
169
+ aria-hidden="true"
170
+ value={value !== null && HEX.test(value) ? value : '#000000'}
171
+ onChange={event => choose(event.target.value)}
172
+ className="sr-only"
173
+ />
174
+ </>
175
+ )
176
+ }
package/toggle/index.tsx CHANGED
@@ -5,19 +5,45 @@ import type { ComponentProps } from 'react'
5
5
 
6
6
  import { tv } from '../tv'
7
7
 
8
+ export type ToggleSize = 'sm' | 'md' | 'lg'
9
+ export type ToggleShape = 'pill' | 'circle' | 'square'
10
+ export type ToggleColor = 'neutral' | 'brand' | 'info' | 'success' | 'warning' | 'danger'
11
+ export type ToggleSurface = 'bare' | 'well' | 'panel'
12
+
8
13
  export const toggle = tv({
9
- base: 'text-label rounded-full inline-flex h-8 items-center gap-tight px-3'
10
- + ' [&_svg]:size-4'
14
+ base: 'text-label inline-flex shrink-0 items-center justify-center gap-tight whitespace-nowrap'
11
15
  + ' ring-1 ring-inset ring-line-strong transition-state press press-shrink'
12
16
  + ' text-fg-muted'
13
17
  + ' enabled:hover:bg-bg-neutral-hover'
14
- + ' on:bg-bg-neutral-solid on:ring-line-neutral-solid on:enabled:text-fg-neutral-solid-contrast'
15
- + ' on:enabled:hover:bg-bg-neutral-solid-hover'
16
18
  + ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus-ring'
17
- + ' disabled:text-fg-disabled disabled:ring-line disabled:cursor-not-allowed'
18
- + ' on:disabled:bg-bg-neutral-solid-checked-disabled on:disabled:ring-transparent on:disabled:text-fg-neutral-solid-contrast',
19
+ + ' disabled:text-fg-disabled disabled:ring-line disabled:cursor-not-allowed',
19
20
 
20
21
  variants: {
22
+ size: {
23
+ sm: 'h-8 [&_svg]:size-4',
24
+ md: 'h-9 [&_svg]:size-4',
25
+ lg: 'h-10 text-t16 font-medium [&_svg]:size-5',
26
+ },
27
+ shape: {
28
+ pill: 'rounded-full',
29
+ circle: 'rounded-full',
30
+ square: 'rounded-r8',
31
+ },
32
+ color: {
33
+ neutral: 'on:bg-bg-neutral-solid on:ring-line-neutral-solid on:enabled:text-fg-neutral-solid-contrast'
34
+ + ' on:enabled:hover:bg-bg-neutral-solid-hover'
35
+ + ' on:disabled:bg-bg-neutral-solid-checked-disabled on:disabled:ring-transparent on:disabled:text-fg-neutral-solid-contrast',
36
+ brand: 'on:bg-bg-brand-weak on:ring-line-brand-weak on:enabled:text-fg-brand-contrast'
37
+ + ' on:enabled:hover:bg-bg-brand-solid/10 on:disabled:text-fg-brand-contrast/40',
38
+ info: 'on:bg-bg-info-weak on:ring-line-info-weak on:enabled:text-fg-info-contrast'
39
+ + ' on:enabled:hover:bg-bg-info-solid/10 on:disabled:text-fg-info-contrast/40',
40
+ success: 'on:bg-bg-success-weak on:ring-line-success-weak on:enabled:text-fg-success-contrast'
41
+ + ' on:enabled:hover:bg-bg-success-solid/10 on:disabled:text-fg-success-contrast/40',
42
+ warning: 'on:bg-bg-warning-weak on:ring-line-warning-weak on:enabled:text-fg-warning-contrast'
43
+ + ' on:enabled:hover:bg-bg-warning-solid/10 on:disabled:text-fg-warning-contrast/40',
44
+ danger: 'on:bg-bg-danger-weak on:ring-line-danger-weak on:enabled:text-fg-danger-contrast'
45
+ + ' on:enabled:hover:bg-bg-danger-solid/10 on:disabled:text-fg-danger-contrast/40',
46
+ },
21
47
  surface: {
22
48
  bare: '',
23
49
  well: 'bg-bg-well',
@@ -25,14 +51,26 @@ export const toggle = tv({
25
51
  },
26
52
  },
27
53
 
28
- defaultVariants: { surface: 'bare' },
54
+ compoundVariants: [
55
+ { shape: 'pill', size: 'sm', class: 'px-3' },
56
+ { shape: 'pill', size: 'md', class: 'px-3.5' },
57
+ { shape: 'pill', size: 'lg', class: 'px-4' },
58
+ { shape: ['circle', 'square'], size: 'sm', class: 'w-8' },
59
+ { shape: ['circle', 'square'], size: 'md', class: 'w-9' },
60
+ { shape: ['circle', 'square'], size: 'lg', class: 'w-10' },
61
+ ],
62
+
63
+ defaultVariants: { size: 'sm', shape: 'pill', color: 'neutral', surface: 'bare' },
29
64
  })
30
65
 
31
- export type ToggleProps = Omit<ComponentProps<typeof TogglePrimitive>, 'className'> & {
32
- surface?: 'bare' | 'well' | 'panel'
66
+ export type ToggleProps = Omit<ComponentProps<typeof TogglePrimitive>, 'className' | 'color'> & {
67
+ size?: ToggleSize
68
+ shape?: ToggleShape
69
+ color?: ToggleColor
70
+ surface?: ToggleSurface
33
71
  className?: string
34
72
  }
35
73
 
36
- export function Toggle({ surface, className, ...rest }: ToggleProps) {
37
- return <TogglePrimitive {...rest} className={toggle({ surface, className })} />
74
+ export function Toggle({ size, shape, color, surface, className, ...rest }: ToggleProps) {
75
+ return <TogglePrimitive {...rest} className={toggle({ size, shape, color, surface, className })} />
38
76
  }
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ import { Radio } from '@base-ui/react/radio'
4
+ import { RadioGroup } from '@base-ui/react/radio-group'
5
+ import { Toggle as TogglePrimitive } from '@base-ui/react/toggle'
6
+ import { ToggleGroup as ToggleGroupPrimitive } from '@base-ui/react/toggle-group'
7
+ import { createContext, use, type ReactNode } from 'react'
8
+
9
+ import { toggle, type ToggleColor, type ToggleShape, type ToggleSize, type ToggleSurface } from '../toggle'
10
+ import { tv } from '../tv'
11
+
12
+ interface Shared {
13
+ size: ToggleSize
14
+ shape: ToggleShape
15
+ surface: ToggleSurface
16
+ multiple: boolean
17
+ }
18
+
19
+ const Context = createContext<Shared>({ size: 'sm', shape: 'pill', surface: 'bare', multiple: false })
20
+
21
+ const toggleGroup = tv({ base: 'flex flex-wrap gap-2' })
22
+
23
+ interface ToggleGroupLook {
24
+ size?: ToggleSize
25
+ shape?: ToggleShape
26
+ surface?: ToggleSurface
27
+ className?: string
28
+ }
29
+
30
+ export type ToggleGroupSingleProps = Omit<RadioGroup.Props<string | null>, 'className'> & ToggleGroupLook & {
31
+ multiple?: false
32
+ }
33
+
34
+ export type ToggleGroupMultipleProps = Omit<ToggleGroupPrimitive.Props<string>, 'className' | 'multiple'> & ToggleGroupLook & {
35
+ multiple: true
36
+ }
37
+
38
+ export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps
39
+
40
+ export function ToggleGroup({ size = 'sm', shape = 'pill', surface = 'bare', className, ...rest }: ToggleGroupProps) {
41
+ const root = toggleGroup({ className })
42
+
43
+ if (rest.multiple) {
44
+ return (
45
+ <Context value={{ size, shape, surface, multiple: true }}>
46
+ <ToggleGroupPrimitive<string> {...rest} className={root} />
47
+ </Context>
48
+ )
49
+ }
50
+
51
+ const { multiple: _multiple, ...single } = rest
52
+
53
+ return (
54
+ <Context value={{ size, shape, surface, multiple: false }}>
55
+ <RadioGroup<string | null> {...single} className={root} />
56
+ </Context>
57
+ )
58
+ }
59
+
60
+ export interface ToggleGroupItemProps {
61
+ value: string
62
+ color?: ToggleColor
63
+ disabled?: boolean
64
+ 'aria-label'?: string
65
+ className?: string
66
+ children?: ReactNode
67
+ }
68
+
69
+ export function ToggleGroupItem({ color, className, ...rest }: ToggleGroupItemProps) {
70
+ const { size, shape, surface, multiple } = use(Context)
71
+ const look = toggle({ size, shape, color, surface, className })
72
+
73
+ if (multiple) return <TogglePrimitive {...rest} className={look} />
74
+
75
+ return <Radio.Root {...rest} nativeButton render={<button type="button" />} className={look} />
76
+ }
package/tooltip/index.tsx CHANGED
@@ -46,7 +46,9 @@ export function TooltipTrigger(props: TooltipTriggerProps) {
46
46
  return <TooltipPrimitive.Trigger delay={inProvider ? undefined : 100} {...props} aria-describedby={open ? id : undefined} />
47
47
  }
48
48
 
49
- export type TooltipContentProps = Omit<ComponentProps<typeof TooltipPrimitive.Popup>, 'className'> & {
49
+ type TooltipPlacement = Pick<ComponentProps<typeof TooltipPrimitive.Positioner>, 'sideOffset' | 'alignOffset' | 'collisionAvoidance'>
50
+
51
+ export type TooltipContentProps = Omit<ComponentProps<typeof TooltipPrimitive.Popup>, 'className'> & TooltipPlacement & {
50
52
  side?: 'top' | 'right' | 'bottom' | 'left'
51
53
  arrow?: boolean
52
54
  className?: string
@@ -69,6 +71,9 @@ const tooltipArrow = tv({
69
71
  export function TooltipContent({
70
72
  side = 'top',
71
73
  arrow = true,
74
+ sideOffset,
75
+ alignOffset,
76
+ collisionAvoidance,
72
77
  className,
73
78
  children,
74
79
  ...rest
@@ -79,7 +84,9 @@ export function TooltipContent({
79
84
  <TooltipPrimitive.Portal>
80
85
  <TooltipPrimitive.Positioner
81
86
  side={side}
82
- sideOffset={arrow ? 11 : 6}
87
+ sideOffset={sideOffset ?? (arrow ? 11 : 6)}
88
+ alignOffset={alignOffset}
89
+ collisionAvoidance={collisionAvoidance}
83
90
  className="z-overlay min-w-max will-change-transform"
84
91
  >
85
92
  <TooltipPrimitive.Popup {...rest} id={id} role="tooltip" className={tooltipContent({ className })}>
package/utilities.css CHANGED
@@ -13,7 +13,7 @@
13
13
  @custom-variant checked (&:where([data-checked], [data-indeterminate]));
14
14
  @custom-variant open (&[data-open]);
15
15
  @custom-variant closed (&[data-closed]);
16
- @custom-variant on (&[aria-pressed="true"]);
16
+ @custom-variant on (&:is([aria-pressed="true"], [aria-checked="true"]));
17
17
  @custom-variant side-top (&[data-side="top"]);
18
18
  @custom-variant side-right (&[data-side="right"]);
19
19
  @custom-variant side-bottom (&[data-side="bottom"]);