@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
@@ -1,7 +1,7 @@
1
1
  // A search (m3.material.io/components/search, .search in ../styles/ui.css): the search bar (its leading icon,
2
2
  // `leadingIcon`, the theme's magnifying glass if it has none, the placeholder, `label`, and what stands after it,
3
3
  // `trailing`: icon buttons, an avatar) and the search view it opens (a <dialog>: over the whole screen on a phone, docked
4
- // where the bar is from 48rem), with the button back (`backLabel`), the field (a role="combobox"), the button that
4
+ // where the bar is from the medium breakpoint), with the button back (`backLabel`), the field (a role="combobox"), the button that
5
5
  // empties it (`clearLabel`), and the results (`results`, the theme's list, List and ListItem, as a role="listbox" of
6
6
  // links, each its headline, evt. its supporting text and its icon) or, when none fits, `emptyText`. On the site
7
7
  // search.script.ts opens and closes it (the back button, Escape, a tap outside), narrows the results to what is
@@ -0,0 +1,31 @@
1
+ // A setting's row (.setting-row in ../styles/ui.css): its name (`label`) and a line under it (`supporting`) at the left,
2
+ // and at the right whatever sets it (`children`: the switch's track, a dropdown button), all on one line, centred on it.
3
+ // Switch.tsx and the editor's MenuRow are both made of it, so the two can never drift. `as`: the element, a <label>
4
+ // for a switch (a click anywhere switches it), else a <div>; without a `label` only `children` show; `className` goes on it.
5
+ import type { ElementType, ReactNode } from 'react';
6
+
7
+ export default function SettingRow({
8
+ label,
9
+ supporting,
10
+ as: Tag = 'div',
11
+ className,
12
+ children,
13
+ }: {
14
+ label: ReactNode;
15
+ supporting?: ReactNode;
16
+ as?: ElementType;
17
+ className?: string;
18
+ children?: ReactNode;
19
+ }) {
20
+ return (
21
+ <Tag className={['setting-row', className].filter(Boolean).join(' ')}>
22
+ {label != null && (
23
+ <span className="setting-row-text">
24
+ <span className="setting-row-label">{label}</span>
25
+ {supporting && <span className="setting-row-supporting">{supporting}</span>}
26
+ </span>
27
+ )}
28
+ {children}
29
+ </Tag>
30
+ );
31
+ }
@@ -1,6 +1,6 @@
1
- // A switch (m3.material.io/components/switch, .switch in ../styles/ui.css): its name (`label`) and a line under the
2
- // name (`supporting`), and after them the track with its handle, all of it one <label>, so a click anywhere switches
3
- // it; the switch is a native <input type="checkbox" role="switch">, so Space switches it and screen readers say
1
+ // A switch (m3.material.io/components/switch, .switch in ../styles/ui.css): a SettingRow (SettingRow.tsx) with its name
2
+ // (`label`) and a line under it (`supporting`), and after them the track with its handle, all of it one <label>, so a
3
+ // click anywhere switches it; the switch is a native <input type="checkbox" role="switch">, so Space switches it and screen readers say
4
4
  // "on"/"off". `icons`: the theme's icons in the handle, 'both' (a check when it is on, a cross when it is off) or
5
5
  // 'selected' (only the check); none without it. `checked`: on from the start, or, with `onChange` (the editor), whether
6
6
  // it is on now; `disabled`: it cannot be changed. `states`: what it is on and off ("Til", "Fra"), shown before the
@@ -9,6 +9,7 @@
9
9
  // label. An Astro component gives an element the same classes with switchClass.
10
10
  import type { ReactNode } from 'react';
11
11
  import { Svg } from '@morphemecms/cms/src/icon';
12
+ import SettingRow from './SettingRow';
12
13
 
13
14
  export type SwitchIcons = 'both' | 'selected';
14
15
 
