@pantoia/ui 0.11.1 → 0.12.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.
@@ -3,8 +3,9 @@
3
3
 
4
4
  .panel { overflow: hidden; }
5
5
  .panel[data-open] { animation: acc-open var(--dur-enter) var(--ease-enter); }
6
- .panel[data-closed] { animation: acc-close var(--dur-exit) var(--ease-exit); }
6
+ .panel[data-closed] { animation: acc-close var(--dur-exit) var(--ease-exit) forwards; }
7
7
 
8
8
  @media (prefers-reduced-motion: reduce) {
9
- .panel { animation: none; }
9
+ .panel[data-open],
10
+ .panel[data-closed] { animation: none; }
10
11
  }
@@ -19,25 +19,36 @@ export function AlertDialogTrigger(props: AlertDialogTriggerProps) {
19
19
  }
20
20
 
21
21
  const alertDialogContent = tv({
22
- base: 'sheet text-fg z-overlay fixed left-1/2 top-1/2'
23
- + ' w-full max-w-112 -translate-x-1/2 -translate-y-1/2'
22
+ base: 'float text-fg z-overlay fixed inset-x-4 top-1/2 mx-auto max-h-11/12 -translate-y-1/2'
24
23
  + ' rounded-r20 p-6 flex flex-col gap-stack overflow-y-auto'
25
24
  + ' open:animate-fade-in closed:animate-fade-out'
25
+ + ' transition-state data-nested-dialog-open:scale-95 motion-reduce:data-nested-dialog-open:scale-100'
26
26
  + ' focus-visible:outline-none',
27
+
28
+ variants: {
29
+ size: {
30
+ sm: 'max-w-112',
31
+ md: 'max-w-120',
32
+ lg: 'max-w-160',
33
+ },
34
+ },
35
+
36
+ defaultVariants: { size: 'sm' },
27
37
  })
28
38
 
29
39
  export type AlertDialogContentProps = Omit<ComponentProps<typeof AlertDialogPrimitive.Popup>, 'className'> & {
40
+ size?: 'sm' | 'md' | 'lg'
30
41
  className?: string
31
42
  }
32
43
 
