@passui/react 0.1.21 → 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;
@@ -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.21",
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
  }
@@ -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}
@@ -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")