@passui/react 0.1.20 → 0.1.22

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.
@@ -4,6 +4,15 @@ export type MenuRect = {
4
4
  width: number;
5
5
  height: number;
6
6
  };
7
+ /** Return toward the source while handing its material back to the real trigger. */
8
+ export declare function menuSurfaceExit(source: ReturnType<typeof menuSurfaceReveal>): {
9
+ opacity: number;
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ borderRadius: number;
15
+ };
7
16
  /** Animate the material's bounds independently so menu text never stretches. */
8
17
  export declare function menuSurfaceReveal(source: MenuRect | undefined, target: MenuRect & {
9
18
  radius?: number;
@@ -1,18 +1,23 @@
1
- import { type InputHTMLAttributes } from "react";
1
+ import { type InputHTMLAttributes, type Ref } from "react";
2
2
  export type OneTimeCodeStatus = "idle" | "verifying" | "success" | "error";
3
+ export type OneTimeCodeFormat = "numeric" | "alphanumeric";
3
4
  export interface OneTimeCodeProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type" | "maxLength" | "size"> {
4
5
  label: string;
5
6
  value: string;
6
7
  onChange: (value: string) => void;
7
8
  /** Called when a complete code is entered, including paste and autofill. */
8
9
  onComplete?: (value: string) => void;
9
- /** Number of digits, 3–8. @default 6 */
10
+ /** Number of characters, 3–8. @default 6 */
10
11
  length?: number;
12
+ /** Accepted characters. Alphanumeric codes use uppercase ASCII letters and digits. @default "numeric" */
13
+ format?: OneTimeCodeFormat;
14
+ /** Ref to the single native input, for focus and selection. */
15
+ ref?: Ref<HTMLInputElement>;
11
16
  status?: OneTimeCodeStatus;
12
17
  /** Localized verification feedback announced without reading decorative cells. */
13
18
  statusLabel?: string;
14
19
  }
15
- /** Normalize common numeric keyboards without splitting the browser's input into cells. */
16
- export declare function normalizeCode(value: string, length: number): string;
20
+ /** Normalize common numeric keyboards and optional ASCII letters in one native field. */
21
+ export declare function normalizeCode(value: string, length: number, format?: OneTimeCodeFormat): string;
17
22
  /** Six cells, one field: paste, autofill, selection and Backspace stay native. */
18
- export declare function OneTimeCode({ label, value, onChange, onComplete, length, status, statusLabel, className, disabled, id: suppliedId, ...props }: OneTimeCodeProps): import("react").JSX.Element;
23
+ export declare function OneTimeCode({ label, value, onChange, onComplete, length, format, ref, status, statusLabel, className, disabled, id: suppliedId, ...props }: OneTimeCodeProps): import("react").JSX.Element;
@@ -5,6 +5,8 @@ export interface SheetProps {
5
5
  open: boolean;
6
6
  /** Called on Escape or an outside press. */
7
7
  onClose: () => void;
8
+ /** Allow tapping the backdrop to close. Escape still calls onClose. @default true */
9
+ dismissOnOutsidePress?: boolean;
8
10
  /** Read out by screen readers as the sheet's name. */
9
11
  label: string;
10
12
  /** ID of a visible heading to use instead of label for the accessible name. */
@@ -152,6 +152,10 @@ export declare const LanguageIcon: {
152
152
  ({ size, strokeWidth, ...props }: IconProps): import("react").JSX.Element;
153
153
  displayName: string;
154
154
  };
155
+ export declare const LoadingIcon: {
156
+ ({ size, strokeWidth, ...props }: IconProps): import("react").JSX.Element;
157
+ displayName: string;
158
+ };
155
159
  export declare const SignOutIcon: {
156
160
  ({ size, strokeWidth, ...props }: IconProps): import("react").JSX.Element;
157
161
  displayName: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passui/react",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Passui: native React controls, design tokens, presentation and appearance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,7 +9,7 @@ import { spring } from "../../motion/transitions"
9
9
  import { concealMenuSource } from "./conceal-menu-source"
10
10
  import { findMenuTypeaheadIndex, isMenuNavigationKey, nextMenuIndex } from "./menu-navigation"
11
11
  import { menuPosition } from "./menu-position"
12
- import { menuSurfaceReveal } from "./menu-reveal"
12
+ import { menuSurfaceExit, menuSurfaceReveal } from "./menu-reveal"
13
13
  import {
14
14
  closeContextMenu,
15
15
  getContextMenu,
@@ -44,6 +44,7 @@ function Menu({ menu }: { menu: OpenMenu }) {
44
44
 
45
45
  useLayoutEffect(() => {
46
46
  if (
47
+ !present ||
47
48
  (!menu.source && !menu.toolbar) ||
48
49
  !position.ready ||
49
50
  !(menu.anchor instanceof HTMLElement)
@@ -64,7 +65,7 @@ function Menu({ menu }: { menu: OpenMenu }) {
64
65
  )
65
66
  menu.anchor.focus({ preventScroll: true })
66
67
  }
67
- }, [menu, position.ready])
68
+ }, [menu, position.ready, present])
68
69
 
69
70
  useLayoutEffect(() => {
70
71
  const surface = ref.current
@@ -283,8 +284,12 @@ function Menu({ menu }: { menu: OpenMenu }) {
283
284
  }
284
285
  : undefined
285
286
  }
286
- exit={reducedMotion ? { opacity: 0 } : source}
287
- transition={reducedMotion ? { duration: 0 } : spring.smooth}
287
+ exit={reducedMotion ? { opacity: 0 } : menuSurfaceExit(source)}
288
+ transition={
289
+ reducedMotion
290
+ ? { duration: 0 }
291
+ : { ...spring.smooth, opacity: { duration: 0.16 } }
292
+ }
288
293
  />
289
294
  )}
290
295
  <motion.div
@@ -5,6 +5,11 @@ export type MenuRect = {
5
5
  height: number
6
6
  }
7
7
 
8
+ /** Return toward the source while handing its material back to the real trigger. */
9
+ export function menuSurfaceExit(source: ReturnType<typeof menuSurfaceReveal>) {
10
+ return { ...source, opacity: 0 }
11
+ }
12
+
8
13
  /** Animate the material's bounds independently so menu text never stretches. */
9
14
  export function menuSurfaceReveal(
10
15
  source: MenuRect | undefined,
@@ -1,7 +1,10 @@
1
1
  import { getNativeLanguageName } from "../../lib/language"
2
2
  import { cn } from "../../lib/class-names"
3
- import { AnimatePresence, motion, useDragControls, useMotionValue } from "motion/react"
3
+ import { motion, useDragControls, useMotionValue } from "motion/react"
4
4
  import { useEffect, useRef, useState } from "react"
5
+ import { useDismiss } from "../../hooks/use-dismiss"
6
+ import { CheckIcon, GlobeIcon } from "../../icons/icons"
7
+ import { Popover } from "../popover/popover"
5
8
 
6
9
  type Side = "left" | "right"
7
10
  type Edge = "top" | "bottom"
@@ -66,6 +69,7 @@ export function LocaleSwitcher<L extends string>({
66
69
  label,
67
70
  }: LocaleSwitcherProps<L>) {
68
71
  const containerRef = useRef<HTMLDivElement>(null)
72
+ const triggerRef = useRef<HTMLButtonElement>(null)
69
73
  const dragControls = useDragControls()
70
74
  const x = useMotionValue(0)
71
75
  const y = useMotionValue(0)
@@ -82,15 +86,12 @@ export function LocaleSwitcher<L extends string>({
82
86
  if (stored) setAnchor(stored)
83
87
  }, [storageKey])
84
88
 
89
+ useDismiss(containerRef, open, () => setOpen(false))
85
90
  useEffect(() => {
86
- if (!open) return
87
- function handlePointerDown(e: PointerEvent) {
88
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
89
- setOpen(false)
90
- }
91
- }
92
- document.addEventListener("pointerdown", handlePointerDown)
93
- return () => document.removeEventListener("pointerdown", handlePointerDown)
91
+ if (open)
92
+ containerRef.current
93
+ ?.querySelector<HTMLButtonElement>('[role="menuitemradio"][aria-checked="true"]')
94
+ ?.focus()
94
95
  }, [open])
95
96
 
96
97
  function commitDraggedPosition() {
@@ -129,74 +130,114 @@ export function LocaleSwitcher<L extends string>({
129
130
  ...(anchor.edge === "top" ? { top: anchor.y } : { bottom: anchor.y }),
130
131
  }}
131
132
  onDragEnd={commitDraggedPosition}
132
- onMouseLeave={() => open && setOpen(false)}
133
+ onDragStart={() => setOpen(false)}
134
+ onKeyDown={(event) => {
135
+ if (!open) return
136
+ if (event.key === "Escape") {
137
+ event.preventDefault()
138
+ setOpen(false)
139
+ triggerRef.current?.focus()
140
+ return
141
+ }
142
+ if (event.key === "Tab") {
143
+ setOpen(false)
144
+ return
145
+ }
146
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
147
+ const options = Array.from(
148
+ containerRef.current?.querySelectorAll<HTMLButtonElement>(
149
+ '[role="menuitemradio"]'
150
+ ) ?? []
151
+ )
152
+ if (!options.length) return
153
+ event.preventDefault()
154
+ const current = options.indexOf(document.activeElement as HTMLButtonElement)
155
+ const next =
156
+ event.key === "Home"
157
+ ? 0
158
+ : event.key === "End"
159
+ ? options.length - 1
160
+ : (current + (event.key === "ArrowDown" ? 1 : -1) + options.length) %
161
+ options.length
162
+ options[next]?.focus()
163
+ }}
133
164
  className="z-60"
134
165
  >
135
- <motion.div
136
- layout
137
- transition={{
138
- type: "spring",
139
- damping: 28,
140
- stiffness: 380,
166
+ <button
167
+ ref={triggerRef}
168
+ type="button"
169
+ aria-label={label}
170
+ aria-haspopup="menu"
171
+ aria-expanded={open}
172
+ onPointerDown={(e) => dragControls.start(e)}
173
+ onClick={() => setOpen((prev) => !prev)}
174
+ onKeyDown={(event) => {
175
+ if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
176
+ event.preventDefault()
177
+ setOpen(true)
178
+ }
141
179
  }}
180
+ className="glass-menu flex h-12 min-w-20 touch-none cursor-grab items-center justify-center gap-2 rounded-full px-4 text-caption font-semibold text-label active:cursor-grabbing"
181
+ >
182
+ <GlobeIcon aria-hidden="true" className="size-5" />
183
+ {locale.toUpperCase()}
184
+ </button>
185
+ <Popover
186
+ open={open}
187
+ anchorRef={triggerRef}
188
+ origin={anchor.side === "right" ? "top-right" : "top-left"}
189
+ role="menu"
190
+ aria-label={label}
142
191
  className={cn(
143
- "overflow-hidden rounded-2xl border border-separator/35 bg-elevated/80 shadow-lg",
144
- "backdrop-blur-xl backdrop-saturate-150",
145
- "flex",
146
- growsUp ? "flex-col-reverse" : "flex-col",
147
- open ? "w-40 gap-px p-1" : "size-10"
192
+ "top-0 flex w-52 max-w-[calc(100vw-2.5rem)] gap-2 p-2",
193
+ anchor.side === "right" ? "right-0" : "left-0",
194
+ growsUp ? "flex-col-reverse" : "flex-col"
148
195
  )}
149
196
  >
150
197
  <button
151
198
  type="button"
152
199
  aria-label={label}
153
- aria-expanded={open}
154
- onPointerDown={(e) => dragControls.start(e)}
155
- onClick={() => setOpen((prev) => !prev)}
156
- className={cn(
157
- "flex shrink-0 cursor-grab items-center justify-center",
158
- "active:cursor-grabbing text-xs font-semibold",
159
- open ? "h-9 w-full rounded-lg hover:bg-accent" : "size-10 rounded-2xl"
160
- )}
200
+ onClick={() => {
201
+ setOpen(false)
202
+ triggerRef.current?.focus()
203
+ }}
204
+ className="flex min-h-12 w-full items-center justify-center gap-2 rounded-4xl bg-fill/50 text-caption font-semibold text-label"
161
205
  >
206
+ <GlobeIcon aria-hidden="true" className="size-5" />
162
207
  {locale.toUpperCase()}
163
208
  </button>
164
-
165
- <AnimatePresence>
166
- {open && (
167
- <motion.div
168
- initial={{ opacity: 0 }}
169
- animate={{ opacity: 1 }}
170
- exit={{ opacity: 0 }}
171
- transition={{ delay: 0.05 }}
172
- className="flex flex-col gap-px"
173
- >
174
- {langs.map((item) => {
175
- const active = item.value === locale
176
- return (
177
- <button
178
- key={item.value}
179
- type="button"
180
- onClick={() => {
181
- setOpen(false)
182
- if (!active) {
183
- onSelect(item.value)
184
- }
185
- }}
186
- className={cn(
187
- "rounded-sm px-2 py-1.5 text-left text-sm",
188
- "hover:bg-accent transition-colors",
189
- active && "text-primary font-medium"
190
- )}
191
- >
192
- {item.label}
193
- </button>
194
- )
195
- })}
196
- </motion.div>
197
- )}
198
- </AnimatePresence>
199
- </motion.div>
209
+ <div className="flex flex-col gap-1">
210
+ {langs.map((item) => {
211
+ const active = item.value === locale
212
+ return (
213
+ <button
214
+ key={item.value}
215
+ type="button"
216
+ role="menuitemradio"
217
+ aria-checked={active}
218
+ tabIndex={-1}
219
+ onClick={() => {
220
+ setOpen(false)
221
+ triggerRef.current?.focus()
222
+ if (!active) {
223
+ onSelect(item.value)
224
+ }
225
+ }}
226
+ className={cn(
227
+ "flex min-h-11 items-center justify-between gap-3 rounded-4xl px-3.5 py-2.5 text-left text-footnote text-label outline-none",
228
+ "hover:bg-fill/65 focus-visible:bg-fill/65 transition-colors",
229
+ active && "bg-accent/12 font-medium"
230
+ )}
231
+ >
232
+ {item.label}
233
+ {active && (
234
+ <CheckIcon aria-hidden="true" className="size-4 shrink-0" />
235
+ )}
236
+ </button>
237
+ )
238
+ })}
239
+ </div>
240
+ </Popover>
200
241
  </motion.div>
201
242
  )
202
243
  }
@@ -1,8 +1,16 @@
1
- import { type CSSProperties, type InputHTMLAttributes, useEffect, useId, useRef } from "react"
1
+ import {
2
+ type CSSProperties,
3
+ type InputHTMLAttributes,
4
+ type Ref,
5
+ useEffect,
6
+ useId,
7
+ useRef,
8
+ } from "react"
2
9
  import { CheckIcon } from "../../icons/icons"
3
10
  import { cn } from "../../lib/class-names"
4
11
 
5
12
  export type OneTimeCodeStatus = "idle" | "verifying" | "success" | "error"
13
+ export type OneTimeCodeFormat = "numeric" | "alphanumeric"
6
14
  export interface OneTimeCodeProps
7
15
  extends Omit<
8
16
  InputHTMLAttributes<HTMLInputElement>,
@@ -13,21 +21,29 @@ export interface OneTimeCodeProps
13
21
  onChange: (value: string) => void
14
22
  /** Called when a complete code is entered, including paste and autofill. */
15
23
  onComplete?: (value: string) => void
16
- /** Number of digits, 3–8. @default 6 */
24
+ /** Number of characters, 3–8. @default 6 */
17
25
  length?: number
26
+ /** Accepted characters. Alphanumeric codes use uppercase ASCII letters and digits. @default "numeric" */
27
+ format?: OneTimeCodeFormat
28
+ /** Ref to the single native input, for focus and selection. */
29
+ ref?: Ref<HTMLInputElement>
18
30
  status?: OneTimeCodeStatus
19
31
  /** Localized verification feedback announced without reading decorative cells. */
20
32
  statusLabel?: string
21
33
  }
22
34
 
23
- /** Normalize common numeric keyboards without splitting the browser's input into cells. */
24
- export function normalizeCode(value: string, length: number) {
35
+ /** Normalize common numeric keyboards and optional ASCII letters in one native field. */
36
+ export function normalizeCode(
37
+ value: string,
38
+ length: number,
39
+ format: OneTimeCodeFormat = "numeric"
40
+ ) {
25
41
  return Array.from(value, (digit) => {
26
42
  const point = digit.codePointAt(0) ?? 0
27
43
  for (const base of [0x30, 0x660, 0x6f0, 0xff10]) {
28
44
  if (point >= base && point < base + 10) return String(point - base)
29
45
  }
30
- return ""
46
+ return format === "alphanumeric" && /^[a-z]$/i.test(digit) ? digit.toUpperCase() : ""
31
47
  })
32
48
  .join("")
33
49
  .slice(0, length)
@@ -40,6 +56,8 @@ export function OneTimeCode({
40
56
  onChange,
41
57
  onComplete,
42
58
  length = 6,
59
+ format = "numeric",
60
+ ref,
43
61
  status = "idle",
44
62
  statusLabel,
45
63
  className,
@@ -52,13 +70,14 @@ export function OneTimeCode({
52
70
  const filter = `otp-goo-${generated.replace(/:/g, "")}`
53
71
  const input = useRef<HTMLInputElement>(null)
54
72
  const count = Number.isFinite(length) ? Math.max(3, Math.min(8, Math.round(length))) : 6
55
- const code = normalizeCode(value, count)
73
+ const code = normalizeCode(value, count, format)
56
74
  const locked = disabled || props.readOnly || status === "verifying" || status === "success"
57
75
  useEffect(() => {
58
76
  if (status === "error") input.current?.select()
59
77
  }, [status])
60
78
  const update = (next: string) => {
61
- const normalized = normalizeCode(next, count)
79
+ if (locked) return
80
+ const normalized = normalizeCode(next, count, format)
62
81
  onChange(normalized)
63
82
  if (normalized.length === count && normalized !== code) onComplete?.(normalized)
64
83
  }
@@ -123,26 +142,37 @@ export function OneTimeCode({
123
142
  <CheckIcon aria-hidden="true" className="passui-otp-check" size={24} />
124
143
  <input
125
144
  {...props}
126
- ref={input}
145
+ ref={(node) => {
146
+ input.current = node
147
+ if (typeof ref === "function") return ref(node)
148
+ if (ref) ref.current = node
149
+ }}
127
150
  id={id}
128
151
  type="text"
129
- inputMode="numeric"
130
- autoComplete="one-time-code"
131
- pattern="[0-9]*"
152
+ inputMode={format === "numeric" ? "numeric" : "text"}
153
+ autoComplete={props.autoComplete ?? "one-time-code"}
154
+ autoCapitalize={format === "alphanumeric" ? "characters" : "off"}
155
+ spellCheck={false}
156
+ pattern={format === "numeric" ? "[0-9]*" : "[A-Za-z0-9]*"}
132
157
  maxLength={count}
158
+ minLength={count}
133
159
  aria-label={label}
134
160
  value={code}
135
161
  disabled={disabled}
136
162
  readOnly={locked}
137
163
  aria-busy={status === "verifying" || undefined}
138
164
  aria-invalid={status === "error" || props["aria-invalid"]}
139
- aria-describedby={statusLabel ? `${id}-status` : props["aria-describedby"]}
165
+ aria-describedby={
166
+ [props["aria-describedby"], statusLabel && `${id}-status`]
167
+ .filter(Boolean)
168
+ .join(" ") || undefined
169
+ }
140
170
  onChange={(event) => update(event.target.value)}
141
171
  onPaste={(event) => {
142
172
  props.onPaste?.(event)
143
173
  if (event.defaultPrevented || locked) return
144
174
  const pasted = event.clipboardData.getData("text")
145
- const normalized = normalizeCode(pasted, count)
175
+ const normalized = normalizeCode(pasted, count, format)
146
176
  if (normalized && normalized !== pasted) {
147
177
  event.preventDefault()
148
178
  update(normalized)
@@ -40,9 +40,12 @@ export const AnchoredSurface = forwardRef<HTMLDivElement, AnchoredSurfaceProps>(
40
40
  const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)")
41
41
  const material = useRef<HTMLDivElement>(null)
42
42
  const content = useRef<HTMLDivElement>(null)
43
- const animated = useRef<{ values: number[]; velocities: number[]; opacity: number } | null>(
44
- null
45
- )
43
+ const animated = useRef<{
44
+ values: number[]
45
+ velocities: number[]
46
+ opacity: number
47
+ materialOpacity: number
48
+ } | null>(null)
46
49
  const placement = useAnchoredPlacement(anchor, width, needed, true)
47
50
  const [geometry, setGeometry] = useState<{
48
51
  source: ReturnType<typeof menuSurfaceReveal>
@@ -82,12 +85,16 @@ export const AnchoredSurface = forwardRef<HTMLDivElement, AnchoredSurfaceProps>(
82
85
  useLayoutEffect(() => {
83
86
  if (!root.current) return
84
87
  const unregister = registerFloatingLayer(root.current, anchor)
88
+ return unregister
89
+ }, [anchor])
90
+
91
+ useLayoutEffect(() => {
92
+ if (!present) return
85
93
  const restore = concealMenuSource(anchor, false)
86
94
  return () => {
87
- unregister()
88
95
  restore()
89
96
  }
90
- }, [anchor])
97
+ }, [anchor, present])
91
98
 
92
99
  useLayoutEffect(() => {
93
100
  if (!geometry || !material.current || !content.current) {
@@ -110,6 +117,7 @@ export const AnchoredSurface = forwardRef<HTMLDivElement, AnchoredSurfaceProps>(
110
117
  values: [...source],
111
118
  velocities: source.map(() => 0),
112
119
  opacity: 0,
120
+ materialOpacity: 1,
113
121
  }
114
122
  animated.current = state
115
123
  let frame = 0,
@@ -120,6 +128,7 @@ export const AnchoredSurface = forwardRef<HTMLDivElement, AnchoredSurfaceProps>(
120
128
  pane.style.width = `${Math.max(0, width)}px`
121
129
  pane.style.height = `${Math.max(0, height)}px`
122
130
  pane.style.borderRadius = `${Math.max(0, radius)}px`
131
+ pane.style.opacity = String(state.materialOpacity)
123
132
  body.style.opacity = String(state.opacity)
124
133
  }
125
134
  const tick = (now: number) => {
@@ -151,11 +160,18 @@ export const AnchoredSurface = forwardRef<HTMLDivElement, AnchoredSurfaceProps>(
151
160
  )
152
161
  const opacity = present ? Math.max(0, (progress - 0.25) / 0.75) : 0
153
162
  state.opacity += (opacity - state.opacity) * (1 - Math.exp(-dt / 0.04))
163
+ state.materialOpacity +=
164
+ ((present ? 1 : 0) - state.materialOpacity) * (1 - Math.exp(-dt / 0.04))
154
165
  paint()
155
- if (resting && Math.abs(opacity - state.opacity) < 0.01) {
166
+ if (
167
+ resting &&
168
+ Math.abs(opacity - state.opacity) < 0.01 &&
169
+ Math.abs((present ? 1 : 0) - state.materialOpacity) < 0.01
170
+ ) {
156
171
  state.values = [...target]
157
172
  state.velocities.fill(0)
158
173
  state.opacity = present ? 1 : 0
174
+ state.materialOpacity = present ? 1 : 0
159
175
  paint()
160
176
  if (!present) remove?.()
161
177
  return
@@ -166,6 +182,7 @@ export const AnchoredSurface = forwardRef<HTMLDivElement, AnchoredSurfaceProps>(
166
182
  state.values = [...target]
167
183
  state.velocities.fill(0)
168
184
  state.opacity = present ? 1 : 0
185
+ state.materialOpacity = present ? 1 : 0
169
186
  paint()
170
187
  if (!present) remove?.()
171
188
  } else {
@@ -7,7 +7,7 @@ import {
7
7
  } from "motion/react"
8
8
  import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"
9
9
  import { concealMenuSource } from "../context-menu/conceal-menu-source"
10
- import { menuSurfaceReveal } from "../context-menu/menu-reveal"
10
+ import { menuSurfaceExit, menuSurfaceReveal } from "../context-menu/menu-reveal"
11
11
  import { cn } from "../../lib/class-names"
12
12
  import { screenEdges } from "../../lib/screen-edges"
13
13
  import { floatingPanelMotion, spring } from "../../motion/transitions"
@@ -183,11 +183,16 @@ function OpenPopover({
183
183
  }
184
184
  )
185
185
  )
186
+ }, [anchorRef, fit])
187
+
188
+ useLayoutEffect(() => {
189
+ const source = anchorRef?.current
190
+ if (!source || !present) return
186
191
  const restoreSource = concealMenuSource(source, false)
187
192
  return () => {
188
193
  restoreSource()
189
194
  }
190
- }, [anchorRef, fit])
195
+ }, [anchorRef, present])
191
196
 
192
197
  return (
193
198
  <motion.div
@@ -274,8 +279,12 @@ function OpenPopover({
274
279
  }
275
280
  : undefined
276
281
  }
277
- exit={reducedMotion ? { opacity: 0 } : (reveal ?? { opacity: 0 })}
278
- transition={reducedMotion ? { duration: 0 } : spring.smooth}
282
+ exit={reducedMotion || !reveal ? { opacity: 0 } : menuSurfaceExit(reveal)}
283
+ transition={
284
+ reducedMotion
285
+ ? { duration: 0 }
286
+ : { ...spring.smooth, opacity: { duration: 0.16 } }
287
+ }
279
288
  />
280
289
  )}
281
290
  {children}
@@ -13,6 +13,8 @@ export interface SheetProps {
13
13
  open: boolean
14
14
  /** Called on Escape or an outside press. */
15
15
  onClose: () => void
16
+ /** Allow tapping the backdrop to close. Escape still calls onClose. @default true */
17
+ dismissOnOutsidePress?: boolean
16
18
  /** Read out by screen readers as the sheet's name. */
17
19
  label: string
18
20
  /** ID of a visible heading to use instead of label for the accessible name. */
@@ -41,6 +43,7 @@ export function Sheet({ open, ...props }: SheetProps) {
41
43
 
42
44
  function OpenSheet({
43
45
  onClose,
46
+ dismissOnOutsidePress = true,
44
47
  label,
45
48
  labelledBy,
46
49
  placement = "adaptive",
@@ -77,7 +80,7 @@ function OpenSheet({
77
80
  initial={{ opacity: 0 }}
78
81
  animate={{ opacity: 1 }}
79
82
  exit={{ opacity: 0 }}
80
- onClick={onClose}
83
+ onClick={dismissOnOutsidePress ? onClose : undefined}
81
84
  />
82
85
  <motion.div
83
86
  ref={panel}
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  LanguagesIcon,
3
+ Loading03Icon,
3
4
  Logout01Icon,
4
5
  MoreVertical,
5
6
  SentIcon,
@@ -227,6 +228,7 @@ export const catalogIcons = {
227
228
 
228
229
  export const NoteIcon = /* @__PURE__ */ icon(Note01Icon, "NoteIcon")
229
230
  export const LanguageIcon = /* @__PURE__ */ icon(LanguagesIcon, "LanguageIcon")
231
+ export const LoadingIcon = /* @__PURE__ */ icon(Loading03Icon, "LoadingIcon")
230
232
  export const SignOutIcon = /* @__PURE__ */ icon(Logout01Icon, "SignOutIcon")
231
233
  export const MoreVerticalIcon = /* @__PURE__ */ icon(MoreVertical, "MoreVerticalIcon")
232
234
  export const SendIcon = /* @__PURE__ */ icon(SentIcon, "SendIcon")