@passui/react 0.0.0-stage → 0.1.15

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 (275) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -2
  3. package/package.json +96 -5
  4. package/src/appearance/index.ts +2 -0
  5. package/src/appearance/platform.tsx +123 -0
  6. package/src/appearance/theme.tsx +535 -0
  7. package/src/components/ambient/ambient.docs.tsx +68 -0
  8. package/src/components/ambient/ambient.tsx +43 -0
  9. package/src/components/ambient/examples/ambient-images.tsx +30 -0
  10. package/src/components/ambient/examples/ambient-now-playing.tsx +55 -0
  11. package/src/components/ambient/examples/ambient-showcase.tsx +33 -0
  12. package/src/components/ambient/examples/ambient-usage.tsx +19 -0
  13. package/src/components/ambient/index.ts +1 -0
  14. package/src/components/button/button-styles.ts +60 -0
  15. package/src/components/button/button.docs.tsx +116 -0
  16. package/src/components/button/button.tsx +91 -0
  17. package/src/components/button/examples/button-disabled.tsx +19 -0
  18. package/src/components/button/examples/button-icon.tsx +39 -0
  19. package/src/components/button/examples/button-link.tsx +12 -0
  20. package/src/components/button/examples/button-pending.tsx +27 -0
  21. package/src/components/button/examples/button-showcase.tsx +115 -0
  22. package/src/components/button/examples/button-sizes.tsx +12 -0
  23. package/src/components/button/examples/button-usage.tsx +12 -0
  24. package/src/components/button/examples/button-variants.tsx +15 -0
  25. package/src/components/button/examples/button-with-icon.tsx +32 -0
  26. package/src/components/button/examples/icon-button-player.tsx +38 -0
  27. package/src/components/button/examples/icon-button-showcase.tsx +83 -0
  28. package/src/components/button/examples/icon-button-usage.tsx +11 -0
  29. package/src/components/button/examples/icon-button-variants.tsx +39 -0
  30. package/src/components/button/icon-button.docs.tsx +68 -0
  31. package/src/components/button/icon-button.tsx +73 -0
  32. package/src/components/button/pending-spinner.tsx +14 -0
  33. package/src/components/button/use-pending-indicator.ts +27 -0
  34. package/src/components/chip/chip.docs.tsx +37 -0
  35. package/src/components/chip/chip.tsx +35 -0
  36. package/src/components/chip/examples/chip-in-rows.tsx +19 -0
  37. package/src/components/chip/examples/chip-showcase.tsx +51 -0
  38. package/src/components/chip/examples/chip-tones.tsx +13 -0
  39. package/src/components/chip/examples/chip-usage.tsx +5 -0
  40. package/src/components/compact-date-picker/calendar-month.ts +131 -0
  41. package/src/components/compact-date-picker/compact-date-picker.docs.tsx +81 -0
  42. package/src/components/compact-date-picker/compact-date-picker.tsx +438 -0
  43. package/src/components/compact-date-picker/examples/compact-date-picker-showcase.tsx +35 -0
  44. package/src/components/compact-date-picker/examples/compact-date-picker-usage.tsx +22 -0
  45. package/src/components/compact-time-picker/compact-time-picker.docs.tsx +80 -0
  46. package/src/components/compact-time-picker/compact-time-picker.tsx +432 -0
  47. package/src/components/compact-time-picker/examples/compact-time-picker-showcase.tsx +31 -0
  48. package/src/components/compact-time-picker/examples/compact-time-picker-usage.tsx +21 -0
  49. package/src/components/compact-time-picker/time-value.ts +67 -0
  50. package/src/components/content-view/content-view.docs.tsx +61 -0
  51. package/src/components/content-view/content-view.tsx +197 -0
  52. package/src/components/content-view/examples/content-view-header-action.tsx +33 -0
  53. package/src/components/content-view/examples/content-view-showcase.tsx +65 -0
  54. package/src/components/content-view/examples/content-view-stats.tsx +35 -0
  55. package/src/components/content-view/examples/content-view-usage.tsx +14 -0
  56. package/src/components/content-view/index.ts +1 -0
  57. package/src/components/context-menu/conceal-menu-source.ts +33 -0
  58. package/src/components/context-menu/context-menu-host.tsx +445 -0
  59. package/src/components/context-menu/context-menu-store.ts +165 -0
  60. package/src/components/context-menu/context-menu.docs.tsx +92 -0
  61. package/src/components/context-menu/examples/context-menu-checked.tsx +37 -0
  62. package/src/components/context-menu/examples/context-menu-list.tsx +85 -0
  63. package/src/components/context-menu/examples/context-menu-showcase.tsx +56 -0
  64. package/src/components/context-menu/examples/context-menu-toolbar.tsx +63 -0
  65. package/src/components/context-menu/examples/context-menu-usage.tsx +24 -0
  66. package/src/components/context-menu/menu-position.ts +55 -0
  67. package/src/components/context-menu/menu-reveal.ts +75 -0
  68. package/src/components/context-menu/use-context-menu.ts +90 -0
  69. package/src/components/conversation/conversation.docs.tsx +88 -0
  70. package/src/components/conversation/conversation.tsx +279 -0
  71. package/src/components/conversation/examples/conversation-composer.tsx +36 -0
  72. package/src/components/conversation/examples/conversation-days.tsx +48 -0
  73. package/src/components/conversation/examples/conversation-showcase.tsx +71 -0
  74. package/src/components/conversation/examples/conversation-usage.tsx +28 -0
  75. package/src/components/conversation/index.ts +1 -0
  76. package/src/components/date-picker/date-picker.docs.tsx +88 -0
  77. package/src/components/date-picker/date-picker.tsx +103 -0
  78. package/src/components/date-picker/date-value.ts +37 -0
  79. package/src/components/date-picker/examples/date-picker-locales.tsx +41 -0
  80. package/src/components/date-picker/examples/date-picker-past-only.tsx +29 -0
  81. package/src/components/date-picker/examples/date-picker-showcase.tsx +49 -0
  82. package/src/components/date-picker/examples/date-picker-usage.tsx +22 -0
  83. package/src/components/dialog/dialog-host.tsx +156 -0
  84. package/src/components/dialog/dialog-store.ts +56 -0
  85. package/src/components/dialog/dialog.docs.tsx +114 -0
  86. package/src/components/dialog/dialog.tsx +200 -0
  87. package/src/components/dialog/examples/dialog-confirm.tsx +24 -0
  88. package/src/components/dialog/examples/dialog-form.tsx +46 -0
  89. package/src/components/dialog/examples/dialog-inline.tsx +48 -0
  90. package/src/components/dialog/examples/dialog-showcase.tsx +53 -0
  91. package/src/components/dialog/examples/dialog-usage.tsx +31 -0
  92. package/src/components/dialog/index.ts +1 -0
  93. package/src/components/labeled-content/examples/labeled-content-descriptions.tsx +37 -0
  94. package/src/components/labeled-content/examples/labeled-content-showcase.tsx +54 -0
  95. package/src/components/labeled-content/examples/labeled-content-usage.tsx +22 -0
  96. package/src/components/labeled-content/examples/labeled-content-values.tsx +26 -0
  97. package/src/components/labeled-content/examples/labeled-content-wide.tsx +46 -0
  98. package/src/components/labeled-content/labeled-content.docs.tsx +69 -0
  99. package/src/components/labeled-content/labeled-content.tsx +66 -0
  100. package/src/components/locale-switcher/examples/locale-switcher-native-names.tsx +30 -0
  101. package/src/components/locale-switcher/examples/locale-switcher-showcase.tsx +46 -0
  102. package/src/components/locale-switcher/examples/locale-switcher-translated-page.tsx +35 -0
  103. package/src/components/locale-switcher/examples/locale-switcher-usage.tsx +20 -0
  104. package/src/components/locale-switcher/index.ts +1 -0
  105. package/src/components/locale-switcher/locale-switcher.docs.tsx +91 -0
  106. package/src/components/locale-switcher/locale-switcher.tsx +252 -0
  107. package/src/components/picker/examples/picker-details.tsx +44 -0
  108. package/src/components/picker/examples/picker-searchable.tsx +48 -0
  109. package/src/components/picker/examples/picker-showcase.tsx +66 -0
  110. package/src/components/picker/examples/picker-usage.tsx +20 -0
  111. package/src/components/picker/picker.docs.tsx +68 -0
  112. package/src/components/picker/picker.tsx +493 -0
  113. package/src/components/popover/examples/popover-help.tsx +57 -0
  114. package/src/components/popover/examples/popover-menu.tsx +51 -0
  115. package/src/components/popover/examples/popover-showcase.tsx +61 -0
  116. package/src/components/popover/examples/popover-usage.tsx +31 -0
  117. package/src/components/popover/popover.docs.tsx +84 -0
  118. package/src/components/popover/popover.tsx +138 -0
  119. package/src/components/profile-image/examples/profile-image-fallbacks.tsx +29 -0
  120. package/src/components/profile-image/examples/profile-image-group.tsx +34 -0
  121. package/src/components/profile-image/examples/profile-image-showcase.tsx +30 -0
  122. package/src/components/profile-image/examples/profile-image-usage.tsx +14 -0
  123. package/src/components/profile-image/profile-image.docs.tsx +81 -0
  124. package/src/components/profile-image/profile-image.tsx +80 -0
  125. package/src/components/search-field/examples/search-field-filter.tsx +53 -0
  126. package/src/components/search-field/examples/search-field-showcase.tsx +56 -0
  127. package/src/components/search-field/examples/search-field-toolbar.tsx +27 -0
  128. package/src/components/search-field/examples/search-field-usage.tsx +18 -0
  129. package/src/components/search-field/search-field.docs.tsx +58 -0
  130. package/src/components/search-field/search-field.tsx +29 -0
  131. package/src/components/search-list/examples/search-list-dropdown.tsx +53 -0
  132. package/src/components/search-list/examples/search-list-groups.tsx +64 -0
  133. package/src/components/search-list/examples/search-list-showcase.tsx +67 -0
  134. package/src/components/search-list/examples/search-list-usage.tsx +27 -0
  135. package/src/components/search-list/search-list.docs.tsx +87 -0
  136. package/src/components/search-list/search-list.tsx +265 -0
  137. package/src/components/section/examples/section-actions.tsx +35 -0
  138. package/src/components/section/examples/section-groups.tsx +41 -0
  139. package/src/components/section/examples/section-showcase.tsx +63 -0
  140. package/src/components/section/examples/section-usage.tsx +19 -0
  141. package/src/components/section/section.docs.tsx +51 -0
  142. package/src/components/section/section.tsx +41 -0
  143. package/src/components/secure-field/examples/secure-field-new-password.tsx +56 -0
  144. package/src/components/secure-field/examples/secure-field-reveal.tsx +20 -0
  145. package/src/components/secure-field/examples/secure-field-showcase.tsx +62 -0
  146. package/src/components/secure-field/examples/secure-field-usage.tsx +19 -0
  147. package/src/components/secure-field/secure-field.docs.tsx +72 -0
  148. package/src/components/secure-field/secure-field.tsx +10 -0
  149. package/src/components/segmented-control/examples/segmented-control-overflow.tsx +28 -0
  150. package/src/components/segmented-control/examples/segmented-control-showcase.tsx +59 -0
  151. package/src/components/segmented-control/examples/segmented-control-usage.tsx +20 -0
  152. package/src/components/segmented-control/examples/segmented-control-views.tsx +32 -0
  153. package/src/components/segmented-control/segmented-control.docs.tsx +61 -0
  154. package/src/components/segmented-control/segmented-control.tsx +122 -0
  155. package/src/components/sheet/examples/sheet-choices.tsx +58 -0
  156. package/src/components/sheet/examples/sheet-share.tsx +51 -0
  157. package/src/components/sheet/examples/sheet-showcase.tsx +74 -0
  158. package/src/components/sheet/examples/sheet-usage.tsx +32 -0
  159. package/src/components/sheet/modal-environment.ts +113 -0
  160. package/src/components/sheet/sheet-portal.tsx +89 -0
  161. package/src/components/sheet/sheet.docs.tsx +75 -0
  162. package/src/components/sheet/sheet.tsx +112 -0
  163. package/src/components/sheet/use-sheet-focus.ts +130 -0
  164. package/src/components/sidebar/examples/sidebar-groups.tsx +133 -0
  165. package/src/components/sidebar/examples/sidebar-showcase.tsx +123 -0
  166. package/src/components/sidebar/examples/sidebar-unread.tsx +66 -0
  167. package/src/components/sidebar/examples/sidebar-usage.tsx +52 -0
  168. package/src/components/sidebar/sidebar.docs.tsx +135 -0
  169. package/src/components/sidebar/sidebar.tsx +69 -0
  170. package/src/components/stat-tile/examples/stat-tile-filters.tsx +32 -0
  171. package/src/components/stat-tile/examples/stat-tile-grid.tsx +21 -0
  172. package/src/components/stat-tile/examples/stat-tile-showcase.tsx +52 -0
  173. package/src/components/stat-tile/examples/stat-tile-usage.tsx +13 -0
  174. package/src/components/stat-tile/index.ts +1 -0
  175. package/src/components/stat-tile/stat-tile.docs.tsx +60 -0
  176. package/src/components/stat-tile/stat-tile.tsx +68 -0
  177. package/src/components/status-indicator/examples/status-indicator-live.tsx +34 -0
  178. package/src/components/status-indicator/examples/status-indicator-showcase.tsx +39 -0
  179. package/src/components/status-indicator/examples/status-indicator-states.tsx +17 -0
  180. package/src/components/status-indicator/examples/status-indicator-usage.tsx +9 -0
  181. package/src/components/status-indicator/status-indicator.docs.tsx +43 -0
  182. package/src/components/status-indicator/status-indicator.tsx +35 -0
  183. package/src/components/swatch-picker/examples/swatch-picker-accents.tsx +42 -0
  184. package/src/components/swatch-picker/examples/swatch-picker-labels.tsx +45 -0
  185. package/src/components/swatch-picker/examples/swatch-picker-showcase.tsx +70 -0
  186. package/src/components/swatch-picker/examples/swatch-picker-usage.tsx +39 -0
  187. package/src/components/swatch-picker/swatch-picker.docs.tsx +60 -0
  188. package/src/components/swatch-picker/swatch-picker.tsx +96 -0
  189. package/src/components/tab-bar/examples/tab-bar-five-tabs.tsx +66 -0
  190. package/src/components/tab-bar/examples/tab-bar-minimize.tsx +71 -0
  191. package/src/components/tab-bar/examples/tab-bar-showcase.tsx +82 -0
  192. package/src/components/tab-bar/examples/tab-bar-usage.tsx +54 -0
  193. package/src/components/tab-bar/tab-bar.docs.tsx +136 -0
  194. package/src/components/tab-bar/tab-bar.tsx +148 -0
  195. package/src/components/tab-view/examples/tab-view-keyboard.tsx +39 -0
  196. package/src/components/tab-view/examples/tab-view-settings.tsx +56 -0
  197. package/src/components/tab-view/examples/tab-view-showcase.tsx +53 -0
  198. package/src/components/tab-view/examples/tab-view-usage.tsx +23 -0
  199. package/src/components/tab-view/tab-view.docs.tsx +77 -0
  200. package/src/components/tab-view/tab-view.tsx +130 -0
  201. package/src/components/table/examples/table-empty.tsx +53 -0
  202. package/src/components/table/examples/table-invoices.tsx +102 -0
  203. package/src/components/table/examples/table-selection.tsx +150 -0
  204. package/src/components/table/examples/table-showcase.tsx +115 -0
  205. package/src/components/table/examples/table-usage.tsx +67 -0
  206. package/src/components/table/examples/table-wide.tsx +75 -0
  207. package/src/components/table/table.docs.tsx +131 -0
  208. package/src/components/table/table.tsx +149 -0
  209. package/src/components/text-editor/examples/text-editor-comment.tsx +48 -0
  210. package/src/components/text-editor/examples/text-editor-count.tsx +38 -0
  211. package/src/components/text-editor/examples/text-editor-showcase.tsx +58 -0
  212. package/src/components/text-editor/examples/text-editor-usage.tsx +25 -0
  213. package/src/components/text-editor/text-editor.docs.tsx +61 -0
  214. package/src/components/text-editor/text-editor.tsx +19 -0
  215. package/src/components/text-field/examples/text-field-icons.tsx +72 -0
  216. package/src/components/text-field/examples/text-field-showcase.tsx +50 -0
  217. package/src/components/text-field/examples/text-field-usage.tsx +18 -0
  218. package/src/components/text-field/examples/text-field-validation.tsx +27 -0
  219. package/src/components/text-field/text-field.docs.tsx +118 -0
  220. package/src/components/text-field/text-field.tsx +135 -0
  221. package/src/components/toast/examples/toast-action.tsx +32 -0
  222. package/src/components/toast/examples/toast-promise.tsx +45 -0
  223. package/src/components/toast/examples/toast-showcase.tsx +49 -0
  224. package/src/components/toast/examples/toast-usage.tsx +13 -0
  225. package/src/components/toast/toast-store.ts +166 -0
  226. package/src/components/toast/toast.docs.tsx +79 -0
  227. package/src/components/toast/toaster.tsx +374 -0
  228. package/src/components/toggle/examples/toggle-checkboxes.tsx +43 -0
  229. package/src/components/toggle/examples/toggle-settings.tsx +31 -0
  230. package/src/components/toggle/examples/toggle-showcase.tsx +59 -0
  231. package/src/components/toggle/examples/toggle-usage.tsx +13 -0
  232. package/src/components/toggle/toggle.docs.tsx +69 -0
  233. package/src/components/toggle/toggle.tsx +85 -0
  234. package/src/components/tooltip/examples/tooltip-icon-buttons.tsx +27 -0
  235. package/src/components/tooltip/examples/tooltip-shortcuts.tsx +38 -0
  236. package/src/components/tooltip/examples/tooltip-showcase.tsx +44 -0
  237. package/src/components/tooltip/examples/tooltip-usage.tsx +13 -0
  238. package/src/components/tooltip/tooltip-host.tsx +156 -0
  239. package/src/components/tooltip/tooltip.docs.tsx +56 -0
  240. package/src/components/view-controller/examples/view-controller-action.tsx +27 -0
  241. package/src/components/view-controller/examples/view-controller-settings.tsx +31 -0
  242. package/src/components/view-controller/examples/view-controller-showcase.tsx +45 -0
  243. package/src/components/view-controller/examples/view-controller-usage.tsx +19 -0
  244. package/src/components/view-controller/index.ts +1 -0
  245. package/src/components/view-controller/view-controller.docs.tsx +71 -0
  246. package/src/components/view-controller/view-controller.tsx +81 -0
  247. package/src/docs/index.ts +3 -0
  248. package/src/docs/registry.ts +113 -0
  249. package/src/docs/snippet.ts +17 -0
  250. package/src/docs/types.ts +180 -0
  251. package/src/hooks/scroll/minimize-decision.ts +13 -0
  252. package/src/hooks/use-anchored-placement.ts +43 -0
  253. package/src/hooks/use-dismiss.ts +58 -0
  254. package/src/hooks/use-media-query.ts +106 -0
  255. package/src/hooks/use-minimize-on-scroll.ts +92 -0
  256. package/src/hooks/use-visual-viewport.ts +76 -0
  257. package/src/hooks/viewport/types.ts +19 -0
  258. package/src/hooks/viewport/viewport-metrics.ts +25 -0
  259. package/src/icons/icons.tsx +298 -0
  260. package/src/index.ts +78 -0
  261. package/src/lib/anchored-placement.ts +92 -0
  262. package/src/lib/class-names.ts +21 -0
  263. package/src/lib/date.ts +96 -0
  264. package/src/lib/floating-layers.ts +28 -0
  265. package/src/lib/haptics.ts +25 -0
  266. package/src/lib/language.ts +21 -0
  267. package/src/lib/screen-edges.ts +45 -0
  268. package/src/lib/shapes.ts +70 -0
  269. package/src/motion/transitions.ts +30 -0
  270. package/src/styles/base.css +1 -0
  271. package/src/styles/styles.css +3 -0
  272. package/src/styles/theme.css +1 -0
  273. package/src/styles/typeset.css +1 -0
  274. package/src/styles/utilities.css +1 -0
  275. package/src/tokens.ts +1 -0
