@duro-app/ui 4.1.0 → 4.3.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 (47) hide show
  1. package/dist/{Table-Bj5u2Q-k.js → Table-JTgcR_Fh.js} +241 -226
  2. package/dist/Table-JTgcR_Fh.js.map +1 -0
  3. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
  4. package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -1
  5. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  6. package/dist/components/Menu/Menu.d.ts +2 -0
  7. package/dist/components/Menu/Menu.d.ts.map +1 -1
  8. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  9. package/dist/components/Menu/Menu.stories.d.ts +1 -0
  10. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  11. package/dist/components/Menu/styles.css.d.ts +8 -0
  12. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  13. package/dist/components/Popover/Popover.d.ts +41 -0
  14. package/dist/components/Popover/Popover.d.ts.map +1 -0
  15. package/dist/components/Popover/Popover.meta.d.ts +3 -0
  16. package/dist/components/Popover/Popover.meta.d.ts.map +1 -0
  17. package/dist/components/Popover/Popover.stories.d.ts +11 -0
  18. package/dist/components/Popover/Popover.stories.d.ts.map +1 -0
  19. package/dist/components/Popover/outside.d.ts +2 -0
  20. package/dist/components/Popover/outside.d.ts.map +1 -0
  21. package/dist/components/Popover/position.d.ts +27 -0
  22. package/dist/components/Popover/position.d.ts.map +1 -0
  23. package/dist/components/Popover/styles.css.d.ts +61 -0
  24. package/dist/components/Popover/styles.css.d.ts.map +1 -0
  25. package/dist/index.css +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1645 -1381
  29. package/dist/index.js.map +1 -1
  30. package/dist/table.js +1 -1
  31. package/package.json +2 -2
  32. package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
  33. package/src/components/Dialog/Dialog.meta.ts +6 -0
  34. package/src/components/Drawer/Drawer.meta.ts +6 -0
  35. package/src/components/Menu/Menu.meta.ts +7 -2
  36. package/src/components/Menu/Menu.stories.tsx +26 -0
  37. package/src/components/Menu/Menu.tsx +9 -0
  38. package/src/components/Menu/styles.css.ts +8 -0
  39. package/src/components/Popover/Popover.meta.ts +69 -0
  40. package/src/components/Popover/Popover.stories.tsx +250 -0
  41. package/src/components/Popover/Popover.tsx +293 -0
  42. package/src/components/Popover/outside.ts +9 -0
  43. package/src/components/Popover/position.ts +48 -0
  44. package/src/components/Popover/styles.css.ts +75 -0
  45. package/src/components/Tooltip/Tooltip.meta.ts +1 -1
  46. package/src/index.ts +2 -0
  47. package/dist/Table-Bj5u2Q-k.js.map +0 -1