33
- export function AlertDialogContent({ className, children, ...rest }: AlertDialogContentProps) {
44
+ export function AlertDialogContent({ size, className, children, ...rest }: AlertDialogContentProps) {
34
45
  return (
35
46
  <AlertDialogPrimitive.Portal>
36
- <AlertDialogPrimitive.Backdrop className="bg-bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
47
+ <AlertDialogPrimitive.Backdrop forceRender className="bg-bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
37
48
 
38
49
  <AlertDialogPrimitive.Popup
39
50
  {...rest}
40
- className={alertDialogContent({ className })}
51
+ className={alertDialogContent({ size, className })}
41
52
  >
42
53
  {children}
43
54
  </AlertDialogPrimitive.Popup>
@@ -98,11 +109,47 @@ export function AlertDialogHeader({ lead, trail, className, children }: AlertDia
98
109
  )
99
110
  }
100
111
 
101
- const alertDialogFooter = tv({ base: 'mt-auto flex justify-end gap-2' })
112
+ const alertDialogBody = tv({ base: '-m-1 min-h-0 overflow-y-auto p-1' })
113
+
114
+ export function AlertDialogBody({ className, children }: { className?: string, children?: ReactNode }) {
115
+ return (
116
+ <div className={alertDialogBody({ className })}>
117
+ {children}
118
+ </div>
119
+ )
120
+ }
121
+
122
+ type FooterLayout = 'end' | 'between' | 'fill' | 'grow-last'
123
+
124
+ const alertDialogFooter = tv({
125
+ base: 'mt-auto flex justify-end gap-2',
126
+ variants: {
127
+ narrow: {
128
+ 'end': '',
129
+ 'between': 'max-sm:*:first:mr-auto',
130
+ 'fill': 'max-sm:*:flex-1',
131
+ 'grow-last': 'max-sm:*:last:grow',
132
+ },
133
+ wide: {
134
+ 'end': '',
135
+ 'between': 'sm:*:first:mr-auto',
136
+ 'fill': 'sm:*:flex-1',
137
+ 'grow-last': 'sm:*:last:grow',
138
+ },
139
+ },
140
+ })
141
+
142
+ export interface AlertDialogFooterProps {
143
+ layout?: FooterLayout | { narrow: FooterLayout, wide: FooterLayout }
144
+ className?: string
145
+ children?: ReactNode
146
+ }
147
+
148
+ export function AlertDialogFooter({ layout = 'end', className, children }: AlertDialogFooterProps) {
149
+ const { narrow, wide } = typeof layout === 'string' ? { narrow: layout, wide: layout } : layout
102
150
 
103
- export function AlertDialogFooter({ className, children }: { className?: string, children?: ReactNode }) {
104
151
  return (
105
- <div className={alertDialogFooter({ className })}>
152
+ <div className={alertDialogFooter({ narrow, wide, className })}>
106
153
  {children}
107
154
  </div>
108
155
  )
@@ -4,13 +4,16 @@
4
4
  .panel {
5
5
  --bottom-sheet-slide: 100%;
6
6
  transform: translateY(var(--drawer-swipe-movement-y, 0px));
7
- transition: transform var(--dur-enter) var(--ease-enter);
7
+ transform-origin: center bottom;
8
+ transition: transform var(--dur-enter) var(--ease-enter), scale var(--dur-enter) var(--ease-enter);
8
9
  }
9
10
 
10
11
  .panel[data-open] { animation: slide-in var(--dur-enter) var(--ease-enter); }
11
12
  .panel[data-closed] { animation: slide-out var(--dur-exit) var(--ease-exit) forwards; }
12
13
  .panel[data-swiping] { transition-duration: 0ms; }
14
+ .panel[data-nested-drawer-open] { scale: 0.95; }
13
15
 
14
16
  @media (prefers-reduced-motion: reduce) {
15
17
  .panel { --bottom-sheet-slide: 0%; }
18
+ .panel[data-nested-drawer-open] { scale: 1; }
16
19
  }
@@ -26,7 +26,7 @@ export function BottomSheetClose(props: BottomSheetCloseProps) {
26
26
  }
27
27
 
28
28
  export const bottomSheetContent = tv({
29
- base: 'sheet text-fg z-overlay fixed inset-x-0 bottom-0 mx-auto rounded-t-r20'
29
+ base: 'float text-fg z-overlay fixed inset-x-0 bottom-0 mx-auto rounded-t-r20'
30
30
  + ' flex max-h-11/12 w-full flex-col gap-stack overflow-y-auto px-4 pb-4 sm:max-w-120'
31
31
  + ' focus-visible:outline-none',
32
32
  })
@@ -38,7 +38,7 @@ export type BottomSheetContentProps = Omit<ComponentProps<typeof DrawerPrimitive
38
38
  export function BottomSheetContent({ className, children, ...rest }: BottomSheetContentProps) {
39
39
  return (
40
40
  <DrawerPrimitive.Portal>
41
- <DrawerPrimitive.Backdrop className="bg-bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
41
+ <DrawerPrimitive.Backdrop forceRender className="bg-bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
42
42
 
43
43
  <DrawerPrimitive.Viewport>
44
44
  <DrawerPrimitive.Popup {...rest} className={`${s.panel} ${bottomSheetContent({ className })}`}>
@@ -99,3 +99,49 @@ export function BottomSheetDescription({ className, ...rest }: BottomSheetDescri
99
99
  <DrawerPrimitive.Description {...rest} className={bottomSheetDescription({ className })} />
100
100
  )
101
101
  }
102
+
103
+ const bottomSheetBody = tv({ base: '-m-1 min-h-0 overflow-y-auto p-1' })
104
+
105
+ export function BottomSheetBody({ className, children }: { className?: string, children?: ReactNode }) {
106
+ return (
107
+ <div className={bottomSheetBody({ className })}>
108
+ {children}
109
+ </div>
110
+ )
111
+ }
112
+
113
+ type FooterLayout = 'end' | 'between' | 'fill' | 'grow-last'
114
+
115
+ const bottomSheetFooter = tv({
116
+ base: 'mt-auto flex justify-end gap-2',
117
+ variants: {
118
+ narrow: {
119
+ 'end': '',
120
+ 'between': 'max-sm:*:first:mr-auto',
121
+ 'fill': 'max-sm:*:flex-1',
122
+ 'grow-last': 'max-sm:*:last:grow',
123
+ },
124
+ wide: {
125
+ 'end': '',
126
+ 'between': 'sm:*:first:mr-auto',
127
+ 'fill': 'sm:*:flex-1',
128
+ 'grow-last': 'sm:*:last:grow',
129
+ },
130
+ },
131
+ })
132
+
133
+ export interface BottomSheetFooterProps {
134
+ layout?: FooterLayout | { narrow: FooterLayout, wide: FooterLayout }
135
+ className?: string
136
+ children?: ReactNode
137
+ }
138
+
139
+ export function BottomSheetFooter({ layout = 'end', className, children }: BottomSheetFooterProps) {
140
+ const { narrow, wide } = typeof layout === 'string' ? { narrow: layout, wide: layout } : layout
141
+
142
+ return (
143
+ <div className={bottomSheetFooter({ narrow, wide, className })}>
144
+ {children}
145
+ </div>
146
+ )
147
+ }
@@ -0,0 +1,11 @@
1
+ @keyframes col-open { from { height: 0; } to { height: var(--collapsible-panel-height); } }
2
+ @keyframes col-close { from { height: var(--collapsible-panel-height); } to { height: 0; } }
3
+
4
+ .panel { overflow: hidden; }
5
+ .panel[data-open] { animation: col-open var(--dur-enter) var(--ease-enter); }
6
+ .panel[data-closed] { animation: col-close var(--dur-exit) var(--ease-exit) forwards; }
7
+
8
+ @media (prefers-reduced-motion: reduce) {
9
+ .panel[data-open],
10
+ .panel[data-closed] { animation: none; }
11
+ }
@@ -0,0 +1,30 @@
1
+ 'use client'
2
+
3
+ import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'
4
+ import type { ComponentProps } from 'react'
5
+
6
+ import s from './index.module.css'
7
+
8
+ export type CollapsibleProps = ComponentProps<typeof CollapsiblePrimitive.Root>
9
+
10
+ export function Collapsible(props: CollapsibleProps) {
11
+ return <CollapsiblePrimitive.Root {...props} />
12
+ }
13
+
14
+ export type CollapsibleTriggerProps = ComponentProps<typeof CollapsiblePrimitive.Trigger>
15
+
16
+ export function CollapsibleTrigger(props: CollapsibleTriggerProps) {
17
+ return <CollapsiblePrimitive.Trigger {...props} />
18
+ }
19
+
20
+ export type CollapsibleContentProps = Omit<ComponentProps<typeof CollapsiblePrimitive.Panel>, 'className'> & {
21
+ className?: string
22
+ }
23
+
24
+ export function CollapsibleContent({ className, children, ...rest }: CollapsibleContentProps) {
25
+ return (
26
+ <CollapsiblePrimitive.Panel {...rest} className={s.panel}>
27
+ <div className={className}>{children}</div>
28
+ </CollapsiblePrimitive.Panel>
29
+ )
30
+ }
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { Combobox as ComboboxPrimitive, type ComboboxFilter, type ComboboxFilterOptions } from '@base-ui/react/combobox'
4
- import { isValidElement, type ComponentProps, type ReactNode } from 'react'
4
+ import { createContext, isValidElement, use, type ComponentProps, type ReactNode } from 'react'
5
5
 
6
6
  import { tv } from '../tv'
7
7
 
@@ -23,7 +23,7 @@ export function Combobox<Value = string, Item = Value>({
23
23
  }
24
24
 
25
25
  export const comboboxAnchor = tv({
26
- base: 'flex items-center gap-tight rounded-r8 px-3'
26
+ base: 'group/combobox flex items-center gap-tight rounded-r8 px-3'
27
27
  + ' transition-state'
28
28
  + ' invalid-within:ring-2 invalid-within:ring-line-danger'
29
29
  + ' has-disabled:bg-bg-neutral-disabled has-disabled:cursor-not-allowed'
@@ -34,6 +34,8 @@ export const comboboxAnchor = tv({
34
34
  surface: {
35
35
  bare: 'ring-1 ring-inset ring-line-strong',
36
36
  well: 'bg-bg-well',
37
+ ghost: 'bg-transparent not-has-disabled:not-data-disabled:not-data-readonly:not-focus-within:hover:bg-bg-neutral-hover'
38
+ + ' focus-within:bg-bg-neutral-selected data-readonly:focus-within:bg-transparent has-disabled:bg-transparent',
37
39
  },
38
40
  size: { sm: 'h-8', md: 'h-9', lg: 'h-10 [&_input]:text-t16' },
39
41
  },
@@ -42,15 +44,34 @@ export const comboboxAnchor = tv({
42
44
  })
43
45
 
44
46
  export type ComboboxAnchorProps = Omit<ComponentProps<typeof ComboboxPrimitive.InputGroup>, 'className'> & {
45
- surface?: 'bare' | 'well'
47
+ surface?: 'bare' | 'well' | 'ghost'
46
48
  size?: 'sm' | 'md' | 'lg'
47
49
  className?: string
48
50
  }
49
51
 
50
- export function ComboboxAnchor({ surface, size, className, ...rest }: ComboboxAnchorProps) {
51
- return <ComboboxPrimitive.InputGroup {...rest} className={comboboxAnchor({ surface, size, className })} />
52
+ const AnchorSurface = createContext<'bare' | 'well' | 'ghost'>('bare')
53
+
54
+ export function ComboboxAnchor({ surface = 'bare', size, className, ...rest }: ComboboxAnchorProps) {
55
+ return (
56
+ <AnchorSurface value={surface}>
57
+ <ComboboxPrimitive.InputGroup {...rest} className={comboboxAnchor({ surface, size, className })} />
58
+ </AnchorSurface>
59
+ )
52
60
  }
53
61
 
62
+ const anchorTool = tv({
63
+ base: '',
64
+ variants: {
65
+ surface: {
66
+ bare: '',
67
+ well: '',
68
+ ghost: 'opacity-40 group-data-popup-open/combobox:opacity-100'
69
+ + ' group-not-has-disabled/combobox:group-not-data-readonly/combobox:group-hover/combobox:opacity-100'
70
+ + ' group-not-has-disabled/combobox:group-not-data-readonly/combobox:group-focus-within/combobox:opacity-100',
71
+ },
72
+ },
73
+ })
74
+
54
75
  export type ComboboxInputProps = Omit<ComponentProps<typeof ComboboxPrimitive.Input>, 'className'> & {
55
76
  className?: string
56
77
  }
@@ -79,7 +100,7 @@ export type ComboboxTriggerProps = Omit<ComponentProps<typeof ComboboxPrimitive.
79
100
 
80
101
  export function ComboboxTrigger({ label = '목록 열기', className, children, ...rest }: ComboboxTriggerProps) {
81
102
  return (
82
- <ComboboxPrimitive.Trigger aria-label={label} {...rest} className={comboboxTrigger({ className })}>
103
+ <ComboboxPrimitive.Trigger aria-label={label} {...rest} className={comboboxTrigger({ className: [anchorTool({ surface: use(AnchorSurface) }), className] })}>
83
104
  {children ?? (
84
105
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4 transition-transform in-data-popup-open:rotate-180 motion-reduce:transition-none">
85
106
  <path d="m6 9 6 6 6-6" />
@@ -103,7 +124,7 @@ export type ComboboxCancelProps = Omit<ComponentProps<typeof ComboboxPrimitive.C
103
124
 
104
125
  export function ComboboxCancel({ label = '지우기', keepMounted = true, className, children, ...rest }: ComboboxCancelProps) {
105
126
  return (
106
- <ComboboxPrimitive.Clear aria-label={label} keepMounted={keepMounted} {...rest} className={comboboxCancel({ className })}>
127
+ <ComboboxPrimitive.Clear aria-label={label} keepMounted={keepMounted} {...rest} className={comboboxCancel({ className: [anchorTool({ surface: use(AnchorSurface) }), className] })}>
107
128
  {children ?? (
108
129
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
109
130
  <path d="M18 6 6 18" />
package/dialog/index.tsx CHANGED
@@ -6,16 +6,17 @@ import type { ComponentProps, ReactNode } from 'react'
6
6
  import { tv } from '../tv'
7
7
 
8
8
  export const dialog = tv({
9
- base: 'sheet text-fg z-overlay fixed flex flex-col gap-stack overflow-y-auto'
10
- + ' open:animate-fade-in closed:animate-fade-out focus-visible:outline-none',
9
+ base: 'float text-fg z-overlay fixed flex flex-col gap-stack overflow-y-auto'
10
+ + ' open:animate-fade-in closed:animate-fade-out focus-visible:outline-none'
11
+ + ' transition-state data-nested-dialog-open:scale-95 motion-reduce:data-nested-dialog-open:scale-100',
11
12
 
12
13
  variants: {
13
14
  size: {
14
- md: 'left-1/2 top-1/2 w-full max-w-120 -translate-x-1/2 -translate-y-1/2'
15
- + ' rounded-r20 p-6',
15
+ sm: 'inset-x-4 top-1/2 mx-auto max-h-11/12 max-w-112 -translate-y-1/2 rounded-r20 p-6',
16
+ md: 'inset-x-4 top-1/2 mx-auto max-h-11/12 max-w-120 -translate-y-1/2 rounded-r20 p-6',
17
+ lg: 'inset-x-4 top-1/2 mx-auto max-h-11/12 max-w-160 -translate-y-1/2 rounded-r20 p-6',
16
18
  full: 'inset-0 p-6',
17
- top: 'left-1/2 top-24 w-full max-w-144 -translate-x-1/2'
18
- + ' rounded-r20 gap-0 overflow-hidden',
19
+ top: 'inset-x-4 top-24 mx-auto max-w-144 rounded-r20 gap-0 overflow-hidden',
19
20
  },
20
21
  },
21
22
 
@@ -41,14 +42,14 @@ export function DialogClose(props: DialogCloseProps) {
41
42
  }
42
43
 
43
44
  export type DialogContentProps = Omit<ComponentProps<typeof DialogPrimitive.Popup>, 'className'> & {
44
- size?: 'md' | 'full' | 'top'
45
+ size?: 'sm' | 'md' | 'lg' | 'full' | 'top'
45
46
  className?: string
46
47
  }
47
48
 
48
49
  export function DialogContent({ size, className, children, ...rest }: DialogContentProps) {
49
50
  return (
50
51
  <DialogPrimitive.Portal>
51
- <DialogPrimitive.Backdrop className="bg-bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
52
+ <DialogPrimitive.Backdrop forceRender className="bg-bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
52
53
 
53
54
  <DialogPrimitive.Popup {...rest} className={dialog({ size, className })}>
54
55
  {children}
@@ -107,11 +108,47 @@ export function DialogHeader({ lead, trail, className, children }: DialogHeaderP
107
108
  )
108
109
  }
109
110
 
110
- const dialogFooter = tv({ base: 'mt-auto flex justify-end gap-2' })
111
+ const dialogBody = tv({ base: '-m-1 min-h-0 overflow-y-auto p-1' })
111
112
 
112
- export function DialogFooter({ className, children }: { className?: string, children?: ReactNode }) {
113
+ export function DialogBody({ className, children }: { className?: string, children?: ReactNode }) {
113
114
  return (
114
- <div className={dialogFooter({ className })}>
115
+ <div className={dialogBody({ className })}>
116
+ {children}
117
+ </div>
118
+ )
119
+ }
120
+
121
+ type FooterLayout = 'end' | 'between' | 'fill' | 'grow-last'
122
+
123
+ const dialogFooter = tv({
124
+ base: 'mt-auto flex justify-end gap-2',
125
+ variants: {
126
+ narrow: {
127
+ 'end': '',
128
+ 'between': 'max-sm:*:first:mr-auto',
129
+ 'fill': 'max-sm:*:flex-1',
130
+ 'grow-last': 'max-sm:*:last:grow',
131
+ },
132
+ wide: {
133
+ 'end': '',
134
+ 'between': 'sm:*:first:mr-auto',
135
+ 'fill': 'sm:*:flex-1',
136
+ 'grow-last': 'sm:*:last:grow',
137
+ },
138
+ },
139
+ })
140
+
141
+ export interface DialogFooterProps {
142
+ layout?: FooterLayout | { narrow: FooterLayout, wide: FooterLayout }
143
+ className?: string
144
+ children?: ReactNode
145
+ }
146
+
147
+ export function DialogFooter({ layout = 'end', className, children }: DialogFooterProps) {
148
+ const { narrow, wide } = typeof layout === 'string' ? { narrow: layout, wide: layout } : layout
149
+
150
+ return (
151
+ <div className={dialogFooter({ narrow, wide, className })}>
115
152
  {children}
116
153
  </div>
117
154
  )
@@ -4,14 +4,17 @@
4
4
  .panel {
5
5
  --drawer-slide: -100%;
6
6
  transform: translateX(var(--drawer-swipe-movement-x, 0px));
7
- transition: transform var(--dur-enter) var(--ease-enter);
7
+ transform-origin: left center;
8
+ transition: transform var(--dur-enter) var(--ease-enter), scale var(--dur-enter) var(--ease-enter);
8
9
  }
9
10
 
10
11
  .panel[data-open] { animation: slide-in var(--dur-enter) var(--ease-enter); }
11
12
  .panel[data-closed] { animation: slide-out var(--dur-exit) var(--ease-exit) forwards; }
12
13
  .panel[data-swiping] { transition-duration: 0ms; }
13
- .panel-right { --drawer-slide: 100%; }
14
+ .panel[data-nested-drawer-open] { scale: 0.95; }
15
+ .panel-right { --drawer-slide: 100%; transform-origin: right center; }
14
16
 
15
17
  @media (prefers-reduced-motion: reduce) {
16
18
  .panel { --drawer-slide: 0%; }
19
+ .panel[data-nested-drawer-open] { scale: 1; }
17
20
  }
package/drawer/index.tsx CHANGED
@@ -36,7 +36,7 @@ export function DrawerClose(props: DrawerCloseProps) {
36
36
  }
37
37
 
38
38
  export const drawerContent = tv({
39
- base: 'sheet text-fg z-overlay fixed inset-y-0'
39
+ base: 'float text-fg z-overlay fixed inset-y-0'
40
40
  + ' flex w-full flex-col gap-stack overflow-y-auto p-4 sm:w-120'
41
41
  + ' focus-visible:outline-none',
42
42
  variants: {
@@ -53,7 +53,7 @@ export function DrawerContent({ className, style, ...rest }: DrawerContentProps)
53
53
 
54
54
  return (
55
55
  <DrawerPrimitive.Portal>
56
- <DrawerPrimitive.Backdrop className="bg-bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
56
+ <DrawerPrimitive.Backdrop forceRender className="bg-bg-overlay z-overlay fixed inset-0 open:animate-fade-in closed:animate-fade-out" />
57
57
 
58
58
  <DrawerPrimitive.Viewport>
59
59
  <DrawerPrimitive.Popup
@@ -122,3 +122,49 @@ export function DrawerDescription({ className, ...rest }: DrawerDescriptionProps
122
122
  <DrawerPrimitive.Description {...rest} className={drawerDescription({ className })} />
123
123
  )
124
124
  }
125
+
126
+ const drawerBody = tv({ base: '-m-1 min-h-0 overflow-y-auto p-1' })
127
+
128
+ export function DrawerBody({ className, children }: { className?: string, children?: ReactNode }) {
129
+ return (
130
+ <div className={drawerBody({ className })}>
131
+ {children}
132
+ </div>
133
+ )
134
+ }
135
+
136
+ type FooterLayout = 'end' | 'between' | 'fill' | 'grow-last'
137
+
138
+ const drawerFooter = tv({
139
+ base: 'mt-auto flex justify-end gap-2',
140
+ variants: {
141
+ narrow: {
142
+ 'end': '',
143
+ 'between': 'max-sm:*:first:mr-auto',
144
+ 'fill': 'max-sm:*:flex-1',
145
+ 'grow-last': 'max-sm:*:last:grow',
146
+ },
147
+ wide: {
148
+ 'end': '',
149
+ 'between': 'sm:*:first:mr-auto',
150
+ 'fill': 'sm:*:flex-1',
151
+ 'grow-last': 'sm:*:last:grow',
152
+ },
153
+ },
154
+ })
155
+
156
+ export interface DrawerFooterProps {
157
+ layout?: FooterLayout | { narrow: FooterLayout, wide: FooterLayout }
158
+ className?: string
159
+ children?: ReactNode
160
+ }
161
+
162
+ export function DrawerFooter({ layout = 'end', className, children }: DrawerFooterProps) {
163
+ const { narrow, wide } = typeof layout === 'string' ? { narrow: layout, wide: layout } : layout
164
+
165
+ return (
166
+ <div className={drawerFooter({ narrow, wide, className })}>
167
+ {children}
168
+ </div>
169
+ )
170
+ }
@@ -0,0 +1,24 @@
1
+ import { Fieldset as FieldsetPrimitive } from '@base-ui/react/fieldset'
2
+ import type { ComponentProps } from 'react'
3
+
4
+ import { tv } from '../tv'
5
+
6
+ const fieldset = tv({ base: 'flex min-w-0 flex-col gap-stack' })
7
+
8
+ export type FieldsetProps = Omit<ComponentProps<typeof FieldsetPrimitive.Root>, 'className'> & {
9
+ className?: string
10
+ }
11
+
12
+ export function Fieldset({ className, ...rest }: FieldsetProps) {
13
+ return <FieldsetPrimitive.Root {...rest} className={fieldset({ className })} />
14
+ }
15
+
16
+ const fieldsetLegend = tv({ base: 'text-label data-disabled:text-fg-disabled' })
17
+
18
+ export type FieldsetLegendProps = Omit<ComponentProps<typeof FieldsetPrimitive.Legend>, 'className'> & {
19
+ className?: string
20
+ }
21
+
22
+ export function FieldsetLegend({ className, ...rest }: FieldsetLegendProps) {
23
+ return <FieldsetPrimitive.Legend {...rest} className={fieldsetLegend({ className })} />
24
+ }
package/input/index.tsx CHANGED
@@ -18,6 +18,8 @@ export const input = tv({
18
18
  surface: {
19
19
  bare: 'bg-transparent ring-1 ring-inset ring-line-strong read-only:bg-bg-well',
20
20
  well: 'bg-bg-well',
21
+ ghost: 'bg-transparent enabled:not-read-only:not-focus:hover:bg-bg-neutral-hover'
22
+ + ' focus:bg-bg-neutral-selected read-only:focus:bg-transparent disabled:bg-transparent',
21
23
  },
22
24
  size: {
23
25
  sm: 'h-8 px-3',
@@ -30,7 +32,7 @@ export const input = tv({
30
32
  })
31
33
 
32
34
  export type InputProps = Omit<ComponentProps<typeof InputPrimitive>, 'className' | 'size'> & {
33
- surface?: 'bare' | 'well'
35
+ surface?: 'bare' | 'well' | 'ghost'
34
36
  size?: 'sm' | 'md' | 'lg'
35
37
  className?: string
36
38
  }
@@ -0,0 +1,141 @@
1
+ 'use client'
2
+
3
+ import type { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
4
+ import type { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'
5
+ import { createContext, use, useCallback, useState, useSyncExternalStore, type ReactElement, type ReactNode } from 'react'
6
+
7
+ import {
8
+ BottomSheet,
9
+ BottomSheetClose,
10
+ BottomSheetContent,
11
+ BottomSheetDescription,
12
+ BottomSheetTitle,
13
+ BottomSheetTrigger,
14
+ } from '../bottom-sheet'
15
+ import {
16
+ Dialog,
17
+ DialogBody,
18
+ DialogClose,
19
+ DialogContent,
20
+ DialogDescription,
21
+ DialogFooter,
22
+ DialogHeader,
23
+ DialogTitle,
24
+ DialogTrigger,
25
+ type DialogContentProps,
26
+ type DialogFooterProps,
27
+ type DialogHeaderProps,
28
+ type DialogTriggerProps,
29
+ } from '../dialog'
30
+
31
+ type Shape = 'dialog' | 'sheet'
32
+
33
+ export type ModalBreakpoint = 'xs' | 'sm' | 'md'
34
+
35
+ export const modalQuery: Record<ModalBreakpoint, string> = {
36
+ xs: '(min-width: 30rem)',
37
+ sm: '(min-width: 40rem)',
38
+ md: '(min-width: 48rem)',
39
+ }
40
+
41
+ const ModalShape = createContext<Shape>('sheet')
42
+
43
+ const media = (query: string) => (typeof window.matchMedia === 'function' ? window.matchMedia(query) : null)
44
+
45
+ function useWide(query: string) {
46
+ const subscribe = useCallback((notify: () => void) => {
47
+ const list = media(query)
48
+ list?.addEventListener('change', notify)
49
+ return () => list?.removeEventListener('change', notify)
50
+ }, [query])
51
+
52
+ return useSyncExternalStore(subscribe, () => media(query)?.matches ?? false, () => false)
53
+ }
54
+
55
+ export interface ModalProps {
56
+ breakpoint?: ModalBreakpoint
57
+ as?: 'auto' | Shape
58
+ open?: boolean
59
+ defaultOpen?: boolean
60
+ onOpenChange?: (open: boolean, eventDetails: DialogPrimitive.Root.ChangeEventDetails | DrawerPrimitive.Root.ChangeEventDetails) => void
61
+ children?: ReactNode
62
+ }
63
+
64
+ export function Modal({ breakpoint = 'xs', as = 'auto', open, defaultOpen = false, onOpenChange, children }: ModalProps) {
65
+ const wide = useWide(modalQuery[breakpoint])
66
+ const [inner, setInner] = useState(defaultOpen)
67
+ const shape: Shape = as === 'auto' ? (wide ? 'dialog' : 'sheet') : as
68
+ const current = open ?? inner
69
+
70
+ const change = (next: boolean, eventDetails: DialogPrimitive.Root.ChangeEventDetails | DrawerPrimitive.Root.ChangeEventDetails) => {
71
+ onOpenChange?.(next, eventDetails)
72
+ if (open === undefined && !eventDetails.isCanceled) setInner(next)
73
+ }
74
+
75
+ return (
76
+ <ModalShape value={shape}>
77
+ {shape === 'dialog'
78
+ ? <Dialog open={current} onOpenChange={change}>{children}</Dialog>
79
+ : <BottomSheet open={current} onOpenChange={change}>{children}</BottomSheet>}
80
+ </ModalShape>
81
+ )
82
+ }
83
+
84
+ export type ModalTriggerProps = Omit<DialogTriggerProps, 'className' | 'render' | 'style' | 'handle' | 'payload'> & {
85
+ render?: ReactElement
86
+ className?: string
87
+ }
88
+
89
+ export function ModalTrigger(props: ModalTriggerProps) {
90
+ return use(ModalShape) === 'dialog' ? <DialogTrigger {...props} /> : <BottomSheetTrigger {...props} />
91
+ }
92
+
93
+ export type ModalCloseProps = ModalTriggerProps
94
+
95
+ export function ModalClose(props: ModalCloseProps) {
96
+ return use(ModalShape) === 'dialog' ? <DialogClose {...props} /> : <BottomSheetClose {...props} />
97
+ }
98
+
99
+ export type ModalContentProps = Pick<DialogContentProps, 'initialFocus' | 'finalFocus'> & {
100
+ size?: 'sm' | 'md' | 'lg'
101
+ className?: string
102
+ children?: ReactNode
103
+ }
104
+
105
+ export function ModalContent({ size, ...rest }: ModalContentProps) {
106
+ return use(ModalShape) === 'dialog' ? <DialogContent {...rest} size={size} /> : <BottomSheetContent {...rest} />
107
+ }
108
+
109
+ export type ModalHeaderProps = DialogHeaderProps
110
+
111
+ export function ModalHeader(props: ModalHeaderProps) {
112
+ return <DialogHeader {...props} />
113
+ }
114
+
115
+ export interface ModalTitleProps {
116
+ className?: string
117
+ children?: ReactNode
118
+ }
119
+
120
+ export function ModalTitle(props: ModalTitleProps) {
121
+ return use(ModalShape) === 'dialog' ? <DialogTitle {...props} /> : <BottomSheetTitle {...props} />
122
+ }
123
+
124
+ export type ModalDescriptionProps = ModalTitleProps
125
+
126
+ export function ModalDescription(props: ModalDescriptionProps) {
127
+ return use(ModalShape) === 'dialog' ? <DialogDescription {...props} /> : <BottomSheetDescription {...props} />
128
+ }
129
+
130
+ export function ModalBody(props: { className?: string, children?: ReactNode }) {
131
+ return <DialogBody {...props} />
132
+ }
133
+
134
+ export type ModalFooterProps = DialogFooterProps
135
+
136
+ export function ModalFooter({ layout, ...rest }: ModalFooterProps) {
137
+ const shape = use(ModalShape)
138
+ const resolved = typeof layout === 'object' ? layout[shape === 'dialog' ? 'wide' : 'narrow'] : layout
139
+
140
+ return <DialogFooter {...rest} layout={resolved ?? (shape === 'dialog' ? 'end' : 'fill')} />
141
+ }
@@ -7,7 +7,7 @@ import { tv } from '../tv'
7
7
 
8
8
  export const numberField = tv({
9
9
  slots: {
10
- root: 'flex items-center rounded-r8'
10
+ root: 'group/number flex items-center rounded-r8'
11
11
  + ' transition-state'
12
12
  + ' invalid-within:ring-2 invalid-within:ring-line-danger'
13
13
  + ' has-disabled:bg-bg-neutral-disabled has-disabled:cursor-not-allowed'
@@ -25,6 +25,11 @@ export const numberField = tv({
25
25
  surface: {
26
26
  bare: { root: 'bg-transparent ring-1 ring-inset ring-line-strong' },
27
27
  well: { root: 'bg-bg-well' },
28
+ ghost: {
29
+ root: 'bg-transparent focus-within:bg-bg-neutral-selected has-disabled:bg-transparent',
30
+ step: 'opacity-40 group-not-has-disabled/number:group-hover/number:opacity-100'
31
+ + ' group-not-has-disabled/number:group-focus-within/number:opacity-100',
32
+ },
28
33
  },
29
34
  size: {
30
35
  sm: { root: 'h-8', step: 'size-8' },
@@ -32,10 +37,16 @@ export const numberField = tv({
32
37
  lg: { root: 'h-10', step: 'size-10', input: 'text-t16' },
33
38
  },
34
39
  readonly: {
35
- true: { root: 'bg-bg-well', input: 'text-fg-muted' },
40
+ true: { root: 'bg-bg-well has-disabled:bg-bg-well has-disabled:cursor-default', step: 'disabled:cursor-default', input: 'text-fg-muted' },
41
+ false: {},
36
42
  },
37
43
  },
38
44
 
45
+ compoundVariants: [
46
+ { surface: 'ghost', readonly: false, class: { root: 'not-has-disabled:not-focus-within:hover:bg-bg-neutral-hover' } },
47
+ { surface: 'ghost', readonly: true, class: { root: 'bg-transparent has-disabled:bg-transparent focus-within:bg-transparent' } },
48
+ ],
49
+
39
50
  defaultVariants: { surface: 'bare', size: 'md' },
40
51
  })
41
52
 
@@ -68,7 +79,7 @@ export type NumberFieldProps = Omit<NumberFieldPrimitive.Root.Props, 'className'
68
79
  & {
69
80
  decrementLabel: string
70
81
  incrementLabel: string
71
- surface?: 'bare' | 'well'
82
+ surface?: 'bare' | 'well' | 'ghost'
72
83
  size?: 'sm' | 'md' | 'lg'
73
84
  className?: string
74
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantoia/ui",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Pantoia Design System",
5
5
  "homepage": "https://design.pantoia.com",
6
6
  "repository": {
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { Progress as ProgressPrimitive } from '@base-ui/react/progress'
4
+ import type { ComponentProps } from 'react'
5
+
6
+ import { tv } from '../tv'
7
+
8
+ export const progress = tv({
9
+ slots: {
10
+ root: 'flex flex-wrap items-center justify-between gap-x-2 gap-y-tight',
11
+ track: 'bg-bg-neutral-selected h-1.5 w-full overflow-hidden rounded-full',
12
+ indicator: 'h-full rounded-full transition-all motion-reduce:transition-none',
13
+ },
14
+
15
+ variants: {
16
+ color: {
17
+ neutral: { indicator: 'bg-bg-neutral-solid' },
18
+ brand: { indicator: 'bg-bg-brand-solid' },
19
+ info: { indicator: 'bg-bg-info-solid' },
20
+ success: { indicator: 'bg-bg-success-solid' },
21
+ warning: { indicator: 'bg-bg-warning-solid' },
22
+ danger: { indicator: 'bg-bg-danger-solid' },
23
+ },
24
+ },
25
+
26
+ defaultVariants: { color: 'brand' },
27
+ })
28
+
29
+ export type ProgressProps = Omit<ComponentProps<typeof ProgressPrimitive.Root>, 'className' | 'value'> & {
30
+ value: number
31
+ color?: 'neutral' | 'brand' | 'info' | 'success' | 'warning' | 'danger'
32
+ className?: string
33
+ }
34
+
35
+ export function Progress({ color, className, children, ...rest }: ProgressProps) {
36
+ const ui = progress({ color })
37
+
38
+ return (
39
+ <ProgressPrimitive.Root {...rest} className={ui.root({ className })}>
40
+ {children}
41
+ <ProgressPrimitive.Track className={ui.track()}>
42
+ <ProgressPrimitive.Indicator className={ui.indicator()} />
43
+ </ProgressPrimitive.Track>
44
+ </ProgressPrimitive.Root>
45
+ )
46
+ }
47
+
48
+ const progressLabel = tv({ base: 'text-label' })
49
+
50
+ export type ProgressLabelProps = Omit<ComponentProps<typeof ProgressPrimitive.Label>, 'className'> & {
51
+ className?: string
52
+ }
53
+
54
+ export function ProgressLabel({ className, ...rest }: ProgressLabelProps) {
55
+ return <ProgressPrimitive.Label {...rest} className={progressLabel({ className })} />
56
+ }
57
+
58
+ const progressValue = tv({ base: 'text-caption text-fg-muted tabular-nums' })
59
+
60
+ export type ProgressValueProps = Omit<ComponentProps<typeof ProgressPrimitive.Value>, 'className'> & {
61
+ className?: string
62
+ }
63
+
64
+ export function ProgressValue({ className, ...rest }: ProgressValueProps) {
65
+ return <ProgressPrimitive.Value {...rest} className={progressValue({ className })} />
66
+ }
package/registry.json CHANGED
@@ -194,6 +194,27 @@
194
194
  "layer": "ui"
195
195
  }
196
196
  },
197
+ {
198
+ "name": "collapsible",
199
+ "type": "registry:ui",
200
+ "title": "Collapsible",
201
+ "files": [
202
+ {
203
+ "path": "collapsible/index.tsx",
204
+ "type": "registry:ui"
205
+ },
206
+ {
207
+ "path": "collapsible/index.module.css",
208
+ "type": "registry:ui"
209
+ }
210
+ ],
211
+ "dependencies": [
212
+ "@base-ui/react"
213
+ ],
214
+ "meta": {
215
+ "layer": "ui"
216
+ }
217
+ },
197
218
  {
198
219
  "name": "combobox",
199
220
  "type": "registry:ui",
@@ -353,6 +374,26 @@
353
374
  "layer": "ui"
354
375
  }
355
376
  },
377
+ {
378
+ "name": "fieldset",
379
+ "type": "registry:ui",
380
+ "title": "Fieldset",
381
+ "files": [
382
+ {
383
+ "path": "fieldset/index.tsx",
384
+ "type": "registry:ui"
385
+ }
386
+ ],
387
+ "dependencies": [
388
+ "@base-ui/react"
389
+ ],
390
+ "registryDependencies": [
391
+ "tv"
392
+ ],
393
+ "meta": {
394
+ "layer": "ui"
395
+ }
396
+ },
356
397
  {
357
398
  "name": "input",
358
399
  "type": "registry:ui",
@@ -407,6 +448,27 @@
407
448
  "layer": "ui"
408
449
  }
409
450
  },
451
+ {
452
+ "name": "modal",
453
+ "type": "registry:ui",
454
+ "title": "Modal",
455
+ "files": [
456
+ {
457
+ "path": "modal/index.tsx",
458
+ "type": "registry:ui"
459
+ }
460
+ ],
461
+ "dependencies": [
462
+ "@base-ui/react"
463
+ ],
464
+ "registryDependencies": [
465
+ "dialog",
466
+ "bottom-sheet"
467
+ ],
468
+ "meta": {
469
+ "layer": "derived"
470
+ }
471
+ },
410
472
  {
411
473
  "name": "number-field",
412
474
  "type": "registry:ui",
@@ -448,6 +510,26 @@
448
510
  "radius": "rounded-r16"
449
511
  }
450
512
  },
513
+ {
514
+ "name": "progress",
515
+ "type": "registry:ui",
516
+ "title": "Progress",
517
+ "files": [
518
+ {
519
+ "path": "progress/index.tsx",
520
+ "type": "registry:ui"
521
+ }
522
+ ],
523
+ "dependencies": [
524
+ "@base-ui/react"
525
+ ],
526
+ "registryDependencies": [
527
+ "tv"
528
+ ],
529
+ "meta": {
530
+ "layer": "ui"
531
+ }
532
+ },
451
533
  {
452
534
  "name": "radio-group",
453
535
  "type": "registry:ui",
@@ -469,6 +551,29 @@
469
551
  "layer": "ui"
470
552
  }
471
553
  },
554
+ {
555
+ "name": "segmented-control",
556
+ "type": "registry:ui",
557
+ "title": "SegmentedControl",
558
+ "files": [
559
+ {
560
+ "path": "segmented-control/index.tsx",
561
+ "type": "registry:ui"
562
+ }
563
+ ],
564
+ "dependencies": [
565
+ "@base-ui/react"
566
+ ],
567
+ "registryDependencies": [
568
+ "tv"
569
+ ],
570
+ "meta": {
571
+ "layer": "ui",
572
+ "sizes": [
573
+ "SegmentedControlItem"
574
+ ]
575
+ }
576
+ },
472
577
  {
473
578
  "name": "select",
474
579
  "type": "registry:ui",
@@ -490,6 +595,26 @@
490
595
  "radius": "rounded-r12"
491
596
  }
492
597
  },
598
+ {
599
+ "name": "separator",
600
+ "type": "registry:ui",
601
+ "title": "Separator",
602
+ "files": [
603
+ {
604
+ "path": "separator/index.tsx",
605
+ "type": "registry:ui"
606
+ }
607
+ ],
608
+ "dependencies": [
609
+ "@base-ui/react"
610
+ ],
611
+ "registryDependencies": [
612
+ "tv"
613
+ ],
614
+ "meta": {
615
+ "layer": "ui"
616
+ }
617
+ },
493
618
  {
494
619
  "name": "switch",
495
620
  "type": "registry:ui",
@@ -0,0 +1,63 @@
1
+ 'use client'
2
+
3
+ import { Radio } from '@base-ui/react/radio'
4
+ import { RadioGroup } from '@base-ui/react/radio-group'
5
+ import { createContext, use, type ComponentProps } from 'react'
6
+
7
+ import { tv } from '../tv'
8
+
9
+ type Size = 'sm' | 'md' | 'lg'
10
+
11
+ const Context = createContext<{ size: Size, fill: boolean }>({ size: 'md', fill: false })
12
+
13
+ export const segmentedControl = tv({
14
+ slots: {
15
+ root: 'bg-bg-neutral-selected rounded-r10 inline-flex shrink-0 gap-0.5 p-1',
16
+ item: 'text-label text-fg-muted rounded-r6 inline-flex h-full items-center justify-center gap-tight whitespace-nowrap'
17
+ + ' transition-state press-shrink cursor-pointer'
18
+ + ' not-data-disabled:hover:text-fg'
19
+ + ' checked:bg-bg-neutral-thumb checked:text-fg checked:shadow-s1'
20
+ + ' focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-line-focus-ring'
21
+ + ' data-disabled:text-fg-disabled data-disabled:cursor-not-allowed',
22
+ },
23
+
24
+ variants: {
25
+ size: {
26
+ sm: { root: 'h-8', item: 'text-t13 font-medium px-2.5 [&_svg]:size-4' },
27
+ md: { root: 'h-9', item: 'px-2.5 [&_svg]:size-4' },
28
+ lg: { root: 'h-10', item: 'text-t16 font-medium px-3 [&_svg]:size-5' },
29
+ },
30
+ fill: {
31
+ true: { root: 'flex w-full', item: 'flex-1' },
32
+ false: '',
33
+ },
34
+ },
35
+
36
+ defaultVariants: { size: 'md', fill: false },
37
+ })
38
+
39
+ export type SegmentedControlProps<Value> = Omit<ComponentProps<typeof RadioGroup<Value>>, 'className'> & {
40
+ size?: Size
41
+ fill?: boolean
42
+ className?: string
43
+ }
44
+
45
+ export function SegmentedControl<Value>({ size = 'md', fill = false, className, ...rest }: SegmentedControlProps<Value>) {
46
+ const ui = segmentedControl({ size, fill })
47
+
48
+ return (
49
+ <Context value={{ size, fill }}>
50
+ <RadioGroup {...rest} className={ui.root({ className })} />
51
+ </Context>
52
+ )
53
+ }
54
+
55
+ export type SegmentedControlItemProps<Value> = Omit<Radio.Root.Props<Value>, 'className'> & {
56
+ className?: string
57
+ }
58
+
59
+ export function SegmentedControlItem<Value>({ className, ...rest }: SegmentedControlItemProps<Value>) {
60
+ const { size, fill } = use(Context)
61
+
62
+ return <Radio.Root {...rest} className={segmentedControl({ size, fill }).item({ className })} />
63
+ }
package/select/index.tsx CHANGED
@@ -6,8 +6,21 @@ import type { ComponentProps, ReactNode } from 'react'
6
6
 
7
7
  import { tv } from '../tv'
8
8
 
9
+ const selectIcon = tv({
10
+ base: 'text-fg-subtle transition-state',
11
+ variants: {
12
+ surface: {
13
+ bare: '',
14
+ well: '',
15
+ ghost: 'opacity-40 group-data-popup-open/select:opacity-100'
16
+ + ' group-enabled/select:group-not-data-readonly/select:group-hover/select:opacity-100'
17
+ + ' group-enabled/select:group-not-data-readonly/select:group-focus-visible/select:opacity-100',
18
+ },
19
+ },
20
+ })
21
+
9
22
  export const selectTrigger = tv({
10
- base: 'flex w-full items-center justify-between gap-tight rounded-r8'
23
+ base: 'group/select flex w-full items-center justify-between gap-tight rounded-r8'
11
24
  + ' text-value text-fg'
12
25
  + ' transition-state'
13
26
  + ' invalid:ring-2 invalid:ring-line-danger'
@@ -19,6 +32,8 @@ export const selectTrigger = tv({
19
32
  surface: {
20
33
  bare: 'bg-transparent ring-1 ring-inset ring-line-strong',
21
34
  well: 'bg-bg-well',
35
+ ghost: 'bg-transparent enabled:not-data-readonly:not-data-popup-open:hover:bg-bg-neutral-hover'
36
+ + ' data-popup-open:bg-bg-neutral-selected disabled:bg-transparent',
22
37
  },
23
38
  size: {
24
39
  sm: 'h-8 px-3',
@@ -75,7 +90,7 @@ export type SelectProps = Omit<ComponentProps<typeof SelectPrimitive.Trigger>, '
75
90
  defaultValue?: string | null
76
91
  onValueChange?: (value: string | null, eventDetails: SelectPrimitive.Root.ChangeEventDetails) => void
77
92
  placeholder?: string
78
- surface?: 'bare' | 'well'
93
+ surface?: 'bare' | 'well' | 'ghost'
79
94
  size?: 'sm' | 'md' | 'lg'
80
95
  arrow?: boolean
81
96
  className?: string
@@ -140,7 +155,7 @@ export function Select({
140
155
  <span className="min-w-0 truncate">
141
156
  <SelectPrimitive.Value placeholder={placeholder} />
142
157
  </span>
143
- <SelectPrimitive.Icon className="text-fg-subtle">
158
+ <SelectPrimitive.Icon className={selectIcon({ surface })}>
144
159
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4 transition-transform in-data-popup-open:rotate-180 motion-reduce:transition-none">
145
160
  <path d="m6 9 6 6 6-6" />
146
161
  </svg>
@@ -0,0 +1,25 @@
1
+ import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
2
+ import type { ComponentProps } from 'react'
3
+
4
+ import { tv } from '../tv'
5
+
6
+ export const separator = tv({
7
+ base: 'bg-line shrink-0',
8
+
9
+ variants: {
10
+ orientation: {
11
+ horizontal: 'h-px w-full',
12
+ vertical: 'w-px self-stretch',
13
+ },
14
+ },
15
+
16
+ defaultVariants: { orientation: 'horizontal' },
17
+ })
18
+
19
+ export type SeparatorProps = Omit<ComponentProps<typeof SeparatorPrimitive>, 'className'> & {
20
+ className?: string
21
+ }
22
+
23
+ export function Separator({ orientation = 'horizontal', className, ...rest }: SeparatorProps) {
24
+ return <SeparatorPrimitive {...rest} orientation={orientation} className={separator({ orientation, className })} />
25
+ }
@@ -78,8 +78,7 @@ Select와 RadioGroup의 state는 `useState<string | null>`로 둔다.
78
78
  ```
79
79
  frame 배경과 선. 페이지 안의 영역
80
80
  card 배경과 그림자. 목록의 항목
81
- float 배경과 선과 그림자. 페이지 위에 뜨는 것
82
- sheet 배경만. 화면을 덮는 것
81
+ float 배경과 선과 그림자. 페이지나 overlay 위에 뜨는 것
83
82
  well bg-bg-well. 파인 면
84
83
  ```
85
84
 
@@ -147,27 +146,33 @@ className 그 한 곳만 바뀐다
147
146
  | 컴포넌트 | add | 하위 컴포넌트 | variant |
148
147
  |---|---|---|---|
149
148
  | Accordion | accordion | Accordion, AccordionItem, AccordionTrigger, AccordionContent | - |
150
- | AlertDialog | alert-dialog | AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle, AlertDialogDescription, AlertDialogHeader, AlertDialogFooter, AlertDialogAction, AlertDialogCancel | - |
149
+ | AlertDialog | alert-dialog | AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle, AlertDialogDescription, AlertDialogHeader, AlertDialogBody, AlertDialogFooter, AlertDialogAction, AlertDialogCancel | size |
151
150
  | Avatar | avatar | Avatar | color, size |
152
151
  | Badge | badge | Badge | color, emphasis, shape |
153
- | BottomSheet | bottom-sheet | BottomSheet, BottomSheetTrigger, BottomSheetClose, BottomSheetContent, BottomSheetHeader, BottomSheetTitle, BottomSheetDescription | - |
152
+ | BottomSheet | bottom-sheet | BottomSheet, BottomSheetTrigger, BottomSheetClose, BottomSheetContent, BottomSheetHeader, BottomSheetTitle, BottomSheetDescription, BottomSheetBody, BottomSheetFooter | - |
154
153
  | Button | button | Button | color, emphasis, size, shape |
155
154
  | Callout | callout | Callout | color |
156
155
  | Checkbox | checkbox | CheckboxGroup, Checkbox | color |
156
+ | Collapsible | collapsible | Collapsible, CollapsibleTrigger, CollapsibleContent | - |
157
157
  | Combobox | combobox | Combobox, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxCancel, ComboboxContent, ComboboxViewport, ComboboxCollection, ComboboxItem, ComboboxItemText, ComboboxItemDescription, ComboboxGroup, ComboboxEmpty, ComboboxSeparator | surface, size |
158
158
  | CommandPalette | command-palette | CommandPalette | - |
159
159
  | ContextMenu | context-menu | ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent | color |
160
- | Dialog | dialog | Dialog, DialogTrigger, DialogClose, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter | size |
161
- | Drawer | drawer | Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription | side |
160
+ | Dialog | dialog | Dialog, DialogTrigger, DialogClose, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogBody, DialogFooter | size |
161
+ | Drawer | drawer | Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerBody, DrawerFooter | side |
162
162
  | DropdownMenu | dropdown-menu | DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent | color |
163
163
  | Field | field | Field, FieldLabel, FieldHelp, FieldError, FieldPending, FieldSuccess | layout |
164
+ | Fieldset | fieldset | Fieldset, FieldsetLegend | - |
164
165
  | Input | input | Input | surface, size |
165
166
  | Kbd | kbd | Kbd | - |
166
167
  | Label | label | Label | - |
168
+ | Modal | modal | Modal, ModalTrigger, ModalClose, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter | - |
167
169
  | NumberField | number-field | NumberField | surface, size, readonly |
168
170
  | Popover | popover | Popover, PopoverTrigger, PopoverAnchor, PopoverClose, PopoverContent | - |
171
+ | Progress | progress | Progress, ProgressLabel, ProgressValue | color |
169
172
  | RadioGroup | radio-group | RadioGroup, RadioGroupItem, RadioGroupItemLabel, RadioGroupItemDescription | color |
173
+ | SegmentedControl | segmented-control | SegmentedControl, SegmentedControlItem | size, fill |
170
174
  | Select | select | Select, SelectGroup, SelectItem, SelectItemText, SelectItemDescription, SelectSeparator | surface, size |
175
+ | Separator | separator | Separator | orientation |
171
176
  | Switch | switch | Switch | color |
172
177
  | Table | table | Table, TableHeader, TableHeadCell, TableBody, TableRow, TableCell | fixed, mono |
173
178
  | Tabs | tabs | Tabs, TabsList, TabsTrigger, TabsContent | - |
@@ -181,6 +186,7 @@ className 그 한 곳만 바뀐다
181
186
  ```
182
187
  Dialog와 AlertDialog 내용을 보여 주면 Dialog, 되돌릴 수 없는 일을 한 번 더 물으면 AlertDialog
183
188
  Dialog와 Drawer 화면 가운데면 Dialog, 가장자리에서 열리면 Drawer
189
+ Modal과 Dialog 화면 폭에 따라 BottomSheet로 바뀌어야 하면 Modal, 늘 가운데면 Dialog
184
190
  Drawer와 BottomSheet 왼쪽이나 오른쪽에서 열리면 Drawer, 아래에서 올라오면 BottomSheet
185
191
  DropdownMenu와 ContextMenu 트리거를 누르면 DropdownMenu, 영역을 오른쪽 클릭하거나 길게 누르면 ContextMenu
186
192
  Select와 Combobox 목록에서 고르면 Select, 입력해서 찾으면 Combobox
@@ -188,11 +194,24 @@ Checkbox와 Switch 여러 개를 고르면 Checkbox, 설정을 바로
188
194
  Toggle과 Switch 도구 모음에서 상태를 누르면 Toggle, 설정이면 Switch
189
195
  Badge와 Button 누를 수 없으면 Badge, 누르면 Button
190
196
  Tabs와 Accordion 한 번에 하나만 보이면 Tabs, 여러 개를 펴 두면 Accordion
197
+ SegmentedControl과 Tabs 같은 내용의 보기 방식을 바꾸면 SegmentedControl, 다른 내용으로 바꾸면 Tabs
198
+ SegmentedControl과 Toggle 여럿 중 하나를 고르면 SegmentedControl, 하나를 켜고 끄면 Toggle
199
+ Collapsible과 Accordion 한 영역을 접으면 Collapsible, 여러 영역을 함께 다루면 Accordion
191
200
  Tooltip과 Popover 짧은 설명이면 Tooltip, 안에서 조작하면 Popover
192
201
  ```
193
202
 
194
203
  Avatar의 배경색은 color나 seed 중 하나로 정한다. seed에는 사용자 id처럼 바뀌지 않는 값을 넘기고, 같은 seed는 어느 앱에서나 같은 색이다.
195
204
 
205
+ Dialog, AlertDialog, Drawer, BottomSheet의 Footer는 layout으로 버튼을 배치한다. end, between, fill, grow-last 중에 고르고, 화면 폭마다 다르면 { narrow, wide }를 넘긴다. 기준은 sm이고, ModalFooter는 Dialog 모양이면 wide, BottomSheet 모양이면 narrow를 쓴다.
206
+
207
+ 오버레이 안에서 다른 오버레이를 열 때는 Root를 부모 Content 안에 둔다. 뒤의 창은 어두워진다. Dialog는 안에서 무엇을 열어도 조금 작아지고, Drawer와 BottomSheet는 Drawer나 BottomSheet를 열 때만 작아진다.
208
+
209
+ Modal은 기준 폭(기본 xs, 480px)보다 넓으면 Dialog, 좁으면 BottomSheet로 연다.
210
+
211
+ Input, Textarea, NumberField, Select, ComboboxAnchor의 surface는 폼에서는 bare나 well을 쓰고, 표 셀, 헤더, 문장 안에서 값을 바로 고칠 때는 ghost를 쓴다.
212
+
213
+ Fieldset 안의 컨트롤은 Field로 감싼다. Fieldset의 disabled는 Field를 거쳐 컨트롤에 전달된다.
214
+
196
215
  Field는 라벨, 도움말, 오류 메시지를 입력 컨트롤에 묶는다. Input, Textarea, NumberField, Select, Combobox와 함께 쓴다.
197
216
 
198
217
  Select는 고른 항목의 `SelectItemText`나 글자를 트리거에 보인다. `SelectItem`을 다른 컴포넌트로 감싸면 `items`로 라벨을 넘긴다.
@@ -19,6 +19,9 @@ export const textarea = tv({
19
19
  surface: {
20
20
  bare: 'bg-transparent ring-1 ring-inset ring-line-strong read-only:bg-bg-well',
21
21
  well: 'bg-bg-well',
22
+ ghost: 'bg-transparent enabled:not-read-only:not-focus:hover:bg-bg-neutral-hover'
23
+ + ' focus:bg-bg-neutral-selected read-only:focus:bg-transparent disabled:bg-transparent'
24
+ + ' resize-none enabled:not-read-only:hover:resize-y enabled:not-read-only:focus:resize-y',
22
25
  },
23
26
  size: {
24
27
  sm: 'p-2',
@@ -33,7 +36,7 @@ export const textarea = tv({
33
36
  export type TextareaProps = Omit<ComponentProps<'textarea'>, 'className' | 'value' | 'defaultValue'>
34
37
  & Pick<FieldPrimitive.Control.Props, 'value' | 'defaultValue' | 'onValueChange'>
35
38
  & {
36
- surface?: 'bare' | 'well'
39
+ surface?: 'bare' | 'well' | 'ghost'
37
40
  size?: 'sm' | 'md' | 'lg'
38
41
  className?: string
39
42
  }
package/tokens.css CHANGED
@@ -16,6 +16,7 @@
16
16
  --pds-bg-neutral-hover: rgba(0,23,51,0.02);
17
17
  --pds-bg-neutral-knob: #ffffff;
18
18
  --pds-bg-neutral-knob-off: #ffffff;
19
+ --pds-bg-neutral-thumb: #ffffff;
19
20
  --pds-bg-neutral-track: var(--pds-gray-300);
20
21
  --pds-bg-neutral-selected: rgba(2,32,71,0.05);
21
22
  --pds-bg-neutral-selected-hover: rgba(0,27,55,0.1);
@@ -81,6 +82,7 @@
81
82
  --pds-bg-neutral-hover: rgba(209,209,253,0.05);
82
83
  --pds-bg-neutral-knob: #2c2c35;
83
84
  --pds-bg-neutral-knob-off: var(--pds-gray-800);
85
+ --pds-bg-neutral-thumb: #3c3c47;
84
86
  --pds-bg-neutral-track: var(--pds-gray-300);
85
87
  --pds-bg-neutral-selected: rgba(217,217,255,0.11);
86
88
  --pds-bg-neutral-selected-hover: rgba(222,222,255,0.19);
@@ -145,6 +147,7 @@
145
147
  --color-bg-neutral-hover: var(--pds-bg-neutral-hover);
146
148
  --color-bg-neutral-knob: var(--pds-bg-neutral-knob);
147
149
  --color-bg-neutral-knob-off: var(--pds-bg-neutral-knob-off);
150
+ --color-bg-neutral-thumb: var(--pds-bg-neutral-thumb);
148
151
  --color-bg-neutral-track: var(--pds-bg-neutral-track);
149
152
  --color-bg-neutral-selected: var(--pds-bg-neutral-selected);
150
153
  --color-bg-neutral-selected-hover: var(--pds-bg-neutral-selected-hover);
package/utilities.css CHANGED
@@ -90,10 +90,6 @@
90
90
  @apply bg-bg-panel border border-line shadow-s3;
91
91
  }
92
92
 
93
- @utility sheet {
94
- @apply bg-bg-panel;
95
- }
96
-
97
93
  @utility press {
98
94
  position: relative;
99
95