@@ -0,0 +1,445 @@
1
+ import {
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 { registerFloatingLayer } from "../../lib/floating-layers"
18
+ import { screenEdges } from "../../lib/screen-edges"
19
+ import { spring } from "../../motion/transitions"
20
+ import { concealMenuSource } from "./conceal-menu-source"
21
+ import { menuPosition } from "./menu-position"
22
+ import { menuReveal, toolbarMenuReveal } from "./menu-reveal"
23
+ import {
24
+ closeContextMenu,
25
+ getContextMenu,
26
+ type OpenMenu,
27
+ subscribeContextMenu,
28
+ } from "./context-menu-store"
29
+
30
+ /** Renders the open context menu. Mount once, near the app root. */
31
+ export function ContextMenuHost() {
32
+ const menu = useSyncExternalStore(
33
+ subscribeContextMenu,
34
+ getContextMenu,
35
+ () => null
36
+ )
37
+ return (
38
+ <AnimatePresence>
39
+ {menu && <Menu key={menu.id} menu={menu} />}
40
+ </AnimatePresence>
41
+ )
42
+ }
43
+
44
+ function Menu({ menu }: { menu: OpenMenu }) {
45
+ const present = useIsPresent()
46
+ const reducedMotion = useReducedMotion()
47
+ const ref = useRef<HTMLDivElement>(null)
48
+ const [position, setPosition] = useState({
49
+ left: menu.x,
50
+ top: menu.y,
51
+ originX: 0,
52
+ originY: 0,
53
+ maxHeight: undefined as number | undefined,
54
+ width: 0,
55
+ height: 0,
56
+ ready: false,
57
+ })
58
+ const [focused, setFocused] = useState(-1)
59
+
60
+ useLayoutEffect(() => {
61
+ if (
62
+ (!menu.source && !menu.toolbar) ||
63
+ !position.ready ||
64
+ !(menu.anchor instanceof HTMLElement)
65
+ )
66
+ return
67
+ const anchor = menu.toolbar ?? menu.anchor
68
+ const surface = ref.current
69
+ const restore = concealMenuSource(
70
+ anchor,
71
+ Boolean(menu.toolbar)
72
+ )
73
+ return () => {
74
+ const restored = restore()
75
+ if (
76
+ restored &&
77
+ menu.toolbar &&
78
+ menu.restoreFocus &&
79
+ menu.anchor instanceof HTMLElement &&
80
+ (document.activeElement === document.body ||
81
+ surface?.contains(document.activeElement))
82
+ )
83
+ menu.anchor.focus({ preventScroll: true })
84
+ }
85
+ }, [menu, position.ready])
86
+
87
+ useLayoutEffect(() => {
88
+ const surface = ref.current
89
+ if (!surface || !present || !position.ready) return
90
+ const unregister = menu.anchor
91
+ ? registerFloatingLayer(surface, menu.anchor)
92
+ : undefined
93
+ surface.focus()
94
+ return unregister
95
+ }, [menu.anchor, present, position.ready])
96
+
97
+ // Keep the whole menu on screen, opening up or left when near an edge. The
98
+ // layout size, not the on-screen one: the menu is still mid-scale here.
99
+ useLayoutEffect(() => {
100
+ if (!ref.current || !present) return
101
+ const viewport = window.visualViewport
102
+ let frame = 0
103
+ const refit = () => {
104
+ const element = ref.current
105
+ if (!element) return
106
+ const edges = screenEdges()
107
+ const left = viewport?.offsetLeft ?? 0
108
+ const top = viewport?.offsetTop ?? 0
109
+ setPosition({
110
+ ...menuPosition(
111
+ menu,
112
+ {
113
+ width: element.offsetWidth,
114
+ height: Math.max(
115
+ element.offsetHeight,
116
+ element.scrollHeight
117
+ ),
118
+ },
119
+ {
120
+ left: left + edges.left,
121
+ top: top + edges.top,
122
+ right:
123
+ left +
124
+ (viewport?.width ??
125
+ window.innerWidth) -
126
+ edges.right,
127
+ bottom:
128
+ top +
129
+ (viewport?.height ??
130
+ window.innerHeight) -
131
+ edges.bottom,
132
+ }
133
+ ),
134
+ ready: true,
135
+ })
136
+ }
137
+ const schedule = () => {
138
+ cancelAnimationFrame(frame)
139
+ frame = requestAnimationFrame(refit)
140
+ }
141
+ refit()
142
+ viewport?.addEventListener("resize", schedule)
143
+ viewport?.addEventListener("scroll", schedule)
144
+ window.addEventListener("resize", schedule)
145
+ return () => {
146
+ cancelAnimationFrame(frame)
147
+ viewport?.removeEventListener("resize", schedule)
148
+ viewport?.removeEventListener("scroll", schedule)
149
+ window.removeEventListener("resize", schedule)
150
+ }
151
+ }, [menu, present])
152
+
153
+ useEffect(() => {
154
+ if (!present) return
155
+ const onPointer = (event: PointerEvent) => {
156
+ if (!ref.current?.contains(event.target as Node))
157
+ closeContextMenu(false)
158
+ }
159
+ const close = () => closeContextMenu(false)
160
+ let scrollIntentUntil = 0
161
+ const markScroll = () => {
162
+ scrollIntentUntil = performance.now() + 2000
163
+ }
164
+ const onScrollKey = (event: KeyboardEvent) => {
165
+ if (
166
+ !ref.current?.contains(
167
+ event.target as Node
168
+ ) &&
169
+ [
170
+ "PageDown",
171
+ "PageUp",
172
+ "Home",
173
+ "End",
174
+ " ",
175
+ ].includes(event.key)
176
+ )
177
+ markScroll()
178
+ }
179
+ const onScroll = (event: Event) => {
180
+ if (
181
+ performance.now() < scrollIntentUntil &&
182
+ !ref.current?.contains(event.target as Node)
183
+ )
184
+ close()
185
+ }
186
+ // Opened by a long-press: ignore the finger lifting off.
187
+ const timer = setTimeout(() => {
188
+ window.addEventListener(
189
+ "pointerdown",
190
+ onPointer,
191
+ true
192
+ )
193
+ }, 0)
194
+ window.addEventListener("blur", close)
195
+ window.addEventListener("wheel", markScroll, {
196
+ capture: true,
197
+ passive: true,
198
+ })
199
+ window.addEventListener("touchmove", markScroll, {
200
+ capture: true,
201
+ passive: true,
202
+ })
203
+ window.addEventListener("keydown", onScrollKey, true)
204
+ window.addEventListener("scroll", onScroll, true)
205
+ return () => {
206
+ clearTimeout(timer)
207
+ window.removeEventListener(
208
+ "pointerdown",
209
+ onPointer,
210
+ true
211
+ )
212
+ window.removeEventListener("blur", close)
213
+ window.removeEventListener(
214
+ "wheel",
215
+ markScroll,
216
+ true
217
+ )
218
+ window.removeEventListener(
219
+ "touchmove",
220
+ markScroll,
221
+ true
222
+ )
223
+ window.removeEventListener(
224
+ "keydown",
225
+ onScrollKey,
226
+ true
227
+ )
228
+ window.removeEventListener(
229
+ "scroll",
230
+ onScroll,
231
+ true
232
+ )
233
+ }
234
+ }, [present])
235
+
236
+ const source = menuReveal(
237
+ position.ready ? menu.source : undefined,
238
+ position
239
+ )
240
+ const toolbarReveal =
241
+ menu.source && menu.toolbarRect
242
+ ? toolbarMenuReveal(
243
+ menu.source,
244
+ menu.toolbarRect,
245
+ position
246
+ )
247
+ : undefined
248
+ let actionIndex = -1
249
+ return (
250
+ <motion.div
251
+ key={position.ready ? "surface" : "measure"}
252
+ ref={ref}
253
+ role="menu"
254
+ tabIndex={-1}
255
+ onKeyDown={(event) => {
256
+ if (!present) return
257
+ if (event.key === "Escape") {
258
+ event.preventDefault()
259
+ event.stopPropagation()
260
+ closeContextMenu()
261
+ return
262
+ }
263
+ if (event.key === "Tab") {
264
+ closeContextMenu()
265
+ return
266
+ }
267
+ const keys = [
268
+ "ArrowDown",
269
+ "ArrowUp",
270
+ "Home",
271
+ "End",
272
+ ]
273
+ if (!keys.includes(event.key)) return
274
+ event.preventDefault()
275
+ const controls = Array.from(
276
+ ref.current?.querySelectorAll<HTMLButtonElement>(
277
+ "[role=menuitem]:not(:disabled)"
278
+ ) ?? []
279
+ )
280
+ if (!controls.length) return
281
+ const current = controls.indexOf(
282
+ document.activeElement as HTMLButtonElement
283
+ )
284
+ const next =
285
+ event.key === "Home"
286
+ ? 0
287
+ : event.key === "End"
288
+ ? controls.length - 1
289
+ : event.key === "ArrowDown"
290
+ ? (current + 1) % controls.length
291
+ : current < 0
292
+ ? controls.length - 1
293
+ : (current -
294
+ 1 +
295
+ controls.length) %
296
+ controls.length
297
+ controls[next]?.focus()
298
+ }}
299
+ {...{ [FLOATING_LAYER]: "" }}
300
+ className={cn(
301
+ "glass-menu fixed z-75 flex min-w-50 max-w-[min(280px,calc(100vw-24px))] flex-col overflow-y-auto overscroll-contain rounded-3xl p-1.5",
302
+ menu.touch && "min-w-60",
303
+ !present && "pointer-events-none"
304
+ )}
305
+ style={{
306
+ left: position.left,
307
+ top: position.top,
308
+ maxHeight: position.maxHeight,
309
+ minWidth: menu.toolbarRect
310
+ ? Math.min(
311
+ Math.max(
312
+ 200,
313
+ menu.toolbarRect.width
314
+ ),
315
+ window.innerWidth - 24
316
+ )
317
+ : undefined,
318
+ transformOrigin: menu.source
319
+ ? "50% 50%"
320
+ : `${position.originX * 100}% ${position.originY * 100}%`,
321
+ visibility: position.ready
322
+ ? undefined
323
+ : "hidden",
324
+ }}
325
+ initial={reducedMotion ? false : source}
326
+ animate={
327
+ toolbarReveal && !reducedMotion
328
+ ? toolbarReveal.enter
329
+ : {
330
+ opacity: 1,
331
+ x: 0,
332
+ y: 0,
333
+ scaleX: 1,
334
+ scaleY: 1,
335
+ }
336
+ }
337
+ exit={
338
+ reducedMotion
339
+ ? { opacity: 0 }
340
+ : (toolbarReveal?.exit ?? source)
341
+ }
342
+ transition={
343
+ reducedMotion
344
+ ? { duration: 0 }
345
+ : toolbarReveal
346
+ ? {
347
+ duration: 0.34,
348
+ times: present
349
+ ? [0, 0.35, 1]
350
+ : [0, 0.65, 1],
351
+ ease: [0.22, 1, 0.36, 1],
352
+ }
353
+ : spring.smooth
354
+ }
355
+ onContextMenu={(event) => event.preventDefault()}
356
+ >
357
+ <motion.div
358
+ className="flex flex-col"
359
+ initial={{ opacity: reducedMotion ? 1 : 0 }}
360
+ animate={{ opacity: 1 }}
361
+ transition={{
362
+ duration: reducedMotion ? 0 : 0.12,
363
+ delay:
364
+ reducedMotion || !menu.source
365
+ ? 0
366
+ : menu.toolbar
367
+ ? 0.14
368
+ : 0.08,
369
+ }}
370
+ >
371
+ {menu.items.map((item, index) => {
372
+ if (item === "divider") {
373
+ return (
374
+ <div
375
+ key={`divider-${index}`}
376
+ className="mx-2 my-1 h-px bg-separator/80"
377
+ />
378
+ )
379
+ }
380
+ if ("heading" in item) {
381
+ return (
382
+ <span
383
+ key={`heading-${item.heading}`}
384
+ className="px-2.5 pt-1.5 pb-0.5 text-caption2 font-semibold text-label-tertiary"
385
+ >
386
+ {item.heading}
387
+ </span>
388
+ )
389
+ }
390
+ if (!item.disabled) actionIndex += 1
391
+ const current = actionIndex
392
+ return (
393
+ <button
394
+ key={item.label}
395
+ type="button"
396
+ role="menuitem"
397
+ tabIndex={-1}
398
+ disabled={item.disabled}
399
+ onFocus={() =>
400
+ setFocused(current)
401
+ }
402
+ onPointerEnter={() =>
403
+ !item.disabled &&
404
+ setFocused(current)
405
+ }
406
+ onClick={() => {
407
+ closeContextMenu()
408
+ item.onSelect()
409
+ }}
410
+ className={cn(
411
+ "flex items-center gap-2.5 rounded-lg px-2.5 text-start text-subheadline transition-colors disabled:opacity-40",
412
+ menu.touch
413
+ ? "py-2.5"
414
+ : "py-1.5",
415
+ item.destructive
416
+ ? "text-danger"
417
+ : "text-label",
418
+ focused === current &&
419
+ !item.disabled &&
420
+ (item.destructive
421
+ ? "bg-danger/10"
422
+ : "bg-fill")
423
+ )}
424
+ >
425
+ <span className="flex w-4 shrink-0 justify-center text-label-secondary">
426
+ {item.checked ? (
427
+ <CheckIcon
428
+ size={15}
429
+ strokeWidth={2.4}
430
+ className="text-accent-text"
431
+ />
432
+ ) : (
433
+ item.icon
434
+ )}
435
+ </span>
436
+ <span className="grow truncate">
437
+ {item.label}
438
+ </span>
439
+ </button>
440
+ )
441
+ })}
442
+ </motion.div>
443
+ </motion.div>
444
+ )
445
+ }
@@ -0,0 +1,165 @@
1
+ import type { ReactNode } from "react"
2
+ import type { MenuRect } from "./menu-reveal"
3
+
4
+ export type ContextMenuAlignX = "start" | "end"
5
+ export type ContextMenuAlignY = "top" | "bottom"
6
+ export type ContextMenuEdge = "leading" | "trailing"
7
+
8
+ /** One row of a context menu, or a divider between groups of rows. */
9
+ export type ContextMenuItem =
10
+ | {
11
+ label: string
12
+ icon?: ReactNode
13
+ onSelect: () => void
14
+ destructive?: boolean
15
+ disabled?: boolean
16
+ /** Marks the current choice, e.g. a note's kind. */
17
+ checked?: boolean
18
+ }
19
+ | { heading: string }
20
+ | "divider"
21
+
22
+ export interface OpenMenu {
23
+ id: number
24
+ anchor: Element | null
25
+ x: number
26
+ y: number
27
+ items: ContextMenuItem[]
28
+ /** Opened by long-press: bigger rows for fingers. */
29
+ touch: boolean
30
+ /** Which edge of the menu sits at `x`: its left (`start`) or its right (`end`). */
31
+ alignX: ContextMenuAlignX
32
+ /** Which edge of the menu sits at `y`: its top (`top`) or its bottom (`bottom`). */
33
+ alignY: ContextMenuAlignY
34
+ source?: MenuRect
35
+ toolbar?: HTMLElement
36
+ toolbarRect?: MenuRect
37
+ restoreFocus?: boolean
38
+ }
39
+
40
+ let menu: OpenMenu | null = null
41
+ /** The menu shown most recently, kept after it closes so a follow-up can stand in its place. */
42
+ let last: OpenMenu | null = null
43
+ let nextId = 1
44
+ const listeners = new Set<() => void>()
45
+ const emit = () => {
46
+ for (const listener of listeners) listener()
47
+ }
48
+
49
+ /** Opens a menu at a point, such as where a pointer was pressed. */
50
+ export function openContextMenu(
51
+ x: number,
52
+ y: number,
53
+ items: ContextMenuItem[],
54
+ touch = false,
55
+ anchor: Element | null = typeof document === "undefined"
56
+ ? null
57
+ : document.activeElement
58
+ ) {
59
+ menu = {
60
+ id: nextId++,
61
+ anchor,
62
+ x,
63
+ y,
64
+ items,
65
+ touch,
66
+ alignX: "start",
67
+ alignY: "top",
68
+ }
69
+ last = menu
70
+ emit()
71
+ }
72
+
73
+ /**
74
+ * Opens a second menu where the last one stood, the way an iOS menu swaps
75
+ * in a submenu: a long list (a note's fonts, its kinds) stays one row in the
76
+ * first menu instead of making it tall.
77
+ */
78
+ export function openFollowUpMenu(items: ContextMenuItem[]) {
79
+ if (!last) return
80
+ menu = { ...last, id: nextId++, items, source: undefined }
81
+ last = menu
82
+ emit()
83
+ }
84
+
85
+ /**
86
+ * Opens a menu from a control, the way a SwiftUI `Menu` does: under it (or
87
+ * above it), lined up with its leading or trailing edge. Leading and
88
+ * trailing follow the text direction, so right-to-left languages mirror.
89
+ * The menu's real size decides where it goes; nothing is guessed.
90
+ */
91
+ export function openMenu(
92
+ anchor: Element,
93
+ items: ContextMenuItem[],
94
+ {
95
+ edge = "leading",
96
+ above = false,
97
+ toolbar,
98
+ }: {
99
+ edge?: ContextMenuEdge
100
+ above?: boolean
101
+ /** Replace this action group with a horizontally expanding menu surface. */
102
+ toolbar?: HTMLElement
103
+ } = {}
104
+ ) {
105
+ const rect = anchor.getBoundingClientRect()
106
+ const toolbarRect = toolbar?.getBoundingClientRect()
107
+ const rtl = getComputedStyle(anchor).direction === "rtl"
108
+ // Trailing is the right edge in left-to-right text, the left edge in right-to-left.
109
+ const right = (edge === "trailing") !== rtl
110
+ const gap = 6
111
+ menu = {
112
+ id: nextId++,
113
+ anchor,
114
+ x: right
115
+ ? (toolbarRect ?? rect).right
116
+ : (toolbarRect ?? rect).left,
117
+ y: toolbar
118
+ ? rect.top
119
+ : above
120
+ ? rect.top - gap
121
+ : rect.bottom + gap,
122
+ items,
123
+ touch: false,
124
+ alignX: right ? "end" : "start",
125
+ alignY: above ? "bottom" : "top",
126
+ toolbar,
127
+ toolbarRect,
128
+ source: {
129
+ left: rect.left,
130
+ top: rect.top,
131
+ width: rect.width,
132
+ height: rect.height,
133
+ },
134
+ }
135
+ last = menu
136
+ emit()
137
+ }
138
+
139
+ export function closeContextMenu(restoreFocus = true) {
140
+ if (!menu) return
141
+ const anchor = menu.anchor
142
+ const toolbar = menu.toolbar
143
+ menu.restoreFocus = restoreFocus
144
+ menu = null
145
+ if (
146
+ restoreFocus &&
147
+ !toolbar &&
148
+ typeof HTMLElement !== "undefined" &&
149
+ anchor instanceof HTMLElement &&
150
+ anchor.isConnected
151
+ )
152
+ anchor.focus()
153
+ emit()
154
+ }
155
+
156
+ export function subscribeContextMenu(listener: () => void) {
157
+ listeners.add(listener)
158
+ return () => {
159
+ listeners.delete(listener)
160
+ }
161
+ }
162
+
163
+ export function getContextMenu(): OpenMenu | null {
164
+ return menu
165
+ }
@@ -0,0 +1,92 @@
1
+ import { ContextMenuToolbar } from "./examples/context-menu-toolbar"
2
+ import { ContextMenuChecked } from "./examples/context-menu-checked"
3
+ import { toast } from "../toast/toast-store"
4
+ import { defineDocs } from "../../docs/types"
5
+ import type { ContextMenuItem } from "./context-menu-store"
6
+ import { ContextMenuList } from "./examples/context-menu-list"
7
+ import { ContextMenuShowcase } from "./examples/context-menu-showcase"
8
+ import { ContextMenuUsage } from "./examples/context-menu-usage"
9
+ import { useContextMenu } from "./use-context-menu"
10
+
11
+ // useContextMenu is a hook: the playground spreads its handlers on a
12
+ // card, and the switches change the menu's rows.
13
+ type DemoProps = {
14
+ heading: boolean
15
+ destructive: boolean
16
+ }
17
+
18
+ function items({
19
+ heading,
20
+ destructive,
21
+ }: DemoProps): ContextMenuItem[] {
22
+ return [
23
+ ...(heading ? [{ heading: "Photo" }] : []),
24
+ { label: "Copy", onSelect: () => toast("Copied") },
25
+ { label: "Share", onSelect: () => toast("Shared") },
26
+ ...(destructive
27
+ ? [
28
+ "divider" as const,
29
+ {
30
+ label: "Delete",
31
+ destructive: true,
32
+ onSelect: () => toast("Deleted"),
33
+ },
34
+ ]
35
+ : []),
36
+ ]
37
+ }
38
+
39
+ function ContextMenuDemo(props: DemoProps) {
40
+ const menu = useContextMenu(() => items(props))
41
+ return (
42
+ <div
43
+ {...menu}
44
+ className="flex h-36 w-64 select-none items-center justify-center rounded-4xl bg-fill/60 p-6 text-center text-callout text-label-secondary"
45
+ >
46
+ Right-click, or press and hold on a touch screen
47
+ </div>
48
+ )
49
+ }
50
+
51
+ export const contextMenuDocs = defineDocs<DemoProps>()({
52
+ slug: "context-menu",
53
+ name: "useContextMenu",
54
+ importPath: "@passui/react",
55
+ controls: {
56
+ heading: { kind: "boolean", default: false },
57
+ destructive: { kind: "boolean", default: true },
58
+ },
59
+ render: (values) => <ContextMenuDemo {...values} />,
60
+ childrenSnippet: ({ heading, destructive }) => ({
61
+ imports: ['import { toast } from "@passui/react"'],
62
+ element: "div",
63
+ omit: ["heading", "destructive"],
64
+ setup: [
65
+ "const menu = useContextMenu(() => [",
66
+ ...(heading ? [' { heading: "Photo" },'] : []),
67
+ ' { label: "Copy", onSelect: () => toast("Copied") },',
68
+ ' { label: "Share", onSelect: () => toast("Shared") },',
69
+ ...(destructive
70
+ ? [
71
+ ' "divider",',
72
+ ' { label: "Delete", destructive: true, onSelect: () => toast("Deleted") },',
73
+ ]
74
+ : []),
75
+ "])",
76
+ ],
77
+ spreadProps: ["menu"],
78
+ children: "\n Right-click me\n",
79
+ }),
80
+ usage: ContextMenuUsage,
81
+ showcase: ContextMenuShowcase,
82
+ examples: [
83
+ { id: "toolbar", component: ContextMenuToolbar },
84
+ {
85
+ id: "list",
86
+ component: ContextMenuList,
87
+ playground: { heading: true },
88
+ },
89
+ { id: "checked", component: ContextMenuChecked },
90
+ ],
91
+ previewHeight: 360,
92
+ })