@passui/react 0.1.45 → 0.1.47

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 (141) hide show
  1. package/README.md +57 -46
  2. package/dist/types/src/components/accordion/accordion.d.ts +1 -1
  3. package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +1 -1
  4. package/dist/types/src/components/inline-disclosure/inline-disclosure.d.ts +1 -1
  5. package/dist/types/src/components/popover/anchored-surface.d.ts +5 -1
  6. package/dist/types/src/components/popover/index.d.ts +41 -0
  7. package/dist/types/src/components/popover/popover-content.d.ts +38 -0
  8. package/dist/types/src/components/popover/popover-root.d.ts +22 -0
  9. package/dist/types/src/components/popover/popover-trigger.d.ts +23 -0
  10. package/dist/types/src/components/stepper/rolling-digit.d.ts +1 -1
  11. package/dist/types/src/components/table/table.d.ts +1 -1
  12. package/dist/types/src/components/view-controller/view-controller.d.ts +2 -2
  13. package/dist/types/src/index.d.ts +1 -1
  14. package/dist/types/src/machines/popover.machine.d.ts +30 -0
  15. package/package.json +307 -307
  16. package/src/appearance/index.ts +2 -2
  17. package/src/appearance/platform.tsx +76 -60
  18. package/src/appearance/theme.tsx +409 -377
  19. package/src/components/accordion/accordion.tsx +58 -49
  20. package/src/components/ambient/ambient.tsx +25 -25
  21. package/src/components/ambient/index.ts +1 -1
  22. package/src/components/aspect-ratio/aspect-ratio.tsx +21 -16
  23. package/src/components/breadcrumb/breadcrumb.tsx +63 -57
  24. package/src/components/button/button-styles.ts +39 -32
  25. package/src/components/button/button.tsx +91 -84
  26. package/src/components/button/icon-button.tsx +27 -25
  27. package/src/components/button/pending-spinner.tsx +8 -8
  28. package/src/components/button/use-pending-indicator.ts +11 -11
  29. package/src/components/chip/chip.tsx +35 -30
  30. package/src/components/code-block/code-block.tsx +40 -39
  31. package/src/components/combo-box/combo-box.tsx +5 -5
  32. package/src/components/command/command.tsx +124 -94
  33. package/src/components/compact-date-picker/calendar-month.ts +75 -53
  34. package/src/components/compact-date-picker/compact-date-picker.tsx +308 -289
  35. package/src/components/compact-time-picker/compact-time-picker.tsx +288 -281
  36. package/src/components/compact-time-picker/time-value.ts +32 -29
  37. package/src/components/content-unavailable-view/content-unavailable-view.tsx +64 -48
  38. package/src/components/content-view/content-view.tsx +168 -159
  39. package/src/components/content-view/index.ts +1 -1
  40. package/src/components/context-menu/conceal-menu-source.ts +25 -25
  41. package/src/components/context-menu/context-menu-host.tsx +439 -402
  42. package/src/components/context-menu/context-menu-store.ts +125 -123
  43. package/src/components/context-menu/menu-navigation.ts +25 -18
  44. package/src/components/context-menu/menu-position.ts +33 -29
  45. package/src/components/context-menu/menu-reveal.ts +32 -32
  46. package/src/components/context-menu/use-context-menu.ts +63 -56
  47. package/src/components/conversation/conversation.tsx +265 -263
  48. package/src/components/conversation/index.ts +1 -1
  49. package/src/components/date-picker/date-picker.tsx +95 -94
  50. package/src/components/date-picker/date-value.ts +20 -10
  51. package/src/components/dialog/dialog-host.tsx +105 -103
  52. package/src/components/dialog/dialog-store.ts +37 -36
  53. package/src/components/dialog/dialog.tsx +192 -179
  54. package/src/components/dialog/index.ts +1 -1
  55. package/src/components/disclosure-group/disclosure-group.tsx +93 -86
  56. package/src/components/divider/divider.tsx +24 -22
  57. package/src/components/inline-disclosure/inline-disclosure.tsx +40 -36
  58. package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +23 -19
  59. package/src/components/labeled-content/labeled-content.tsx +68 -68
  60. package/src/components/locale-switcher/index.ts +1 -1
  61. package/src/components/locale-switcher/locale-switcher.tsx +278 -251
  62. package/src/components/menu/menu.tsx +75 -58
  63. package/src/components/morph/index.ts +2 -2
  64. package/src/components/morph/morph.tsx +227 -201
  65. package/src/components/morph/use-morph-disclosure.ts +19 -19
  66. package/src/components/one-time-code/one-time-code.tsx +216 -200
  67. package/src/components/picker/picker-trigger.ts +16 -13
  68. package/src/components/picker/picker.tsx +371 -345
  69. package/src/components/popover/anchored-surface.tsx +253 -229
  70. package/src/components/popover/index.ts +50 -0
  71. package/src/components/popover/popover-content.tsx +374 -0
  72. package/src/components/popover/popover-root.tsx +97 -0
  73. package/src/components/popover/popover-trigger.tsx +71 -0
  74. package/src/components/profile-image/profile-image.tsx +80 -64
  75. package/src/components/progress-view/progress-view.tsx +61 -59
  76. package/src/components/progress-view/use-liquid-progress.ts +38 -38
  77. package/src/components/radio-group/radio-group.tsx +71 -68
  78. package/src/components/search-field/expanding-search-field.tsx +191 -177
  79. package/src/components/search-field/search-field.tsx +59 -50
  80. package/src/components/search-list/search-list.tsx +246 -236
  81. package/src/components/section/section.tsx +58 -58
  82. package/src/components/secure-field/secure-field.tsx +3 -3
  83. package/src/components/segmented-control/segmented-control.tsx +136 -136
  84. package/src/components/sheet/modal-environment.ts +71 -64
  85. package/src/components/sheet/sheet-portal.tsx +58 -53
  86. package/src/components/sheet/sheet.tsx +146 -141
  87. package/src/components/sheet/use-sheet-focus.ts +97 -82
  88. package/src/components/sidebar/sidebar.tsx +78 -78
  89. package/src/components/skeleton/skeleton.tsx +30 -21
  90. package/src/components/slider/slider.tsx +113 -104
  91. package/src/components/slosh-slider/liquid-step.ts +32 -31
  92. package/src/components/slosh-slider/slosh-slider.tsx +265 -228
  93. package/src/components/stat-tile/index.ts +1 -1
  94. package/src/components/stat-tile/stat-tile.tsx +79 -68
  95. package/src/components/status-indicator/status-indicator.tsx +40 -37
  96. package/src/components/stepper/rolling-digit.tsx +82 -68
  97. package/src/components/stepper/stepper.tsx +122 -113
  98. package/src/components/swatch-picker/swatch-picker.tsx +120 -120
  99. package/src/components/tab-bar/tab-bar.tsx +149 -148
  100. package/src/components/tab-view/tab-view.tsx +140 -137
  101. package/src/components/table/table.tsx +117 -98
  102. package/src/components/text-editor/text-editor.tsx +34 -29
  103. package/src/components/text-field/text-field.tsx +182 -180
  104. package/src/components/toast/toast-store.ts +127 -115
  105. package/src/components/toast/toaster.tsx +361 -347
  106. package/src/components/toggle/toggle.tsx +297 -282
  107. package/src/components/tooltip/tooltip-host.tsx +157 -150
  108. package/src/components/view-controller/index.ts +1 -1
  109. package/src/components/view-controller/view-controller.tsx +64 -53
  110. package/src/components/wake-slider/wake-slider.tsx +220 -205
  111. package/src/hooks/scroll/minimize-decision.ts +11 -7
  112. package/src/hooks/use-anchored-disclosure.ts +19 -19
  113. package/src/hooks/use-anchored-placement.ts +29 -25
  114. package/src/hooks/use-dismiss.ts +33 -29
  115. package/src/hooks/use-floating-dismiss.ts +24 -22
  116. package/src/hooks/use-media-query.ts +67 -60
  117. package/src/hooks/use-minimize-on-scroll.ts +69 -60
  118. package/src/hooks/use-visual-viewport.ts +63 -63
  119. package/src/hooks/viewport/types.ts +16 -16
  120. package/src/hooks/viewport/viewport-metrics.ts +16 -10
  121. package/src/icons/icons.tsx +440 -328
  122. package/src/index.ts +91 -91
  123. package/src/lib/anchored-placement.ts +70 -65
  124. package/src/lib/class-names.ts +14 -13
  125. package/src/lib/clipboard.ts +8 -7
  126. package/src/lib/date.ts +61 -60
  127. package/src/lib/floating-layers.ts +39 -24
  128. package/src/lib/haptics.ts +10 -10
  129. package/src/lib/language.ts +9 -9
  130. package/src/lib/range-value.ts +27 -21
  131. package/src/lib/screen-edges.ts +33 -30
  132. package/src/lib/shapes.ts +38 -34
  133. package/src/machines/popover.machine.ts +51 -0
  134. package/src/motion/transitions.ts +46 -43
  135. package/src/styles/code-block.css +69 -43
  136. package/src/styles/motion-controls.css +393 -191
  137. package/src/tokens.ts +1 -1
  138. package/dist/types/src/components/popover/popover-fit.d.ts +0 -21
  139. package/dist/types/src/components/popover/popover.d.ts +0 -21
  140. package/src/components/popover/popover-fit.ts +0 -69
  141. package/src/components/popover/popover.tsx +0 -334
