zabi-components 8.0.0 → 8.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/CHANGELOG.md +1924 -0
- package/README.md +243 -10
- package/THEME.md +149 -332
- package/THEMING.md +1192 -266
- package/dist/atoms/ActionPanel.svelte +15 -5
- package/dist/atoms/Avatar.svelte +104 -0
- package/dist/atoms/Avatar.svelte.d.ts +34 -0
- package/dist/atoms/Badge.svelte +36 -7
- package/dist/atoms/Badge.svelte.d.ts +2 -2
- package/dist/atoms/Button.svelte +177 -27
- package/dist/atoms/Button.svelte.d.ts +30 -2
- package/dist/atoms/Card.svelte +4 -4
- package/dist/atoms/CardHeader.svelte +16 -4
- package/dist/atoms/Checkbox.svelte +48 -21
- package/dist/atoms/Checkbox.svelte.d.ts +4 -2
- package/dist/atoms/CodeBlock.svelte +20 -2
- package/dist/atoms/CodeBlock.svelte.d.ts +4 -0
- package/dist/atoms/ColorPicker.svelte +259 -49
- package/dist/atoms/ColorPicker.svelte.d.ts +3 -0
- package/dist/atoms/Container.svelte +16 -8
- package/dist/atoms/DateField.svelte +107 -0
- package/dist/atoms/DateField.svelte.d.ts +53 -0
- package/dist/atoms/FieldMessages.svelte +69 -0
- package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
- package/dist/atoms/FloatingActionButton.svelte +174 -0
- package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
- package/dist/atoms/Heading.svelte +37 -5
- package/dist/atoms/Heading.svelte.d.ts +7 -0
- package/dist/atoms/IconButton.svelte +220 -24
- package/dist/atoms/IconButton.svelte.d.ts +49 -4
- package/dist/atoms/Input.svelte +253 -52
- package/dist/atoms/Input.svelte.d.ts +32 -2
- package/dist/atoms/List.svelte +4 -1
- package/dist/atoms/ListItem.svelte +44 -11
- package/dist/atoms/ListItem.svelte.d.ts +5 -0
- package/dist/atoms/Progress.svelte +27 -8
- package/dist/atoms/Progress.svelte.d.ts +4 -0
- package/dist/atoms/Radio.svelte +19 -3
- package/dist/atoms/Radio.svelte.d.ts +4 -2
- package/dist/atoms/Rating.svelte +494 -0
- package/dist/atoms/Rating.svelte.d.ts +56 -0
- package/dist/atoms/Select.svelte +433 -91
- package/dist/atoms/Select.svelte.d.ts +49 -3
- package/dist/atoms/SelectionControl.svelte +94 -17
- package/dist/atoms/SelectionControl.svelte.d.ts +4 -3
- package/dist/atoms/Skeleton.svelte +1 -1
- package/dist/atoms/Slider.svelte +309 -0
- package/dist/atoms/Slider.svelte.d.ts +40 -0
- package/dist/atoms/Spinner.svelte +57 -0
- package/dist/atoms/Spinner.svelte.d.ts +19 -0
- package/dist/atoms/Table.svelte +151 -6
- package/dist/atoms/Table.svelte.d.ts +15 -3
- package/dist/atoms/TemporalField.svelte +140 -0
- package/dist/atoms/TemporalField.svelte.d.ts +32 -0
- package/dist/atoms/Text.svelte +23 -11
- package/dist/atoms/Text.svelte.d.ts +6 -3
- package/dist/atoms/Textarea.svelte +55 -44
- package/dist/atoms/Textarea.svelte.d.ts +14 -2
- package/dist/atoms/ThemeToggle.svelte +223 -44
- package/dist/atoms/ThemeToggle.svelte.d.ts +26 -3
- package/dist/atoms/TimeField.svelte +108 -0
- package/dist/atoms/TimeField.svelte.d.ts +54 -0
- package/dist/atoms/Toast.svelte +17 -4
- package/dist/atoms/Toast.svelte.d.ts +2 -0
- package/dist/atoms/Toggle.svelte +41 -8
- package/dist/atoms/Toggle.svelte.d.ts +11 -2
- package/dist/atoms/Tooltip.svelte +508 -23
- package/dist/atoms/Tooltip.svelte.d.ts +11 -0
- package/dist/atoms/ZabiStringsProvider.svelte +45 -0
- package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
- package/dist/atoms/index.d.ts +17 -0
- package/dist/atoms/index.js +12 -0
- package/dist/atoms/selection-control.styles.d.ts +36 -5
- package/dist/atoms/selection-control.styles.js +40 -9
- package/dist/components/atoms/index.d.ts +17 -0
- package/dist/components/atoms/index.d.ts.map +1 -1
- package/dist/components/atoms/selection-control.styles.d.ts +36 -5
- package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
- package/dist/components/index.d.ts +80 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/molecules/index.d.ts +46 -0
- package/dist/components/molecules/index.d.ts.map +1 -1
- package/dist/components/molecules/toast-store.d.ts +53 -7
- package/dist/components/molecules/toast-store.d.ts.map +1 -1
- package/dist/components/organisms/index.d.ts +5 -0
- package/dist/components/organisms/index.d.ts.map +1 -1
- package/dist/components/types/variants.d.ts +15 -1
- package/dist/components/types/variants.d.ts.map +1 -1
- package/dist/components/util/app-shell.d.ts +28 -0
- package/dist/components/util/app-shell.d.ts.map +1 -0
- package/dist/components/util/avatar.d.ts +36 -0
- package/dist/components/util/avatar.d.ts.map +1 -0
- package/dist/components/util/bottom-sheet.d.ts +30 -0
- package/dist/components/util/bottom-sheet.d.ts.map +1 -0
- package/dist/components/util/bottom-tab-bar.d.ts +35 -0
- package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
- package/dist/components/util/calendar.d.ts +56 -0
- package/dist/components/util/calendar.d.ts.map +1 -0
- package/dist/components/util/collapsible.d.ts +58 -0
- package/dist/components/util/collapsible.d.ts.map +1 -0
- package/dist/components/util/confirm-dialog.d.ts +12 -0
- package/dist/components/util/confirm-dialog.d.ts.map +1 -0
- package/dist/components/util/date.d.ts +96 -0
- package/dist/components/util/date.d.ts.map +1 -0
- package/dist/components/util/drawer.d.ts +13 -0
- package/dist/components/util/drawer.d.ts.map +1 -0
- package/dist/components/util/dropdown.d.ts +47 -0
- package/dist/components/util/dropdown.d.ts.map +1 -0
- package/dist/components/util/field.d.ts +39 -0
- package/dist/components/util/field.d.ts.map +1 -0
- package/dist/components/util/fit-in-viewport.d.ts +201 -0
- package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
- package/dist/components/util/focus-utils.d.ts +75 -2
- package/dist/components/util/focus-utils.d.ts.map +1 -1
- package/dist/components/util/hydration.d.ts +45 -0
- package/dist/components/util/hydration.d.ts.map +1 -0
- package/dist/components/util/keyboard-inset.d.ts +18 -0
- package/dist/components/util/keyboard-inset.d.ts.map +1 -0
- package/dist/components/util/media-grid.d.ts +51 -0
- package/dist/components/util/media-grid.d.ts.map +1 -0
- package/dist/components/util/media.d.ts +6 -0
- package/dist/components/util/media.d.ts.map +1 -0
- package/dist/components/util/overlay-chrome.d.ts +53 -0
- package/dist/components/util/overlay-chrome.d.ts.map +1 -0
- package/dist/components/util/overlay.d.ts +72 -0
- package/dist/components/util/overlay.d.ts.map +1 -0
- package/dist/components/util/photo.d.ts +215 -0
- package/dist/components/util/photo.d.ts.map +1 -0
- package/dist/components/util/portal.d.ts +23 -0
- package/dist/components/util/portal.d.ts.map +1 -0
- package/dist/components/util/pull-to-refresh.d.ts +27 -0
- package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
- package/dist/components/util/radio-keys.d.ts +25 -0
- package/dist/components/util/radio-keys.d.ts.map +1 -0
- package/dist/components/util/rating.d.ts +23 -0
- package/dist/components/util/rating.d.ts.map +1 -0
- package/dist/components/util/ready-made-strings.d.ts +77 -0
- package/dist/components/util/ready-made-strings.d.ts.map +1 -0
- package/dist/components/util/scroll-padding.d.ts +20 -0
- package/dist/components/util/scroll-padding.d.ts.map +1 -0
- package/dist/components/util/segmented-control.d.ts +16 -0
- package/dist/components/util/segmented-control.d.ts.map +1 -0
- package/dist/components/util/select.d.ts +41 -0
- package/dist/components/util/select.d.ts.map +1 -0
- package/dist/components/util/sheet-drag.d.ts +60 -0
- package/dist/components/util/sheet-drag.d.ts.map +1 -0
- package/dist/components/util/sidebar.d.ts +63 -0
- package/dist/components/util/sidebar.d.ts.map +1 -0
- package/dist/components/util/sortable-list.d.ts +65 -0
- package/dist/components/util/sortable-list.d.ts.map +1 -0
- package/dist/components/util/stepper.d.ts +40 -0
- package/dist/components/util/stepper.d.ts.map +1 -0
- package/dist/components/util/swipeable-list-item.d.ts +36 -0
- package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
- package/dist/components/util/theme-mode.d.ts +80 -0
- package/dist/components/util/theme-mode.d.ts.map +1 -0
- package/dist/components/util/toaster.d.ts +93 -0
- package/dist/components/util/toaster.d.ts.map +1 -0
- package/dist/components/util/tooltip.d.ts +10 -0
- package/dist/components/util/tooltip.d.ts.map +1 -0
- package/dist/components/util/top-navbar.d.ts +11 -0
- package/dist/components/util/top-navbar.d.ts.map +1 -0
- package/dist/components/util/touch-target.d.ts +31 -0
- package/dist/components/util/touch-target.d.ts.map +1 -0
- package/dist/components/util/zabi-strings.d.ts +133 -0
- package/dist/components/util/zabi-strings.d.ts.map +1 -0
- package/dist/create-theme/cli.js +169 -0
- package/dist/create-theme/index.d.ts +176 -0
- package/dist/create-theme/index.js +817 -0
- package/dist/create-theme/lib/ramp-math.js +128 -0
- package/dist/create-theme/lib/resolve.js +141 -0
- package/dist/create-theme/theme-data.js +1116 -0
- package/dist/index.d.ts +80 -2
- package/dist/index.js +57 -2
- package/dist/molecules/Alert.svelte +33 -5
- package/dist/molecules/Alert.svelte.d.ts +2 -0
- package/dist/molecules/AppBar.svelte +415 -0
- package/dist/molecules/AppBar.svelte.d.ts +69 -0
- package/dist/molecules/AvatarGroup.svelte +116 -0
- package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
- package/dist/molecules/BottomSheet.svelte +540 -0
- package/dist/molecules/BottomSheet.svelte.d.ts +93 -0
- package/dist/molecules/BottomTabBar.svelte +297 -0
- package/dist/molecules/BottomTabBar.svelte.d.ts +64 -0
- package/dist/molecules/Calendar.svelte +597 -0
- package/dist/molecules/Calendar.svelte.d.ts +72 -0
- package/dist/molecules/Collapsible.svelte +243 -0
- package/dist/molecules/Collapsible.svelte.d.ts +44 -0
- package/dist/molecules/CollapsibleGroup.svelte +126 -0
- package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
- package/dist/molecules/ComponentDemo.svelte +16 -4
- package/dist/molecules/ComponentDemo.svelte.d.ts +3 -0
- package/dist/molecules/ConfirmDialog.svelte +253 -0
- package/dist/molecules/ConfirmDialog.svelte.d.ts +55 -0
- package/dist/molecules/ContactForm.svelte +34 -17
- package/dist/molecules/ContactForm.svelte.d.ts +8 -0
- package/dist/molecules/Drawer.svelte +394 -0
- package/dist/molecules/Drawer.svelte.d.ts +55 -0
- package/dist/molecules/Dropdown.svelte +509 -48
- package/dist/molecules/Dropdown.svelte.d.ts +45 -6
- package/dist/molecules/DropdownItem.svelte +122 -0
- package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
- package/dist/molecules/EmptyState.svelte +55 -10
- package/dist/molecules/EmptyState.svelte.d.ts +8 -0
- package/dist/molecules/FormField.svelte +9 -1
- package/dist/molecules/FormField.svelte.d.ts +2 -0
- package/dist/molecules/Header.svelte +1 -1
- package/dist/molecules/ImageUpload.svelte +383 -70
- package/dist/molecules/ImageUpload.svelte.d.ts +47 -2
- package/dist/molecules/MediaGrid.svelte +527 -0
- package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
- package/dist/molecules/Modal.svelte +288 -72
- package/dist/molecules/Modal.svelte.d.ts +51 -2
- package/dist/molecules/NavigationMenuContent.svelte +58 -1
- package/dist/molecules/NavigationMenuItem.svelte +0 -1
- package/dist/molecules/NavigationMenuLink.svelte +1 -1
- package/dist/molecules/NavigationMenuList.svelte +10 -3
- package/dist/molecules/NavigationMenuTrigger.svelte +2 -2
- package/dist/molecules/Page.svelte +32 -2
- package/dist/molecules/Page.svelte.d.ts +11 -0
- package/dist/molecules/PhotoGrid.svelte +433 -0
- package/dist/molecules/PhotoGrid.svelte.d.ts +62 -0
- package/dist/molecules/PhotoViewer.svelte +1116 -0
- package/dist/molecules/PhotoViewer.svelte.d.ts +56 -0
- package/dist/molecules/PropsTable.svelte +22 -10
- package/dist/molecules/PropsTable.svelte.d.ts +3 -0
- package/dist/molecules/PullToRefresh.svelte +263 -0
- package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
- package/dist/molecules/RadioGroup.svelte +52 -14
- package/dist/molecules/RadioGroup.svelte.d.ts +14 -3
- package/dist/molecules/Section.svelte +1 -1
- package/dist/molecules/SegmentedControl.svelte +323 -0
- package/dist/molecules/SegmentedControl.svelte.d.ts +34 -0
- package/dist/molecules/SidebarBrandHeader.svelte +11 -1
- package/dist/molecules/SidebarBrandHeader.svelte.d.ts +3 -0
- package/dist/molecules/SidebarFooter.svelte +29 -6
- package/dist/molecules/SidebarFooter.svelte.d.ts +3 -0
- package/dist/molecules/SlideUp.svelte +220 -61
- package/dist/molecules/SlideUp.svelte.d.ts +26 -0
- package/dist/molecules/SortableList.svelte +632 -0
- package/dist/molecules/SortableList.svelte.d.ts +69 -0
- package/dist/molecules/Stepper.svelte +548 -0
- package/dist/molecules/Stepper.svelte.d.ts +39 -0
- package/dist/molecules/StickyActionBar.svelte +191 -0
- package/dist/molecules/StickyActionBar.svelte.d.ts +65 -0
- package/dist/molecules/SwipeableListItem.svelte +334 -0
- package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
- package/dist/molecules/Tabs.svelte +233 -9
- package/dist/molecules/Tabs.svelte.d.ts +7 -0
- package/dist/molecules/Toaster.svelte +350 -5
- package/dist/molecules/Toaster.svelte.d.ts +95 -2
- package/dist/molecules/ToasterToast.svelte +253 -67
- package/dist/molecules/ToasterToast.svelte.d.ts +9 -0
- package/dist/molecules/UnsavedChangesBar.svelte +212 -0
- package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
- package/dist/molecules/index.d.ts +46 -0
- package/dist/molecules/index.js +27 -0
- package/dist/molecules/toast-store.d.ts +53 -7
- package/dist/molecules/toast-store.js +21 -0
- package/dist/organisms/AppShell.svelte +196 -0
- package/dist/organisms/AppShell.svelte.d.ts +61 -0
- package/dist/organisms/SidebarAccountPanel.svelte +107 -12
- package/dist/organisms/SidebarAccountPanel.svelte.d.ts +19 -0
- package/dist/organisms/SidebarNavigation.svelte +96 -15
- package/dist/organisms/SidebarNavigation.svelte.d.ts +29 -1
- package/dist/organisms/SidebarPanel.svelte +31 -10
- package/dist/organisms/SidebarShell.svelte +162 -11
- package/dist/organisms/SidebarShell.svelte.d.ts +56 -2
- package/dist/organisms/TopNavbar.svelte +256 -31
- package/dist/organisms/TopNavbar.svelte.d.ts +26 -0
- package/dist/organisms/index.d.ts +5 -0
- package/dist/organisms/index.js +3 -0
- package/dist/types/components.d.ts +4 -0
- package/dist/types/components.d.ts.map +1 -1
- package/dist/types/index.d.ts +605 -25
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/variants.ts +17 -1
- package/dist/util/app-shell.d.ts +27 -0
- package/dist/util/app-shell.js +72 -0
- package/dist/util/avatar.d.ts +35 -0
- package/dist/util/avatar.js +52 -0
- package/dist/util/bottom-sheet.d.ts +29 -0
- package/dist/util/bottom-sheet.js +43 -0
- package/dist/util/bottom-tab-bar.d.ts +34 -0
- package/dist/util/bottom-tab-bar.js +54 -0
- package/dist/util/calendar.d.ts +55 -0
- package/dist/util/calendar.js +74 -0
- package/dist/util/collapsible.d.ts +57 -0
- package/dist/util/collapsible.js +23 -0
- package/dist/util/confirm-dialog.d.ts +11 -0
- package/dist/util/confirm-dialog.js +2 -0
- package/dist/util/date.d.ts +95 -0
- package/dist/util/date.js +237 -0
- package/dist/util/drawer.d.ts +12 -0
- package/dist/util/drawer.js +7 -0
- package/dist/util/dropdown.d.ts +46 -0
- package/dist/util/dropdown.js +21 -0
- package/dist/util/field.d.ts +38 -0
- package/dist/util/field.js +38 -0
- package/dist/util/fit-in-viewport.d.ts +200 -0
- package/dist/util/fit-in-viewport.js +399 -0
- package/dist/util/focus-utils.d.ts +75 -2
- package/dist/util/focus-utils.js +182 -9
- package/dist/util/hydration.d.ts +44 -0
- package/dist/util/hydration.js +58 -0
- package/dist/util/keyboard-inset.d.ts +17 -0
- package/dist/util/keyboard-inset.js +53 -0
- package/dist/util/media-grid.d.ts +50 -0
- package/dist/util/media-grid.js +81 -0
- package/dist/util/media.d.ts +5 -0
- package/dist/util/media.js +10 -0
- package/dist/util/overlay-chrome.d.ts +52 -0
- package/dist/util/overlay-chrome.js +56 -0
- package/dist/util/overlay.d.ts +71 -0
- package/dist/util/overlay.js +240 -0
- package/dist/util/photo.d.ts +214 -0
- package/dist/util/photo.js +206 -0
- package/dist/util/portal.d.ts +22 -0
- package/dist/util/portal.js +50 -0
- package/dist/util/pull-to-refresh.d.ts +26 -0
- package/dist/util/pull-to-refresh.js +30 -0
- package/dist/util/radio-keys.d.ts +24 -0
- package/dist/util/radio-keys.js +49 -0
- package/dist/util/rating.d.ts +22 -0
- package/dist/util/rating.js +26 -0
- package/dist/util/ready-made-strings.d.ts +76 -0
- package/dist/util/ready-made-strings.js +67 -0
- package/dist/util/scroll-padding.d.ts +19 -0
- package/dist/util/scroll-padding.js +44 -0
- package/dist/util/segmented-control.d.ts +15 -0
- package/dist/util/segmented-control.js +1 -0
- package/dist/util/select.d.ts +40 -0
- package/dist/util/select.js +63 -0
- package/dist/util/sheet-drag.d.ts +59 -0
- package/dist/util/sheet-drag.js +241 -0
- package/dist/util/sidebar.d.ts +62 -0
- package/dist/util/sidebar.js +38 -0
- package/dist/util/sortable-list.d.ts +64 -0
- package/dist/util/sortable-list.js +53 -0
- package/dist/util/stepper.d.ts +39 -0
- package/dist/util/stepper.js +38 -0
- package/dist/util/swipeable-list-item.d.ts +35 -0
- package/dist/util/swipeable-list-item.js +32 -0
- package/dist/util/theme-mode.d.ts +79 -0
- package/dist/util/theme-mode.js +121 -0
- package/dist/util/toaster.d.ts +92 -0
- package/dist/util/toaster.js +75 -0
- package/dist/util/tooltip.d.ts +9 -0
- package/dist/util/tooltip.js +18 -0
- package/dist/util/top-navbar.d.ts +10 -0
- package/dist/util/top-navbar.js +5 -0
- package/dist/util/touch-target.d.ts +30 -0
- package/dist/util/touch-target.js +30 -0
- package/dist/util/zabi-strings.d.ts +132 -0
- package/dist/util/zabi-strings.js +105 -0
- package/dist/zabi-components-colors.css +5 -2
- package/dist/zabi-components-theme-dark-only.css +543 -241
- package/dist/zabi-components-theme-dark.css +543 -241
- package/dist/zabi-components-theme-only.css +1584 -367
- package/dist/zabi-components-theme.css +1584 -367
- package/dist/zabi-components.css +3885 -518
- package/docs/lucide-icons.md +40 -17
- package/docs/theme-imports.md +81 -11
- package/package.json +35 -8
|
@@ -1,27 +1,97 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { zabiCommonStrings } from "../util/zabi-strings.js";
|
|
2
3
|
import type { Snippet } from 'svelte';
|
|
4
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
5
|
import {
|
|
4
|
-
getFocusableElements,
|
|
5
6
|
saveFocus,
|
|
6
7
|
returnFocus,
|
|
7
8
|
focusFirstElement,
|
|
9
|
+
joinOverlayStack,
|
|
10
|
+
recoverStrayFocus,
|
|
8
11
|
} from '../util/focus-utils.js';
|
|
12
|
+
import {
|
|
13
|
+
followKeyboard,
|
|
14
|
+
lockBodyScroll,
|
|
15
|
+
registerOverlayFooter,
|
|
16
|
+
registerOverlayHeader,
|
|
17
|
+
trapTabKey,
|
|
18
|
+
watchScrollerNeedsFocus,
|
|
19
|
+
} from '../util/overlay.js';
|
|
20
|
+
import { TOUCH_HIT_AREA } from '../util/touch-target.js';
|
|
9
21
|
import { generateId } from "../util/ssr-safe.js";
|
|
22
|
+
import { portal as portalTo } from "../util/portal.js";
|
|
10
23
|
import Card from '../atoms/Card.svelte';
|
|
11
24
|
import CardHeader from '../atoms/CardHeader.svelte';
|
|
12
25
|
import CardContent from '../atoms/CardContent.svelte';
|
|
13
26
|
import CardFooter from '../atoms/CardFooter.svelte';
|
|
14
27
|
|
|
15
28
|
import { cn } from "../util/cn.js";
|
|
29
|
+
import {
|
|
30
|
+
OVERLAY_CHROME,
|
|
31
|
+
OVERLAY_CLOSE_GLYPH,
|
|
32
|
+
OVERLAY_TITLE_2XL,
|
|
33
|
+
OVERLAY_TITLE_HYPHENS,
|
|
34
|
+
OVERLAY_TITLE_WRAP,
|
|
35
|
+
textIsEnlarged,
|
|
36
|
+
} from "../util/overlay-chrome.js";
|
|
16
37
|
type Size = 'sm' | 'md' | 'lg';
|
|
38
|
+
type CloseReason = 'escape' | 'backdrop' | 'close-button';
|
|
17
39
|
|
|
18
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Anything else is an attribute of the dialog panel (`aria-describedby`,
|
|
42
|
+
* `aria-busy`, `data-*`, `id`), which is where it is spread.
|
|
43
|
+
*/
|
|
44
|
+
type Props = Omit<
|
|
45
|
+
HTMLAttributes<HTMLDivElement>,
|
|
46
|
+
'class' | 'title' | 'role' | 'onclick' | 'onkeydown' | 'onclose' | 'children'
|
|
47
|
+
> & {
|
|
19
48
|
isOpen?: boolean;
|
|
20
49
|
title?: string;
|
|
21
50
|
description?: string;
|
|
22
51
|
size?: Size;
|
|
52
|
+
/**
|
|
53
|
+
* `alertdialog` for a dialog that interrupts to ask for a response,
|
|
54
|
+
* such as a confirmation. Focus handling is the same for both.
|
|
55
|
+
*/
|
|
56
|
+
role?: 'dialog' | 'alertdialog';
|
|
23
57
|
/** Render the close button even when there is no `title`. */
|
|
24
58
|
showClose?: boolean;
|
|
59
|
+
/** Accessible name of the close button. */
|
|
60
|
+
closeLabel?: string;
|
|
61
|
+
/**
|
|
62
|
+
* CSS selector, looked up inside the panel, of the control that takes
|
|
63
|
+
* focus when the modal opens (a search field, or the safe choice in a
|
|
64
|
+
* confirmation). Without it, or with no match, the first control does.
|
|
65
|
+
*/
|
|
66
|
+
initialFocus?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Render the overlay in `document.body` instead of in place, so an
|
|
69
|
+
* ancestor with a transform, filter or clipped overflow cannot trap it.
|
|
70
|
+
* The theme class belongs on `<html>` or `<body>`; set lower, it does
|
|
71
|
+
* not reach a portalled modal.
|
|
72
|
+
*/
|
|
73
|
+
portal?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* When false, Escape, a backdrop click and the close button do not
|
|
76
|
+
* close the modal (a pending confirm, say). Focus stays trapped, and
|
|
77
|
+
* setting `isOpen` yourself still closes it.
|
|
78
|
+
*/
|
|
79
|
+
dismissible?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Fills the screen instead of floating over it: for a long form on a
|
|
82
|
+
* phone. `true` at every width; `"mobile"` below 768px only, with the
|
|
83
|
+
* usual dialog from there up. The title and the close button stay at
|
|
84
|
+
* the top, the footer at the bottom, both inside the safe areas, and
|
|
85
|
+
* the content scrolls between them. Everything else is the same
|
|
86
|
+
* dialog: focus, Escape, `dismissible`, `portal`.
|
|
87
|
+
*/
|
|
88
|
+
fullScreen?: boolean | 'mobile';
|
|
89
|
+
/** Fired when the modal closes itself, with what the user did. */
|
|
90
|
+
onclose?: (detail: { reason: CloseReason }) => void;
|
|
91
|
+
/**
|
|
92
|
+
* @deprecated for close reporting: use `onclose`. Still called with the
|
|
93
|
+
* event on Escape, a backdrop click and the close button.
|
|
94
|
+
*/
|
|
25
95
|
onclick?: (event: Event) => void;
|
|
26
96
|
onkeydown?: (event: Event) => void;
|
|
27
97
|
/** On the `role="dialog"` panel (testing, analytics). */
|
|
@@ -30,15 +100,22 @@
|
|
|
30
100
|
class?: string;
|
|
31
101
|
children?: Snippet;
|
|
32
102
|
footer?: Snippet;
|
|
33
|
-
}
|
|
103
|
+
};
|
|
34
104
|
|
|
35
105
|
let {
|
|
36
|
-
isOpen = $bindable(
|
|
106
|
+
isOpen = $bindable<Exclude<Props["isOpen"], undefined>>(),
|
|
37
107
|
title = '',
|
|
38
108
|
description = '',
|
|
39
109
|
size = 'md',
|
|
110
|
+
role = 'dialog',
|
|
40
111
|
showClose = true,
|
|
112
|
+
closeLabel: closeLabelGiven,
|
|
113
|
+
initialFocus,
|
|
114
|
+
portal = false,
|
|
115
|
+
dismissible = true,
|
|
116
|
+
fullScreen = false,
|
|
41
117
|
class: className = "",
|
|
118
|
+
onclose,
|
|
42
119
|
onclick,
|
|
43
120
|
onkeydown,
|
|
44
121
|
"data-testid": dataTestId = undefined,
|
|
@@ -47,11 +124,28 @@
|
|
|
47
124
|
...restProps
|
|
48
125
|
}: Props = $props();
|
|
49
126
|
|
|
127
|
+
/** Words many components share: a `ZabiStringsProvider` above this one may give them; else English. */
|
|
128
|
+
const common = zabiCommonStrings();
|
|
129
|
+
const closeLabel = $derived(closeLabelGiven ?? common().close);
|
|
130
|
+
|
|
131
|
+
// No fallback on a bindable prop: Svelte refuses `bind:…={undefined}` on
|
|
132
|
+
// one that has a fallback (`props_invalid_value`), and a page that throws
|
|
133
|
+
// while it hydrates never becomes interactive. The default is applied
|
|
134
|
+
// here instead: at once, for the server and the first render, and again
|
|
135
|
+
// whenever a parent hands back `undefined`.
|
|
136
|
+
const applyDefaults = () => {
|
|
137
|
+
if (isOpen === undefined) isOpen = false;
|
|
138
|
+
};
|
|
139
|
+
applyDefaults();
|
|
140
|
+
$effect.pre(applyDefaults);
|
|
141
|
+
|
|
50
142
|
const modalTitleId = generateId('modal-title');
|
|
51
143
|
const modalDescriptionId = generateId('modal-description');
|
|
52
144
|
|
|
53
145
|
let modalContainer = $state<HTMLDivElement>();
|
|
54
146
|
let focusActive = false;
|
|
147
|
+
/** Position among the open overlays; lifts a modal opened later above the earlier ones. */
|
|
148
|
+
let depth = $state(0);
|
|
55
149
|
|
|
56
150
|
const sizeClasses = $derived(
|
|
57
151
|
{
|
|
@@ -61,28 +155,94 @@
|
|
|
61
155
|
}[size] || 'w-full md:w-[28rem]',
|
|
62
156
|
);
|
|
63
157
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
158
|
+
/**
|
|
159
|
+
* What `fullScreen` adds to each part: `all` at every width, `mobile`
|
|
160
|
+
* below `md` only. Both are written out, because a class name put
|
|
161
|
+
* together at run time is one Tailwind never sees.
|
|
162
|
+
*
|
|
163
|
+
* The panel is as tall as the visible screen (`dvh`: the browser's own
|
|
164
|
+
* bars are not counted) and the content scrolls inside it, so the header
|
|
165
|
+
* and the footer stay where they are. The content keeps 120px at least:
|
|
166
|
+
* when the header and the footer leave less (a long title in very large
|
|
167
|
+
* text on a small screen), the panel scrolls as a whole instead, and the
|
|
168
|
+
* footer is reached by scrolling rather than not at all. The gutters are
|
|
169
|
+
* in px: at 320px with enlarged text, a gutter that grew with the text
|
|
170
|
+
* would leave a form no room. Each is at least the safe-area inset on its
|
|
171
|
+
* side.
|
|
172
|
+
*/
|
|
173
|
+
const FULL_SCREEN = {
|
|
174
|
+
backdrop: { all: 'md:p-0', mobile: '' },
|
|
175
|
+
panel: {
|
|
176
|
+
all: 'h-dvh max-h-none rounded-none border-0 md:w-full md:rounded-none',
|
|
177
|
+
mobile: 'max-md:h-dvh max-md:max-h-none max-md:rounded-none max-md:border-0',
|
|
178
|
+
},
|
|
179
|
+
card: {
|
|
180
|
+
all: 'flex min-h-0 flex-1 flex-col rounded-none md:rounded-none',
|
|
181
|
+
mobile: 'max-md:flex max-md:min-h-0 max-md:flex-1 max-md:flex-col max-md:rounded-none',
|
|
182
|
+
},
|
|
183
|
+
header: {
|
|
184
|
+
all: 'shrink-0 border-b border-border pt-[max(24px,calc(env(safe-area-inset-top,0px)_+_8px))] pr-[max(24px,env(safe-area-inset-right,0px))] pl-[max(24px,env(safe-area-inset-left,0px))]',
|
|
185
|
+
mobile: 'max-md:shrink-0 max-md:border-b max-md:border-border max-md:pt-[max(24px,calc(env(safe-area-inset-top,0px)_+_8px))] max-md:pr-[max(24px,env(safe-area-inset-right,0px))] max-md:pl-[max(24px,env(safe-area-inset-left,0px))]',
|
|
186
|
+
},
|
|
187
|
+
content: {
|
|
188
|
+
all: 'flex min-h-[120px] flex-col',
|
|
189
|
+
mobile: 'max-md:flex max-md:min-h-[120px] max-md:flex-col',
|
|
190
|
+
},
|
|
191
|
+
/** From `md` up, `mobile` restates the padding of the usual dialog. */
|
|
192
|
+
scroller: {
|
|
193
|
+
all: 'min-h-0 flex-1 overflow-y-auto overscroll-contain pr-[max(24px,env(safe-area-inset-right,0px))] pl-[max(24px,env(safe-area-inset-left,0px))]',
|
|
194
|
+
mobile: 'max-md:min-h-0 max-md:flex-1 max-md:overflow-y-auto max-md:overscroll-contain max-md:pr-[max(24px,env(safe-area-inset-right,0px))] max-md:pl-[max(24px,env(safe-area-inset-left,0px))] md:px-[24px]',
|
|
195
|
+
},
|
|
196
|
+
scrollerBelowHeader: { all: 'pt-[16px]', mobile: 'max-md:pt-[16px]' },
|
|
197
|
+
scrollerAtTop: {
|
|
198
|
+
all: 'pt-[max(24px,calc(env(safe-area-inset-top,0px)_+_8px))]',
|
|
199
|
+
mobile: 'max-md:pt-[max(24px,calc(env(safe-area-inset-top,0px)_+_8px))] md:pt-[24px]',
|
|
200
|
+
},
|
|
201
|
+
scrollerAboveFooter: { all: 'pb-[16px]', mobile: 'max-md:pb-[16px]' },
|
|
202
|
+
scrollerAtBottom: {
|
|
203
|
+
all: 'pb-[max(24px,calc(env(safe-area-inset-bottom,0px)_+_8px))]',
|
|
204
|
+
mobile: 'max-md:pb-[max(24px,calc(env(safe-area-inset-bottom,0px)_+_8px))] md:pb-[24px]',
|
|
205
|
+
},
|
|
206
|
+
footer: {
|
|
207
|
+
all: 'shrink-0 flex-wrap border-t border-border pr-[max(24px,env(safe-area-inset-right,0px))] pb-[max(24px,calc(env(safe-area-inset-bottom,0px)_+_8px))] pl-[max(24px,env(safe-area-inset-left,0px))] group-data-keyboard-open/overlay:pb-[24px]',
|
|
208
|
+
mobile: 'max-md:shrink-0 max-md:flex-wrap max-md:border-t max-md:border-border max-md:pr-[max(24px,env(safe-area-inset-right,0px))] max-md:pb-[max(24px,calc(env(safe-area-inset-bottom,0px)_+_8px))] max-md:pl-[max(24px,env(safe-area-inset-left,0px))] max-md:group-data-keyboard-open/overlay:pb-[24px]',
|
|
209
|
+
},
|
|
210
|
+
} as const;
|
|
211
|
+
|
|
212
|
+
const fullScreenMode = $derived(
|
|
213
|
+
fullScreen === 'mobile' ? 'mobile' : fullScreen ? 'all' : null,
|
|
214
|
+
);
|
|
215
|
+
const full = (part: keyof typeof FULL_SCREEN) =>
|
|
216
|
+
fullScreenMode ? FULL_SCREEN[part][fullScreenMode] : '';
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The scrolling content of a full-screen modal. With nothing in it that
|
|
220
|
+
* takes focus (a long text), the keyboard could not scroll it, so it is a
|
|
221
|
+
* Tab stop itself for as long as that is true.
|
|
222
|
+
*/
|
|
223
|
+
let scroller = $state<HTMLDivElement>();
|
|
224
|
+
let scrollerNeedsFocus = $state(false);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read as the overlay opens: with the text enlarged, a long word in the
|
|
228
|
+
* title is hyphenated before it is cut (util/overlay-chrome.ts).
|
|
229
|
+
*/
|
|
230
|
+
let enlargedText = $state(false);
|
|
231
|
+
$effect(() => {
|
|
232
|
+
if (isOpen) enlargedText = textIsEnlarged();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
$effect(() => {
|
|
236
|
+
const box = scroller;
|
|
237
|
+
if (!isOpen || !box) {
|
|
238
|
+
scrollerNeedsFocus = false;
|
|
239
|
+
return;
|
|
71
240
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const next = Number(body.dataset.zabiScrollLock ?? '1') - 1;
|
|
75
|
-
if (next <= 0) {
|
|
76
|
-
body.style.overflow = body.dataset.zabiScrollLockOverflow ?? '';
|
|
77
|
-
delete body.dataset.zabiScrollLock;
|
|
78
|
-
delete body.dataset.zabiScrollLockOverflow;
|
|
79
|
-
} else {
|
|
80
|
-
body.dataset.zabiScrollLock = String(next);
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
}
|
|
241
|
+
return watchScrollerNeedsFocus(box, (needsFocus) => (scrollerNeedsFocus = needsFocus));
|
|
242
|
+
});
|
|
84
243
|
|
|
85
|
-
function closeModal(event?: Event) {
|
|
244
|
+
function closeModal(reason: CloseReason, event?: Event) {
|
|
245
|
+
if (!dismissible) return;
|
|
86
246
|
isOpen = false;
|
|
87
247
|
if (focusActive) {
|
|
88
248
|
focusActive = false;
|
|
@@ -91,6 +251,7 @@
|
|
|
91
251
|
if (onclick && event) {
|
|
92
252
|
onclick(event);
|
|
93
253
|
}
|
|
254
|
+
onclose?.({ reason });
|
|
94
255
|
}
|
|
95
256
|
|
|
96
257
|
$effect(() => {
|
|
@@ -99,11 +260,44 @@
|
|
|
99
260
|
saveFocus();
|
|
100
261
|
focusActive = true;
|
|
101
262
|
const unlockScroll = lockBodyScroll();
|
|
263
|
+
const overlay = joinOverlayStack(container);
|
|
264
|
+
depth = overlay.depth;
|
|
102
265
|
const t = setTimeout(() => {
|
|
103
|
-
|
|
266
|
+
// Something inside has taken focus already (a field that
|
|
267
|
+
// focuses itself as it mounts): leave it there. `initialFocus`
|
|
268
|
+
// is the consumer saying where focus goes, so it still decides.
|
|
269
|
+
const active = document.activeElement;
|
|
270
|
+
const inside = !!active && active !== container && container.contains(active);
|
|
271
|
+
if (inside && !(initialFocus && container.querySelector(initialFocus))) return;
|
|
272
|
+
focusFirstElement(container, initialFocus);
|
|
104
273
|
}, 0);
|
|
274
|
+
// Focus can leave the dialog without a Tab (a confirm that starts
|
|
275
|
+
// loading disables the focused button); the topmost dialog takes
|
|
276
|
+
// Tab and Escape back from `<body>`.
|
|
277
|
+
const stopRecovery = recoverStrayFocus(container, {
|
|
278
|
+
onEscape: (event) => closeModal('escape', event),
|
|
279
|
+
});
|
|
280
|
+
// Where the on-screen keyboard covers the page, the overlay
|
|
281
|
+
// keeps to what is left above it.
|
|
282
|
+
const stopKeyboard = container.parentElement
|
|
283
|
+
? followKeyboard(container.parentElement)
|
|
284
|
+
: undefined;
|
|
285
|
+
// The footer, for the toast stack to stay off it. The panel holds
|
|
286
|
+
// the Card, and the Card the footer: nothing in the content matches.
|
|
287
|
+
const pinned = footer
|
|
288
|
+
? container.querySelector<HTMLElement>(':scope > div > footer')
|
|
289
|
+
: null;
|
|
290
|
+
const forgetFooter = pinned ? registerOverlayFooter(pinned) : undefined;
|
|
291
|
+
// And the header, which holds the close button: toasts start below it.
|
|
292
|
+
const top = container.querySelector<HTMLElement>(':scope > div > header');
|
|
293
|
+
const forgetHeader = top ? registerOverlayHeader(top) : undefined;
|
|
105
294
|
return () => {
|
|
106
295
|
clearTimeout(t);
|
|
296
|
+
stopRecovery();
|
|
297
|
+
stopKeyboard?.();
|
|
298
|
+
forgetFooter?.();
|
|
299
|
+
forgetHeader?.();
|
|
300
|
+
overlay.leave();
|
|
107
301
|
unlockScroll();
|
|
108
302
|
if (focusActive) {
|
|
109
303
|
focusActive = false;
|
|
@@ -113,48 +307,19 @@
|
|
|
113
307
|
}
|
|
114
308
|
});
|
|
115
309
|
|
|
116
|
-
/** Re-queries focusables on every Tab so content added while open stays inside the trap. */
|
|
117
|
-
function handleTrapKeydown(event: KeyboardEvent) {
|
|
118
|
-
const container = modalContainer;
|
|
119
|
-
if (event.key !== 'Tab' || !container) return;
|
|
120
|
-
// A nested dialog handles its own Tab cycle.
|
|
121
|
-
const owner = (event.target as Element | null)?.closest?.('[role="dialog"]');
|
|
122
|
-
if (owner && owner !== container) return;
|
|
123
|
-
|
|
124
|
-
const focusable = getFocusableElements(container);
|
|
125
|
-
if (focusable.length === 0) {
|
|
126
|
-
event.preventDefault();
|
|
127
|
-
container.focus();
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
const first = focusable[0];
|
|
131
|
-
const last = focusable[focusable.length - 1];
|
|
132
|
-
const active = document.activeElement as HTMLElement | null;
|
|
133
|
-
|
|
134
|
-
if (!active || !focusable.includes(active)) {
|
|
135
|
-
event.preventDefault();
|
|
136
|
-
first.focus();
|
|
137
|
-
} else if (event.shiftKey && active === first) {
|
|
138
|
-
event.preventDefault();
|
|
139
|
-
last.focus();
|
|
140
|
-
} else if (!event.shiftKey && active === last) {
|
|
141
|
-
event.preventDefault();
|
|
142
|
-
first.focus();
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
310
|
function handleBackdropClick(event: Event) {
|
|
147
311
|
if (event.target === event.currentTarget) {
|
|
148
|
-
closeModal(event);
|
|
312
|
+
closeModal('backdrop', event);
|
|
149
313
|
}
|
|
150
314
|
}
|
|
151
315
|
|
|
152
316
|
function handleKeydown(event: Event) {
|
|
153
317
|
const keyboardEvent = event as KeyboardEvent;
|
|
154
|
-
// `defaultPrevented` means a nested dialog already
|
|
318
|
+
// `defaultPrevented` means a nested dialog already handled the key:
|
|
319
|
+
// it closed itself, or it is not dismissible and must keep its parent open.
|
|
155
320
|
if (keyboardEvent.key === 'Escape' && !keyboardEvent.defaultPrevented) {
|
|
156
321
|
keyboardEvent.preventDefault();
|
|
157
|
-
closeModal(event);
|
|
322
|
+
closeModal('escape', event);
|
|
158
323
|
}
|
|
159
324
|
onkeydown?.(event);
|
|
160
325
|
}
|
|
@@ -163,7 +328,14 @@
|
|
|
163
328
|
{#if isOpen}
|
|
164
329
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
165
330
|
<div
|
|
166
|
-
class=
|
|
331
|
+
class={cn(
|
|
332
|
+
"group/overlay fixed inset-0 z-modal flex items-end justify-center bg-overlay p-0 md:items-center md:p-4",
|
|
333
|
+
dismissible ? 'cursor-pointer' : 'cursor-default',
|
|
334
|
+
full('backdrop'),
|
|
335
|
+
)}
|
|
336
|
+
style:z-index={depth > 0 ? `calc(var(--z-modal) + ${depth})` : undefined}
|
|
337
|
+
data-overlay-depth={depth}
|
|
338
|
+
use:portalTo={portal}
|
|
167
339
|
onclick={handleBackdropClick}
|
|
168
340
|
onkeydown={handleKeydown}
|
|
169
341
|
role="presentation"
|
|
@@ -171,42 +343,65 @@
|
|
|
171
343
|
<div
|
|
172
344
|
bind:this={modalContainer}
|
|
173
345
|
class={cn(
|
|
174
|
-
|
|
346
|
+
// With the keyboard up the root is what is left of the screen: never taller than that.
|
|
347
|
+
"group-data-keyboard-open/overlay:max-h-full",
|
|
348
|
+
"flex max-h-[90dvh] min-w-[320px] cursor-default flex-col overflow-y-auto rounded-t-overlay border border-border-overlay bg-surface-overlay p-0 shadow-lg animate-[slideUp_0.3s_ease-out] motion-reduce:animate-none md:animate-none md:rounded-overlay",
|
|
175
349
|
sizeClasses,
|
|
350
|
+
full('panel'),
|
|
176
351
|
className,
|
|
177
352
|
)}
|
|
178
|
-
role
|
|
353
|
+
{role}
|
|
179
354
|
aria-modal="true"
|
|
180
355
|
aria-labelledby={title ? modalTitleId : undefined}
|
|
181
356
|
aria-describedby={description ? modalDescriptionId : undefined}
|
|
182
357
|
tabindex="-1"
|
|
183
358
|
data-testid={dataTestId}
|
|
184
|
-
|
|
359
|
+
data-full-screen={fullScreenMode === 'all' ? 'true' : (fullScreenMode ?? undefined)}
|
|
360
|
+
onkeydown={(event) => trapTabKey(modalContainer, event)}
|
|
185
361
|
{...restProps}
|
|
186
362
|
>
|
|
187
363
|
<!-- The panel owns padding: Card's own p-6 would stack with the header's px-6/pt-6 and indent the title 24px past the body. -->
|
|
188
|
-
|
|
364
|
+
<!-- And the corners: the Card sits 1px inside the panel (its
|
|
365
|
+
border), so its radius is the panel's less that pixel, not a
|
|
366
|
+
Card's own 12px. Below `md` the panel is a sheet with square
|
|
367
|
+
bottom corners, and so is the Card. -->
|
|
368
|
+
<Card
|
|
369
|
+
variant="flat"
|
|
370
|
+
fullWidth={false}
|
|
371
|
+
className={cn(
|
|
372
|
+
"bg-transparent! p-0! rounded-b-none rounded-t-[calc(var(--radius-overlay)-1px)] md:rounded-[calc(var(--radius-overlay)-1px)]",
|
|
373
|
+
full('card'),
|
|
374
|
+
)}
|
|
375
|
+
>
|
|
189
376
|
{#if title || description || showClose}
|
|
377
|
+
<!-- Chrome: laid out in px, with a title that stops at 1.3
|
|
378
|
+
times its size (util/overlay-chrome.ts). The padding of the
|
|
379
|
+
content and the footer is px with it, so they stay in line. -->
|
|
190
380
|
<CardHeader
|
|
191
381
|
{description}
|
|
192
382
|
descriptionId={description ? modalDescriptionId : undefined}
|
|
193
|
-
className="px-6 pt-6 pb-4"
|
|
383
|
+
className={cn("px-6 pt-6 pb-4", OVERLAY_CHROME, full('header'))}
|
|
194
384
|
>
|
|
195
|
-
|
|
385
|
+
<!-- 12px between a long title and the button: the title wraps, the button keeps its size. -->
|
|
386
|
+
<div class="flex items-center gap-[12px] {title ? 'justify-between' : 'justify-end'}">
|
|
196
387
|
{#if title}
|
|
197
388
|
<h2
|
|
198
389
|
id={modalTitleId}
|
|
199
|
-
class="
|
|
390
|
+
class="min-w-0 {OVERLAY_TITLE_2XL} font-normal tracking-normal text-headline {OVERLAY_TITLE_WRAP} {enlargedText ? OVERLAY_TITLE_HYPHENS : ''}"
|
|
200
391
|
>
|
|
201
392
|
{title}
|
|
202
393
|
</h2>
|
|
203
394
|
{/if}
|
|
204
395
|
{#if showClose}
|
|
396
|
+
<!-- `aria-disabled`, not `disabled` or removed: the button may hold focus when `dismissible` turns false, and a disabled or missing element would drop focus out of the trap. -->
|
|
205
397
|
<button
|
|
206
398
|
type="button"
|
|
207
|
-
onclick={closeModal}
|
|
208
|
-
class="focus-ring flex size-8
|
|
209
|
-
|
|
399
|
+
onclick={(event) => closeModal('close-button', event)}
|
|
400
|
+
class="focus-ring pointer-coarse:relative flex size-8 shrink-0 items-center justify-center rounded-control {OVERLAY_CLOSE_GLYPH} text-description transition-colors {TOUCH_HIT_AREA} {dismissible
|
|
401
|
+
? 'cursor-pointer hover:bg-surface-overlay-hover hover:text-headline active:bg-surface-active'
|
|
402
|
+
: 'cursor-not-allowed opacity-50'}"
|
|
403
|
+
aria-label={closeLabel}
|
|
404
|
+
aria-disabled={dismissible ? undefined : "true"}
|
|
210
405
|
>
|
|
211
406
|
×
|
|
212
407
|
</button>
|
|
@@ -215,16 +410,37 @@
|
|
|
215
410
|
</CardHeader>
|
|
216
411
|
{/if}
|
|
217
412
|
|
|
218
|
-
{#if children}
|
|
413
|
+
{#if children && fullScreenMode}
|
|
414
|
+
<!-- The padding is on the box that scrolls, so its scrollbar is at the edge of the screen. -->
|
|
415
|
+
<CardContent className={cn("flex-1", full('content'))}>
|
|
416
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
417
|
+
<div
|
|
418
|
+
bind:this={scroller}
|
|
419
|
+
data-modal-content
|
|
420
|
+
class={cn(
|
|
421
|
+
full('scroller'),
|
|
422
|
+
full(title || description || showClose ? 'scrollerBelowHeader' : 'scrollerAtTop'),
|
|
423
|
+
full(footer ? 'scrollerAboveFooter' : 'scrollerAtBottom'),
|
|
424
|
+
scrollerNeedsFocus &&
|
|
425
|
+
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus-ring",
|
|
426
|
+
)}
|
|
427
|
+
tabindex={scrollerNeedsFocus ? 0 : undefined}
|
|
428
|
+
role={scrollerNeedsFocus && title ? "group" : undefined}
|
|
429
|
+
aria-labelledby={scrollerNeedsFocus && title ? modalTitleId : undefined}
|
|
430
|
+
>
|
|
431
|
+
{@render children?.()}
|
|
432
|
+
</div>
|
|
433
|
+
</CardContent>
|
|
434
|
+
{:else if children}
|
|
219
435
|
<CardContent
|
|
220
|
-
className="flex-1 px-
|
|
436
|
+
className="flex-1 px-[24px] {title || description || showClose ? '' : 'pt-[24px]'} {footer ? '' : 'pb-[24px]'}"
|
|
221
437
|
>
|
|
222
438
|
{@render children?.()}
|
|
223
439
|
</CardContent>
|
|
224
440
|
{/if}
|
|
225
441
|
|
|
226
442
|
{#if footer}
|
|
227
|
-
<CardFooter className="flex justify-end gap-
|
|
443
|
+
<CardFooter className={cn("flex justify-end gap-[12px] p-[24px] pt-[16px]", full('footer'))}>
|
|
228
444
|
{@render footer?.()}
|
|
229
445
|
</CardFooter>
|
|
230
446
|
{/if}
|
|
@@ -1,12 +1,61 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
3
|
type Size = 'sm' | 'md' | 'lg';
|
|
3
|
-
|
|
4
|
+
type CloseReason = 'escape' | 'backdrop' | 'close-button';
|
|
5
|
+
/**
|
|
6
|
+
* Anything else is an attribute of the dialog panel (`aria-describedby`,
|
|
7
|
+
* `aria-busy`, `data-*`, `id`), which is where it is spread.
|
|
8
|
+
*/
|
|
9
|
+
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'title' | 'role' | 'onclick' | 'onkeydown' | 'onclose' | 'children'> & {
|
|
4
10
|
isOpen?: boolean;
|
|
5
11
|
title?: string;
|
|
6
12
|
description?: string;
|
|
7
13
|
size?: Size;
|
|
14
|
+
/**
|
|
15
|
+
* `alertdialog` for a dialog that interrupts to ask for a response,
|
|
16
|
+
* such as a confirmation. Focus handling is the same for both.
|
|
17
|
+
*/
|
|
18
|
+
role?: 'dialog' | 'alertdialog';
|
|
8
19
|
/** Render the close button even when there is no `title`. */
|
|
9
20
|
showClose?: boolean;
|
|
21
|
+
/** Accessible name of the close button. */
|
|
22
|
+
closeLabel?: string;
|
|
23
|
+
/**
|
|
24
|
+
* CSS selector, looked up inside the panel, of the control that takes
|
|
25
|
+
* focus when the modal opens (a search field, or the safe choice in a
|
|
26
|
+
* confirmation). Without it, or with no match, the first control does.
|
|
27
|
+
*/
|
|
28
|
+
initialFocus?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Render the overlay in `document.body` instead of in place, so an
|
|
31
|
+
* ancestor with a transform, filter or clipped overflow cannot trap it.
|
|
32
|
+
* The theme class belongs on `<html>` or `<body>`; set lower, it does
|
|
33
|
+
* not reach a portalled modal.
|
|
34
|
+
*/
|
|
35
|
+
portal?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* When false, Escape, a backdrop click and the close button do not
|
|
38
|
+
* close the modal (a pending confirm, say). Focus stays trapped, and
|
|
39
|
+
* setting `isOpen` yourself still closes it.
|
|
40
|
+
*/
|
|
41
|
+
dismissible?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Fills the screen instead of floating over it: for a long form on a
|
|
44
|
+
* phone. `true` at every width; `"mobile"` below 768px only, with the
|
|
45
|
+
* usual dialog from there up. The title and the close button stay at
|
|
46
|
+
* the top, the footer at the bottom, both inside the safe areas, and
|
|
47
|
+
* the content scrolls between them. Everything else is the same
|
|
48
|
+
* dialog: focus, Escape, `dismissible`, `portal`.
|
|
49
|
+
*/
|
|
50
|
+
fullScreen?: boolean | 'mobile';
|
|
51
|
+
/** Fired when the modal closes itself, with what the user did. */
|
|
52
|
+
onclose?: (detail: {
|
|
53
|
+
reason: CloseReason;
|
|
54
|
+
}) => void;
|
|
55
|
+
/**
|
|
56
|
+
* @deprecated for close reporting: use `onclose`. Still called with the
|
|
57
|
+
* event on Escape, a backdrop click and the close button.
|
|
58
|
+
*/
|
|
10
59
|
onclick?: (event: Event) => void;
|
|
11
60
|
onkeydown?: (event: Event) => void;
|
|
12
61
|
/** On the `role="dialog"` panel (testing, analytics). */
|
|
@@ -15,7 +64,7 @@ interface Props {
|
|
|
15
64
|
class?: string;
|
|
16
65
|
children?: Snippet;
|
|
17
66
|
footer?: Snippet;
|
|
18
|
-
}
|
|
67
|
+
};
|
|
19
68
|
declare const Modal: import("svelte").Component<Props, {}, "isOpen">;
|
|
20
69
|
type Modal = ReturnType<typeof Modal>;
|
|
21
70
|
export default Modal;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
import { getContext } from "svelte";
|
|
4
4
|
import { cn } from "../util/cn.js";
|
|
5
|
+
import { isInsideToastRegion } from "../util/focus-utils.js";
|
|
6
|
+
import { measurePlacement, watchViewport } from "../util/fit-in-viewport.js";
|
|
5
7
|
import {
|
|
6
8
|
NAVIGATION_MENU_CONTEXT_KEY,
|
|
7
9
|
navigationMenuPanelId,
|
|
@@ -42,6 +44,52 @@
|
|
|
42
44
|
: "",
|
|
43
45
|
);
|
|
44
46
|
|
|
47
|
+
/** Room kept between the panel and each side of the screen: its width is the viewport less 16px at most. */
|
|
48
|
+
const VIEWPORT_MARGIN = 8;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How far the panel is moved from under the left edge of its item to stay on
|
|
52
|
+
* screen, and its width limit when it is wider than the screen. The panel
|
|
53
|
+
* of the second trigger used to span x 182 to 382 in a 375px viewport.
|
|
54
|
+
*/
|
|
55
|
+
let leftOffset = $state(0);
|
|
56
|
+
let maxWidth = $state<number | null>(null);
|
|
57
|
+
/**
|
|
58
|
+
* Set when a box that scrolls around the menu would cut the panel off: it
|
|
59
|
+
* is then placed against the viewport with `position: fixed`, which such
|
|
60
|
+
* a box does not clip, at these coordinates. It stays where it is in the
|
|
61
|
+
* DOM, so Escape, the outside press and focus work as before.
|
|
62
|
+
*/
|
|
63
|
+
let fixedAt = $state<{ left: number; top: number; width: number } | null>(null);
|
|
64
|
+
|
|
65
|
+
// After the panel is in the DOM and before it is painted, then whenever the screen changes.
|
|
66
|
+
$effect(() => {
|
|
67
|
+
if (!isActive || !contentElement) {
|
|
68
|
+
leftOffset = 0;
|
|
69
|
+
maxWidth = null;
|
|
70
|
+
fixedAt = null;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const panel: HTMLElement = contentElement;
|
|
74
|
+
const anchor = panel.closest<HTMLElement>("[data-navigation-menu-item]") ?? panel;
|
|
75
|
+
const update = () => {
|
|
76
|
+
// The panel always opens below, from the left edge of its item
|
|
77
|
+
// (`left-0`, in either writing direction, as it always has), and
|
|
78
|
+
// is slid along that edge only as far as it takes to fit.
|
|
79
|
+
const fit = measurePlacement(
|
|
80
|
+
anchor,
|
|
81
|
+
panel,
|
|
82
|
+
{ block: "bottom", inline: "start" },
|
|
83
|
+
{ margin: VIEWPORT_MARGIN, flipBlock: false, flipInline: false, rtl: false },
|
|
84
|
+
);
|
|
85
|
+
leftOffset = fit.inlineOffset;
|
|
86
|
+
maxWidth = fit.maxWidth;
|
|
87
|
+
fixedAt = fit.fixed;
|
|
88
|
+
};
|
|
89
|
+
update();
|
|
90
|
+
return watchViewport(update, panel);
|
|
91
|
+
});
|
|
92
|
+
|
|
45
93
|
/** Disclosure panel: one document listener while open, removed on close/unmount. */
|
|
46
94
|
$effect(() => {
|
|
47
95
|
if (!isActive || !contentElement) return;
|
|
@@ -53,6 +101,8 @@
|
|
|
53
101
|
|
|
54
102
|
function handleClickOutside(event: MouseEvent) {
|
|
55
103
|
const target = event.target as Node;
|
|
104
|
+
// A toast lies over the panel; a press on it is for the toast.
|
|
105
|
+
if (isInsideToastRegion(target)) return;
|
|
56
106
|
if (!panel.contains(target) && !parentItem?.contains(target)) {
|
|
57
107
|
context?.setActiveItem(null);
|
|
58
108
|
}
|
|
@@ -80,7 +130,14 @@
|
|
|
80
130
|
<div
|
|
81
131
|
bind:this={contentElement}
|
|
82
132
|
id={panelId || undefined}
|
|
83
|
-
class={cn("absolute left-0 top-full mt-2 bg-surface-overlay rounded-
|
|
133
|
+
class={cn("absolute left-0 top-full mt-2 bg-surface-overlay rounded-overlay shadow-lg border border-border-overlay p-4 z-dropdown min-w-[200px] transition-[opacity,translate] duration-200 ease-in-out", className)}
|
|
134
|
+
style:position={fixedAt ? "fixed" : undefined}
|
|
135
|
+
style:top={fixedAt ? `${fixedAt.top}px` : undefined}
|
|
136
|
+
style:left={fixedAt ? `${fixedAt.left}px` : leftOffset !== 0 ? `${leftOffset}px` : undefined}
|
|
137
|
+
style:margin={fixedAt ? "0" : undefined}
|
|
138
|
+
style:width={fixedAt ? `${fixedAt.width}px` : undefined}
|
|
139
|
+
style:max-width={maxWidth !== null ? `${maxWidth}px` : undefined}
|
|
140
|
+
style:min-width={maxWidth !== null ? "0" : undefined}
|
|
84
141
|
data-navigation-menu-content
|
|
85
142
|
{...restProps}
|
|
86
143
|
>
|