@@ -36,13 +37,12 @@ export default function Switch({
36
37
  // Changed by the editor (onChange): on as it says; else on from the start, then by the visitor.
37
38
  const on = rest.onChange ? { checked: Boolean(checked) } : { defaultChecked: checked };
38
39
  return (
39
- <label className={[switchClass(icons), className].filter(Boolean).join(' ')}>
40
- {!hideLabel && (
41
- <span className="switch-text">
42
- <span className="switch-label">{label}</span>
43
- {supporting && <span className="switch-supporting">{supporting}</span>}
44
- </span>
45
- )}
40
+ <SettingRow
41
+ as="label"
42
+ className={[switchClass(icons), className].filter(Boolean).join(' ')}
43
+ label={hideLabel ? null : label}
44
+ supporting={hideLabel ? null : supporting}
45
+ >
46
46
  {states && (
47
47
  <span className="switch-state" aria-hidden="true">
48
48
  {states[checked ? 0 : 1]}
@@ -55,6 +55,6 @@ export default function Switch({
55
55
  {icons === 'both' && <Svg name="x" size={16} className="switch-icon switch-icon-off" />}
56
56
  </span>
57
57
  </span>
58
- </label>
58
+ </SettingRow>
59
59
  );
60
60
  }
@@ -196,16 +196,19 @@ export default function TextField({
196
196
  );
197
197
  }
198
198
 
199
- /** A field that is tapped to choose (the editor's choice from a list, which opens its own sheet, or with `href` a page
200
- * with the choices): the text field (its kind and its look) with a button, or a link, in its container showing what is
201
- * chosen (`children`), its name on its edge (`label`; without it, it is named by `aria-label`), an icon at its end
202
- * (`trailingIcon`: caret-down, the default, or the arrow to a page), and the line under it (`supporting`) or what is
203
- * wrong (`error`). Other attributes (onClick, aria-…) go on the button, and `className` on the field around it. */
199
+ /** A field that is tapped to choose (the editor's choice from a list, which opens its own sheet or a menu under it, or
200
+ * with `href` a page with the choices): the text field (its kind and its look) with a button, or a link, in its container
201
+ * showing what is chosen (`children`), its name on its edge (`label`; without it, it is named by `aria-label`), an icon at
202
+ * its end (`trailingIcon`: caret-down, the default, or the arrow to a page), and the line under it (`supporting`) or what
203
+ * is wrong (`error`). `size="small"`: a small box, as wide as what is chosen, for a row's choice (a setting's, the
204
+ * editor's MenuRow). While its menu is open (aria-expanded on the button) its edge and name are in the accent and the
205
+ * arrow points up. Other attributes (onClick, aria-…) go on the button, and `className` on the field around it. */
204
206
  export function TextFieldButton({
205
207
  label,
206
208
  supporting,
207
209
  error,
208
210
  kind,
211
+ size,
209
212
  href,
210
213
  trailingIcon = 'caret-down',
211
214
  id,
@@ -217,6 +220,7 @@ export function TextFieldButton({
217
220
  supporting?: ReactNode;
218
221
  error?: string | boolean;
219
222
  kind?: TextFieldKind;
223
+ size?: 'small';
220
224
  href?: string;
221
225
  trailingIcon?: IconRef;
222
226
  id?: string;
@@ -229,7 +233,14 @@ export function TextFieldButton({
229
233
  const described = line ? `${field}-supporting` : undefined;
230
234
  return (
231
235
  <div
232
- className={[textFieldClass(kind, !!error), label === undefined && 'text-field-without-label', className].filter(Boolean).join(' ')}
236
+ className={[
237
+ textFieldClass(kind, !!error),
238
+ label === undefined && 'text-field-without-label',
239
+ size === 'small' && 'text-field-small',
240
+ className,
241
+ ]
242
+ .filter(Boolean)
243
+ .join(' ')}
233
244
  >
234
245
  <div className={textFieldContainerClass}>
235
246
  {href === undefined ? (
@@ -1,6 +1,7 @@
1
1
  // What a standard bottom sheet (BottomSheet.tsx with kind 'standard') does on the site: it can be dragged between its
2
- // states and tapped open and closed (standardBottomSheet in bottomSheetControl.ts). Run on every page by the theme's Base.astro
3
- // (blockScripts, packages/editor/site/blockScripts.ts); without a standard bottom sheet on the page it does nothing.
2
+ // states and tapped to step through them (standardBottomSheet in bottomSheetControl.ts).
3
+ // Run on every page by the theme's Base.astro (blockScripts, packages/editor/site/blockScripts.ts); without a standard
4
+ // bottom sheet on the page it does nothing.
4
5
  import { standardBottomSheet } from '@theme/ui/bottomSheetControl';
5
6
 
6
7
  export default function bottomSheets() {
@@ -1,21 +1,39 @@
1
1
  // What a bottom sheet (BottomSheet.tsx) does in a browser.
2
- // A modal one (in its <dialog> with bottomSheetClass), for the script that shows it (an app's: Calendly's
3
- // Sheet.astro): bottomSheet(dialog) gives it its open() and close(). Native <dialog>:
4
- // focus handling, Escape and screen readers work out of the box. Motion: on a phone it slides up and down (a state
5
- // transition) and its drag handle can be pulled down to close it (feedback), on a computer (from 48rem, a dialog in the
6
- // middle of the screen) it fades; with reduced motion it appears and disappears at once. Its close button, Escape and a
7
- // tap on the scrim (outside it) close it; the page under it does not scroll while it is open. It moves with the site's
8
- // motion scheme (motionSpring, @morphemecms/cms/lib/motion.ts).
9
- // A standard one (kind 'standard', at the bottom of the screen without a scrim; bottomSheet.script.ts on every page,
10
- // or a block's or an app's script that wants its controls): standardBottomSheet(sheet) lets it be dragged by its handle
11
- // and its header between the states it rests in (collapsed, half-expanded where that is clearly between the others,
12
- // expanded, and hidden when it has a close button), to the nearest when let go, or on to the next when it is flicked;
13
- // a tap on the handle (or Enter or Space on it) expands it when it is collapsed and collapses it otherwise, and its close
14
- // button hides it. How high each state is, is ui.css's (data-state), read without being painted, so a theme sets it
15
- // there. It gives its state() and set(state) to show it in another (also again after it is hidden). The height follows
16
- // with the site's motion scheme; with reduced motion it changes at once.
2
+ // A modal one (in its <dialog> with bottomSheetClass), for the script that shows it (an app's: Calendly's Sheet.astro):
3
+ // bottomSheet(dialog) gives it its open() and close(). Native <dialog>: focus handling, Escape and screen readers work
4
+ // out of the box. Motion: on a phone it slides up and down (a state transition); it opens at its start height
5
+ // (BottomSheet's `startAt`, half the screen without it), is pulled up to full height and pulled down to close by its
6
+ // handle, its header or its content (modalSheetSize.ts; the editor's sheets, with bottomSheetFitClass or
7
+ // bottomSheetFullClass, keep their heights and the old handle pull down to close), on a computer (from the medium
8
+ // breakpoint) it follows its data-wide (BottomSheet's `wide`): 'dialog' (the default, a dialog in the middle of the
9
+ // screen) fades, 'side-sheet' (a modal side sheet at the end of the screen) slides in from the end (the left in a
10
+ // language written from the right) and out again, 'sheet' (still a bottom sheet) slides up and down as on a phone;
11
+ // with reduced motion it appears and disappears at once. Its close button, Escape and a tap on the scrim (outside it)
12
+ // close it; the page under it does not scroll while it is open. It moves with the site's motion scheme (motionSpring,
13
+ // @morphemecms/cms/lib/motion.ts).
14
+ // A standard one (kind 'standard', at the bottom of the screen without a scrim; bottomSheet.script.ts on every page, or
15
+ // a block's or an app's script that wants its controls): standardBottomSheet(sheet) lets it be dragged by its handle
16
+ // and its header between the states it rests in: the ones in its data-states (the `states` prop of BottomSheet; without
17
+ // it Material 3's, with 'hidden' when it has a close button; half-expanded only where that is clearly between the
18
+ // others), to the nearest when let go, or on to the next when it is flicked; a tap on the handle (or Enter or Space on
19
+ // it) steps through the states, up one at a time and from the highest back to the lowest (never to hidden), and its
20
+ // close button hides it. Its resize button (`resizeLabel`, [data-bottom-sheet-resize]) does what a tap on the handle
21
+ // does and has aria-expanded true while the sheet is above its lowest stop; its collapse button (`collapseLabel`,
22
+ // [data-bottom-sheet-collapse]) sets the lowest stop, moves focus on to the handle, and shows (ui.css) only under
23
+ // data-full, which is set while the sheet is expanded and as high as its max-height lets it be (also when the window
24
+ // is resized). The decisions are sheet-stops.mjs's (tested); this only measures and applies them.
25
+ // From the medium breakpoint a sheet with data-wide 'side-sheet' (the default; BottomSheet's `wide`) is a side sheet
26
+ // (ui.css) and this does nothing to it: the handle, the header and the buttons do not act. It follows the media query:
27
+ // when the window gets that wide it drops a drag and the height it was dragged to, and when it gets narrower it applies
28
+ // the marks again.
29
+ // How high each state is, is ui.css's (data-state), read without being painted, so a theme sets it there. It gives its
30
+ // state() and set(state) to show it in another (also again after it is hidden). The height follows with the site's
31
+ // motion scheme; with reduced motion it changes at once.
17
32
  import { animate } from 'motion';
33
+ import { atLeast } from '@morphemecms/cms/lib/breakpoints.mjs';
18
34
  import { motionSpring } from '@morphemecms/cms/lib/motion';
35
+ import { defaultStates, nextOnTap, restAfterDrag, restingStops } from '@morphemecms/cms/lib/sheet-stops.mjs';
36
+ import { modalSheetSize } from '@theme/ui/modalSheetSize';
19
37
  import type { BottomSheetState } from '@theme/ui/BottomSheet';
20
38
 
21
39
  export type BottomSheetControls = { open: () => void; close: () => Promise<void> };
@@ -25,26 +43,42 @@ export function bottomSheet(sheet: HTMLDialogElement): BottomSheetControls {
25
43
  const panel = sheet.querySelector<HTMLElement>('[data-bottom-sheet-container]')!;
26
44
  const handle = sheet.querySelector<HTMLElement>('[data-bottom-sheet-handle]');
27
45
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
28
- // A dialog in the middle on a computer (.bottom-sheet in ui.css): it fades, and has no handle to drag.
29
- const wide = matchMedia('(width >= 48rem)');
46
+ // On a computer (from the medium breakpoint) what data-wide says (ui.css): a dialog in the middle fades, a side sheet
47
+ // slides in from the end, a sheet slides up; the dialog and the side sheet have no handle to drag.
48
+ const wide = matchMedia(atLeast('medium'));
49
+ const kind = () =>
50
+ !wide.matches ? 'sheet' : panel.dataset.wide === 'side-sheet' ? 'side-sheet' : panel.dataset.wide === 'sheet' ? 'sheet' : 'dialog';
51
+ // Out past the end edge: to the right, or to the left in a language written from the right.
52
+ const away = () => (getComputedStyle(sheet).direction === 'rtl' ? '-100%' : '100%');
53
+ const enter = () => {
54
+ const k = kind();
55
+ return k === 'dialog' ? { opacity: [0, 1], scale: [0.98, 1] } : k === 'side-sheet' ? { x: [away(), '0%'] } : { y: ['100%', '0%'] };
56
+ };
57
+ const leave = () => {
58
+ const k = kind();
59
+ return k === 'dialog' ? { opacity: 0, scale: 0.98 } : k === 'side-sheet' ? { x: away() } : { y: '100%' };
60
+ };
30
61
  let closing = false;
62
+ // On a phone: its start height and the pull by handle, header and content (not for the editor's sheets).
63
+ const size = modalSheetSize(sheet, panel, () => void close());
31
64
 
32
65
  function open() {
33
66
  if (sheet.open) return;
34
67
  sheet.showModal();
35
68
  document.documentElement.style.overflow = 'hidden';
69
+ size.open();
36
70
  if (reduce) return;
37
- if (wide.matches) animate(panel, { opacity: [0, 1], scale: [0.98, 1] }, motionSpring('spatial'));
38
- else animate(panel, { y: ['100%', '0%'] }, motionSpring('spatial'));
71
+ animate(panel, enter(), motionSpring('spatial'));
39
72
  }
40
73
 
41
74
  async function close() {
42
75
  if (!sheet.open || closing) return;
43
76
  closing = true;
44
- if (!reduce) await animate(panel, wide.matches ? { opacity: 0, scale: 0.98 } : { y: '100%' }, motionSpring('spatial', 'fast'));
77
+ if (!reduce) await animate(panel, leave(), motionSpring('spatial', 'fast'));
45
78
  sheet.close();
46
79
  panel.style.transform = '';
47
80
  panel.style.opacity = '';
81
+ size.reset();
48
82
  document.documentElement.style.overflow = '';
49
83
  closing = false;
50
84
  }
@@ -60,10 +94,11 @@ export function bottomSheet(sheet: HTMLDialogElement): BottomSheetControls {
60
94
  if (e.target === sheet) close();
61
95
  });
62
96
 
63
- // Drag the handle down to close.
97
+ // Drag the handle down to close (the sheets modalSheetSize does not manage).
64
98
  let startY = 0;
65
99
  let dragging = false;
66
100
  handle?.addEventListener('pointerdown', (e) => {
101
+ if (size.managed()) return;
67
102
  dragging = true;
68
103
  startY = e.clientY;
69
104
  handle.setPointerCapture(e.pointerId);
@@ -95,8 +130,13 @@ export function standardBottomSheet(sheet: HTMLElement): StandardBottomSheetCont
95
130
  const handle = sheet.querySelector<HTMLElement>('[data-bottom-sheet-handle]');
96
131
  const header = sheet.querySelector<HTMLElement>('[data-bottom-sheet-header]');
97
132
  const closeButton = sheet.querySelector<HTMLElement>('[data-bottom-sheet-close]');
133
+ const resizeButton = sheet.querySelector<HTMLElement>('[data-bottom-sheet-resize]');
134
+ const collapseButton = sheet.querySelector<HTMLElement>('[data-bottom-sheet-collapse]');
98
135
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
99
136
  const state = () => (sheet.dataset.state as BottomSheetState | undefined) ?? 'collapsed';
137
+ // A side sheet from the medium breakpoint (data-wide, ui.css): nothing here acts while it is one.
138
+ const wideQuery = matchMedia(atLeast('medium'));
139
+ const asSideSheet = () => wideQuery.matches && sheet.dataset.wide === 'side-sheet';
100
140
 
101
141
  // How high it is in a state, as ui.css draws it: set, measured and put back before anything is painted.
102
142
  function heightIn(s: BottomSheetState) {
@@ -109,26 +149,33 @@ export function standardBottomSheet(sheet: HTMLElement): StandardBottomSheetCont
109
149
  sheet.style.height = was.height;
110
150
  return px;
111
151
  }
112
- // The states it rests in, from the lowest: half-expanded only where it is clearly between the others.
113
- function stops() {
114
- const collapsed = heightIn('collapsed');
115
- const half = heightIn('half-expanded');
116
- const expanded = heightIn('expanded');
117
- return [
118
- ...(closeButton ? [{ state: 'hidden' as const, height: 0 }] : []),
119
- { state: 'collapsed' as const, height: collapsed },
120
- ...(half - collapsed > 48 && expanded - half > 48 ? [{ state: 'half-expanded' as const, height: half }] : []),
121
- { state: 'expanded' as const, height: expanded },
122
- ];
152
+ // The states it allows (data-states, else Material 3's), and the stops they make at the heights measured now.
153
+ const states = (sheet.dataset.states?.split(' ').filter(Boolean) as BottomSheetState[] | undefined) ?? defaultStates(!!closeButton);
154
+ const stops = () =>
155
+ restingStops({ collapsed: heightIn('collapsed'), 'half-expanded': heightIn('half-expanded'), expanded: heightIn('expanded') }, states);
156
+ // The lowest stop that is not 'hidden': where the collapse button goes, and where the resize button's up caret shows.
157
+ const lowest = () => stops().find((stop) => stop.state !== 'hidden')?.state ?? 'collapsed';
158
+ // data-full: expanded, and as high as max-height lets it be (1px of slack).
159
+ function settleFull(s: BottomSheetState) {
160
+ const max = parseFloat(getComputedStyle(sheet).maxHeight);
161
+ sheet.toggleAttribute('data-full', s === 'expanded' && max > 0 && sheet.offsetHeight >= max - 1);
123
162
  }
163
+ // The resize button's aria-expanded (and so its caret): true while the sheet is above its lowest stop.
164
+ const settleResize = (s: BottomSheetState) => resizeButton?.setAttribute('aria-expanded', String(s !== 'hidden' && s !== lowest()));
124
165
  const shown = (s: BottomSheetState) => {
125
166
  sheet.dataset.state = s;
126
167
  sheet.style.height = '';
127
168
  handle?.setAttribute('aria-expanded', String(s === 'half-expanded' || s === 'expanded'));
169
+ settleResize(s);
170
+ settleFull(s);
128
171
  };
129
172
 
130
173
  // From the height it has now (0 when hidden) to the one of `to`, with all of it there while it moves.
131
174
  async function set(to: BottomSheetState) {
175
+ if (asSideSheet()) {
176
+ sheet.dataset.state = to;
177
+ return;
178
+ }
132
179
  const from = state() === 'hidden' ? 0 : sheet.offsetHeight;
133
180
  const height = heightIn(to);
134
181
  if (reduce || from === height) return shown(to);
@@ -146,7 +193,7 @@ export function standardBottomSheet(sheet: HTMLElement): StandardBottomSheetCont
146
193
  let afterDrag = false;
147
194
  const down = (e: PointerEvent) => {
148
195
  const target = e.target as Element;
149
- if (e.button !== 0 || (target.closest('button, a') && target.closest('[data-bottom-sheet-handle]') === null)) return;
196
+ if (asSideSheet() || e.button !== 0 || (target.closest('button, a') && target.closest('[data-bottom-sheet-handle]') === null)) return;
150
197
  const list = stops();
151
198
  drag = {
152
199
  y: e.clientY,
@@ -159,7 +206,7 @@ export function standardBottomSheet(sheet: HTMLElement): StandardBottomSheetCont
159
206
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
160
207
  };
161
208
  const move = (e: PointerEvent) => {
162
- if (!drag) return;
209
+ if (!drag || asSideSheet()) return;
163
210
  const dy = e.clientY - drag.y;
164
211
  if (!dragged && Math.abs(dy) < 4) return;
165
212
  if (!dragged) {
@@ -183,14 +230,7 @@ export function standardBottomSheet(sheet: HTMLElement): StandardBottomSheetCont
183
230
  delete sheet.dataset.moving;
184
231
  const height = sheet.offsetHeight;
185
232
  const list = stops();
186
- // Flicked: on to the next state that way; else the nearest.
187
- const next =
188
- v > 0.4
189
- ? list.find((s) => s.height > height + 1)
190
- : v < -0.4
191
- ? list.findLast((s) => s.height < height - 1)
192
- : list.reduce((a, b) => (Math.abs(b.height - height) < Math.abs(a.height - height) ? b : a));
193
- set((next ?? list.reduce((a, b) => (Math.abs(b.height - height) < Math.abs(a.height - height) ? b : a))).state);
233
+ set(restAfterDrag(list, height, v));
194
234
  };
195
235
  for (const el of [handle, header]) {
196
236
  el?.addEventListener('pointerdown', down);
@@ -198,12 +238,57 @@ export function standardBottomSheet(sheet: HTMLElement): StandardBottomSheetCont
198
238
  el?.addEventListener('pointerup', up);
199
239
  el?.addEventListener('pointercancel', up);
200
240
  }
201
- // A tap on the handle (or Enter or Space): expanded when collapsed, else collapsed; not after it was dragged.
241
+ // A tap on the handle (or Enter or Space): on to the next state; not after it was dragged.
202
242
  handle?.addEventListener('click', () => {
203
- if (afterDrag) return;
204
- set(state() === 'collapsed' || state() === 'hidden' ? 'expanded' : 'collapsed');
243
+ if (afterDrag || asSideSheet()) return;
244
+ set(nextOnTap(stops(), state()));
205
245
  });
206
- closeButton?.addEventListener('click', () => set('hidden'));
246
+ // The buttons in the header are clicks, not drags (down() leaves them out), like the close button.
247
+ resizeButton?.addEventListener('click', () => asSideSheet() || set(nextOnTap(stops(), state())));
248
+ // The collapse button hides itself with data-full, so focus moves on to the handle (else the resize button, else the
249
+ // sheet) rather than falling to the page.
250
+ collapseButton?.addEventListener('click', async () => {
251
+ if (asSideSheet()) return;
252
+ await set(lowest());
253
+ if (!collapseButton.contains(document.activeElement) && document.activeElement !== document.body) return;
254
+ const next = handle ?? resizeButton ?? sheet;
255
+ if (next === sheet && !sheet.hasAttribute('tabindex')) sheet.tabIndex = -1;
256
+ next.focus();
257
+ });
258
+ closeButton?.addEventListener('click', () => asSideSheet() || set('hidden'));
259
+ // At attach the marks follow the measured stops (the server only guessed), and again when the window is resized
260
+ // (a phone turned while expanded), as the stops move with it; the listener removes itself once the sheet is no longer
261
+ // in the page.
262
+ settleResize(state());
263
+ settleFull(state());
264
+ const onResize = () => {
265
+ if (!sheet.isConnected) {
266
+ removeEventListener('resize', onResize);
267
+ wideQuery.removeEventListener('change', onWide);
268
+ return;
269
+ }
270
+ if (asSideSheet()) return;
271
+ settleResize(state());
272
+ settleFull(state());
273
+ };
274
+ // Becoming a side sheet (or a bottom sheet again): drop a drag in progress and the height it gave the sheet, and on
275
+ // the way back apply the marks as at attach.
276
+ const onWide = () => {
277
+ if (!sheet.isConnected) return onResize();
278
+ drag = undefined;
279
+ dragged = false;
280
+ delete sheet.dataset.moving;
281
+ sheet.style.height = '';
282
+ if (asSideSheet()) {
283
+ sheet.removeAttribute('data-full');
284
+ return;
285
+ }
286
+ handle?.setAttribute('aria-expanded', String(state() === 'half-expanded' || state() === 'expanded'));
287
+ settleResize(state());
288
+ settleFull(state());
289
+ };
290
+ addEventListener('resize', onResize);
291
+ wideQuery.addEventListener('change', onWide);
207
292
 
208
293
  const controls = { state, set };
209
294
  standardSheets.set(sheet, controls);
@@ -1,57 +1,73 @@
1
- // What a FAB menu (FabMenu.tsx) does on the site: its FAB opens and closes its actions (aria-expanded, the list's
2
- // hidden), and while they are open its name is the cross's (data-close-label), else its own (data-label). Opened with a
3
- // key, the focus goes to the first action; the arrow keys go between them, Home and End to the first and the last;
4
- // Escape closes it and goes back to the FAB, and a choice, a tap outside it or the focus moving out close it. Run on
5
- // every page by the theme's Base.astro (blockScripts, packages/editor/site/blockScripts.ts); without a FAB menu on the page it does
6
- // nothing.
7
- export default function fabMenu() {
8
- document.querySelectorAll<HTMLElement>('[data-fab-menu]').forEach((root) => {
9
- const button = root.querySelector<HTMLButtonElement>('.fab-menu-button');
10
- const list = root.querySelector<HTMLElement>('.fab-menu-list');
11
- if (!button || !list) return;
12
- const items = () => [...list.querySelectorAll<HTMLElement>('a[href], button:not(:disabled)')];
13
- const isOpen = () => button.getAttribute('aria-expanded') === 'true';
1
+ // What a FAB menu (FabMenu.tsx) does: its FAB opens and closes its actions (aria-expanded, the list's hidden), and
2
+ // while they are open its name is the cross's (data-close-label), else its own (data-label). Opened with a key, the
3
+ // focus goes to the first action; the arrow keys go between them, Home and End to the first and the last; Escape
4
+ // closes it and goes back to the FAB, and a choice, a tap outside it or the focus moving out close it. setUpFabMenu does
5
+ // it for one menu (the editor's FabMenu in ui.tsx, which undoes it when the menu goes); the default export for every
6
+ // menu on a site page, run by the theme's Base.astro (blockScripts, packages/editor/site/blockScripts.ts).
14
7
 
15
- const outside = (e: PointerEvent) => {
16
- if (!root.contains(e.target as Node)) set(false);
17
- };
18
- function set(open: boolean, focus?: 'first' | 'button') {
19
- button!.setAttribute('aria-expanded', String(open));
20
- const name = open ? button!.dataset.closeLabel : button!.dataset.label;
21
- if (name) button!.setAttribute('aria-label', name);
22
- list!.hidden = !open;
23
- if (open) document.addEventListener('pointerdown', outside, true);
24
- else document.removeEventListener('pointerdown', outside, true);
25
- if (focus === 'button') button!.focus();
26
- else if (focus) items()[0]?.focus();
27
- }
8
+ /** Makes one FAB menu (its [data-fab-menu] element) open and close; returns what undoes it (every listener removed). */
9
+ export function setUpFabMenu(root: HTMLElement): () => void {
10
+ const button = root.querySelector<HTMLButtonElement>('.fab-menu-button');
11
+ const list = root.querySelector<HTMLElement>('.fab-menu-list');
12
+ if (!button || !list) return () => {};
13
+ const items = () => [...list.querySelectorAll<HTMLElement>('a[href], button:not(:disabled)')];
14
+ const isOpen = () => button.getAttribute('aria-expanded') === 'true';
28
15
 
29
- button.addEventListener('click', (e) => {
30
- const open = !isOpen();
31
- set(open, open && e.detail === 0 ? 'first' : undefined);
32
- });
33
- root.addEventListener('keydown', (e) => {
34
- if (!isOpen()) return;
35
- if (e.key === 'Escape') {
36
- e.preventDefault();
37
- set(false, 'button');
38
- return;
39
- }
40
- const all = items();
41
- const at = all.indexOf(document.activeElement as HTMLElement);
42
- if (at < 0) return;
43
- const to = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: all.length - 1 }[e.key];
44
- if (to === undefined) return;
16
+ const outside = (e: PointerEvent) => {
17
+ if (!root.contains(e.target as Node)) set(false);
18
+ };
19
+ function set(open: boolean, focus?: 'first' | 'button') {
20
+ button!.setAttribute('aria-expanded', String(open));
21
+ const name = open ? button!.dataset.closeLabel : button!.dataset.label;
22
+ if (name) button!.setAttribute('aria-label', name);
23
+ list!.hidden = !open;
24
+ if (open) document.addEventListener('pointerdown', outside, true);
25
+ else document.removeEventListener('pointerdown', outside, true);
26
+ if (focus === 'button') button!.focus();
27
+ else if (focus) items()[0]?.focus();
28
+ }
29
+
30
+ const toggle = (e: MouseEvent) => {
31
+ const open = !isOpen();
32
+ set(open, open && e.detail === 0 ? 'first' : undefined);
33
+ };
34
+ const keys = (e: KeyboardEvent) => {
35
+ if (!isOpen()) return;
36
+ if (e.key === 'Escape') {
45
37
  e.preventDefault();
46
- all[(to + all.length) % all.length].focus();
47
- });
48
- list.addEventListener('click', (e) => {
49
- if ((e.target as HTMLElement).closest('a[href], button')) set(false);
50
- });
51
- // The focus moving to something outside it (Tab); a tap outside is `outside`.
52
- root.addEventListener('focusout', (e) => {
53
- const to = e.relatedTarget as Node | null;
54
- if (isOpen() && to && !root.contains(to)) set(false);
55
- });
56
- });
38
+ set(false, 'button');
39
+ return;
40
+ }
41
+ const all = items();
42
+ const at = all.indexOf(document.activeElement as HTMLElement);
43
+ if (at < 0) return;
44
+ const to = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: all.length - 1 }[e.key];
45
+ if (to === undefined) return;
46
+ e.preventDefault();
47
+ all[(to + all.length) % all.length].focus();
48
+ };
49
+ const choice = (e: MouseEvent) => {
50
+ if ((e.target as HTMLElement).closest('a[href], button')) set(false);
51
+ };
52
+ // The focus moving to something outside it (Tab); a tap outside is `outside`.
53
+ const left = (e: FocusEvent) => {
54
+ const to = e.relatedTarget as Node | null;
55
+ if (isOpen() && to && !root.contains(to)) set(false);
56
+ };
57
+
58
+ button.addEventListener('click', toggle);
59
+ root.addEventListener('keydown', keys);
60
+ list.addEventListener('click', choice);
61
+ root.addEventListener('focusout', left);
62
+ return () => {
63
+ button.removeEventListener('click', toggle);
64
+ root.removeEventListener('keydown', keys);
65
+ list.removeEventListener('click', choice);
66
+ root.removeEventListener('focusout', left);
67
+ document.removeEventListener('pointerdown', outside, true);
68
+ };
69
+ }
70
+
71
+ export default function fabMenu() {
72
+ document.querySelectorAll<HTMLElement>('[data-fab-menu]').forEach((root) => setUpFabMenu(root));
57
73
  }