@pantoia/ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/accordion/index.module.css +4 -4
  2. package/accordion/index.tsx +21 -51
  3. package/alert-dialog/index.tsx +36 -42
  4. package/badge/index.tsx +1 -1
  5. package/button/index.tsx +31 -30
  6. package/callout/index.tsx +4 -4
  7. package/checkbox/index.tsx +20 -85
  8. package/combobox/index.tsx +122 -316
  9. package/command-palette/index.tsx +51 -32
  10. package/dialog/index.tsx +28 -24
  11. package/dist/index.js +5 -5
  12. package/drawer/index.module.css +2 -2
  13. package/drawer/index.tsx +52 -130
  14. package/dropdown-menu/index.tsx +87 -95
  15. package/field/index.tsx +27 -86
  16. package/input/index.tsx +7 -3
  17. package/kbd/index.tsx +2 -2
  18. package/label/index.tsx +6 -8
  19. package/number-field/index.tsx +122 -173
  20. package/package.json +2 -6
  21. package/popover/index.tsx +61 -32
  22. package/radio-group/index.tsx +35 -38
  23. package/registry.json +64 -154
  24. package/select/index.tsx +141 -70
  25. package/switch/index.tsx +13 -6
  26. package/table/index.tsx +1 -2
  27. package/tabs/index.module.css +3 -3
  28. package/tabs/index.tsx +22 -76
  29. package/textarea/index.tsx +19 -7
  30. package/toast/index.module.css +187 -0
  31. package/toast/index.tsx +141 -46
  32. package/toggle/index.tsx +4 -3
  33. package/tokens.css +7 -1
  34. package/tooltip/index.tsx +61 -30
  35. package/utilities.css +11 -6
  36. package/accordion/AGENTS.md +0 -60
  37. package/alert-dialog/AGENTS.md +0 -78
  38. package/badge/AGENTS.md +0 -35
  39. package/button/AGENTS.md +0 -39
  40. package/callout/AGENTS.md +0 -37
  41. package/checkbox/AGENTS.md +0 -54
  42. package/combobox/AGENTS.md +0 -150
  43. package/command-palette/AGENTS.md +0 -41
  44. package/dialog/AGENTS.md +0 -81
  45. package/drawer/AGENTS.md +0 -66
  46. package/dropdown-menu/AGENTS.md +0 -133
  47. package/field/AGENTS.md +0 -92
  48. package/input/AGENTS.md +0 -35
  49. package/kbd/AGENTS.md +0 -17
  50. package/label/AGENTS.md +0 -21
  51. package/number-field/AGENTS.md +0 -49
  52. package/popover/AGENTS.md +0 -64
  53. package/radio-group/AGENTS.md +0 -81
  54. package/select/AGENTS.md +0 -90
  55. package/switch/AGENTS.md +0 -37
  56. package/table/AGENTS.md +0 -82
  57. package/tabs/AGENTS.md +0 -61
  58. package/test-utils.ts +0 -10
  59. package/textarea/AGENTS.md +0 -35
  60. package/toast/AGENTS.md +0 -34
  61. package/toggle/AGENTS.md +0 -37
  62. package/tooltip/AGENTS.md +0 -50
