@morphemecms/cms 0.5.0 → 0.7.0

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 (396) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/astro.mjs +3 -0
  3. package/bin/morpheme.mjs +3 -1
  4. package/defaults/blockTexts.ts +2 -2
  5. package/defaults/collections.ts +10 -0
  6. package/defaults/editor.ts +57 -53
  7. package/defaults/privacy.ts +4 -1
  8. package/defaults/rules.ts +36 -38
  9. package/defaults/standardPages.ts +0 -3
  10. package/defaults/texts.ts +11 -2
  11. package/demo/src/content/pages/about.md +2 -0
  12. package/icons/lucide/browser-fill.svg +1 -0
  13. package/icons/lucide/buildings-fill.svg +1 -0
  14. package/icons/lucide/certificate-fill.svg +1 -0
  15. package/icons/lucide/chart-line-up-fill.svg +1 -0
  16. package/icons/lucide/chats-fill.svg +1 -0
  17. package/icons/lucide/envelope-simple-fill.svg +1 -0
  18. package/icons/lucide/file-text-fill.svg +1 -0
  19. package/icons/lucide/folder-simple-fill.svg +1 -0
  20. package/icons/lucide/gauge.svg +1 -1
  21. package/icons/lucide/gear-six.svg +1 -0
  22. package/icons/lucide/globe-simple.svg +1 -0
  23. package/icons/lucide/heart-fill.svg +1 -0
  24. package/icons/lucide/layout.svg +1 -0
  25. package/icons/lucide/list-checks-fill.svg +1 -0
  26. package/icons/lucide/magnifying-glass-fill.svg +1 -0
  27. package/icons/lucide/megaphone-fill.svg +1 -0
  28. package/icons/lucide/newspaper-fill.svg +1 -0
  29. package/icons/lucide/paint-brush-broad-fill.svg +1 -0
  30. package/icons/lucide/puzzle-piece-fill.svg +1 -0
  31. package/icons/lucide/shield-check-fill.svg +1 -0
  32. package/icons/lucide/sidebar-simple.svg +1 -1
  33. package/icons/lucide/sidebar.svg +1 -0
  34. package/icons/lucide/square-half-bottom-fill.svg +1 -0
  35. package/icons/lucide/square-half.svg +1 -0
  36. package/icons/lucide/squares-four-fill.svg +1 -0
  37. package/icons/lucide/storefront-fill.svg +1 -0
  38. package/icons/lucide/tag-fill.svg +1 -0
  39. package/icons/lucide/toggle-right-fill.svg +1 -0
  40. package/icons/lucide/trash-fill.svg +1 -0
  41. package/icons/lucide/user-fill.svg +1 -0
  42. package/icons/lucide/wrench-fill.svg +1 -0
  43. package/icons/material-symbols/browser-fill.svg +1 -0
  44. package/icons/material-symbols/buildings-fill.svg +1 -0
  45. package/icons/material-symbols/certificate-fill.svg +1 -0
  46. package/icons/material-symbols/chart-line-up-fill.svg +1 -0
  47. package/icons/material-symbols/chats-fill.svg +1 -0
  48. package/icons/material-symbols/envelope-simple-fill.svg +1 -0
  49. package/icons/material-symbols/file-text-fill.svg +1 -0
  50. package/icons/material-symbols/folder-simple-fill.svg +1 -0
  51. package/icons/material-symbols/gauge.svg +1 -1
  52. package/icons/material-symbols/gear-six.svg +1 -0
  53. package/icons/material-symbols/globe-simple.svg +1 -0
  54. package/icons/material-symbols/heart-fill.svg +1 -0
  55. package/icons/material-symbols/layout.svg +1 -0
  56. package/icons/material-symbols/list-checks-fill.svg +1 -0
  57. package/icons/material-symbols/magnifying-glass-fill.svg +1 -0
  58. package/icons/material-symbols/megaphone-fill.svg +1 -0
  59. package/icons/material-symbols/newspaper-fill.svg +1 -0
  60. package/icons/material-symbols/paint-brush-broad-fill.svg +1 -0
  61. package/icons/material-symbols/puzzle-piece-fill.svg +1 -0
  62. package/icons/material-symbols/shield-check-fill.svg +1 -0
  63. package/icons/material-symbols/sidebar-simple.svg +1 -1
  64. package/icons/material-symbols/sidebar.svg +1 -0
  65. package/icons/material-symbols/square-half-bottom-fill.svg +1 -0
  66. package/icons/material-symbols/square-half.svg +1 -0
  67. package/icons/material-symbols/squares-four-fill.svg +1 -0
  68. package/icons/material-symbols/storefront-fill.svg +1 -0
  69. package/icons/material-symbols/tag-fill.svg +1 -0
  70. package/icons/material-symbols/toggle-right-fill.svg +1 -0
  71. package/icons/material-symbols/trash-fill.svg +1 -0
  72. package/icons/material-symbols/user-fill.svg +1 -0
  73. package/icons/material-symbols/wrench-fill.svg +1 -0
  74. package/icons/phosphor/browser-fill.svg +1 -0
  75. package/icons/phosphor/buildings-fill.svg +1 -0
  76. package/icons/phosphor/certificate-fill.svg +1 -0
  77. package/icons/phosphor/chart-line-up-fill.svg +1 -0
  78. package/icons/phosphor/chats-fill.svg +1 -0
  79. package/icons/phosphor/envelope-simple-fill.svg +1 -0
  80. package/icons/phosphor/file-text-fill.svg +1 -0
  81. package/icons/phosphor/folder-simple-fill.svg +1 -0
  82. package/icons/phosphor/gear-six.svg +1 -0
  83. package/icons/phosphor/globe-simple.svg +1 -0
  84. package/icons/phosphor/heart-fill.svg +1 -0
  85. package/icons/phosphor/layout.svg +1 -0
  86. package/icons/phosphor/list-checks-fill.svg +1 -0
  87. package/icons/phosphor/magnifying-glass-fill.svg +1 -0
  88. package/icons/phosphor/megaphone-fill.svg +1 -0
  89. package/icons/phosphor/newspaper-fill.svg +1 -0
  90. package/icons/phosphor/paint-brush-broad-fill.svg +1 -0
  91. package/icons/phosphor/puzzle-piece-fill.svg +1 -0
  92. package/icons/phosphor/shield-check-fill.svg +1 -0
  93. package/icons/phosphor/sidebar.svg +1 -0
  94. package/icons/phosphor/square-half-bottom-fill.svg +1 -0
  95. package/icons/phosphor/square-half.svg +1 -0
  96. package/icons/phosphor/squares-four-fill.svg +1 -0
  97. package/icons/phosphor/storefront-fill.svg +1 -0
  98. package/icons/phosphor/tag-fill.svg +1 -0
  99. package/icons/phosphor/toggle-right-fill.svg +1 -0
  100. package/icons/phosphor/trash-fill.svg +1 -0
  101. package/icons/phosphor/user-fill.svg +1 -0
  102. package/icons/phosphor/wrench-fill.svg +1 -0
  103. package/lib/breakpoints.d.mts +13 -0
  104. package/lib/breakpoints.mjs +20 -0
  105. package/lib/breakpoints.test.mjs +39 -0
  106. package/lib/build-lane.d.mts +15 -0
  107. package/lib/build-lane.mjs +23 -0
  108. package/lib/build-lane.test.mjs +74 -0
  109. package/lib/build-settings.d.mts +16 -0
  110. package/lib/build-settings.mjs +39 -0
  111. package/lib/build-settings.test.mjs +41 -0
  112. package/lib/contracts.d.mts +3 -2
  113. package/lib/contracts.mjs +3 -2
  114. package/lib/drawer-swipe.d.mts +10 -0
  115. package/lib/drawer-swipe.mjs +17 -0
  116. package/lib/drawer-swipe.test.mjs +33 -0
  117. package/lib/drawerKeys.d.mts +9 -0
  118. package/lib/drawerKeys.mjs +26 -0
  119. package/lib/drawerKeys.test.mjs +21 -0
  120. package/lib/eyebrow.d.mts +6 -0
  121. package/lib/eyebrow.mjs +24 -0
  122. package/lib/eyebrow.test.mjs +20 -0
  123. package/lib/front-sections.test.mjs +82 -0
  124. package/lib/heading-levels.d.mts +7 -0
  125. package/lib/heading-levels.mjs +16 -0
  126. package/lib/heading-levels.test.mjs +37 -0
  127. package/lib/inline.ts +3 -2
  128. package/lib/markdown.ts +2 -0
  129. package/lib/materialIcon.d.mts +21 -0
  130. package/lib/materialIcon.mjs +29 -0
  131. package/lib/materialIcon.test.mjs +45 -0
  132. package/lib/menu-layout.d.mts +11 -0
  133. package/lib/menu-layout.mjs +14 -0
  134. package/lib/menu-layout.test.mjs +23 -0
  135. package/lib/menu.d.mts +59 -0
  136. package/lib/menu.mjs +129 -0
  137. package/lib/menu.test.mjs +291 -0
  138. package/lib/rehype-emphasize.d.mts +4 -0
  139. package/lib/rehype-emphasize.mjs +20 -0
  140. package/lib/rehype-emphasize.test.mjs +39 -0
  141. package/lib/section-in-view.d.mts +8 -0
  142. package/lib/section-in-view.mjs +25 -0
  143. package/lib/section-in-view.test.mjs +35 -0
  144. package/lib/section-marker.d.mts +5 -0
  145. package/lib/section-marker.mjs +18 -0
  146. package/lib/section-marker.test.mjs +22 -0
  147. package/lib/section-ranges.d.mts +12 -0
  148. package/lib/section-ranges.mjs +43 -0
  149. package/lib/section-ranges.test.mjs +61 -0
  150. package/lib/sheet-stops.d.mts +68 -0
  151. package/lib/sheet-stops.mjs +198 -0
  152. package/lib/sheet-stops.test.mjs +155 -0
  153. package/lib/still-timer.d.mts +5 -0
  154. package/lib/still-timer.mjs +17 -0
  155. package/lib/still-timer.test.mjs +42 -0
  156. package/lib/text-sections.d.mts +10 -0
  157. package/lib/text-sections.mjs +45 -0
  158. package/lib/text-sections.test.mjs +78 -0
  159. package/lib/video.mjs +2 -1
  160. package/lib/without-h1.d.mts +1 -0
  161. package/lib/without-h1.mjs +7 -0
  162. package/lib/without-h1.test.mjs +26 -0
  163. package/lib/word-at.d.mts +6 -0
  164. package/lib/word-at.mjs +20 -0
  165. package/lib/word-at.test.mjs +19 -0
  166. package/package.json +3 -1
  167. package/pages/editor/version.json.js +22 -2
  168. package/scripts/already-checked.mjs +11 -0
  169. package/scripts/already-checked.test.mjs +18 -0
  170. package/scripts/build-settings-problems.mjs +27 -0
  171. package/scripts/build-settings-problems.test.mjs +35 -0
  172. package/scripts/check-content.mjs +42 -4
  173. package/scripts/check-worker.mjs +96 -4
  174. package/scripts/ci-build.mjs +85 -0
  175. package/scripts/ci-build.test.mjs +137 -0
  176. package/scripts/editor-roundtrip.mjs +62 -3
  177. package/scripts/editor-screens.mjs +21 -7
  178. package/scripts/git-env.mjs +6 -0
  179. package/scripts/step-command.mjs +34 -0
  180. package/scripts/step-command.test.mjs +50 -0
  181. package/scripts/text-problems.mjs +83 -0
  182. package/scripts/text-problems.test.mjs +74 -0
  183. package/scripts/worker-modules.mjs +11 -1
  184. package/site/breakpoints.d.mts +6 -0
  185. package/site/breakpoints.mjs +52 -0
  186. package/site/breakpoints.test.mjs +36 -0
  187. package/site/casing.d.mts +3 -0
  188. package/site/casing.mjs +26 -0
  189. package/site/content.config.ts +2 -0
  190. package/site/content.ts +2 -4
  191. package/site/defineSite.d.mts +1 -0
  192. package/site/defineSite.mjs +3 -0
  193. package/site/home.ts +3 -2
  194. package/site/icons.d.mts +116 -4
  195. package/site/icons.mjs +62 -6
  196. package/site/images.mjs +1 -1
  197. package/site/live-diff.d.mts +22 -0
  198. package/site/live-diff.mjs +98 -0
  199. package/site/live-diff.test.mjs +235 -0
  200. package/site/materialSymbols.d.mts +5 -0
  201. package/site/materialSymbols.mjs +19 -0
  202. package/site/sections.ts +1 -1
  203. package/site/theme.d.mts +1 -1
  204. package/site/theme.mjs +7 -1
  205. package/src/ActionParts.tsx +1 -1
  206. package/src/Advanced.tsx +101 -20
  207. package/src/App.tsx +47 -121
  208. package/src/AppList.tsx +1 -1
  209. package/src/BlockParts.tsx +3 -3
  210. package/src/Colors.tsx +1 -1
  211. package/src/CommunicationParts.tsx +1 -1
  212. package/src/ContainmentParts.tsx +80 -28
  213. package/src/Deleted.tsx +18 -4
  214. package/src/EditorFooter.tsx +14 -0
  215. package/src/EntryForm.tsx +75 -39
  216. package/src/FormFrame.tsx +3 -8
  217. package/src/Library.tsx +46 -51
  218. package/src/Licenses.tsx +27 -11
  219. package/src/MainDrawerParts.tsx +60 -0
  220. package/src/MainMenu.tsx +136 -0
  221. package/src/MarkdownEditor.tsx +175 -32
  222. package/src/Media.tsx +69 -16
  223. package/src/MediaTags.tsx +91 -0
  224. package/src/ModalSheetSample.tsx +47 -0
  225. package/src/NavigationParts.tsx +67 -21
  226. package/src/NavigationRestParts.tsx +1 -1
  227. package/src/Parts.tsx +71 -0
  228. package/src/PartsShown.tsx +1 -1
  229. package/src/PickerParts.tsx +2 -2
  230. package/src/Redirects.tsx +3 -2
  231. package/src/Screen.tsx +93 -90
  232. package/src/SelectionParts.tsx +22 -6
  233. package/src/SiteLogoLink.tsx +14 -0
  234. package/src/StartFab.tsx +22 -0
  235. package/src/StartStatus.tsx +1 -1
  236. package/src/autoTags.d.mts +28 -0
  237. package/src/autoTags.mjs +68 -0
  238. package/src/autoTags.test.mjs +109 -0
  239. package/src/buildSettings.test.mjs +20 -0
  240. package/src/buildSettings.ts +20 -0
  241. package/src/checkSite.ts +14 -1
  242. package/src/deploy.ts +7 -9
  243. package/src/editor.css +150 -17
  244. package/src/eyebrow.d.mts +5 -0
  245. package/src/eyebrow.mjs +53 -0
  246. package/src/eyebrowState.d.mts +21 -0
  247. package/src/eyebrowState.mjs +62 -0
  248. package/src/eyebrowState.test.mjs +78 -0
  249. package/src/fileRecords.d.mts +115 -0
  250. package/src/fileRecords.mjs +224 -0
  251. package/src/fileRecords.test.mjs +224 -0
  252. package/src/fileTagFilter.d.mts +30 -0
  253. package/src/fileTagFilter.mjs +44 -0
  254. package/src/fileTagFilter.test.mjs +57 -0
  255. package/src/fileTags.d.mts +56 -0
  256. package/src/fileTags.mjs +100 -0
  257. package/src/fileTags.test.mjs +127 -0
  258. package/src/firstHeading.d.mts +6 -0
  259. package/src/firstHeading.mjs +37 -0
  260. package/src/i18n/da-DK/advanced.ts +27 -12
  261. package/src/i18n/da-DK/app.ts +9 -7
  262. package/src/i18n/da-DK/apps.ts +16 -16
  263. package/src/i18n/da-DK/blockSettings.ts +9 -9
  264. package/src/i18n/da-DK/blocks.ts +2 -2
  265. package/src/i18n/da-DK/check.ts +5 -2
  266. package/src/i18n/da-DK/cms.ts +14 -12
  267. package/src/i18n/da-DK/colors.ts +5 -5
  268. package/src/i18n/da-DK/common.ts +2 -2
  269. package/src/i18n/da-DK/company.ts +3 -3
  270. package/src/i18n/da-DK/consents.ts +6 -6
  271. package/src/i18n/da-DK/forms.ts +6 -6
  272. package/src/i18n/da-DK/history.ts +6 -6
  273. package/src/i18n/da-DK/keywords.ts +2 -2
  274. package/src/i18n/da-DK/library.ts +17 -15
  275. package/src/i18n/da-DK/licenses.ts +4 -4
  276. package/src/i18n/da-DK/media.ts +10 -4
  277. package/src/i18n/da-DK/parts.ts +62 -25
  278. package/src/i18n/da-DK/preview.ts +5 -6
  279. package/src/i18n/da-DK/redirects.ts +7 -7
  280. package/src/i18n/da-DK/seo.ts +1 -1
  281. package/src/i18n/da-DK/text.ts +2 -0
  282. package/src/i18n/da-DK/theme.ts +5 -5
  283. package/src/i18n/en-US/advanced.ts +34 -18
  284. package/src/i18n/en-US/app.ts +7 -5
  285. package/src/i18n/en-US/apps.ts +9 -9
  286. package/src/i18n/en-US/blockSettings.ts +4 -4
  287. package/src/i18n/en-US/blocks.ts +6 -6
  288. package/src/i18n/en-US/check.ts +6 -3
  289. package/src/i18n/en-US/cms.ts +15 -14
  290. package/src/i18n/en-US/colors.ts +18 -18
  291. package/src/i18n/en-US/common.ts +3 -3
  292. package/src/i18n/en-US/consents.ts +4 -4
  293. package/src/i18n/en-US/forms.ts +5 -5
  294. package/src/i18n/en-US/history.ts +2 -2
  295. package/src/i18n/en-US/library.ts +21 -15
  296. package/src/i18n/en-US/licenses.ts +6 -6
  297. package/src/i18n/en-US/media.ts +7 -1
  298. package/src/i18n/en-US/parts.ts +60 -21
  299. package/src/i18n/en-US/preview.ts +5 -6
  300. package/src/i18n/en-US/redirects.ts +3 -3
  301. package/src/i18n/en-US/seo.ts +1 -1
  302. package/src/i18n/en-US/text.ts +3 -0
  303. package/src/i18n/en-US/theme.ts +6 -6
  304. package/src/icon.tsx +90 -27
  305. package/src/licenseData.test.mjs +28 -0
  306. package/src/licenseData.ts +12 -1
  307. package/src/markdown.d.mts +2 -0
  308. package/src/markdown.mjs +73 -5
  309. package/src/markdown.test.mjs +74 -0
  310. package/src/materialIcons.ts +8 -0
  311. package/src/menuChildren.ts +36 -0
  312. package/src/menuOpen.ts +34 -0
  313. package/src/pagePreview.tsx +23 -19
  314. package/src/r2Caching.ts +14 -0
  315. package/src/sectionFrame.ts +39 -0
  316. package/src/sectionState.d.mts +19 -0
  317. package/src/sectionState.mjs +31 -0
  318. package/src/sectionState.test.mjs +59 -0
  319. package/src/sections.d.mts +9 -7
  320. package/src/sections.mjs +74 -27
  321. package/src/sections.test.mjs +59 -0
  322. package/src/sidebar.ts +2 -2
  323. package/src/site.ts +27 -17
  324. package/src/stillCursor.d.mts +8 -0
  325. package/src/stillCursor.mjs +19 -0
  326. package/src/stillCursor.test.mjs +26 -0
  327. package/src/tagUses.d.mts +47 -0
  328. package/src/tagUses.mjs +121 -0
  329. package/src/tagUses.test.mjs +141 -0
  330. package/src/themeUi.ts +10 -0
  331. package/src/topBarSlot.tsx +37 -0
  332. package/src/ui.tsx +654 -150
  333. package/src/virtual.d.ts +5 -0
  334. package/starter/README.md +2 -1
  335. package/starter/package.json +2 -1
  336. package/starter/wrangler.jsonc +3 -2
  337. package/theme/blocks/cards/LinkCards.tsx +2 -1
  338. package/theme/blocks/hero/View.tsx +2 -1
  339. package/theme/blocks/video/View.tsx +2 -1
  340. package/theme/components/BlogIndex.astro +2 -2
  341. package/theme/components/BlogPosts.astro +1 -1
  342. package/theme/components/BlogPosts.tsx +9 -6
  343. package/theme/components/Container.astro +1 -1
  344. package/theme/components/CookieConsent.astro +1 -1
  345. package/theme/components/EndBlock.astro +1 -1
  346. package/theme/components/Footer.astro +5 -5
  347. package/theme/components/Header.astro +3 -3
  348. package/theme/components/Icon.astro +2 -2
  349. package/theme/components/MobileMenu.tsx +72 -61
  350. package/theme/components/MobileNav.astro +2 -2
  351. package/theme/components/PageHeader.astro +2 -2
  352. package/theme/components/PageSection.astro +1 -1
  353. package/theme/components/PopupButton.tsx +2 -2
  354. package/theme/components/PopupView.tsx +2 -2
  355. package/theme/components/PostList.tsx +7 -3
  356. package/theme/components/RichText.astro +22 -7
  357. package/theme/components/Section.astro +17 -10
  358. package/theme/components/SectionHeading.tsx +1 -1
  359. package/theme/components/home/PageBlock.astro +4 -4
  360. package/theme/components/home/Text.astro +3 -3
  361. package/theme/layouts/PageLayout.astro +3 -3
  362. package/theme/layouts/PostLayout.astro +9 -8
  363. package/theme/styles/base.css +13 -3
  364. package/theme/styles/blocks.css +97 -10
  365. package/theme/styles/components.css +9 -9
  366. package/theme/styles/elevation.test.mjs +168 -0
  367. package/theme/styles/global.css +65 -59
  368. package/theme/styles/ui.css +460 -131
  369. package/theme/templates/Home.astro +1 -1
  370. package/theme/templates/New.astro +5 -5
  371. package/theme/templates/NotFound.astro +2 -2
  372. package/theme/ui/BottomSheet.tsx +118 -21
  373. package/theme/ui/BottomSheetResizeIcon.tsx +13 -0
  374. package/theme/ui/Button.tsx +4 -3
  375. package/theme/ui/Dialog.tsx +1 -1
  376. package/theme/ui/FabMenu.tsx +13 -12
  377. package/theme/ui/LoadingIndicator.tsx +1 -1
  378. package/theme/ui/NavigationDrawer.tsx +116 -15
  379. package/theme/ui/NavigationRail.tsx +22 -13
  380. package/theme/ui/Search.tsx +1 -1
  381. package/theme/ui/SettingRow.tsx +31 -0
  382. package/theme/ui/Switch.tsx +11 -11
  383. package/theme/ui/TextField.tsx +17 -6
  384. package/theme/ui/bottomSheet.script.ts +3 -2
  385. package/theme/ui/bottomSheetControl.ts +131 -46
  386. package/theme/ui/fabMenu.script.ts +69 -53
  387. package/theme/ui/modalSheetSize.ts +214 -0
  388. package/theme/ui/navigationDrawerControl.ts +35 -0
  389. package/theme/ui/search.script.ts +4 -2
  390. package/worker/index.js +3 -0
  391. package/worker/plan-status.js +46 -0
  392. package/worker/push.js +7 -5
  393. package/worker/r2-status.js +2 -1
  394. package/icons/lucide/desktop.svg +0 -1
  395. package/icons/material-symbols/desktop.svg +0 -1
  396. package/icons/phosphor/desktop.svg +0 -1
