@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.
- package/LICENSE +21 -0
- package/README.md +51 -3
- package/astro.d.mts +2 -0
- package/astro.mjs +162 -0
- package/bin/morpheme.mjs +38 -0
- package/client.d.ts +10 -0
- package/defaults/blockTexts.ts +33 -0
- package/defaults/collections.ts +774 -0
- package/defaults/colors.ts +125 -0
- package/defaults/company.ts +20 -0
- package/defaults/editor.ts +178 -0
- package/defaults/internalLinks.ts +30 -0
- package/defaults/opens.ts +18 -0
- package/defaults/popup.tsx +93 -0
- package/defaults/privacy.ts +79 -0
- package/defaults/rules.ts +450 -0
- package/defaults/schema.ts +16 -0
- package/defaults/sitedata.ts +129 -0
- package/defaults/standard.ts +106 -0
- package/defaults/standardPages.ts +87 -0
- package/defaults/texts.ts +481 -0
- package/demo/README.md +26 -0
- package/demo/public/apple-touch-icon.png +0 -0
- package/demo/public/editor/apple-touch-icon.png +0 -0
- package/demo/public/editor/icon-192.png +0 -0
- package/demo/public/editor/icon-512.png +0 -0
- package/demo/public/editor/icon-maskable-512.png +0 -0
- package/demo/public/favicon.ico +0 -0
- package/demo/public/favicon.svg +1 -0
- package/demo/public/images/uploads/2026/10/andromeda.webp +0 -0
- package/demo/public/images/uploads/2026/10/earth-from-space.webp +0 -0
- package/demo/public/images/uploads/2026/10/editor-blog.webp +0 -0
- package/demo/public/images/uploads/2026/10/editor-colors.webp +0 -0
- package/demo/public/images/uploads/2026/10/editor-home.webp +0 -0
- package/demo/public/images/uploads/2026/10/editor-page.webp +0 -0
- package/demo/public/images/uploads/2026/10/editor-pages.webp +0 -0
- package/demo/public/images/uploads/2026/10/editor-seo.webp +0 -0
- package/demo/public/images/uploads/2026/10/editor-start.webp +0 -0
- package/demo/public/images/uploads/2026/10/editor-themes.webp +0 -0
- package/demo/public/images/uploads/2026/10/moon.webp +0 -0
- package/demo/public/images/uploads/2026/10/saturn.webp +0 -0
- package/demo/public/logo.svg +1 -0
- package/demo/src/content/blog/build-a-page-from-blocks.md +19 -0
- package/demo/src/content/blog/colors-everyone-can-read.md +19 -0
- package/demo/src/content/blog/edit-your-website-from-your-phone.md +21 -0
- package/demo/src/content/blog/every-save-is-a-version.md +21 -0
- package/demo/src/content/blog/schedule-a-post.md +17 -0
- package/demo/src/content/blog/secure-from-day-one.md +17 -0
- package/demo/src/content/blog/seo-without-the-guesswork.md +23 -0
- package/demo/src/content/blog/switch-themes-without-losing-anything.md +23 -0
- package/demo/src/content/blog/the-app-store.md +21 -0
- package/demo/src/content/pages/about.md +22 -0
- package/demo/src/content/pages/app-store.md +30 -0
- package/demo/src/content/pages/blocks.md +98 -0
- package/demo/src/content/pages/blog-page.md +11 -0
- package/demo/src/content/pages/contact.md +16 -0
- package/demo/src/content/pages/get-started.md +20 -0
- package/demo/src/content/pages/home.md +26 -0
- package/demo/src/content/pages/page-not-found.md +11 -0
- package/demo/src/content/pages/privacy-policy.md +24 -0
- package/demo/src/data/blocks.json +25 -0
- package/demo/src/data/booking.json +6 -0
- package/demo/src/data/categories/apps.json +6 -0
- package/demo/src/data/categories/design.json +6 -0
- package/demo/src/data/categories/editing.json +6 -0
- package/demo/src/data/categories/security.json +6 -0
- package/demo/src/data/categories/seo.json +6 -0
- package/demo/src/data/colors.json +75 -0
- package/demo/src/data/contact.json +8 -0
- package/demo/src/data/cookies.json +21 -0
- package/demo/src/data/footer.json +27 -0
- package/demo/src/data/menu.json +35 -0
- package/demo/src/data/mobile-menu.json +25 -0
- package/demo/src/data/popups/booking-bar.json +13 -0
- package/demo/src/data/popups/try-the-editor.json +16 -0
- package/demo/src/data/standard-pages.json +8 -0
- package/demo/src/data/testimonials/sample-bakery.json +10 -0
- package/demo/src/data/testimonials/sample-clinic.json +10 -0
- package/demo/src/data/testimonials/sample-studio.json +10 -0
- package/demo/src/site/colors.ts +11 -0
- package/demo/src/site/config.ts +19 -0
- package/icons/lucide/LICENSE +43 -0
- package/icons/lucide/LICENSE-material-design-icons +20 -0
- package/icons/lucide/address-book.svg +1 -0
- package/icons/lucide/app-window.svg +1 -0
- package/icons/lucide/arrow-counter-clockwise.svg +1 -0
- package/icons/lucide/arrow-down.svg +1 -0
- package/icons/lucide/arrow-left.svg +1 -0
- package/icons/lucide/arrow-right.svg +1 -0
- package/icons/lucide/arrow-square-out.svg +1 -0
- package/icons/lucide/arrow-u-up-left.svg +1 -0
- package/icons/lucide/arrow-u-up-right.svg +1 -0
- package/icons/lucide/arrow-up-right.svg +1 -0
- package/icons/lucide/arrow-up.svg +1 -0
- package/icons/lucide/arrows-down-up.svg +1 -0
- package/icons/lucide/arrows-in-line-horizontal.svg +1 -0
- package/icons/lucide/arrows-out-line-horizontal.svg +1 -0
- package/icons/lucide/article.svg +1 -0
- package/icons/lucide/bank.svg +1 -0
- package/icons/lucide/bell.svg +1 -0
- package/icons/lucide/bicycle.svg +1 -0
- package/icons/lucide/binoculars.svg +1 -0
- package/icons/lucide/book-open-text.svg +1 -0
- package/icons/lucide/book-open.svg +1 -0
- package/icons/lucide/browser.svg +1 -0
- package/icons/lucide/buildings.svg +1 -0
- package/icons/lucide/bus.svg +1 -0
- package/icons/lucide/calendar-blank.svg +1 -0
- package/icons/lucide/calendar-check.svg +1 -0
- package/icons/lucide/car.svg +1 -0
- package/icons/lucide/cards.svg +1 -0
- package/icons/lucide/caret-down.svg +1 -0
- package/icons/lucide/caret-left.svg +1 -0
- package/icons/lucide/caret-right.svg +1 -0
- package/icons/lucide/caret-up.svg +1 -0
- package/icons/lucide/certificate.svg +1 -0
- package/icons/lucide/chart-line-up.svg +1 -0
- package/icons/lucide/chats.svg +1 -0
- package/icons/lucide/check-circle.svg +1 -0
- package/icons/lucide/check.svg +1 -0
- package/icons/lucide/circle-half.svg +1 -0
- package/icons/lucide/circle.svg +1 -0
- package/icons/lucide/clock-counter-clockwise.svg +1 -0
- package/icons/lucide/clock.svg +1 -0
- package/icons/lucide/code.svg +1 -0
- package/icons/lucide/coffee.svg +1 -0
- package/icons/lucide/columns.svg +1 -0
- package/icons/lucide/cookie.svg +1 -0
- package/icons/lucide/copy.svg +1 -0
- package/icons/lucide/credit-card.svg +1 -0
- package/icons/lucide/currency-circle-dollar.svg +1 -0
- package/icons/lucide/cursor-click.svg +1 -0
- package/icons/lucide/database.svg +1 -0
- package/icons/lucide/desktop.svg +1 -0
- package/icons/lucide/device-mobile.svg +1 -0
- package/icons/lucide/door-open.svg +1 -0
- package/icons/lucide/download-simple.svg +1 -0
- package/icons/lucide/envelope-simple.svg +1 -0
- package/icons/lucide/eye-slash.svg +1 -0
- package/icons/lucide/eye.svg +1 -0
- package/icons/lucide/file-pdf.svg +1 -0
- package/icons/lucide/file-text.svg +1 -0
- package/icons/lucide/file-video.svg +1 -0
- package/icons/lucide/file-zip.svg +1 -0
- package/icons/lucide/flower-lotus.svg +1 -0
- package/icons/lucide/folder-simple.svg +1 -0
- package/icons/lucide/funnel.svg +1 -0
- package/icons/lucide/gauge.svg +1 -0
- package/icons/lucide/gear.svg +1 -0
- package/icons/lucide/github-logo.svg +1 -0
- package/icons/lucide/globe.svg +1 -0
- package/icons/lucide/google-logo.svg +1 -0
- package/icons/lucide/graduation-cap.svg +1 -0
- package/icons/lucide/hammer.svg +1 -0
- package/icons/lucide/hand-heart.svg +1 -0
- package/icons/lucide/handshake.svg +1 -0
- package/icons/lucide/hard-drives.svg +1 -0
- package/icons/lucide/heart.svg +1 -0
- package/icons/lucide/house.svg +1 -0
- package/icons/lucide/image-square.svg +1 -0
- package/icons/lucide/image.svg +1 -0
- package/icons/lucide/info.svg +1 -0
- package/icons/lucide/keyboard.svg +1 -0
- package/icons/lucide/leaf.svg +1 -0
- package/icons/lucide/lifebuoy.svg +1 -0
- package/icons/lucide/lightbulb.svg +1 -0
- package/icons/lucide/link-break.svg +1 -0
- package/icons/lucide/link-simple.svg +1 -0
- package/icons/lucide/list-bullets.svg +1 -0
- package/icons/lucide/list-checks.svg +1 -0
- package/icons/lucide/list-numbers.svg +1 -0
- package/icons/lucide/list.svg +1 -0
- package/icons/lucide/lock-simple-open.svg +1 -0
- package/icons/lucide/lock-simple.svg +1 -0
- package/icons/lucide/magnifying-glass.svg +1 -0
- package/icons/lucide/map-pin.svg +1 -0
- package/icons/lucide/map-trifold.svg +1 -0
- package/icons/lucide/medal.svg +1 -0
- package/icons/lucide/megaphone.svg +1 -0
- package/icons/lucide/minus.svg +1 -0
- package/icons/lucide/moon.svg +1 -0
- package/icons/lucide/newspaper.svg +1 -0
- package/icons/lucide/pack.json +23 -0
- package/icons/lucide/paint-brush-broad.svg +1 -0
- package/icons/lucide/palette.svg +1 -0
- package/icons/lucide/paper-plane-tilt.svg +1 -0
- package/icons/lucide/pencil-simple.svg +1 -0
- package/icons/lucide/phone.svg +1 -0
- package/icons/lucide/play-fill.svg +1 -0
- package/icons/lucide/plus.svg +1 -0
- package/icons/lucide/prohibit.svg +1 -0
- package/icons/lucide/puzzle-piece.svg +1 -0
- package/icons/lucide/question.svg +1 -0
- package/icons/lucide/quotes.svg +1 -0
- package/icons/lucide/rows.svg +1 -0
- package/icons/lucide/ruler.svg +1 -0
- package/icons/lucide/scan.svg +1 -0
- package/icons/lucide/shapes.svg +1 -0
- package/icons/lucide/share-network.svg +1 -0
- package/icons/lucide/shield-check.svg +1 -0
- package/icons/lucide/sidebar-simple.svg +1 -0
- package/icons/lucide/sign-out.svg +1 -0
- package/icons/lucide/signpost.svg +1 -0
- package/icons/lucide/sliders-horizontal.svg +1 -0
- package/icons/lucide/smiley.svg +1 -0
- package/icons/lucide/sparkle.svg +1 -0
- package/icons/lucide/square-half-bottom.svg +1 -0
- package/icons/lucide/square.svg +1 -0
- package/icons/lucide/squares-four.svg +1 -0
- package/icons/lucide/sticker.svg +1 -0
- package/icons/lucide/storefront.svg +1 -0
- package/icons/lucide/sun.svg +1 -0
- package/icons/lucide/swatches.svg +1 -0
- package/icons/lucide/tag.svg +1 -0
- package/icons/lucide/text-aa.svg +1 -0
- package/icons/lucide/text-align-left.svg +1 -0
- package/icons/lucide/text-b.svg +1 -0
- package/icons/lucide/text-h-one.svg +1 -0
- package/icons/lucide/text-h-three.svg +1 -0
- package/icons/lucide/text-h-two.svg +1 -0
- package/icons/lucide/text-italic.svg +1 -0
- package/icons/lucide/text-t.svg +1 -0
- package/icons/lucide/ticket.svg +1 -0
- package/icons/lucide/toggle-right.svg +1 -0
- package/icons/lucide/train.svg +1 -0
- package/icons/lucide/trash.svg +1 -0
- package/icons/lucide/upload-simple.svg +1 -0
- package/icons/lucide/user-circle.svg +1 -0
- package/icons/lucide/user.svg +1 -0
- package/icons/lucide/users-three.svg +1 -0
- package/icons/lucide/users.svg +1 -0
- package/icons/lucide/video-camera.svg +1 -0
- package/icons/lucide/warning-circle.svg +1 -0
- package/icons/lucide/warning.svg +1 -0
- package/icons/lucide/wave-sine.svg +1 -0
- package/icons/lucide/wheelchair.svg +1 -0
- package/icons/lucide/wrench.svg +1 -0
- package/icons/lucide/x.svg +1 -0
- package/icons/material-symbols/LICENSE +201 -0
- package/icons/material-symbols/LICENSE-material-design-icons +20 -0
- package/icons/material-symbols/address-book.svg +1 -0
- package/icons/material-symbols/app-window.svg +1 -0
- package/icons/material-symbols/arrow-counter-clockwise.svg +1 -0
- package/icons/material-symbols/arrow-down.svg +1 -0
- package/icons/material-symbols/arrow-left.svg +1 -0
- package/icons/material-symbols/arrow-right.svg +1 -0
- package/icons/material-symbols/arrow-square-out.svg +1 -0
- package/icons/material-symbols/arrow-u-up-left.svg +1 -0
- package/icons/material-symbols/arrow-u-up-right.svg +1 -0
- package/icons/material-symbols/arrow-up-right.svg +1 -0
- package/icons/material-symbols/arrow-up.svg +1 -0
- package/icons/material-symbols/arrows-down-up.svg +1 -0
- package/icons/material-symbols/arrows-in-line-horizontal.svg +1 -0
- package/icons/material-symbols/arrows-out-line-horizontal.svg +1 -0
- package/icons/material-symbols/article.svg +1 -0
- package/icons/material-symbols/bank.svg +1 -0
- package/icons/material-symbols/bell.svg +1 -0
- package/icons/material-symbols/bicycle.svg +1 -0
- package/icons/material-symbols/binoculars.svg +1 -0
- package/icons/material-symbols/book-open-text.svg +1 -0
- package/icons/material-symbols/book-open.svg +1 -0
- package/icons/material-symbols/browser.svg +1 -0
- package/icons/material-symbols/buildings.svg +1 -0
- package/icons/material-symbols/bus.svg +1 -0
- package/icons/material-symbols/calendar-blank.svg +1 -0
- package/icons/material-symbols/calendar-check.svg +1 -0
- package/icons/material-symbols/car.svg +1 -0
- package/icons/material-symbols/cards.svg +1 -0
- package/icons/material-symbols/caret-down.svg +1 -0
- package/icons/material-symbols/caret-left.svg +1 -0
- package/icons/material-symbols/caret-right.svg +1 -0
- package/icons/material-symbols/caret-up.svg +1 -0
- package/icons/material-symbols/certificate.svg +1 -0
- package/icons/material-symbols/chart-line-up.svg +1 -0
- package/icons/material-symbols/chats.svg +1 -0
- package/icons/material-symbols/check-circle.svg +1 -0
- package/icons/material-symbols/check.svg +1 -0
- package/icons/material-symbols/circle-half.svg +1 -0
- package/icons/material-symbols/circle.svg +1 -0
- package/icons/material-symbols/clock-counter-clockwise.svg +1 -0
- package/icons/material-symbols/clock.svg +1 -0
- package/icons/material-symbols/code.svg +1 -0
- package/icons/material-symbols/coffee.svg +1 -0
- package/icons/material-symbols/columns.svg +1 -0
- package/icons/material-symbols/cookie.svg +1 -0
- package/icons/material-symbols/copy.svg +1 -0
- package/icons/material-symbols/credit-card.svg +1 -0
- package/icons/material-symbols/currency-circle-dollar.svg +1 -0
- package/icons/material-symbols/cursor-click.svg +1 -0
- package/icons/material-symbols/database.svg +1 -0
- package/icons/material-symbols/desktop.svg +1 -0
- package/icons/material-symbols/device-mobile.svg +1 -0
- package/icons/material-symbols/door-open.svg +1 -0
- package/icons/material-symbols/download-simple.svg +1 -0
- package/icons/material-symbols/envelope-simple.svg +1 -0
- package/icons/material-symbols/eye-slash.svg +1 -0
- package/icons/material-symbols/eye.svg +1 -0
- package/icons/material-symbols/file-pdf.svg +1 -0
- package/icons/material-symbols/file-text.svg +1 -0
- package/icons/material-symbols/file-video.svg +1 -0
- package/icons/material-symbols/file-zip.svg +1 -0
- package/icons/material-symbols/flower-lotus.svg +1 -0
- package/icons/material-symbols/folder-simple.svg +1 -0
- package/icons/material-symbols/funnel.svg +1 -0
- package/icons/material-symbols/gauge.svg +1 -0
- package/icons/material-symbols/gear.svg +1 -0
- package/icons/material-symbols/github-logo.svg +1 -0
- package/icons/material-symbols/globe.svg +1 -0
- package/icons/material-symbols/google-logo.svg +1 -0
- package/icons/material-symbols/graduation-cap.svg +1 -0
- package/icons/material-symbols/hammer.svg +1 -0
- package/icons/material-symbols/hand-heart.svg +1 -0
- package/icons/material-symbols/handshake.svg +1 -0
- package/icons/material-symbols/hard-drives.svg +1 -0
- package/icons/material-symbols/heart.svg +1 -0
- package/icons/material-symbols/house.svg +1 -0
- package/icons/material-symbols/image-square.svg +1 -0
- package/icons/material-symbols/image.svg +1 -0
- package/icons/material-symbols/info.svg +1 -0
- package/icons/material-symbols/keyboard.svg +1 -0
- package/icons/material-symbols/leaf.svg +1 -0
- package/icons/material-symbols/lifebuoy.svg +1 -0
- package/icons/material-symbols/lightbulb.svg +1 -0
- package/icons/material-symbols/link-break.svg +1 -0
- package/icons/material-symbols/link-simple.svg +1 -0
- package/icons/material-symbols/list-bullets.svg +1 -0
- package/icons/material-symbols/list-checks.svg +1 -0
- package/icons/material-symbols/list-numbers.svg +1 -0
- package/icons/material-symbols/list.svg +1 -0
- package/icons/material-symbols/lock-simple-open.svg +1 -0
- package/icons/material-symbols/lock-simple.svg +1 -0
- package/icons/material-symbols/magnifying-glass.svg +1 -0
- package/icons/material-symbols/map-pin.svg +1 -0
- package/icons/material-symbols/map-trifold.svg +1 -0
- package/icons/material-symbols/medal.svg +1 -0
- package/icons/material-symbols/megaphone.svg +1 -0
- package/icons/material-symbols/minus.svg +1 -0
- package/icons/material-symbols/moon.svg +1 -0
- package/icons/material-symbols/newspaper.svg +1 -0
- package/icons/material-symbols/pack.json +29 -0
- package/icons/material-symbols/paint-brush-broad.svg +1 -0
- package/icons/material-symbols/palette.svg +1 -0
- package/icons/material-symbols/paper-plane-tilt.svg +1 -0
- package/icons/material-symbols/pencil-simple.svg +1 -0
- package/icons/material-symbols/phone.svg +1 -0
- package/icons/material-symbols/play-fill.svg +1 -0
- package/icons/material-symbols/plus.svg +1 -0
- package/icons/material-symbols/prohibit.svg +1 -0
- package/icons/material-symbols/puzzle-piece.svg +1 -0
- package/icons/material-symbols/question.svg +1 -0
- package/icons/material-symbols/quotes.svg +1 -0
- package/icons/material-symbols/rows.svg +1 -0
- package/icons/material-symbols/ruler.svg +1 -0
- package/icons/material-symbols/scan.svg +1 -0
- package/icons/material-symbols/shapes.svg +1 -0
- package/icons/material-symbols/share-network.svg +1 -0
- package/icons/material-symbols/shield-check.svg +1 -0
- package/icons/material-symbols/sidebar-simple.svg +1 -0
- package/icons/material-symbols/sign-out.svg +1 -0
- package/icons/material-symbols/signpost.svg +1 -0
- package/icons/material-symbols/sliders-horizontal.svg +1 -0
- package/icons/material-symbols/smiley.svg +1 -0
- package/icons/material-symbols/sparkle.svg +1 -0
- package/icons/material-symbols/square-half-bottom.svg +1 -0
- package/icons/material-symbols/square.svg +1 -0
- package/icons/material-symbols/squares-four.svg +1 -0
- package/icons/material-symbols/sticker.svg +1 -0
- package/icons/material-symbols/storefront.svg +1 -0
- package/icons/material-symbols/sun.svg +1 -0
- package/icons/material-symbols/swatches.svg +1 -0
- package/icons/material-symbols/tag.svg +1 -0
- package/icons/material-symbols/text-aa.svg +1 -0
- package/icons/material-symbols/text-align-left.svg +1 -0
- package/icons/material-symbols/text-b.svg +1 -0
- package/icons/material-symbols/text-h-one.svg +1 -0
- package/icons/material-symbols/text-h-three.svg +1 -0
- package/icons/material-symbols/text-h-two.svg +1 -0
- package/icons/material-symbols/text-italic.svg +1 -0
- package/icons/material-symbols/text-t.svg +1 -0
- package/icons/material-symbols/ticket.svg +1 -0
- package/icons/material-symbols/toggle-right.svg +1 -0
- package/icons/material-symbols/train.svg +1 -0
- package/icons/material-symbols/trash.svg +1 -0
- package/icons/material-symbols/upload-simple.svg +1 -0
- package/icons/material-symbols/user-circle.svg +1 -0
- package/icons/material-symbols/user.svg +1 -0
- package/icons/material-symbols/users-three.svg +1 -0
- package/icons/material-symbols/users.svg +1 -0
- package/icons/material-symbols/video-camera.svg +1 -0
- package/icons/material-symbols/warning-circle.svg +1 -0
- package/icons/material-symbols/warning.svg +1 -0
- package/icons/material-symbols/wave-sine.svg +1 -0
- package/icons/material-symbols/wheelchair.svg +1 -0
- package/icons/material-symbols/wrench.svg +1 -0
- package/icons/material-symbols/x.svg +1 -0
- package/icons/phosphor/LICENSE +21 -0
- package/icons/phosphor/address-book.svg +1 -0
- package/icons/phosphor/app-window.svg +1 -0
- package/icons/phosphor/arrow-counter-clockwise.svg +1 -0
- package/icons/phosphor/arrow-down.svg +1 -0
- package/icons/phosphor/arrow-left.svg +1 -0
- package/icons/phosphor/arrow-right.svg +1 -0
- package/icons/phosphor/arrow-square-out.svg +1 -0
- package/icons/phosphor/arrow-u-up-left.svg +1 -0
- package/icons/phosphor/arrow-u-up-right.svg +1 -0
- package/icons/phosphor/arrow-up-right.svg +1 -0
- package/icons/phosphor/arrow-up.svg +1 -0
- package/icons/phosphor/arrows-down-up.svg +1 -0
- package/icons/phosphor/arrows-in-line-horizontal.svg +1 -0
- package/icons/phosphor/arrows-out-line-horizontal.svg +1 -0
- package/icons/phosphor/article.svg +1 -0
- package/icons/phosphor/bank.svg +1 -0
- package/icons/phosphor/bell.svg +1 -0
- package/icons/phosphor/bicycle.svg +1 -0
- package/icons/phosphor/binoculars.svg +1 -0
- package/icons/phosphor/book-open-text.svg +1 -0
- package/icons/phosphor/book-open.svg +1 -0
- package/icons/phosphor/browser.svg +1 -0
- package/icons/phosphor/buildings.svg +1 -0
- package/icons/phosphor/bus.svg +1 -0
- package/icons/phosphor/calendar-blank.svg +1 -0
- package/icons/phosphor/calendar-check.svg +1 -0
- package/icons/phosphor/car.svg +1 -0
- package/icons/phosphor/cards.svg +1 -0
- package/icons/phosphor/caret-down.svg +1 -0
- package/icons/phosphor/caret-left.svg +1 -0
- package/icons/phosphor/caret-right.svg +1 -0
- package/icons/phosphor/caret-up.svg +1 -0
- package/icons/phosphor/certificate.svg +1 -0
- package/icons/phosphor/chart-line-up.svg +1 -0
- package/icons/phosphor/chats.svg +1 -0
- package/icons/phosphor/check-circle.svg +1 -0
- package/icons/phosphor/check.svg +1 -0
- package/icons/phosphor/circle-half.svg +1 -0
- package/icons/phosphor/circle.svg +1 -0
- package/icons/phosphor/clock-counter-clockwise.svg +1 -0
- package/icons/phosphor/clock.svg +1 -0
- package/icons/phosphor/code.svg +1 -0
- package/icons/phosphor/coffee.svg +1 -0
- package/icons/phosphor/columns.svg +1 -0
- package/icons/phosphor/cookie.svg +1 -0
- package/icons/phosphor/copy.svg +1 -0
- package/icons/phosphor/credit-card.svg +1 -0
- package/icons/phosphor/currency-circle-dollar.svg +1 -0
- package/icons/phosphor/cursor-click.svg +1 -0
- package/icons/phosphor/database.svg +1 -0
- package/icons/phosphor/desktop.svg +1 -0
- package/icons/phosphor/device-mobile.svg +1 -0
- package/icons/phosphor/door-open.svg +1 -0
- package/icons/phosphor/download-simple.svg +1 -0
- package/icons/phosphor/envelope-simple.svg +1 -0
- package/icons/phosphor/eye-slash.svg +1 -0
- package/icons/phosphor/eye.svg +1 -0
- package/icons/phosphor/file-pdf.svg +1 -0
- package/icons/phosphor/file-text.svg +1 -0
- package/icons/phosphor/file-video.svg +1 -0
- package/icons/phosphor/file-zip.svg +1 -0
- package/icons/phosphor/flower-lotus.svg +1 -0
- package/icons/phosphor/folder-simple.svg +1 -0
- package/icons/phosphor/funnel.svg +1 -0
- package/icons/phosphor/gauge.svg +1 -0
- package/icons/phosphor/gear.svg +1 -0
- package/icons/phosphor/github-logo.svg +1 -0
- package/icons/phosphor/globe.svg +1 -0
- package/icons/phosphor/google-logo.svg +1 -0
- package/icons/phosphor/graduation-cap.svg +1 -0
- package/icons/phosphor/hammer.svg +1 -0
- package/icons/phosphor/hand-heart.svg +1 -0
- package/icons/phosphor/handshake.svg +1 -0
- package/icons/phosphor/hard-drives.svg +1 -0
- package/icons/phosphor/heart.svg +1 -0
- package/icons/phosphor/house.svg +1 -0
- package/icons/phosphor/image-square.svg +1 -0
- package/icons/phosphor/image.svg +1 -0
- package/icons/phosphor/info.svg +1 -0
- package/icons/phosphor/keyboard.svg +1 -0
- package/icons/phosphor/leaf.svg +1 -0
- package/icons/phosphor/lifebuoy.svg +1 -0
- package/icons/phosphor/lightbulb.svg +1 -0
- package/icons/phosphor/link-break.svg +1 -0
- package/icons/phosphor/link-simple.svg +1 -0
- package/icons/phosphor/list-bullets.svg +1 -0
- package/icons/phosphor/list-checks.svg +1 -0
- package/icons/phosphor/list-numbers.svg +1 -0
- package/icons/phosphor/list.svg +1 -0
- package/icons/phosphor/lock-simple-open.svg +1 -0
- package/icons/phosphor/lock-simple.svg +1 -0
- package/icons/phosphor/magnifying-glass.svg +1 -0
- package/icons/phosphor/map-pin.svg +1 -0
- package/icons/phosphor/map-trifold.svg +1 -0
- package/icons/phosphor/medal.svg +1 -0
- package/icons/phosphor/megaphone.svg +1 -0
- package/icons/phosphor/minus.svg +1 -0
- package/icons/phosphor/moon.svg +1 -0
- package/icons/phosphor/newspaper.svg +1 -0
- package/icons/phosphor/pack.json +8 -0
- package/icons/phosphor/paint-brush-broad.svg +1 -0
- package/icons/phosphor/palette.svg +1 -0
- package/icons/phosphor/paper-plane-tilt.svg +1 -0
- package/icons/phosphor/pencil-simple.svg +1 -0
- package/icons/phosphor/phone.svg +1 -0
- package/icons/phosphor/play-fill.svg +1 -0
- package/icons/phosphor/plus.svg +1 -0
- package/icons/phosphor/prohibit.svg +1 -0
- package/icons/phosphor/puzzle-piece.svg +1 -0
- package/icons/phosphor/question.svg +1 -0
- package/icons/phosphor/quotes.svg +1 -0
- package/icons/phosphor/rows.svg +1 -0
- package/icons/phosphor/ruler.svg +1 -0
- package/icons/phosphor/scan.svg +1 -0
- package/icons/phosphor/shapes.svg +1 -0
- package/icons/phosphor/share-network.svg +1 -0
- package/icons/phosphor/shield-check.svg +1 -0
- package/icons/phosphor/sidebar-simple.svg +1 -0
- package/icons/phosphor/sign-out.svg +1 -0
- package/icons/phosphor/signpost.svg +1 -0
- package/icons/phosphor/sliders-horizontal.svg +1 -0
- package/icons/phosphor/smiley.svg +1 -0
- package/icons/phosphor/sparkle.svg +1 -0
- package/icons/phosphor/square-half-bottom.svg +1 -0
- package/icons/phosphor/square.svg +1 -0
- package/icons/phosphor/squares-four.svg +1 -0
- package/icons/phosphor/sticker.svg +1 -0
- package/icons/phosphor/storefront.svg +1 -0
- package/icons/phosphor/sun.svg +1 -0
- package/icons/phosphor/swatches.svg +1 -0
- package/icons/phosphor/tag.svg +1 -0
- package/icons/phosphor/text-aa.svg +1 -0
- package/icons/phosphor/text-align-left.svg +1 -0
- package/icons/phosphor/text-b.svg +1 -0
- package/icons/phosphor/text-h-one.svg +1 -0
- package/icons/phosphor/text-h-three.svg +1 -0
- package/icons/phosphor/text-h-two.svg +1 -0
- package/icons/phosphor/text-italic.svg +1 -0
- package/icons/phosphor/text-t.svg +1 -0
- package/icons/phosphor/ticket.svg +1 -0
- package/icons/phosphor/toggle-right.svg +1 -0
- package/icons/phosphor/train.svg +1 -0
- package/icons/phosphor/trash.svg +1 -0
- package/icons/phosphor/upload-simple.svg +1 -0
- package/icons/phosphor/user-circle.svg +1 -0
- package/icons/phosphor/user.svg +1 -0
- package/icons/phosphor/users-three.svg +1 -0
- package/icons/phosphor/users.svg +1 -0
- package/icons/phosphor/video-camera.svg +1 -0
- package/icons/phosphor/warning-circle.svg +1 -0
- package/icons/phosphor/warning.svg +1 -0
- package/icons/phosphor/wave-sine.svg +1 -0
- package/icons/phosphor/wheelchair.svg +1 -0
- package/icons/phosphor/wrench.svg +1 -0
- package/icons/phosphor/x.svg +1 -0
- package/lib/apps.d.mts +28 -0
- package/lib/apps.mjs +139 -0
- package/lib/blocks.d.mts +23 -0
- package/lib/blocks.mjs +90 -0
- package/lib/colors.ts +86 -0
- package/lib/contactBar.ts +18 -0
- package/lib/cookieCategories.d.mts +17 -0
- package/lib/cookieCategories.mjs +28 -0
- package/lib/crop.d.mts +34 -0
- package/lib/crop.mjs +83 -0
- package/lib/csp.d.mts +22 -0
- package/lib/csp.mjs +130 -0
- package/lib/cspSources.d.mts +24 -0
- package/lib/cspSources.mjs +60 -0
- package/lib/dynamic-colors.ts +426 -0
- package/lib/git.d.mts +24 -0
- package/lib/git.mjs +64 -0
- package/lib/hooks.ts +51 -0
- package/lib/inline.ts +20 -0
- package/lib/license-texts/Apache-2.0.txt +191 -0
- package/lib/license-texts/BSD-2-Clause.txt +24 -0
- package/lib/license-texts/BSD-3-Clause.txt +28 -0
- package/lib/license-texts/ISC.txt +15 -0
- package/lib/license-texts/MIT.txt +21 -0
- package/lib/licenses.d.mts +190 -0
- package/lib/licenses.mjs +683 -0
- package/lib/markdown.ts +42 -0
- package/lib/motion-schemes.d.mts +2 -0
- package/lib/motion-schemes.mjs +5 -0
- package/lib/motion.ts +131 -0
- package/lib/notice.d.mts +3 -0
- package/lib/notice.mjs +34 -0
- package/lib/scheduled.d.mts +15 -0
- package/lib/scheduled.mjs +37 -0
- package/lib/snackbar.ts +149 -0
- package/lib/status.d.mts +7 -0
- package/lib/status.mjs +18 -0
- package/lib/svg.d.mts +22 -0
- package/lib/svg.mjs +231 -0
- package/lib/text-colors.d.mts +11 -0
- package/lib/text-colors.mjs +43 -0
- package/lib/video-id.d.mts +14 -0
- package/lib/video-id.mjs +72 -0
- package/lib/video.d.mts +20 -0
- package/lib/video.mjs +91 -0
- package/package.json +131 -6
- package/pages/404.astro +10 -0
- package/pages/[...slug].astro +51 -0
- package/pages/_headers.txt +31 -0
- package/pages/blog/index.astro +10 -0
- package/pages/blog/kategori/[category].astro +18 -0
- package/pages/editor/code-images.json.js +25 -0
- package/pages/editor/index.astro +75 -0
- package/pages/editor/manifest.webmanifest.js +23 -0
- package/pages/editor/sw.js +33 -0
- package/pages/editor/sw.js.js +4 -0
- package/pages/editor/thumbnails.json.js +21 -0
- package/pages/editor/version.json.js +22 -0
- package/pages/headers.js +4 -0
- package/pages/index.astro +10 -0
- package/pages/ny.astro +6 -0
- package/pages/robots.txt.js +3 -0
- package/pages/rss.xml.js +20 -0
- package/scripts/app-screenshots.mjs +164 -0
- package/scripts/app-workers.mjs +6 -0
- package/scripts/bin.mjs +12 -0
- package/scripts/built-site.mjs +99 -0
- package/scripts/check-consent.mjs +113 -0
- package/scripts/check-content.mjs +149 -0
- package/scripts/check-demo.mjs +24 -0
- package/scripts/check-themes.mjs +45 -0
- package/scripts/check-worker.mjs +569 -0
- package/scripts/compare-html.mjs +81 -0
- package/scripts/demo-site.mjs +42 -0
- package/scripts/editor-icons.mjs +56 -0
- package/scripts/editor-roundtrip.mjs +134 -0
- package/scripts/editor-screens.mjs +101 -0
- package/scripts/git-env.mjs +9 -0
- package/scripts/icon-packs.mjs +151 -0
- package/scripts/link-counts.mjs +22 -0
- package/scripts/page-colors.mjs +42 -0
- package/scripts/releases.d.mts +10 -0
- package/scripts/releases.mjs +80 -0
- package/scripts/responsive-images.mjs +94 -0
- package/scripts/scheduled-list.mjs +15 -0
- package/scripts/theme-screenshots.mjs +88 -0
- package/scripts/types.mjs +21 -0
- package/scripts/video-thumbnails.mjs +89 -0
- package/scripts/worker-modules.mjs +34 -0
- package/site/appCookies.d.mts +9 -0
- package/site/appCookies.mjs +74 -0
- package/site/apps.ts +21 -0
- package/site/autolinks.d.mts +11 -0
- package/site/autolinks.mjs +200 -0
- package/site/blockScripts.ts +31 -0
- package/site/categories.ts +16 -0
- package/site/consent.ts +26 -0
- package/site/content.config.ts +84 -0
- package/site/content.ts +104 -0
- package/site/cookieList.ts +17 -0
- package/site/defineSite.d.mts +142 -0
- package/site/defineSite.mjs +136 -0
- package/site/directives/interact.d.mts +2 -0
- package/site/directives/interact.mjs +31 -0
- package/site/editor-version.d.mts +1 -0
- package/site/editor-version.mjs +26 -0
- package/site/fill.ts +13 -0
- package/site/format.ts +11 -0
- package/site/home.ts +66 -0
- package/site/iconPacks.d.mts +20 -0
- package/site/iconPacks.mjs +43 -0
- package/site/icons.d.mts +693 -0
- package/site/icons.mjs +605 -0
- package/site/images.d.mts +43 -0
- package/site/images.mjs +157 -0
- package/site/motion.ts +13 -0
- package/site/noindex.d.mts +6 -0
- package/site/noindex.mjs +21 -0
- package/site/pageColors.ts +110 -0
- package/site/popups.ts +98 -0
- package/site/schema.ts +189 -0
- package/site/sections.ts +3 -0
- package/site/seo.ts +28 -0
- package/site/site.ts +76 -0
- package/site/siteConfig.d.mts +1 -0
- package/site/siteConfig.mjs +8 -0
- package/site/siteCsp.d.mts +10 -0
- package/site/siteCsp.mjs +75 -0
- package/site/siteModules.d.mts +7 -0
- package/site/siteModules.mjs +27 -0
- package/site/standardPages.ts +59 -0
- package/site/testimonials.ts +48 -0
- package/site/text.ts +43 -0
- package/site/theme.d.mts +83 -0
- package/site/theme.mjs +472 -0
- package/site/tracking.client.ts +225 -0
- package/site/tracking.ts +9 -0
- package/src/ActionParts.tsx +205 -0
- package/src/Advanced.tsx +152 -0
- package/src/App.tsx +904 -0
- package/src/AppList.tsx +1345 -0
- package/src/BlockDialog.tsx +360 -0
- package/src/BlockParts.tsx +652 -0
- package/src/BlockSettings.tsx +533 -0
- package/src/BrandForm.tsx +164 -0
- package/src/Colors.tsx +476 -0
- package/src/CommunicationParts.tsx +199 -0
- package/src/CompanyForm.tsx +209 -0
- package/src/Consents.tsx +175 -0
- package/src/ContainmentParts.tsx +185 -0
- package/src/Crop.tsx +152 -0
- package/src/DataForm.tsx +434 -0
- package/src/Deleted.tsx +177 -0
- package/src/EntryForm.tsx +740 -0
- package/src/FileView.tsx +30 -0
- package/src/FormFrame.tsx +237 -0
- package/src/Keywords.tsx +225 -0
- package/src/Library.tsx +1563 -0
- package/src/Licenses.tsx +336 -0
- package/src/LinkSheet.tsx +138 -0
- package/src/MarkdownEditor.tsx +1075 -0
- package/src/Media.tsx +677 -0
- package/src/NavigationParts.tsx +350 -0
- package/src/NavigationRestParts.tsx +247 -0
- package/src/Notifications.tsx +59 -0
- package/src/PagePicker.tsx +136 -0
- package/src/Parts.tsx +991 -0
- package/src/PartsShown.tsx +168 -0
- package/src/PickerParts.tsx +183 -0
- package/src/Redirects.tsx +450 -0
- package/src/Screen.tsx +184 -0
- package/src/SectionsField.tsx +65 -0
- package/src/SelectionParts.tsx +134 -0
- package/src/SeoScores.tsx +184 -0
- package/src/Sheet.tsx +35 -0
- package/src/ThemeStore.tsx +376 -0
- package/src/Tokens.tsx +346 -0
- package/src/Versions.tsx +214 -0
- package/src/addresses.ts +75 -0
- package/src/allIcons.ts +7 -0
- package/src/appFiles.ts +29 -0
- package/src/apps.ts +281 -0
- package/src/ask.tsx +123 -0
- package/src/blocks.ts +251 -0
- package/src/brand.ts +153 -0
- package/src/cache.ts +42 -0
- package/src/check.ts +285 -0
- package/src/checkSite.ts +256 -0
- package/src/consents.ts +26 -0
- package/src/cookieList.ts +70 -0
- package/src/countries.ts +59 -0
- package/src/deploy.ts +155 -0
- package/src/editor.css +602 -0
- package/src/examples.ts +85 -0
- package/src/fields.tsx +942 -0
- package/src/frontmatter.d.mts +6 -0
- package/src/frontmatter.mjs +42 -0
- package/src/github.ts +376 -0
- package/src/i18n/da-DK/advanced.ts +28 -0
- package/src/i18n/da-DK/app.ts +71 -0
- package/src/i18n/da-DK/apps.ts +90 -0
- package/src/i18n/da-DK/blockSettings.ts +38 -0
- package/src/i18n/da-DK/blocks.ts +408 -0
- package/src/i18n/da-DK/check.ts +25 -0
- package/src/i18n/da-DK/colors.ts +64 -0
- package/src/i18n/da-DK/common.ts +118 -0
- package/src/i18n/da-DK/company.ts +24 -0
- package/src/i18n/da-DK/consents.ts +23 -0
- package/src/i18n/da-DK/forms.ts +116 -0
- package/src/i18n/da-DK/history.ts +35 -0
- package/src/i18n/da-DK/index.ts +49 -0
- package/src/i18n/da-DK/keywords.ts +19 -0
- package/src/i18n/da-DK/library.ts +135 -0
- package/src/i18n/da-DK/licenses.ts +25 -0
- package/src/i18n/da-DK/media.ts +79 -0
- package/src/i18n/da-DK/parts.ts +696 -0
- package/src/i18n/da-DK/preview.ts +13 -0
- package/src/i18n/da-DK/redirects.ts +51 -0
- package/src/i18n/da-DK/seo.ts +20 -0
- package/src/i18n/da-DK/text.ts +35 -0
- package/src/i18n/da-DK/theme.ts +41 -0
- package/src/i18n/en-US/advanced.ts +32 -0
- package/src/i18n/en-US/app.ts +84 -0
- package/src/i18n/en-US/apps.ts +134 -0
- package/src/i18n/en-US/blockSettings.ts +48 -0
- package/src/i18n/en-US/blocks.ts +441 -0
- package/src/i18n/en-US/check.ts +31 -0
- package/src/i18n/en-US/colors.ts +82 -0
- package/src/i18n/en-US/common.ts +144 -0
- package/src/i18n/en-US/company.ts +25 -0
- package/src/i18n/en-US/consents.ts +25 -0
- package/src/i18n/en-US/forms.ts +145 -0
- package/src/i18n/en-US/history.ts +43 -0
- package/src/i18n/en-US/index.ts +51 -0
- package/src/i18n/en-US/keywords.ts +24 -0
- package/src/i18n/en-US/library.ts +176 -0
- package/src/i18n/en-US/licenses.ts +36 -0
- package/src/i18n/en-US/media.ts +80 -0
- package/src/i18n/en-US/parts.ts +718 -0
- package/src/i18n/en-US/preview.ts +15 -0
- package/src/i18n/en-US/redirects.ts +66 -0
- package/src/i18n/en-US/seo.ts +26 -0
- package/src/i18n/en-US/text.ts +41 -0
- package/src/i18n/en-US/theme.ts +41 -0
- package/src/i18n/index.ts +124 -0
- package/src/icon.tsx +160 -0
- package/src/iconPacks.ts +89 -0
- package/src/images.ts +163 -0
- package/src/licenses.ts +79 -0
- package/src/markdown.d.mts +12 -0
- package/src/markdown.mjs +204 -0
- package/src/nav.ts +68 -0
- package/src/notFound.ts +28 -0
- package/src/notifications.ts +223 -0
- package/src/pagePreview.tsx +204 -0
- package/src/r2Caching.ts +55 -0
- package/src/redirects.ts +149 -0
- package/src/registry.tsx +188 -0
- package/src/schema.ts +417 -0
- package/src/sections.d.mts +45 -0
- package/src/sections.mjs +303 -0
- package/src/seo.ts +333 -0
- package/src/shared.ts +44 -0
- package/src/sidebar.ts +31 -0
- package/src/site.ts +317 -0
- package/src/standard/Block.astro +19 -0
- package/src/standard/BlockSite.astro +18 -0
- package/src/standard/accordion/index.tsx +47 -0
- package/src/standard/accordion/load.ts +23 -0
- package/src/standard/accordion/shared.ts +15 -0
- package/src/standard/accordion/view.ts +32 -0
- package/src/standard/buttons/fields.ts +98 -0
- package/src/standard/buttons/index.tsx +23 -0
- package/src/standard/buttons/load.ts +34 -0
- package/src/standard/buttons/shared.ts +12 -0
- package/src/standard/buttons/view.ts +30 -0
- package/src/standard/cards/index.tsx +71 -0
- package/src/standard/cards/load.ts +17 -0
- package/src/standard/cards/shared.ts +27 -0
- package/src/standard/cards/view.ts +29 -0
- package/src/standard/checklist/index.tsx +27 -0
- package/src/standard/checklist/load.ts +5 -0
- package/src/standard/checklist/view.ts +13 -0
- package/src/standard/cookies/index.tsx +17 -0
- package/src/standard/cookies/list.ts +56 -0
- package/src/standard/cookies/load.ts +11 -0
- package/src/standard/cookies/shared.ts +20 -0
- package/src/standard/cookies/view.ts +28 -0
- package/src/standard/faq/index.tsx +29 -0
- package/src/standard/faq/load.ts +14 -0
- package/src/standard/faq/view.ts +21 -0
- package/src/standard/head.ts +23 -0
- package/src/standard/hero/fields.ts +48 -0
- package/src/standard/hero/index.tsx +57 -0
- package/src/standard/hero/load.ts +23 -0
- package/src/standard/hero/view.ts +38 -0
- package/src/standard/links/index.tsx +41 -0
- package/src/standard/links/load.ts +17 -0
- package/src/standard/links/view.ts +25 -0
- package/src/standard/list/index.tsx +50 -0
- package/src/standard/list/load.ts +5 -0
- package/src/standard/list/view.ts +18 -0
- package/src/standard/load.ts +7 -0
- package/src/standard/notice/index.tsx +30 -0
- package/src/standard/notice/load.ts +16 -0
- package/src/standard/notice/view.ts +10 -0
- package/src/standard/points/index.tsx +42 -0
- package/src/standard/points/load.ts +5 -0
- package/src/standard/points/view.ts +12 -0
- package/src/standard/posts/index.tsx +132 -0
- package/src/standard/posts/load.ts +36 -0
- package/src/standard/posts/shared.ts +21 -0
- package/src/standard/posts/view.ts +63 -0
- package/src/standard/pricecards/index.tsx +57 -0
- package/src/standard/pricecards/load.ts +11 -0
- package/src/standard/pricecards/view.ts +50 -0
- package/src/standard/sharedTexts.ts +6 -0
- package/src/standard/site.ts +124 -0
- package/src/standard/siteLooks.ts +19 -0
- package/src/standard/sites.ts +13 -0
- package/src/standard/steps/index.tsx +28 -0
- package/src/standard/steps/load.ts +5 -0
- package/src/standard/steps/view.ts +11 -0
- package/src/standard/testimonials/index.tsx +87 -0
- package/src/standard/testimonials/list.ts +43 -0
- package/src/standard/testimonials/load.ts +58 -0
- package/src/standard/testimonials/shared.ts +13 -0
- package/src/standard/testimonials/view.ts +76 -0
- package/src/standard/themeBlocks.ts +13 -0
- package/src/standard/video/index.tsx +23 -0
- package/src/standard/video/load.ts +10 -0
- package/src/standard/video/view.ts +42 -0
- package/src/standard/view.tsx +105 -0
- package/src/standard/views.ts +37 -0
- package/src/store.ts +499 -0
- package/src/theme.ts +90 -0
- package/src/themeUi.ts +97 -0
- package/src/themes.ts +91 -0
- package/src/ui.tsx +3402 -0
- package/src/virtual.d.ts +5 -0
- package/src/when.ts +22 -0
- package/starter/.githooks/pre-push +4 -0
- package/starter/.nvmrc +1 -0
- package/starter/.prettierignore +11 -0
- package/starter/.prettierrc.json +7 -0
- package/starter/README.md +26 -0
- package/starter/astro.config.mjs +5 -0
- package/starter/package.json +67 -0
- package/starter/public/_redirects +1 -0
- package/starter/public/apple-touch-icon.png +0 -0
- package/starter/public/editor/apple-touch-icon.png +0 -0
- package/starter/public/editor/icon-192.png +0 -0
- package/starter/public/editor/icon-512.png +0 -0
- package/starter/public/editor/icon-maskable-512.png +0 -0
- package/starter/public/favicon.ico +0 -0
- package/starter/public/favicon.svg +1 -0
- package/starter/public/logo.svg +1 -0
- package/starter/src/content/pages/blog.md +8 -0
- package/starter/src/content/pages/forside.md +9 -0
- package/starter/src/content/pages/kontakt.md +7 -0
- package/starter/src/content/pages/privatlivspolitik.md +9 -0
- package/starter/src/content/pages/siden-findes-ikke.md +7 -0
- package/starter/src/content.config.ts +2 -0
- package/starter/src/data/apps.json +1 -0
- package/starter/src/data/block-settings.json +3 -0
- package/starter/src/data/blocks.json +1 -0
- package/starter/src/data/booking.json +6 -0
- package/starter/src/data/colors.json +70 -0
- package/starter/src/data/contact.json +8 -0
- package/starter/src/data/cookies.json +21 -0
- package/starter/src/data/csp.json +1 -0
- package/starter/src/data/footer.json +15 -0
- package/starter/src/data/internal-links.json +4 -0
- package/starter/src/data/menu.json +4 -0
- package/starter/src/data/mobile-menu.json +4 -0
- package/starter/src/data/old-addresses.json +1 -0
- package/starter/src/data/redirects.json +1 -0
- package/starter/src/data/standard-pages.json +8 -0
- package/starter/src/data/theme.json +1 -0
- package/starter/src/env.d.ts +2 -0
- package/starter/src/site/config.ts +13 -0
- package/starter/src/site/worker.js +14 -0
- package/starter/tsconfig.json +17 -0
- package/starter/worker/apps.js +6 -0
- package/starter/wrangler.jsonc +39 -0
- package/theme/LICENSE +21 -0
- package/theme/blocks/ArrowLink.tsx +14 -0
- package/theme/blocks/BlockHead.tsx +26 -0
- package/theme/blocks/Panel.tsx +32 -0
- package/theme/blocks/Person.tsx +26 -0
- package/theme/blocks/accordion/PageRow.tsx +25 -0
- package/theme/blocks/accordion/View.tsx +17 -0
- package/theme/blocks/buttons/Buttons.tsx +28 -0
- package/theme/blocks/buttons/View.tsx +12 -0
- package/theme/blocks/cards/LinkCards.tsx +72 -0
- package/theme/blocks/cards/View.tsx +20 -0
- package/theme/blocks/checklist/Checks.tsx +15 -0
- package/theme/blocks/checklist/View.tsx +14 -0
- package/theme/blocks/cookies/View.tsx +36 -0
- package/theme/blocks/faq/Accordion.tsx +34 -0
- package/theme/blocks/faq/View.tsx +16 -0
- package/theme/blocks/faq/script.ts +56 -0
- package/theme/blocks/hero/View.tsx +44 -0
- package/theme/blocks/links/LinkGroups.tsx +24 -0
- package/theme/blocks/links/View.tsx +17 -0
- package/theme/blocks/list/Rows.tsx +24 -0
- package/theme/blocks/list/View.tsx +14 -0
- package/theme/blocks/notice/View.tsx +13 -0
- package/theme/blocks/points/PointList.tsx +34 -0
- package/theme/blocks/points/View.tsx +14 -0
- package/theme/blocks/posts/View.tsx +26 -0
- package/theme/blocks/pricecards/View.tsx +40 -0
- package/theme/blocks/steps/StepList.tsx +23 -0
- package/theme/blocks/steps/View.tsx +14 -0
- package/theme/blocks/testimonials/List.tsx +46 -0
- package/theme/blocks/testimonials/Slider.tsx +44 -0
- package/theme/blocks/testimonials/Testimonial.tsx +124 -0
- package/theme/blocks/testimonials/View.tsx +21 -0
- package/theme/blocks/testimonials/script.ts +52 -0
- package/theme/blocks/video/View.tsx +41 -0
- package/theme/components/ActionBar.astro +63 -0
- package/theme/components/AppSlot.astro +21 -0
- package/theme/components/ArrowLink.tsx +21 -0
- package/theme/components/BlogIndex.astro +48 -0
- package/theme/components/BlogPosts.astro +18 -0
- package/theme/components/BlogPosts.tsx +45 -0
- package/theme/components/BookButton.tsx +22 -0
- package/theme/components/Container.astro +11 -0
- package/theme/components/CookieConsent.astro +121 -0
- package/theme/components/EndBlock.astro +21 -0
- package/theme/components/FontPreload.astro +6 -0
- package/theme/components/Footer.astro +85 -0
- package/theme/components/Header.astro +186 -0
- package/theme/components/Icon.astro +16 -0
- package/theme/components/Img.astro +29 -0
- package/theme/components/Img.tsx +33 -0
- package/theme/components/Inline.astro +11 -0
- package/theme/components/Logo.astro +23 -0
- package/theme/components/LogoMark.astro +10 -0
- package/theme/components/Markdown.astro +17 -0
- package/theme/components/MobileMenu.tsx +224 -0
- package/theme/components/MobileNav.astro +33 -0
- package/theme/components/PageHeader.astro +22 -0
- package/theme/components/PageSection.astro +24 -0
- package/theme/components/PhoneButton.tsx +11 -0
- package/theme/components/Popup.astro +145 -0
- package/theme/components/PopupButton.tsx +24 -0
- package/theme/components/PopupView.tsx +48 -0
- package/theme/components/PostList.tsx +58 -0
- package/theme/components/PostMeta.tsx +17 -0
- package/theme/components/RichText.astro +28 -0
- package/theme/components/Section.astro +29 -0
- package/theme/components/SectionHeading.tsx +8 -0
- package/theme/components/TextSection.astro +16 -0
- package/theme/components/home/PageBlock.astro +19 -0
- package/theme/components/home/Text.astro +13 -0
- package/theme/components/loadPopup.ts +22 -0
- package/theme/components/loadPosts.ts +16 -0
- package/theme/components/posts.ts +15 -0
- package/theme/editor/looks.mjs +7 -0
- package/theme/editor/previews.tsx +56 -0
- package/theme/layouts/Base.astro +273 -0
- package/theme/layouts/PageLayout.astro +126 -0
- package/theme/layouts/PostLayout.astro +137 -0
- package/theme/screenshots/1-front.jpg +0 -0
- package/theme/screenshots/2-front-dark.jpg +0 -0
- package/theme/screenshots/3-blog.jpg +0 -0
- package/theme/screenshots/4-contact.jpg +0 -0
- package/theme/scripts/motion.ts +29 -0
- package/theme/styles/base.css +320 -0
- package/theme/styles/blocks.css +1808 -0
- package/theme/styles/components.css +206 -0
- package/theme/styles/global.css +1302 -0
- package/theme/styles/ui.css +6544 -0
- package/theme/templates/Blog.astro +38 -0
- package/theme/templates/Category.astro +19 -0
- package/theme/templates/Home.astro +36 -0
- package/theme/templates/New.astro +68 -0
- package/theme/templates/NotFound.astro +117 -0
- package/theme/theme.json +28 -0
- package/theme/ui/Badge.tsx +45 -0
- package/theme/ui/BottomSheet.tsx +148 -0
- package/theme/ui/Button.tsx +79 -0
- package/theme/ui/ButtonGroup.tsx +31 -0
- package/theme/ui/Card.tsx +45 -0
- package/theme/ui/Carousel.tsx +124 -0
- package/theme/ui/Checkbox.tsx +59 -0
- package/theme/ui/Chip.tsx +101 -0
- package/theme/ui/ChipSet.tsx +27 -0
- package/theme/ui/DatePicker.tsx +149 -0
- package/theme/ui/DatePickerContainer.tsx +316 -0
- package/theme/ui/Dialog.tsx +100 -0
- package/theme/ui/Divider.tsx +27 -0
- package/theme/ui/ExposedDropdownMenu.tsx +77 -0
- package/theme/ui/Fab.tsx +88 -0
- package/theme/ui/FabMenu.tsx +67 -0
- package/theme/ui/IconButton.tsx +78 -0
- package/theme/ui/List.tsx +287 -0
- package/theme/ui/LoadingIndicator.tsx +29 -0
- package/theme/ui/Menu.tsx +278 -0
- package/theme/ui/NavigationBar.tsx +62 -0
- package/theme/ui/NavigationDrawer.tsx +80 -0
- package/theme/ui/NavigationRail.tsx +154 -0
- package/theme/ui/ProgressIndicator.tsx +162 -0
- package/theme/ui/RadioButton.tsx +50 -0
- package/theme/ui/Search.tsx +129 -0
- package/theme/ui/SegmentedButton.tsx +49 -0
- package/theme/ui/SideSheet.tsx +54 -0
- package/theme/ui/Slider.tsx +155 -0
- package/theme/ui/Snackbar.tsx +67 -0
- package/theme/ui/SplitButton.tsx +67 -0
- package/theme/ui/Switch.tsx +60 -0
- package/theme/ui/Tabs.tsx +114 -0
- package/theme/ui/TextField.tsx +278 -0
- package/theme/ui/TimePicker.tsx +128 -0
- package/theme/ui/TimePickerContainer.tsx +234 -0
- package/theme/ui/Toolbar.tsx +67 -0
- package/theme/ui/Tooltip.tsx +58 -0
- package/theme/ui/TopAppBar.tsx +77 -0
- package/theme/ui/bottomSheet.script.ts +8 -0
- package/theme/ui/bottomSheet.ts +211 -0
- package/theme/ui/carousel.script.ts +28 -0
- package/theme/ui/carouselLayout.ts +229 -0
- package/theme/ui/checkbox.script.ts +8 -0
- package/theme/ui/datePicker.script.ts +302 -0
- package/theme/ui/dates.ts +185 -0
- package/theme/ui/dialog.ts +48 -0
- package/theme/ui/dropdownMenu.script.ts +166 -0
- package/theme/ui/fabMenu.script.ts +57 -0
- package/theme/ui/navigationRail.script.ts +47 -0
- package/theme/ui/ripple.script.ts +159 -0
- package/theme/ui/search.script.ts +92 -0
- package/theme/ui/sideSheet.ts +48 -0
- package/theme/ui/slider.script.ts +12 -0
- package/theme/ui/sliderUpdate.ts +52 -0
- package/theme/ui/snackbar.script.ts +16 -0
- package/theme/ui/splitButton.script.ts +63 -0
- package/theme/ui/tabs.script.ts +48 -0
- package/theme/ui/textField.script.ts +17 -0
- package/theme/ui/textFieldUpdate.ts +44 -0
- package/theme/ui/timePicker.script.ts +248 -0
- package/theme/ui/toolbar.script.ts +43 -0
- package/theme/ui/tooltip.script.ts +68 -0
- package/worker/consent.js +141 -0
- package/worker/editor-github.js +167 -0
- package/worker/editor-host.js +74 -0
- package/worker/files.js +401 -0
- package/worker/github.js +36 -0
- package/worker/index.js +184 -0
- package/worker/not-found.js +97 -0
- package/worker/oauth.js +95 -0
- package/worker/publish.js +52 -0
- package/worker/push.js +270 -0
- package/worker/r2-status.js +50 -0
- 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
|
+
}
|