@@ -1,422 +1,459 @@
1
- import { AnimatePresence, motion, useIsPresent, useReducedMotion } from "motion/react"
2
- import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"
3
- import { FLOATING_LAYER } from "../../hooks/use-dismiss"
4
- import { CheckIcon } from "../../icons/icons"
5
- import { cn } from "../../lib/class-names"
6
- import { belongsToFloatingSurface, registerFloatingLayer } from "../../lib/floating-layers"
7
- import { screenEdges } from "../../lib/screen-edges"
8
- import { spring, fluidCollapse, fluidOvershoot } from "../../motion/transitions"
9
- import { concealMenuSource } from "./conceal-menu-source"
10
- import { findMenuTypeaheadIndex, isMenuNavigationKey, nextMenuIndex } from "./menu-navigation"
11
- import { menuPosition } from "./menu-position"
12
- import { menuSurfaceExit, menuSurfaceReveal } from "./menu-reveal"
13
1
  import {
14
- closeContextMenu,
15
- getContextMenu,
16
- type ContextMenuItem,
17
- type ContextMenuSize,
18
- openContextMenu,
19
- openFollowUpMenu,
20
- openMenu,
21
- subscribeContextMenu,
22
- } from "./context-menu-store"
23
- import type { OpenMenu } from "./context-menu-store"
2
+ AnimatePresence,
3
+ motion,
4
+ useIsPresent,
5
+ useReducedMotion,
6
+ } from "motion/react";
7
+ import {
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState,
12
+ useSyncExternalStore,
13
+ } from "react";
14
+ import { FLOATING_LAYER } from "../../hooks/use-dismiss";
15
+ import { CheckIcon } from "../../icons/icons";
16
+ import { cn } from "../../lib/class-names";
17
+ import {
18
+ belongsToFloatingSurface,
19
+ registerFloatingLayer,
20
+ } from "../../lib/floating-layers";
21
+ import { screenEdges } from "../../lib/screen-edges";
22
+ import { fluidOvershoot } from "../../motion/transitions";
23
+ import { concealMenuSource } from "./conceal-menu-source";
24
+ import {
25
+ findMenuTypeaheadIndex,
26
+ isMenuNavigationKey,
27
+ nextMenuIndex,
28
+ } from "./menu-navigation";
29
+ import { menuPosition } from "./menu-position";
30
+ import { menuSurfaceExit, menuSurfaceReveal } from "./menu-reveal";
31
+ import {
32
+ closeContextMenu,
33
+ getContextMenu,
34
+ type ContextMenuSize,
35
+ subscribeContextMenu,
36
+ } from "./context-menu-store";
37
+ import type { OpenMenu } from "./context-menu-store";
24
38
 
