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