@@ -0,0 +1,293 @@
1
+ import {
2
+ type ReactNode,
3
+ type Ref,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useId,
9
+ useImperativeHandle,
10
+ useLayoutEffect,
11
+ useRef,
12
+ useState,
13
+ } from 'react'
14
+ import {createPortal} from 'react-dom'
15
+ import {html} from 'react-strict-dom'
16
+ import {usePortalMount} from '../ThemeProvider/ThemeProvider'
17
+ import {styles} from './styles.css'
18
+ import {computePopoverPosition, type PopoverAlign, type PopoverSide} from './position'
19
+ import {isOutsidePress} from './outside'
20
+
21
+ // --- Context ---
22
+
23
+ type IgnoreEntry = Element | null | (() => Element | null)
24
+
25
+ interface PopoverContextValue {
26
+ open: boolean
27
+ setOpen: (open: boolean) => void
28
+ popoverId: string
29
+ side: PopoverSide
30
+ align: PopoverAlign
31
+ anchor?: () => DOMRect
32
+ triggerRef: React.RefObject<HTMLButtonElement | null>
33
+ popupRef: React.RefObject<HTMLDivElement | null>
34
+ repositionRef: React.RefObject<(() => void) | null>
35
+ }
36
+
37
+ const PopoverContext = createContext<PopoverContextValue | null>(null)
38
+
39
+ function usePopover(part: string) {
40
+ const ctx = useContext(PopoverContext)
41
+ if (!ctx) throw new Error(`Popover.${part} must be used within Popover.Root`)
42
+ return ctx
43
+ }
44
+
45
+ const FOCUSABLE =
46
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
47
+
48
+ const OFFSET = 4 // `xs` token
49
+
50
+ /**
51
+ * Whether a nested popup control owns this Escape: a Select, Menu or
52
+ * Combobox inside the popup closes on Esc without preventing default, and
53
+ * that Esc must close only it. Only controls that open a popup count — an
54
+ * expanded trigger that declares one (`aria-haspopup`), an expanded combobox,
55
+ * or focus inside a listbox or menu — never a plain disclosure, and never
56
+ * this popover's own Trigger.
57
+ *
58
+ * holds-until: Duro's Select, Menu and Combobox call preventDefault on the
59
+ * Escape they consume; the defaultPrevented check alone then suffices and
60
+ * this selector goes.
61
+ */
62
+ const NESTED_POPUP_OWNER =
63
+ '[aria-expanded="true"][aria-haspopup], [role="combobox"][aria-expanded="true"], [role="listbox"], [role="menu"]'
64
+
65
+ function nestedOwnsEscape(target: EventTarget | null, ownTrigger: Element | null): boolean {
66
+ if (!(target instanceof Element)) return false
67
+ const owner = target.closest(NESTED_POPUP_OWNER)
68
+ return owner !== null && owner !== ownTrigger
69
+ }
70
+
71
+ // --- Root ---
72
+
73
+ export interface PopoverHandle {
74
+ /** Re-measure the anchor and re-place the popup. */
75
+ reposition: () => void
76
+ }
77
+
78
+ interface RootProps {
79
+ children: ReactNode
80
+ open?: boolean
81
+ defaultOpen?: boolean
82
+ onOpenChange?: (open: boolean) => void
83
+ side?: PopoverSide
84
+ align?: PopoverAlign
85
+ /** Virtual anchor: a viewport rect. Replaces the Trigger as the anchor. */
86
+ anchor?: () => DOMRect
87
+ /** Elements (or getters) whose pointerdown does not count as an outside press. */
88
+ ignore?: ReadonlyArray<IgnoreEntry>
89
+ ref?: Ref<PopoverHandle>
90
+ }
91
+
92
+ function Root({
93
+ children,
94
+ open: openProp,
95
+ defaultOpen = false,
96
+ onOpenChange,
97
+ side = 'bottom',
98
+ align = 'start',
99
+ anchor,
100
+ ignore,
101
+ ref,
102
+ }: RootProps) {
103
+ const [inner, setInner] = useState(defaultOpen)
104
+ const controlled = openProp !== undefined
105
+ const open = controlled ? openProp : inner
106
+ const popoverId = useId()
107
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
108
+ const popupRef = useRef<HTMLDivElement | null>(null)
109
+ const repositionRef = useRef<(() => void) | null>(null)
110
+ const previousFocusRef = useRef<HTMLElement | null>(null)
111
+ const ignoreRef = useRef(ignore)
112
+ ignoreRef.current = ignore
113
+ const openRef = useRef(open)
114
+ openRef.current = open
115
+
116
+ const setOpen = useCallback(
117
+ (next: boolean) => {
118
+ if (!controlled) setInner(next)
119
+ if (next !== openRef.current) onOpenChange?.(next)
120
+ },
121
+ [controlled, onOpenChange],
122
+ )
123
+
124
+ useImperativeHandle(ref, () => ({reposition: () => repositionRef.current?.()}), [])
125
+
126
+ // Focus: into the popup on open, back to the trigger (or the previously
127
+ // focused element when there is no trigger) on close.
128
+ const wasOpenRef = useRef(false)
129
+ useEffect(() => {
130
+ if (open && !wasOpenRef.current) {
131
+ previousFocusRef.current = document.activeElement as HTMLElement | null
132
+ const popup = popupRef.current
133
+ if (popup) {
134
+ const first = popup.querySelector<HTMLElement>(FOCUSABLE)
135
+ ;(first ?? popup).focus({preventScroll: true})
136
+ }
137
+ } else if (!open && wasOpenRef.current) {
138
+ const target = triggerRef.current ?? previousFocusRef.current
139
+ target?.focus?.()
140
+ previousFocusRef.current = null
141
+ }
142
+ wasOpenRef.current = open
143
+ }, [open])
144
+
145
+ // Esc and outside press.
146
+ useEffect(() => {
147
+ if (!open) return
148
+
149
+ function onKeyDown(e: KeyboardEvent) {
150
+ if (e.key !== 'Escape' || e.defaultPrevented) return
151
+ if (nestedOwnsEscape(e.target, triggerRef.current)) return
152
+ e.preventDefault()
153
+ setOpen(false)
154
+ }
155
+
156
+ function onPointerDown(e: PointerEvent) {
157
+ const ignored = (ignoreRef.current ?? []).map((entry) =>
158
+ typeof entry === 'function' ? entry() : entry,
159
+ )
160
+ const inside = [popupRef.current, triggerRef.current, ...ignored]
161
+ if (isOutsidePress(e.target as Node | null, inside)) setOpen(false)
162
+ }
163
+
164
+ document.addEventListener('keydown', onKeyDown)
165
+ document.addEventListener('pointerdown', onPointerDown, true)
166
+ return () => {
167
+ document.removeEventListener('keydown', onKeyDown)
168
+ document.removeEventListener('pointerdown', onPointerDown, true)
169
+ }
170
+ }, [open, setOpen])
171
+
172
+ const ctx: PopoverContextValue = {
173
+ open,
174
+ setOpen,
175
+ popoverId,
176
+ side,
177
+ align,
178
+ anchor,
179
+ triggerRef,
180
+ popupRef,
181
+ repositionRef,
182
+ }
183
+
184
+ return <PopoverContext.Provider value={ctx}>{children}</PopoverContext.Provider>
185
+ }
186
+
187
+ // --- Trigger ---
188
+ // Trigger IS the button (like Menu.Trigger): give it a label, not a Button.
189
+
190
+ function Trigger({children}: {children: ReactNode}) {
191
+ const {open, setOpen, popoverId, triggerRef} = usePopover('Trigger')
192
+
193
+ return (
194
+ <html.button
195
+ ref={triggerRef}
196
+ type="button"
197
+ onClick={() => setOpen(!open)}
198
+ aria-haspopup="dialog"
199
+ aria-expanded={open}
200
+ aria-controls={open ? popoverId : undefined}
201
+ style={styles.trigger}
202
+ >
203
+ {children}
204
+ </html.button>
205
+ )
206
+ }
207
+
208
+ // --- Popup ---
209
+
210
+ interface PopupProps {
211
+ children: ReactNode
212
+ /** Accessible name for the dialog. */
213
+ label: string
214
+ }
215
+
216
+ function Popup({children, label}: PopupProps) {
217
+ const {open, popoverId, side, align, anchor, triggerRef, popupRef, repositionRef} =
218
+ usePopover('Popup')
219
+ const [coords, setCoords] = useState({top: 0, left: 0})
220
+ // Inside ThemeProvider's portal layer, like Select/Dialog/Drawer: theme
221
+ // tokens cascade into the popup and it stacks above dialogs.
222
+ const mount = usePortalMount()
223
+
224
+ // Measure before paint on open; re-measure on scroll/resize (capture phase
225
+ // catches any scrolling ancestor) and when Root.reposition() is called.
226
+ useLayoutEffect(() => {
227
+ if (!open) {
228
+ repositionRef.current = null
229
+ return
230
+ }
231
+ const update = () => {
232
+ const popup = popupRef.current
233
+ if (!popup) return
234
+ const rect = anchor ? anchor() : triggerRef.current?.getBoundingClientRect()
235
+ if (!rect) return
236
+ const next = computePopoverPosition({
237
+ anchor: {top: rect.top, left: rect.left, width: rect.width, height: rect.height},
238
+ popup: {width: popup.offsetWidth, height: popup.offsetHeight},
239
+ viewport: {width: window.innerWidth, height: window.innerHeight},
240
+ side,
241
+ align,
242
+ offset: OFFSET,
243
+ })
244
+ setCoords((prev) =>
245
+ prev.top === next.top && prev.left === next.left ? prev : {top: next.top, left: next.left},
246
+ )
247
+ }
248
+ repositionRef.current = update
249
+ update()
250
+ window.addEventListener('resize', update)
251
+ window.addEventListener('scroll', update, true)
252
+ return () => {
253
+ repositionRef.current = null
254
+ window.removeEventListener('resize', update)
255
+ window.removeEventListener('scroll', update, true)
256
+ }
257
+ }, [open, side, align, anchor, triggerRef, popupRef, repositionRef])
258
+
259
+ if (!open || typeof document === 'undefined') return null
260
+
261
+ return createPortal(
262
+ <html.div
263
+ ref={popupRef}
264
+ id={popoverId}
265
+ role="dialog"
266
+ aria-label={label}
267
+ tabIndex={-1}
268
+ style={[styles.popup, styles.popupPosition(coords.top, coords.left)]}
269
+ >
270
+ {children}
271
+ </html.div>,
272
+ mount ?? document.body,
273
+ )
274
+ }
275
+
276
+ // --- Close ---
277
+
278
+ function Close({children}: {children: ReactNode}) {
279
+ const {setOpen} = usePopover('Close')
280
+
281
+ return (
282
+ <html.button type="button" onClick={() => setOpen(false)} style={styles.close}>
283
+ {children}
284
+ </html.button>
285
+ )
286
+ }
287
+
288
+ export const Popover = {
289
+ Root,
290
+ Trigger,
291
+ Popup,
292
+ Close,
293
+ }
@@ -0,0 +1,9 @@
1
+ // True when a pointerdown target lies outside the popup, the trigger and every
2
+ // ignored element. `inside` may hold nulls (refs not yet attached).
3
+ export function isOutsidePress(
4
+ target: Node | null,
5
+ inside: ReadonlyArray<Element | null>,
6
+ ): boolean {
7
+ if (!target) return true
8
+ return !inside.some((el) => el != null && el.contains(target))
9
+ }
@@ -0,0 +1,48 @@
1
+ export interface PopoverRect {
2
+ top: number
3
+ left: number
4
+ width: number
5
+ height: number
6
+ }
7
+
8
+ export interface PopoverSize {
9
+ width: number
10
+ height: number
11
+ }
12
+
13
+ export type PopoverSide = 'top' | 'bottom'
14
+ export type PopoverAlign = 'start' | 'center' | 'end'
15
+
16
+ interface Input {
17
+ anchor: PopoverRect
18
+ popup: PopoverSize
19
+ viewport: PopoverSize
20
+ side: PopoverSide
21
+ align: PopoverAlign
22
+ offset: number
23
+ }
24
+
25
+ // Pure placement: puts the popup on the requested side of the anchor, flips to
26
+ // the opposite side when it would overflow the viewport there (and the other
27
+ // side fits), and clamps horizontally into the viewport.
28
+ export function computePopoverPosition({anchor, popup, viewport, side, align, offset}: Input) {
29
+ const below = anchor.top + anchor.height + offset
30
+ const above = anchor.top - offset - popup.height
31
+ const fitsBelow = below + popup.height <= viewport.height
32
+ const fitsAbove = above >= 0
33
+
34
+ let resolved: PopoverSide = side
35
+ if (side === 'bottom' && !fitsBelow && fitsAbove) resolved = 'top'
36
+ else if (side === 'top' && !fitsAbove && fitsBelow) resolved = 'bottom'
37
+
38
+ const top = resolved === 'bottom' ? below : above
39
+
40
+ let left = anchor.left
41
+ if (align === 'center') left = anchor.left + (anchor.width - popup.width) / 2
42
+ else if (align === 'end') left = anchor.left + anchor.width - popup.width
43
+
44
+ const maxLeft = Math.max(0, viewport.width - popup.width)
45
+ left = Math.min(Math.max(0, left), maxLeft)
46
+
47
+ return {top, left, side: resolved}
48
+ }
@@ -0,0 +1,75 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
+ import {duration} from '@duro-app/tokens/tokens/motion.css'
7
+
8
+ export const styles = css.create({
9
+ trigger: {
10
+ display: 'inline-flex',
11
+ alignItems: 'center',
12
+ gap: spacing.sm,
13
+ paddingTop: spacing.sm,
14
+ paddingBottom: spacing.sm,
15
+ paddingLeft: spacing.md,
16
+ paddingRight: spacing.md,
17
+ fontFamily: typography.fontFamily,
18
+ fontSize: typography.fontSizeSm,
19
+ color: colors.text,
20
+ backgroundColor: {
21
+ default: 'transparent',
22
+ ':hover': colors.bgCardHover,
23
+ },
24
+ borderWidth: 1,
25
+ borderStyle: 'solid',
26
+ borderColor: colors.border,
27
+ borderRadius: radii.sm,
28
+ cursor: 'pointer',
29
+ transitionProperty: 'background-color, border-color',
30
+ transitionDuration: duration.fast,
31
+ },
32
+ popup: {
33
+ position: 'fixed',
34
+ top: 0,
35
+ left: 0,
36
+ boxSizing: 'border-box',
37
+ backgroundColor: colors.bgCard,
38
+ borderWidth: 1,
39
+ borderStyle: 'solid',
40
+ borderColor: colors.border,
41
+ borderRadius: radii.sm,
42
+ boxShadow: shadows.md,
43
+ padding: spacing.md,
44
+ fontFamily: typography.fontFamily,
45
+ fontSize: typography.fontSizeSm,
46
+ color: colors.text,
47
+ zIndex: 50,
48
+ // The portal layer is pointer-events: none so clicks fall through it;
49
+ // the popup takes them back.
50
+ pointerEvents: 'auto',
51
+ },
52
+ close: {
53
+ display: 'inline-flex',
54
+ alignItems: 'center',
55
+ paddingTop: spacing.xs,
56
+ paddingBottom: spacing.xs,
57
+ paddingLeft: spacing.sm,
58
+ paddingRight: spacing.sm,
59
+ fontFamily: typography.fontFamily,
60
+ fontSize: typography.fontSizeSm,
61
+ color: colors.text,
62
+ backgroundColor: {
63
+ default: 'transparent',
64
+ ':hover': colors.bgCardHover,
65
+ },
66
+ borderWidth: 0,
67
+ borderRadius: radii.sm,
68
+ cursor: 'pointer',
69
+ },
70
+ // Dynamic position — applied at runtime from the measured anchor.
71
+ popupPosition: (top: number, left: number) => ({
72
+ top,
73
+ left,
74
+ }),
75
+ })
@@ -9,7 +9,7 @@ export const meta: ComponentMeta = {
9
9
  ],
10
10
  whenNotToUse: [
11
11
  'Critical information that must always be visible — use Text or Callout',
12
- 'Interactive content (links, buttons) — tooltips are not interactive',
12
+ 'Interactive content (links, buttons) — tooltips are not interactive — use Popover',
13
13
  ],
14
14
  anatomy: {
15
15
  required: ['Root', 'Trigger'],
package/src/index.ts CHANGED
@@ -90,6 +90,8 @@ export {Text, type TextVariant, type TextColor} from './components/Text/Text'
90
90
  export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
91
91
  export {ToggleGroup} from './components/ToggleGroup/ToggleGroup'
92
92
  export {Tooltip} from './components/Tooltip/Tooltip'
93
+ export {Popover} from './components/Popover/Popover'
94
+ export type {PopoverHandle} from './components/Popover/Popover'
93
95
  export {Switch} from './components/Switch/Switch'
94
96
 
95
97
  // Layout primitives