25
39
  const surfaceSizes: Record<ContextMenuSize, string> = {
26
- sm: "min-w-44 max-w-[min(240px,calc(100vw-24px))] rounded-2xl p-1",
27
- md: "min-w-50 max-w-[min(280px,calc(100vw-24px))] rounded-3xl p-1.5",
28
- lg: "min-w-56 max-w-[min(320px,calc(100vw-24px))] rounded-3xl p-2",
29
- }
40
+ sm: "min-w-44 max-w-[min(240px,calc(100vw-24px))] rounded-2xl p-1",
41
+ md: "min-w-50 max-w-[min(280px,calc(100vw-24px))] rounded-3xl p-1.5",
42
+ lg: "min-w-56 max-w-[min(320px,calc(100vw-24px))] rounded-3xl p-2",
43
+ };
30
44
  const itemSizes: Record<ContextMenuSize, string> = {
31
- sm: "flex items-center gap-2 rounded-md px-2 py-1 text-start text-caption transition-colors disabled:opacity-40",
32
- md: "flex items-center gap-2.5 rounded-lg px-2.5 text-start text-subheadline transition-colors disabled:opacity-40",
33
- lg: "flex items-center gap-3 rounded-lg px-3 py-2 text-start text-body transition-colors disabled:opacity-40",
34
- }
45
+ sm: "flex items-center gap-2 rounded-md px-2 py-1 text-start text-caption transition-colors disabled:opacity-40",
46
+ md: "flex items-center gap-2.5 rounded-lg px-2.5 text-start text-subheadline transition-colors disabled:opacity-40",
47
+ lg: "flex items-center gap-3 rounded-lg px-3 py-2 text-start text-body transition-colors disabled:opacity-40",
48
+ };
35
49
  const headingSizes: Record<ContextMenuSize, string> = {
36
- sm: "px-2 pt-1 pb-0.5 text-caption2 font-semibold text-label-tertiary",
37
- md: "px-2.5 pt-1.5 pb-0.5 text-caption2 font-semibold text-label-tertiary",
38
- lg: "px-3 pt-2 pb-1 text-caption font-semibold text-label-tertiary",
39
- }
50
+ sm: "px-2 pt-1 pb-0.5 text-caption2 font-semibold text-label-tertiary",
51
+ md: "px-2.5 pt-1.5 pb-0.5 text-caption2 font-semibold text-label-tertiary",
52
+ lg: "px-3 pt-2 pb-1 text-caption font-semibold text-label-tertiary",
53
+ };
40
54
  const dividerSizes: Record<ContextMenuSize, string> = {
41
- sm: "mx-1.5 my-0.5 h-px bg-separator/80",
42
- md: "mx-2 my-1 h-px bg-separator/80",
43
- lg: "mx-2.5 my-1.5 h-px bg-separator/80",
44
- }
55
+ sm: "mx-1.5 my-0.5 h-px bg-separator/80",
56
+ md: "mx-2 my-1 h-px bg-separator/80",
57
+ lg: "mx-2.5 my-1.5 h-px bg-separator/80",
58
+ };
45
59
  const iconSlotSizes: Record<ContextMenuSize, string> = {
46
- sm: "flex w-3.5 shrink-0 justify-center text-label-secondary",
47
- md: "flex w-4 shrink-0 justify-center text-label-secondary",
48
- lg: "flex w-5 shrink-0 justify-center text-label-secondary",
49
- }
60
+ sm: "flex w-3.5 shrink-0 justify-center text-label-secondary",
61
+ md: "flex w-4 shrink-0 justify-center text-label-secondary",
62
+ lg: "flex w-5 shrink-0 justify-center text-label-secondary",
63
+ };
50
64
  const checkIconSizes: Record<ContextMenuSize, number> = {
51
- sm: 13,
52
- md: 15,
53
- lg: 17,
54
- }
65
+ sm: 13,
66
+ md: 15,
67
+ lg: 17,
68
+ };
55
69
 