@@ -0,0 +1,187 @@
1
+ @keyframes swipe-out-left {
2
+ from { transform: var(--toast-y) translateX(var(--toast-swipe-movement-x)); opacity: 1; }
3
+ to { transform: var(--toast-y) translateX(calc(var(--toast-swipe-movement-x) - 100%)); opacity: 0; }
4
+ }
5
+
6
+ @keyframes swipe-out-right {
7
+ from { transform: var(--toast-y) translateX(var(--toast-swipe-movement-x)); opacity: 1; }
8
+ to { transform: var(--toast-y) translateX(calc(var(--toast-swipe-movement-x) + 100%)); opacity: 0; }
9
+ }
10
+
11
+ @keyframes swipe-out-up {
12
+ from { transform: var(--toast-y) translateY(var(--toast-swipe-movement-y)); opacity: 1; }
13
+ to { transform: var(--toast-y) translateY(calc(var(--toast-swipe-movement-y) - 100%)); opacity: 0; }
14
+ }
15
+
16
+ @keyframes swipe-out-down {
17
+ from { transform: var(--toast-y) translateY(var(--toast-swipe-movement-y)); opacity: 1; }
18
+ to { transform: var(--toast-y) translateY(calc(var(--toast-swipe-movement-y) + 100%)); opacity: 0; }
19
+ }
20
+
21
+ .viewport {
22
+ --toast-gap: 8px;
23
+ --toast-inset: 16px;
24
+ position: fixed;
25
+ box-sizing: border-box;
26
+ width: 356px;
27
+ margin: 0;
28
+ padding: 0;
29
+ outline: none;
30
+ transition: transform 400ms ease;
31
+ }
32
+
33
+ .viewport[data-position$='right'] { right: var(--toast-inset); }
34
+ .viewport[data-position$='left'] { left: var(--toast-inset); }
35
+ .viewport[data-position$='center'] { left: 50%; transform: translateX(-50%); }
36
+ .viewport[data-position^='top'] { top: var(--toast-inset); }
37
+ .viewport[data-position^='bottom'] { bottom: var(--toast-inset); }
38
+
39
+ .toast {
40
+ --toast-lift: -1;
41
+ --toast-y: translateY(0);
42
+ position: absolute;
43
+ bottom: 0;
44
+ z-index: calc(1000 - var(--toast-index));
45
+ box-sizing: border-box;
46
+ outline: none;
47
+ overflow-wrap: anywhere;
48
+ touch-action: none;
49
+ opacity: 1;
50
+ transform: var(--toast-y);
51
+ transition: transform 400ms, opacity 400ms, height 400ms, box-shadow 200ms;
52
+ }
53
+
54
+ .toast:focus-visible {
55
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2);
56
+ }
57
+
58
+ .toast > * {
59
+ transition: opacity 400ms;
60
+ }
61
+
62
+ .viewport[data-position^='top'] .toast { --toast-lift: 1; top: 0; bottom: auto; }
63
+ .viewport[data-position$='right'] .toast { right: 0; }
64
+ .viewport[data-position$='left'] .toast { left: 0; }
65
+
66
+ .toast:not([data-expanded]) {
67
+ --toast-y: translateY(calc(var(--toast-lift) * var(--toast-gap) * var(--toast-index))) scale(calc(1 - var(--toast-index) * 0.05));
68
+ }
69
+
70
+ .toast:not([data-expanded]):not([data-front]) {
71
+ height: var(--toast-frontmost-height);
72
+ }
73
+
74
+ .toast[data-expanded],
75
+ .viewport[data-expand] .toast {
76
+ --toast-y: translateY(calc(var(--toast-lift) * (var(--toast-offset-y) + var(--toast-index) * var(--toast-gap))));
77
+ height: var(--toast-height);
78
+ }
79
+
80
+ .viewport[data-expand] .toast {
81
+ height: auto;
82
+ }
83
+
84
+ .toast[data-expanded]::after,
85
+ .viewport[data-expand] .toast::after {
86
+ content: '';
87
+ position: absolute;
88
+ bottom: 100%;
89
+ left: 0;
90
+ width: 100%;
91
+ height: calc(var(--toast-gap) + 1px);
92
+ }
93
+
94
+ .viewport .toast[data-starting-style] {
95
+ --toast-y: translateY(calc(var(--toast-lift) * -100%));
96
+ opacity: 0;
97
+ }
98
+
99
+ .toast[data-limited] {
100
+ opacity: 0;
101
+ pointer-events: none;
102
+ }
103
+
104
+ .viewport .toast[data-ending-style]:not([data-swipe-direction]) {
105
+ opacity: 0;
106
+ }
107
+
108
+ .viewport .toast[data-ending-style][data-front]:not([data-swipe-direction]) {
109
+ --toast-y: translateY(calc(var(--toast-lift) * -100%));
110
+ }
111
+
112
+ .viewport .toast[data-ending-style][data-expanded]:not([data-front]):not([data-swipe-direction]),
113
+ .viewport[data-expand] .toast[data-ending-style]:not([data-front]):not([data-swipe-direction]) {
114
+ --toast-y: translateY(calc(var(--toast-lift) * (var(--toast-offset-y) + var(--toast-index) * var(--toast-gap)) + var(--toast-lift) * -100%));
115
+ }
116
+
117
+ .viewport:not([data-expand]) .toast[data-ending-style]:not([data-expanded]):not([data-front]):not([data-swipe-direction]) {
118
+ --toast-y: translateY(40%);
119
+ transition: transform 500ms, opacity 200ms;
120
+ }
121
+
122
+ .toast[data-swiping] {
123
+ transition: none;
124
+ }
125
+
126
+ .toast[data-swiping]::before {
127
+ content: '';
128
+ position: absolute;
129
+ left: -100%;
130
+ right: -100%;
131
+ z-index: -1;
132
+ height: 100%;
133
+ top: 50%;
134
+ transform: scaleY(3) translateY(-50%);
135
+ }
136
+
137
+ .viewport[data-position^='top'] .toast[data-swiping]::before {
138
+ top: auto;
139
+ bottom: 50%;
140
+ transform: scaleY(3) translateY(50%);
141
+ }
142
+
143
+ .toast[data-ending-style]:not([data-swiping])::before {
144
+ content: '';
145
+ position: absolute;
146
+ inset: 0;
147
+ transform: scaleY(2);
148
+ }
149
+
150
+ .toast[data-ending-style][data-swipe-direction] {
151
+ animation-duration: 200ms;
152
+ animation-timing-function: ease-out;
153
+ animation-fill-mode: forwards;
154
+ }
155
+
156
+ .toast[data-ending-style][data-swipe-direction='left'] { animation-name: swipe-out-left; }
157
+ .toast[data-ending-style][data-swipe-direction='right'] { animation-name: swipe-out-right; }
158
+ .toast[data-ending-style][data-swipe-direction='up'] { animation-name: swipe-out-up; }
159
+ .toast[data-ending-style][data-swipe-direction='down'] { animation-name: swipe-out-down; }
160
+
161
+ @media (max-width: 600px) {
162
+ .viewport {
163
+ right: var(--toast-inset);
164
+ left: var(--toast-inset);
165
+ width: 100%;
166
+ }
167
+
168
+ .viewport .toast {
169
+ right: 0;
170
+ left: 0;
171
+ width: calc(100% - var(--toast-inset) * 2);
172
+ }
173
+
174
+ .viewport[data-position$='center'] {
175
+ left: var(--toast-inset);
176
+ right: var(--toast-inset);
177
+ transform: none;
178
+ }
179
+ }
180
+
181
+ @media (prefers-reduced-motion) {
182
+ .toast,
183
+ .toast > * {
184
+ transition: none !important;
185
+ animation: none !important;
186
+ }
187
+ }
package/toast/index.tsx CHANGED
@@ -1,65 +1,160 @@
1
1
  'use client'
