@morphemecms/cms 0.0.0-stage → 0.1.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 (1106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -3
  3. package/astro.d.mts +2 -0
  4. package/astro.mjs +162 -0
  5. package/bin/morpheme.mjs +38 -0
  6. package/client.d.ts +10 -0
  7. package/defaults/blockTexts.ts +33 -0
  8. package/defaults/collections.ts +774 -0
  9. package/defaults/colors.ts +125 -0
  10. package/defaults/company.ts +20 -0
  11. package/defaults/editor.ts +178 -0
  12. package/defaults/internalLinks.ts +30 -0
  13. package/defaults/opens.ts +18 -0
  14. package/defaults/popup.tsx +93 -0
  15. package/defaults/privacy.ts +79 -0
  16. package/defaults/rules.ts +450 -0
  17. package/defaults/schema.ts +16 -0
  18. package/defaults/sitedata.ts +129 -0
  19. package/defaults/standard.ts +106 -0
  20. package/defaults/standardPages.ts +87 -0
  21. package/defaults/texts.ts +481 -0
  22. package/demo/README.md +26 -0
  23. package/demo/public/apple-touch-icon.png +0 -0
  24. package/demo/public/editor/apple-touch-icon.png +0 -0
  25. package/demo/public/editor/icon-192.png +0 -0
  26. package/demo/public/editor/icon-512.png +0 -0
  27. package/demo/public/editor/icon-maskable-512.png +0 -0
  28. package/demo/public/favicon.ico +0 -0
  29. package/demo/public/favicon.svg +1 -0
  30. package/demo/public/images/uploads/2026/10/andromeda.webp +0 -0
  31. package/demo/public/images/uploads/2026/10/earth-from-space.webp +0 -0
  32. package/demo/public/images/uploads/2026/10/editor-blog.webp +0 -0
  33. package/demo/public/images/uploads/2026/10/editor-colors.webp +0 -0
  34. package/demo/public/images/uploads/2026/10/editor-home.webp +0 -0
  35. package/demo/public/images/uploads/2026/10/editor-page.webp +0 -0
  36. package/demo/public/images/uploads/2026/10/editor-pages.webp +0 -0
  37. package/demo/public/images/uploads/2026/10/editor-seo.webp +0 -0
  38. package/demo/public/images/uploads/2026/10/editor-start.webp +0 -0
  39. package/demo/public/images/uploads/2026/10/editor-themes.webp +0 -0
  40. package/demo/public/images/uploads/2026/10/moon.webp +0 -0
  41. package/demo/public/images/uploads/2026/10/saturn.webp +0 -0
  42. package/demo/public/logo.svg +1 -0
  43. package/demo/src/content/blog/build-a-page-from-blocks.md +19 -0
  44. package/demo/src/content/blog/colors-everyone-can-read.md +19 -0
  45. package/demo/src/content/blog/edit-your-website-from-your-phone.md +21 -0
  46. package/demo/src/content/blog/every-save-is-a-version.md +21 -0
  47. package/demo/src/content/blog/schedule-a-post.md +17 -0
  48. package/demo/src/content/blog/secure-from-day-one.md +17 -0
  49. package/demo/src/content/blog/seo-without-the-guesswork.md +23 -0
  50. package/demo/src/content/blog/switch-themes-without-losing-anything.md +23 -0
  51. package/demo/src/content/blog/the-app-store.md +21 -0
  52. package/demo/src/content/pages/about.md +22 -0
  53. package/demo/src/content/pages/app-store.md +30 -0
  54. package/demo/src/content/pages/blocks.md +98 -0
  55. package/demo/src/content/pages/blog-page.md +11 -0
  56. package/demo/src/content/pages/contact.md +16 -0
  57. package/demo/src/content/pages/get-started.md +20 -0
  58. package/demo/src/content/pages/home.md +26 -0
  59. package/demo/src/content/pages/page-not-found.md +11 -0
  60. package/demo/src/content/pages/privacy-policy.md +24 -0
  61. package/demo/src/data/blocks.json +25 -0
  62. package/demo/src/data/booking.json +6 -0
  63. package/demo/src/data/categories/apps.json +6 -0
  64. package/demo/src/data/categories/design.json +6 -0
  65. package/demo/src/data/categories/editing.json +6 -0
  66. package/demo/src/data/categories/security.json +6 -0
  67. package/demo/src/data/categories/seo.json +6 -0
  68. package/demo/src/data/colors.json +75 -0
  69. package/demo/src/data/contact.json +8 -0
  70. package/demo/src/data/cookies.json +21 -0
  71. package/demo/src/data/footer.json +27 -0
  72. package/demo/src/data/menu.json +35 -0
  73. package/demo/src/data/mobile-menu.json +25 -0
  74. package/demo/src/data/popups/booking-bar.json +13 -0
  75. package/demo/src/data/popups/try-the-editor.json +16 -0
  76. package/demo/src/data/standard-pages.json +8 -0
  77. package/demo/src/data/testimonials/sample-bakery.json +10 -0
  78. package/demo/src/data/testimonials/sample-clinic.json +10 -0
  79. package/demo/src/data/testimonials/sample-studio.json +10 -0
  80. package/demo/src/site/colors.ts +11 -0
  81. package/demo/src/site/config.ts +19 -0
  82. package/icons/lucide/LICENSE +43 -0
  83. package/icons/lucide/LICENSE-material-design-icons +20 -0
  84. package/icons/lucide/address-book.svg +1 -0
  85. package/icons/lucide/app-window.svg +1 -0
  86. package/icons/lucide/arrow-counter-clockwise.svg +1 -0
  87. package/icons/lucide/arrow-down.svg +1 -0
  88. package/icons/lucide/arrow-left.svg +1 -0
  89. package/icons/lucide/arrow-right.svg +1 -0
  90. package/icons/lucide/arrow-square-out.svg +1 -0
  91. package/icons/lucide/arrow-u-up-left.svg +1 -0
  92. package/icons/lucide/arrow-u-up-right.svg +1 -0
  93. package/icons/lucide/arrow-up-right.svg +1 -0
  94. package/icons/lucide/arrow-up.svg +1 -0
  95. package/icons/lucide/arrows-down-up.svg +1 -0
  96. package/icons/lucide/arrows-in-line-horizontal.svg +1 -0
  97. package/icons/lucide/arrows-out-line-horizontal.svg +1 -0
  98. package/icons/lucide/article.svg +1 -0
  99. package/icons/lucide/bank.svg +1 -0
  100. package/icons/lucide/bell.svg +1 -0
  101. package/icons/lucide/bicycle.svg +1 -0
  102. package/icons/lucide/binoculars.svg +1 -0
  103. package/icons/lucide/book-open-text.svg +1 -0
  104. package/icons/lucide/book-open.svg +1 -0
  105. package/icons/lucide/browser.svg +1 -0
  106. package/icons/lucide/buildings.svg +1 -0
  107. package/icons/lucide/bus.svg +1 -0
  108. package/icons/lucide/calendar-blank.svg +1 -0
  109. package/icons/lucide/calendar-check.svg +1 -0
  110. package/icons/lucide/car.svg +1 -0
  111. package/icons/lucide/cards.svg +1 -0
  112. package/icons/lucide/caret-down.svg +1 -0
  113. package/icons/lucide/caret-left.svg +1 -0
  114. package/icons/lucide/caret-right.svg +1 -0
  115. package/icons/lucide/caret-up.svg +1 -0
  116. package/icons/lucide/certificate.svg +1 -0
  117. package/icons/lucide/chart-line-up.svg +1 -0
  118. package/icons/lucide/chats.svg +1 -0
  119. package/icons/lucide/check-circle.svg +1 -0
  120. package/icons/lucide/check.svg +1 -0
  121. package/icons/lucide/circle-half.svg +1 -0
  122. package/icons/lucide/circle.svg +1 -0
  123. package/icons/lucide/clock-counter-clockwise.svg +1 -0
  124. package/icons/lucide/clock.svg +1 -0
  125. package/icons/lucide/code.svg +1 -0
  126. package/icons/lucide/coffee.svg +1 -0
  127. package/icons/lucide/columns.svg +1 -0
  128. package/icons/lucide/cookie.svg +1 -0
  129. package/icons/lucide/copy.svg +1 -0
  130. package/icons/lucide/credit-card.svg +1 -0
  131. package/icons/lucide/currency-circle-dollar.svg +1 -0
  132. package/icons/lucide/cursor-click.svg +1 -0
  133. package/icons/lucide/database.svg +1 -0
  134. package/icons/lucide/desktop.svg +1 -0
  135. package/icons/lucide/device-mobile.svg +1 -0
  136. package/icons/lucide/door-open.svg +1 -0
  137. package/icons/lucide/download-simple.svg +1 -0
  138. package/icons/lucide/envelope-simple.svg +1 -0
  139. package/icons/lucide/eye-slash.svg +1 -0
  140. package/icons/lucide/eye.svg +1 -0
  141. package/icons/lucide/file-pdf.svg +1 -0
  142. package/icons/lucide/file-text.svg +1 -0
  143. package/icons/lucide/file-video.svg +1 -0
  144. package/icons/lucide/file-zip.svg +1 -0
  145. package/icons/lucide/flower-lotus.svg +1 -0
  146. package/icons/lucide/folder-simple.svg +1 -0
  147. package/icons/lucide/funnel.svg +1 -0
  148. package/icons/lucide/gauge.svg +1 -0
  149. package/icons/lucide/gear.svg +1 -0
  150. package/icons/lucide/github-logo.svg +1 -0
  151. package/icons/lucide/globe.svg +1 -0
  152. package/icons/lucide/google-logo.svg +1 -0
  153. package/icons/lucide/graduation-cap.svg +1 -0
  154. package/icons/lucide/hammer.svg +1 -0
  155. package/icons/lucide/hand-heart.svg +1 -0
  156. package/icons/lucide/handshake.svg +1 -0
  157. package/icons/lucide/hard-drives.svg +1 -0
  158. package/icons/lucide/heart.svg +1 -0
  159. package/icons/lucide/house.svg +1 -0
  160. package/icons/lucide/image-square.svg +1 -0
  161. package/icons/lucide/image.svg +1 -0
  162. package/icons/lucide/info.svg +1 -0
  163. package/icons/lucide/keyboard.svg +1 -0
  164. package/icons/lucide/leaf.svg +1 -0
  165. package/icons/lucide/lifebuoy.svg +1 -0
  166. package/icons/lucide/lightbulb.svg +1 -0
  167. package/icons/lucide/link-break.svg +1 -0
  168. package/icons/lucide/link-simple.svg +1 -0
  169. package/icons/lucide/list-bullets.svg +1 -0
  170. package/icons/lucide/list-checks.svg +1 -0
  171. package/icons/lucide/list-numbers.svg +1 -0
  172. package/icons/lucide/list.svg +1 -0
  173. package/icons/lucide/lock-simple-open.svg +1 -0
  174. package/icons/lucide/lock-simple.svg +1 -0
  175. package/icons/lucide/magnifying-glass.svg +1 -0
  176. package/icons/lucide/map-pin.svg +1 -0
  177. package/icons/lucide/map-trifold.svg +1 -0
  178. package/icons/lucide/medal.svg +1 -0
  179. package/icons/lucide/megaphone.svg +1 -0
  180. package/icons/lucide/minus.svg +1 -0
  181. package/icons/lucide/moon.svg +1 -0
  182. package/icons/lucide/newspaper.svg +1 -0
  183. package/icons/lucide/pack.json +23 -0
  184. package/icons/lucide/paint-brush-broad.svg +1 -0
  185. package/icons/lucide/palette.svg +1 -0
  186. package/icons/lucide/paper-plane-tilt.svg +1 -0
  187. package/icons/lucide/pencil-simple.svg +1 -0
  188. package/icons/lucide/phone.svg +1 -0
  189. package/icons/lucide/play-fill.svg +1 -0
  190. package/icons/lucide/plus.svg +1 -0
  191. package/icons/lucide/prohibit.svg +1 -0
  192. package/icons/lucide/puzzle-piece.svg +1 -0
  193. package/icons/lucide/question.svg +1 -0
  194. package/icons/lucide/quotes.svg +1 -0
  195. package/icons/lucide/rows.svg +1 -0
  196. package/icons/lucide/ruler.svg +1 -0
  197. package/icons/lucide/scan.svg +1 -0
  198. package/icons/lucide/shapes.svg +1 -0
  199. package/icons/lucide/share-network.svg +1 -0
  200. package/icons/lucide/shield-check.svg +1 -0
  201. package/icons/lucide/sidebar-simple.svg +1 -0
  202. package/icons/lucide/sign-out.svg +1 -0
  203. package/icons/lucide/signpost.svg +1 -0
  204. package/icons/lucide/sliders-horizontal.svg +1 -0
  205. package/icons/lucide/smiley.svg +1 -0
  206. package/icons/lucide/sparkle.svg +1 -0
  207. package/icons/lucide/square-half-bottom.svg +1 -0
  208. package/icons/lucide/square.svg +1 -0
  209. package/icons/lucide/squares-four.svg +1 -0
  210. package/icons/lucide/sticker.svg +1 -0
  211. package/icons/lucide/storefront.svg +1 -0
  212. package/icons/lucide/sun.svg +1 -0
  213. package/icons/lucide/swatches.svg +1 -0
  214. package/icons/lucide/tag.svg +1 -0
  215. package/icons/lucide/text-aa.svg +1 -0
  216. package/icons/lucide/text-align-left.svg +1 -0
  217. package/icons/lucide/text-b.svg +1 -0
  218. package/icons/lucide/text-h-one.svg +1 -0
  219. package/icons/lucide/text-h-three.svg +1 -0
  220. package/icons/lucide/text-h-two.svg +1 -0
  221. package/icons/lucide/text-italic.svg +1 -0
  222. package/icons/lucide/text-t.svg +1 -0
  223. package/icons/lucide/ticket.svg +1 -0
  224. package/icons/lucide/toggle-right.svg +1 -0
  225. package/icons/lucide/train.svg +1 -0
  226. package/icons/lucide/trash.svg +1 -0
  227. package/icons/lucide/upload-simple.svg +1 -0
  228. package/icons/lucide/user-circle.svg +1 -0
  229. package/icons/lucide/user.svg +1 -0
  230. package/icons/lucide/users-three.svg +1 -0
  231. package/icons/lucide/users.svg +1 -0
  232. package/icons/lucide/video-camera.svg +1 -0
  233. package/icons/lucide/warning-circle.svg +1 -0
  234. package/icons/lucide/warning.svg +1 -0
  235. package/icons/lucide/wave-sine.svg +1 -0
  236. package/icons/lucide/wheelchair.svg +1 -0
  237. package/icons/lucide/wrench.svg +1 -0
  238. package/icons/lucide/x.svg +1 -0
  239. package/icons/material-symbols/LICENSE +201 -0
  240. package/icons/material-symbols/LICENSE-material-design-icons +20 -0
  241. package/icons/material-symbols/address-book.svg +1 -0
  242. package/icons/material-symbols/app-window.svg +1 -0
  243. package/icons/material-symbols/arrow-counter-clockwise.svg +1 -0
  244. package/icons/material-symbols/arrow-down.svg +1 -0
  245. package/icons/material-symbols/arrow-left.svg +1 -0
  246. package/icons/material-symbols/arrow-right.svg +1 -0
  247. package/icons/material-symbols/arrow-square-out.svg +1 -0
  248. package/icons/material-symbols/arrow-u-up-left.svg +1 -0
  249. package/icons/material-symbols/arrow-u-up-right.svg +1 -0
  250. package/icons/material-symbols/arrow-up-right.svg +1 -0
  251. package/icons/material-symbols/arrow-up.svg +1 -0
  252. package/icons/material-symbols/arrows-down-up.svg +1 -0
  253. package/icons/material-symbols/arrows-in-line-horizontal.svg +1 -0
  254. package/icons/material-symbols/arrows-out-line-horizontal.svg +1 -0
  255. package/icons/material-symbols/article.svg +1 -0
  256. package/icons/material-symbols/bank.svg +1 -0
  257. package/icons/material-symbols/bell.svg +1 -0
  258. package/icons/material-symbols/bicycle.svg +1 -0
  259. package/icons/material-symbols/binoculars.svg +1 -0
  260. package/icons/material-symbols/book-open-text.svg +1 -0
  261. package/icons/material-symbols/book-open.svg +1 -0
  262. package/icons/material-symbols/browser.svg +1 -0
  263. package/icons/material-symbols/buildings.svg +1 -0
  264. package/icons/material-symbols/bus.svg +1 -0
  265. package/icons/material-symbols/calendar-blank.svg +1 -0
  266. package/icons/material-symbols/calendar-check.svg +1 -0
  267. package/icons/material-symbols/car.svg +1 -0
  268. package/icons/material-symbols/cards.svg +1 -0
  269. package/icons/material-symbols/caret-down.svg +1 -0
  270. package/icons/material-symbols/caret-left.svg +1 -0
  271. package/icons/material-symbols/caret-right.svg +1 -0
  272. package/icons/material-symbols/caret-up.svg +1 -0
  273. package/icons/material-symbols/certificate.svg +1 -0
  274. package/icons/material-symbols/chart-line-up.svg +1 -0
  275. package/icons/material-symbols/chats.svg +1 -0
  276. package/icons/material-symbols/check-circle.svg +1 -0
  277. package/icons/material-symbols/check.svg +1 -0
  278. package/icons/material-symbols/circle-half.svg +1 -0
  279. package/icons/material-symbols/circle.svg +1 -0
  280. package/icons/material-symbols/clock-counter-clockwise.svg +1 -0
  281. package/icons/material-symbols/clock.svg +1 -0
  282. package/icons/material-symbols/code.svg +1 -0
  283. package/icons/material-symbols/coffee.svg +1 -0
  284. package/icons/material-symbols/columns.svg +1 -0
  285. package/icons/material-symbols/cookie.svg +1 -0
  286. package/icons/material-symbols/copy.svg +1 -0
  287. package/icons/material-symbols/credit-card.svg +1 -0
  288. package/icons/material-symbols/currency-circle-dollar.svg +1 -0
  289. package/icons/material-symbols/cursor-click.svg +1 -0
  290. package/icons/material-symbols/database.svg +1 -0
  291. package/icons/material-symbols/desktop.svg +1 -0
  292. package/icons/material-symbols/device-mobile.svg +1 -0
  293. package/icons/material-symbols/door-open.svg +1 -0
  294. package/icons/material-symbols/download-simple.svg +1 -0
  295. package/icons/material-symbols/envelope-simple.svg +1 -0
  296. package/icons/material-symbols/eye-slash.svg +1 -0
  297. package/icons/material-symbols/eye.svg +1 -0
  298. package/icons/material-symbols/file-pdf.svg +1 -0
  299. package/icons/material-symbols/file-text.svg +1 -0
  300. package/icons/material-symbols/file-video.svg +1 -0
  301. package/icons/material-symbols/file-zip.svg +1 -0
  302. package/icons/material-symbols/flower-lotus.svg +1 -0
  303. package/icons/material-symbols/folder-simple.svg +1 -0
  304. package/icons/material-symbols/funnel.svg +1 -0
  305. package/icons/material-symbols/gauge.svg +1 -0
  306. package/icons/material-symbols/gear.svg +1 -0
  307. package/icons/material-symbols/github-logo.svg +1 -0
  308. package/icons/material-symbols/globe.svg +1 -0
  309. package/icons/material-symbols/google-logo.svg +1 -0
  310. package/icons/material-symbols/graduation-cap.svg +1 -0
  311. package/icons/material-symbols/hammer.svg +1 -0
  312. package/icons/material-symbols/hand-heart.svg +1 -0
  313. package/icons/material-symbols/handshake.svg +1 -0
  314. package/icons/material-symbols/hard-drives.svg +1 -0
  315. package/icons/material-symbols/heart.svg +1 -0
  316. package/icons/material-symbols/house.svg +1 -0
  317. package/icons/material-symbols/image-square.svg +1 -0
  318. package/icons/material-symbols/image.svg +1 -0
  319. package/icons/material-symbols/info.svg +1 -0
  320. package/icons/material-symbols/keyboard.svg +1 -0
  321. package/icons/material-symbols/leaf.svg +1 -0
  322. package/icons/material-symbols/lifebuoy.svg +1 -0
  323. package/icons/material-symbols/lightbulb.svg +1 -0
  324. package/icons/material-symbols/link-break.svg +1 -0
  325. package/icons/material-symbols/link-simple.svg +1 -0
  326. package/icons/material-symbols/list-bullets.svg +1 -0
  327. package/icons/material-symbols/list-checks.svg +1 -0
  328. package/icons/material-symbols/list-numbers.svg +1 -0
  329. package/icons/material-symbols/list.svg +1 -0
  330. package/icons/material-symbols/lock-simple-open.svg +1 -0
  331. package/icons/material-symbols/lock-simple.svg +1 -0
  332. package/icons/material-symbols/magnifying-glass.svg +1 -0
  333. package/icons/material-symbols/map-pin.svg +1 -0
  334. package/icons/material-symbols/map-trifold.svg +1 -0
  335. package/icons/material-symbols/medal.svg +1 -0
  336. package/icons/material-symbols/megaphone.svg +1 -0
  337. package/icons/material-symbols/minus.svg +1 -0
  338. package/icons/material-symbols/moon.svg +1 -0
  339. package/icons/material-symbols/newspaper.svg +1 -0
  340. package/icons/material-symbols/pack.json +29 -0
  341. package/icons/material-symbols/paint-brush-broad.svg +1 -0
  342. package/icons/material-symbols/palette.svg +1 -0
  343. package/icons/material-symbols/paper-plane-tilt.svg +1 -0
  344. package/icons/material-symbols/pencil-simple.svg +1 -0
  345. package/icons/material-symbols/phone.svg +1 -0
  346. package/icons/material-symbols/play-fill.svg +1 -0
  347. package/icons/material-symbols/plus.svg +1 -0
  348. package/icons/material-symbols/prohibit.svg +1 -0
  349. package/icons/material-symbols/puzzle-piece.svg +1 -0
  350. package/icons/material-symbols/question.svg +1 -0
  351. package/icons/material-symbols/quotes.svg +1 -0
  352. package/icons/material-symbols/rows.svg +1 -0
  353. package/icons/material-symbols/ruler.svg +1 -0
  354. package/icons/material-symbols/scan.svg +1 -0
  355. package/icons/material-symbols/shapes.svg +1 -0
  356. package/icons/material-symbols/share-network.svg +1 -0
  357. package/icons/material-symbols/shield-check.svg +1 -0
  358. package/icons/material-symbols/sidebar-simple.svg +1 -0
  359. package/icons/material-symbols/sign-out.svg +1 -0
  360. package/icons/material-symbols/signpost.svg +1 -0
  361. package/icons/material-symbols/sliders-horizontal.svg +1 -0
  362. package/icons/material-symbols/smiley.svg +1 -0
  363. package/icons/material-symbols/sparkle.svg +1 -0
  364. package/icons/material-symbols/square-half-bottom.svg +1 -0
  365. package/icons/material-symbols/square.svg +1 -0
  366. package/icons/material-symbols/squares-four.svg +1 -0
  367. package/icons/material-symbols/sticker.svg +1 -0
  368. package/icons/material-symbols/storefront.svg +1 -0
  369. package/icons/material-symbols/sun.svg +1 -0
  370. package/icons/material-symbols/swatches.svg +1 -0
  371. package/icons/material-symbols/tag.svg +1 -0
  372. package/icons/material-symbols/text-aa.svg +1 -0
  373. package/icons/material-symbols/text-align-left.svg +1 -0
  374. package/icons/material-symbols/text-b.svg +1 -0
  375. package/icons/material-symbols/text-h-one.svg +1 -0
  376. package/icons/material-symbols/text-h-three.svg +1 -0
  377. package/icons/material-symbols/text-h-two.svg +1 -0
  378. package/icons/material-symbols/text-italic.svg +1 -0
  379. package/icons/material-symbols/text-t.svg +1 -0
  380. package/icons/material-symbols/ticket.svg +1 -0
  381. package/icons/material-symbols/toggle-right.svg +1 -0
  382. package/icons/material-symbols/train.svg +1 -0
  383. package/icons/material-symbols/trash.svg +1 -0
  384. package/icons/material-symbols/upload-simple.svg +1 -0
  385. package/icons/material-symbols/user-circle.svg +1 -0
  386. package/icons/material-symbols/user.svg +1 -0
  387. package/icons/material-symbols/users-three.svg +1 -0
  388. package/icons/material-symbols/users.svg +1 -0
  389. package/icons/material-symbols/video-camera.svg +1 -0
  390. package/icons/material-symbols/warning-circle.svg +1 -0
  391. package/icons/material-symbols/warning.svg +1 -0
  392. package/icons/material-symbols/wave-sine.svg +1 -0
  393. package/icons/material-symbols/wheelchair.svg +1 -0
  394. package/icons/material-symbols/wrench.svg +1 -0
  395. package/icons/material-symbols/x.svg +1 -0
  396. package/icons/phosphor/LICENSE +21 -0
  397. package/icons/phosphor/address-book.svg +1 -0
  398. package/icons/phosphor/app-window.svg +1 -0
  399. package/icons/phosphor/arrow-counter-clockwise.svg +1 -0
  400. package/icons/phosphor/arrow-down.svg +1 -0
  401. package/icons/phosphor/arrow-left.svg +1 -0
  402. package/icons/phosphor/arrow-right.svg +1 -0
  403. package/icons/phosphor/arrow-square-out.svg +1 -0
  404. package/icons/phosphor/arrow-u-up-left.svg +1 -0
  405. package/icons/phosphor/arrow-u-up-right.svg +1 -0
  406. package/icons/phosphor/arrow-up-right.svg +1 -0
  407. package/icons/phosphor/arrow-up.svg +1 -0
  408. package/icons/phosphor/arrows-down-up.svg +1 -0
  409. package/icons/phosphor/arrows-in-line-horizontal.svg +1 -0
  410. package/icons/phosphor/arrows-out-line-horizontal.svg +1 -0
  411. package/icons/phosphor/article.svg +1 -0
  412. package/icons/phosphor/bank.svg +1 -0
  413. package/icons/phosphor/bell.svg +1 -0
  414. package/icons/phosphor/bicycle.svg +1 -0
  415. package/icons/phosphor/binoculars.svg +1 -0
  416. package/icons/phosphor/book-open-text.svg +1 -0
  417. package/icons/phosphor/book-open.svg +1 -0
  418. package/icons/phosphor/browser.svg +1 -0
  419. package/icons/phosphor/buildings.svg +1 -0
  420. package/icons/phosphor/bus.svg +1 -0
  421. package/icons/phosphor/calendar-blank.svg +1 -0
  422. package/icons/phosphor/calendar-check.svg +1 -0
  423. package/icons/phosphor/car.svg +1 -0
  424. package/icons/phosphor/cards.svg +1 -0
  425. package/icons/phosphor/caret-down.svg +1 -0
  426. package/icons/phosphor/caret-left.svg +1 -0
  427. package/icons/phosphor/caret-right.svg +1 -0
  428. package/icons/phosphor/caret-up.svg +1 -0
  429. package/icons/phosphor/certificate.svg +1 -0
  430. package/icons/phosphor/chart-line-up.svg +1 -0
  431. package/icons/phosphor/chats.svg +1 -0
  432. package/icons/phosphor/check-circle.svg +1 -0
  433. package/icons/phosphor/check.svg +1 -0
  434. package/icons/phosphor/circle-half.svg +1 -0
  435. package/icons/phosphor/circle.svg +1 -0
  436. package/icons/phosphor/clock-counter-clockwise.svg +1 -0
  437. package/icons/phosphor/clock.svg +1 -0
  438. package/icons/phosphor/code.svg +1 -0
  439. package/icons/phosphor/coffee.svg +1 -0
  440. package/icons/phosphor/columns.svg +1 -0
  441. package/icons/phosphor/cookie.svg +1 -0
  442. package/icons/phosphor/copy.svg +1 -0
  443. package/icons/phosphor/credit-card.svg +1 -0
  444. package/icons/phosphor/currency-circle-dollar.svg +1 -0
  445. package/icons/phosphor/cursor-click.svg +1 -0
  446. package/icons/phosphor/database.svg +1 -0
  447. package/icons/phosphor/desktop.svg +1 -0
  448. package/icons/phosphor/device-mobile.svg +1 -0
  449. package/icons/phosphor/door-open.svg +1 -0
  450. package/icons/phosphor/download-simple.svg +1 -0
  451. package/icons/phosphor/envelope-simple.svg +1 -0
  452. package/icons/phosphor/eye-slash.svg +1 -0
  453. package/icons/phosphor/eye.svg +1 -0
  454. package/icons/phosphor/file-pdf.svg +1 -0
  455. package/icons/phosphor/file-text.svg +1 -0
  456. package/icons/phosphor/file-video.svg +1 -0
  457. package/icons/phosphor/file-zip.svg +1 -0
  458. package/icons/phosphor/flower-lotus.svg +1 -0
  459. package/icons/phosphor/folder-simple.svg +1 -0
  460. package/icons/phosphor/funnel.svg +1 -0
  461. package/icons/phosphor/gauge.svg +1 -0
  462. package/icons/phosphor/gear.svg +1 -0
  463. package/icons/phosphor/github-logo.svg +1 -0
  464. package/icons/phosphor/globe.svg +1 -0
  465. package/icons/phosphor/google-logo.svg +1 -0
  466. package/icons/phosphor/graduation-cap.svg +1 -0
  467. package/icons/phosphor/hammer.svg +1 -0
  468. package/icons/phosphor/hand-heart.svg +1 -0
  469. package/icons/phosphor/handshake.svg +1 -0
  470. package/icons/phosphor/hard-drives.svg +1 -0
  471. package/icons/phosphor/heart.svg +1 -0
  472. package/icons/phosphor/house.svg +1 -0
  473. package/icons/phosphor/image-square.svg +1 -0
  474. package/icons/phosphor/image.svg +1 -0
  475. package/icons/phosphor/info.svg +1 -0
  476. package/icons/phosphor/keyboard.svg +1 -0
  477. package/icons/phosphor/leaf.svg +1 -0
  478. package/icons/phosphor/lifebuoy.svg +1 -0
  479. package/icons/phosphor/lightbulb.svg +1 -0
  480. package/icons/phosphor/link-break.svg +1 -0
  481. package/icons/phosphor/link-simple.svg +1 -0
  482. package/icons/phosphor/list-bullets.svg +1 -0
  483. package/icons/phosphor/list-checks.svg +1 -0
  484. package/icons/phosphor/list-numbers.svg +1 -0
  485. package/icons/phosphor/list.svg +1 -0
  486. package/icons/phosphor/lock-simple-open.svg +1 -0
  487. package/icons/phosphor/lock-simple.svg +1 -0
  488. package/icons/phosphor/magnifying-glass.svg +1 -0
  489. package/icons/phosphor/map-pin.svg +1 -0
  490. package/icons/phosphor/map-trifold.svg +1 -0
  491. package/icons/phosphor/medal.svg +1 -0
  492. package/icons/phosphor/megaphone.svg +1 -0
  493. package/icons/phosphor/minus.svg +1 -0
  494. package/icons/phosphor/moon.svg +1 -0
  495. package/icons/phosphor/newspaper.svg +1 -0
  496. package/icons/phosphor/pack.json +8 -0
  497. package/icons/phosphor/paint-brush-broad.svg +1 -0
  498. package/icons/phosphor/palette.svg +1 -0
  499. package/icons/phosphor/paper-plane-tilt.svg +1 -0
  500. package/icons/phosphor/pencil-simple.svg +1 -0
  501. package/icons/phosphor/phone.svg +1 -0
  502. package/icons/phosphor/play-fill.svg +1 -0
  503. package/icons/phosphor/plus.svg +1 -0
  504. package/icons/phosphor/prohibit.svg +1 -0
  505. package/icons/phosphor/puzzle-piece.svg +1 -0
  506. package/icons/phosphor/question.svg +1 -0
  507. package/icons/phosphor/quotes.svg +1 -0
  508. package/icons/phosphor/rows.svg +1 -0
  509. package/icons/phosphor/ruler.svg +1 -0
  510. package/icons/phosphor/scan.svg +1 -0
  511. package/icons/phosphor/shapes.svg +1 -0
  512. package/icons/phosphor/share-network.svg +1 -0
  513. package/icons/phosphor/shield-check.svg +1 -0
  514. package/icons/phosphor/sidebar-simple.svg +1 -0
  515. package/icons/phosphor/sign-out.svg +1 -0
  516. package/icons/phosphor/signpost.svg +1 -0
  517. package/icons/phosphor/sliders-horizontal.svg +1 -0
  518. package/icons/phosphor/smiley.svg +1 -0
  519. package/icons/phosphor/sparkle.svg +1 -0
  520. package/icons/phosphor/square-half-bottom.svg +1 -0
  521. package/icons/phosphor/square.svg +1 -0
  522. package/icons/phosphor/squares-four.svg +1 -0
  523. package/icons/phosphor/sticker.svg +1 -0
  524. package/icons/phosphor/storefront.svg +1 -0
  525. package/icons/phosphor/sun.svg +1 -0
  526. package/icons/phosphor/swatches.svg +1 -0
  527. package/icons/phosphor/tag.svg +1 -0
  528. package/icons/phosphor/text-aa.svg +1 -0
  529. package/icons/phosphor/text-align-left.svg +1 -0
  530. package/icons/phosphor/text-b.svg +1 -0
  531. package/icons/phosphor/text-h-one.svg +1 -0
  532. package/icons/phosphor/text-h-three.svg +1 -0
  533. package/icons/phosphor/text-h-two.svg +1 -0
  534. package/icons/phosphor/text-italic.svg +1 -0
  535. package/icons/phosphor/text-t.svg +1 -0
  536. package/icons/phosphor/ticket.svg +1 -0
  537. package/icons/phosphor/toggle-right.svg +1 -0
  538. package/icons/phosphor/train.svg +1 -0
  539. package/icons/phosphor/trash.svg +1 -0
  540. package/icons/phosphor/upload-simple.svg +1 -0
  541. package/icons/phosphor/user-circle.svg +1 -0
  542. package/icons/phosphor/user.svg +1 -0
  543. package/icons/phosphor/users-three.svg +1 -0
  544. package/icons/phosphor/users.svg +1 -0
  545. package/icons/phosphor/video-camera.svg +1 -0
  546. package/icons/phosphor/warning-circle.svg +1 -0
  547. package/icons/phosphor/warning.svg +1 -0
  548. package/icons/phosphor/wave-sine.svg +1 -0
  549. package/icons/phosphor/wheelchair.svg +1 -0
  550. package/icons/phosphor/wrench.svg +1 -0
  551. package/icons/phosphor/x.svg +1 -0
  552. package/lib/apps.d.mts +28 -0
  553. package/lib/apps.mjs +139 -0
  554. package/lib/blocks.d.mts +23 -0
  555. package/lib/blocks.mjs +90 -0
  556. package/lib/colors.ts +86 -0
  557. package/lib/contactBar.ts +18 -0
  558. package/lib/cookieCategories.d.mts +17 -0
  559. package/lib/cookieCategories.mjs +28 -0
  560. package/lib/crop.d.mts +34 -0
  561. package/lib/crop.mjs +83 -0
  562. package/lib/csp.d.mts +22 -0
  563. package/lib/csp.mjs +130 -0
  564. package/lib/cspSources.d.mts +24 -0
  565. package/lib/cspSources.mjs +60 -0
  566. package/lib/dynamic-colors.ts +426 -0
  567. package/lib/git.d.mts +24 -0
  568. package/lib/git.mjs +64 -0
  569. package/lib/hooks.ts +51 -0
  570. package/lib/inline.ts +20 -0
  571. package/lib/license-texts/Apache-2.0.txt +191 -0
  572. package/lib/license-texts/BSD-2-Clause.txt +24 -0
  573. package/lib/license-texts/BSD-3-Clause.txt +28 -0
  574. package/lib/license-texts/ISC.txt +15 -0
  575. package/lib/license-texts/MIT.txt +21 -0
  576. package/lib/licenses.d.mts +190 -0
  577. package/lib/licenses.mjs +683 -0
  578. package/lib/markdown.ts +42 -0
  579. package/lib/motion-schemes.d.mts +2 -0
  580. package/lib/motion-schemes.mjs +5 -0
  581. package/lib/motion.ts +131 -0
  582. package/lib/notice.d.mts +3 -0
  583. package/lib/notice.mjs +34 -0
  584. package/lib/scheduled.d.mts +15 -0
  585. package/lib/scheduled.mjs +37 -0
  586. package/lib/snackbar.ts +149 -0
  587. package/lib/status.d.mts +7 -0
  588. package/lib/status.mjs +18 -0
  589. package/lib/svg.d.mts +22 -0
  590. package/lib/svg.mjs +231 -0
  591. package/lib/text-colors.d.mts +11 -0
  592. package/lib/text-colors.mjs +43 -0
  593. package/lib/video-id.d.mts +14 -0
  594. package/lib/video-id.mjs +72 -0
  595. package/lib/video.d.mts +20 -0
  596. package/lib/video.mjs +91 -0
  597. package/package.json +131 -6
  598. package/pages/404.astro +10 -0
  599. package/pages/[...slug].astro +51 -0
  600. package/pages/_headers.txt +31 -0
  601. package/pages/blog/index.astro +10 -0
  602. package/pages/blog/kategori/[category].astro +18 -0
  603. package/pages/editor/code-images.json.js +25 -0
  604. package/pages/editor/index.astro +75 -0
  605. package/pages/editor/manifest.webmanifest.js +23 -0
  606. package/pages/editor/sw.js +33 -0
  607. package/pages/editor/sw.js.js +4 -0
  608. package/pages/editor/thumbnails.json.js +21 -0
  609. package/pages/editor/version.json.js +22 -0
  610. package/pages/headers.js +4 -0
  611. package/pages/index.astro +10 -0
  612. package/pages/ny.astro +6 -0
  613. package/pages/robots.txt.js +3 -0
  614. package/pages/rss.xml.js +20 -0
  615. package/scripts/app-screenshots.mjs +164 -0
  616. package/scripts/app-workers.mjs +6 -0
  617. package/scripts/bin.mjs +12 -0
  618. package/scripts/built-site.mjs +99 -0
  619. package/scripts/check-consent.mjs +113 -0
  620. package/scripts/check-content.mjs +149 -0
  621. package/scripts/check-demo.mjs +24 -0
  622. package/scripts/check-themes.mjs +45 -0
  623. package/scripts/check-worker.mjs +569 -0
  624. package/scripts/compare-html.mjs +81 -0
  625. package/scripts/demo-site.mjs +42 -0
  626. package/scripts/editor-icons.mjs +56 -0
  627. package/scripts/editor-roundtrip.mjs +134 -0
  628. package/scripts/editor-screens.mjs +101 -0
  629. package/scripts/git-env.mjs +9 -0
  630. package/scripts/icon-packs.mjs +151 -0
  631. package/scripts/link-counts.mjs +22 -0
  632. package/scripts/page-colors.mjs +42 -0
  633. package/scripts/releases.d.mts +10 -0
  634. package/scripts/releases.mjs +80 -0
  635. package/scripts/responsive-images.mjs +94 -0
  636. package/scripts/scheduled-list.mjs +15 -0
  637. package/scripts/theme-screenshots.mjs +88 -0
  638. package/scripts/types.mjs +21 -0
  639. package/scripts/video-thumbnails.mjs +89 -0
  640. package/scripts/worker-modules.mjs +34 -0
  641. package/site/appCookies.d.mts +9 -0
  642. package/site/appCookies.mjs +74 -0
  643. package/site/apps.ts +21 -0
  644. package/site/autolinks.d.mts +11 -0
  645. package/site/autolinks.mjs +200 -0
  646. package/site/blockScripts.ts +31 -0
  647. package/site/categories.ts +16 -0
  648. package/site/consent.ts +26 -0
  649. package/site/content.config.ts +84 -0
  650. package/site/content.ts +104 -0
  651. package/site/cookieList.ts +17 -0
  652. package/site/defineSite.d.mts +142 -0
  653. package/site/defineSite.mjs +136 -0
  654. package/site/directives/interact.d.mts +2 -0
  655. package/site/directives/interact.mjs +31 -0
  656. package/site/editor-version.d.mts +1 -0
  657. package/site/editor-version.mjs +26 -0
  658. package/site/fill.ts +13 -0
  659. package/site/format.ts +11 -0
  660. package/site/home.ts +66 -0
  661. package/site/iconPacks.d.mts +20 -0
  662. package/site/iconPacks.mjs +43 -0
  663. package/site/icons.d.mts +693 -0
  664. package/site/icons.mjs +605 -0
  665. package/site/images.d.mts +43 -0
  666. package/site/images.mjs +157 -0
  667. package/site/motion.ts +13 -0
  668. package/site/noindex.d.mts +6 -0
  669. package/site/noindex.mjs +21 -0
  670. package/site/pageColors.ts +110 -0
  671. package/site/popups.ts +98 -0
  672. package/site/schema.ts +189 -0
  673. package/site/sections.ts +3 -0
  674. package/site/seo.ts +28 -0
  675. package/site/site.ts +76 -0
  676. package/site/siteConfig.d.mts +1 -0
  677. package/site/siteConfig.mjs +8 -0
  678. package/site/siteCsp.d.mts +10 -0
  679. package/site/siteCsp.mjs +75 -0
  680. package/site/siteModules.d.mts +7 -0
  681. package/site/siteModules.mjs +27 -0
  682. package/site/standardPages.ts +59 -0
  683. package/site/testimonials.ts +48 -0
  684. package/site/text.ts +43 -0
  685. package/site/theme.d.mts +83 -0
  686. package/site/theme.mjs +472 -0
  687. package/site/tracking.client.ts +225 -0
  688. package/site/tracking.ts +9 -0
  689. package/src/ActionParts.tsx +205 -0
  690. package/src/Advanced.tsx +152 -0
  691. package/src/App.tsx +904 -0
  692. package/src/AppList.tsx +1345 -0
  693. package/src/BlockDialog.tsx +360 -0
  694. package/src/BlockParts.tsx +652 -0
  695. package/src/BlockSettings.tsx +533 -0
  696. package/src/BrandForm.tsx +164 -0
  697. package/src/Colors.tsx +476 -0
  698. package/src/CommunicationParts.tsx +199 -0
  699. package/src/CompanyForm.tsx +209 -0
  700. package/src/Consents.tsx +175 -0
  701. package/src/ContainmentParts.tsx +185 -0
  702. package/src/Crop.tsx +152 -0
  703. package/src/DataForm.tsx +434 -0
  704. package/src/Deleted.tsx +177 -0
  705. package/src/EntryForm.tsx +740 -0
  706. package/src/FileView.tsx +30 -0
  707. package/src/FormFrame.tsx +237 -0
  708. package/src/Keywords.tsx +225 -0
  709. package/src/Library.tsx +1563 -0
  710. package/src/Licenses.tsx +336 -0
  711. package/src/LinkSheet.tsx +138 -0
  712. package/src/MarkdownEditor.tsx +1075 -0
  713. package/src/Media.tsx +677 -0
  714. package/src/NavigationParts.tsx +350 -0
  715. package/src/NavigationRestParts.tsx +247 -0
  716. package/src/Notifications.tsx +59 -0
  717. package/src/PagePicker.tsx +136 -0
  718. package/src/Parts.tsx +991 -0
  719. package/src/PartsShown.tsx +168 -0
  720. package/src/PickerParts.tsx +183 -0
  721. package/src/Redirects.tsx +450 -0
  722. package/src/Screen.tsx +184 -0
  723. package/src/SectionsField.tsx +65 -0
  724. package/src/SelectionParts.tsx +134 -0
  725. package/src/SeoScores.tsx +184 -0
  726. package/src/Sheet.tsx +35 -0
  727. package/src/ThemeStore.tsx +376 -0
  728. package/src/Tokens.tsx +346 -0
  729. package/src/Versions.tsx +214 -0
  730. package/src/addresses.ts +75 -0
  731. package/src/allIcons.ts +7 -0
  732. package/src/appFiles.ts +29 -0
  733. package/src/apps.ts +281 -0
  734. package/src/ask.tsx +123 -0
  735. package/src/blocks.ts +251 -0
  736. package/src/brand.ts +153 -0
  737. package/src/cache.ts +42 -0
  738. package/src/check.ts +285 -0
  739. package/src/checkSite.ts +256 -0
  740. package/src/consents.ts +26 -0
  741. package/src/cookieList.ts +70 -0
  742. package/src/countries.ts +59 -0
  743. package/src/deploy.ts +155 -0
  744. package/src/editor.css +602 -0
  745. package/src/examples.ts +85 -0
  746. package/src/fields.tsx +942 -0
  747. package/src/frontmatter.d.mts +6 -0
  748. package/src/frontmatter.mjs +42 -0
  749. package/src/github.ts +376 -0
  750. package/src/i18n/da-DK/advanced.ts +28 -0
  751. package/src/i18n/da-DK/app.ts +71 -0
  752. package/src/i18n/da-DK/apps.ts +90 -0
  753. package/src/i18n/da-DK/blockSettings.ts +38 -0
  754. package/src/i18n/da-DK/blocks.ts +408 -0
  755. package/src/i18n/da-DK/check.ts +25 -0
  756. package/src/i18n/da-DK/colors.ts +64 -0
  757. package/src/i18n/da-DK/common.ts +118 -0
  758. package/src/i18n/da-DK/company.ts +24 -0
  759. package/src/i18n/da-DK/consents.ts +23 -0
  760. package/src/i18n/da-DK/forms.ts +116 -0
  761. package/src/i18n/da-DK/history.ts +35 -0
  762. package/src/i18n/da-DK/index.ts +49 -0
  763. package/src/i18n/da-DK/keywords.ts +19 -0
  764. package/src/i18n/da-DK/library.ts +135 -0
  765. package/src/i18n/da-DK/licenses.ts +25 -0
  766. package/src/i18n/da-DK/media.ts +79 -0
  767. package/src/i18n/da-DK/parts.ts +696 -0
  768. package/src/i18n/da-DK/preview.ts +13 -0
  769. package/src/i18n/da-DK/redirects.ts +51 -0
  770. package/src/i18n/da-DK/seo.ts +20 -0
  771. package/src/i18n/da-DK/text.ts +35 -0
  772. package/src/i18n/da-DK/theme.ts +41 -0
  773. package/src/i18n/en-US/advanced.ts +32 -0
  774. package/src/i18n/en-US/app.ts +84 -0
  775. package/src/i18n/en-US/apps.ts +134 -0
  776. package/src/i18n/en-US/blockSettings.ts +48 -0
  777. package/src/i18n/en-US/blocks.ts +441 -0
  778. package/src/i18n/en-US/check.ts +31 -0
  779. package/src/i18n/en-US/colors.ts +82 -0
  780. package/src/i18n/en-US/common.ts +144 -0
  781. package/src/i18n/en-US/company.ts +25 -0
  782. package/src/i18n/en-US/consents.ts +25 -0
  783. package/src/i18n/en-US/forms.ts +145 -0
  784. package/src/i18n/en-US/history.ts +43 -0
  785. package/src/i18n/en-US/index.ts +51 -0
  786. package/src/i18n/en-US/keywords.ts +24 -0
  787. package/src/i18n/en-US/library.ts +176 -0
  788. package/src/i18n/en-US/licenses.ts +36 -0
  789. package/src/i18n/en-US/media.ts +80 -0
  790. package/src/i18n/en-US/parts.ts +718 -0
  791. package/src/i18n/en-US/preview.ts +15 -0
  792. package/src/i18n/en-US/redirects.ts +66 -0
  793. package/src/i18n/en-US/seo.ts +26 -0
  794. package/src/i18n/en-US/text.ts +41 -0
  795. package/src/i18n/en-US/theme.ts +41 -0
  796. package/src/i18n/index.ts +124 -0
  797. package/src/icon.tsx +160 -0
  798. package/src/iconPacks.ts +89 -0
  799. package/src/images.ts +163 -0
  800. package/src/licenses.ts +79 -0
  801. package/src/markdown.d.mts +12 -0
  802. package/src/markdown.mjs +204 -0
  803. package/src/nav.ts +68 -0
  804. package/src/notFound.ts +28 -0
  805. package/src/notifications.ts +223 -0
  806. package/src/pagePreview.tsx +204 -0
  807. package/src/r2Caching.ts +55 -0
  808. package/src/redirects.ts +149 -0
  809. package/src/registry.tsx +188 -0
  810. package/src/schema.ts +417 -0
  811. package/src/sections.d.mts +45 -0
  812. package/src/sections.mjs +303 -0
  813. package/src/seo.ts +333 -0
  814. package/src/shared.ts +44 -0
  815. package/src/sidebar.ts +31 -0
  816. package/src/site.ts +317 -0
  817. package/src/standard/Block.astro +19 -0
  818. package/src/standard/BlockSite.astro +18 -0
  819. package/src/standard/accordion/index.tsx +47 -0
  820. package/src/standard/accordion/load.ts +23 -0
  821. package/src/standard/accordion/shared.ts +15 -0
  822. package/src/standard/accordion/view.ts +32 -0
  823. package/src/standard/buttons/fields.ts +98 -0
  824. package/src/standard/buttons/index.tsx +23 -0
  825. package/src/standard/buttons/load.ts +34 -0
  826. package/src/standard/buttons/shared.ts +12 -0
  827. package/src/standard/buttons/view.ts +30 -0
  828. package/src/standard/cards/index.tsx +71 -0
  829. package/src/standard/cards/load.ts +17 -0
  830. package/src/standard/cards/shared.ts +27 -0
  831. package/src/standard/cards/view.ts +29 -0
  832. package/src/standard/checklist/index.tsx +27 -0
  833. package/src/standard/checklist/load.ts +5 -0
  834. package/src/standard/checklist/view.ts +13 -0
  835. package/src/standard/cookies/index.tsx +17 -0
  836. package/src/standard/cookies/list.ts +56 -0
  837. package/src/standard/cookies/load.ts +11 -0
  838. package/src/standard/cookies/shared.ts +20 -0
  839. package/src/standard/cookies/view.ts +28 -0
  840. package/src/standard/faq/index.tsx +29 -0
  841. package/src/standard/faq/load.ts +14 -0
  842. package/src/standard/faq/view.ts +21 -0
  843. package/src/standard/head.ts +23 -0
  844. package/src/standard/hero/fields.ts +48 -0
  845. package/src/standard/hero/index.tsx +57 -0
  846. package/src/standard/hero/load.ts +23 -0
  847. package/src/standard/hero/view.ts +38 -0
  848. package/src/standard/links/index.tsx +41 -0
  849. package/src/standard/links/load.ts +17 -0
  850. package/src/standard/links/view.ts +25 -0
  851. package/src/standard/list/index.tsx +50 -0
  852. package/src/standard/list/load.ts +5 -0
  853. package/src/standard/list/view.ts +18 -0
  854. package/src/standard/load.ts +7 -0
  855. package/src/standard/notice/index.tsx +30 -0
  856. package/src/standard/notice/load.ts +16 -0
  857. package/src/standard/notice/view.ts +10 -0
  858. package/src/standard/points/index.tsx +42 -0
  859. package/src/standard/points/load.ts +5 -0
  860. package/src/standard/points/view.ts +12 -0
  861. package/src/standard/posts/index.tsx +132 -0
  862. package/src/standard/posts/load.ts +36 -0
  863. package/src/standard/posts/shared.ts +21 -0
  864. package/src/standard/posts/view.ts +63 -0
  865. package/src/standard/pricecards/index.tsx +57 -0
  866. package/src/standard/pricecards/load.ts +11 -0
  867. package/src/standard/pricecards/view.ts +50 -0
  868. package/src/standard/sharedTexts.ts +6 -0
  869. package/src/standard/site.ts +124 -0
  870. package/src/standard/siteLooks.ts +19 -0
  871. package/src/standard/sites.ts +13 -0
  872. package/src/standard/steps/index.tsx +28 -0
  873. package/src/standard/steps/load.ts +5 -0
  874. package/src/standard/steps/view.ts +11 -0
  875. package/src/standard/testimonials/index.tsx +87 -0
  876. package/src/standard/testimonials/list.ts +43 -0
  877. package/src/standard/testimonials/load.ts +58 -0
  878. package/src/standard/testimonials/shared.ts +13 -0
  879. package/src/standard/testimonials/view.ts +76 -0
  880. package/src/standard/themeBlocks.ts +13 -0
  881. package/src/standard/video/index.tsx +23 -0
  882. package/src/standard/video/load.ts +10 -0
  883. package/src/standard/video/view.ts +42 -0
  884. package/src/standard/view.tsx +105 -0
  885. package/src/standard/views.ts +37 -0
  886. package/src/store.ts +499 -0
  887. package/src/theme.ts +90 -0
  888. package/src/themeUi.ts +97 -0
  889. package/src/themes.ts +91 -0
  890. package/src/ui.tsx +3402 -0
  891. package/src/virtual.d.ts +5 -0
  892. package/src/when.ts +22 -0
  893. package/starter/.githooks/pre-push +4 -0
  894. package/starter/.nvmrc +1 -0
  895. package/starter/.prettierignore +11 -0
  896. package/starter/.prettierrc.json +7 -0
  897. package/starter/README.md +26 -0
  898. package/starter/astro.config.mjs +5 -0
  899. package/starter/package.json +67 -0
  900. package/starter/public/_redirects +1 -0
  901. package/starter/public/apple-touch-icon.png +0 -0
  902. package/starter/public/editor/apple-touch-icon.png +0 -0
  903. package/starter/public/editor/icon-192.png +0 -0
  904. package/starter/public/editor/icon-512.png +0 -0
  905. package/starter/public/editor/icon-maskable-512.png +0 -0
  906. package/starter/public/favicon.ico +0 -0
  907. package/starter/public/favicon.svg +1 -0
  908. package/starter/public/logo.svg +1 -0
  909. package/starter/src/content/pages/blog.md +8 -0
  910. package/starter/src/content/pages/forside.md +9 -0
  911. package/starter/src/content/pages/kontakt.md +7 -0
  912. package/starter/src/content/pages/privatlivspolitik.md +9 -0
  913. package/starter/src/content/pages/siden-findes-ikke.md +7 -0
  914. package/starter/src/content.config.ts +2 -0
  915. package/starter/src/data/apps.json +1 -0
  916. package/starter/src/data/block-settings.json +3 -0
  917. package/starter/src/data/blocks.json +1 -0
  918. package/starter/src/data/booking.json +6 -0
  919. package/starter/src/data/colors.json +70 -0
  920. package/starter/src/data/contact.json +8 -0
  921. package/starter/src/data/cookies.json +21 -0
  922. package/starter/src/data/csp.json +1 -0
  923. package/starter/src/data/footer.json +15 -0
  924. package/starter/src/data/internal-links.json +4 -0
  925. package/starter/src/data/menu.json +4 -0
  926. package/starter/src/data/mobile-menu.json +4 -0
  927. package/starter/src/data/old-addresses.json +1 -0
  928. package/starter/src/data/redirects.json +1 -0
  929. package/starter/src/data/standard-pages.json +8 -0
  930. package/starter/src/data/theme.json +1 -0
  931. package/starter/src/env.d.ts +2 -0
  932. package/starter/src/site/config.ts +13 -0
  933. package/starter/src/site/worker.js +14 -0
  934. package/starter/tsconfig.json +17 -0
  935. package/starter/worker/apps.js +6 -0
  936. package/starter/wrangler.jsonc +39 -0
  937. package/theme/LICENSE +21 -0
  938. package/theme/blocks/ArrowLink.tsx +14 -0
  939. package/theme/blocks/BlockHead.tsx +26 -0
  940. package/theme/blocks/Panel.tsx +32 -0
  941. package/theme/blocks/Person.tsx +26 -0
  942. package/theme/blocks/accordion/PageRow.tsx +25 -0
  943. package/theme/blocks/accordion/View.tsx +17 -0
  944. package/theme/blocks/buttons/Buttons.tsx +28 -0
  945. package/theme/blocks/buttons/View.tsx +12 -0
  946. package/theme/blocks/cards/LinkCards.tsx +72 -0
  947. package/theme/blocks/cards/View.tsx +20 -0
  948. package/theme/blocks/checklist/Checks.tsx +15 -0
  949. package/theme/blocks/checklist/View.tsx +14 -0
  950. package/theme/blocks/cookies/View.tsx +36 -0
  951. package/theme/blocks/faq/Accordion.tsx +34 -0
  952. package/theme/blocks/faq/View.tsx +16 -0
  953. package/theme/blocks/faq/script.ts +56 -0
  954. package/theme/blocks/hero/View.tsx +44 -0
  955. package/theme/blocks/links/LinkGroups.tsx +24 -0
  956. package/theme/blocks/links/View.tsx +17 -0
  957. package/theme/blocks/list/Rows.tsx +24 -0
  958. package/theme/blocks/list/View.tsx +14 -0
  959. package/theme/blocks/notice/View.tsx +13 -0
  960. package/theme/blocks/points/PointList.tsx +34 -0
  961. package/theme/blocks/points/View.tsx +14 -0
  962. package/theme/blocks/posts/View.tsx +26 -0
  963. package/theme/blocks/pricecards/View.tsx +40 -0
  964. package/theme/blocks/steps/StepList.tsx +23 -0
  965. package/theme/blocks/steps/View.tsx +14 -0
  966. package/theme/blocks/testimonials/List.tsx +46 -0
  967. package/theme/blocks/testimonials/Slider.tsx +44 -0
  968. package/theme/blocks/testimonials/Testimonial.tsx +124 -0
  969. package/theme/blocks/testimonials/View.tsx +21 -0
  970. package/theme/blocks/testimonials/script.ts +52 -0
  971. package/theme/blocks/video/View.tsx +41 -0
  972. package/theme/components/ActionBar.astro +63 -0
  973. package/theme/components/AppSlot.astro +21 -0
  974. package/theme/components/ArrowLink.tsx +21 -0
  975. package/theme/components/BlogIndex.astro +48 -0
  976. package/theme/components/BlogPosts.astro +18 -0
  977. package/theme/components/BlogPosts.tsx +45 -0
  978. package/theme/components/BookButton.tsx +22 -0
  979. package/theme/components/Container.astro +11 -0
  980. package/theme/components/CookieConsent.astro +121 -0
  981. package/theme/components/EndBlock.astro +21 -0
  982. package/theme/components/FontPreload.astro +6 -0
  983. package/theme/components/Footer.astro +85 -0
  984. package/theme/components/Header.astro +186 -0
  985. package/theme/components/Icon.astro +16 -0
  986. package/theme/components/Img.astro +29 -0
  987. package/theme/components/Img.tsx +33 -0
  988. package/theme/components/Inline.astro +11 -0
  989. package/theme/components/Logo.astro +23 -0
  990. package/theme/components/LogoMark.astro +10 -0
  991. package/theme/components/Markdown.astro +17 -0
  992. package/theme/components/MobileMenu.tsx +224 -0
  993. package/theme/components/MobileNav.astro +33 -0
  994. package/theme/components/PageHeader.astro +22 -0
  995. package/theme/components/PageSection.astro +24 -0
  996. package/theme/components/PhoneButton.tsx +11 -0
  997. package/theme/components/Popup.astro +145 -0
  998. package/theme/components/PopupButton.tsx +24 -0
  999. package/theme/components/PopupView.tsx +48 -0
  1000. package/theme/components/PostList.tsx +58 -0
  1001. package/theme/components/PostMeta.tsx +17 -0
  1002. package/theme/components/RichText.astro +28 -0
  1003. package/theme/components/Section.astro +29 -0
  1004. package/theme/components/SectionHeading.tsx +8 -0
  1005. package/theme/components/TextSection.astro +16 -0
  1006. package/theme/components/home/PageBlock.astro +19 -0
  1007. package/theme/components/home/Text.astro +13 -0
  1008. package/theme/components/loadPopup.ts +22 -0
  1009. package/theme/components/loadPosts.ts +16 -0
  1010. package/theme/components/posts.ts +15 -0
  1011. package/theme/editor/looks.mjs +7 -0
  1012. package/theme/editor/previews.tsx +56 -0
  1013. package/theme/layouts/Base.astro +273 -0
  1014. package/theme/layouts/PageLayout.astro +126 -0
  1015. package/theme/layouts/PostLayout.astro +137 -0
  1016. package/theme/screenshots/1-front.jpg +0 -0
  1017. package/theme/screenshots/2-front-dark.jpg +0 -0
  1018. package/theme/screenshots/3-blog.jpg +0 -0
  1019. package/theme/screenshots/4-contact.jpg +0 -0
  1020. package/theme/scripts/motion.ts +29 -0
  1021. package/theme/styles/base.css +320 -0
  1022. package/theme/styles/blocks.css +1808 -0
  1023. package/theme/styles/components.css +206 -0
  1024. package/theme/styles/global.css +1302 -0
  1025. package/theme/styles/ui.css +6544 -0
  1026. package/theme/templates/Blog.astro +38 -0
  1027. package/theme/templates/Category.astro +19 -0
  1028. package/theme/templates/Home.astro +36 -0
  1029. package/theme/templates/New.astro +68 -0
  1030. package/theme/templates/NotFound.astro +117 -0
  1031. package/theme/theme.json +28 -0
  1032. package/theme/ui/Badge.tsx +45 -0
  1033. package/theme/ui/BottomSheet.tsx +148 -0
  1034. package/theme/ui/Button.tsx +79 -0
  1035. package/theme/ui/ButtonGroup.tsx +31 -0
  1036. package/theme/ui/Card.tsx +45 -0
  1037. package/theme/ui/Carousel.tsx +124 -0
  1038. package/theme/ui/Checkbox.tsx +59 -0
  1039. package/theme/ui/Chip.tsx +101 -0
  1040. package/theme/ui/ChipSet.tsx +27 -0
  1041. package/theme/ui/DatePicker.tsx +149 -0
  1042. package/theme/ui/DatePickerContainer.tsx +316 -0
  1043. package/theme/ui/Dialog.tsx +100 -0
  1044. package/theme/ui/Divider.tsx +27 -0
  1045. package/theme/ui/ExposedDropdownMenu.tsx +77 -0
  1046. package/theme/ui/Fab.tsx +88 -0
  1047. package/theme/ui/FabMenu.tsx +67 -0
  1048. package/theme/ui/IconButton.tsx +78 -0
  1049. package/theme/ui/List.tsx +287 -0
  1050. package/theme/ui/LoadingIndicator.tsx +29 -0
  1051. package/theme/ui/Menu.tsx +278 -0
  1052. package/theme/ui/NavigationBar.tsx +62 -0
  1053. package/theme/ui/NavigationDrawer.tsx +80 -0
  1054. package/theme/ui/NavigationRail.tsx +154 -0
  1055. package/theme/ui/ProgressIndicator.tsx +162 -0
  1056. package/theme/ui/RadioButton.tsx +50 -0
  1057. package/theme/ui/Search.tsx +129 -0
  1058. package/theme/ui/SegmentedButton.tsx +49 -0
  1059. package/theme/ui/SideSheet.tsx +54 -0
  1060. package/theme/ui/Slider.tsx +155 -0
  1061. package/theme/ui/Snackbar.tsx +67 -0
  1062. package/theme/ui/SplitButton.tsx +67 -0
  1063. package/theme/ui/Switch.tsx +60 -0
  1064. package/theme/ui/Tabs.tsx +114 -0
  1065. package/theme/ui/TextField.tsx +278 -0
  1066. package/theme/ui/TimePicker.tsx +128 -0
  1067. package/theme/ui/TimePickerContainer.tsx +234 -0
  1068. package/theme/ui/Toolbar.tsx +67 -0
  1069. package/theme/ui/Tooltip.tsx +58 -0
  1070. package/theme/ui/TopAppBar.tsx +77 -0
  1071. package/theme/ui/bottomSheet.script.ts +8 -0
  1072. package/theme/ui/bottomSheet.ts +211 -0
  1073. package/theme/ui/carousel.script.ts +28 -0
  1074. package/theme/ui/carouselLayout.ts +229 -0
  1075. package/theme/ui/checkbox.script.ts +8 -0
  1076. package/theme/ui/datePicker.script.ts +302 -0
  1077. package/theme/ui/dates.ts +185 -0
  1078. package/theme/ui/dialog.ts +48 -0
  1079. package/theme/ui/dropdownMenu.script.ts +166 -0
  1080. package/theme/ui/fabMenu.script.ts +57 -0
  1081. package/theme/ui/navigationRail.script.ts +47 -0
  1082. package/theme/ui/ripple.script.ts +159 -0
  1083. package/theme/ui/search.script.ts +92 -0
  1084. package/theme/ui/sideSheet.ts +48 -0
  1085. package/theme/ui/slider.script.ts +12 -0
  1086. package/theme/ui/sliderUpdate.ts +52 -0
  1087. package/theme/ui/snackbar.script.ts +16 -0
  1088. package/theme/ui/splitButton.script.ts +63 -0
  1089. package/theme/ui/tabs.script.ts +48 -0
  1090. package/theme/ui/textField.script.ts +17 -0
  1091. package/theme/ui/textFieldUpdate.ts +44 -0
  1092. package/theme/ui/timePicker.script.ts +248 -0
  1093. package/theme/ui/toolbar.script.ts +43 -0
  1094. package/theme/ui/tooltip.script.ts +68 -0
  1095. package/worker/consent.js +141 -0
  1096. package/worker/editor-github.js +167 -0
  1097. package/worker/editor-host.js +74 -0
  1098. package/worker/files.js +401 -0
  1099. package/worker/github.js +36 -0
  1100. package/worker/index.js +184 -0
  1101. package/worker/not-found.js +97 -0
  1102. package/worker/oauth.js +95 -0
  1103. package/worker/publish.js +52 -0
  1104. package/worker/push.js +270 -0
  1105. package/worker/r2-status.js +50 -0
  1106. package/worker/session.js +58 -0
package/src/ui.tsx ADDED
@@ -0,0 +1,3402 @@
1
+ // The editor's parts: every button, field, switch, chip and row a screen is made of, each with one
2
+ // look, set here. A screen uses these and never writes the classes of a button or a field itself
3
+ // (CLAUDE.md), so the editor looks the same everywhere and a change here changes it everywhere. They
4
+ // are all shown, in every state, on the screen #udseende/dele (Parts.tsx). The screen itself, with its top bar,
5
+ // is Screen.tsx.
6
+ import {
7
+ useEffect,
8
+ useId,
9
+ useLayoutEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ type AnchorHTMLAttributes,
14
+ type HTMLAttributes,
15
+ type ButtonHTMLAttributes,
16
+ type ChangeEvent,
17
+ type CSSProperties,
18
+ type DialogHTMLAttributes,
19
+ type DOMAttributes,
20
+ type InputHTMLAttributes,
21
+ type KeyboardEvent,
22
+ type FocusEvent,
23
+ type FormEvent,
24
+ type MouseEvent,
25
+ type PointerEvent,
26
+ isValidElement,
27
+ type ReactElement,
28
+ type ReactNode,
29
+ type Ref,
30
+ type RefObject,
31
+ type TextareaHTMLAttributes,
32
+ } from 'react';
33
+ import {
34
+ ArrowRight,
35
+ ArrowsDownUp,
36
+ CaretDown,
37
+ CaretRight,
38
+ CaretUp,
39
+ Check,
40
+ Funnel,
41
+ LockSimple,
42
+ LockSimpleOpen,
43
+ PencilSimple,
44
+ Plus,
45
+ SidebarSimple,
46
+ X,
47
+ type Icon,
48
+ type IconProps,
49
+ } from '@phosphor-icons/react';
50
+ import {
51
+ SiteBadge,
52
+ SiteBottomSheetHeader,
53
+ siteBottomSheetContainerClass,
54
+ siteBottomSheetFitClass,
55
+ siteBottomSheetFullClass,
56
+ SiteButton,
57
+ SiteCard,
58
+ siteCardClass,
59
+ siteListClass,
60
+ siteListClasses,
61
+ SiteListItem,
62
+ SiteListItemLeadingIcon,
63
+ SiteMenu,
64
+ SiteMenuItem,
65
+ sitePlaceMenu,
66
+ siteMenuItemClass,
67
+ SiteCarousel,
68
+ SiteCheckbox,
69
+ SiteChip,
70
+ SiteChipSet,
71
+ SiteDivider,
72
+ SiteDatePicker,
73
+ SiteDialog,
74
+ siteDialogModalClass,
75
+ SiteIconButton,
76
+ SiteLoadingIndicator,
77
+ SiteSearchBar,
78
+ SiteNavigationDrawer,
79
+ SiteNavigationDrawerItem,
80
+ SiteNavigationRail,
81
+ SiteNavigationRailItem,
82
+ SiteNavigationRailSection,
83
+ siteNavigationDrawerDialogClass,
84
+ SiteSegmentedButton,
85
+ SiteSideSheet,
86
+ SiteTab,
87
+ SiteTabs,
88
+ SiteSlider,
89
+ SiteSwitch,
90
+ SiteTextField,
91
+ SiteToolbar,
92
+ SiteTopAppBar,
93
+ SiteTextFieldButton,
94
+ SiteTimePicker,
95
+ dayPeriods,
96
+ siteRipples,
97
+ } from './themeUi';
98
+ import { locale, t } from './i18n';
99
+ import { InEditor, type ButtonKind, type IconButtonKind } from './standard/view';
100
+ import { SvgFile } from './icon';
101
+ import { countries, countryName, dialCodes, flagOf, regionOf } from './countries';
102
+ import { byText } from './schema';
103
+ import { contrast } from '../lib/colors';
104
+ import { motionTiming } from '../lib/motion';
105
+
106
+ // The parts Material 3 has (a button, an icon button, a chip, a carousel …) are the theme's UI parts
107
+ // (packages/editor/theme/ui/, through themeUi.ts), the same the site draws, in the theme's design (its ui.css and
108
+ // styles/global.css): here only what the editor gives them (its icons, what a tap does). The editor's own parts (the
109
+ // SEO rating, the colour field, the form's layout …) take the theme's design from its variables (--block-radius,
110
+ // --block-radius-small, --block-shadow and the site's colours).
111
+
112
+ // ---------------------------------------------------------------------------------------------------
113
+ // Buttons
114
+
115
+ /** The theme's button (Material 3's buttons): filled, the one thing to do; tonal, an important step that is not the
116
+ * main one; elevated, one on a coloured background; outlined, another choice beside it ("Annuller" too); text, a small
117
+ * step on the side (Fjern, Tilføj). medium (the default): side by side, and across the screen or a sheet with `wide`
118
+ * (Gem ændringer); small: in a line of text or by a field (Skift billede, Tilføj tag). */
119
+ export type { ButtonKind };
120
+ export type ButtonSize = 'medium' | 'small';
121
+
122
+ type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & {
123
+ kind?: ButtonKind;
124
+ size?: ButtonSize;
125
+ /** Across the screen or the sheet. */
126
+ wide?: boolean;
127
+ /** Before the text. */
128
+ icon?: Icon;
129
+ type?: 'button' | 'submit';
130
+ };
131
+
132
+ export function Button({ icon: Glyph, wide, className, children, ...rest }: ButtonProps) {
133
+ return (
134
+ <SiteButton icon={Glyph && <Glyph />} className={[wide && 'w-full', className].filter(Boolean).join(' ')} {...rest}>
135
+ {children}
136
+ </SiteButton>
137
+ );
138
+ }
139
+
140
+ /** A link that looks like a Button: to another screen, or a file to open or download. */
141
+ export function LinkButton({
142
+ icon: Glyph,
143
+ wide,
144
+ className,
145
+ href = '',
146
+ children,
147
+ ...rest
148
+ }: Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'type'> & { kind?: ButtonKind; size?: ButtonSize; wide?: boolean; icon?: Icon }) {
149
+ return (
150
+ <SiteButton href={href} icon={Glyph && <Glyph />} className={[wide && 'w-full', className].filter(Boolean).join(' ')} {...rest}>
151
+ {children}
152
+ </SiteButton>
153
+ );
154
+ }
155
+
156
+ /** The theme's icon button: standard (grey: every icon button, the screen's own actions too) or filled (confirms: a
157
+ * tick); `selected`: marked, while what it does applies (a filter, the open one); `badge`: how many are new (a
158
+ * number), or that something is (true), which `label` says too, as the label is the button's name. */
159
+ type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'type'> & {
160
+ icon: Icon;
161
+ /** What it does: its name for screen readers and its tooltip. */
162
+ label: string;
163
+ kind?: IconButtonKind;
164
+ selected?: boolean;
165
+ badge?: number | boolean;
166
+ type?: 'button' | 'submit';
167
+ };
168
+
169
+ export function IconButton({ icon: Glyph, label, ...rest }: IconButtonProps) {
170
+ const tooltip = useTooltip(label, rest);
171
+ return (
172
+ <>
173
+ <SiteIconButton icon={<Glyph />} label={label} {...rest} {...tooltip.on} />
174
+ {tooltip.tip}
175
+ </>
176
+ );
177
+ }
178
+
179
+ /** A link that looks like an IconButton (+ on a list, Se på sitet, the account). */
180
+ export function IconLink({
181
+ icon: Glyph,
182
+ label,
183
+ href = '',
184
+ ...rest
185
+ }: Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'type'> & {
186
+ icon: Icon;
187
+ label: string;
188
+ kind?: IconButtonKind;
189
+ selected?: boolean;
190
+ badge?: number | boolean;
191
+ }) {
192
+ const tooltip = useTooltip(label, rest);
193
+ return (
194
+ <>
195
+ <SiteIconButton icon={<Glyph />} label={label} href={href} {...rest} {...tooltip.on} />
196
+ {tooltip.tip}
197
+ </>
198
+ );
199
+ }
200
+
201
+ /** A tool in the text's toolbar (MarkdownEditor.tsx): the theme's icon button a little smaller (its --icon-button-size),
202
+ * so the toolbar fits on a phone, and it keeps the cursor in the text while it is tapped. The only other size. With
203
+ * `menu` it opens a Menu under it (the kinds of text, when the toolbar is too short): the theme's text button with the
204
+ * icon and a small arrow after it. */
205
+ export function ToolbarButton({
206
+ icon: Glyph,
207
+ label,
208
+ selected,
209
+ menu,
210
+ onClick,
211
+ }: {
212
+ icon: Icon;
213
+ label: string;
214
+ /** Marked while it applies where the cursor is (bold); undefined for a tool that is not on or off. */
215
+ selected?: boolean;
216
+ /** It opens a Menu: whether the menu is open. */
217
+ menu?: boolean;
218
+ onClick: (e: MouseEvent<HTMLButtonElement>) => void;
219
+ }) {
220
+ const tooltip = useTooltip(label);
221
+ const own = {
222
+ 'aria-pressed': selected,
223
+ onMouseDown: (e: MouseEvent) => e.preventDefault(),
224
+ onClick,
225
+ ...tooltip.on,
226
+ };
227
+ return (
228
+ <>
229
+ {menu === undefined ? (
230
+ <SiteIconButton icon={<Glyph />} label={label} selected={selected} style={toolbarSize} {...own} />
231
+ ) : (
232
+ <SiteButton
233
+ kind="text"
234
+ size="xsmall"
235
+ icon={<Glyph />}
236
+ trailingIcon={<CaretDown />}
237
+ aria-label={label}
238
+ aria-haspopup="menu"
239
+ aria-expanded={menu}
240
+ {...own}
241
+ >
242
+ {null}
243
+ </SiteButton>
244
+ )}
245
+ {tooltip.tip}
246
+ </>
247
+ );
248
+ }
249
+
250
+ /** The theme's toolbar (Material 3's, docked) with the text's tools (MarkdownEditor.tsx), close round them, without the
251
+ * line a docked one has over it; `className` places it. */
252
+ export function Toolbar({
253
+ ref,
254
+ label,
255
+ className,
256
+ children,
257
+ }: {
258
+ ref?: Ref<HTMLDivElement>;
259
+ label: string;
260
+ className?: string;
261
+ children: ReactNode;
262
+ }) {
263
+ return (
264
+ <SiteToolbar ref={ref} label={label} kind="docked" className={className} style={closeToolbar}>
265
+ {children}
266
+ </SiteToolbar>
267
+ );
268
+ }
269
+
270
+ // The text's toolbar: the room round its tools and no line over it.
271
+ const closeToolbar = {
272
+ '--toolbar-gap': '0.125rem',
273
+ '--toolbar-docked-padding-block': '0.375rem',
274
+ '--toolbar-docked-padding-inline': '0.375rem',
275
+ '--toolbar-docked-line-width': '0',
276
+ } as CSSProperties;
277
+
278
+ // A tool's size in the text's toolbar.
279
+ const toolbarSize = { '--icon-button-size': '2.5rem' } as CSSProperties;
280
+
281
+ // ---------------------------------------------------------------------------------------------------
282
+ // Tooltips
283
+ // The handlers of an element that has a tooltip, which it may have itself too (they are both called).
284
+ type TooltipHandlers = Pick<
285
+ DOMAttributes<HTMLElement>,
286
+ 'onPointerEnter' | 'onPointerLeave' | 'onPointerDown' | 'onFocus' | 'onBlur' | 'onKeyDown'
287
+ >;
288
+
289
+ /** Where a tooltip goes: under what it is about, in the middle, or over it when there is no room under it;
290
+ * always inside the window. */
291
+ function placeTip(tip: HTMLElement, anchor: Element) {
292
+ const at = anchor.getBoundingClientRect();
293
+ const gap = 4;
294
+ const below = at.bottom + gap + tip.offsetHeight <= innerHeight - 8;
295
+ tip.style.top = `${below ? at.bottom + gap : at.top - gap - tip.offsetHeight}px`;
296
+ tip.style.left = `${Math.max(8, Math.min(at.left + at.width / 2 - tip.offsetWidth / 2, innerWidth - tip.offsetWidth - 8))}px`;
297
+ }
298
+
299
+ /** An element's name as a tooltip (`on`: its handlers, `tip`: beside it): the theme's plain tooltip (.tooltip-plain in
300
+ * its ui.css), shown after a moment under the mouse, and at once when the element gets the focus from the keyboard;
301
+ * gone when the mouse leaves, on a tap, on Escape and when it loses the focus. Not on a phone, where the element's name
302
+ * is read out instead. It is a popover (.editor-tooltip in editor.css), so it stands over everything, also a sheet, and
303
+ * is not cut off by a list's frame. `own`: the element's own handlers, called too. It is not read out, as it says the
304
+ * element's name. */
305
+ function useTooltip(text: string, own: TooltipHandlers = {}) {
306
+ const tip = useRef<HTMLSpanElement>(null);
307
+ const timer = useRef<number>(undefined);
308
+ useEffect(() => () => clearTimeout(timer.current), []);
309
+ const show = (anchor: Element) => {
310
+ const el = tip.current;
311
+ if (!el?.showPopover || !anchor.isConnected) return;
312
+ try {
313
+ if (!el.matches(':popover-open')) el.showPopover();
314
+ placeTip(el, anchor);
315
+ } catch {}
316
+ };
317
+ const hide = () => {
318
+ clearTimeout(timer.current);
319
+ const el = tip.current;
320
+ try {
321
+ if (el?.matches(':popover-open')) el.hidePopover();
322
+ } catch {}
323
+ };
324
+ const on: TooltipHandlers = {
325
+ onPointerEnter: (e) => {
326
+ own.onPointerEnter?.(e);
327
+ if (e.pointerType === 'touch') return;
328
+ const anchor = e.currentTarget;
329
+ clearTimeout(timer.current);
330
+ timer.current = window.setTimeout(() => show(anchor), 500);
331
+ },
332
+ onPointerLeave: (e) => {
333
+ own.onPointerLeave?.(e);
334
+ hide();
335
+ },
336
+ onPointerDown: (e) => {
337
+ own.onPointerDown?.(e);
338
+ hide();
339
+ },
340
+ onFocus: (e: FocusEvent<HTMLElement>) => {
341
+ own.onFocus?.(e);
342
+ if (e.currentTarget.matches(':focus-visible')) show(e.currentTarget);
343
+ },
344
+ onBlur: (e) => {
345
+ own.onBlur?.(e);
346
+ hide();
347
+ },
348
+ onKeyDown: (e) => {
349
+ own.onKeyDown?.(e);
350
+ if (e.key === 'Escape') hide();
351
+ },
352
+ };
353
+ return {
354
+ on,
355
+ tip: (
356
+ <span ref={tip} popover="manual" aria-hidden className="tooltip-plain editor-tooltip">
357
+ {text}
358
+ </span>
359
+ ),
360
+ };
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------------------------------
364
+ // Fields
365
+
366
+ /** What a field says in itself, as the theme's text field does: its name on its edge (`label`; without it, it is named
367
+ * by its form's Label or by `aria-label`), what it does under it (`hint`), what is wrong instead (`problem`, the field
368
+ * marked; aria-invalid marks it without saying why) and how much it should have at most (`limit`: 12/60, marked past
369
+ * it). */
370
+ export type InField = { label?: string; hint?: ReactNode; problem?: string; limit?: number };
371
+
372
+ const error = (problem: string | undefined, invalid: unknown) => problem || (invalid ? true : undefined);
373
+
374
+ /** The theme's text field, with what it says in itself (InField). `className` goes on the field around the <input> (its
375
+ * width, its place). */
376
+ export function TextField({
377
+ label,
378
+ hint,
379
+ problem,
380
+ limit,
381
+ ...rest
382
+ }: InputHTMLAttributes<HTMLInputElement> & InField & { ref?: Ref<HTMLInputElement> }) {
383
+ return <SiteTextField label={label} supporting={hint} error={error(problem, rest['aria-invalid'])} limit={limit} {...rest} />;
384
+ }
385
+
386
+ /** The theme's text field of several lines (`rows` high), as TextField. */
387
+ export function TextArea({ label, hint, problem, limit, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement> & InField) {
388
+ return <SiteTextField multiline label={label} supporting={hint} error={error(problem, rest['aria-invalid'])} limit={limit} {...rest} />;
389
+ }
390
+
391
+ /** The theme's date picker (Material 3's): a field to type a date in, with the calendar that picks it, with what it
392
+ * says in itself (InField: under it the date's format when it has no hint) and its texts in the editor's language.
393
+ * `value`: the day, as it is saved (2026-11-02), or '' for none. */
394
+ export function DatePicker({
395
+ id,
396
+ label = '',
397
+ hint,
398
+ problem,
399
+ value,
400
+ onChange,
401
+ }: InField & { id: string; value: string; onChange: (value: string) => void }) {
402
+ return (
403
+ <SiteDatePicker
404
+ id={id}
405
+ name={id}
406
+ label={label}
407
+ supporting={hint}
408
+ error={problem}
409
+ value={value}
410
+ texts={{ locale, ...t.common.datePicker }}
411
+ onChange={(day: string) => onChange(day)}
412
+ />
413
+ );
414
+ }
415
+
416
+ /** The theme's time picker (Material 3's): a field to type a time in, with the clock that picks it, as DatePicker.
417
+ * `value`: the time (12:00), or '' for none. */
418
+ export function TimePicker({
419
+ id,
420
+ label = '',
421
+ hint,
422
+ problem,
423
+ value,
424
+ onChange,
425
+ }: InField & { id: string; value: string; onChange: (value: string) => void }) {
426
+ const [am, pm] = dayPeriods(locale);
427
+ const { time, ...texts } = t.common.timePicker;
428
+ return (
429
+ <SiteTimePicker
430
+ id={id}
431
+ name={id}
432
+ label={label || time}
433
+ supporting={hint}
434
+ error={problem}
435
+ value={value}
436
+ timeFormat={hour12 ? '12h' : '24h'}
437
+ texts={{ locale, am, pm, ...texts }}
438
+ onChange={onChange}
439
+ />
440
+ );
441
+ }
442
+
443
+ // Whether the editor's language writes the time with AM and PM.
444
+ const hour12 = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 ?? false;
445
+
446
+ /** A date and a time ("2026-03-01T12:00", as it is saved): the theme's DatePicker with the field's name, and its
447
+ * TimePicker beside it. A day without a time is at midnight; without a day it is none (''). */
448
+ export function DateTimeField({
449
+ id,
450
+ value,
451
+ onChange,
452
+ ...field
453
+ }: InField & { id: string; value: string; onChange: (value: string) => void }) {
454
+ const [day = '', time = ''] = value.split('T');
455
+ const join = (d: string, h: string) => (d ? `${d}T${h || '00:00'}` : '');
456
+ return (
457
+ <div className="grid gap-3 sm:grid-cols-[1fr_auto]">
458
+ <DatePicker id={id} {...field} value={day} onChange={(d) => onChange(join(d, time))} />
459
+ <TimePicker id={`${id}-time`} value={time.slice(0, 5)} onChange={(h) => onChange(join(day, h))} />
460
+ </div>
461
+ );
462
+ }
463
+
464
+ /** A field that is tapped to choose (Select, PagePicker, a phone number's country code): the theme's TextFieldButton,
465
+ * a button in the text field, or with `href` a link (to a page with the choices), showing what is chosen (`children`)
466
+ * 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). */
468
+ export function ChoiceField({
469
+ href,
470
+ arrow: Arrow = CaretDown,
471
+ label,
472
+ hint,
473
+ problem,
474
+ children,
475
+ ...rest
476
+ }: Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & InField & { href?: string; arrow?: Icon }) {
477
+ return (
478
+ <SiteTextFieldButton label={label} supporting={hint} error={problem} href={href} trailingIcon={<Arrow />} {...rest}>
479
+ {children}
480
+ </SiteTextFieldButton>
481
+ );
482
+ }
483
+
484
+ // A colour as it is typed: with or without #, three or six hex digits; as #rrggbb, or '' when it is none.
485
+ function colorOf(typed: string): string {
486
+ const hex = typed.trim().replace(/^#/, '').toLowerCase();
487
+ if (/^[0-9a-f]{6}$/.test(hex)) return `#${hex}`;
488
+ if (/^[0-9a-f]{3}$/.test(hex)) return `#${[...hex].map((c) => c + c).join('')}`;
489
+ return '';
490
+ }
491
+
492
+ // A colour as its hue (0-360), saturation and brightness (0-1), as the colour sheet picks it.
493
+ type Hsv = { h: number; s: number; v: number };
494
+
495
+ function hsvOf(color: string): Hsv {
496
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16) / 255);
497
+ const max = Math.max(r, g, b);
498
+ const d = max - Math.min(r, g, b);
499
+ const h = !d ? 0 : max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
500
+ return { h: h * 60, s: max ? d / max : 0, v: max };
501
+ }
502
+
503
+ function hexOf({ h, s, v }: Hsv): string {
504
+ const part = (n: number) => {
505
+ const k = (n + h / 60) % 6;
506
+ const c = v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
507
+ return Math.round(c * 255)
508
+ .toString(16)
509
+ .padStart(2, '0');
510
+ };
511
+ return `#${part(5)}${part(3)}${part(1)}`;
512
+ }
513
+
514
+ const clamp = (n: number) => Math.min(1, Math.max(0, n));
515
+ // The edge of a swatch: grey, so a light and a dark colour both show on a light and a dark sheet.
516
+ const swatchEdge = 'border border-[rgb(128_128_128/0.45)]';
517
+ const percent = (n: number) => `${Math.round(n * 100)} %`;
518
+
519
+ /** The colour as text, to type or paste (with or without #, also three digits). What is typed is taken
520
+ * as soon as it is a colour; one that is not says so under the field when it is left. */
521
+ function ColorText({ id, value, label, onChange }: { id: string; value: string; label?: string; onChange: (value: string) => void }) {
522
+ const [typed, setTyped] = useState(value);
523
+ const [wrong, setWrong] = useState(false);
524
+ // A new colour from outside (the sheet, "Brug standardfarverne") replaces what was typed.
525
+ useEffect(() => {
526
+ if (colorOf(typed) !== value) setTyped(value);
527
+ setWrong(false);
528
+ }, [value]);
529
+ return (
530
+ <SiteTextField
531
+ id={id}
532
+ aria-label={label}
533
+ className="min-w-0 flex-1 font-mono"
534
+ error={wrong ? t.common.notAColor : undefined}
535
+ value={typed}
536
+ spellCheck={false}
537
+ autoCapitalize="off"
538
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
539
+ setTyped(e.target.value);
540
+ const color = colorOf(e.target.value);
541
+ if (color) {
542
+ setWrong(false);
543
+ if (color !== value) onChange(color);
544
+ }
545
+ }}
546
+ onKeyDown={(e: KeyboardEvent) => e.key === 'Enter' && e.preventDefault()}
547
+ onBlur={() => {
548
+ const color = colorOf(typed);
549
+ if (color) setTyped(color);
550
+ setWrong(!color);
551
+ }}
552
+ />
553
+ );
554
+ }
555
+
556
+ // Where a finger or the mouse is on an element, from 0 to 1 across and down, while it is held down.
557
+ function drag(onMove: (x: number, y: number) => void) {
558
+ const at = (e: PointerEvent<HTMLElement>) => {
559
+ const box = e.currentTarget.getBoundingClientRect();
560
+ onMove(clamp((e.clientX - box.left) / box.width), clamp((e.clientY - box.top) / box.height));
561
+ };
562
+ return {
563
+ onPointerDown: (e: PointerEvent<HTMLElement>) => {
564
+ e.currentTarget.setPointerCapture(e.pointerId);
565
+ at(e);
566
+ },
567
+ onPointerMove: (e: PointerEvent<HTMLElement>) => e.currentTarget.hasPointerCapture(e.pointerId) && at(e),
568
+ };
569
+ }
570
+
571
+ // A field to drag in (the colour sheet's shades and hues, the colour wheel), and the round thumb on it.
572
+ const ring = 'touch-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none';
573
+ const thumb =
574
+ 'pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_2px_6px_rgb(0_0_0/0.3)]';
575
+
576
+ /** What is in the sheet a colour is picked in (ColorField): a field with its shades (saturation across,
577
+ * brightness up) and a bar with the hues, both to drag in or move with the arrow keys; what it was and
578
+ * what it becomes side by side, with the colour as text; and the colours to pick from (`swatches`, the
579
+ * site's). "Brug farven" takes it, "Brug og lås" (with `onLock`) takes it and locks it, "Annuller" and the cross
580
+ * leave it as it was. Made again each time the sheet opens, so it starts from the colour the field has. */
581
+ function ColorSheet({
582
+ id,
583
+ title,
584
+ value,
585
+ swatches,
586
+ onChange,
587
+ onLock,
588
+ onClose,
589
+ }: {
590
+ id: string;
591
+ title: string;
592
+ value: string;
593
+ swatches?: string[];
594
+ onChange: (value: string) => void;
595
+ /** "Brug og lås" as well: takes the colour and locks it (a base colour of the generator, ColorTiles). */
596
+ onLock?: (value: string) => void;
597
+ onClose: () => void;
598
+ }) {
599
+ const s = t.common.colorSheet;
600
+ const [hsv, setHsv] = useState(() => hsvOf(value));
601
+ const color = hexOf(hsv);
602
+ // A colour typed or tapped: the hue is kept when it has none (grey, white, black).
603
+ const take = (c: string) => {
604
+ const next = hsvOf(c);
605
+ setHsv((old) => ({ ...next, h: next.s && next.v ? next.h : old.h }));
606
+ };
607
+ const keys = (e: KeyboardEvent, move: Partial<Record<string, (c: Hsv) => Hsv>>) => {
608
+ const step = move[e.key];
609
+ if (!step) return;
610
+ e.preventDefault();
611
+ setHsv(step);
612
+ };
613
+ return (
614
+ <>
615
+ <SheetTitle title={title} onClose={onClose} />
616
+ <SheetBody>
617
+ <div
618
+ role="slider"
619
+ tabIndex={0}
620
+ aria-label={s.shade}
621
+ aria-valuemin={0}
622
+ aria-valuemax={100}
623
+ aria-valuenow={Math.round(hsv.s * 100)}
624
+ aria-valuetext={s.shadeValue(percent(hsv.s), percent(hsv.v))}
625
+ className={`relative h-48 rounded-(--block-radius) ${ring}`}
626
+ style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hsv.h} 100% 50%))` }}
627
+ {...drag((x, y) => setHsv((c) => ({ ...c, s: x, v: 1 - y })))}
628
+ onKeyDown={(e) =>
629
+ keys(e, {
630
+ ArrowLeft: (c) => ({ ...c, s: clamp(c.s - 0.02) }),
631
+ ArrowRight: (c) => ({ ...c, s: clamp(c.s + 0.02) }),
632
+ ArrowDown: (c) => ({ ...c, v: clamp(c.v - 0.02) }),
633
+ ArrowUp: (c) => ({ ...c, v: clamp(c.v + 0.02) }),
634
+ })
635
+ }
636
+ >
637
+ <span className={`${thumb} size-7`} style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: color }} />
638
+ </div>
639
+ <div
640
+ role="slider"
641
+ tabIndex={0}
642
+ aria-label={s.hue}
643
+ aria-valuemin={0}
644
+ aria-valuemax={360}
645
+ aria-valuenow={Math.round(hsv.h)}
646
+ className={`relative mx-4 h-8 rounded-full ${ring}`}
647
+ style={{ background: 'linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)' }}
648
+ {...drag((x) => setHsv((c) => ({ ...c, h: x * 360 })))}
649
+ onKeyDown={(e) =>
650
+ keys(e, {
651
+ ArrowLeft: (c) => ({ ...c, h: Math.max(0, c.h - 4) }),
652
+ ArrowDown: (c) => ({ ...c, h: Math.max(0, c.h - 4) }),
653
+ ArrowRight: (c) => ({ ...c, h: Math.min(360, c.h + 4) }),
654
+ ArrowUp: (c) => ({ ...c, h: Math.min(360, c.h + 4) }),
655
+ })
656
+ }
657
+ >
658
+ <span className={`${thumb} top-1/2 size-8`} style={{ left: `${(hsv.h / 360) * 100}%`, background: `hsl(${hsv.h} 100% 50%)` }} />
659
+ </div>
660
+ <div className="flex items-start gap-3">
661
+ <div className={`flex h-11 w-24 shrink-0 overflow-hidden rounded-(--block-radius-small) ${swatchEdge}`}>
662
+ <span role="img" aria-label={s.before} title={s.before} className="flex-1" style={{ background: value }} />
663
+ <span role="img" aria-label={s.after} title={s.after} className="flex-1" style={{ background: color }} />
664
+ </div>
665
+ <ColorText id={`${id}-sheet`} label={s.code} value={color} onChange={take} />
666
+ </div>
667
+ {!!swatches?.length && (
668
+ <div>
669
+ <h3 className="mb-2 text-sm font-medium text-muted">{s.swatches}</h3>
670
+ <div role="group" aria-label={s.swatches} className="grid grid-cols-6 gap-2">
671
+ {swatches.map((c) => (
672
+ <ChoiceTile key={c} selected={c === color} label={c} onClick={() => take(c)}>
673
+ <span className={`size-7 rounded-full ${swatchEdge}`} style={{ background: c }} />
674
+ </ChoiceTile>
675
+ ))}
676
+ </div>
677
+ </div>
678
+ )}
679
+ </SheetBody>
680
+ <SheetActions
681
+ onCancel={onClose}
682
+ onDone={() => {
683
+ if (color !== value) onChange(color);
684
+ onClose();
685
+ }}
686
+ done={s.use}
687
+ start={
688
+ onLock && (
689
+ <Button
690
+ kind="outlined"
691
+ onClick={() => {
692
+ onLock(color);
693
+ onClose();
694
+ }}
695
+ >
696
+ {s.useAndLock}
697
+ </Button>
698
+ )
699
+ }
700
+ />
701
+ </>
702
+ );
703
+ }
704
+
705
+ /** A colour (#rrggbb): a round swatch that opens the colour sheet (ColorSheet), with the colours to pick
706
+ * from in it (`swatches`), and the colour as text beside it, to type or paste. */
707
+ export function ColorField({
708
+ id,
709
+ value,
710
+ label,
711
+ swatches,
712
+ onChange,
713
+ }: {
714
+ id: string;
715
+ value: string;
716
+ /** The colour's name: the sheet's title, and the swatch's label for screen readers. */
717
+ label: string;
718
+ /** Colours to pick from in the sheet (the site's other colours). */
719
+ swatches?: string[];
720
+ onChange: (value: string) => void;
721
+ }) {
722
+ const dialog = useRef<HTMLDialogElement>(null);
723
+ // Counts the times the sheet is opened, so it starts again each time.
724
+ const [opened, setOpened] = useState(0);
725
+ const close = () => dialog.current?.close();
726
+ return (
727
+ <div className="flex items-start gap-3">
728
+ <button
729
+ type="button"
730
+ aria-haspopup="dialog"
731
+ aria-label={t.common.pickColor(label)}
732
+ title={t.common.pickColor(label)}
733
+ className={`size-11 shrink-0 rounded-full ${swatchEdge} transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none`}
734
+ style={{ background: value }}
735
+ onClick={() => {
736
+ setOpened((n) => n + 1);
737
+ dialog.current?.showModal();
738
+ // The sheet has the focus, not its first button (CLAUDE.md).
739
+ dialog.current?.focus();
740
+ }}
741
+ />
742
+ <ColorText id={id} value={value} onChange={onChange} />
743
+ <BottomSheet
744
+ ref={dialog}
745
+ tabIndex={-1}
746
+ aria-label={label}
747
+ onClick={(e) => e.target === dialog.current && close()}
748
+ className="focus:outline-none"
749
+ >
750
+ {opened > 0 && (
751
+ <ColorSheet key={opened} id={id} title={label} value={value} swatches={swatches} onChange={onChange} onClose={close} />
752
+ )}
753
+ </BottomSheet>
754
+ </div>
755
+ );
756
+ }
757
+
758
+ /** A field's name above it, and what to write under the name (muted). Without `htmlFor` (a list, a
759
+ * name over more than one field) the name is a text, not a label. */
760
+ export function Label({ htmlFor, children, hint }: { htmlFor?: string; children: ReactNode; hint?: ReactNode }) {
761
+ return (
762
+ <>
763
+ {htmlFor ? (
764
+ <label htmlFor={htmlFor} className="mb-1 block font-medium">
765
+ {children}
766
+ </label>
767
+ ) : (
768
+ <p className="mb-1 font-medium">{children}</p>
769
+ )}
770
+ {hint && <span className="mb-2 block text-sm text-muted">{hint}</span>}
771
+ </>
772
+ );
773
+ }
774
+
775
+ /** Small muted text under a field or a part: what it does, what happens. */
776
+ export function SupportingText({ id, children, className = '' }: { id?: string; children: ReactNode; className?: string }) {
777
+ return (
778
+ <span id={id} className={`block text-sm text-muted ${className}`}>
779
+ {children}
780
+ </span>
781
+ );
782
+ }
783
+
784
+ /** A message at the top of a screen about what it shows (an earlier version of a page): the theme's filled card with a
785
+ * title, a text, and what can be done about it (a small button). */
786
+ export function Notice({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
787
+ return (
788
+ <SiteCard kind="filled" role="status" className="space-y-2">
789
+ <p className="font-medium text-accent">{title}</p>
790
+ <p className="text-sm">{children}</p>
791
+ {action}
792
+ </SiteCard>
793
+ );
794
+ }
795
+
796
+ /** The search over a list: the theme's search bar, searching in place (SearchBar in its Search.tsx). */
797
+ export function SearchBar({
798
+ value,
799
+ onChange,
800
+ label,
801
+ className = '',
802
+ inputRef,
803
+ }: {
804
+ value: string;
805
+ onChange: (value: string) => void;
806
+ /** What is searched, for screen readers ("Søg i billederne"). */
807
+ label: string;
808
+ className?: string;
809
+ inputRef?: Ref<HTMLInputElement>;
810
+ }) {
811
+ return (
812
+ <SiteSearchBar
813
+ label={label}
814
+ placeholder={t.common.search}
815
+ className={`min-w-0 ${className}`}
816
+ ref={inputRef}
817
+ value={value}
818
+ onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
819
+ />
820
+ );
821
+ }
822
+
823
+ /** The search over a list with the icon buttons beside it, always in this order: `sort` (the arrows), `filter` (the
824
+ * funnel), `date` (the calendar), `seo` (Google) and `view` (the menu or the list), each only when given. Every search
825
+ * with buttons beside it is this, so the buttons stand in the same order on every screen; `className` places the row. */
826
+ export function SearchRow({
827
+ sort,
828
+ filter,
829
+ date,
830
+ seo,
831
+ view,
832
+ className = '',
833
+ ...search
834
+ }: Omit<Parameters<typeof SearchBar>[0], 'className'> & {
835
+ sort?: ReactNode;
836
+ filter?: ReactNode;
837
+ date?: ReactNode;
838
+ seo?: ReactNode;
839
+ view?: ReactNode;
840
+ className?: string;
841
+ }) {
842
+ return (
843
+ <div className={`flex gap-2 ${className}`}>
844
+ <SearchBar {...search} className="flex-1" />
845
+ {[sort, filter, date, seo, view].map(
846
+ (tool, i) =>
847
+ tool && (
848
+ <span key={i} className="shrink-0">
849
+ {tool}
850
+ </span>
851
+ ),
852
+ )}
853
+ </div>
854
+ );
855
+ }
856
+
857
+ /** The theme's slider, with its name over it (the zoom when cropping a picture): a value between `min` and `max`. */
858
+ export function Slider(props: {
859
+ label: string;
860
+ min: number;
861
+ max: number;
862
+ step?: number;
863
+ value: number;
864
+ disabled?: boolean;
865
+ className?: string;
866
+ onChange: (value: number) => void;
867
+ }) {
868
+ return <SiteSlider {...props} />;
869
+ }
870
+
871
+ // ---------------------------------------------------------------------------------------------------
872
+ // The fields of a form
873
+
874
+ // Every type of field in a form, a setting or a sheet with fields (fields.tsx) is drawn of these, so
875
+ // they look the same everywhere.
876
+
877
+ /** A field in a form: its name (Label) and what to write under it, the field (TextField, TextArea,
878
+ * Select …), what is wrong with what was typed, and with `count` how many characters it has of the most
879
+ * it should have (in the accent colour when there are more). */
880
+ export function FormField({
881
+ label,
882
+ htmlFor,
883
+ hint,
884
+ problem,
885
+ count,
886
+ children,
887
+ }: {
888
+ label: string;
889
+ htmlFor?: string;
890
+ hint?: ReactNode;
891
+ /** What is wrong, under the field; `id` is the field's aria-describedby. */
892
+ problem?: { id: string; text: string };
893
+ count?: { length: number; max: number };
894
+ children: ReactNode;
895
+ }) {
896
+ return (
897
+ <div>
898
+ <Label htmlFor={htmlFor} hint={hint}>
899
+ {label}
900
+ </Label>
901
+ {children}
902
+ {problem && (
903
+ <p id={problem.id} className="mt-1 text-sm text-accent">
904
+ {problem.text}.
905
+ </p>
906
+ )}
907
+ {count && (
908
+ <p className={`mt-1 text-right text-xs ${count.length > count.max ? 'text-accent' : 'text-muted'}`}>
909
+ {count.length} / {count.max}
910
+ </p>
911
+ )}
912
+ </div>
913
+ );
914
+ }
915
+
916
+ /** Yes or no with a tick, its name after it and what it does under the name. Something that is turned
917
+ * on and off (Vis/Skjul) is a SwitchRow instead. */
918
+ export function Checkbox({
919
+ label,
920
+ hint,
921
+ checked,
922
+ onChange,
923
+ }: {
924
+ label: string;
925
+ hint?: ReactNode;
926
+ checked: boolean;
927
+ onChange: (checked: boolean) => void;
928
+ }) {
929
+ return (
930
+ <SiteCheckbox
931
+ label={label}
932
+ supporting={hint}
933
+ checked={checked}
934
+ onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)}
935
+ />
936
+ );
937
+ }
938
+
939
+ /** Fields that belong together, on the theme's outlined card with their name over them (a group of fields). */
940
+ export function FieldGroup({ label, hint, children }: { label: string; hint?: ReactNode; children: ReactNode }) {
941
+ const id = useId();
942
+ return (
943
+ <div role="group" aria-labelledby={id} className={`${siteCardClass('outlined')} space-y-5`}>
944
+ <h3 id={id} className="font-semibold">
945
+ {label}
946
+ </h3>
947
+ {hint && <SupportingText>{hint}</SupportingText>}
948
+ {children}
949
+ </div>
950
+ );
951
+ }
952
+
953
+ /** What a field holds, shown but not changed here: with its icon (the kind of an entry, chosen when it
954
+ * was made), or `muted` (what is made elsewhere, as a page's address from its title). */
955
+ export function ShownValue({ id, icon, muted, children }: { id?: string; icon?: Icon; muted?: boolean; children: ReactNode }) {
956
+ return (
957
+ <p id={id} className={`flex items-center gap-3 ${muted ? 'text-muted' : ''}`}>
958
+ {icon && <LeadingIcon icon={icon} />}
959
+ <span className="min-w-0 flex-1">{children}</span>
960
+ </p>
961
+ );
962
+ }
963
+
964
+ /** One of many icons: the chosen one with its name, and a button that unfolds them all in a grid to tap
965
+ * (ChoiceTile, the chosen one marked); a tap picks one and folds them again. The choice with the value
966
+ * '' is no icon (a cross). `icon` draws an icon by its value. */
967
+ export function IconPicker({
968
+ id,
969
+ label,
970
+ value,
971
+ options,
972
+ icon,
973
+ onChange,
974
+ }: {
975
+ id?: string;
976
+ label: string;
977
+ value: string;
978
+ options: { value: string; label: string }[];
979
+ icon: (value: string, size: number) => ReactElement;
980
+ onChange: (value: string) => void;
981
+ }) {
982
+ const [open, setOpen] = useState(false);
983
+ const own = useId();
984
+ const grid = `${id ?? own}-icons`;
985
+ const chosen = options.find((o) => o.value === value);
986
+ return (
987
+ <div id={id}>
988
+ <div className="flex items-center gap-3">
989
+ {value && <LeadingIcon>{icon(value, 24)}</LeadingIcon>}
990
+ <span className="min-w-0 flex-1 truncate">{chosen ? chosen.label : t.forms.noIconYet}</span>
991
+ <Button kind="outlined" size="small" aria-expanded={open} aria-controls={grid} onClick={() => setOpen(!open)}>
992
+ {open ? t.forms.hideIcons : value ? t.forms.changeIcon : t.forms.pickIcon}
993
+ </Button>
994
+ </div>
995
+ {open && (
996
+ <div
997
+ id={grid}
998
+ role="group"
999
+ aria-label={label}
1000
+ className="editor-reveal mt-3 grid grid-cols-[repeat(auto-fill,minmax(3rem,1fr))] gap-2"
1001
+ >
1002
+ {options.map((o) => (
1003
+ <ChoiceTile
1004
+ key={o.value || 'none'}
1005
+ selected={o.value === value}
1006
+ label={o.label}
1007
+ onClick={() => {
1008
+ onChange(o.value);
1009
+ setOpen(false);
1010
+ }}
1011
+ >
1012
+ {o.value ? icon(o.value, 24) : <X size={20} className="text-muted" />}
1013
+ </ChoiceTile>
1014
+ ))}
1015
+ </div>
1016
+ )}
1017
+ </div>
1018
+ );
1019
+ }
1020
+
1021
+ /** A picture: a small one of it (`src`), the button that picks one, from Filer or a new one, or another,
1022
+ * and with `onRemove` (when it may be left out) the button that takes it away. */
1023
+ export function ImagePick({ id, src, onPick, onRemove }: { id?: string; src?: string; onPick: () => void; onRemove?: () => void }) {
1024
+ return (
1025
+ <div className="flex items-center gap-4">
1026
+ {src && <img src={src} alt="" className="size-20 shrink-0 rounded-(--block-radius) object-cover" />}
1027
+ <Button id={id} kind="outlined" size="small" onClick={onPick}>
1028
+ {src ? t.forms.changeImage : t.forms.pickImage}
1029
+ </Button>
1030
+ {src && onRemove && (
1031
+ <Button kind="text" size="small" onClick={onRemove}>
1032
+ {t.common.remove}
1033
+ </Button>
1034
+ )}
1035
+ </div>
1036
+ );
1037
+ }
1038
+
1039
+ /** A list of items (the theme's segmented list, listClass), each a FoldItem that folds out to its fields
1040
+ * (the questions of a block). */
1041
+ export function FoldList({ ref, children }: { ref?: Ref<HTMLUListElement>; children: ReactNode }) {
1042
+ return (
1043
+ <ul ref={ref} className={listClass}>
1044
+ {children}
1045
+ </ul>
1046
+ );
1047
+ }
1048
+
1049
+ /** An item of a FoldList: its title, which folds it out and in, the buttons at its right (`tools`: the
1050
+ * arrows, Fjern), and its fields while it is open. */
1051
+ export function FoldItem({
1052
+ title,
1053
+ open,
1054
+ onToggle,
1055
+ tools,
1056
+ children,
1057
+ }: {
1058
+ title: string;
1059
+ open: boolean;
1060
+ onToggle: () => void;
1061
+ tools?: ReactNode;
1062
+ children: ReactNode;
1063
+ }) {
1064
+ return (
1065
+ <ListItem
1066
+ onClick={onToggle}
1067
+ aria-expanded={open}
1068
+ leading={<CaretRight size={14} className={`shrink-0 text-muted transition-transform ${open ? 'rotate-90' : ''}`} />}
1069
+ headline={title}
1070
+ truncate
1071
+ actions={tools && <span className="flex shrink-0 pr-1.5">{tools}</span>}
1072
+ >
1073
+ {open && <div className="editor-reveal space-y-4 px-4 pt-1 pb-4">{children}</div>}
1074
+ </ListItem>
1075
+ );
1076
+ }
1077
+
1078
+ /** The statuses of a page or post (`status`, packages/editor/lib/status.mjs), in the order of the choices over a list. */
1079
+ export type PageState = 'published' | 'draft' | 'unlisted' | 'scheduled';
1080
+ const PAGE_STATES: PageState[] = ['published', 'draft', 'unlisted', 'scheduled'];
1081
+
1082
+ /** A choice in a sheet that can be sorted and filtered (usePickTools): a page or post may have its status, whether the
1083
+ * menu links to it and its date. */
1084
+ export type Pickable = { value: string; label: string; state?: PageState; inMenu?: boolean; date?: string };
1085
+
1086
+ type PickSort = 'nyeste' | 'aeldste' | 'az' | 'za';
1087
+ type InMenu = '' | 'in' | 'out';
1088
+
1089
+ /** The order and the filters of a sheet of choices (PagePicker, MultiChoice), as over the lists: the arrows with A–Å and
1090
+ * Å–A (and newest and oldest first, when the choices have a date: the posts first newest), kept in this browser by
1091
+ * `keep` (the list they come from); and the funnel with their status and whether the menu links to them, each only when
1092
+ * the choices differ in it, a filter picked as a chip that removes it. `sort` and `filter` go beside the search (SearchRow), `chips` under it,
1093
+ * and `arrange` orders and filters choices. */
1094
+ export function usePickTools(all: Pickable[], keep: string) {
1095
+ const dated = all.some((o) => o.date);
1096
+ const sorts: PickSort[] = dated ? ['nyeste', 'aeldste', 'az', 'za'] : ['az', 'za'];
1097
+ const sortKey = `editor-sort-valg-${keep}`;
1098
+ const [savedSort, setSort] = useState<PickSort | null>(() => {
1099
+ try {
1100
+ return localStorage.getItem(sortKey) as PickSort | null;
1101
+ } catch {
1102
+ return null;
1103
+ }
1104
+ });
1105
+ const sort = savedSort && sorts.includes(savedSort) ? savedSort : sorts[0];
1106
+ const [picked, setState] = useKept<PageState | ''>(`valg-${keep}-status`, '');
1107
+ const [inMenu, setInMenu] = useKept<InMenu>(`valg-${keep}-menu`, '');
1108
+ // A filter only when the choices differ in it; the one picked is forgotten when it is no longer there to pick.
1109
+ const states = PAGE_STATES.filter((s) => all.some((o) => (o.state ?? 'published') === s));
1110
+ const state = states.length > 1 && states.includes(picked as PageState) ? picked : '';
1111
+ const menus = all.some((o) => o.inMenu) && all.some((o) => o.inMenu === false);
1112
+ const menu = menus ? inMenu : '';
1113
+ const filters = [
1114
+ states.length > 1 && {
1115
+ title: t.library.statusFilter,
1116
+ value: state,
1117
+ options: [{ value: '', label: t.library.statusShown.all }, ...states.map((s) => ({ value: s, label: t.library.statusShown[s] }))],
1118
+ onChange: (v: string) => setState(v as PageState | ''),
1119
+ },
1120
+ menus && {
1121
+ title: t.forms.menuFilter,
1122
+ value: menu,
1123
+ options: (['', 'in', 'out'] as const).map((m) => ({ value: m, label: t.forms.menuShown[m || 'all'] })),
1124
+ onChange: (v: string) => setInMenu(v as InMenu),
1125
+ },
1126
+ ].filter((f) => !!f);
1127
+ const chosen = [
1128
+ state && { key: 'state', label: t.library.statusShown[state], clear: () => setState('') },
1129
+ menu && { key: 'menu', label: t.forms.menuShown[menu], clear: () => setInMenu('') },
1130
+ ].filter((c) => !!c);
1131
+
1132
+ function pickSort(next: PickSort) {
1133
+ setSort(next);
1134
+ try {
1135
+ localStorage.setItem(sortKey, next);
1136
+ } catch {}
1137
+ }
1138
+
1139
+ const byDate = (a: Pickable, b: Pickable) => (b.date ?? '').localeCompare(a.date ?? '');
1140
+ const compare: Record<PickSort, (a: Pickable, b: Pickable) => number> = {
1141
+ nyeste: (a, b) => byDate(a, b) || byText(a.label, b.label),
1142
+ aeldste: (a, b) => byDate(b, a) || byText(a.label, b.label),
1143
+ az: (a, b) => byText(a.label, b.label),
1144
+ za: (a, b) => byText(b.label, a.label),
1145
+ };
1146
+
1147
+ return {
1148
+ /** Whether a filter is picked. */
1149
+ filtered: chosen.length > 0,
1150
+ sort: (
1151
+ <Choice
1152
+ title={t.library.sort}
1153
+ icon={ArrowsDownUp}
1154
+ value={sort}
1155
+ options={sorts.map((s) => ({ value: s, label: t.library.sorts[s] }))}
1156
+ onChange={pickSort}
1157
+ />
1158
+ ),
1159
+ filter: filters.length > 0 && <Filters title={t.library.filter} selected={chosen.length > 0} groups={filters} />,
1160
+ chips: chosen.length > 0 && (
1161
+ <div className="mx-5 mb-2 flex shrink-0 flex-wrap gap-2">
1162
+ {chosen.map((c) => (
1163
+ <InputChip key={c.key} removeLabel={t.library.removeFilter(c.label)} onRemove={c.clear}>
1164
+ {c.label}
1165
+ </InputChip>
1166
+ ))}
1167
+ </div>
1168
+ ),
1169
+ arrange: <T extends Pickable>(options: T[]) =>
1170
+ options
1171
+ .filter((o) => (!state || (o.state ?? 'published') === state) && (!menu || !!o.inMenu === (menu === 'in')))
1172
+ .sort(compare[sort]),
1173
+ };
1174
+ }
1175
+
1176
+ /** Several picked from a list (a post's tags): the picked ones as chips, a tap on one takes it away, and
1177
+ * a last button (`add`, "Tilføj tag") that opens a sheet with all the choices, where a tap picks one or
1178
+ * takes it away again; the sheet stays open, so more can be picked. With many choices a search over
1179
+ * them, their order and filters (usePickTools); `max`: at most so many; `footer`: at the bottom of the sheet (SheetLink), given what closes it.
1180
+ * A picked value that is not among the choices is still shown, so it is not lost. */
1181
+ export function MultiChoice({
1182
+ label,
1183
+ options,
1184
+ value,
1185
+ add,
1186
+ max,
1187
+ keep,
1188
+ footer,
1189
+ onChange,
1190
+ }: {
1191
+ label: string;
1192
+ options: Pickable[];
1193
+ value: string[];
1194
+ add?: string;
1195
+ max?: number;
1196
+ /** Where the choices come from (a relation: "pages"), for the order kept in this browser (usePickTools). */
1197
+ keep?: string;
1198
+ footer?: (close: () => void) => ReactNode;
1199
+ onChange: (value: string[]) => void;
1200
+ }) {
1201
+ const dialog = useRef<HTMLDialogElement>(null);
1202
+ const [query, setQuery] = useState('');
1203
+ const many = options.length > 12;
1204
+ const tools = usePickTools(many ? options : [], keep ?? label);
1205
+ const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? t.forms.missing(v);
1206
+ const full = max != null && value.length >= max;
1207
+ const toggle = (v: string) => onChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
1208
+ const found = options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()));
1209
+ const shown = many ? tools.arrange(found) : found;
1210
+ return (
1211
+ <>
1212
+ <div className="flex flex-wrap gap-2">
1213
+ {value.map((v) => (
1214
+ <InputChip key={v} removeLabel={t.forms.removeOne(labelOf(v))} onRemove={() => toggle(v)}>
1215
+ {labelOf(v)}
1216
+ </InputChip>
1217
+ ))}
1218
+ {!full && (
1219
+ <Button
1220
+ kind="outlined"
1221
+ size="small"
1222
+ icon={Plus}
1223
+ aria-haspopup="dialog"
1224
+ onClick={() => {
1225
+ setQuery('');
1226
+ dialog.current?.showModal();
1227
+ }}
1228
+ >
1229
+ {add ?? t.forms.add}
1230
+ </Button>
1231
+ )}
1232
+ </div>
1233
+ <BottomSheet ref={dialog} aria-label={label} onClick={(e) => e.target === dialog.current && dialog.current?.close()}>
1234
+ <SheetTitle title={label} onClose={() => dialog.current?.close()} />
1235
+ {/* With many choices: the search, the order and the filters (usePickTools). */}
1236
+ {many && (
1237
+ <>
1238
+ <SearchRow
1239
+ value={query}
1240
+ onChange={setQuery}
1241
+ label={t.common.searchIn(label)}
1242
+ sort={tools.sort}
1243
+ filter={tools.filter}
1244
+ className="mx-5 mb-2 shrink-0"
1245
+ />
1246
+ {tools.chips}
1247
+ </>
1248
+ )}
1249
+ <SheetList>
1250
+ {shown.map((o) => {
1251
+ const picked = value.includes(o.value);
1252
+ return (
1253
+ <li key={o.value}>
1254
+ <Option selected={picked} disabled={!picked && full} onClick={() => toggle(o.value)}>
1255
+ {o.label}
1256
+ </Option>
1257
+ </li>
1258
+ );
1259
+ })}
1260
+ {!shown.length && <EmptyNote item>{t.forms.noneFound}</EmptyNote>}
1261
+ </SheetList>
1262
+ {footer?.(() => dialog.current?.close())}
1263
+ </BottomSheet>
1264
+ </>
1265
+ );
1266
+ }
1267
+
1268
+ /** A link to another screen, with an arrow after it (under a field's name: where it is edited). */
1269
+ export function ArrowLink({
1270
+ href,
1271
+ onClick,
1272
+ className = 'text-accent',
1273
+ children,
1274
+ }: {
1275
+ href: string;
1276
+ onClick?: (e: MouseEvent<HTMLAnchorElement>) => void;
1277
+ className?: string;
1278
+ children: ReactNode;
1279
+ }) {
1280
+ return (
1281
+ <a href={href} className={`inline-flex items-center gap-1 font-medium hover:underline ${className}`} onClick={onClick}>
1282
+ {children}
1283
+ <ArrowRight size={14} />
1284
+ </a>
1285
+ );
1286
+ }
1287
+
1288
+ // ---------------------------------------------------------------------------------------------------
1289
+ // Switches
1290
+
1291
+ /** The theme's switch (Material 3's) on its own, at the end of a row that names it (a block's row on its app's page,
1292
+ * AppList.tsx): its name (`label`) only for screen readers, what it is (`texts`: Til, Fra) before it. */
1293
+ export function Switch({
1294
+ selected,
1295
+ label,
1296
+ texts,
1297
+ disabled,
1298
+ onChange,
1299
+ }: {
1300
+ selected: boolean;
1301
+ label: string;
1302
+ texts: [string, string];
1303
+ /** It cannot be changed now (a block in use under "Blokindstillinger"). */
1304
+ disabled?: boolean;
1305
+ onChange: (selected: boolean) => void;
1306
+ }) {
1307
+ return (
1308
+ <SiteSwitch
1309
+ label={label}
1310
+ hideLabel
1311
+ checked={selected}
1312
+ states={texts}
1313
+ disabled={disabled}
1314
+ onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)}
1315
+ />
1316
+ );
1317
+ }
1318
+
1319
+ /** The theme's switch with its name before it, on one line: the only way the editor shows one, with what it does under
1320
+ * the name (`hint`) and what it is (`texts`: Til, Fra) before the switch. Never in a frame or a card: no border,
1321
+ * background or ring around the name and the switch (CLAUDE.md). The only switch without it: at the end of a block's
1322
+ * row on its app's page (Switch, AppList.tsx). */
1323
+ export function SwitchRow({ label, hint, selected, texts, disabled, onChange }: Parameters<typeof Switch>[0] & { hint?: ReactNode }) {
1324
+ return (
1325
+ <SiteSwitch
1326
+ label={label}
1327
+ supporting={hint}
1328
+ checked={selected}
1329
+ states={texts}
1330
+ disabled={disabled}
1331
+ onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)}
1332
+ />
1333
+ );
1334
+ }
1335
+
1336
+ // ---------------------------------------------------------------------------------------------------
1337
+ // Chips
1338
+
1339
+ /** The theme's input chip: what is chosen (a tag, a filter), removed with its cross. */
1340
+ export function InputChip({ children, onRemove, removeLabel }: { children: ReactNode; onRemove: () => void; removeLabel: string }) {
1341
+ return <SiteChip kind="input" label={children} removeLabel={removeLabel} onRemove={onRemove} />;
1342
+ }
1343
+
1344
+ /** Words typed as chips (the keywords of a page): a word becomes a chip with Enter, a comma or when the field is left,
1345
+ * and the chip's cross removes it. The word being typed is in `onChange` already, last, so it is saved too. With `one`,
1346
+ * only one word, and the field is gone while it has it (its chip is removed to type another). */
1347
+ export function ChipField({
1348
+ id,
1349
+ value,
1350
+ one,
1351
+ onChange,
1352
+ }: {
1353
+ id?: string;
1354
+ value: string[];
1355
+ one?: boolean;
1356
+ onChange: (value: string[]) => void;
1357
+ }) {
1358
+ const input = useRef<HTMLInputElement>(null);
1359
+ const [draft, setDraft] = useState('');
1360
+ // The word being typed is the last of `value` while it is there; changed from outside (a version restored), it is not.
1361
+ const typing = draft.trim() !== '' && value.at(-1) === draft.trim();
1362
+ const chips = typing ? value.slice(0, -1) : value;
1363
+ const known = (word: string, among: string[]) => among.some((w) => w.toLowerCase() === word.toLowerCase());
1364
+ // Typed text: the words before a comma become chips, what is after it is still being typed.
1365
+ const type = (typed: string) => {
1366
+ const parts = typed.split(',');
1367
+ const rest = parts.pop()!;
1368
+ const words = [...chips];
1369
+ for (const w of parts.map((p) => p.trim()).filter(Boolean)) if (!known(w, words) && !(one && words.length)) words.push(w);
1370
+ const last = rest.trim() && !(one && words.length) ? [rest.trim()] : [];
1371
+ setDraft(last.length ? rest : '');
1372
+ onChange([...words, ...last]);
1373
+ };
1374
+ // Enter, or the field is left: the word typed becomes a chip, unless it is one already.
1375
+ const done = () => {
1376
+ setDraft('');
1377
+ if (typing && known(draft.trim(), chips)) onChange(chips);
1378
+ };
1379
+ const remove = (word: string) => {
1380
+ onChange([...chips.filter((w) => w !== word), ...(typing ? [draft.trim()] : [])]);
1381
+ if (one) requestAnimationFrame(() => input.current?.focus());
1382
+ };
1383
+ const field = !(one && chips.length) && (
1384
+ <TextField
1385
+ ref={input}
1386
+ id={id}
1387
+ value={typing ? draft : ''}
1388
+ enterKeyHint="done"
1389
+ onChange={(e) => type(e.target.value)}
1390
+ onKeyDown={(e) => {
1391
+ if (e.key === 'Enter') {
1392
+ e.preventDefault();
1393
+ done();
1394
+ }
1395
+ }}
1396
+ onBlur={done}
1397
+ />
1398
+ );
1399
+ return (
1400
+ <div className="space-y-2">
1401
+ {chips.length > 0 && (
1402
+ <div className="flex flex-wrap gap-2">
1403
+ {chips.map((w) => (
1404
+ <InputChip key={w} removeLabel={t.forms.removeOne(w)} onRemove={() => remove(w)}>
1405
+ {w}
1406
+ </InputChip>
1407
+ ))}
1408
+ </div>
1409
+ )}
1410
+ {field}
1411
+ </div>
1412
+ );
1413
+ }
1414
+
1415
+ /** The theme's chip set: chips side by side in a group with its name (`label`), on as many lines as they need, or with
1416
+ * `scroll` on one line that scrolls sideways (the harmonies under "Farver" › "Generator"). */
1417
+ export function ChipSet({ label, scroll = false, children }: { label: string; scroll?: boolean; children: ReactNode }) {
1418
+ return (
1419
+ <SiteChipSet layout={scroll ? 'scroll' : 'wrap'} label={label}>
1420
+ {children}
1421
+ </SiteChipSet>
1422
+ );
1423
+ }
1424
+
1425
+ /** The theme's filter chip: one of a few choices side by side (a picture's format), the chosen one marked. Many choices
1426
+ * go in a sheet instead (Choice). */
1427
+ export function FilterChip({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: ReactNode }) {
1428
+ return <SiteChip kind="filter" label={children} selected={selected} onClick={onClick} />;
1429
+ }
1430
+
1431
+ /** One of many icons to tap, in a grid (an icon field): the theme's outlined, square icon button as wide as its column,
1432
+ * the chosen one on (a toggle). `label` names it for screen readers and as its tooltip. */
1433
+ export function ChoiceTile({
1434
+ selected,
1435
+ label,
1436
+ onClick,
1437
+ children,
1438
+ }: {
1439
+ selected: boolean;
1440
+ label: string;
1441
+ onClick: () => void;
1442
+ children: ReactElement<{ size?: number; className?: string }>;
1443
+ }) {
1444
+ return (
1445
+ <SiteIconButton
1446
+ icon={children}
1447
+ label={label}
1448
+ title={label}
1449
+ kind="outlined"
1450
+ shape="square"
1451
+ selected={selected}
1452
+ className="aspect-square h-auto w-full"
1453
+ onClick={onClick}
1454
+ />
1455
+ );
1456
+ }
1457
+
1458
+ /** The theme's segmented button: two or three choices of a setting side by side (Konto › Udseende), each with its name
1459
+ * and an icon, the chosen one marked. */
1460
+ export function SegmentedButton<T extends string>({
1461
+ label,
1462
+ value,
1463
+ options,
1464
+ disabled,
1465
+ onChange,
1466
+ }: {
1467
+ /** What is chosen, for screen readers. */
1468
+ label: string;
1469
+ value: T;
1470
+ options: { value: T; label: string; icon?: Icon }[];
1471
+ /** It cannot be changed now (the place of R2 caching's bucket while it is on): grey, and it stays. */
1472
+ disabled?: boolean;
1473
+ onChange: (v: T) => void;
1474
+ }) {
1475
+ return (
1476
+ <SiteSegmentedButton
1477
+ label={label}
1478
+ segments={options.map(({ value: v, label: name, icon: Glyph }) => ({
1479
+ label: name,
1480
+ icon: Glyph && <Glyph />,
1481
+ value: v,
1482
+ selected: v === value,
1483
+ disabled,
1484
+ onClick: () => onChange(v),
1485
+ }))}
1486
+ />
1487
+ );
1488
+ }
1489
+
1490
+ // ---------------------------------------------------------------------------------------------------
1491
+ // Rows
1492
+
1493
+ /** The classes of the rows under a row in a list (a submenu): the theme's list, indented along a line, the text a little
1494
+ * lighter. */
1495
+ export const subListClass = `${siteListClass} ml-5 border-l border-line`;
1496
+
1497
+ /** What a row looks like besides the theme's list item: `accent`, a row that does something (Log ud), in the accent
1498
+ * colour; `delete`, the row that deletes what a screen shows, alone in a Group at the bottom, in the middle in the accent
1499
+ * colour; `muted`, grey (what cannot be opened: a page that is gone); `sub`, a row under a row (a submenu), its text a
1500
+ * little lighter. */
1501
+ export type RowTone = 'accent' | 'delete' | 'muted' | 'sub';
1502
+ const toneClass: Record<RowTone, string> = {
1503
+ accent: 'text-accent',
1504
+ delete: 'justify-center text-center font-medium text-accent',
1505
+ muted: 'text-muted',
1506
+ sub: 'text-ink/80',
1507
+ };
1508
+
1509
+ /** A row in a list (Group, listClass): the theme's list item (Material 3's ListItem), with the editor's icons. A link
1510
+ * (`href`), a button (`onClick`) or only shown; its icon before it (`icon`, a Phosphor icon or one drawn; `iconSelected`:
1511
+ * in the selected colour, the open one) or anything there (`leading`: LeadingImage, LeadingToken), its name
1512
+ * (`headline`) and what is under it (`supporting`; `truncate`: each on one line, cut off), how many at the right
1513
+ * (`count`), the arrow of a row that opens something (`arrow`, or another icon: what a tap does) or anything there
1514
+ * (`trailing`), the buttons beside it (`actions`: the arrows, Fjern, a switch) and what is in its <li> after it
1515
+ * (`children`: a sheet it opens, a text it folds out); `tone`: RowTone. The rest (aria-…, ref, style, onClick on a
1516
+ * link) on the row. */
1517
+ export function ListItem({
1518
+ icon: shownIcon,
1519
+ iconSelected: shownSelected,
1520
+ arrow,
1521
+ count,
1522
+ tone,
1523
+ className,
1524
+ ...item
1525
+ }: {
1526
+ icon?: Icon | ReactElement<{ size?: number; className?: string }>;
1527
+ iconSelected?: boolean;
1528
+ leading?: ReactNode;
1529
+ headline: ReactNode;
1530
+ supporting?: ReactNode;
1531
+ truncate?: boolean;
1532
+ count?: ReactNode;
1533
+ arrow?: boolean | Icon;
1534
+ trailing?: ReactNode;
1535
+ actions?: ReactNode;
1536
+ children?: ReactNode;
1537
+ href?: string;
1538
+ onClick?: () => void;
1539
+ disabled?: boolean;
1540
+ selected?: boolean;
1541
+ tone?: RowTone;
1542
+ className?: string;
1543
+ } & Record<string, unknown>) {
1544
+ let icon = shownIcon;
1545
+ let iconSelected = shownSelected;
1546
+ const Arrow = arrow === true ? CaretRight : arrow || null;
1547
+ // A LeadingIcon made beforehand (an app's icon in a list) is the item's leading icon.
1548
+ const made =
1549
+ isValidElement(item.leading) && item.leading.type === LeadingIcon
1550
+ ? (item.leading.props as { icon?: Icon; selected?: boolean; children?: ReactNode })
1551
+ : null;
1552
+ if (made) {
1553
+ icon = made.icon ?? ((made.children || <span className="block size-6" />) as ReactElement<{ size?: number }>);
1554
+ iconSelected = made.selected;
1555
+ item.leading = undefined;
1556
+ }
1557
+ const Lead = icon && !isValidElement(icon) ? (icon as Icon) : null;
1558
+ // Nothing (false, '', null) is no part: `count={n > 0 && n}`.
1559
+ const some = (v: ReactNode) => (v === false || v === '' || v == null ? undefined : v);
1560
+ return (
1561
+ <SiteListItem
1562
+ {...item}
1563
+ leading={some(item.leading)}
1564
+ supporting={some(item.supporting)}
1565
+ trailing={some(item.trailing)}
1566
+ actions={some(item.actions)}
1567
+ leadingIcon={Lead ? <Lead /> : (icon as ReactElement | undefined)}
1568
+ leadingIconSelected={iconSelected}
1569
+ trailingSupporting={some(count)}
1570
+ trailingIcon={Arrow ? <Arrow /> : undefined}
1571
+ className={[tone && toneClass[tone], className].filter(Boolean).join(' ') || undefined}
1572
+ />
1573
+ );
1574
+ }
1575
+
1576
+ /** At the right of a row: the theme's text button with how many rows are under it and the arrow that shows or hides
1577
+ * them (the pages under a page). `label` names it for screen readers (with the row's name), `title` is its tooltip. */
1578
+ export function UnfoldButton({
1579
+ open,
1580
+ count,
1581
+ label,
1582
+ title,
1583
+ className = '',
1584
+ onClick,
1585
+ }: {
1586
+ open: boolean;
1587
+ count: number;
1588
+ label: string;
1589
+ title: string;
1590
+ className?: string;
1591
+ onClick: () => void;
1592
+ }) {
1593
+ return (
1594
+ <SiteButton
1595
+ kind="text"
1596
+ size="small"
1597
+ trailingIcon={open ? <CaretUp /> : <CaretDown />}
1598
+ aria-expanded={open}
1599
+ aria-label={label}
1600
+ title={title}
1601
+ className={`shrink-0 ${className}`}
1602
+ onClick={onClick}
1603
+ >
1604
+ {count}
1605
+ </SiteButton>
1606
+ );
1607
+ }
1608
+
1609
+ /** The classes of a choice in a sheet's list (Option), for one with more in it (an icon, a hint): the theme's menu
1610
+ * item (Material 3's), the chosen one marked by its aria-pressed, aria-selected or aria-checked. */
1611
+ export const optionClass = () => `${siteMenuItemClass} group disabled:opacity-50`;
1612
+
1613
+ /** A choice in a sheet's list (the theme's menu item): the chosen one marked, with a tick. */
1614
+ export function Option({
1615
+ selected = false,
1616
+ disabled,
1617
+ onClick,
1618
+ children,
1619
+ role,
1620
+ }: {
1621
+ selected?: boolean;
1622
+ disabled?: boolean;
1623
+ onClick: () => void;
1624
+ children: ReactNode;
1625
+ role?: string;
1626
+ }) {
1627
+ return (
1628
+ <button
1629
+ type="button"
1630
+ role={role}
1631
+ aria-pressed={role ? undefined : selected}
1632
+ aria-selected={role === 'option' ? selected : undefined}
1633
+ disabled={disabled}
1634
+ className={optionClass()}
1635
+ onClick={onClick}
1636
+ >
1637
+ <span className="min-w-0 flex-1">{children}</span>
1638
+ {selected && <Check size={20} className="shrink-0" />}
1639
+ </button>
1640
+ );
1641
+ }
1642
+
1643
+ // ---------------------------------------------------------------------------------------------------
1644
+ // The menu
1645
+
1646
+ /** A menu under the button that opened it (`anchor`; null while it is closed): the theme's menu with its choices
1647
+ * (MenuItem) over the screen, placed as the theme places one (sitePlaceMenu: under the button, over it when there is no
1648
+ * 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
1650
+ * 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). */
1652
+ export function Menu({
1653
+ anchor,
1654
+ label,
1655
+ onClose,
1656
+ children,
1657
+ }: {
1658
+ anchor: HTMLElement | null;
1659
+ /** What the choices are, for screen readers, and the sheet's title on a phone. */
1660
+ label: string;
1661
+ onClose: () => void;
1662
+ children: ReactNode;
1663
+ }) {
1664
+ const menu = useRef<HTMLUListElement>(null);
1665
+ const sheet = useRef<HTMLDialogElement>(null);
1666
+ const wide = useWide();
1667
+ useEffect(() => {
1668
+ if (wide) return;
1669
+ const el = sheet.current;
1670
+ // No choice has the focus to begin with (the sheet has it, as in ask.tsx), so none looks picked.
1671
+ if (anchor && !el?.open) {
1672
+ el?.showModal();
1673
+ el?.focus();
1674
+ }
1675
+ if (!anchor && el?.open) el.close();
1676
+ }, [anchor, wide]);
1677
+ // Before it is painted, so it is never seen anywhere else than at its button.
1678
+ useLayoutEffect(() => {
1679
+ if (!anchor || !wide) return;
1680
+ const close = (e: Event) => {
1681
+ if (e.type === 'keydown' && (e as globalThis.KeyboardEvent).key !== 'Escape') return;
1682
+ // A tap on the button itself is its own: it closes the menu.
1683
+ if (e.type === 'pointerdown' && (menu.current?.contains(e.target as Node) || anchor.contains(e.target as Node))) return;
1684
+ onClose();
1685
+ };
1686
+ // Where the button is now; closed when it is no longer shown (the row over a selection, gone with the selection).
1687
+ const place = () => {
1688
+ if (!anchor.isConnected || !anchor.getClientRects().length) return onClose();
1689
+ if (menu.current) sitePlaceMenu(menu.current, anchor);
1690
+ };
1691
+ place();
1692
+ for (const type of ['pointerdown', 'keydown']) document.addEventListener(type, close, true);
1693
+ addEventListener('scroll', place, { capture: true, passive: true });
1694
+ addEventListener('resize', place, { passive: true });
1695
+ return () => {
1696
+ for (const type of ['pointerdown', 'keydown']) document.removeEventListener(type, close, true);
1697
+ removeEventListener('scroll', place, { capture: true });
1698
+ removeEventListener('resize', place);
1699
+ };
1700
+ }, [anchor, wide]);
1701
+ if (!wide)
1702
+ return (
1703
+ <BottomSheet
1704
+ ref={sheet}
1705
+ tabIndex={-1}
1706
+ aria-label={label}
1707
+ onClick={(e) => (e.target === sheet.current || (e.target as Element).closest('[role^="menuitem"]')) && sheet.current?.close()}
1708
+ onClose={(e) => e.target === e.currentTarget && onClose()}
1709
+ className="focus:outline-none"
1710
+ >
1711
+ <SheetTitle title={label} onClose={() => sheet.current?.close()} />
1712
+ <SheetList div>
1713
+ <SiteMenu role="menu" aria-label={label}>
1714
+ {children}
1715
+ </SiteMenu>
1716
+ </SheetList>
1717
+ </BottomSheet>
1718
+ );
1719
+ if (!anchor) return null;
1720
+ return (
1721
+ <SiteMenu
1722
+ ref={menu}
1723
+ role="menu"
1724
+ aria-label={label}
1725
+ className="fixed z-30"
1726
+ onClick={(e: MouseEvent) => (e.target as Element).closest('[role^="menuitem"]') && onClose()}
1727
+ >
1728
+ {children}
1729
+ </SiteMenu>
1730
+ );
1731
+ }
1732
+
1733
+ /** A choice in a Menu: the theme's menu item, with its icon before it; `selected`: the one that is chosen now, marked
1734
+ * (the kind of text where the cursor is). A tap on it keeps the cursor where it is in a text. */
1735
+ export function MenuItem({
1736
+ icon: Glyph,
1737
+ selected,
1738
+ onClick,
1739
+ children,
1740
+ }: {
1741
+ icon?: Icon;
1742
+ /** Undefined for a choice that does something, not one of some to choose between. */
1743
+ selected?: boolean;
1744
+ onClick: () => void;
1745
+ children: ReactNode;
1746
+ }) {
1747
+ return (
1748
+ <SiteMenuItem icon={Glyph && <Glyph />} selected={selected} onMouseDown={(e: MouseEvent) => e.preventDefault()} onClick={onClick}>
1749
+ {children}
1750
+ </SiteMenuItem>
1751
+ );
1752
+ }
1753
+
1754
+ /** The fields of a sheet, between SheetTitle and SheetActions, scrolled when there are many. */
1755
+ export function SheetBody({ onSubmit, children }: { onSubmit?: (e: FormEvent<HTMLFormElement>) => void; children: ReactNode }) {
1756
+ const className = 'min-h-0 flex-1 space-y-5 overflow-y-auto px-5 pt-2 pb-4';
1757
+ return onSubmit ? (
1758
+ <form className={className} onSubmit={onSubmit}>
1759
+ {children}
1760
+ </form>
1761
+ ) : (
1762
+ <div className={className}>{children}</div>
1763
+ );
1764
+ }
1765
+
1766
+ /** The choices of a sheet (Option), scrolled when there are many: a list, or with `div` groups of them
1767
+ * (SheetGroupTitle). */
1768
+ export function SheetList({ div, children }: { div?: boolean; children: ReactNode }) {
1769
+ const className = 'min-h-0 flex-1 overflow-y-auto px-2 pb-4';
1770
+ return div ? <div className={className}>{children}</div> : <ul className={className}>{children}</ul>;
1771
+ }
1772
+
1773
+ /** The small heading over some of the choices in a sheet (Faste sider, Sider). */
1774
+ export function SheetGroupTitle({ children }: { children: ReactNode }) {
1775
+ return <h3 className="px-3 pt-4 pb-1 text-sm font-medium text-muted">{children}</h3>;
1776
+ }
1777
+
1778
+ /** A link at the bottom of a sheet, under its choices and the theme's divider, to make something new elsewhere (Opret
1779
+ * et tag): the theme's text button with a +. */
1780
+ export function SheetLink({
1781
+ href,
1782
+ onClick,
1783
+ children,
1784
+ }: {
1785
+ href: string;
1786
+ onClick?: (e: MouseEvent<HTMLAnchorElement>) => void;
1787
+ children: ReactNode;
1788
+ }) {
1789
+ return (
1790
+ <div className="shrink-0">
1791
+ <SiteDivider />
1792
+ <div className="px-2 py-2">
1793
+ <SiteButton kind="text" href={href} icon={<Plus />} onClick={onClick}>
1794
+ {children}
1795
+ </SiteButton>
1796
+ </div>
1797
+ </div>
1798
+ );
1799
+ }
1800
+
1801
+ /** The bottom of a sheet with fields, under the theme's divider: "Annuller" and the main button on the right,
1802
+ * something else (Slet) on the left. */
1803
+ export function SheetActions({
1804
+ onCancel,
1805
+ onDone,
1806
+ done,
1807
+ disabled,
1808
+ start,
1809
+ }: {
1810
+ onCancel: () => void;
1811
+ onDone: () => void;
1812
+ /** The main button's text (Gem, Indsæt). */
1813
+ done: string;
1814
+ disabled?: boolean;
1815
+ start?: ReactNode;
1816
+ }) {
1817
+ return (
1818
+ <div className="shrink-0">
1819
+ <SiteDivider />
1820
+ <div className="flex items-center gap-2 px-5 py-3">
1821
+ {start}
1822
+ <Button kind="outlined" className="ml-auto" onClick={onCancel}>
1823
+ {t.common.cancel}
1824
+ </Button>
1825
+ <Button disabled={disabled} onClick={onDone}>
1826
+ {done}
1827
+ </Button>
1828
+ </div>
1829
+ </div>
1830
+ );
1831
+ }
1832
+
1833
+ // ---------------------------------------------------------------------------------------------------
1834
+ // The preview of a block
1835
+
1836
+ /** How a block looks on the site, with what is in it now, in a dashed frame: it can be used as on
1837
+ * the site (a text unfolds, a slider slides), but a link in it goes nowhere, so the editor stays open.
1838
+ * (The card of a block in the text is not this: a tap there opens its menu.) */
1839
+ export function Preview({ children }: { children: ReactNode }) {
1840
+ return (
1841
+ <div
1842
+ role="region"
1843
+ aria-label={t.common.preview}
1844
+ className="site-block-preview not-prose border-[1.5px] border-dashed border-line p-2"
1845
+ onClickCapture={(e) => {
1846
+ if ((e.target as HTMLElement).closest('a[href]')) e.preventDefault();
1847
+ }}
1848
+ >
1849
+ <InEditor>{children}</InEditor>
1850
+ </div>
1851
+ );
1852
+ }
1853
+
1854
+ /** A little page in a set of the site's colours (by their names, as "Farver" has them), whatever mode the editor is in:
1855
+ * a heading, text, a link, a surface with text, a tinted box, a box in each of the secondary and tertiary colours and a button, so it shows how readable they are together.
1856
+ * The colours are CSS variables on it, which the classes take, and the theme's filled button (ui.css) in them. "Farver"
1857
+ * (Colors.tsx) and the variants of a colour app. */
1858
+ export function ColorsPreview({ colors, title }: { colors: Record<string, string>; title: string }) {
1859
+ const p = t.colors.preview;
1860
+ const vars = {
1861
+ ...Object.fromEntries(Object.entries(colors).map(([name, value]) => [`--${name}`, value])),
1862
+ '--button-filled-surface': 'var(--accent)',
1863
+ '--button-filled-hover': 'var(--accent-hover)',
1864
+ '--button-filled-text': 'var(--on-accent)',
1865
+ } as CSSProperties;
1866
+ return (
1867
+ <div style={vars} aria-label={title} role="img" className="space-y-3 rounded-2xl border border-line bg-bg p-4 text-ink">
1868
+ <p className="text-lg font-medium tracking-tight">{p.heading}</p>
1869
+ <p className="text-sm text-muted">{p.text}</p>
1870
+ <p>
1871
+ <span className="link">{p.link}</span>
1872
+ </p>
1873
+ <div className="space-y-2 rounded-xl border border-line bg-surface p-3">
1874
+ <p className="text-sm">{p.card}</p>
1875
+ <p className="rounded-lg bg-tint px-3 py-2 text-sm font-medium text-accent">{p.box}</p>
1876
+ </div>
1877
+ {/* The secondary and tertiary colours, which a harmony on the colour wheel places. */}
1878
+ <div className="grid grid-cols-2 gap-2 text-sm font-medium">
1879
+ <p className="rounded-lg bg-secondary-container px-3 py-2 text-on-secondary-container">{p.secondary}</p>
1880
+ <p className="rounded-lg bg-tertiary-container px-3 py-2 text-on-tertiary-container">{p.tertiary}</p>
1881
+ </div>
1882
+ <span className="button button-filled button-small">{p.button}</span>
1883
+ </div>
1884
+ );
1885
+ }
1886
+
1887
+ /** A few colours side by side in small circles, read out together as `label` (a variant's colours, ChoiceRow). */
1888
+ export function ColorSwatches({ colors, label }: { colors: string[]; label: string }) {
1889
+ return (
1890
+ <span role="img" aria-label={label} className="flex shrink-0 gap-1.5">
1891
+ {colors.map((color, i) => (
1892
+ <span key={i} className="size-6 rounded-full border border-line" style={{ background: color }} />
1893
+ ))}
1894
+ </span>
1895
+ );
1896
+ }
1897
+
1898
+ /** A base colour in ColorTiles: the colour, its name, what its pen does (`onChange`, with `lock` when "Brug og lås"
1899
+ * took it; `disabled`: the pen is grey and cannot be used), and, for the one that can be locked, whether it is
1900
+ * (`locked`) and what its lock does (`onLock`). */
1901
+ export type ColorTile = {
1902
+ color: string;
1903
+ name: string;
1904
+ disabled?: boolean;
1905
+ locked?: boolean;
1906
+ onChange?: (color: string, lock: boolean) => void;
1907
+ onLock?: (locked: boolean) => void;
1908
+ };
1909
+
1910
+ /** A few colours side by side, each a tile filled with it, with its name and its colour as text in black or white,
1911
+ * whichever is easier to read on it (the generator's three base colours under "Farver"), and the theme's icon buttons
1912
+ * in the same colour: a pen that opens the colour sheet (ColorSheet, without colours to pick from), and on one that
1913
+ * can be locked a lock, on while it is locked, and "Brug og lås" in its sheet. `label` names them together. */
1914
+ export function ColorTiles({ colors, label }: { colors: ColorTile[]; label: string }) {
1915
+ const dialog = useRef<HTMLDialogElement>(null);
1916
+ // The tile whose sheet is open, and the times it was opened, so it starts again each time.
1917
+ const [editing, setEditing] = useState<{ tile: number; opened: number }>({ tile: 0, opened: 0 });
1918
+ const close = () => dialog.current?.close();
1919
+ const tile = colors[editing.tile];
1920
+ return (
1921
+ <>
1922
+ <ul
1923
+ aria-label={label}
1924
+ className={`grid overflow-hidden rounded-(--block-radius) ${swatchEdge}`}
1925
+ style={{ gridTemplateColumns: `repeat(${colors.length}, minmax(0, 1fr))` }}
1926
+ >
1927
+ {colors.map(({ color, name, disabled, locked, onChange, onLock }, i) => {
1928
+ const text = contrast(color, '#000000') >= contrast(color, '#ffffff') ? '#000000' : '#ffffff';
1929
+ // The icon buttons in the tile's text colour, on and off.
1930
+ const vars = {
1931
+ background: color,
1932
+ color: text,
1933
+ '--icon-button-standard-text': text,
1934
+ '--icon-button-standard-selected-text': text,
1935
+ } as CSSProperties;
1936
+ return (
1937
+ <li key={name} className="flex min-w-0 flex-col gap-1 p-3" style={vars}>
1938
+ <span className="block text-sm font-medium break-words">{name}</span>
1939
+ <span className="block truncate font-mono text-sm uppercase">{color}</span>
1940
+ <span className="mt-auto -ml-2 flex">
1941
+ {onChange && (
1942
+ <IconButton
1943
+ icon={PencilSimple}
1944
+ label={t.common.pickColor(name)}
1945
+ aria-haspopup="dialog"
1946
+ disabled={disabled}
1947
+ onClick={() => {
1948
+ setEditing((e) => ({ tile: i, opened: e.opened + 1 }));
1949
+ dialog.current?.showModal();
1950
+ // The sheet has the focus, not its first button (CLAUDE.md).
1951
+ dialog.current?.focus();
1952
+ }}
1953
+ />
1954
+ )}
1955
+ {onLock && (
1956
+ <IconButton
1957
+ icon={locked ? LockSimple : LockSimpleOpen}
1958
+ label={t.common.lockColor(name)}
1959
+ selected={!!locked}
1960
+ onClick={() => onLock(!locked)}
1961
+ />
1962
+ )}
1963
+ </span>
1964
+ </li>
1965
+ );
1966
+ })}
1967
+ </ul>
1968
+ <BottomSheet
1969
+ ref={dialog}
1970
+ tabIndex={-1}
1971
+ aria-label={tile?.name}
1972
+ onClick={(e) => e.target === dialog.current && close()}
1973
+ className="focus:outline-none"
1974
+ >
1975
+ {editing.opened > 0 && tile?.onChange && (
1976
+ <ColorSheet
1977
+ key={editing.opened}
1978
+ id={`color-tile-${editing.tile}`}
1979
+ title={tile.name}
1980
+ value={tile.color}
1981
+ onChange={(c) => tile.onChange?.(c, false)}
1982
+ onLock={tile.onLock && ((c) => tile.onChange?.(c, true))}
1983
+ onClose={close}
1984
+ />
1985
+ )}
1986
+ </BottomSheet>
1987
+ </>
1988
+ );
1989
+ }
1990
+
1991
+ /** A colour wheel (the generator under "Farver"): every hue round the edge at its most colourful (`edge`, from the top
1992
+ * clockwise), grey in the middle (`middle`), and dots on it (`dots`, x and y from -1 to 1 from the middle, y down), a
1993
+ * locked one with a lock in it. The first is the one chosen, to drag, tap or move with the arrow keys (round the wheel,
1994
+ * and out and in); the others follow it. `disabled`: it cannot be moved (the source colour is locked). `label` names it
1995
+ * for screen readers, `valueText` says what is chosen. */
1996
+ export function ColorWheel({
1997
+ edge,
1998
+ middle,
1999
+ dots,
2000
+ label,
2001
+ valueText,
2002
+ disabled = false,
2003
+ onMove,
2004
+ }: {
2005
+ edge: string[];
2006
+ middle: string;
2007
+ dots: { x: number; y: number; color: string; locked?: boolean }[];
2008
+ label: string;
2009
+ valueText: string;
2010
+ disabled?: boolean;
2011
+ onMove: (x: number, y: number) => void;
2012
+ }) {
2013
+ const [first] = dots;
2014
+ const angle = Math.atan2(first.x, -first.y);
2015
+ const keys = (e: KeyboardEvent) => {
2016
+ const step = ({ ArrowLeft: [-5, 0], ArrowRight: [5, 0], ArrowUp: [0, 0.05], ArrowDown: [0, -0.05] } as Record<string, number[]>)[e.key];
2017
+ if (!step || disabled) return;
2018
+ e.preventDefault();
2019
+ const a = angle + (step[0] * Math.PI) / 180;
2020
+ const r = clamp(Math.hypot(first.x, first.y) + step[1]);
2021
+ onMove(r * Math.sin(a), -r * Math.cos(a));
2022
+ };
2023
+ return (
2024
+ <div
2025
+ role="slider"
2026
+ tabIndex={0}
2027
+ aria-label={label}
2028
+ aria-valuemin={0}
2029
+ aria-valuemax={360}
2030
+ aria-valuenow={Math.round(((angle * 180) / Math.PI + 360) % 360)}
2031
+ aria-valuetext={valueText}
2032
+ aria-disabled={disabled || undefined}
2033
+ className={`relative aspect-square w-full max-w-72 rounded-full ${swatchEdge} ${ring}`}
2034
+ style={{ background: `radial-gradient(closest-side, ${middle}, transparent), conic-gradient(${[...edge, edge[0]].join(', ')})` }}
2035
+ {...(disabled ? {} : drag((x, y) => onMove(x * 2 - 1, y * 2 - 1)))}
2036
+ onKeyDown={keys}
2037
+ >
2038
+ {/* The chosen dot last, so it is on top. */}
2039
+ {dots
2040
+ .map((dot, i) => (
2041
+ <span
2042
+ key={i}
2043
+ className={`${thumb} flex items-center justify-center ${i ? 'size-6' : 'size-9'}`}
2044
+ style={{
2045
+ left: `${(dot.x + 1) * 50}%`,
2046
+ top: `${(dot.y + 1) * 50}%`,
2047
+ background: dot.color,
2048
+ color: contrast(dot.color, '#000000') >= contrast(dot.color, '#ffffff') ? '#000000' : '#ffffff',
2049
+ }}
2050
+ >
2051
+ {dot.locked && <LockSimple size={i ? 12 : 16} aria-hidden="true" />}
2052
+ </span>
2053
+ ))
2054
+ .reverse()}
2055
+ </div>
2056
+ );
2057
+ }
2058
+
2059
+ /** A few icons side by side, each an SVG file as it is, read out together as `label` (an icon pack's, ChoiceRow on
2060
+ * "Ikonpakker"): in the row's colour, as its own icons. */
2061
+ export function IconSwatches({ icons, label }: { icons: string[]; label: string }) {
2062
+ return (
2063
+ <span role="img" aria-label={label} className="flex shrink-0 gap-2">
2064
+ {icons.map((file, i) => (
2065
+ <SvgFile key={i} file={file} />
2066
+ ))}
2067
+ </span>
2068
+ );
2069
+ }
2070
+
2071
+ /** A row in a Group that is one of several choices on a screen of their own ("Ikonpakker"): the theme's list item
2072
+ * (ListItem) as a button, its name (`headline`) and what is under it (`supporting`), what it gives at the right (`trailing`: IconSwatches,
2073
+ * ColorSwatches), and the chosen one marked as a choice in a sheet is (Option): aria-pressed, in the accent colour, with
2074
+ * a tick. */
2075
+ export function ChoiceRow({
2076
+ selected,
2077
+ headline,
2078
+ supporting,
2079
+ trailing,
2080
+ onClick,
2081
+ }: {
2082
+ selected: boolean;
2083
+ headline: ReactNode;
2084
+ supporting?: ReactNode;
2085
+ trailing?: ReactNode;
2086
+ onClick: () => void;
2087
+ }) {
2088
+ return (
2089
+ <ListItem
2090
+ onClick={onClick}
2091
+ aria-pressed={selected}
2092
+ tone={selected ? 'accent' : undefined}
2093
+ headline={headline}
2094
+ supporting={supporting}
2095
+ trailing={
2096
+ <span className="flex items-center gap-3">
2097
+ {trailing}
2098
+ <Check size={20} className={`shrink-0 ${selected ? 'text-accent' : 'invisible'}`} />
2099
+ </span>
2100
+ }
2101
+ />
2102
+ );
2103
+ }
2104
+
2105
+ // ---------------------------------------------------------------------------------------------------
2106
+ // Lists of rows
2107
+
2108
+ /** The classes of a list of rows (in Group, and alone in a sheet: where a picture is used): the theme's segmented list
2109
+ * (Material 3 Expressive's), each row on its own surface. */
2110
+ export const listClass = siteListClasses('segmented');
2111
+
2112
+ /** The small heading over a Group, and over what stands as one (the themes on Konto): in Material 3's title small, as
2113
+ * written (not in capitals, Material 2's subheader), muted as a group's heading in a sheet (SheetGroupTitle). */
2114
+ export function GroupTitle({ children }: { children: ReactNode }) {
2115
+ return <h2 className="mb-2 px-4 text-sm font-medium tracking-[0.00625rem] text-muted">{children}</h2>;
2116
+ }
2117
+
2118
+ /** The theme's ripple on every part the editor draws that is pressed (its buttons, rows, chips …), as on the site:
2119
+ * started once, when the editor opens. */
2120
+ export const startRipples = siteRipples;
2121
+
2122
+ /** Something on its way that takes a moment (Gemmer, Henter versionerne): the theme's loading indicator (Material 3's),
2123
+ * as big as the text, before what it is. */
2124
+ export function LoadingIndicator({ children }: { children: ReactNode }) {
2125
+ return (
2126
+ <span className="inline-flex items-center gap-2">
2127
+ <SiteLoadingIndicator label="" aria-hidden style={inText} />
2128
+ {children}
2129
+ </span>
2130
+ );
2131
+ }
2132
+
2133
+ // The theme's loading indicator as big as the text it is before, in its colour (on a filled button too).
2134
+ const inText = {
2135
+ '--loading-indicator-size': '1.25em',
2136
+ '--loading-indicator-shape-size': '1.125em',
2137
+ '--loading-indicator-colour': 'currentColor',
2138
+ } as CSSProperties;
2139
+
2140
+ /** 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. */
2142
+ export function EmptyNote({ item, busy, children }: { item?: boolean; busy?: boolean; children: ReactNode }) {
2143
+ const className = 'px-4 py-6 text-center text-muted';
2144
+ const text = busy ? <LoadingIndicator>{children}</LoadingIndicator> : children;
2145
+ return item ? <li className={className}>{text}</li> : <p className={className}>{text}</p>;
2146
+ }
2147
+
2148
+ /** What is wrong, in the accent colour: why a sheet cannot be saved (under its fields), or what could
2149
+ * not be read. `alert`: read by screen readers as soon as it is shown. */
2150
+ export function ProblemNote({ alert, children }: { alert?: boolean; children: ReactNode }) {
2151
+ return (
2152
+ <div role={alert ? 'alert' : undefined} className="text-sm break-words text-accent">
2153
+ {children}
2154
+ </div>
2155
+ );
2156
+ }
2157
+
2158
+ /** A heading over fields in a form (Andre felter, SEO); `first` at the top of a column, without the space
2159
+ * above it on a wide screen. */
2160
+ 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>;
2162
+ }
2163
+
2164
+ /** 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
2165
+ * own name): its heading, as large as a FormHeading, is a button with the arrow at its right, which turns while the
2166
+ * section is open, and a line under each section. Folded, only the heading is shown; what is in it stays (hidden), so
2167
+ * the text and the fields keep what was written, and it shows softly when opened (.editor-reveal). It stops under the
2168
+ * top bar when the page scrolls to it. `ref`: the section, for what scrolls to it and marks it. */
2169
+ export function FoldSection({
2170
+ ref,
2171
+ title,
2172
+ open,
2173
+ onToggle,
2174
+ children,
2175
+ }: {
2176
+ ref?: Ref<HTMLElement>;
2177
+ title: string;
2178
+ open: boolean;
2179
+ onToggle: () => void;
2180
+ children: ReactNode;
2181
+ }) {
2182
+ const id = useId();
2183
+ return (
2184
+ <section ref={ref} aria-labelledby={`${id}-title`} className="scroll-mt-[calc(5rem+env(safe-area-inset-top))] border-b border-line">
2185
+ <h2>
2186
+ <button
2187
+ type="button"
2188
+ id={`${id}-title`}
2189
+ aria-expanded={open}
2190
+ aria-controls={`${id}-body`}
2191
+ className="-mx-3 flex w-[calc(100%+1.5rem)] items-center gap-3 rounded-(--block-radius-small) px-3 py-4 text-left text-xl font-semibold transition-colors hover:bg-tint focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
2192
+ onClick={onToggle}
2193
+ >
2194
+ <span className="min-w-0 flex-1">{title}</span>
2195
+ <CaretDown
2196
+ size={20}
2197
+ aria-hidden="true"
2198
+ className={`shrink-0 text-muted transition-transform motion-reduce:transition-none ${open ? 'rotate-180' : ''}`}
2199
+ />
2200
+ </button>
2201
+ </h2>
2202
+ <div id={`${id}-body`} hidden={!open} className="editor-reveal space-y-6 pt-2 pb-8">
2203
+ {children}
2204
+ </div>
2205
+ </section>
2206
+ );
2207
+ }
2208
+
2209
+ /** Rows in the theme's segmented list, under a small heading: the start screen, the lists. Never with a note under it. */
2210
+ export function Group({ title, children }: { title?: string; children: ReactNode }) {
2211
+ return (
2212
+ <section className="mb-8">
2213
+ {title && <GroupTitle>{title}</GroupTitle>}
2214
+ <ul className={listClass}>{children}</ul>
2215
+ </section>
2216
+ );
2217
+ }
2218
+
2219
+ /** A list of checks, each fine or not (the SEO rating of a page), as PageSpeed shows them: a score from 0 to 100 (the
2220
+ * share that is fine) in a ring, green from 90, orange from 50, else red, with the title under it; then those that can be
2221
+ * done better (a red triangle before each) and those that are fine (a green dot), each under its name and number
2222
+ * (editor.css: --editor-status-*). A row with a `fix` folds out to it (how to do it better). Material 3 has none, so it
2223
+ * has its own name. Never with a note under it. The ring fills up to the score when it comes into view, and its number
2224
+ * counts up with it. */
2225
+ export type Check = { ok: boolean; text: string; fix?: string };
2226
+
2227
+ // The ring's colours for each status, as whole class names (so Tailwind finds them).
2228
+ const statusTones = {
2229
+ good: { fill: 'fill-(--editor-status-good-surface)', stroke: 'stroke-(--editor-status-good)', text: 'text-(--editor-status-good-text)' },
2230
+ average: {
2231
+ fill: 'fill-(--editor-status-average-surface)',
2232
+ stroke: 'stroke-(--editor-status-average)',
2233
+ text: 'text-(--editor-status-average-text)',
2234
+ },
2235
+ bad: { fill: 'fill-(--editor-status-bad-surface)', stroke: 'stroke-(--editor-status-bad)', text: 'text-(--editor-status-bad-text)' },
2236
+ };
2237
+
2238
+ /** The score of checks from 0 to 100: the share that is fine (100 with none). */
2239
+ export const scoreOf = (items: Check[]) => (items.length ? Math.round((items.filter((i) => i.ok).length / items.length) * 100) : 100);
2240
+ const toneOf = (score: number) => statusTones[score >= 90 ? 'good' : score >= 50 ? 'average' : 'bad'];
2241
+
2242
+ /** A score from 0 to 100 in a small ring, in the colours of CheckList's (green from 90, orange from 50, else red), at
2243
+ * the start of a row (the SEO rating of each page). Material 3 has none, so it has its own name. In a row that is a
2244
+ * link, `onClick` is what a tap on the ring does besides (the page opens at its rating, the rest of the row at its top). */
2245
+ export function ScoreRing({ score, label, onClick }: { score: number; label: string; onClick?: () => void }) {
2246
+ const tone = toneOf(score);
2247
+ return (
2248
+ <span role="img" aria-label={label} onClick={onClick} className="relative size-10 shrink-0">
2249
+ <svg viewBox="0 0 36 36" aria-hidden="true" className="size-full -rotate-90">
2250
+ <circle cx="18" cy="18" r="15.9155" className={tone.fill} />
2251
+ <circle
2252
+ cx="18"
2253
+ cy="18"
2254
+ r="15.9155"
2255
+ fill="none"
2256
+ strokeWidth="3"
2257
+ strokeLinecap="round"
2258
+ pathLength={100}
2259
+ strokeDasharray={`${score} 100`}
2260
+ className={tone.stroke}
2261
+ />
2262
+ </svg>
2263
+ <span aria-hidden="true" className={`absolute inset-0 grid place-items-center text-sm font-medium tabular-nums ${tone.text}`}>
2264
+ {score}
2265
+ </span>
2266
+ </span>
2267
+ );
2268
+ }
2269
+
2270
+ export function CheckList({ title, items }: { title?: string; items: Check[] }) {
2271
+ const [open, setOpen] = useState<string | null>(null);
2272
+ // The ring fills up to the score when it comes into view (and stays so); at once for those who want less motion.
2273
+ const ring = useRef<HTMLDivElement>(null);
2274
+ const [seen, setSeen] = useState(false);
2275
+ useEffect(() => {
2276
+ const el = ring.current;
2277
+ if (!el || seen) return;
2278
+ if (!('IntersectionObserver' in window)) return setSeen(true);
2279
+ const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setSeen(true), { threshold: 0.5 });
2280
+ io.observe(el);
2281
+ return () => io.disconnect();
2282
+ }, [seen]);
2283
+ const bad = items.filter((i) => !i.ok);
2284
+ const good = items.filter((i) => i.ok);
2285
+ const score = scoreOf(items);
2286
+ const tone = toneOf(score);
2287
+ // The number counts up with the ring (from what it shows to the score, over the ring's 1.2 s, easing out), and goes
2288
+ // on to a new score the same way; at once for those who want less motion.
2289
+ const [shown, setShown] = useState(0);
2290
+ const from = useRef(0);
2291
+ useEffect(() => {
2292
+ if (!seen) return;
2293
+ const start = from.current;
2294
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches || start === score) {
2295
+ from.current = score;
2296
+ return setShown(score);
2297
+ }
2298
+ const t0 = performance.now();
2299
+ let frame = 0;
2300
+ const step = (now: number) => {
2301
+ const p = Math.min(1, (now - t0) / 1200);
2302
+ const value = Math.round(start + (score - start) * (1 - (1 - p) ** 3));
2303
+ from.current = value;
2304
+ setShown(value);
2305
+ if (p < 1) frame = requestAnimationFrame(step);
2306
+ };
2307
+ frame = requestAnimationFrame(step);
2308
+ return () => cancelAnimationFrame(frame);
2309
+ }, [seen, score]);
2310
+ const mark = (ok: boolean) =>
2311
+ ok ? (
2312
+ <span aria-hidden="true" className="mx-0.5 size-3 shrink-0 rounded-full bg-(--editor-status-good)" />
2313
+ ) : (
2314
+ <svg aria-hidden="true" viewBox="0 0 12 12" className="size-4 shrink-0 fill-(--editor-status-bad)">
2315
+ <path d="M6 1 11.5 11H.5Z" />
2316
+ </svg>
2317
+ );
2318
+ const row = (item: Check) => {
2319
+ const shown = open === item.text;
2320
+ const text = (
2321
+ <span className="min-w-0 flex-1">
2322
+ <span className="sr-only">{`${item.ok ? t.common.check.ok : t.common.check.notOk}: `}</span>
2323
+ {item.text}
2324
+ </span>
2325
+ );
2326
+ return item.fix ? (
2327
+ <ListItem
2328
+ key={item.text}
2329
+ onClick={() => setOpen(shown ? null : item.text)}
2330
+ aria-expanded={shown}
2331
+ leading={mark(item.ok)}
2332
+ headline={text}
2333
+ trailing={<CaretDown size={18} className={`shrink-0 text-muted transition-transform ${shown ? 'rotate-180' : ''}`} />}
2334
+ >
2335
+ {shown && <p className="editor-reveal px-4 pb-3 pl-11 text-muted">{item.fix}</p>}
2336
+ </ListItem>
2337
+ ) : (
2338
+ <ListItem key={item.text} leading={mark(item.ok)} headline={text} />
2339
+ );
2340
+ };
2341
+ // The ring: its circle is 2πr = 100 long (r = 15.9155), so the score is how much of it is drawn.
2342
+ return (
2343
+ <div>
2344
+ <div className="mb-8 flex flex-col items-center gap-3">
2345
+ <div ref={ring} className="relative size-28">
2346
+ <svg viewBox="0 0 36 36" aria-hidden="true" className="size-full -rotate-90">
2347
+ <circle cx="18" cy="18" r="15.9155" className={tone.fill} />
2348
+ <circle
2349
+ cx="18"
2350
+ cy="18"
2351
+ r="15.9155"
2352
+ fill="none"
2353
+ strokeWidth="2.5"
2354
+ strokeLinecap="round"
2355
+ pathLength={100}
2356
+ strokeDasharray={`${seen ? score : 0} 100`}
2357
+ className={`transition-[stroke-dasharray] duration-[1200ms] ease-out motion-reduce:transition-none ${seen && score > 0 ? '' : 'opacity-0'} ${tone.stroke}`}
2358
+ />
2359
+ </svg>
2360
+ <span aria-hidden="true" className={`absolute inset-0 grid place-items-center text-4xl tabular-nums ${tone.text}`}>
2361
+ {shown}
2362
+ </span>
2363
+ </div>
2364
+ {title && <h3 className="text-xl font-medium">{title}</h3>}
2365
+ <span className="sr-only">{t.common.check.score(good.length, items.length)}</span>
2366
+ </div>
2367
+ {bad.length > 0 && <Group title={`${t.common.check.notOk} (${bad.length})`}>{bad.map(row)}</Group>}
2368
+ {good.length > 0 && <Group title={`${t.common.check.ok} (${good.length})`}>{good.map(row)}</Group>}
2369
+ </div>
2370
+ );
2371
+ }
2372
+
2373
+ /** How a link to a page looks when it is shared on social media (Facebook, LinkedIn, a message), as a card: its picture
2374
+ * cut to 1.91:1 (one on its side or square keeps its upper part, as the site cuts it), the site's domain in small
2375
+ * capitals, the title and the description, each cut off after two lines. Material 3 has none, so it has its own name. */
2376
+ export function SharePreview({ image, domain, title, description }: { image: string; domain: string; title: string; description: string }) {
2377
+ const [tall, setTall] = useState(false);
2378
+ return (
2379
+ <figure aria-label={t.forms.sharePreview} className={`${siteCardClass('outlined')} overflow-hidden p-0`}>
2380
+ <img
2381
+ src={image}
2382
+ alt=""
2383
+ className={`aspect-[1.91/1] w-full bg-line/50 object-cover ${tall ? 'object-[50%_20%]' : ''}`}
2384
+ onLoad={(e) => setTall(e.currentTarget.naturalHeight >= e.currentTarget.naturalWidth)}
2385
+ />
2386
+ <figcaption className="space-y-0.5 border-t border-line px-4 py-3">
2387
+ <span className="block truncate text-xs tracking-wide text-muted uppercase">{domain}</span>
2388
+ <span className="line-clamp-2 font-semibold">{title}</span>
2389
+ <span className="line-clamp-2 text-sm text-muted">{description}</span>
2390
+ </figcaption>
2391
+ </figure>
2392
+ );
2393
+ }
2394
+
2395
+ /** Pictures in the theme's carousel (a theme's or an app's screenshots), each opening a sheet with it whole, under its
2396
+ * name. */
2397
+ export function ImageCarousel({ label, images }: { label: string; images: { src: string; label: string }[] }) {
2398
+ const sheet = useRef<HTMLDialogElement>(null);
2399
+ const [shown, setShown] = useState<{ src: string; label: string } | null>(null);
2400
+ return (
2401
+ <>
2402
+ <SiteCarousel
2403
+ label={label}
2404
+ roleDescription={t.common.carousel.name}
2405
+ itemRoleDescription={t.common.carousel.item}
2406
+ indicatorLabels={images.map((_, i) => t.common.carousel.of(i + 1, images.length))}
2407
+ items={images.map((x) => ({
2408
+ key: x.src,
2409
+ label: x.label,
2410
+ content: (
2411
+ <button
2412
+ type="button"
2413
+ aria-haspopup="dialog"
2414
+ className="block aspect-[3/4] w-full overflow-hidden border border-line bg-surface"
2415
+ onClick={() => {
2416
+ setShown(x);
2417
+ sheet.current?.showModal();
2418
+ }}
2419
+ >
2420
+ <img src={x.src} alt={x.label} loading="lazy" className="size-full object-cover object-top" />
2421
+ </button>
2422
+ ),
2423
+ }))}
2424
+ />
2425
+ <BottomSheet
2426
+ ref={sheet}
2427
+ aria-label={shown?.label}
2428
+ onClose={(e) => e.target === e.currentTarget && setShown(null)}
2429
+ onClick={(e) => e.target === sheet.current && sheet.current?.close()}
2430
+ >
2431
+ <SheetTitle title={shown?.label ?? ''} onClose={() => sheet.current?.close()} />
2432
+ {shown && (
2433
+ <SheetBody>
2434
+ <img src={shown.src} alt={shown.label} className="mx-auto w-full max-w-sm border border-line" />
2435
+ </SheetBody>
2436
+ )}
2437
+ </BottomSheet>
2438
+ </>
2439
+ );
2440
+ }
2441
+
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)');
2446
+ }
2447
+
2448
+ // Whether the screen is as a media query says, kept up to date when the window changes.
2449
+ function useMedia(query: string) {
2450
+ const [is, setIs] = useState(() => matchMedia(query).matches);
2451
+ useEffect(() => {
2452
+ const list = matchMedia(query);
2453
+ const change = () => setIs(list.matches);
2454
+ list.addEventListener('change', change);
2455
+ return () => list.removeEventListener('change', change);
2456
+ }, [query]);
2457
+ return is;
2458
+ }
2459
+
2460
+ /** Whether `box` is in view (or within `margin` of it), kept up to date while the screen scrolls: so a part of a long
2461
+ * form does its work only when it is seen (the SEO rating, the earlier versions in EntryForm.tsx). */
2462
+ export function useInView(box: RefObject<Element | null>, margin = '0px') {
2463
+ const [seen, setSeen] = useState(false);
2464
+ useEffect(() => {
2465
+ const el = box.current;
2466
+ if (!el) return;
2467
+ if (!('IntersectionObserver' in window)) return setSeen(true);
2468
+ const io = new IntersectionObserver((entries) => setSeen(entries.some((e) => e.isIntersecting)), { rootMargin: margin });
2469
+ io.observe(el);
2470
+ return () => io.disconnect();
2471
+ }, [box, margin]);
2472
+ return seen;
2473
+ }
2474
+
2475
+ // What is searched for and picked over each list, while the editor is open (useKept).
2476
+ const kept = new Map<string, unknown>();
2477
+
2478
+ /** A screen's search or filter (the search over the pages, a status picked), kept while the editor is
2479
+ * open, so it is the same again on coming back to the screen (from a page opened in the list). Under
2480
+ * its own key for each screen and each of its searches and filters; without a key, not kept. */
2481
+ export function useKept<T>(key: string | null, first: T): [T, (value: T) => void] {
2482
+ const [value, setValue] = useState<T>(() => (key && kept.has(key) ? (kept.get(key) as T) : first));
2483
+ function set(next: T) {
2484
+ if (key) kept.set(key, next);
2485
+ setValue(next);
2486
+ }
2487
+ return [value, set];
2488
+ }
2489
+
2490
+ /** 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
+ * 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). */
2493
+ export function SideSheet({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
2494
+ const id = useId();
2495
+ return (
2496
+ <SiteSideSheet
2497
+ headline={title}
2498
+ headlineId={id}
2499
+ closeLabel={t.common.close}
2500
+ onClose={onClose}
2501
+ className="editor-reveal sticky top-24 max-h-[calc(100dvh-7rem)] self-start"
2502
+ >
2503
+ <div className="space-y-5">{children}</div>
2504
+ </SiteSideSheet>
2505
+ );
2506
+ }
2507
+
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). */
2510
+ export type NavigationItem = { id: string; label: string; icon: Icon; badge?: number | boolean };
2511
+
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({
2517
+ title,
2518
+ items,
2519
+ value,
2520
+ onChange,
2521
+ }: {
2522
+ title: string;
2523
+ items: NavigationItem[];
2524
+ value: string;
2525
+ onChange: (id: string) => void;
2526
+ }) {
2527
+ return (
2528
+ <nav aria-label={title}>
2529
+ <ul className="space-y-1">
2530
+ {items.map(({ id, label, icon: Glyph, badge }) => {
2531
+ const open = id === value;
2532
+ 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>
2540
+ );
2541
+ })}
2542
+ </ul>
2543
+ </nav>
2544
+ );
2545
+ }
2546
+
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({
2552
+ label,
2553
+ menuLabel,
2554
+ expanded,
2555
+ peek,
2556
+ header,
2557
+ onExpandedChange,
2558
+ children,
2559
+ ...rest
2560
+ }: {
2561
+ label: string;
2562
+ menuLabel: string;
2563
+ expanded: boolean;
2564
+ peek?: boolean;
2565
+ header?: ReactNode;
2566
+ onExpandedChange: (expanded: boolean) => void;
2567
+ children: ReactNode;
2568
+ } & HTMLAttributes<HTMLElement>) {
2569
+ 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}
2579
+ >
2580
+ {children}
2581
+ </SiteNavigationRail>
2582
+ );
2583
+ }
2584
+
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;
2587
+
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({
2591
+ href,
2592
+ icon: Glyph,
2593
+ label,
2594
+ count,
2595
+ selected,
2596
+ }: {
2597
+ href: string;
2598
+ icon: Icon;
2599
+ label: string;
2600
+ count?: number | null;
2601
+ selected?: boolean;
2602
+ }) {
2603
+ return <SiteNavigationRailItem href={href} icon={<Glyph />} label={label} selected={selected} trailingSupporting={count ?? undefined} />;
2604
+ }
2605
+
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({
2612
+ label,
2613
+ open,
2614
+ onClick,
2615
+ className = '',
2616
+ }: {
2617
+ label: string;
2618
+ open?: boolean;
2619
+ onClick: () => void;
2620
+ className?: string;
2621
+ }) {
2622
+ return (
2623
+ <IconButton
2624
+ icon={SidebarRight}
2625
+ label={label}
2626
+ selected={open}
2627
+ aria-expanded={open}
2628
+
2629
+ className={className}
2630
+ onClick={onClick}
2631
+ />
2632
+ );
2633
+ }
2634
+
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 = '',
2644
+ }: {
2645
+ title: string;
2646
+ items: NavigationItem[];
2647
+ value: string;
2648
+ onChange: (id: string) => void;
2649
+ className?: string;
2650
+ }) {
2651
+ const dialog = useRef<HTMLDialogElement>(null);
2652
+ const close = () => dialog.current?.close();
2653
+ return (
2654
+ <>
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>
2680
+ </>
2681
+ );
2682
+ }
2683
+
2684
+ /** Tabs (Material 3): what they are called (`label`), the one open (`value`), each one's name and icon, and the
2685
+ * opening of another. Every tab has its icon over its name, so all tabs in the editor look the same. */
2686
+ export type TabChoice<T extends string> = {
2687
+ label: string;
2688
+ value: T;
2689
+ options: { value: T; label: string; icon: Icon }[];
2690
+ onChange: (v: T) => void;
2691
+ };
2692
+
2693
+ /** The row of the theme's tabs: its primary tabs, sharing the width, each with its icon over its name, the open one
2694
+ * with the theme's indicator under it. The arrow keys go to the one before and after and open it. `id` ties it to its
2695
+ * TabPanel. In a screen's top app bar (`tabs` on Screen), the bar's line is the divider under them (ui.css). */
2696
+ export function TabRow<T extends string>({ id, label, value, options, onChange }: TabChoice<T> & { id: string }) {
2697
+ const keys = (e: KeyboardEvent<HTMLElement>) => {
2698
+ const at = options.findIndex((o) => o.value === value);
2699
+ const to = { ArrowLeft: at - 1, ArrowRight: at + 1, Home: 0, End: options.length - 1 }[e.key];
2700
+ if (to === undefined) return;
2701
+ e.preventDefault();
2702
+ const next = options[(to + options.length) % options.length];
2703
+ onChange(next.value);
2704
+ e.currentTarget.querySelector<HTMLElement>(`[data-tab="${next.value}"]`)?.focus();
2705
+ };
2706
+ return (
2707
+ <SiteTabs kind="primary" scrollable={false} label={label} onKeyDown={keys}>
2708
+ {options.map(({ value: v, label: name, icon: Glyph }) => (
2709
+ <SiteTab
2710
+ key={v}
2711
+ id={`${id}-tab-${v}`}
2712
+ data-tab={v}
2713
+ controls={`${id}-panel`}
2714
+ selected={v === value}
2715
+ icon={<Glyph />}
2716
+ onClick={() => onChange(v)}
2717
+ >
2718
+ <span className="truncate">{name}</span>
2719
+ </SiteTab>
2720
+ ))}
2721
+ </SiteTabs>
2722
+ );
2723
+ }
2724
+
2725
+ /** What the open tab of a TabRow (the same `id`) shows. */
2726
+ export function TabPanel({ id, value, className = '', children }: { id: string; value: string; className?: string; children: ReactNode }) {
2727
+ return (
2728
+ <div
2729
+ role="tabpanel"
2730
+ id={`${id}-panel`}
2731
+ aria-labelledby={`${id}-tab-${value}`}
2732
+ tabIndex={0}
2733
+ className={`focus-visible:outline-none ${className}`}
2734
+ >
2735
+ {children}
2736
+ </div>
2737
+ );
2738
+ }
2739
+
2740
+ /** A form in two columns on a wide computer screen (xl): `main` in the wide one, and at the right
2741
+ * `first` (the status, or the fields beside a preview) and `side` (the other fields), each on a card,
2742
+ * or without one (`cards` false: a preview that is its own card). `sticky`: the right column stays in
2743
+ * 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`. */
2744
+ export function FormColumns({
2745
+ first,
2746
+ main,
2747
+ side,
2748
+ cards = true,
2749
+ sticky = false,
2750
+ wideOnly = false,
2751
+ }: {
2752
+ first?: ReactNode;
2753
+ main: ReactNode;
2754
+ side?: ReactNode;
2755
+ cards?: boolean;
2756
+ sticky?: boolean;
2757
+ /** The right column only on a wide computer screen (a form's NavigationDrawer, which is a ModalNavigationDrawer narrower). */
2758
+ wideOnly?: boolean;
2759
+ }) {
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>}
2768
+ </div>
2769
+ );
2770
+ }
2771
+
2772
+ /** The icon before a name in a row or a list of choices, the same everywhere: the theme's leading icon (Material's),
2773
+ * grey; `selected`: in the selected colour, the open part in the sidebar (App.tsx). Or what is in it (`children`): the
2774
+ * chosen icon of an icon field. Beside its row's first two lines, as the theme places it. */
2775
+ export function LeadingIcon({ icon: Glyph, selected = false, children }: { icon?: Icon; selected?: boolean; children?: ReactNode }) {
2776
+ return (
2777
+ <SiteListItemLeadingIcon icon={Glyph && <Glyph />} selected={selected}>
2778
+ {children}
2779
+ </SiteListItemLeadingIcon>
2780
+ );
2781
+ }
2782
+
2783
+ /** A picture before a name in a row, as a ListItem's `leading` (Material's leading image): small and square, with the
2784
+ * corners of a field, beside its row's first two lines, as an icon; the picture a page's colours came from ("Farver" ›
2785
+ * "Sider"). `alt` empty when the row's text says what it is. */
2786
+ export function LeadingImage({ src, alt = '' }: { src: string; alt?: string }) {
2787
+ return <img src={src} alt={alt} loading="lazy" className="size-10 rounded-(--block-radius-small) bg-tint object-cover" />;
2788
+ }
2789
+
2790
+ /** What a design token is, for the sample of it before its row ("Dele" › "Værdier", Tokens.tsx). */
2791
+ export type TokenKind = 'shape' | 'elevation' | 'font' | 'color';
2792
+
2793
+ /** A sample of one of the theme's design tokens before its name in a row, as a ListItem's `leading`, beside its first
2794
+ * two lines as an icon,
2795
+ * drawn with the token itself (`value`, its CSS variable as CSS: `var(--radius)`), so it shows what the theme sets: a corner with its radius
2796
+ * (shape), a surface with its shadow (elevation), "Aa" in its font (font; in `weight`, for a type role) and a round of its
2797
+ * colour (color). Material 3 has none; the editor's own. */
2798
+ export function LeadingToken({ kind, value, weight }: { kind: TokenKind; value: string; weight?: string }) {
2799
+ return (
2800
+ <span className="flex size-10 items-center justify-center" aria-hidden="true">
2801
+ {kind === 'shape' && <span className="size-10 border-t-2 border-l-2 border-accent bg-tint" style={{ borderTopLeftRadius: value }} />}
2802
+ {kind === 'elevation' && <span className="size-8 rounded-(--block-radius) bg-surface" style={{ boxShadow: value }} />}
2803
+ {kind === 'font' && (
2804
+ <span className="text-2xl leading-none" style={{ fontFamily: value, fontWeight: weight }}>
2805
+ Aa
2806
+ </span>
2807
+ )}
2808
+ {kind === 'color' && <span className="size-8 rounded-full border border-line" style={{ background: value }} />}
2809
+ </span>
2810
+ );
2811
+ }
2812
+
2813
+ // ---------------------------------------------------------------------------------------------------
2814
+ // The sheet from the bottom
2815
+
2816
+ /** A screen's top bar (Screen.tsx): the theme's top app bar (Material 3's), at the top of the screen while it scrolls,
2817
+ * below a phone's clock, and marked as the theme marks it once the screen has scrolled under it (data-stuck). */
2818
+ export function TopAppBar({ children }: { children: ReactNode }) {
2819
+ const [stuck, setStuck] = useState(false);
2820
+ useEffect(() => {
2821
+ const check = () => setStuck(scrollY > 0);
2822
+ check();
2823
+ addEventListener('scroll', check, { passive: true });
2824
+ return () => removeEventListener('scroll', check);
2825
+ }, []);
2826
+ return (
2827
+ <SiteTopAppBar className="sticky top-0 z-20 pt-[env(safe-area-inset-top)]" data-stuck={stuck || undefined}>
2828
+ {children}
2829
+ </SiteTopAppBar>
2830
+ );
2831
+ }
2832
+
2833
+ /** The theme's dialog (Material 3's), in the middle of the screen over its scrim, on a <dialog> (`ref`; opened with
2834
+ * showModal): a question (ask.tsx). */
2835
+ export function Dialog({ ref, className, children, ...rest }: DialogHTMLAttributes<HTMLDialogElement> & { ref?: Ref<HTMLDialogElement> }) {
2836
+ return (
2837
+ <dialog ref={ref} className={[siteDialogModalClass, className].filter(Boolean).join(' ')} {...rest}>
2838
+ <SiteDialog className="p-6">{children}</SiteDialog>
2839
+ </dialog>
2840
+ );
2841
+ }
2842
+
2843
+ /** The sheet from the bottom (a dialog in the middle on a computer): the theme's bottom sheet, as high as what is in
2844
+ * it, on a <dialog> (`ref`; opened with showModal), with a SheetTitle and SheetBody, SheetList or SheetActions in it.
2845
+ * `full`: as high as the screen, and wider on a computer, with a whole page in it (the preview, PageFrame). */
2846
+ export function BottomSheet({
2847
+ ref,
2848
+ full,
2849
+ className,
2850
+ children,
2851
+ ...rest
2852
+ }: DialogHTMLAttributes<HTMLDialogElement> & { ref?: Ref<HTMLDialogElement>; full?: boolean }) {
2853
+ const sheet = full ? siteBottomSheetFullClass : siteBottomSheetFitClass;
2854
+ return (
2855
+ <dialog ref={ref} className={[sheet, 'editor-sheet', className].filter(Boolean).join(' ')} {...rest}>
2856
+ <div className={siteBottomSheetContainerClass()}>{children}</div>
2857
+ </dialog>
2858
+ );
2859
+ }
2860
+
2861
+ /** 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
2863
+ * in it runs (no scripts), and its links lead nowhere. */
2864
+ export function PageFrame({ html, width, title }: { html: string; width: number; title: string }) {
2865
+ const box = useRef<HTMLDivElement>(null);
2866
+ const [size, setSize] = useState({ width: 0, height: 0 });
2867
+ useEffect(() => {
2868
+ const el = box.current;
2869
+ if (!el) return;
2870
+ const watch = new ResizeObserver(([e]) => setSize({ width: e.contentRect.width, height: e.contentRect.height }));
2871
+ watch.observe(el);
2872
+ return () => watch.disconnect();
2873
+ }, []);
2874
+ const scale = size.width ? Math.min(1, size.width / width) : 1;
2875
+ return (
2876
+ <div ref={box} className="relative min-h-0 flex-1 overflow-hidden border-t border-line bg-line/40">
2877
+ {size.width > 0 && (
2878
+ <iframe
2879
+ title={title}
2880
+ srcDoc={html}
2881
+ sandbox="allow-same-origin"
2882
+ className="absolute top-0 left-1/2 origin-top border-0 bg-white"
2883
+ style={{ width, height: size.height / scale, transform: `translateX(-50%) scale(${scale})` }}
2884
+ />
2885
+ )}
2886
+ </div>
2887
+ );
2888
+ }
2889
+
2890
+ /** The top of a sheet: its title and the cross that closes it. On a phone a handle above it, as in
2891
+ * Google's sheets: the top is dragged up to make the sheet as tall as the screen (as far as there is
2892
+ * more in it) and down to make it small again or close it, fast or far; a tap on the handle makes it
2893
+ * taller or smaller. Its height is the sheet's CSS (`data-tall` in editor.css); while it is dragged,
2894
+ * its height and place follow the finger. */
2895
+ export function SheetTitle({ title, onClose }: { title: string; onClose: () => void }) {
2896
+ const top = useRef<HTMLDivElement>(null);
2897
+ const [tall, setTall] = useState(false);
2898
+ // Where the finger went down and last was, how fast it went (px/ms, down), and the heights.
2899
+ const drag = useRef<{
2900
+ id: number;
2901
+ y: number;
2902
+ last: number;
2903
+ at: number;
2904
+ v: number;
2905
+ from: number;
2906
+ small: number;
2907
+ big: number;
2908
+ moved: boolean;
2909
+ } | null>(null);
2910
+ // A drag on the handle is not a tap.
2911
+ const dragged = useRef(false);
2912
+ const sheetOf = () => top.current?.closest('dialog') ?? null;
2913
+ const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
2914
+ // The sheet as its CSS makes it, small or tall, without what the drag put on it.
2915
+ const settle = (sheet: HTMLDialogElement, big: boolean) => {
2916
+ sheet.toggleAttribute('data-tall', big);
2917
+ sheet.style.removeProperty('height');
2918
+ sheet.style.removeProperty('max-height');
2919
+ sheet.style.removeProperty('transform');
2920
+ sheet.style.removeProperty('transition');
2921
+ setTall(big);
2922
+ };
2923
+ // Small again when it closes (the cross, Fortryd, Esc), so it opens small next time.
2924
+ useEffect(() => {
2925
+ const sheet = sheetOf();
2926
+ if (!sheet) return;
2927
+ const reset = () => settle(sheet, false);
2928
+ sheet.addEventListener('close', reset);
2929
+ return () => sheet.removeEventListener('close', reset);
2930
+ }, []);
2931
+ // How high the sheet is small and tall (each as high as what is in it, if that is less).
2932
+ const heights = (sheet: HTMLDialogElement) => {
2933
+ const was = sheet.hasAttribute('data-tall');
2934
+ sheet.toggleAttribute('data-tall', false);
2935
+ const small = sheet.offsetHeight;
2936
+ sheet.toggleAttribute('data-tall', true);
2937
+ const big = sheet.offsetHeight;
2938
+ sheet.toggleAttribute('data-tall', was);
2939
+ return { small, big, now: was ? big : small };
2940
+ };
2941
+ // Glides from where the finger left it to a height (0: down and closed), then is that.
2942
+ const glide = (sheet: HTMLDialogElement, to: number, small: number, big: number) => {
2943
+ const done = () => (to ? settle(sheet, to === big && big > small) : (onClose(), settle(sheet, false)));
2944
+ if (reduced()) return done();
2945
+ // The site's spatial spring (motionTiming, "Bevægelse").
2946
+ const { duration, easing } = motionTiming('spatial');
2947
+ sheet.style.transition = `height ${duration}ms ${easing}, transform ${duration}ms ${easing}`;
2948
+ sheet.style.height = `${to || small}px`;
2949
+ sheet.style.transform = to ? 'none' : `translateY(${small}px)`;
2950
+ setTimeout(done, duration);
2951
+ };
2952
+ const toggle = () => {
2953
+ const sheet = sheetOf();
2954
+ if (!sheet) return;
2955
+ const { small, big, now } = heights(sheet);
2956
+ if (big <= small) return;
2957
+ sheet.style.maxHeight = 'none';
2958
+ sheet.style.height = `${now}px`;
2959
+ void sheet.offsetHeight;
2960
+ glide(sheet, tall ? small : big, small, big);
2961
+ };
2962
+
2963
+ const down = (e: PointerEvent<HTMLDivElement>) => {
2964
+ // 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;
2966
+ 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
+ dragged.current = false;
2968
+ };
2969
+ const move = (e: PointerEvent<HTMLDivElement>) => {
2970
+ const d = drag.current;
2971
+ const sheet = sheetOf();
2972
+ if (!d || d.id !== e.pointerId || !sheet) return;
2973
+ const dy = e.clientY - d.y;
2974
+ if (!d.moved) {
2975
+ if (Math.abs(dy) < 6) return;
2976
+ const { small, big, now } = heights(sheet);
2977
+ Object.assign(d, { moved: true, from: now, small, big });
2978
+ dragged.current = true;
2979
+ top.current?.setPointerCapture(e.pointerId);
2980
+ sheet.style.transition = 'none';
2981
+ sheet.style.maxHeight = 'none';
2982
+ }
2983
+ d.v = (e.clientY - d.last) / Math.max(1, e.timeStamp - d.at);
2984
+ d.last = e.clientY;
2985
+ d.at = e.timeStamp;
2986
+ const high = d.from - dy;
2987
+ // Taller as far as it goes (a little more, slowly, as if on a rubber band), lower by sliding down.
2988
+ const height = high > d.big ? d.big + (high - d.big) / 4 : Math.max(high, d.small);
2989
+ sheet.style.height = `${height}px`;
2990
+ sheet.style.transform = high < d.small ? `translateY(${d.small - high}px)` : 'none';
2991
+ };
2992
+ const up = (e: PointerEvent<HTMLDivElement>) => {
2993
+ const d = drag.current;
2994
+ drag.current = null;
2995
+ const sheet = sheetOf();
2996
+ if (!d?.moved || d.id !== e.pointerId || !sheet) return;
2997
+ const high = d.from - (e.clientY - d.y);
2998
+ const { small, big } = d;
2999
+ const fast = Math.abs(d.v) > 0.5 && e.timeStamp - d.at < 100;
3000
+ let to: number;
3001
+ if (fast && d.v > 0) to = high > small + 1 ? small : 0;
3002
+ else if (fast) to = big > small ? big : small;
3003
+ else if (high < small) to = high < small * 0.7 ? 0 : small;
3004
+ else to = big > small && high - small > (big - small) / 2 ? big : small;
3005
+ glide(sheet, to, small, big);
3006
+ };
3007
+
3008
+ return (
3009
+ <div
3010
+ ref={top}
3011
+ onPointerDown={down}
3012
+ onPointerMove={move}
3013
+ onPointerUp={up}
3014
+ onPointerCancel={up}
3015
+ className="shrink-0 touch-none select-none md:touch-auto md:select-auto"
3016
+ >
3017
+ <SiteBottomSheetHeader
3018
+ headline={title}
3019
+ closeLabel={t.common.close}
3020
+ handleLabel={tall ? t.common.sheetSmaller : t.common.sheetTaller}
3021
+ onHandle={() => !dragged.current && toggle()}
3022
+ onClose={onClose}
3023
+ />
3024
+ </div>
3025
+ );
3026
+ }
3027
+
3028
+ /** A choice: what it says, and other words it is found by in a search (`words`: another name, a code);
3029
+ * `detail`: shown muted at its right (a country code). */
3030
+ type ChoiceOption<T> = { value: T; label: string; group?: string; lang?: string; words?: string; detail?: string };
3031
+
3032
+ /** The sheet with the choices of a Choice or a ChoiceListItem, the chosen one marked; choices may be in
3033
+ * groups with a heading (the years). A choice closes it. With `search` (what is searched, for screen
3034
+ * readers), a search field over the choices finds them by what they say and their words; it is empty
3035
+ * again when the sheet opens. */
3036
+ function ChoiceSheet<T extends string>({
3037
+ dialog,
3038
+ title,
3039
+ value,
3040
+ options,
3041
+ search,
3042
+ onChange,
3043
+ }: {
3044
+ dialog: RefObject<HTMLDialogElement | null>;
3045
+ title: string;
3046
+ value: T;
3047
+ options: ChoiceOption<T>[];
3048
+ search?: string;
3049
+ onChange: (v: T) => void;
3050
+ }) {
3051
+ const [query, setQuery] = useState('');
3052
+ const close = () => dialog.current?.close();
3053
+ const q = query.trim().toLocaleLowerCase();
3054
+ const shown = q ? options.filter((o) => `${o.label} ${o.words ?? ''}`.toLocaleLowerCase().includes(q)) : options;
3055
+ return (
3056
+ <BottomSheet ref={dialog} aria-label={title} onClick={(e) => e.target === dialog.current && close()} onClose={() => setQuery('')}>
3057
+ <SheetTitle title={title} onClose={close} />
3058
+ {search && <SearchBar value={query} onChange={setQuery} label={search} className="mx-5 mb-2 shrink-0" />}
3059
+ <SheetList>
3060
+ {!shown.length && <EmptyNote item>{t.common.noMatch}</EmptyNote>}
3061
+ {shown.map((o, i) => (
3062
+ <li key={o.value} lang={o.lang}>
3063
+ {o.group && o.group !== shown[i - 1]?.group && <SheetGroupTitle>{o.group}</SheetGroupTitle>}
3064
+ <Option
3065
+ selected={o.value === value}
3066
+ onClick={() => {
3067
+ onChange(o.value);
3068
+ close();
3069
+ }}
3070
+ >
3071
+ {o.detail ? (
3072
+ <span className="flex gap-3">
3073
+ <span className="min-w-0 flex-1">{o.label}</span>
3074
+ <span className="shrink-0 text-muted tabular-nums">{o.detail}</span>
3075
+ </span>
3076
+ ) : (
3077
+ o.label
3078
+ )}
3079
+ </Option>
3080
+ </li>
3081
+ ))}
3082
+ </SheetList>
3083
+ </BottomSheet>
3084
+ );
3085
+ }
3086
+
3087
+ /** An icon that opens a sheet with choices (ChoiceSheet). It is marked itself while it filters (selected), and greyed
3088
+ * out while it cannot be used (`disabled`: the sorting while the pages are shown as the menu). */
3089
+ export function Choice<T extends string>({
3090
+ title,
3091
+ icon,
3092
+ value,
3093
+ selected,
3094
+ disabled,
3095
+ options,
3096
+ search,
3097
+ onChange,
3098
+ }: {
3099
+ title: string;
3100
+ icon: Icon;
3101
+ value: T;
3102
+ selected?: boolean;
3103
+ disabled?: boolean;
3104
+ options: ChoiceOption<T>[];
3105
+ search?: string;
3106
+ onChange: (v: T) => void;
3107
+ }) {
3108
+ const dialog = useRef<HTMLDialogElement>(null);
3109
+ return (
3110
+ <>
3111
+ <IconButton
3112
+ icon={icon}
3113
+ label={title}
3114
+ selected={selected}
3115
+ disabled={disabled}
3116
+ aria-haspopup="dialog"
3117
+ onClick={() => dialog.current?.showModal()}
3118
+ />
3119
+ <ChoiceSheet dialog={dialog} title={title} value={value} options={options} search={search} onChange={onChange} />
3120
+ </>
3121
+ );
3122
+ }
3123
+
3124
+ /** One filter in Filters: its heading, what is chosen and the choices (the first: all), which may be in
3125
+ * groups with a heading (the pages and the tags). */
3126
+ export type FilterGroup = { title: string; value: string; options: ChoiceOption<string>[]; onChange: (v: string) => void };
3127
+
3128
+ /** The funnel, which opens a sheet with all the filters of a list, each a group that folds out from its
3129
+ * heading (with what is chosen in it beside it) to its choices, the chosen one marked. One group is open
3130
+ * at a time, the first when the sheet opens, and the sheet does not get lower when another opens; a lone
3131
+ * group stands open under its heading. A choice is taken at once and the sheet stays open, so another filter can be
3132
+ * chosen too; the cross or a tap outside closes it. The funnel is marked while a filter is chosen (selected). */
3133
+ export function Filters({ title, selected, groups }: { title: string; selected?: boolean; groups: FilterGroup[] }) {
3134
+ const dialog = useRef<HTMLDialogElement>(null);
3135
+ const first = groups[0]?.title ?? '';
3136
+ const [open, setOpen] = useState<string | null>(first);
3137
+ const lone = groups.length === 1;
3138
+ const close = () => dialog.current?.close();
3139
+ function toggle(group: string) {
3140
+ // As high as it has been, so it does not get lower under the finger.
3141
+ const el = dialog.current;
3142
+ if (el) el.style.minHeight = `${Math.max(el.offsetHeight, parseFloat(el.style.minHeight) || 0)}px`;
3143
+ setOpen(open === group ? null : group);
3144
+ }
3145
+ return (
3146
+ <>
3147
+ <IconButton icon={Funnel} label={title} selected={selected} aria-haspopup="dialog" onClick={() => dialog.current?.showModal()} />
3148
+ <BottomSheet
3149
+ ref={dialog}
3150
+ aria-label={title}
3151
+ onClick={(e) => e.target === dialog.current && close()}
3152
+ onClose={(e) => {
3153
+ if (e.target !== e.currentTarget) return;
3154
+ e.currentTarget.style.minHeight = '';
3155
+ setOpen(first);
3156
+ }}
3157
+ >
3158
+ <SheetTitle title={title} onClose={close} />
3159
+ <SheetList div>
3160
+ {groups.map((g) => {
3161
+ const unfolded = lone || open === g.title;
3162
+ return (
3163
+ <section key={g.title}>
3164
+ {lone ? (
3165
+ <SheetGroupTitle>{g.title}</SheetGroupTitle>
3166
+ ) : (
3167
+ <h3>
3168
+ <button
3169
+ type="button"
3170
+ aria-expanded={unfolded}
3171
+ className={`${optionClass()} font-medium`}
3172
+ onClick={() => toggle(g.title)}
3173
+ >
3174
+ <span className="min-w-0 flex-1">{g.title}</span>
3175
+ <span className="min-w-0 truncate font-normal text-muted">{g.options.find((o) => o.value === g.value)?.label}</span>
3176
+ <CaretDown size={16} className={`shrink-0 text-muted transition-transform ${unfolded ? 'rotate-180' : ''}`} />
3177
+ </button>
3178
+ </h3>
3179
+ )}
3180
+ {unfolded && (
3181
+ <ul className={lone ? '' : 'pl-3'}>
3182
+ {g.options.map((o, i) => (
3183
+ <li key={o.value} lang={o.lang}>
3184
+ {o.group && o.group !== g.options[i - 1]?.group && <SheetGroupTitle>{o.group}</SheetGroupTitle>}
3185
+ <Option selected={o.value === g.value} onClick={() => g.onChange(o.value)}>
3186
+ {o.label}
3187
+ </Option>
3188
+ </li>
3189
+ ))}
3190
+ </ul>
3191
+ )}
3192
+ </section>
3193
+ );
3194
+ })}
3195
+ </SheetList>
3196
+ </BottomSheet>
3197
+ </>
3198
+ );
3199
+ }
3200
+
3201
+ /** A phone number with its country code before it: the code, with the country's flag, opens a sheet
3202
+ * with the countries (ChoiceSheet) and a search over them; the number is typed in the field after it
3203
+ * (`children`). The chosen country is marked and scrolled to. */
3204
+ export function PhoneField({ code, onCode, children }: { code: string; onCode: (code: string) => void; children: ReactNode }) {
3205
+ const dialog = useRef<HTMLDialogElement>(null);
3206
+ // The country picked, of those with the code (+1 is both the USA and Canada); the first with it until one is.
3207
+ const [picked, setPicked] = useState<string>();
3208
+ const region = picked && dialCodes[picked] === code ? picked : regionOf(code);
3209
+ const options = useMemo(
3210
+ () => countries().map((c) => ({ value: c.region, label: `${flagOf(c.region)} ${c.name}`, detail: c.code, words: c.words })),
3211
+ [],
3212
+ );
3213
+ const name = region ? countryName(region) : code;
3214
+ return (
3215
+ <div className="flex gap-2">
3216
+ <div className="shrink-0">
3217
+ <ChoiceField
3218
+ aria-haspopup="dialog"
3219
+ aria-label={t.common.phoneCode.label(name, code)}
3220
+ title={name}
3221
+ onClick={() => {
3222
+ dialog.current?.showModal();
3223
+ dialog.current?.querySelector('[aria-pressed="true"]')?.scrollIntoView({ block: 'center' });
3224
+ }}
3225
+ >
3226
+ {region && <span aria-hidden="true">{flagOf(region)}</span>}
3227
+ <span className="tabular-nums">{code}</span>
3228
+ </ChoiceField>
3229
+ </div>
3230
+ <div className="min-w-0 flex-1">{children}</div>
3231
+ <ChoiceSheet
3232
+ dialog={dialog}
3233
+ title={t.common.phoneCode.title}
3234
+ value={region ?? ''}
3235
+ options={options}
3236
+ search={t.common.phoneCode.search}
3237
+ onChange={(r) => {
3238
+ setPicked(r);
3239
+ onCode(dialCodes[r]);
3240
+ }}
3241
+ />
3242
+ </div>
3243
+ );
3244
+ }
3245
+
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
3249
+ * 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
+ export function Select<T extends string>({
3251
+ id,
3252
+ title,
3253
+ value,
3254
+ options,
3255
+ empty,
3256
+ search,
3257
+ href,
3258
+ onChange,
3259
+ ...field
3260
+ }: InField & {
3261
+ id?: string;
3262
+ title: string;
3263
+ value: T;
3264
+ options: ChoiceOption<T>[];
3265
+ empty?: string;
3266
+ search?: string;
3267
+ href?: string;
3268
+ onChange: (v: T) => void;
3269
+ }) {
3270
+ const dialog = useRef<HTMLDialogElement>(null);
3271
+ const chosen = options.find((o) => o.value === value);
3272
+ if (href)
3273
+ return (
3274
+ <ChoiceField id={id} href={href} arrow={CaretRight} {...field}>
3275
+ <span lang={chosen?.lang} className={`min-w-0 flex-1 truncate ${chosen ? '' : 'opacity-60'}`}>
3276
+ {chosen ? chosen.label : empty}
3277
+ </span>
3278
+ </ChoiceField>
3279
+ );
3280
+ return (
3281
+ <>
3282
+ <ChoiceField
3283
+ id={id}
3284
+ {...field}
3285
+ aria-haspopup="dialog"
3286
+ onClick={() => {
3287
+ dialog.current?.showModal();
3288
+ dialog.current?.querySelector('[aria-pressed="true"]')?.scrollIntoView({ block: 'center' });
3289
+ }}
3290
+ >
3291
+ <span lang={chosen?.lang} className={`min-w-0 flex-1 truncate ${chosen ? '' : 'opacity-60'}`}>
3292
+ {chosen ? chosen.label : empty}
3293
+ </span>
3294
+ </ChoiceField>
3295
+ <ChoiceSheet dialog={dialog} title={title} value={value} options={options} search={search} onChange={onChange} />
3296
+ </>
3297
+ );
3298
+ }
3299
+
3300
+ /** A row in a Group with its name and, at the right, what is chosen (muted, `empty` while nothing is), that opens a
3301
+ * sheet to choose in (`onClick`, with `children`: the sheet): ChoiceListItem, the pages of "Standardsider"
3302
+ * (PageListItem in PagePicker.tsx). `ref`: the row, for marking it (WrongField). */
3303
+ export function PickListItem({
3304
+ title,
3305
+ chosen,
3306
+ lang,
3307
+ ref,
3308
+ onClick,
3309
+ children,
3310
+ }: {
3311
+ title: string;
3312
+ chosen?: string;
3313
+ lang?: string;
3314
+ ref?: Ref<HTMLButtonElement>;
3315
+ onClick: () => void;
3316
+ children?: ReactNode;
3317
+ }) {
3318
+ return (
3319
+ <ListItem
3320
+ ref={ref}
3321
+ onClick={onClick}
3322
+ aria-haspopup="dialog"
3323
+ headline={title}
3324
+ trailing={
3325
+ <span lang={lang} className="min-w-0 truncate text-muted">
3326
+ {chosen}
3327
+ </span>
3328
+ }
3329
+ arrow
3330
+ >
3331
+ {children}
3332
+ </ListItem>
3333
+ );
3334
+ }
3335
+
3336
+ /** A row in a Group with its name and what is chosen (PickListItem), that opens a sheet with the choices
3337
+ * (ChoiceSheet), with a search over them when there may be many (`search`): the language. */
3338
+ export function ChoiceListItem<T extends string>({
3339
+ title,
3340
+ value,
3341
+ options,
3342
+ search,
3343
+ onChange,
3344
+ }: {
3345
+ title: string;
3346
+ value: T;
3347
+ options: ChoiceOption<T>[];
3348
+ search?: string;
3349
+ onChange: (v: T) => void;
3350
+ }) {
3351
+ const dialog = useRef<HTMLDialogElement>(null);
3352
+ const chosen = options.find((o) => o.value === value);
3353
+ return (
3354
+ <PickListItem title={title} chosen={chosen?.label} lang={chosen?.lang} onClick={() => dialog.current?.showModal()}>
3355
+ <ChoiceSheet dialog={dialog} title={title} value={value} options={options} search={search} onChange={onChange} />
3356
+ </PickListItem>
3357
+ );
3358
+ }
3359
+
3360
+ /** A new entry of more than one kind (a pop-up: Standard or Mailchimp): the "+" of a list's top bar,
3361
+ * which opens a sheet with the kinds, each with its icon, its name and what it is, as under "Indsæt";
3362
+ * a choice closes it and goes on with that kind. */
3363
+ export function NewChoice<T extends string>({
3364
+ title,
3365
+ options,
3366
+ onChange,
3367
+ }: {
3368
+ title: string;
3369
+ options: { value: T; label: string; hint?: string; icon?: Icon }[];
3370
+ onChange: (v: T) => void;
3371
+ }) {
3372
+ const dialog = useRef<HTMLDialogElement>(null);
3373
+ const close = () => dialog.current?.close();
3374
+ return (
3375
+ <>
3376
+ <IconButton icon={Plus} label={title} aria-haspopup="dialog" onClick={() => dialog.current?.showModal()} />
3377
+ <BottomSheet ref={dialog} aria-label={title} onClick={(e) => e.target === dialog.current && close()}>
3378
+ <SheetTitle title={title} onClose={close} />
3379
+ <SheetList>
3380
+ {options.map((o) => (
3381
+ <li key={o.value}>
3382
+ <button
3383
+ type="button"
3384
+ className={optionClass()}
3385
+ onClick={() => {
3386
+ close();
3387
+ onChange(o.value);
3388
+ }}
3389
+ >
3390
+ <LeadingIcon icon={o.icon} />
3391
+ <span className="min-w-0">
3392
+ <span className="block font-medium">{o.label}</span>
3393
+ {o.hint && <span className="block text-sm text-muted">{o.hint}</span>}
3394
+ </span>
3395
+ </button>
3396
+ </li>
3397
+ ))}
3398
+ </SheetList>
3399
+ </BottomSheet>
3400
+ </>
3401
+ );
3402
+ }