56
70
  /** Renders the open context menu. Mount once, near the app root. */
57
71
  export function ContextMenuHost() {
58
- const menu = useSyncExternalStore(subscribeContextMenu, getContextMenu, () => null)
59
- return <AnimatePresence>{menu && <Menu key={menu.id} menu={menu} />}</AnimatePresence>
72
+ const menu = useSyncExternalStore(
73
+ subscribeContextMenu,
74
+ getContextMenu,
75
+ () => null,
76
+ );
77
+ return (
78
+ <AnimatePresence>
79
+ {menu && <Menu key={menu.id} menu={menu} />}
80
+ </AnimatePresence>
81
+ );
60
82
  }
61
83
 
62
84
  function Menu({ menu }: { menu: OpenMenu }) {
63
- const present = useIsPresent()
64
- const reducedMotion = useReducedMotion()
65
- const ref = useRef<HTMLDivElement>(null)
66
- const content = useRef<HTMLDivElement>(null)
67
- const [position, setPosition] = useState({
68
- left: menu.x,
69
- top: menu.y,
70
- originX: 0,
71
- originY: 0,
72
- maxHeight: undefined as number | undefined,
73
- width: 0,
74
- height: 0,
75
- radius: 0,
76
- ready: false,
77
- })
78
- const [focused, setFocused] = useState(-1)
79
- const typeahead = useRef({ query: "", at: 0 })
85
+ const present = useIsPresent();
86
+ const reducedMotion = useReducedMotion();
87
+ const ref = useRef<HTMLDivElement>(null);
88
+ const content = useRef<HTMLDivElement>(null);
89
+ const [position, setPosition] = useState({
90
+ left: menu.x,
91
+ top: menu.y,
92
+ originX: 0,
93
+ originY: 0,
94
+ maxHeight: undefined as number | undefined,
95
+ width: 0,
96
+ height: 0,
97
+ radius: 0,
98
+ ready: false,
99
+ });
100
+ const [focused, setFocused] = useState(-1);
101
+ const typeahead = useRef({ query: "", at: 0 });
80
102
 
81
- useLayoutEffect(() => {
82
- if (
83
- !present ||
84
- (!menu.source && !menu.toolbar) ||
85
- !position.ready ||
86
- !(menu.anchor instanceof HTMLElement)
87
- )
88
- return
89
- const anchor = menu.toolbar ?? menu.anchor
90
- const surface = ref.current
91
- const restore = concealMenuSource(anchor, Boolean(menu.toolbar))
92
- return () => {
93
- const restored = restore()
94
- if (
95
- restored &&
96
- menu.toolbar &&
97
- menu.restoreFocus &&
98
- menu.anchor instanceof HTMLElement &&
99
- (document.activeElement === document.body ||
100
- surface?.contains(document.activeElement))
101
- )
102
- menu.anchor.focus({ preventScroll: true })
103
- }
104
- }, [menu, position.ready, present])
103
+ useLayoutEffect(() => {
104
+ if (
105
+ !present ||
106
+ (!menu.source && !menu.toolbar) ||
107
+ !position.ready ||
108
+ !(menu.anchor instanceof HTMLElement)
109
+ )
110
+ return;
111
+ const anchor = menu.toolbar ?? menu.anchor;
112
+ const surface = ref.current;
113
+ const restore = concealMenuSource(anchor, Boolean(menu.toolbar));
114
+ return () => {
115
+ const restored = restore();
116
+ if (
117
+ restored &&
118
+ menu.toolbar &&
119
+ menu.restoreFocus &&
120
+ menu.anchor instanceof HTMLElement &&
121
+ (document.activeElement === document.body ||
122
+ surface?.contains(document.activeElement))
123
+ )
124
+ menu.anchor.focus({ preventScroll: true });
125
+ };
126
+ }, [menu, position.ready, present]);
105
127
 
106
- useLayoutEffect(() => {
107
- const surface = ref.current
108
- if (!surface || !present || !position.ready) return
109
- const unregister = menu.anchor ? registerFloatingLayer(surface, menu.anchor) : undefined
110
- surface.focus()
111
- return unregister
112
- }, [menu.anchor, present, position.ready])
128
+ useLayoutEffect(() => {
129
+ const surface = ref.current;
130
+ if (!surface || !present || !position.ready) return;
131
+ const unregister = menu.anchor
132
+ ? registerFloatingLayer(surface, menu.anchor)
133
+ : undefined;
134
+ surface.focus();
135
+ return unregister;
136
+ }, [menu.anchor, present, position.ready]);
113
137
 
114
- // Keep the whole menu on screen, opening up or left when near an edge. The
115
- // layout size, not the on-screen one: the menu is still mid-scale here.
116
- useLayoutEffect(() => {
117
- if (!ref.current || !present) return
118
- const viewport = window.visualViewport
119
- let frame = 0
120
- const refit = () => {
121
- const element = ref.current
122
- if (!element) return
123
- const edges = screenEdges()
124
- const left = viewport?.offsetLeft ?? 0
125
- const top = viewport?.offsetTop ?? 0
126
- const next = {
127
- ...menuPosition(
128
- menu,
129
- {
130
- width: element.offsetWidth,
131
- height: Math.max(
132
- element.offsetHeight,
133
- (content.current?.scrollHeight ?? 0) + 12
134
- ),
135
- },
136
- {
137
- left: left + edges.left,
138
- top: top + edges.top,
139
- right: left + (viewport?.width ?? window.innerWidth) - edges.right,
140
- bottom: top + (viewport?.height ?? window.innerHeight) - edges.bottom,
141
- }
142
- ),
143
- radius: Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0,
144
- ready: true,
145
- }
146
- setPosition((current) =>
147
- current.left === next.left &&
148
- current.top === next.top &&
149
- current.originX === next.originX &&
150
- current.originY === next.originY &&
151
- current.maxHeight === next.maxHeight &&
152
- current.width === next.width &&
153
- current.height === next.height &&
154
- current.radius === next.radius &&
155
- current.ready === next.ready
156
- ? current
157
- : next
158
- )
159
- }
160
- const schedule = () => {
161
- cancelAnimationFrame(frame)
162
- frame = requestAnimationFrame(refit)
163
- }
164
- refit()
165
- viewport?.addEventListener("resize", schedule)
166
- viewport?.addEventListener("scroll", schedule)
167
- window.addEventListener("resize", schedule)
168
- return () => {
169
- cancelAnimationFrame(frame)
170
- viewport?.removeEventListener("resize", schedule)
171
- viewport?.removeEventListener("scroll", schedule)
172
- window.removeEventListener("resize", schedule)
173
- }
174
- }, [menu, present])
138
+ // Keep the whole menu on screen, opening up or left when near an edge. The
139
+ // layout size, not the on-screen one: the menu is still mid-scale here.
140
+ useLayoutEffect(() => {
141
+ if (!ref.current || !present) return;
142
+ const viewport = window.visualViewport;
143
+ let frame = 0;
144
+ const refit = () => {
145
+ const element = ref.current;
146
+ if (!element) return;
147
+ const edges = screenEdges();
148
+ const left = viewport?.offsetLeft ?? 0;
149
+ const top = viewport?.offsetTop ?? 0;
150
+ const next = {
151
+ ...menuPosition(
152
+ menu,
153
+ {
154
+ width: element.offsetWidth,
155
+ height: Math.max(
156
+ element.offsetHeight,
157
+ (content.current?.scrollHeight ?? 0) + 12,
158
+ ),
159
+ },
160
+ {
161
+ left: left + edges.left,
162
+ top: top + edges.top,
163
+ right: left + (viewport?.width ?? window.innerWidth) - edges.right,
164
+ bottom:
165
+ top + (viewport?.height ?? window.innerHeight) - edges.bottom,
166
+ },
167
+ ),
168
+ radius:
169
+ Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0,
170
+ ready: true,
171
+ };
172
+ setPosition((current) =>
173
+ current.left === next.left &&
174
+ current.top === next.top &&
175
+ current.originX === next.originX &&
176
+ current.originY === next.originY &&
177
+ current.maxHeight === next.maxHeight &&
178
+ current.width === next.width &&
179
+ current.height === next.height &&
180
+ current.radius === next.radius &&
181
+ current.ready === next.ready
182
+ ? current
183
+ : next,
184
+ );
185
+ };
186
+ const schedule = () => {
187
+ cancelAnimationFrame(frame);
188
+ frame = requestAnimationFrame(refit);
189
+ };
190
+ refit();
191
+ viewport?.addEventListener("resize", schedule);
192
+ viewport?.addEventListener("scroll", schedule);
193
+ window.addEventListener("resize", schedule);
194
+ return () => {
195
+ cancelAnimationFrame(frame);
196
+ viewport?.removeEventListener("resize", schedule);
197
+ viewport?.removeEventListener("scroll", schedule);
198
+ window.removeEventListener("resize", schedule);
199
+ };
200
+ }, [menu, present]);
175
201
 
176
- useEffect(() => {
177
- if (!present) return
178
- const onPointer = (event: PointerEvent) => {
179
- if (!ref.current || !belongsToFloatingSurface(ref.current, event.target as Element))
180
- closeContextMenu(false)
181
- }
182
- const close = () => closeContextMenu(false)
183
- let scrollIntentUntil = 0
184
- const markScroll = () => {
185
- scrollIntentUntil = performance.now() + 2000
186
- }
187
- const onScrollKey = (event: KeyboardEvent) => {
188
- if (
189
- !ref.current?.contains(event.target as Node) &&
190
- ["PageDown", "PageUp", "Home", "End", " "].includes(event.key)
191
- )
192
- markScroll()
193
- }
194
- const onScroll = (event: Event) => {
195
- if (
196
- performance.now() < scrollIntentUntil &&
197
- !ref.current?.contains(event.target as Node)
198
- )
199
- close()
200
- }
201
- // Opened by a long-press: ignore the finger lifting off.
202
- const timer = setTimeout(() => {
203
- window.addEventListener("pointerdown", onPointer, true)
204
- }, 0)
205
- window.addEventListener("blur", close)
206
- window.addEventListener("wheel", markScroll, {
207
- capture: true,
208
- passive: true,
209
- })
210
- window.addEventListener("touchmove", markScroll, {
211
- capture: true,
212
- passive: true,
213
- })
214
- window.addEventListener("keydown", onScrollKey, true)
215
- window.addEventListener("scroll", onScroll, true)
216
- return () => {
217
- clearTimeout(timer)
218
- window.removeEventListener("pointerdown", onPointer, true)
219
- window.removeEventListener("blur", close)
220
- window.removeEventListener("wheel", markScroll, true)
221
- window.removeEventListener("touchmove", markScroll, true)
222
- window.removeEventListener("keydown", onScrollKey, true)
223
- window.removeEventListener("scroll", onScroll, true)
224
- }
225
- }, [present])
202
+ useEffect(() => {
203
+ if (!present) return;
204
+ const onPointer = (event: PointerEvent) => {
205
+ if (
206
+ !ref.current ||
207
+ !belongsToFloatingSurface(ref.current, event.target as Element)
208
+ )
209
+ closeContextMenu(false);
210
+ };
211
+ const close = () => closeContextMenu(false);
212
+ let scrollIntentUntil = 0;
213
+ const markScroll = () => {
214
+ scrollIntentUntil = performance.now() + 2000;
215
+ };
216
+ const onScrollKey = (event: KeyboardEvent) => {
217
+ if (
218
+ !ref.current?.contains(event.target as Node) &&
219
+ ["PageDown", "PageUp", "Home", "End", " "].includes(event.key)
220
+ )
221
+ markScroll();
222
+ };
223
+ const onScroll = (event: Event) => {
224
+ if (
225
+ performance.now() < scrollIntentUntil &&
226
+ !ref.current?.contains(event.target as Node)
227
+ )
228
+ close();
229
+ };
230
+ // Opened by a long-press: ignore the finger lifting off.
231
+ const timer = setTimeout(() => {
232
+ window.addEventListener("pointerdown", onPointer, true);
233
+ }, 0);
234
+ window.addEventListener("blur", close);
235
+ window.addEventListener("wheel", markScroll, {
236
+ capture: true,
237
+ passive: true,
238
+ });
239
+ window.addEventListener("touchmove", markScroll, {
240
+ capture: true,
241
+ passive: true,
242
+ });
243
+ window.addEventListener("keydown", onScrollKey, true);
244
+ window.addEventListener("scroll", onScroll, true);
245
+ return () => {
246
+ clearTimeout(timer);
247
+ window.removeEventListener("pointerdown", onPointer, true);
248
+ window.removeEventListener("blur", close);
249
+ window.removeEventListener("wheel", markScroll, true);
250
+ window.removeEventListener("touchmove", markScroll, true);
251
+ window.removeEventListener("keydown", onScrollKey, true);
252
+ window.removeEventListener("scroll", onScroll, true);
253
+ };
254
+ }, [present]);
226
255
 
227
- const source = menuSurfaceReveal(
228
- position.ready ? (menu.toolbarRect ?? menu.source) : undefined,
229
- position
230
- )
231
- let actionIndex = -1
232
- return (
233
- <div
234
- ref={ref}
235
- role="menu"
236
- tabIndex={-1}
237
- onKeyDown={(event) => {
238
- if (!present) return
239
- if (event.key === "Escape") {
240
- event.preventDefault()
241
- event.stopPropagation()
242
- closeContextMenu()
243
- return
244
- }
245
- if (event.key === "Tab") {
246
- closeContextMenu()
247
- return
248
- }
249
- const controls = Array.from(
250
- ref.current?.querySelectorAll<HTMLButtonElement>(
251
- "[role=menuitem]:not(:disabled)"
252
- ) ?? []
253
- )
254
- if (!isMenuNavigationKey(event.key)) {
255
- if (
256
- event.key.length !== 1 ||
257
- !event.key.trim() ||
258
- event.altKey ||
259
- event.ctrlKey ||
260
- event.metaKey ||
261
- event.nativeEvent.isComposing
262
- )
263
- return
264
- const now = performance.now()
265
- const query =
266
- (now - typeahead.current.at < 700 ? typeahead.current.query : "") +
267
- event.key
268
- typeahead.current = { query, at: now }
269
- const labels = controls.map((control) => control.textContent ?? "")
270
- const current = controls.indexOf(document.activeElement as HTMLButtonElement)
271
- let next = findMenuTypeaheadIndex(labels, query, current)
272
- if (next < 0) {
273
- typeahead.current.query = event.key
274
- next = findMenuTypeaheadIndex(labels, event.key, current)
275
- }
276
- if (next >= 0) {
277
- event.preventDefault()
278
- controls[next]?.focus()
279
- }
280
- return
281
- }
282
- event.preventDefault()
283
- if (!controls.length) return
284
- const current = controls.indexOf(document.activeElement as HTMLButtonElement)
285
- const next = nextMenuIndex(event.key, current, controls.length)
286
- controls[next]?.focus()
287
- }}
288
- {...{ [FLOATING_LAYER]: "" }}
289
- className={cn(
290
- "fixed z-75 isolate flex flex-col outline-none",
291
- surfaceSizes[menu.size],
292
- menu.touch && "min-w-60",
293
- !present && "pointer-events-none"
294
- )}
295
- style={{
296
- left: position.left,
297
- top: position.top,
298
- maxHeight: position.maxHeight,
299
- minWidth: menu.toolbarRect
300
- ? Math.min(Math.max(200, menu.toolbarRect.width), window.innerWidth - 24)
301
- : undefined,
302
- visibility: position.ready ? undefined : "hidden",
303
- }}
304
- onContextMenu={(event) => event.preventDefault()}
305
- >
306
- {position.ready && (
307
- <motion.div
308
- aria-hidden="true"
309
- className="glass-menu pointer-events-none absolute top-0 left-0 -z-10 rounded-3xl"
310
- style={{ width: "100%", height: "100%" }}
311
- initial={reducedMotion || !position.ready ? false : source}
312
- animate={
313
- position.ready
314
- ? {
315
- x: 0,
316
- y: 0,
317
- width: position.width,
318
- height: position.height,
319
- opacity: 1,
320
- borderRadius: position.radius,
321
- }
322
- : undefined
323
- }
324
- exit={
325
- reducedMotion
326
- ? { opacity: 0 }
327
- : { ...menuSurfaceExit(source), transition: fluidCollapse }
328
- }
329
- transition={
330
- reducedMotion
331
- ? { duration: 0 }
332
- : { ...fluidOvershoot, opacity: { duration: 0.16 } }
333
- }
334
- />
335
- )}
336
- <motion.div
337
- ref={content}
338
- className="flex flex-col overflow-y-auto overscroll-contain"
339
- style={{
340
- maxHeight: position.maxHeight
341
- ? Math.max(0, position.maxHeight - 12)
342
- : undefined,
343
- }}
344
- initial={
345
- reducedMotion
346
- ? false
347
- : {
348
- opacity: 0,
349
- scale: 0.96,
350
- y: menu.source && menu.source.top > position.top ? 4 : -4,
351
- }
352
- }
353
- animate={{ opacity: 1, scale: 1, y: 0 }}
354
- exit={{
355
- opacity: 0,
356
- scale: 0.98,
357
- transition: {
358
- duration: reducedMotion ? 0 : 0.06,
359
- },
360
- }}
361
- transition={{
362
- duration: reducedMotion ? 0 : 0.2,
363
- ease: reducedMotion
364
- ? undefined
365
- : ([0.4, 0, 0.2, 1] as [number, number, number, number]),
366
- delay: reducedMotion || !menu.source ? 0 : 0.06,
367
- }}
368
- >
369
- {menu.items.map((item, index) => {
370
- if (item === "divider") {
371
- return <div key={`divider-${index}`} className={dividerSizes[menu.size]} />
372
- }
373
- if ("heading" in item) {
374
- return (
375
- <span key={`heading-${index}`} className={headingSizes[menu.size]}>
376
- {item.heading}
377
- </span>
378
- )
379
- }
380
- if (!item.disabled) actionIndex += 1
381
- const current = actionIndex
382
- return (
383
- <button
384
- key={`item-${index}`}
385
- type="button"
386
- role="menuitem"
387
- tabIndex={-1}
388
- disabled={item.disabled}
389
- onFocus={() => setFocused(current)}
390
- onPointerEnter={() => !item.disabled && setFocused(current)}
391
- onClick={() => {
392
- closeContextMenu()
393
- item.onSelect()
394
- }}
395
- className={cn(
396
- itemSizes[menu.size],
397
- menu.touch ? "py-2.5" : "py-1.5",
398
- item.destructive ? "text-danger" : "text-label",
399
- focused === current &&
400
- !item.disabled &&
401
- (item.destructive ? "bg-danger/10" : "bg-fill")
402
- )}
403
- >
404
- <span className={iconSlotSizes[menu.size]}>
405
- {item.checked ? (
406
- <CheckIcon
407
- size={checkIconSizes[menu.size]}
408
- strokeWidth={2.4}
409
- className="text-accent-text"
410
- />
411
- ) : (
412
- item.icon
413
- )}
414
- </span>
415
- <span className="grow truncate">{item.label}</span>
416
- </button>
417
- )
418
- })}
419
- </motion.div>
420
- </div>
421
- )
256
+ const source = menuSurfaceReveal(
257
+ position.ready ? (menu.toolbarRect ?? menu.source) : undefined,
258
+ position,
259
+ );
260
+ let actionIndex = -1;
261
+ let dividerIndex = 0;
262
+ let headingIndex = 0;
263
+ return (
264
+ <div
265
+ ref={ref}
266
+ role="menu"
267
+ tabIndex={-1}
268
+ onKeyDown={(event) => {
269
+ if (!present) return;
270
+ if (event.key === "Escape") {
271
+ event.preventDefault();
272
+ event.stopPropagation();
273
+ closeContextMenu();
274
+ return;
275
+ }
276
+ if (event.key === "Tab") {
277
+ closeContextMenu();
278
+ return;
279
+ }
280
+ const controls = Array.from(
281
+ ref.current?.querySelectorAll<HTMLButtonElement>(
282
+ "[role=menuitem]:not(:disabled)",
283
+ ) ?? [],
284
+ );
285
+ if (!isMenuNavigationKey(event.key)) {
286
+ if (
287
+ event.key.length !== 1 ||
288
+ !event.key.trim() ||
289
+ event.altKey ||
290
+ event.ctrlKey ||
291
+ event.metaKey ||
292
+ event.nativeEvent.isComposing
293
+ )
294
+ return;
295
+ const now = performance.now();
296
+ const query =
297
+ (now - typeahead.current.at < 700 ? typeahead.current.query : "") +
298
+ event.key;
299
+ typeahead.current = { query, at: now };
300
+ const labels = controls.map((control) => control.textContent ?? "");
301
+ const current = controls.indexOf(
302
+ document.activeElement as HTMLButtonElement,
303
+ );
304
+ let next = findMenuTypeaheadIndex(labels, query, current);
305
+ if (next < 0) {
306
+ typeahead.current.query = event.key;
307
+ next = findMenuTypeaheadIndex(labels, event.key, current);
308
+ }
309
+ if (next >= 0) {
310
+ event.preventDefault();
311
+ controls[next]?.focus();
312
+ }
313
+ return;
314
+ }
315
+ event.preventDefault();
316
+ if (!controls.length) return;
317
+ const current = controls.indexOf(
318
+ document.activeElement as HTMLButtonElement,
319
+ );
320
+ const next = nextMenuIndex(event.key, current, controls.length);
321
+ controls[next]?.focus();
322
+ }}
323
+ {...{ [FLOATING_LAYER]: "" }}
324
+ className={cn(
325
+ "fixed isolate z-75 flex flex-col outline-none",
326
+ surfaceSizes[menu.size],
327
+ menu.touch && "min-w-60",
328
+ !present && "pointer-events-none",
329
+ )}
330
+ style={{
331
+ left: position.left,
332
+ top: position.top,
333
+ maxHeight: position.maxHeight,
334
+ minWidth: menu.toolbarRect
335
+ ? Math.min(
336
+ Math.max(200, menu.toolbarRect.width),
337
+ window.innerWidth - 24,
338
+ )
339
+ : undefined,
340
+ visibility: position.ready ? undefined : "hidden",
341
+ }}
342
+ onContextMenu={(event) => event.preventDefault()}
343
+ >
344
+ {position.ready && (
345
+ <motion.div
346
+ aria-hidden="true"
347
+ className="glass-menu pointer-events-none absolute top-0 left-0 -z-10 rounded-3xl"
348
+ style={{ width: "100%", height: "100%" }}
349
+ initial={reducedMotion || !position.ready ? false : source}
350
+ animate={
351
+ position.ready
352
+ ? {
353
+ x: 0,
354
+ y: 0,
355
+ width: position.width,
356
+ height: position.height,
357
+ opacity: 1,
358
+ borderRadius: position.radius,
359
+ }
360
+ : undefined
361
+ }
362
+ exit={reducedMotion ? { opacity: 0 } : menuSurfaceExit(source)}
363
+ transition={
364
+ reducedMotion
365
+ ? { duration: 0 }
366
+ : { ...fluidOvershoot, opacity: { duration: 0.16 } }
367
+ }
368
+ />
369
+ )}
370
+ <motion.div
371
+ ref={content}
372
+ className="flex flex-col overflow-y-auto overscroll-contain"
373
+ style={{
374
+ maxHeight: position.maxHeight
375
+ ? Math.max(0, position.maxHeight - 12)
376
+ : undefined,
377
+ }}
378
+ initial={
379
+ reducedMotion
380
+ ? false
381
+ : {
382
+ opacity: 0,
383
+ scale: 0.96,
384
+ y: menu.source && menu.source.top > position.top ? 4 : -4,
385
+ }
386
+ }
387
+ animate={{ opacity: 1, scale: 1, y: 0 }}
388
+ exit={{
389
+ opacity: 0,
390
+ scale: 0.98,
391
+ transition: {
392
+ duration: reducedMotion ? 0 : 0.06,
393
+ },
394
+ }}
395
+ transition={{
396
+ duration: reducedMotion ? 0 : 0.2,
397
+ ease: reducedMotion
398
+ ? undefined
399
+ : ([0.4, 0, 0.2, 1] as [number, number, number, number]),
400
+ delay: reducedMotion || !menu.source ? 0 : 0.06,
401
+ }}
402
+ >
403
+ {menu.items.map((item) => {
404
+ if (item === "divider") {
405
+ const key = `divider-${dividerIndex++}`;
406
+ return <div key={key} className={dividerSizes[menu.size]} />;
407
+ }
408
+ if ("heading" in item) {
409
+ const key = `heading-${headingIndex++}`;
410
+ return (
411
+ <span key={key} className={headingSizes[menu.size]}>
412
+ {item.heading}
413
+ </span>
414
+ );
415
+ }
416
+ if (!item.disabled) actionIndex += 1;
417
+ const current = actionIndex;
418
+ const key = `item-${current}`;
419
+ return (
420
+ <button
421
+ key={key}
422
+ type="button"
423
+ role="menuitem"
424
+ tabIndex={-1}
425
+ disabled={item.disabled}
426
+ onFocus={() => setFocused(current)}
427
+ onPointerEnter={() => !item.disabled && setFocused(current)}
428
+ onClick={() => {
429
+ closeContextMenu();
430
+ item.onSelect();
431
+ }}
432
+ className={cn(
433
+ itemSizes[menu.size],
434
+ menu.touch ? "py-2.5" : "py-1.5",
435
+ item.destructive ? "text-danger" : "text-label",
436
+ focused === current &&
437
+ !item.disabled &&
438
+ (item.destructive ? "bg-danger/10" : "bg-fill"),
439
+ )}
440
+ >
441
+ <span className={iconSlotSizes[menu.size]}>
442
+ {item.checked ? (
443
+ <CheckIcon
444
+ size={checkIconSizes[menu.size]}
445
+ strokeWidth={2.4}
446
+ className="text-accent-text"
447
+ />
448
+ ) : (
449
+ item.icon
450
+ )}
451
+ </span>
452
+ <span className="grow truncate">{item.label}</span>
453
+ </button>
454
+ );
455
+ })}
456
+ </motion.div>
457
+ </div>
458
+ );
422
459
  }