2
2
 
3
- import type { ComponentProps } from 'react'
4
- import { Toaster as SonnerToaster } from 'sonner'
3
+ import { Toast as ToastPrimitive, type ToastManager, type ToastManagerAddOptions } from '@base-ui/react/toast'
4
+ import type { MouseEvent, ReactNode } from 'react'
5
5
 
6
+ import { button } from '../button'
6
7
  import { tv } from '../tv'
7
8
 
8
- const toaster = tv({ base: 'z-toast' })
9
+ import s from './index.module.css'
9
10
 
10
- export { toast } from 'sonner'
11
+ export type ToastPosition = 'top-left' | 'top-center' | 'top-right'
12
+ | 'bottom-left' | 'bottom-center' | 'bottom-right'
11
13
 
12
- export interface ToastProps
13
- extends Omit<ComponentProps<typeof SonnerToaster>, 'id' | 'position' | 'duration' | 'expand'> {
14
- toasterId?: string
15
- position?: 'top-left' | 'top-center' | 'top-right'
16
- | 'bottom-left' | 'bottom-center' | 'bottom-right'
17
- duration?: number
14
+ export type ToastOptions = Omit<ToastManagerAddOptions<object>, 'title' | 'type' | 'actionProps' | 'transitionStatus'> & {
15
+ action?: {
16
+ label: ReactNode
17
+ onClick: (event: MouseEvent<HTMLButtonElement>) => void
18
+ }
19
+ }
20
+
21
+ type Kind = 'success' | 'error' | 'warning' | 'info'
22
+
23
+ export function createToast(manager: ToastManager = ToastPrimitive.createToastManager()) {
24
+ const show = (type?: Kind) => (message: ReactNode, { action, ...options }: ToastOptions = {}) => {
25
+ const id: string = manager.add({
26
+ ...options,
27
+ type,
28
+ title: message,
29
+ actionProps: action && {
30
+ children: action.label,
31
+ onClick: event => {
32
+ action.onClick(event)
33
+ if (!event.defaultPrevented) manager.close(id)
34
+ },
35
+ },
36
+ })
37
+ return id
38
+ }
39
+
40
+ return Object.assign(show(), {
41
+ success: show('success'),
42
+ error: show('error'),
43
+ warning: show('warning'),
44
+ info: show('info'),
45
+ dismiss: (id?: string) => manager.close(id),
46
+ manager,
47
+ })
48
+ }
49
+
50
+ export const toast = createToast()
51
+
52
+ const toastViewport = tv({ base: 'z-toast' })
53
+
54
+ const toastRoot = tv({
55
+ base: 'float rounded-r8 flex w-full items-center gap-stack p-4',
56
+ variants: {
57
+ type: {
58
+ success: 'text-fg-success',
59
+ error: 'text-fg-danger',
60
+ warning: 'text-fg-warning',
61
+ info: 'text-fg-info',
62
+ },
63
+ },
64
+ })
65
+
66
+ const toastContent = tv({ base: 'min-w-0 flex-1' })
67
+
68
+ const toastTitle = tv({ base: 'text-label text-fg' })
69
+
70
+ const toastDescription = tv({ base: 'text-caption text-fg-muted' })
71
+
72
+ const icons: Record<Kind, ReactNode> = {
73
+ success: (
74
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20" aria-hidden="true" className="size-5">
75
+ <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
76
+ </svg>
77
+ ),
78
+ warning: (
79
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" height="20" width="20" aria-hidden="true" className="size-5">
80
+ <path fillRule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clipRule="evenodd" />
81
+ </svg>
82
+ ),
83
+ info: (
84
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20" aria-hidden="true" className="size-5">
85
+ <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
86
+ </svg>
87
+ ),
88
+ error: (
89
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20" aria-hidden="true" className="size-5">
90
+ <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
91
+ </svg>
92
+ ),
93
+ }
94
+
95
+ type Swipe = 'up' | 'down' | 'left' | 'right'
96
+
97
+ function swipeOf(position: ToastPosition): Swipe[] {
98
+ const [y, x] = position.split('-')
99
+ const out: Swipe[] = [y === 'top' ? 'up' : 'down']
100
+ if (x === 'left' || x === 'right') out.push(x)
101
+ return out
102
+ }
103
+
104
+ function ToastList({ position }: { position: ToastPosition }) {
105
+ const { toasts } = ToastPrimitive.useToastManager()
106
+ const swipe = swipeOf(position)
107
+
108
+ return toasts.map((item, index) => {
109
+ const icon = item.type ? icons[item.type as Kind] : undefined
110
+
111
+ return (
112
+ <ToastPrimitive.Root
113
+ key={item.id}
114
+ toast={item}
115
+ swipeDirection={swipe}
116
+ data-front={index === 0 ? '' : undefined}
117
+ className={toastRoot({ type: item.type as Kind | undefined, className: s.toast })}
118
+ >
119
+ {icon && <div className="shrink-0">{icon}</div>}
120
+ <ToastPrimitive.Content className={toastContent()}>
121
+ <ToastPrimitive.Title className={toastTitle()} />
122
+ <ToastPrimitive.Description className={toastDescription()} />
123
+ </ToastPrimitive.Content>
124
+ <ToastPrimitive.Action className={button({ color: 'neutral', emphasis: 'weak', size: 'sm' })} />
125
+ </ToastPrimitive.Root>
126
+ )
127
+ })
128
+ }
129
+
130
+ export type ToastProps = Omit<ToastPrimitive.Provider.Props, 'children'> & {
131
+ position?: ToastPosition
18
132
  expand?: boolean
133
+ className?: string
134
+ 'aria-label'?: string
19
135
  }
20
136
 
21
137
  export function Toast({
22
- toasterId,
138
+ toastManager = toast.manager,
139
+ timeout = 4000,
23
140
  position = 'bottom-center',
24
- duration = 4000,
25
141
  expand = false,
26
- offset = 16,
27
- gap = 8,
28
142
  className,
29
- toastOptions,
143
+ 'aria-label': label,
30
144
  ...rest
31
145
  }: ToastProps) {
32
146
  return (
33
- <SonnerToaster
34
- {...rest}
35
- id={toasterId}
36
- position={position}
37
- duration={duration}
38
- expand={expand}
39
- offset={offset}
40
- gap={gap}
41
- className={toaster({ className })}
42
- toastOptions={{
43
- unstyled: true,
44
- ...toastOptions,
45
- classNames: {
46
- toast: 'float rounded-r8'
47
- + ' flex w-full items-center gap-stack p-4',
48
- content: 'min-w-0 flex-1',
49
- title: 'text-label text-fg',
50
- description: 'text-caption text-fg-muted',
51
- icon: 'shrink-0',
52
- actionButton: 'text-label text-fg shrink-0 rounded-r8 px-3 h-8'
53
- + ' bg-bg-well transition-state press press-shrink enabled:hover:bg-bg-neutral-solid/10'
54
- + ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus-ring'
55
- + ' disabled:text-fg-disabled disabled:cursor-not-allowed',
56
- success: 'text-fg-success',
57
- error: 'text-fg-danger',
58
- warning: 'text-fg-warning',
59
- info: 'text-fg-info',
60
- ...toastOptions?.classNames,
61
- },
62
- }}
63
- />
147
+ <ToastPrimitive.Provider {...rest} toastManager={toastManager} timeout={timeout}>
148
+ <ToastPrimitive.Portal>
149
+ <ToastPrimitive.Viewport
150
+ {...(label && { 'aria-label': label })}
151
+ data-position={position}
152
+ data-expand={expand ? '' : undefined}
153
+ className={toastViewport({ className: [s.viewport, className] })}
154
+ >
155
+ <ToastList position={position} />
156
+ </ToastPrimitive.Viewport>
157
+ </ToastPrimitive.Portal>
158
+ </ToastPrimitive.Provider>
64
159
  )
65
160
  }
package/toggle/index.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { Toggle as TogglePrimitive } from 'radix-ui'
3
+ import { Toggle as TogglePrimitive } from '@base-ui/react/toggle'
4
4
  import type { ComponentProps } from 'react'
5
5
 
6
6
  import { tv } from '../tv'
@@ -28,10 +28,11 @@ export const toggle = tv({
28
28
  defaultVariants: { surface: 'bare' },
29
29
  })
30
30
 
31
- export interface ToggleProps extends ComponentProps<typeof TogglePrimitive.Root> {
31
+ export type ToggleProps = Omit<ComponentProps<typeof TogglePrimitive>, 'className'> & {
32
32
  surface?: 'bare' | 'well' | 'panel'
33
+ className?: string
33
34
  }
34
35
 
35
36
  export function Toggle({ surface, className, ...rest }: ToggleProps) {
36
- return <TogglePrimitive.Root {...rest} className={toggle({ surface, className })} />
37
+ return <TogglePrimitive {...rest} className={toggle({ surface, className })} />
37
38
  }
package/tokens.css CHANGED
@@ -15,6 +15,8 @@
15
15
  --pds-bg-neutral-disabled: var(--pds-gray-200);
16
16
  --pds-bg-neutral-hover: rgba(0,23,51,0.02);
17
17
  --pds-bg-neutral-knob: #ffffff;
18
+ --pds-bg-neutral-knob-off: #ffffff;
19
+ --pds-bg-neutral-track: var(--pds-gray-300);
18
20
  --pds-bg-neutral-selected: rgba(2,32,71,0.05);
19
21
  --pds-bg-neutral-selected-hover: rgba(0,27,55,0.1);
20
22
  --pds-bg-neutral-solid: var(--pds-gray-900);
@@ -64,7 +66,7 @@
64
66
  --pds-bg: #17171c;
65
67
  --pds-bg-overlay: rgba(0,0,0,0.56);
66
68
  --pds-bg-panel: #202027;
67
- --pds-bg-well: #101013;
69
+ --pds-bg-well: #2c2c35;
68
70
 
69
71
  --pds-bg-brand-solid: var(--pds-brand-500);
70
72
  --pds-bg-brand-solid-hover: var(--pds-brand-600);
@@ -77,6 +79,8 @@
77
79
  --pds-bg-neutral-disabled: var(--pds-gray-200);
78
80
  --pds-bg-neutral-hover: rgba(209,209,253,0.05);
79
81
  --pds-bg-neutral-knob: #2c2c35;
82
+ --pds-bg-neutral-knob-off: var(--pds-gray-800);
83
+ --pds-bg-neutral-track: var(--pds-gray-300);
80
84
  --pds-bg-neutral-selected: rgba(217,217,255,0.11);
81
85
  --pds-bg-neutral-selected-hover: rgba(222,222,255,0.19);
82
86
  --pds-bg-neutral-solid: var(--pds-gray-900);
@@ -138,6 +142,8 @@
138
142
  --color-bg-neutral-disabled: var(--pds-bg-neutral-disabled);
139
143
  --color-bg-neutral-hover: var(--pds-bg-neutral-hover);
140
144
  --color-bg-neutral-knob: var(--pds-bg-neutral-knob);
145
+ --color-bg-neutral-knob-off: var(--pds-bg-neutral-knob-off);
146
+ --color-bg-neutral-track: var(--pds-bg-neutral-track);
141
147
  --color-bg-neutral-selected: var(--pds-bg-neutral-selected);
142
148
  --color-bg-neutral-selected-hover: var(--pds-bg-neutral-selected-hover);
143
149
  --color-bg-neutral-solid: var(--pds-bg-neutral-solid);
package/tooltip/index.tsx CHANGED
@@ -1,35 +1,55 @@
1
1
  'use client'
2
2
 
3
- import { Tooltip as TooltipPrimitive } from 'radix-ui'
4
- import type { ComponentProps, ReactNode } from 'react'
3
+ import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
4
+ import { createContext, useContext, useId, useState } from 'react'
5
+ import type { ComponentProps } from 'react'
5
6
 
6
7
  import { tv } from '../tv'
7
8
 
8
- export interface TooltipProps {
9
- open?: boolean
10
- defaultOpen?: boolean
11
- onOpenChange?: (open: boolean) => void
12
- delay?: number
13
- children?: ReactNode
9
+ const TooltipContext = createContext<{ id?: string, open: boolean }>({ open: false })
10
+ const ProviderContext = createContext(false)
11
+
12
+ export type TooltipProviderProps = ComponentProps<typeof TooltipPrimitive.Provider>
13
+
14
+ export function TooltipProvider({ delay = 100, ...rest }: TooltipProviderProps) {
15
+ return (
16
+ <ProviderContext.Provider value>
17
+ <TooltipPrimitive.Provider {...rest} delay={delay} />
18
+ </ProviderContext.Provider>
19
+ )
14
20
  }
15
21
 
16
- export function Tooltip({ open, defaultOpen = false, onOpenChange, delay = 100, children }: TooltipProps) {
22
+ export type TooltipProps = ComponentProps<typeof TooltipPrimitive.Root>
23
+
24
+ export function Tooltip({ open, defaultOpen = false, onOpenChange, ...rest }: TooltipProps) {
25
+ const id = useId()
26
+ const [seen, setSeen] = useState(defaultOpen)
27
+ const isOpen = open ?? seen
28
+
29
+ const change: TooltipProps['onOpenChange'] = (next, eventDetails) => {
30
+ setSeen(next)
31
+ onOpenChange?.(next, eventDetails)
32
+ }
33
+
17
34
  return (
18
- <TooltipPrimitive.Provider delayDuration={delay}>
19
- <TooltipPrimitive.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
20
- {children}
21
- </TooltipPrimitive.Root>
22
- </TooltipPrimitive.Provider>
35
+ <TooltipContext.Provider value={{ id, open: isOpen }}>
36
+ <TooltipPrimitive.Root {...rest} open={isOpen} onOpenChange={change} />
37
+ </TooltipContext.Provider>
23
38
  )
24
39
  }
25
40
 
26
- export function TooltipTrigger({ children }: { children: ReactNode }) {
27
- return <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
41
+ export type TooltipTriggerProps = ComponentProps<typeof TooltipPrimitive.Trigger>
42
+
43
+ export function TooltipTrigger(props: TooltipTriggerProps) {
44
+ const { id, open } = useContext(TooltipContext)
45
+ const inProvider = useContext(ProviderContext)
46
+ return <TooltipPrimitive.Trigger delay={inProvider ? undefined : 100} {...props} aria-describedby={open ? id : undefined} />
28
47
  }
29
48
 
30
- export interface TooltipContentProps extends Omit<ComponentProps<typeof TooltipPrimitive.Content>, 'side'> {
49
+ export type TooltipContentProps = Omit<ComponentProps<typeof TooltipPrimitive.Popup>, 'className'> & {
31
50
  side?: 'top' | 'right' | 'bottom' | 'left'
32
51
  arrow?: boolean
52
+ className?: string
33
53
  }
34
54
 
35
55
  const tooltipContent = tv({
@@ -38,6 +58,14 @@ const tooltipContent = tv({
38
58
  + ' open:animate-enter closed:animate-exit',
39
59
  })
40
60
 
61
+ const tooltipArrow = tv({
62
+ base: 'data-uncentered:invisible'
63
+ + ' side-top:top-full'
64
+ + ' side-bottom:bottom-full side-bottom:rotate-180'
65
+ + ' side-right:left-0 side-right:origin-top-left side-right:rotate-90 side-right:-translate-y-1/2'
66
+ + ' side-left:right-0 side-left:origin-top-right side-left:-rotate-90 side-left:-translate-y-1/2',
67
+ })
68
+
41
69
  export function TooltipContent({
42
70
  side = 'top',
43
71
  arrow = true,
@@ -45,23 +73,26 @@ export function TooltipContent({
45
73
  children,
46
74
  ...rest
47
75
  }: TooltipContentProps) {
76
+ const { id } = useContext(TooltipContext)
77
+
48
78
  return (
49
79
  <TooltipPrimitive.Portal>
50
- <TooltipPrimitive.Content
51
- {...rest}
80
+ <TooltipPrimitive.Positioner
52
81
  side={side}
53
- sideOffset={6}
54
- className={tooltipContent({ className })}
82
+ sideOffset={arrow ? 11 : 6}
83
+ className="z-overlay min-w-max will-change-transform"
55
84
  >
56
- {children}
57
- {arrow && (
58
- <TooltipPrimitive.Arrow asChild width={10} height={5}>
59
- <svg viewBox="0 0 12 6" preserveAspectRatio="none" className="fill-bg-panel -translate-y-px">
60
- <path d="M0 0L4.58579 4.58579C5.36683 5.36683 6.63316 5.36684 7.41421 4.58579L12 0" className="stroke-line stroke-1" />
61
- </svg>
62
- </TooltipPrimitive.Arrow>
63
- )}
64
- </TooltipPrimitive.Content>
85
+ <TooltipPrimitive.Popup {...rest} id={id} role="tooltip" className={tooltipContent({ className })}>
86
+ {children}
87
+ {arrow && (
88
+ <TooltipPrimitive.Arrow className={tooltipArrow()}>
89
+ <svg width={10} height={5} viewBox="0 0 12 6" preserveAspectRatio="none" className="block fill-bg-panel -translate-y-px">
90
+ <path d="M0 0L4.58579 4.58579C5.36683 5.36683 6.63316 5.36684 7.41421 4.58579L12 0" className="stroke-line stroke-1" />
91
+ </svg>
92
+ </TooltipPrimitive.Arrow>
93
+ )}
94
+ </TooltipPrimitive.Popup>
95
+ </TooltipPrimitive.Positioner>
65
96
  </TooltipPrimitive.Portal>
66
97
  )
67
98
  }
package/utilities.css CHANGED
@@ -10,10 +10,14 @@
10
10
 
11
11
  @custom-variant described (&:has([data-description]));
12
12
 
13
- @custom-variant checked (&:where([data-state="checked"], [data-state="indeterminate"]));
14
- @custom-variant open (&[data-state="open"]);
15
- @custom-variant closed (&[data-state="closed"]);
16
- @custom-variant on (&[data-state="on"]);
13
+ @custom-variant checked (&:where([data-checked], [data-indeterminate]));
14
+ @custom-variant open (&[data-open]);
15
+ @custom-variant closed (&[data-closed]);
16
+ @custom-variant on (&[aria-pressed="true"]);
17
+ @custom-variant side-top (&[data-side="top"]);
18
+ @custom-variant side-right (&[data-side="right"]);
19
+ @custom-variant side-bottom (&[data-side="bottom"]);
20
+ @custom-variant side-left (&[data-side="left"]);
17
21
  @custom-variant selected (&[data-selected]:not([data-selected="false"]));
18
22
  @custom-variant unset (&[data-placeholder]);
19
23
 
@@ -24,6 +28,7 @@
24
28
  .dark { color-scheme: dark; }
25
29
 
26
30
  body {
31
+ position: relative;
27
32
  background-color: var(--color-bg);
28
33
  color: var(--color-fg);
29
34
  font-family: var(--font-sans);
@@ -104,7 +109,7 @@
104
109
  }
105
110
 
106
111
  &:active:not(:disabled):not([aria-disabled="true"])::after,
107
- &[data-pressed]:not(:disabled):not([aria-disabled="true"])::after {
112
+ &[data-pressed]:not([data-popup-open]):not([aria-pressed]):not(:disabled):not([aria-disabled="true"])::after {
108
113
  opacity: 1;
109
114
  transition-duration: 0s;
110
115
  }
@@ -112,7 +117,7 @@
112
117
 
113
118
  @utility press-shrink {
114
119
  &:active:not(:disabled):not([aria-disabled="true"]),
115
- &[data-pressed]:not(:disabled):not([aria-disabled="true"]) {
120
+ &[data-pressed]:not([data-popup-open]):not([aria-pressed]):not(:disabled):not([aria-disabled="true"]) {
116
121
  scale: var(--scale-press);
117
122
  transition-duration: 0s;
118
123
  }
@@ -1,60 +0,0 @@
1
- # Accordion
2
-
3
- ```
4
- 받기 npx @pantoia/ui add accordion
5
- 층 ui
6
- 파일 accordion/index.tsx, accordion/index.module.css, accordion/AGENTS.md
7
- ```
8
-
9
- ## 부위
10
-
11
- | 이름 | props |
12
- |---|---|
13
- | `Accordion` | `AccordionProps` |
14
- | `AccordionItem` | - |
15
- | `AccordionTrigger` | - |
16
- | `AccordionContent` | - |
17
-
18
- ### `AccordionItem`
19
-
20
- | 이름 | 타입 | 필수 |
21
- |---|---|---|
22
- | `value` | `string` | 예 |
23
- | `disabled` | `boolean` | |
24
- | `className` | `string` | |
25
- | `children` | `ReactNode` | |
26
-
27
- ### `AccordionTrigger`
28
-
29
- | 이름 | 타입 | 필수 |
30
- |---|---|---|
31
- | `className` | `string` | |
32
- | `children` | `ReactNode` | |
33
-
34
- ### `AccordionContent`
35
-
36
- | 이름 | 타입 | 필수 |
37
- |---|---|---|
38
- | `className` | `string` | |
39
- | `children` | `ReactNode` | |
40
-
41
- ## props
42
-
43
- ### `AccordionProps`
44
-
45
- | 이름 | 타입 | 필수 |
46
- |---|---|---|
47
- | `value` | `string \| string[]` | |
48
- | `defaultValue` | `string \| string[]` | |
49
- | `onValueChange` | `(value: string & string[]) => void` | |
50
- | `multiple` | `boolean` | |
51
- | `className` | `string` | |
52
- | `children` | `ReactNode` | |
53
-
54
- ## 함께 받는 것
55
-
56
- `tv`
57
-
58
- ## npm 의존
59
-
60
- `radix-ui`