package/src/ui.tsx CHANGED
@@ -11,7 +11,6 @@ import {
11
11
  useRef,
12
12
  useState,
13
13
  type AnchorHTMLAttributes,
14
- type HTMLAttributes,
15
14
  type ButtonHTMLAttributes,
16
15
  type ChangeEvent,
17
16
  type CSSProperties,
@@ -30,6 +29,7 @@ import {
30
29
  type RefObject,
31
30
  type TextareaHTMLAttributes,
32
31
  } from 'react';
32
+ import { createPortal } from 'react-dom';
33
33
  import {
34
34
  ArrowRight,
35
35
  ArrowsDownUp,
@@ -38,14 +38,16 @@ import {
38
38
  CaretUp,
39
39
  Check,
40
40
  Funnel,
41
+ Layout,
41
42
  LockSimple,
42
43
  LockSimpleOpen,
43
44
  PencilSimple,
44
45
  Plus,
46
+ Sidebar,
45
47
  SidebarSimple,
48
+ SquareHalf,
46
49
  X,
47
50
  type Icon,
48
- type IconProps,
49
51
  } from '@phosphor-icons/react';
50
52
  import {
51
53
  SiteBadge,
@@ -72,20 +74,30 @@ import {
72
74
  SiteDatePicker,
73
75
  SiteDialog,
74
76
  siteDialogModalClass,
77
+ SiteExtendedFab,
78
+ SiteFabMenu,
75
79
  SiteIconButton,
76
80
  SiteLoadingIndicator,
77
81
  SiteSearchBar,
78
82
  SiteNavigationDrawer,
79
- SiteNavigationDrawerItem,
80
83
  SiteNavigationRail,
81
84
  SiteNavigationRailItem,
82
- SiteNavigationRailSection,
85
+ SiteNavigationDrawerBack,
86
+ SiteNavigationDrawerItem,
87
+ SiteNavigationDrawerRow,
88
+ SiteNavigationDrawerSection,
89
+ SiteNavigationDrawerSubitem,
90
+ SiteNavigationDrawerToggle,
83
91
  siteNavigationDrawerDialogClass,
92
+ siteFocusNavigationDrawerItem,
93
+ siteSideSheet,
94
+ siteSideSheetDialogClass,
84
95
  SiteSegmentedButton,
85
96
  SiteSideSheet,
86
97
  SiteTab,
87
98
  SiteTabs,
88
99
  SiteSlider,
100
+ SiteSettingRow,
89
101
  SiteSwitch,
90
102
  SiteTextField,
91
103
  SiteToolbar,
@@ -93,15 +105,20 @@ import {
93
105
  SiteTextFieldButton,
94
106
  SiteTimePicker,
95
107
  dayPeriods,
108
+ setUpFabMenu,
96
109
  siteRipples,
97
110
  } from './themeUi';
98
111
  import { locale, t } from './i18n';
99
112
  import { InEditor, type ButtonKind, type IconButtonKind } from './standard/view';
100
113
  import { SvgFile } from './icon';
114
+ import { SiteLogoLink } from './SiteLogoLink';
101
115
  import { countries, countryName, dialCodes, flagOf, regionOf } from './countries';
102
116
  import { byText } from './schema';
103
117
  import { contrast } from '../lib/colors';
104
118
  import { motionTiming } from '../lib/motion';
119
+ import { atLeast, type Breakpoint } from '../lib/breakpoints.mjs';
120
+ import { swipeCloses } from '../lib/drawer-swipe.mjs';
121
+ import { menuLayout } from '../lib/menu-layout.mjs';
105
122
 
106
123
  // The parts Material 3 has (a button, an icon button, a chip, a carousel …) are the theme's UI parts
107
124
  // (packages/editor/theme/ui/, through themeUi.ts), the same the site draws, in the theme's design (its ui.css and
@@ -454,7 +471,7 @@ export function DateTimeField({
454
471
  const [day = '', time = ''] = value.split('T');
455
472
  const join = (d: string, h: string) => (d ? `${d}T${h || '00:00'}` : '');
456
473
  return (
457
- <div className="grid gap-3 sm:grid-cols-[1fr_auto]">
474
+ <div className="grid gap-3 medium:grid-cols-[1fr_auto]">
458
475
  <DatePicker id={id} {...field} value={day} onChange={(d) => onChange(join(d, time))} />
459
476
  <TimePicker id={`${id}-time`} value={time.slice(0, 5)} onChange={(h) => onChange(join(day, h))} />
460
477
  </div>
@@ -464,18 +481,19 @@ export function DateTimeField({
464
481
  /** A field that is tapped to choose (Select, PagePicker, a phone number's country code): the theme's TextFieldButton,
465
482
  * a button in the text field, or with `href` a link (to a page with the choices), showing what is chosen (`children`)
466
483
  * with what it says in itself (InField) and an arrow at its end (`arrow`: CaretDown, the default, opens a sheet;
467
- * CaretRight leads to a page). */
484
+ * CaretRight leads to a page); `size="small"`: a small box without the name, for a row's choice (MenuRow). */
468
485
  export function ChoiceField({
469
486
  href,
470
487
  arrow: Arrow = CaretDown,
488
+ size,
471
489
  label,
472
490
  hint,
473
491
  problem,
474
492
  children,
475
493
  ...rest
476
- }: Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & InField & { href?: string; arrow?: Icon }) {
494
+ }: Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & InField & { href?: string; arrow?: Icon; size?: 'small' }) {
477
495
  return (
478
- <SiteTextFieldButton label={label} supporting={hint} error={problem} href={href} trailingIcon={<Arrow />} {...rest}>
496
+ <SiteTextFieldButton label={label} supporting={hint} error={problem} size={size} href={href} trailingIcon={<Arrow />} {...rest}>
479
497
  {children}
480
498
  </SiteTextFieldButton>
481
499
  );
@@ -1646,24 +1664,32 @@ export function Option({
1646
1664
  /** A menu under the button that opened it (`anchor`; null while it is closed): the theme's menu with its choices
1647
1665
  * (MenuItem) over the screen, placed as the theme places one (sitePlaceMenu: under the button, over it when there is no
1648
1666
  * room under it), and again when the page scrolls or the window changes; on a phone in a sheet from the bottom with
1649
- * `label` as its title (useWide), so a long one is not too long for the screen. A choice closes it, and so do a tap
1667
+ * `label` as its title (useAtLeast('medium')), so a long one is not too long for the screen. A choice closes it, and so do a tap
1650
1668
  * outside it, Esc, and the button going out of sight (the sheet: its cross and a swipe down). The button says it opens
1651
- * one (aria-haspopup="menu", aria-expanded). */
1669
+ * one (aria-haspopup="menu", aria-expanded). `attached`: the dropdown of a field (ChoiceMenu: Material 3's exposed
1670
+ * dropdown menu), the `anchor` being the field's box: the menu is at least as wide as it, the focus goes into it (on the
1671
+ * chosen one), the arrow keys, Home and End move between the choices, and a choice, Esc and Tab close it, the first two
1672
+ * giving the focus back to the field. */
1652
1673
  export function Menu({
1653
1674
  anchor,
1654
1675
  label,
1676
+ attached,
1677
+ keyboard,
1655
1678
  onClose,
1656
1679
  children,
1657
1680
  }: {
1658
1681
  anchor: HTMLElement | null;
1659
1682
  /** What the choices are, for screen readers, and the sheet's title on a phone. */
1660
1683
  label: string;
1684
+ attached?: boolean;
1685
+ /** With `attached`: it was opened with the keyboard, so a choice has the focus (and its ring); else the menu has it. */
1686
+ keyboard?: boolean;
1661
1687
  onClose: () => void;
1662
1688
  children: ReactNode;
1663
1689
  }) {
1664
1690
  const menu = useRef<HTMLUListElement>(null);
1665
1691
  const sheet = useRef<HTMLDialogElement>(null);
1666
- const wide = useWide();
1692
+ const wide = useAtLeast('medium');
1667
1693
  useEffect(() => {
1668
1694
  if (wide) return;
1669
1695
  const el = sheet.current;
@@ -1681,14 +1707,41 @@ export function Menu({
1681
1707
  if (e.type === 'keydown' && (e as globalThis.KeyboardEvent).key !== 'Escape') return;
1682
1708
  // A tap on the button itself is its own: it closes the menu.
1683
1709
  if (e.type === 'pointerdown' && (menu.current?.contains(e.target as Node) || anchor.contains(e.target as Node))) return;
1710
+ if (e.type === 'keydown' && attached) {
1711
+ // Esc closes only the menu (not the sheet or dialog the field is in), and the field has the focus again.
1712
+ e.preventDefault();
1713
+ e.stopPropagation();
1714
+ anchor.querySelector<HTMLElement>('button')?.focus();
1715
+ }
1684
1716
  onClose();
1685
1717
  };
1686
1718
  // Where the button is now; closed when it is no longer shown (the row over a selection, gone with the selection).
1687
1719
  const place = () => {
1688
1720
  if (!anchor.isConnected || !anchor.getClientRects().length) return onClose();
1689
- if (menu.current) sitePlaceMenu(menu.current, anchor);
1721
+ if (!menu.current) return;
1722
+ if (attached) {
1723
+ const { width } = anchor.getBoundingClientRect();
1724
+ Object.assign(menu.current.style, {
1725
+ width: 'max-content',
1726
+ minWidth: `${width}px`,
1727
+ maxWidth: `max(${width}px, var(--menu-width, 18rem))`,
1728
+ });
1729
+ }
1730
+ sitePlaceMenu(menu.current, anchor);
1731
+ if (attached) {
1732
+ // Wider than the field: it grows from the field's end edge, inside the screen.
1733
+ const box = anchor.getBoundingClientRect();
1734
+ const { offsetWidth } = menu.current;
1735
+ if (offsetWidth > box.width) menu.current.style.left = `${Math.max(8, box.right - offsetWidth)}px`;
1736
+ }
1690
1737
  };
1691
1738
  place();
1739
+ if (attached) {
1740
+ const items = [...(menu.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? [])];
1741
+ const first = items.find((i) => i.getAttribute('aria-checked') === 'true') ?? items[0];
1742
+ // With the mouse the menu itself has the focus (arrow keys work, no ring on a choice).
1743
+ (keyboard ? first : menu.current)?.focus({ preventScroll: true });
1744
+ }
1692
1745
  for (const type of ['pointerdown', 'keydown']) document.addEventListener(type, close, true);
1693
1746
  addEventListener('scroll', place, { capture: true, passive: true });
1694
1747
  addEventListener('resize', place, { passive: true });
@@ -1697,7 +1750,7 @@ export function Menu({
1697
1750
  removeEventListener('scroll', place, { capture: true });
1698
1751
  removeEventListener('resize', place);
1699
1752
  };
1700
- }, [anchor, wide]);
1753
+ }, [anchor, wide, attached, keyboard]);
1701
1754
  if (!wide)
1702
1755
  return (
1703
1756
  <BottomSheet
@@ -1710,7 +1763,7 @@ export function Menu({
1710
1763
  >
1711
1764
  <SheetTitle title={label} onClose={() => sheet.current?.close()} />
1712
1765
  <SheetList div>
1713
- <SiteMenu role="menu" aria-label={label}>
1766
+ <SiteMenu role="menu" aria-label={label} className="w-full max-w-none">
1714
1767
  {children}
1715
1768
  </SiteMenu>
1716
1769
  </SheetList>
@@ -1722,14 +1775,30 @@ export function Menu({
1722
1775
  ref={menu}
1723
1776
  role="menu"
1724
1777
  aria-label={label}
1725
- className="fixed z-30"
1726
- onClick={(e: MouseEvent) => (e.target as Element).closest('[role^="menuitem"]') && onClose()}
1778
+ className="fixed z-30 focus:outline-none"
1779
+ tabIndex={attached ? -1 : undefined}
1780
+ onClick={(e: MouseEvent) => {
1781
+ if (!(e.target as Element).closest('[role^="menuitem"]')) return;
1782
+ if (attached) anchor.querySelector<HTMLElement>('button')?.focus();
1783
+ onClose();
1784
+ }}
1785
+ onKeyDown={attached ? (e: KeyboardEvent<HTMLElement>) => (e.key === 'Tab' ? onClose() : moveInMenu(e)) : undefined}
1727
1786
  >
1728
1787
  {children}
1729
1788
  </SiteMenu>
1730
1789
  );
1731
1790
  }
1732
1791
 
1792
+ // The keys of an attached menu: the arrow keys (round), Home and End go between its choices.
1793
+ function moveInMenu(e: KeyboardEvent<HTMLElement>) {
1794
+ const items = [...e.currentTarget.querySelectorAll<HTMLElement>('[role^="menuitem"]:not(:disabled)')];
1795
+ const at = items.indexOf(document.activeElement as HTMLElement);
1796
+ const to = { ArrowDown: at + 1, ArrowUp: at < 0 ? items.length - 1 : at - 1, Home: 0, End: items.length - 1 }[e.key];
1797
+ if (to === undefined) return;
1798
+ e.preventDefault();
1799
+ items[(to + items.length) % items.length]?.focus();
1800
+ }
1801
+
1733
1802
  /** A choice in a Menu: the theme's menu item, with its icon before it; `selected`: the one that is chosen now, marked
1734
1803
  * (the kind of text where the cursor is). A tap on it keeps the cursor where it is in a text. */
1735
1804
  export function MenuItem({
@@ -1751,6 +1820,33 @@ export function MenuItem({
1751
1820
  );
1752
1821
  }
1753
1822
 
1823
+ /** A setting on one line with its choice at the right: the name (`label`) and what it does under it (`hint`) at the left,
1824
+ * and at the right the chosen one (`value`, shown with its label from `options`) in a small outlined box (Select's
1825
+ * field, small), which opens the choices under it (on a phone in a sheet titled with the name), the chosen one checked.
1826
+ * The same row as SwitchRow (the theme's SettingRow, no frame around it), but for a setting with a few choices, not
1827
+ * on/off. `disabled`: it cannot be changed now (the box is grey). */
1828
+ export function MenuRow<T extends string>({
1829
+ label,
1830
+ hint,
1831
+ value,
1832
+ options,
1833
+ disabled,
1834
+ onChange,
1835
+ }: {
1836
+ label: string;
1837
+ hint?: ReactNode;
1838
+ value: T;
1839
+ options: { value: T; label: string }[];
1840
+ disabled?: boolean;
1841
+ onChange: (value: T) => void;
1842
+ }) {
1843
+ return (
1844
+ <SiteSettingRow label={label} supporting={hint}>
1845
+ <ChoiceMenu size="small" aria-label={label} title={label} value={value} options={options} disabled={disabled} onChange={onChange} />
1846
+ </SiteSettingRow>
1847
+ );
1848
+ }
1849
+
1754
1850
  /** The fields of a sheet, between SheetTitle and SheetActions, scrolled when there are many. */
1755
1851
  export function SheetBody({ onSubmit, children }: { onSubmit?: (e: FormEvent<HTMLFormElement>) => void; children: ReactNode }) {
1756
1852
  const className = 'min-h-0 flex-1 space-y-5 overflow-y-auto px-5 pt-2 pb-4';
@@ -2138,7 +2234,7 @@ const inText = {
2138
2234
  } as CSSProperties;
2139
2235
 
2140
2236
  /** What a list or a screen says when it has nothing to show (Ingen …), or while it is fetched (`busy`,
2141
- * with the LoadingIndicator: Henter …): grey, in the middle. `item`: as an item of a list. */
2237
+ * with the LoadingIndicator: Henter…): grey, in the middle. `item`: as an item of a list. */
2142
2238
  export function EmptyNote({ item, busy, children }: { item?: boolean; busy?: boolean; children: ReactNode }) {
2143
2239
  const className = 'px-4 py-6 text-center text-muted';
2144
2240
  const text = busy ? <LoadingIndicator>{children}</LoadingIndicator> : children;
@@ -2158,7 +2254,7 @@ export function ProblemNote({ alert, children }: { alert?: boolean; children: Re
2158
2254
  /** A heading over fields in a form (Andre felter, SEO); `first` at the top of a column, without the space
2159
2255
  * above it on a wide screen. */
2160
2256
  export function FormHeading({ first, children }: { first?: boolean; children: ReactNode }) {
2161
- return <h2 className={`pt-4 text-xl font-semibold ${first ? 'xl:pt-0' : ''}`}>{children}</h2>;
2257
+ return <h2 className={`pt-4 text-xl font-semibold ${first ? 'large:pt-0' : ''}`}>{children}</h2>;
2162
2258
  }
2163
2259
 
2164
2260
  /** A section of a long form that folds in and out (the sections of a page; Material 3 has no accordion, so it has its
@@ -2439,10 +2535,19 @@ export function ImageCarousel({ label, images }: { label: string; images: { src:
2439
2535
  );
2440
2536
  }
2441
2537
 
2442
- /** Whether the screen is as wide as a computer's (md, 48rem), kept up to date when the window changes:
2443
- * then what is picked in a list or a grid is shown in a SideSheet beside it, not in a sheet. */
2444
- export function useWide() {
2445
- return useMedia('(min-width: 48rem)');
2538
+ /** Whether the window is at least as wide as one of Material 3's breakpoints, kept up to date when the window changes.
2539
+ * Use the breakpoint the CSS it goes with changes at (a menu's sheet turns into a dialog from medium, a file's side
2540
+ * panel is beside the grid from expanded). */
2541
+ export function useAtLeast(name: Exclude<Breakpoint, 'compact'>) {
2542
+ return useMedia(atLeast(name));
2543
+ }
2544
+
2545
+ /** How the main menu is laid out in this window (lib/menu-layout.mjs): whether the navigation rail stands, which way it
2546
+ * expands, and whether the top bar has the menu button. */
2547
+ export function useMenuLayout() {
2548
+ const medium = useAtLeast('medium');
2549
+ const expanded = useAtLeast('expanded');
2550
+ return menuLayout(expanded ? 'expanded' : medium ? 'medium' : 'compact');
2446
2551
  }
2447
2552
 
2448
2553
  // Whether the screen is as a media query says, kept up to date when the window changes.
@@ -2489,7 +2594,7 @@ export function useKept<T>(key: string | null, first: T): [T, (value: T) => void
2489
2594
 
2490
2595
  /** On a computer: what is picked in a screen's list or grid (a file in Filer), in the theme's side sheet at the right
2491
2596
  * of it, with its title and a cross that closes it; it stays in view while the screen scrolls. On a
2492
- * phone the same is in a sheet (useWide). */
2597
+ * phone the same is in a sheet (useAtLeast). */
2493
2598
  export function SideSheet({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
2494
2599
  const id = useId();
2495
2600
  return (
@@ -2505,15 +2610,15 @@ export function SideSheet({ title, onClose, children }: { title: string; onClose
2505
2610
  );
2506
2611
  }
2507
2612
 
2508
- /** One destination in a NavigationDrawer: its id, its name and its icon, and evt. the theme's Badge (how many are new
2509
- * there; or a dot). */
2613
+ /** One entry of a SectionList: its id, its name and its icon, and evt. the theme's Badge (how many are new there; or a
2614
+ * dot). */
2510
2615
  export type NavigationItem = { id: string; label: string; icon: Icon; badge?: number | boolean };
2511
2616
 
2512
- /** The theme's navigation drawer (Material 3's): the parts of a screen (the sections of a page's long form: Indhold,
2513
- * Status, Andre felter …, which a tap scrolls to), each the theme's drawer item with its icon, the one open or in view
2514
- * (`value`) marked as the theme marks it. On a wide computer screen it stands beside what it opens (in FormColumns'
2515
- * right column); narrower, it is in a ModalNavigationDrawer. */
2516
- export function NavigationDrawer({
2617
+ /** The sections of a long form (a page's, a post's: Indhold, Status, Andre felter …), as the theme's list (Material 3's
2618
+ * List and ListItem): each with its icon, its name and evt. its badge, the one open or in view (`value`) selected; a tap
2619
+ * calls `onChange` with its id (EntryForm scrolls to it). `title` names the navigation. In a SectionPane on large and
2620
+ * wider screens, in a SectionSheet below. */
2621
+ export function SectionList({
2517
2622
  title,
2518
2623
  items,
2519
2624
  value,
@@ -2526,17 +2631,20 @@ export function NavigationDrawer({
2526
2631
  }) {
2527
2632
  return (
2528
2633
  <nav aria-label={title}>
2529
- <ul className="space-y-1">
2530
- {items.map(({ id, label, icon: Glyph, badge }) => {
2634
+ <ul className={siteListClasses('standard', 'none')}>
2635
+ {items.map(({ id, label, icon, badge }) => {
2531
2636
  const open = id === value;
2532
2637
  return (
2533
- <li key={id}>
2534
- <SiteNavigationDrawerItem active={open} aria-current={open ? 'page' : undefined} onClick={() => onChange(id)}>
2535
- <Glyph size={22} weight={open ? 'fill' : 'regular'} />
2536
- {label}
2537
- {badge ? <SiteBadge count={badge === true ? undefined : badge} /> : null}
2538
- </SiteNavigationDrawerItem>
2539
- </li>
2638
+ <ListItem
2639
+ key={id}
2640
+ icon={icon}
2641
+ iconSelected={open}
2642
+ selected={open}
2643
+ aria-current={open ? 'location' : undefined}
2644
+ headline={label}
2645
+ trailing={badge ? <SiteBadge count={badge === true ? undefined : badge} /> : undefined}
2646
+ onClick={() => onChange(id)}
2647
+ />
2540
2648
  );
2541
2649
  })}
2542
2650
  </ul>
@@ -2544,50 +2652,283 @@ export function NavigationDrawer({
2544
2652
  );
2545
2653
  }
2546
2654
 
2547
- /** The editor's sidebar on a computer: the theme's navigation rail (Material 3's), expanded with each part's icon,
2548
- * name and count, or collapsed to the icons with their names under them; its menu button expands and collapses it
2549
- * (`expanded`, `onExpandedChange`), and with `peek` it is expanded over the content without taking room (while the
2550
- * mouse is over the collapsed rail). `header`: the logo and the site's name, under the menu button while expanded. */
2551
- export function NavigationRail({
2655
+ /** The pane at the end of a long form on large and wider screens (Material 3's 412dp pane): the theme's standard side
2656
+ * sheet titled `title`, with the form's SectionList in it (`children`); it stays in view while the form scrolls. Nothing
2657
+ * when it is not `open`: the button in the top bar (SectionsButton) shows and hides it. Its place in the form is
2658
+ * FormColumns' `pane`, against the window's end edge; below large it is not drawn (a SectionSheet is). */
2659
+ export function SectionPane({ title, open, children }: { title: string; open: boolean; children: ReactNode }) {
2660
+ const id = useId();
2661
+ if (!open) return null;
2662
+ return (
2663
+ <SiteSideSheet headline={title} headlineId={id} className="editor-reveal h-full w-full [--side-sheet-width:var(--section-pane-width)]">
2664
+ {children}
2665
+ </SiteSideSheet>
2666
+ );
2667
+ }
2668
+
2669
+ /** The sections below large: the theme's modal side sheet (Material 3's), from the end edge over the screen, titled
2670
+ * `title`, with the form's SectionList in it (`children`). Controlled: `open` shows it (the theme's `sideSheet()` slides
2671
+ * it in; with reduced motion it just appears), and `onClose` is called when it has been closed by its cross, Escape or a
2672
+ * tap on the scrim; a choice closes it by the caller setting `open` to false. The page does not scroll while it is
2673
+ * open (the theme's control), and it is given back if the sheet is taken away while open (the window grows to large). */
2674
+ export function SectionSheet({
2675
+ title,
2676
+ open,
2677
+ onClose,
2678
+ children,
2679
+ }: {
2680
+ title: string;
2681
+ open: boolean;
2682
+ onClose: () => void;
2683
+ children: ReactNode;
2684
+ }) {
2685
+ const id = useId();
2686
+ const dialog = useRef<HTMLDialogElement>(null);
2687
+ const control = useRef<ReturnType<typeof siteSideSheet> | undefined>(undefined);
2688
+ const closed = useRef(onClose);
2689
+ closed.current = onClose;
2690
+ useEffect(() => {
2691
+ const el = dialog.current!;
2692
+ control.current = siteSideSheet(el);
2693
+ const done = () => closed.current();
2694
+ el.addEventListener('close', done);
2695
+ return () => {
2696
+ el.removeEventListener('close', done);
2697
+ if (el.open) el.close();
2698
+ document.documentElement.style.overflow = '';
2699
+ };
2700
+ }, []);
2701
+ useEffect(() => {
2702
+ if (open) control.current?.open();
2703
+ else void control.current?.close();
2704
+ }, [open]);
2705
+ return (
2706
+ <dialog ref={dialog} aria-labelledby={id} className={siteSideSheetDialogClass}>
2707
+ <SiteSideSheet kind="modal" headline={title} headlineId={id} closeLabel={t.common.close}>
2708
+ {children}
2709
+ </SiteSideSheet>
2710
+ </dialog>
2711
+ );
2712
+ }
2713
+
2714
+ /** The button at the end of the top bar (Screen's `drawer`) for a form's sections: on large and wider it shows and hides
2715
+ * the SectionPane (`open`: it is shown, so the icon is `right_panel_close` (`square-half`), else `right_panel_open` (`layout`)),
2716
+ * below it opens the SectionSheet, so the icon is always `right_panel_open`. `label` names it. */
2717
+ export function SectionsButton({ label, open, onClick }: { label: string; open?: boolean; onClick: () => void }) {
2718
+ const pane = useAtLeast('large');
2719
+ return <IconButton icon={pane && open ? SquareHalf : Layout} label={label} aria-expanded={open} onClick={onClick} />;
2720
+ }
2721
+
2722
+ /** The editor's main navigation (Material 3's navigation drawer, the theme's, in its design): the site's parts in sections
2723
+ * (`MainDrawerSection`, `MainDrawerItem`), at the full height of the window, with the menu button at the top. From medium
2724
+ * and wider a navigation rail (`rail`: its destinations, `MainRailItem` and `MainRailSectionItem`) stands in its place while
2725
+ * the drawer is closed; its menu button, or a section in it, opens the drawer (`onOpenChange`). The drawer is standard at
2726
+ * expanded and wider, beside the content (`open` shows and hides it, and the rail the other way round); modal below, over a
2727
+ * scrim, closing on a choice, the scrim, Escape or a swipe toward its edge, with focus on the first item when it opens
2728
+ * (lib/menu-layout.mjs). `variant` forces one of them, without the rail (the parts page does).
2729
+ * It has two levels when its children do (`MainDrawerSectionItem`, `MainDrawerBack`): `onEscape` is called for Escape and
2730
+ * answers true when it used the key (went back to the first level), so the modal drawer stays open; false lets it close. */
2731
+ export function MainDrawer({
2552
2732
  label,
2553
- menuLabel,
2554
- expanded,
2555
- peek,
2556
- header,
2557
- onExpandedChange,
2733
+ open,
2734
+ onOpenChange,
2735
+ onEscape,
2736
+ variant,
2737
+ rail,
2558
2738
  children,
2559
- ...rest
2560
2739
  }: {
2561
2740
  label: string;
2562
- menuLabel: string;
2563
- expanded: boolean;
2564
- peek?: boolean;
2565
- header?: ReactNode;
2566
- onExpandedChange: (expanded: boolean) => void;
2741
+ open: boolean;
2742
+ onOpenChange: (open: boolean) => void;
2743
+ onEscape?: () => boolean;
2744
+ variant?: 'modal' | 'standard';
2745
+ rail?: ReactNode;
2567
2746
  children: ReactNode;
2568
- } & HTMLAttributes<HTMLElement>) {
2747
+ }) {
2748
+ const layout = useMenuLayout();
2749
+ const shown = variant ?? layout.expanded;
2750
+ const railShown = rail !== undefined && variant === undefined && layout.rail && !(shown === 'standard' && open);
2569
2751
  return (
2570
- <SiteNavigationRail
2571
- label={label}
2572
- menuLabel={menuLabel}
2573
- expanded={expanded || peek}
2574
- modality={peek && !expanded ? 'modal' : 'standard'}
2575
- scrim={false}
2576
- header={header}
2577
- onExpandedChange={onExpandedChange}
2578
- {...rest}
2752
+ <>
2753
+ {railShown && (
2754
+ <MainRail label={label} onOpen={() => onOpenChange(true)}>
2755
+ {rail}
2756
+ </MainRail>
2757
+ )}
2758
+ {shown === 'standard' ? (
2759
+ <StandardMainDrawer label={label} open={open} onOpenChange={onOpenChange} onEscape={onEscape}>
2760
+ {children}
2761
+ </StandardMainDrawer>
2762
+ ) : (
2763
+ <ModalMainDrawer label={label} open={open} onOpenChange={onOpenChange} onEscape={onEscape}>
2764
+ {children}
2765
+ </ModalMainDrawer>
2766
+ )}
2767
+ </>
2768
+ );
2769
+ }
2770
+
2771
+ // The collapsed navigation rail (the theme's): sticky, the full height of the window with room for a phone's notch, with its
2772
+ // menu button at the top, which opens the drawer. Never expanded itself: the drawer is what expands (MainDrawer).
2773
+ function MainRail({ label, onOpen, children }: { label: string; onOpen: () => void; children: ReactNode }) {
2774
+ return (
2775
+ <aside className="editor-main-rail-frame sticky top-0 z-10 h-dvh shrink-0 self-start bg-(--navigation-rail-surface,var(--bg)) pt-[env(safe-area-inset-top)]">
2776
+ <SiteNavigationRail
2777
+ label={label}
2778
+ menuLabel={t.library.openMenu}
2779
+ expanded={false}
2780
+ onExpandedChange={onOpen}
2781
+ className="editor-main-rail h-full"
2782
+ >
2783
+ {children}
2784
+ </SiteNavigationRail>
2785
+ </aside>
2786
+ );
2787
+ }
2788
+
2789
+ // The menu button at the top of either drawer: its icon over the items' icons, level with the top bar's logo; it closes the drawer.
2790
+ // The modal drawer (`logo`) shows the site's logo after the button.
2791
+ function MainDrawerMenuButton({ onClick, logo }: { onClick: () => void; logo?: boolean }) {
2792
+ return (
2793
+ <div className="ms-1 mt-2 mb-7 flex items-center gap-1 self-start">
2794
+ <MenuButton label={t.library.closeMenu} open onClick={onClick} />
2795
+ {logo && <SiteLogoLink />}
2796
+ </div>
2797
+ );
2798
+ }
2799
+
2800
+ // What is inside either drawer: the sections in a <nav>.
2801
+ function MainDrawerBody({ label, children }: { label: string; children: ReactNode }) {
2802
+ return <nav aria-label={label}>{children}</nav>;
2803
+ }
2804
+
2805
+ // The standard drawer: beside the content, sticky, the full height of the window (room at the top for a phone's notch), with
2806
+ // the menu button at the top; there only while it is open (the theme's drawer handles the arrow keys).
2807
+ function StandardMainDrawer({
2808
+ label,
2809
+ open,
2810
+ onOpenChange,
2811
+ onEscape,
2812
+ children,
2813
+ }: {
2814
+ label: string;
2815
+ open: boolean;
2816
+ onOpenChange: (open: boolean) => void;
2817
+ onEscape?: () => boolean;
2818
+ children: ReactNode;
2819
+ }) {
2820
+ if (!open) return null;
2821
+ return (
2822
+ <aside className="sticky top-0 z-10 h-dvh shrink-0 self-start overflow-y-auto">
2823
+ <SiteNavigationDrawer
2824
+ variant="standard"
2825
+ className="editor-main-drawer-panel min-h-full pt-[env(safe-area-inset-top)]"
2826
+ onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
2827
+ if (e.key === 'Escape' && onEscape?.()) e.preventDefault();
2828
+ }}
2829
+ >
2830
+ <MainDrawerMenuButton onClick={() => onOpenChange(false)} />
2831
+ <MainDrawerBody label={label}>{children}</MainDrawerBody>
2832
+ </SiteNavigationDrawer>
2833
+ </aside>
2834
+ );
2835
+ }
2836
+
2837
+ // The modal drawer: a <dialog> from the start edge over its scrim. Always in the page (closed it is not drawn), so the
2838
+ // links are there for check:screens. Escape closes the dialog itself; the scrim is a click on the dialog, not on its content.
2839
+ function ModalMainDrawer({
2840
+ label,
2841
+ open,
2842
+ onOpenChange,
2843
+ onEscape,
2844
+ children,
2845
+ }: {
2846
+ label: string;
2847
+ open: boolean;
2848
+ onOpenChange: (open: boolean) => void;
2849
+ onEscape?: () => boolean;
2850
+ children: ReactNode;
2851
+ }) {
2852
+ const dialog = useRef<HTMLDialogElement>(null);
2853
+ // Escape that went back a level: the dialog must not close on it (keydown comes before the dialog's cancel).
2854
+ const escaped = useRef(false);
2855
+ const down = useRef<{ x: number; y: number } | null>(null);
2856
+ useEffect(() => {
2857
+ const el = dialog.current;
2858
+ if (!el) return;
2859
+ if (open && !el.open) {
2860
+ el.showModal();
2861
+ siteFocusNavigationDrawerItem(el);
2862
+ } else if (!open && el.open) el.close();
2863
+ }, [open]);
2864
+ return (
2865
+ <dialog
2866
+ ref={dialog}
2867
+ aria-label={label}
2868
+ className={`${siteNavigationDrawerDialogClass} editor-main-drawer`}
2869
+ onClose={() => onOpenChange(false)}
2870
+ onCancel={(e) => {
2871
+ if (escaped.current) e.preventDefault();
2872
+ escaped.current = false;
2873
+ }}
2874
+ onClick={(e) => {
2875
+ if (e.target === dialog.current || (e.target as Element).closest('a[href]')) onOpenChange(false);
2876
+ }}
2877
+ onPointerDown={(e) => (down.current = { x: e.clientX, y: e.clientY })}
2878
+ onPointerUp={(e) => {
2879
+ const from = down.current;
2880
+ down.current = null;
2881
+ if (from && swipeCloses(e.clientX - from.x, e.clientY - from.y, getComputedStyle(e.currentTarget).direction === 'rtl'))
2882
+ onOpenChange(false);
2883
+ }}
2579
2884
  >
2580
- {children}
2581
- </SiteNavigationRail>
2885
+ <SiteNavigationDrawer
2886
+ variant="modal"
2887
+ className="editor-main-drawer-panel pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]"
2888
+ onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
2889
+ escaped.current = false;
2890
+ if (e.key === 'Escape' && onEscape?.()) {
2891
+ escaped.current = true;
2892
+ e.preventDefault();
2893
+ }
2894
+ }}
2895
+ >
2896
+ <MainDrawerMenuButton onClick={() => onOpenChange(false)} logo />
2897
+ <MainDrawerBody label={label}>{children}</MainDrawerBody>
2898
+ </SiteNavigationDrawer>
2899
+ </dialog>
2582
2900
  );
2583
2901
  }
2584
2902
 
2585
- /** Parts of the sidebar under each other, with the group's name over them (the theme's rail section). */
2586
- export const NavigationRailSection = SiteNavigationRailSection;
2903
+ /** A destination in the main navigation rail: Home, a link; its icon (filled while `selected`) over its name. */
2904
+ export function MainRailItem({ href, icon: Glyph, label, selected }: { href: string; icon: Icon; label: string; selected?: boolean }) {
2905
+ return <SiteNavigationRailItem href={href} icon={<Glyph weight={selected ? 'fill' : 'regular'} />} label={label} selected={selected} />;
2906
+ }
2587
2907
 
2588
- /** A part in the sidebar: its icon, its name, how many there are (`count`, while expanded) and `selected` when it is
2589
- * open (the theme's rail destination). */
2590
- export function NavigationRailItem({
2908
+ /** A section in the main navigation rail: a button that opens the drawer at that section's list (`onClick`); `selected`
2909
+ * when the open screen is in it. */
2910
+ export function MainRailSectionItem({
2911
+ icon: Glyph,
2912
+ label,
2913
+ selected,
2914
+ onClick,
2915
+ }: {
2916
+ icon: Icon;
2917
+ label: string;
2918
+ selected?: boolean;
2919
+ onClick: () => void;
2920
+ }) {
2921
+ return (
2922
+ <SiteNavigationRailItem icon={<Glyph weight={selected ? 'fill' : 'regular'} />} label={label} selected={selected} onClick={onClick} />
2923
+ );
2924
+ }
2925
+
2926
+ /** A section of the main drawer: its title over its items, a divider between sections (the theme's). */
2927
+ export const MainDrawerSection = SiteNavigationDrawerSection;
2928
+
2929
+ /** A part in the main drawer: its icon (filled while open), its name, how many there are (`count`, at the end) and
2930
+ * `selected` when it is the open one. */
2931
+ export function MainDrawerItem({
2591
2932
  href,
2592
2933
  icon: Glyph,
2593
2934
  label,
@@ -2600,87 +2941,144 @@ export function NavigationRailItem({
2600
2941
  count?: number | null;
2601
2942
  selected?: boolean;
2602
2943
  }) {
2603
- return <SiteNavigationRailItem href={href} icon={<Glyph />} label={label} selected={selected} trailingSupporting={count ?? undefined} />;
2944
+ return (
2945
+ <SiteNavigationDrawerItem
2946
+ href={href}
2947
+ active={selected}
2948
+ aria-current={selected ? 'page' : undefined}
2949
+ icon={<Glyph weight={selected ? 'fill' : 'regular'} />}
2950
+ badge={count ?? undefined}
2951
+ >
2952
+ {label}
2953
+ </SiteNavigationDrawerItem>
2954
+ );
2604
2955
  }
2605
2956
 
2606
- // The sidebar's icon turned round: a panel at the right edge.
2607
- const SidebarRight = ((props: IconProps) => <SidebarSimple {...props} mirrored />) as Icon;
2608
-
2609
- /** The icon of a navigation drawer, the last in the top bar (Screen's `drawer`): the panel at the right edge opens and
2610
- * shuts. */
2611
- export function NavigationDrawerButton({
2957
+ /** A section on the first level of the main drawer: its icon, its name and an arrow to its list. A button (it shows the
2958
+ * list in the same drawer, so the modal drawer stays open); `selected` when the open screen is in it. */
2959
+ export function MainDrawerSectionItem({
2960
+ icon: Glyph,
2612
2961
  label,
2613
- open,
2962
+ selected,
2614
2963
  onClick,
2615
- className = '',
2616
2964
  }: {
2965
+ icon: Icon;
2617
2966
  label: string;
2618
- open?: boolean;
2967
+ selected?: boolean;
2619
2968
  onClick: () => void;
2620
- className?: string;
2621
2969
  }) {
2622
2970
  return (
2623
- <IconButton
2624
- icon={SidebarRight}
2625
- label={label}
2626
- selected={open}
2627
- aria-expanded={open}
2628
-
2629
- className={className}
2971
+ <SiteNavigationDrawerItem
2972
+ active={selected}
2973
+ aria-current={selected ? 'true' : undefined}
2974
+ icon={<Glyph weight={selected ? 'fill' : 'regular'} />}
2975
+ trailing="caret-right"
2630
2976
  onClick={onClick}
2631
- />
2977
+ >
2978
+ {label}
2979
+ </SiteNavigationDrawerItem>
2632
2980
  );
2633
2981
  }
2634
2982
 
2635
- /** A modal navigation drawer (Material 3): the NavigationDrawer in the theme's drawer, in a <dialog> that slides in from
2636
- * the right edge over the screen, opened by its icon in the top bar (NavigationDrawerButton) and closed by a choice, the cross, Escape or a tap
2637
- * beside it. */
2638
- export function ModalNavigationDrawer({
2639
- title,
2640
- items,
2641
- value,
2642
- onChange,
2643
- className = '',
2983
+ /** The back row at the top of a section's list in the main drawer: the first level's name (`label`); `onClick` shows it. */
2984
+ export function MainDrawerBack({ label, onClick }: { label: string; onClick: () => void }) {
2985
+ return <SiteNavigationDrawerBack label={label} onClick={onClick} />;
2986
+ }
2987
+
2988
+ /** A part with sub-screens in a section's list: the part as an item with the button that folds its sub-screens out
2989
+ * (`MainDrawerSubitem`) after it. `selected` when the part itself is the open screen; `open` folds it out (it holds the
2990
+ * open screen), and later changes of it fold it out too. The sub-screens stay in the page, `hidden` while it is folded in. */
2991
+ export function MainDrawerFold({
2992
+ href,
2993
+ icon: Glyph,
2994
+ label,
2995
+ count,
2996
+ selected,
2997
+ open,
2998
+ children,
2644
2999
  }: {
2645
- title: string;
2646
- items: NavigationItem[];
2647
- value: string;
2648
- onChange: (id: string) => void;
2649
- className?: string;
3000
+ href: string;
3001
+ icon: Icon;
3002
+ label: string;
3003
+ count?: number | null;
3004
+ selected?: boolean;
3005
+ open?: boolean;
3006
+ children: ReactNode;
2650
3007
  }) {
2651
- const dialog = useRef<HTMLDialogElement>(null);
2652
- const close = () => dialog.current?.close();
3008
+ const [unfolded, setUnfolded] = useState(Boolean(open));
3009
+ useEffect(() => {
3010
+ if (open) setUnfolded(true);
3011
+ }, [open]);
2653
3012
  return (
2654
3013
  <>
2655
- <NavigationDrawerButton label={title} className={className} onClick={() => dialog.current?.showModal()} />
2656
- <dialog
2657
- ref={dialog}
2658
- aria-label={title}
2659
- onClick={(e) => e.target === dialog.current && close()}
2660
- className={`${siteNavigationDrawerDialogClass} editor-drawer`}
2661
- >
2662
- <SiteNavigationDrawer className="pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
2663
- <div className="flex items-center gap-2 pt-3 pr-3 pb-2 pl-5">
2664
- <h2 className="min-w-0 flex-1 truncate text-lg font-medium">{title}</h2>
2665
- <IconButton icon={X} label={t.common.close} onClick={close} />
2666
- </div>
2667
- <div className="px-3 pb-4">
2668
- <NavigationDrawer
2669
- title={title}
2670
- items={items}
2671
- value={value}
2672
- onChange={(id) => {
2673
- onChange(id);
2674
- close();
2675
- }}
2676
- />
2677
- </div>
2678
- </SiteNavigationDrawer>
2679
- </dialog>
3014
+ <SiteNavigationDrawerRow>
3015
+ <SiteNavigationDrawerItem
3016
+ href={href}
3017
+ active={selected}
3018
+ aria-current={selected ? 'page' : undefined}
3019
+ icon={<Glyph weight={selected || open ? 'fill' : 'regular'} />}
3020
+ badge={count ?? undefined}
3021
+ >
3022
+ {label}
3023
+ </SiteNavigationDrawerItem>
3024
+ <SiteNavigationDrawerToggle
3025
+ label={unfolded ? t.library.hideSubpagesOf(label) : t.library.showSubpagesOf(label)}
3026
+ expanded={unfolded}
3027
+ onClick={() => setUnfolded(!unfolded)}
3028
+ />
3029
+ </SiteNavigationDrawerRow>
3030
+ <div hidden={!unfolded}>{children}</div>
2680
3031
  </>
2681
3032
  );
2682
3033
  }
2683
3034
 
3035
+ /** A sub-screen of a `MainDrawerFold`, `selected` when it is the open one. */
3036
+ export function MainDrawerSubitem({ href, label, selected }: { href: string; label: string; selected?: boolean }) {
3037
+ return (
3038
+ <SiteNavigationDrawerSubitem href={href} active={selected} aria-current={selected ? 'page' : undefined}>
3039
+ {label}
3040
+ </SiteNavigationDrawerSubitem>
3041
+ );
3042
+ }
3043
+
3044
+ /** One level of the main drawer (the first, or a section's list). Every level stays in the page; the ones not `shown` are
3045
+ * `hidden`, so out of the accessibility tree and the focus order. A level takes the focus when it is shown after a change of
3046
+ * level (`focus`: `list`, the first item under the back row, or `top`, the section it came from). */
3047
+ export function MainDrawerLevel({ shown = true, focus, children }: { shown?: boolean; focus?: 'list' | 'top'; children: ReactNode }) {
3048
+ const el = useRef<HTMLDivElement>(null);
3049
+ useEffect(() => {
3050
+ const root = el.current;
3051
+ if (!shown || !focus || !root) return;
3052
+ const target =
3053
+ focus === 'list'
3054
+ ? root.querySelector<HTMLElement>('.navigation-drawer-item:not(.navigation-drawer-back), .navigation-drawer-subitem')
3055
+ : (root.querySelector<HTMLElement>('[data-active]') ?? root.querySelector<HTMLElement>('.navigation-drawer-item'));
3056
+ target?.focus();
3057
+ }, [shown, focus]);
3058
+ return (
3059
+ <div ref={el} hidden={!shown} className={shown ? 'contents' : undefined}>
3060
+ {children}
3061
+ </div>
3062
+ );
3063
+ }
3064
+
3065
+ /** The panel icon at the start of the top bar on every screen (Screen.tsx): it opens and closes the main drawer. `open`: the drawer
3066
+ * is shown, so the icon is `left_panel_close` (`sidebar`), else `left_panel_open` (`sidebar-simple`). `label` says what the next
3067
+ * press does. */
3068
+ export function MenuButton({
3069
+ label,
3070
+ open,
3071
+ onClick,
3072
+ className = '',
3073
+ }: {
3074
+ label: string;
3075
+ open?: boolean;
3076
+ onClick: () => void;
3077
+ className?: string;
3078
+ }) {
3079
+ return <IconButton icon={open ? Sidebar : SidebarSimple} label={label} aria-expanded={open} className={className} onClick={onClick} />;
3080
+ }
3081
+
2684
3082
  /** Tabs (Material 3): what they are called (`label`), the one open (`value`), each one's name and icon, and the
2685
3083
  * opening of another. Every tab has its icon over its name, so all tabs in the editor look the same. */
2686
3084
  export type TabChoice<T extends string> = {
@@ -2737,7 +3135,7 @@ export function TabPanel({ id, value, className = '', children }: { id: string;
2737
3135
  );
2738
3136
  }
2739
3137
 
2740
- /** A form in two columns on a wide computer screen (xl): `main` in the wide one, and at the right
3138
+ /** A form in two columns on a wide computer screen (large): `main` in the wide one, and at the right
2741
3139
  * `first` (the status, or the fields beside a preview) and `side` (the other fields), each on a card,
2742
3140
  * or without one (`cards` false: a preview that is its own card). `sticky`: the right column stays in
2743
3141
  * view while the form scrolls (alone, it spans both rows, so it has the whole height to stay in). Narrower, one column as on a phone: `first`, `main`, then `side`. */
@@ -2747,24 +3145,35 @@ export function FormColumns({
2747
3145
  side,
2748
3146
  cards = true,
2749
3147
  sticky = false,
2750
- wideOnly = false,
3148
+ pane,
2751
3149
  }: {
2752
3150
  first?: ReactNode;
2753
3151
  main: ReactNode;
2754
3152
  side?: ReactNode;
2755
3153
  cards?: boolean;
2756
3154
  sticky?: boolean;
2757
- /** The right column only on a wide computer screen (a form's NavigationDrawer, which is a ModalNavigationDrawer narrower). */
2758
- wideOnly?: boolean;
3155
+ /** A SectionPane, on large and wider screens against the window's end edge under the top bar (412dp, fixed while the form
3156
+ * scrolls; editor.css lets the content column keep clear of it, so `main` is centred to its left); below large nothing.
3157
+ * Instead of `first` and `side`. */
3158
+ pane?: ReactNode;
2759
3159
  }) {
2760
- // On a wide computer screen (xl) each of the right column's parts on the theme's outlined card.
2761
- const carded = useMedia('(min-width: 80rem)') && cards;
2762
- const right = `space-y-6 xl:col-start-2 ${carded ? siteCardClass('outlined') : ''} ${sticky ? 'xl:sticky xl:top-24' : ''} ${wideOnly ? 'hidden xl:block' : ''}`;
2763
- return (
2764
- <div className="flex flex-col gap-6 xl:grid xl:grid-cols-[minmax(0,1fr)_22rem] xl:grid-rows-[auto_1fr] xl:items-start xl:gap-x-8">
2765
- {first && <div className={`${right} ${side ? 'xl:row-start-1' : 'xl:row-span-2 xl:row-start-1'}`}>{first}</div>}
2766
- <div className="min-w-0 space-y-6 xl:col-start-1 xl:row-span-2 xl:row-start-1">{main}</div>
2767
- {side && <div className={`${right} ${first ? 'xl:row-start-2' : 'xl:row-span-2 xl:row-start-1'}`}>{side}</div>}
3160
+ // On a wide computer screen (large) each of the right column's parts on the theme's outlined card.
3161
+ const carded = useAtLeast('large') && cards;
3162
+ const right = `space-y-6 large:col-start-2 ${carded ? siteCardClass('outlined') : ''} ${sticky ? 'large:sticky large:top-24' : ''}`;
3163
+ return (
3164
+ <div
3165
+ className={`flex flex-col gap-6 ${
3166
+ pane ? '' : 'large:grid large:grid-cols-[minmax(0,1fr)_22rem] large:grid-rows-[auto_1fr] large:items-start large:gap-x-8'
3167
+ }`}
3168
+ >
3169
+ {first && <div className={`${right} ${side ? 'large:row-start-1' : 'large:row-span-2 large:row-start-1'}`}>{first}</div>}
3170
+ <div className="min-w-0 space-y-6 large:col-start-1 large:row-span-2 large:row-start-1">{main}</div>
3171
+ {side && <div className={`${right} ${first ? 'large:row-start-2' : 'large:row-span-2 large:row-start-1'}`}>{side}</div>}
3172
+ {pane && (
3173
+ <div className="editor-section-pane hidden large:fixed large:end-0 large:top-(--top-bar-height) large:z-10 large:block large:h-[calc(100dvh-var(--top-bar-height))] large:w-(--section-pane-width)">
3174
+ {pane}
3175
+ </div>
3176
+ )}
2768
3177
  </div>
2769
3178
  );
2770
3179
  }
@@ -2859,7 +3268,7 @@ export function BottomSheet({
2859
3268
  }
2860
3269
 
2861
3270
  /** A web page in a frame, in a full BottomSheet (the preview of a page): `html` is the whole page, drawn as
2862
- * wide as `width` (a phone's or a computer's) and made smaller to fit, scrolled in the frame. Nothing
3271
+ * wide as `width` (one of Material 3's window sizes) and made smaller to fit, scrolled in the frame. Nothing
2863
3272
  * in it runs (no scripts), and its links lead nowhere. */
2864
3273
  export function PageFrame({ html, width, title }: { html: string; width: number; title: string }) {
2865
3274
  const box = useRef<HTMLDivElement>(null);
@@ -2962,7 +3371,7 @@ export function SheetTitle({ title, onClose }: { title: string; onClose: () => v
2962
3371
 
2963
3372
  const down = (e: PointerEvent<HTMLDivElement>) => {
2964
3373
  // Not the cross, and not on a computer, where the sheet is a dialog in the middle.
2965
- if ((e.target as Element).closest('button:not([data-bottom-sheet-handle])') || matchMedia('(min-width: 48rem)').matches) return;
3374
+ if ((e.target as Element).closest('button:not([data-bottom-sheet-handle])') || matchMedia(atLeast('medium')).matches) return;
2966
3375
  drag.current = { id: e.pointerId, y: e.clientY, last: e.clientY, at: e.timeStamp, v: 0, from: 0, small: 0, big: 0, moved: false };
2967
3376
  dragged.current = false;
2968
3377
  };
@@ -3012,7 +3421,7 @@ export function SheetTitle({ title, onClose }: { title: string; onClose: () => v
3012
3421
  onPointerMove={move}
3013
3422
  onPointerUp={up}
3014
3423
  onPointerCancel={up}
3015
- className="shrink-0 touch-none select-none md:touch-auto md:select-auto"
3424
+ className="shrink-0 touch-none select-none medium:touch-auto medium:select-auto"
3016
3425
  >
3017
3426
  <SiteBottomSheetHeader
3018
3427
  headline={title}
@@ -3243,9 +3652,67 @@ export function PhoneField({ code, onCode, children }: { code: string; onCode: (
3243
3652
  );
3244
3653
  }
3245
3654
 
3246
- /** A field with a choice: it shows what is chosen, and opens the sheet with the choices (ChoiceSheet),
3247
- * not the phone's own list. `title` is the sheet's (the field's name); `empty` is shown, muted, while
3248
- * nothing is chosen; with `search` a search field over the choices, when there may be many. With `href`, the choices
3655
+ /** A choice's field with its menu (Material 3's exposed dropdown menu) for a few choices: the field (ChoiceField) shows
3656
+ * what is chosen (`empty`, muted, while nothing is) with its arrow, which points up while the menu is open, and a tap,
3657
+ * Enter, Space or the down arrow opens the Menu (`attached`) right under it, as wide as the field, the chosen one
3658
+ * checked. Select's field and MenuRow's small box (`size`) are this one. `title`: the menu's name on a phone. */
3659
+ function ChoiceMenu<T extends string>({
3660
+ title,
3661
+ value,
3662
+ options,
3663
+ empty,
3664
+ onChange,
3665
+ ...field
3666
+ }: Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onChange' | 'title' | 'value'> &
3667
+ InField & {
3668
+ title: string;
3669
+ value: T;
3670
+ options: ChoiceOption<T>[];
3671
+ empty?: string;
3672
+ size?: 'small';
3673
+ onChange: (v: T) => void;
3674
+ }) {
3675
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
3676
+ const chosen = options.find((o) => o.value === value);
3677
+ // The field's box (the button's parent) is what the menu sits under, so it is as wide as the whole field.
3678
+ const [keyboard, setKeyboard] = useState(false);
3679
+ const toggle = (button: HTMLElement, byKeyboard: boolean) => {
3680
+ setKeyboard(byKeyboard);
3681
+ setAnchor(anchor ? null : button.parentElement);
3682
+ };
3683
+ return (
3684
+ <>
3685
+ <ChoiceField
3686
+ {...field}
3687
+ aria-haspopup="menu"
3688
+ aria-expanded={!!anchor}
3689
+ // A click with no pointer (Enter, Space) has detail 0.
3690
+ onClick={(e: MouseEvent<HTMLElement>) => toggle(e.currentTarget, e.detail === 0)}
3691
+ onKeyDown={(e: KeyboardEvent<HTMLElement>) => {
3692
+ if (e.key !== 'ArrowDown' || anchor) return;
3693
+ e.preventDefault();
3694
+ toggle(e.currentTarget, true);
3695
+ }}
3696
+ >
3697
+ <span lang={chosen?.lang} className={`min-w-0 flex-1 truncate ${chosen ? '' : 'opacity-60'}`}>
3698
+ {chosen ? chosen.label : empty}
3699
+ </span>
3700
+ </ChoiceField>
3701
+ <Menu attached keyboard={keyboard} anchor={anchor} label={title} onClose={() => setAnchor(null)}>
3702
+ {options.map((o) => (
3703
+ <MenuItem key={o.value} selected={o.value === value} onClick={() => onChange(o.value)}>
3704
+ {o.label}
3705
+ </MenuItem>
3706
+ ))}
3707
+ </Menu>
3708
+ </>
3709
+ );
3710
+ }
3711
+
3712
+ /** A field with a choice: it shows what is chosen, and opens the choices: on a computer a menu right under the field
3713
+ * (ChoiceMenu), on a phone the sheet with the choices (ChoiceSheet), not the phone's own list. `title` is the sheet's (the
3714
+ * field's name); `empty` is shown, muted, while nothing is chosen; with `search` a search field over the choices, when
3715
+ * there may be many, they are always in the sheet, as are choices in groups or with a detail. With `href`, the choices
3249
3716
  * are on a page of their own (a field's page, Field.page), which it links to, the arrow pointing there, and no sheet. */
3250
3717
  export function Select<T extends string>({
3251
3718
  id,
@@ -3268,7 +3735,10 @@ export function Select<T extends string>({
3268
3735
  onChange: (v: T) => void;
3269
3736
  }) {
3270
3737
  const dialog = useRef<HTMLDialogElement>(null);
3738
+ const wide = useAtLeast('medium');
3271
3739
  const chosen = options.find((o) => o.value === value);
3740
+ if (wide && !href && !search && !options.some((o) => o.group || o.detail))
3741
+ return <ChoiceMenu id={id} title={title} value={value} options={options} empty={empty} onChange={onChange} {...field} />;
3272
3742
  if (href)
3273
3743
  return (
3274
3744
  <ChoiceField id={id} href={href} arrow={CaretRight} {...field}>
@@ -3400,3 +3870,37 @@ export function NewChoice<T extends string>({
3400
3870
  </>
3401
3871
  );
3402
3872
  }
3873
+
3874
+ /** One action of a FAB menu: its icon, its name and where it goes. */
3875
+ export type FabAction = { icon: Icon; label: string; href: string };
3876
+
3877
+ // An action's icon, drawn.
3878
+ const iconOf = ({ icon: Item }: FabAction) => <Item />;
3879
+
3880
+ /** The screen's main actions at the bottom right (.editor-fab, editor.css): the theme's FAB menu, opened and closed as
3881
+ * on the site (setUpFabMenu), or with one action that action as an extended FAB; nothing without any. Drawn in a portal
3882
+ * on <body>, since the screen's entrance animation (a transform) would make its <main> the containing block of a fixed FAB. */
3883
+ export function FabMenu({ icon: Glyph, label, items }: { icon: Icon; label: string; items: FabAction[] }) {
3884
+ const ref = useRef<HTMLDivElement>(null);
3885
+ const menu = items.length > 1;
3886
+ useEffect(() => {
3887
+ const root = ref.current?.querySelector<HTMLElement>('[data-fab-menu]');
3888
+ return root ? setUpFabMenu(root) : undefined;
3889
+ }, [menu]);
3890
+ if (items.length === 0) return null;
3891
+ return createPortal(
3892
+ <div ref={ref} className="editor-fab">
3893
+ {menu ? (
3894
+ <SiteFabMenu
3895
+ icon={<Glyph />}
3896
+ label={label}
3897
+ closeLabel={t.common.close}
3898
+ items={items.map((item) => ({ ...item, icon: iconOf(item) }))}
3899
+ />
3900
+ ) : (
3901
+ <SiteExtendedFab icon={iconOf(items[0])} label={items[0].label} href={items[0].href} />
3902
+ )}
3903
+ </div>,
3904
+ document.body,
3905
+ );
3906
+ }