@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,66 @@
1
+ import {
2
+ LabeledContent,
3
+ Picker,
4
+ Section,
5
+ } from "@passui/react"
6
+ import { useState } from "react"
7
+
8
+ export function PickerShowcase() {
9
+ const [language, setLanguage] = useState("en")
10
+ const [region, setRegion] = useState("ke")
11
+ const [calendar, setCalendar] = useState("gregorian")
12
+
13
+ return (
14
+ <div className="w-full max-w-sm">
15
+ <Section
16
+ title="Language and region"
17
+ footer="Dates, numbers and currency follow your region."
18
+ >
19
+ <LabeledContent label="Language">
20
+ <Picker
21
+ label="Language"
22
+ value={language}
23
+ onChange={setLanguage}
24
+ options={[
25
+ { value: "en", label: "English" },
26
+ {
27
+ value: "fr",
28
+ label: "Français",
29
+ },
30
+ { value: "zh", label: "中文" },
31
+ ]}
32
+ />
33
+ </LabeledContent>
34
+ <LabeledContent label="Region">
35
+ <Picker
36
+ label="Region"
37
+ value={region}
38
+ onChange={setRegion}
39
+ options={[
40
+ { value: "ke", label: "Kenya" },
41
+ { value: "fr", label: "France" },
42
+ { value: "jp", label: "Japan" },
43
+ ]}
44
+ />
45
+ </LabeledContent>
46
+ <LabeledContent label="Calendar">
47
+ <Picker
48
+ label="Calendar"
49
+ value={calendar}
50
+ onChange={setCalendar}
51
+ options={[
52
+ {
53
+ value: "gregorian",
54
+ label: "Gregorian",
55
+ },
56
+ {
57
+ value: "japanese",
58
+ label: "Japanese",
59
+ },
60
+ ]}
61
+ />
62
+ </LabeledContent>
63
+ </Section>
64
+ </div>
65
+ )
66
+ }
@@ -0,0 +1,20 @@
1
+ import { Picker } from "@passui/react"
2
+ import { useState } from "react"
3
+
4
+ export function PickerUsage() {
5
+ const [sort, setSort] = useState("recent")
6
+ return (
7
+ <div className="w-full max-w-xs">
8
+ <Picker
9
+ label="Sort by"
10
+ value={sort}
11
+ onChange={setSort}
12
+ options={[
13
+ { value: "recent", label: "Most recent" },
14
+ { value: "name", label: "Name" },
15
+ { value: "size", label: "Size" },
16
+ ]}
17
+ />
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,68 @@
1
+ import { PickerDetails } from "./examples/picker-details"
2
+ import { useState } from "react"
3
+ import { defineDocs } from "../../docs/types"
4
+ import { PickerSearchable } from "./examples/picker-searchable"
5
+ import { PickerShowcase } from "./examples/picker-showcase"
6
+ import { PickerUsage } from "./examples/picker-usage"
7
+ import { Picker } from "./picker"
8
+
9
+ type DemoProps = { label: string; searchable?: boolean }
10
+
11
+ function PickerDemo(props: DemoProps) {
12
+ const [size, setSize] = useState("medium")
13
+ return (
14
+ <div className="w-full max-w-xs">
15
+ <Picker
16
+ {...props}
17
+ value={size}
18
+ onChange={setSize}
19
+ searchPlaceholder="Search"
20
+ options={[
21
+ { value: "small", label: "Small" },
22
+ { value: "medium", label: "Medium" },
23
+ { value: "large", label: "Large" },
24
+ {
25
+ value: "extra-large",
26
+ label: "Extra large",
27
+ },
28
+ ]}
29
+ />
30
+ </div>
31
+ )
32
+ }
33
+
34
+ export const pickerDocs = defineDocs<DemoProps>()({
35
+ slug: "picker",
36
+ name: "Picker",
37
+ importPath: "@passui/react",
38
+ controls: {
39
+ label: {
40
+ kind: "text",
41
+ default: "Text size",
42
+ required: true,
43
+ },
44
+ searchable: { kind: "boolean", default: false },
45
+ },
46
+ render: (values) => <PickerDemo {...values} />,
47
+ childrenSnippet: () => ({
48
+ imports: ['import { useState } from "react"'],
49
+ setup: ['const [size, setSize] = useState("medium")'],
50
+ children: "",
51
+ expressionProps: {
52
+ value: "size",
53
+ onChange: "setSize",
54
+ options:
55
+ '[{ value: "small", label: "Small" }, { value: "medium", label: "Medium" }, { value: "large", label: "Large" }]',
56
+ },
57
+ }),
58
+ usage: PickerUsage,
59
+ showcase: PickerShowcase,
60
+ examples: [
61
+ {
62
+ id: "searchable",
63
+ component: PickerSearchable,
64
+ playground: { searchable: true },
65
+ },
66
+ { id: "details", component: PickerDetails },
67
+ ],
68
+ })
@@ -0,0 +1,493 @@
1
+ import {
2
+ AnimatePresence,
3
+ motion,
4
+ useReducedMotion,
5
+ } from "motion/react"
6
+ import {
7
+ type KeyboardEvent,
8
+ useCallback,
9
+ useEffect,
10
+ useId,
11
+ useLayoutEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ } from "react"
16
+ import { createPortal } from "react-dom"
17
+ import { FLOATING_LAYER } from "../../hooks/use-dismiss"
18
+ import {
19
+ CheckIcon,
20
+ SearchIcon,
21
+ SelectorIcon,
22
+ } from "../../icons/icons"
23
+ import { cn } from "../../lib/class-names"
24
+ import {
25
+ type AnchoredPlacement,
26
+ placeInViewport,
27
+ } from "../../lib/anchored-placement"
28
+ import { registerFloatingLayer } from "../../lib/floating-layers"
29
+ import { haptic } from "../../lib/haptics"
30
+ import { screenEdges } from "../../lib/screen-edges"
31
+ import { spring } from "../../motion/transitions"
32
+
33
+ export interface PickerOption<T extends string> {
34
+ value: T
35
+ label: string
36
+ /** A quieter second line or trailing note, such as "Free" or a region. */
37
+ detail?: string
38
+ /** The option's own language, so it is spoken and shaped right. */
39
+ lang?: string
40
+ }
41
+
42
+ /** Long lists get a filter field, as in the system pickers. */
43
+ const SEARCH_FROM = 10
44
+
45
+ /**
46
+ * A pop-up button: shows the chosen value and opens a list that floats
47
+ * above everything, flips upward near the bottom edge and filters long
48
+ * lists. Replaces the browser's own select, which looks foreign on every
49
+ * platform.
50
+ */
51
+ export function Picker<T extends string>({
52
+ value,
53
+ options,
54
+ onChange,
55
+ label,
56
+ placeholder,
57
+ searchable,
58
+ searchPlaceholder = "Search",
59
+ emptyLabel = "No matches",
60
+ className,
61
+ }: {
62
+ value: T
63
+ options: ReadonlyArray<PickerOption<T>>
64
+ onChange: (value: T) => void
65
+ /** Read by assistive technology; the visible label is the field around it. */
66
+ label: string
67
+ placeholder?: string
68
+ /** Defaults to on for lists longer than ten. */
69
+ searchable?: boolean
70
+ searchPlaceholder?: string
71
+ /** Shown when the filter matches nothing. */
72
+ emptyLabel?: string
73
+ className?: string
74
+ }) {
75
+ const [open, setOpen] = useState(false)
76
+ const trigger = useRef<HTMLButtonElement>(null)
77
+ const listId = useId()
78
+ const selected = options.find(
79
+ (option) => option.value === value
80
+ )
81
+
82
+ const close = useCallback((refocus: boolean) => {
83
+ setOpen(false)
84
+ if (refocus) trigger.current?.focus()
85
+ }, [])
86
+
87
+ const onTriggerKey = (
88
+ event: KeyboardEvent<HTMLButtonElement>
89
+ ) => {
90
+ if (
91
+ event.key === "ArrowDown" ||
92
+ event.key === "ArrowUp"
93
+ ) {
94
+ event.preventDefault()
95
+ setOpen(true)
96
+ }
97
+ }
98
+
99
+ return (
100
+ <>
101
+ <button
102
+ ref={trigger}
103
+ type="button"
104
+ role="combobox"
105
+ aria-label={label}
106
+ aria-haspopup="listbox"
107
+ aria-expanded={open}
108
+ aria-controls={open ? listId : undefined}
109
+ onClick={() => setOpen((current) => !current)}
110
+ onKeyDown={onTriggerKey}
111
+ className={cn(
112
+ "flex w-full min-w-0 items-center gap-2 rounded-lg control-field px-3 py-2 text-start text-subheadline text-label",
113
+ className
114
+ )}
115
+ >
116
+ <span
117
+ lang={selected?.lang}
118
+ className={cn(
119
+ "min-w-0 grow truncate",
120
+ !selected && "text-label-tertiary"
121
+ )}
122
+ >
123
+ {selected?.label ?? placeholder ?? ""}
124
+ </span>
125
+ <SelectorIcon
126
+ size={15}
127
+ strokeWidth={2}
128
+ className="shrink-0 text-label-tertiary"
129
+ />
130
+ </button>
131
+ {typeof document !== "undefined" &&
132
+ createPortal(
133
+ <AnimatePresence>
134
+ {open && trigger.current && (
135
+ <SelectList
136
+ id={listId}
137
+ anchor={trigger.current}
138
+ options={options}
139
+ value={value}
140
+ label={label}
141
+ searchable={
142
+ searchable ??
143
+ options.length >
144
+ SEARCH_FROM
145
+ }
146
+ searchPlaceholder={
147
+ searchPlaceholder
148
+ }
149
+ emptyLabel={emptyLabel}
150
+ onPick={(next) => {
151
+ close(true)
152
+ if (next === value) return
153
+ haptic("selection")
154
+ onChange(next)
155
+ }}
156
+ onClose={close}
157
+ />
158
+ )}
159
+ </AnimatePresence>,
160
+ document.body
161
+ )}
162
+ </>
163
+ )
164
+ }
165
+
166
+ function place(anchor: HTMLElement): AnchoredPlacement {
167
+ const rect = anchor.getBoundingClientRect()
168
+ const viewport = window.visualViewport
169
+ const frame = {
170
+ anchor: {
171
+ top: rect.top,
172
+ bottom: rect.bottom,
173
+ left: rect.left,
174
+ },
175
+ viewport: {
176
+ width: viewport?.width ?? window.innerWidth,
177
+ height: viewport?.height ?? window.innerHeight,
178
+ },
179
+ edges: screenEdges(),
180
+ }
181
+ const width = Math.min(Math.max(rect.width, 220), 360)
182
+ const placed = placeInViewport(frame, width, 240)
183
+ // Long lists scroll rather than stretch past 420 px.
184
+ return {
185
+ ...placed,
186
+ maxHeight: Math.min(placed.maxHeight ?? 420, 420),
187
+ }
188
+ }
189
+
190
+ function SelectList<T extends string>({
191
+ id,
192
+ anchor,
193
+ options,
194
+ value,
195
+ label,
196
+ searchable,
197
+ searchPlaceholder,
198
+ emptyLabel,
199
+ onPick,
200
+ onClose,
201
+ }: {
202
+ id: string
203
+ anchor: HTMLElement
204
+ options: ReadonlyArray<PickerOption<T>>
205
+ value: T
206
+ label: string
207
+ searchable: boolean
208
+ searchPlaceholder: string
209
+ emptyLabel: string
210
+ onPick: (value: T) => void
211
+ onClose: (refocus: boolean) => void
212
+ }) {
213
+ const root = useRef<HTMLDivElement>(null)
214
+ const reducedMotion = useReducedMotion()
215
+ const list = useRef<HTMLDivElement>(null)
216
+ const search = useRef<HTMLInputElement>(null)
217
+ const [placement, setPlacement] = useState(() =>
218
+ place(anchor)
219
+ )
220
+ const [query, setQuery] = useState("")
221
+ const typed = useRef({ text: "", at: 0 })
222
+
223
+ const shown = useMemo(() => {
224
+ const needle = query.trim().toLocaleLowerCase()
225
+ if (!needle) return options
226
+ return options.filter((option) =>
227
+ `${option.label} ${option.detail ?? ""} ${option.value}`
228
+ .toLocaleLowerCase()
229
+ .includes(needle)
230
+ )
231
+ }, [options, query])
232
+
233
+ const [active, setActive] = useState(() =>
234
+ Math.max(
235
+ 0,
236
+ options.findIndex(
237
+ (option) => option.value === value
238
+ )
239
+ )
240
+ )
241
+ const activeOption =
242
+ shown[Math.min(active, shown.length - 1)]
243
+
244
+ useLayoutEffect(() => {
245
+ if (!root.current) return
246
+ const unregister = registerFloatingLayer(
247
+ root.current,
248
+ anchor
249
+ )
250
+ if (searchable) search.current?.focus()
251
+ else list.current?.focus()
252
+ return unregister
253
+ }, [searchable, anchor])
254
+
255
+ // The chosen option starts in view, then follows the keyboard.
256
+ useEffect(() => {
257
+ if (!activeOption) return
258
+ list.current
259
+ ?.querySelector(
260
+ `[data-value="${CSS.escape(activeOption.value)}"]`
261
+ )
262
+ ?.scrollIntoView({ block: "nearest" })
263
+ }, [activeOption])
264
+
265
+ useEffect(() => {
266
+ const onPointer = (event: PointerEvent) => {
267
+ const target = event.target as Node
268
+ if (
269
+ !root.current?.contains(target) &&
270
+ !anchor.contains(target)
271
+ )
272
+ onClose(false)
273
+ }
274
+ const onScroll = (event: Event) => {
275
+ if (!root.current?.contains(event.target as Node))
276
+ setPlacement(place(anchor))
277
+ }
278
+ const onResize = () => setPlacement(place(anchor))
279
+ const onBlur = () => onClose(false)
280
+ document.addEventListener(
281
+ "pointerdown",
282
+ onPointer,
283
+ true
284
+ )
285
+ window.addEventListener("scroll", onScroll, true)
286
+ window.addEventListener("resize", onResize)
287
+ window.visualViewport?.addEventListener(
288
+ "resize",
289
+ onResize
290
+ )
291
+ window.addEventListener("blur", onBlur)
292
+ return () => {
293
+ document.removeEventListener(
294
+ "pointerdown",
295
+ onPointer,
296
+ true
297
+ )
298
+ window.removeEventListener(
299
+ "scroll",
300
+ onScroll,
301
+ true
302
+ )
303
+ window.removeEventListener("resize", onResize)
304
+ window.visualViewport?.removeEventListener(
305
+ "resize",
306
+ onResize
307
+ )
308
+ window.removeEventListener("blur", onBlur)
309
+ }
310
+ }, [anchor, onClose])
311
+
312
+ const onKeyDown = (event: KeyboardEvent) => {
313
+ const last = shown.length - 1
314
+ const step = (to: number) => {
315
+ event.preventDefault()
316
+ setActive(Math.max(0, Math.min(last, to)))
317
+ }
318
+ const current = Math.min(active, last)
319
+ if (event.key === "ArrowDown") step(current + 1)
320
+ else if (event.key === "ArrowUp") step(current - 1)
321
+ else if (event.key === "Home" && !searchable) step(0)
322
+ else if (event.key === "End" && !searchable)
323
+ step(last)
324
+ else if (event.key === "PageDown") step(current + 8)
325
+ else if (event.key === "PageUp") step(current - 8)
326
+ else if (
327
+ event.key === "Enter" ||
328
+ (event.key === " " && !searchable)
329
+ ) {
330
+ event.preventDefault()
331
+ if (activeOption) onPick(activeOption.value)
332
+ } else if (event.key === "Escape") {
333
+ event.preventDefault()
334
+ event.stopPropagation()
335
+ onClose(true)
336
+ } else if (event.key === "Tab") {
337
+ onClose(false)
338
+ } else if (
339
+ !searchable &&
340
+ event.key.length === 1 &&
341
+ !event.metaKey &&
342
+ !event.ctrlKey
343
+ ) {
344
+ // Type to jump, as in a native pop-up button.
345
+ const now = Date.now()
346
+ const text =
347
+ (now - typed.current.at < 700
348
+ ? typed.current.text
349
+ : "") + event.key
350
+ typed.current = { text, at: now }
351
+ const match = shown.findIndex((option) =>
352
+ option.label
353
+ .toLocaleLowerCase()
354
+ .startsWith(text.toLocaleLowerCase())
355
+ )
356
+ if (match >= 0) setActive(match)
357
+ }
358
+ }
359
+
360
+ return (
361
+ <motion.div
362
+ ref={root}
363
+ initial={
364
+ reducedMotion
365
+ ? { opacity: 0 }
366
+ : {
367
+ opacity: 0,
368
+ scale: 0.96,
369
+ y: placement.above ? 6 : -6,
370
+ }
371
+ }
372
+ animate={{ opacity: 1, scale: 1, y: 0 }}
373
+ exit={
374
+ reducedMotion
375
+ ? { opacity: 0 }
376
+ : {
377
+ opacity: 0,
378
+ scale: 0.98,
379
+ transition: { duration: 0.1 },
380
+ }
381
+ }
382
+ transition={
383
+ reducedMotion
384
+ ? { duration: 0 }
385
+ : spring.smooth
386
+ }
387
+ style={{
388
+ left: placement.left,
389
+ width: placement.width,
390
+ top: placement.top,
391
+ bottom: placement.bottom,
392
+ maxHeight: placement.maxHeight,
393
+ transformOrigin: placement.above
394
+ ? "50% 100%"
395
+ : "50% 0%",
396
+ }}
397
+ className="glass-menu fixed z-[80] flex flex-col overflow-hidden rounded-3xl"
398
+ {...{ [FLOATING_LAYER]: "" }}
399
+ onKeyDown={onKeyDown}
400
+ >
401
+ {searchable && (
402
+ <div className="flex shrink-0 items-center gap-2 border-b border-separator/70 px-3 py-2">
403
+ <SearchIcon
404
+ size={15}
405
+ className="shrink-0 text-label-tertiary"
406
+ />
407
+ <input
408
+ ref={search}
409
+ value={query}
410
+ placeholder={searchPlaceholder}
411
+ aria-label={searchPlaceholder}
412
+ aria-controls={id}
413
+ aria-activedescendant={
414
+ activeOption
415
+ ? `${id}-${activeOption.value}`
416
+ : undefined
417
+ }
418
+ onChange={(event) => {
419
+ setQuery(event.target.value)
420
+ setActive(0)
421
+ }}
422
+ className="min-w-0 grow bg-transparent text-subheadline text-label outline-none placeholder:text-label-tertiary"
423
+ />
424
+ </div>
425
+ )}
426
+ <div
427
+ ref={list}
428
+ id={id}
429
+ role="listbox"
430
+ aria-label={label}
431
+ tabIndex={-1}
432
+ aria-activedescendant={
433
+ activeOption
434
+ ? `${id}-${activeOption.value}`
435
+ : undefined
436
+ }
437
+ className="no-scrollbar flex min-h-0 flex-col overflow-y-auto overscroll-contain p-1.5 outline-none"
438
+ >
439
+ {shown.map((option, index) => {
440
+ const chosen = option.value === value
441
+ const focused = option === activeOption
442
+ return (
443
+ <div
444
+ key={option.value}
445
+ id={`${id}-${option.value}`}
446
+ data-value={option.value}
447
+ role="option"
448
+ tabIndex={-1}
449
+ aria-selected={chosen}
450
+ lang={option.lang}
451
+ onPointerMove={() =>
452
+ setActive(index)
453
+ }
454
+ onClick={() =>
455
+ onPick(option.value)
456
+ }
457
+ className={cn(
458
+ "flex min-h-8 cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-subheadline [@media(pointer:coarse)]:min-h-11",
459
+ focused ? "bg-fill" : "",
460
+ chosen
461
+ ? "font-semibold text-label"
462
+ : "text-label"
463
+ )}
464
+ >
465
+ <span className="flex w-4 shrink-0 justify-center">
466
+ {chosen && (
467
+ <CheckIcon
468
+ size={15}
469
+ strokeWidth={2.4}
470
+ className="text-accent-text"
471
+ />
472
+ )}
473
+ </span>
474
+ <span className="min-w-0 grow break-words">
475
+ {option.label}
476
+ </span>
477
+ {option.detail && (
478
+ <span className="shrink-0 text-caption font-normal text-label-tertiary">
479
+ {option.detail}
480
+ </span>
481
+ )}
482
+ </div>
483
+ )
484
+ })}
485
+ {shown.length === 0 && (
486
+ <p className="px-3 py-2 text-footnote text-label-tertiary">
487
+ {emptyLabel}
488
+ </p>
489
+ )}
490
+ </div>
491
+ </motion.div>
492
+ )
493
+ }