zabi-components 7.0.2 → 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 +287 -11
- package/THEME.md +149 -332
- package/THEMING.md +1329 -163
- package/dist/atoms/ActionPanel.svelte +24 -9
- package/dist/atoms/ActionPanel.svelte.d.ts +2 -0
- package/dist/atoms/Avatar.svelte +104 -0
- package/dist/atoms/Avatar.svelte.d.ts +34 -0
- package/dist/atoms/Badge.svelte +124 -58
- package/dist/atoms/Badge.svelte.d.ts +17 -5
- package/dist/atoms/Button.svelte +212 -62
- package/dist/atoms/Button.svelte.d.ts +33 -2
- package/dist/atoms/Card.svelte +31 -28
- package/dist/atoms/Card.svelte.d.ts +5 -2
- package/dist/atoms/CardContent.svelte +10 -2
- package/dist/atoms/CardContent.svelte.d.ts +2 -0
- package/dist/atoms/CardFooter.svelte +10 -2
- package/dist/atoms/CardFooter.svelte.d.ts +2 -0
- package/dist/atoms/CardHeader.svelte +26 -6
- package/dist/atoms/CardHeader.svelte.d.ts +2 -0
- package/dist/atoms/Checkbox.svelte +52 -21
- package/dist/atoms/Checkbox.svelte.d.ts +6 -2
- package/dist/atoms/CodeBlock.svelte +35 -9
- package/dist/atoms/CodeBlock.svelte.d.ts +13 -3
- package/dist/atoms/ColorPicker.svelte +272 -52
- package/dist/atoms/ColorPicker.svelte.d.ts +6 -1
- package/dist/atoms/Container.svelte +17 -10
- package/dist/atoms/DateField.svelte +107 -0
- package/dist/atoms/DateField.svelte.d.ts +53 -0
- package/dist/atoms/Divider.svelte +4 -4
- package/dist/atoms/FeatureCard.svelte +4 -3
- 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 +93 -32
- package/dist/atoms/Heading.svelte.d.ts +24 -4
- package/dist/atoms/IconButton.svelte +242 -53
- package/dist/atoms/IconButton.svelte.d.ts +49 -4
- package/dist/atoms/Input.svelte +267 -74
- package/dist/atoms/Input.svelte.d.ts +32 -2
- package/dist/atoms/List.svelte +13 -2
- package/dist/atoms/List.svelte.d.ts +2 -0
- package/dist/atoms/ListItem.svelte +44 -11
- package/dist/atoms/ListItem.svelte.d.ts +5 -0
- package/dist/atoms/ListItemLeading.svelte +4 -2
- package/dist/atoms/ListItemLeading.svelte.d.ts +1 -1
- package/dist/atoms/OptimizedImage.svelte +11 -2
- package/dist/atoms/OptimizedImage.svelte.d.ts +2 -0
- package/dist/atoms/Progress.svelte +31 -9
- package/dist/atoms/Progress.svelte.d.ts +6 -0
- package/dist/atoms/Radio.svelte +23 -3
- package/dist/atoms/Radio.svelte.d.ts +6 -2
- package/dist/atoms/Rating.svelte +494 -0
- package/dist/atoms/Rating.svelte.d.ts +56 -0
- package/dist/atoms/Select.svelte +475 -135
- package/dist/atoms/Select.svelte.d.ts +52 -2
- package/dist/atoms/SelectionControl.svelte +99 -18
- package/dist/atoms/SelectionControl.svelte.d.ts +6 -3
- package/dist/atoms/Skeleton.svelte +5 -3
- 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 +152 -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 +56 -15
- package/dist/atoms/Text.svelte.d.ts +13 -4
- package/dist/atoms/Textarea.svelte +57 -45
- package/dist/atoms/Textarea.svelte.d.ts +14 -2
- package/dist/atoms/ThemeToggle.svelte +238 -56
- package/dist/atoms/ThemeToggle.svelte.d.ts +28 -3
- package/dist/atoms/TimeField.svelte +108 -0
- package/dist/atoms/TimeField.svelte.d.ts +54 -0
- package/dist/atoms/Toast.svelte +25 -10
- package/dist/atoms/Toast.svelte.d.ts +2 -0
- package/dist/atoms/Toggle.svelte +60 -19
- package/dist/atoms/Toggle.svelte.d.ts +18 -2
- package/dist/atoms/Tooltip.svelte +583 -40
- package/dist/atoms/Tooltip.svelte.d.ts +26 -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 +83 -3
- 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 +6 -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/cn.d.ts +8 -0
- package/dist/components/util/cn.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/ssr-safe.d.ts +4 -4
- package/dist/components/util/ssr-safe.d.ts.map +1 -1
- 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 +83 -3
- package/dist/index.js +60 -8
- package/dist/molecules/Alert.svelte +73 -66
- package/dist/molecules/Alert.svelte.d.ts +9 -1
- 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 +25 -5
- package/dist/molecules/ComponentDemo.svelte.d.ts +5 -0
- package/dist/molecules/ConfirmDialog.svelte +253 -0
- package/dist/molecules/ConfirmDialog.svelte.d.ts +55 -0
- package/dist/molecules/ContactForm.svelte +46 -19
- package/dist/molecules/ContactForm.svelte.d.ts +10 -0
- package/dist/molecules/Drawer.svelte +394 -0
- package/dist/molecules/Drawer.svelte.d.ts +55 -0
- package/dist/molecules/Dropdown.svelte +540 -50
- package/dist/molecules/Dropdown.svelte.d.ts +49 -6
- package/dist/molecules/DropdownItem.svelte +122 -0
- package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
- package/dist/molecules/EmptyState.svelte +56 -10
- package/dist/molecules/EmptyState.svelte.d.ts +8 -0
- package/dist/molecules/Form.svelte +25 -14
- package/dist/molecules/Form.svelte.d.ts +13 -5
- package/dist/molecules/FormField.svelte +11 -2
- package/dist/molecules/FormField.svelte.d.ts +2 -0
- package/dist/molecules/Header.svelte +13 -3
- package/dist/molecules/Header.svelte.d.ts +2 -0
- package/dist/molecules/ImageUpload.svelte +422 -70
- package/dist/molecules/ImageUpload.svelte.d.ts +61 -5
- package/dist/molecules/MediaGrid.svelte +527 -0
- package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
- package/dist/molecules/Modal.svelte +340 -45
- package/dist/molecules/Modal.svelte.d.ts +59 -7
- package/dist/molecules/NavigationMenu.svelte +15 -3
- package/dist/molecules/NavigationMenu.svelte.d.ts +4 -0
- package/dist/molecules/NavigationMenuContent.svelte +91 -36
- package/dist/molecules/NavigationMenuContent.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuItem.svelte +10 -3
- package/dist/molecules/NavigationMenuItem.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuLink.svelte +10 -2
- package/dist/molecules/NavigationMenuLink.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuList.svelte +19 -4
- package/dist/molecules/NavigationMenuList.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuTrigger.svelte +16 -3
- package/dist/molecules/NavigationMenuTrigger.svelte.d.ts +2 -0
- package/dist/molecules/Page.svelte +38 -2
- package/dist/molecules/Page.svelte.d.ts +13 -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 +26 -11
- package/dist/molecules/PropsTable.svelte.d.ts +5 -0
- package/dist/molecules/PullToRefresh.svelte +263 -0
- package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
- package/dist/molecules/RadioGroup.svelte +57 -15
- package/dist/molecules/RadioGroup.svelte.d.ts +16 -3
- package/dist/molecules/Section.svelte +13 -5
- package/dist/molecules/Section.svelte.d.ts +3 -1
- package/dist/molecules/SegmentedControl.svelte +323 -0
- package/dist/molecules/SegmentedControl.svelte.d.ts +34 -0
- package/dist/molecules/SidebarBrandHeader.svelte +24 -5
- package/dist/molecules/SidebarBrandHeader.svelte.d.ts +5 -0
- package/dist/molecules/SidebarFooter.svelte +39 -8
- package/dist/molecules/SidebarFooter.svelte.d.ts +5 -0
- package/dist/molecules/SidebarNavSection.svelte +12 -4
- package/dist/molecules/SidebarNavSection.svelte.d.ts +2 -0
- package/dist/molecules/SlideUp.svelte +253 -30
- package/dist/molecules/SlideUp.svelte.d.ts +31 -4
- 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 +254 -24
- package/dist/molecules/Tabs.svelte.d.ts +15 -4
- package/dist/molecules/Toaster.svelte +352 -6
- package/dist/molecules/Toaster.svelte.d.ts +95 -2
- package/dist/molecules/ToasterToast.svelte +279 -62
- package/dist/molecules/ToasterToast.svelte.d.ts +11 -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 +111 -12
- package/dist/organisms/SidebarAccountPanel.svelte.d.ts +21 -0
- package/dist/organisms/SidebarNavigation.svelte +193 -69
- package/dist/organisms/SidebarNavigation.svelte.d.ts +31 -1
- package/dist/organisms/SidebarPanel.svelte +45 -16
- package/dist/organisms/SidebarPanel.svelte.d.ts +2 -0
- package/dist/organisms/SidebarShell.svelte +263 -0
- package/dist/organisms/SidebarShell.svelte.d.ts +106 -0
- package/dist/organisms/TopNavbar.svelte +329 -33
- package/dist/organisms/TopNavbar.svelte.d.ts +28 -0
- package/dist/organisms/index.d.ts +6 -0
- package/dist/organisms/index.js +4 -0
- package/dist/routes/lib/variant-utils.d.ts +26 -0
- package/dist/routes/lib/{variant-utils.ts → variant-utils.js} +15 -46
- package/dist/types/components.d.ts +4 -0
- package/dist/types/components.d.ts.map +1 -1
- package/dist/types/events.js +1 -0
- package/dist/types/index.d.ts +605 -25
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +3 -0
- 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/cn.d.ts +7 -0
- package/dist/util/cn.js +48 -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/ssr-safe.d.ts +4 -4
- 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 +574 -225
- package/dist/zabi-components-theme-dark.css +574 -225
- package/dist/zabi-components-theme-only.css +1680 -317
- package/dist/zabi-components-theme.css +1680 -317
- package/dist/zabi-components.css +4473 -1158
- package/docs/lucide-icons.md +41 -16
- package/docs/theme-imports.md +81 -11
- package/package.json +51 -29
- package/dist/lib/layout-width-tokens.d.ts +0 -17
- package/dist/lib/layout-width-tokens.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Button.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Button.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Input.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Input.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/List.d.ts +0 -3
- package/dist/lib/showcase/component-docs/List.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Modal.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Modal.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Radio.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Radio.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts +0 -3
- package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Skeleton.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Skeleton.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/_shared.d.ts +0 -12
- package/dist/lib/showcase/component-docs/_shared.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/index.d.ts +0 -4
- package/dist/lib/showcase/component-docs/index.d.ts.map +0 -1
- package/dist/lib/showcase/components-catalog.d.ts +0 -3
- package/dist/lib/showcase/components-catalog.d.ts.map +0 -1
- package/dist/lib/showcase/components-showcase-constants.d.ts +0 -89
- package/dist/lib/showcase/components-showcase-constants.d.ts.map +0 -1
- package/dist/lib/showcase/docs-sidebar-helpers.d.ts +0 -15
- package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +0 -1
- package/dist/routes/lib/focus-utils.d.ts +0 -12
- package/dist/routes/lib/focus-utils.ts +0 -110
- package/dist/routes/lib/ssr-safe.ts +0 -58
- package/scripts/fix-lucide-svelte-icon-dts.js +0 -35
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { mergeStrings } from "../util/ready-made-strings.js";
|
|
3
|
+
import { zabiStringsFor } from "../util/zabi-strings.js";
|
|
4
|
+
import { tick, untrack } from "svelte";
|
|
5
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
6
|
+
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
|
7
|
+
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
|
8
|
+
import Ellipsis from "@lucide/svelte/icons/ellipsis";
|
|
9
|
+
import ImageOff from "@lucide/svelte/icons/image-off";
|
|
10
|
+
import X from "@lucide/svelte/icons/x";
|
|
11
|
+
import Button from "../atoms/Button.svelte";
|
|
12
|
+
import IconButton from "../atoms/IconButton.svelte";
|
|
13
|
+
import Spinner from "../atoms/Spinner.svelte";
|
|
14
|
+
import Dropdown from "./Dropdown.svelte";
|
|
15
|
+
import { isDevBuild } from "../util/app-shell.js";
|
|
16
|
+
import { cn } from "../util/cn.js";
|
|
17
|
+
import {
|
|
18
|
+
focusFirstElement,
|
|
19
|
+
joinOverlayStack,
|
|
20
|
+
recoverStrayFocus,
|
|
21
|
+
returnFocus,
|
|
22
|
+
saveFocus,
|
|
23
|
+
} from "../util/focus-utils.js";
|
|
24
|
+
import { lockBodyScroll, trapTabKey } from "../util/overlay.js";
|
|
25
|
+
import {
|
|
26
|
+
clampIndex,
|
|
27
|
+
doubleTapTransform,
|
|
28
|
+
dragAxis,
|
|
29
|
+
isDoubleTap,
|
|
30
|
+
isZoomed,
|
|
31
|
+
panBy,
|
|
32
|
+
PHOTO_VIEWER_STRINGS,
|
|
33
|
+
photoKey,
|
|
34
|
+
pinchSample,
|
|
35
|
+
pinchTransform,
|
|
36
|
+
resist,
|
|
37
|
+
REST,
|
|
38
|
+
settleZoom,
|
|
39
|
+
splitActions,
|
|
40
|
+
swipeCloses,
|
|
41
|
+
swipeTurn,
|
|
42
|
+
TAP_MS,
|
|
43
|
+
TAP_SLOP,
|
|
44
|
+
ZOOM_STEP,
|
|
45
|
+
zoomAbout,
|
|
46
|
+
type Photo,
|
|
47
|
+
type PhotoTransform,
|
|
48
|
+
type PhotoViewerAction,
|
|
49
|
+
type PhotoViewerCloseReason,
|
|
50
|
+
type PhotoViewerStrings,
|
|
51
|
+
type PinchSample,
|
|
52
|
+
type Point,
|
|
53
|
+
type Size,
|
|
54
|
+
} from "../util/photo.js";
|
|
55
|
+
import { portal as portalTo } from "../util/portal.js";
|
|
56
|
+
import { generateId } from "../util/ssr-safe.js";
|
|
57
|
+
import { isRtl } from "../util/radio-keys.js";
|
|
58
|
+
import { createVelocityTracker, FLICK_VELOCITY, timeOf } from "../util/sheet-drag.js";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Photos one at a time, on the whole screen: swipe or use the arrows to
|
|
62
|
+
* go between them, pinch or double tap to look closer, swipe down or
|
|
63
|
+
* press Escape to leave.
|
|
64
|
+
*
|
|
65
|
+
* ```svelte
|
|
66
|
+
* <PhotoGrid {photos} onopen={(at) => { index = at; open = true; }} />
|
|
67
|
+
* <PhotoViewer {photos} bind:index bind:isOpen={open} actions={[share, remove]} />
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* It is a modal dialog, on the same footing as Modal, Drawer and
|
|
71
|
+
* BottomSheet. When it closes, focus goes to the tile of the photo that
|
|
72
|
+
* was showing, if the `PhotoGrid` it was opened from shows it; otherwise
|
|
73
|
+
* to whatever opened it.
|
|
74
|
+
*
|
|
75
|
+
* It is dark in both themes: the photos are on black. Nothing in it is
|
|
76
|
+
* written on that black: every control, the counter and the caption sit
|
|
77
|
+
* on plates in the theme's own surface colour, so they read over any
|
|
78
|
+
* photo in either theme.
|
|
79
|
+
*
|
|
80
|
+
* Other attributes (`id`, `data-*`, ...) land on the `role="dialog"` panel.
|
|
81
|
+
*/
|
|
82
|
+
type Props = Omit<
|
|
83
|
+
HTMLAttributes<HTMLDivElement>,
|
|
84
|
+
// `onclose` is also a DOM event handler; ours carries a reason instead.
|
|
85
|
+
"class" | "onclose" | "aria-label"
|
|
86
|
+
> & {
|
|
87
|
+
photos: Photo[];
|
|
88
|
+
/** Place in `photos` of the photo that is showing. Supports `bind:index`. */
|
|
89
|
+
index?: number;
|
|
90
|
+
isOpen?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Things to do with the photo that is showing. Up to three are
|
|
93
|
+
* buttons in a bar under the photo; with more, two are, and the
|
|
94
|
+
* rest are in a menu. The viewer does nothing by itself: each action
|
|
95
|
+
* is your own function.
|
|
96
|
+
*/
|
|
97
|
+
actions?: PhotoViewerAction[];
|
|
98
|
+
/**
|
|
99
|
+
* Render the overlay in `document.body`, so an ancestor with a
|
|
100
|
+
* transform, filter or clipped overflow cannot trap it; pass `false`
|
|
101
|
+
* to render in place.
|
|
102
|
+
*/
|
|
103
|
+
portal?: boolean;
|
|
104
|
+
/** Fired when the viewer closes itself, with what the user did. */
|
|
105
|
+
onclose?: (detail: { reason: PhotoViewerCloseReason }) => void;
|
|
106
|
+
/** Accessible name of the dialog. */
|
|
107
|
+
label?: string;
|
|
108
|
+
/** Overrides for the built-in strings. */
|
|
109
|
+
strings?: Partial<PhotoViewerStrings>;
|
|
110
|
+
/** Extra classes for the dialog panel. */
|
|
111
|
+
class?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let {
|
|
115
|
+
photos,
|
|
116
|
+
index = $bindable<Exclude<Props["index"], undefined>>(),
|
|
117
|
+
isOpen = $bindable<Exclude<Props["isOpen"], undefined>>(),
|
|
118
|
+
actions = [],
|
|
119
|
+
portal = true,
|
|
120
|
+
onclose,
|
|
121
|
+
label = "Photo viewer",
|
|
122
|
+
strings,
|
|
123
|
+
class: className = "",
|
|
124
|
+
...restProps
|
|
125
|
+
}: Props = $props();
|
|
126
|
+
|
|
127
|
+
// No fallback on a bindable prop: Svelte refuses `bind:…={undefined}` on
|
|
128
|
+
// one that has a fallback (`props_invalid_value`), and a page that throws
|
|
129
|
+
// while it hydrates never becomes interactive. The default is applied
|
|
130
|
+
// here instead: at once, for the server and the first render, and again
|
|
131
|
+
// whenever a parent hands back `undefined`.
|
|
132
|
+
const applyDefaults = () => {
|
|
133
|
+
if (index === undefined) index = 0;
|
|
134
|
+
if (isOpen === undefined) isOpen = false;
|
|
135
|
+
};
|
|
136
|
+
applyDefaults();
|
|
137
|
+
$effect.pre(applyDefaults);
|
|
138
|
+
|
|
139
|
+
/** Room between two photos while one is swiped to the next, in px. */
|
|
140
|
+
const GAP = 16;
|
|
141
|
+
/** How far an arrow key pans a zoomed photo, in px. */
|
|
142
|
+
const KEY_PAN = 64;
|
|
143
|
+
/** A photo that loads faster than this never shows a spinner. */
|
|
144
|
+
const SLOW_MS = 400;
|
|
145
|
+
|
|
146
|
+
/** The app-wide words for this component, from a `ZabiStringsProvider` above it, if there is one. */
|
|
147
|
+
const provided = zabiStringsFor("photoViewer");
|
|
148
|
+
const text = $derived(mergeStrings(PHOTO_VIEWER_STRINGS, provided(), strings));
|
|
149
|
+
const count = $derived(photos.length);
|
|
150
|
+
const current = $derived(clampIndex(index, count));
|
|
151
|
+
const photo = $derived<Photo | undefined>(photos[current]);
|
|
152
|
+
const nameOf = (item: Photo, at: number) =>
|
|
153
|
+
item.alt?.trim() || text.photoName(at + 1, photos.length);
|
|
154
|
+
|
|
155
|
+
/** The photo showing and the one on either side of it, each with where it stands. */
|
|
156
|
+
const slides = $derived(
|
|
157
|
+
[-1, 0, 1]
|
|
158
|
+
.map((slot) => ({ slot, at: current + slot, item: photos[current + slot] }))
|
|
159
|
+
.filter((slide): slide is { slot: number; at: number; item: Photo } => !!slide.item),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const split = $derived(splitActions(actions));
|
|
163
|
+
|
|
164
|
+
let root = $state<HTMLDivElement>();
|
|
165
|
+
let panel = $state<HTMLDivElement>();
|
|
166
|
+
let stage = $state<HTMLDivElement>();
|
|
167
|
+
let captionText = $state<HTMLParagraphElement>();
|
|
168
|
+
let focusActive = false;
|
|
169
|
+
/** Position among the open overlays; lifts a viewer opened later above the earlier ones. */
|
|
170
|
+
let depth = $state(0);
|
|
171
|
+
/** What had focus when the viewer opened. */
|
|
172
|
+
let opener: Element | null = null;
|
|
173
|
+
/**
|
|
174
|
+
* The grid it was in. Kept apart from the opener: when the photo that was
|
|
175
|
+
* opened from is deleted, its tile leaves the page and no longer leads to
|
|
176
|
+
* the grid.
|
|
177
|
+
*/
|
|
178
|
+
let openerGrid: Element | null = null;
|
|
179
|
+
const statusId = generateId("photo-viewer-status");
|
|
180
|
+
let rtl = $state(false);
|
|
181
|
+
|
|
182
|
+
// --- Loading -----------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** Full images that have loaded, and those that failed. */
|
|
185
|
+
let loaded = $state<string[]>([]);
|
|
186
|
+
let failed = $state<string[]>([]);
|
|
187
|
+
/** Bumped by Retry, so the image element is made again and asks again. */
|
|
188
|
+
let attempts = $state<Record<string, number>>({});
|
|
189
|
+
/** The photo showing has been loading for a while. */
|
|
190
|
+
let slow = $state(false);
|
|
191
|
+
|
|
192
|
+
function markLoaded(source: string) {
|
|
193
|
+
if (!loaded.includes(source)) loaded = [...loaded, source];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function markFailed(source: string) {
|
|
197
|
+
if (!failed.includes(source)) failed = [...failed, source];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** An image that was complete before anything was listening. */
|
|
201
|
+
function settled(image: HTMLImageElement) {
|
|
202
|
+
if (!image.complete) return;
|
|
203
|
+
const source = image.getAttribute("src") ?? "";
|
|
204
|
+
// Untracked: this runs as an attachment, which would otherwise run
|
|
205
|
+
// again for every image each time one of the lists changes.
|
|
206
|
+
untrack(() => {
|
|
207
|
+
if (image.naturalWidth > 0) markLoaded(source);
|
|
208
|
+
else markFailed(source);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function retry(source: string) {
|
|
213
|
+
failed = failed.filter((candidate) => candidate !== source);
|
|
214
|
+
attempts = { ...attempts, [source]: (attempts[source] ?? 0) + 1 };
|
|
215
|
+
// The button that was pressed goes with the message it was in: the
|
|
216
|
+
// dialog takes its focus, so it is not dropped onto the page.
|
|
217
|
+
panel?.focus({ preventScroll: true });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
$effect(() => {
|
|
221
|
+
const source = photo?.src;
|
|
222
|
+
slow = false;
|
|
223
|
+
if (!isOpen || !source || loaded.includes(source) || failed.includes(source)) return;
|
|
224
|
+
const timer = setTimeout(() => (slow = true), SLOW_MS);
|
|
225
|
+
return () => clearTimeout(timer);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// --- Where the photo is ------------------------------------------------
|
|
229
|
+
|
|
230
|
+
let zoom = $state<PhotoTransform>(REST);
|
|
231
|
+
/** Sideways travel of a swipe between photos, and downward travel of a swipe to close, in px. */
|
|
232
|
+
let dragX = $state(0);
|
|
233
|
+
let dragY = $state(0);
|
|
234
|
+
/** Whether the next change of position is eased. Off while a finger is moving it. */
|
|
235
|
+
let animating = $state(false);
|
|
236
|
+
|
|
237
|
+
const zoomed = $derived(isZoomed(zoom));
|
|
238
|
+
|
|
239
|
+
function viewSize(): Size {
|
|
240
|
+
return { width: stage?.clientWidth ?? 0, height: stage?.clientHeight ?? 0 };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The size of the photo showing at scale 1: its laid-out box, which transforms do not change. */
|
|
244
|
+
function fit(): Size {
|
|
245
|
+
const box = stage?.querySelector<HTMLElement>("[data-photo-viewer-current] [data-photo-viewer-box]");
|
|
246
|
+
return { width: box?.offsetWidth ?? 0, height: box?.offsetHeight ?? 0 };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A point on the screen, measured from the centre of the stage. */
|
|
250
|
+
function fromCentre(clientX: number, clientY: number): Point {
|
|
251
|
+
const rect = stage?.getBoundingClientRect();
|
|
252
|
+
if (!rect) return { x: 0, y: 0 };
|
|
253
|
+
return { x: clientX - rect.left - rect.width / 2, y: clientY - rect.top - rect.height / 2 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function setZoom(next: PhotoTransform, eased: boolean) {
|
|
257
|
+
animating = eased;
|
|
258
|
+
zoom = next;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Shows the photo `turn` places on (1 is next, -1 previous). The photo
|
|
263
|
+
* that comes in starts from where it stood beside the one showing, plus
|
|
264
|
+
* however far a swipe had already pulled it, and is eased to the middle.
|
|
265
|
+
*/
|
|
266
|
+
async function go(turn: number) {
|
|
267
|
+
const target = current + turn;
|
|
268
|
+
if (turn === 0 || target < 0 || target >= count) {
|
|
269
|
+
animating = true;
|
|
270
|
+
dragX = 0;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const step = (viewSize().width + GAP) * (rtl ? -1 : 1) * Math.sign(turn);
|
|
274
|
+
animating = false;
|
|
275
|
+
zoom = REST;
|
|
276
|
+
// However the photo was changed (a key, a swipe, a button): the next caption starts folded.
|
|
277
|
+
captionOpen = false;
|
|
278
|
+
index = target;
|
|
279
|
+
dragX = dragX + step;
|
|
280
|
+
await tick();
|
|
281
|
+
requestAnimationFrame(() => {
|
|
282
|
+
animating = true;
|
|
283
|
+
dragX = 0;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function goTo(target: number) {
|
|
288
|
+
const next = clampIndex(target, count);
|
|
289
|
+
if (next === current || next === -1) return;
|
|
290
|
+
// Further than one photo away there is nothing to slide past.
|
|
291
|
+
if (Math.abs(next - current) === 1) void go(next - current);
|
|
292
|
+
else {
|
|
293
|
+
animating = false;
|
|
294
|
+
zoom = REST;
|
|
295
|
+
dragX = 0;
|
|
296
|
+
captionOpen = false;
|
|
297
|
+
index = next;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --- Pointers ------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
type Gesture =
|
|
304
|
+
| { kind: "pending"; start: Point; at: Moment; base: PhotoTransform }
|
|
305
|
+
| { kind: "pan"; start: Point; base: PhotoTransform }
|
|
306
|
+
| { kind: "swipe"; start: Point }
|
|
307
|
+
| { kind: "close"; start: Point }
|
|
308
|
+
| { kind: "pinch"; from: PinchSample; base: PhotoTransform }
|
|
309
|
+
| { kind: "spent" };
|
|
310
|
+
|
|
311
|
+
const pointers = new Map<number, Point>();
|
|
312
|
+
let gesture: Gesture | null = null;
|
|
313
|
+
let lastTap: { at: Moment; point: Point } | null = null;
|
|
314
|
+
const speedX = createVelocityTracker();
|
|
315
|
+
const speedY = createVelocityTracker();
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* When a pointer event happened. `time` is the event's own, which is
|
|
319
|
+
* when the finger touched or lifted; `clock` is when it was handled,
|
|
320
|
+
* later by however busy the page is. A tap is timed by the first, as a
|
|
321
|
+
* flick is: handled late, two quick taps were two single taps.
|
|
322
|
+
*/
|
|
323
|
+
interface Moment {
|
|
324
|
+
time: number;
|
|
325
|
+
clock: number;
|
|
326
|
+
}
|
|
327
|
+
const momentOf = (event: Event): Moment => ({ time: timeOf(event), clock: performance.now() });
|
|
328
|
+
/** Some browsers hand every event of one frame the same stamp: then the clock says how long. */
|
|
329
|
+
const elapsed = (from: Moment, to: Moment) =>
|
|
330
|
+
to.time !== from.time ? to.time - from.time : to.clock - from.clock;
|
|
331
|
+
|
|
332
|
+
function pinchNow(): PinchSample {
|
|
333
|
+
const [first, second] = [...pointers.values()];
|
|
334
|
+
return pinchSample(fromCentre(first.x, first.y), fromCentre(second.x, second.y));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function handlePointerDown(event: PointerEvent) {
|
|
338
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
339
|
+
if (pointers.size >= 2) return;
|
|
340
|
+
try {
|
|
341
|
+
stage?.setPointerCapture(event.pointerId);
|
|
342
|
+
} catch {
|
|
343
|
+
/* a test DOM, or a pointer that is already gone */
|
|
344
|
+
}
|
|
345
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
346
|
+
animating = false;
|
|
347
|
+
if (pointers.size === 1) {
|
|
348
|
+
gesture = {
|
|
349
|
+
kind: "pending",
|
|
350
|
+
start: { x: event.clientX, y: event.clientY },
|
|
351
|
+
at: momentOf(event),
|
|
352
|
+
base: zoom,
|
|
353
|
+
};
|
|
354
|
+
} else {
|
|
355
|
+
// A second finger: whatever the first was doing, this is a pinch.
|
|
356
|
+
dragX = 0;
|
|
357
|
+
dragY = 0;
|
|
358
|
+
gesture = { kind: "pinch", from: pinchNow(), base: zoom };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function handlePointerMove(event: PointerEvent) {
|
|
363
|
+
if (!pointers.has(event.pointerId) || !gesture) return;
|
|
364
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
365
|
+
|
|
366
|
+
if (gesture.kind === "pinch") {
|
|
367
|
+
if (pointers.size >= 2) {
|
|
368
|
+
zoom = pinchTransform(gesture.base, gesture.from, pinchNow(), fit(), viewSize());
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (gesture.kind === "spent" || pointers.size !== 1) return;
|
|
373
|
+
|
|
374
|
+
const point = { x: event.clientX, y: event.clientY };
|
|
375
|
+
if (gesture.kind === "pending") {
|
|
376
|
+
const moved = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
|
377
|
+
if (isZoomed(gesture.base)) {
|
|
378
|
+
// Zoomed in, any drag moves the photo: it does not turn the page or close.
|
|
379
|
+
if (Math.hypot(moved.x, moved.y) < TAP_SLOP) return;
|
|
380
|
+
gesture = { kind: "pan", start: point, base: zoom };
|
|
381
|
+
} else {
|
|
382
|
+
const axis = dragAxis(moved);
|
|
383
|
+
if (axis === null) return;
|
|
384
|
+
// Measured from where the drag was recognised, so nothing jumps by the slop.
|
|
385
|
+
gesture = { kind: axis === "x" ? "swipe" : "close", start: point };
|
|
386
|
+
speedX.reset(point.x, timeOf(event));
|
|
387
|
+
speedY.reset(point.y, timeOf(event));
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
|
393
|
+
if (gesture.kind === "pan") {
|
|
394
|
+
zoom = panBy(gesture.base, delta, fit(), viewSize());
|
|
395
|
+
} else if (gesture.kind === "swipe") {
|
|
396
|
+
speedX.add(point.x, timeOf(event));
|
|
397
|
+
// Towards a photo that is not there, the photo only gives a little.
|
|
398
|
+
const towards = (delta.x < 0 ? 1 : -1) * (rtl ? -1 : 1);
|
|
399
|
+
const exists = current + towards >= 0 && current + towards < count;
|
|
400
|
+
dragX = exists ? delta.x : resist(delta.x);
|
|
401
|
+
} else if (gesture.kind === "close") {
|
|
402
|
+
speedY.add(point.y, timeOf(event));
|
|
403
|
+
dragY = Math.max(0, delta.y);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function handlePointerUp(event: PointerEvent) {
|
|
408
|
+
if (!pointers.has(event.pointerId)) return;
|
|
409
|
+
const lifted = pointers.get(event.pointerId)!;
|
|
410
|
+
pointers.delete(event.pointerId);
|
|
411
|
+
try {
|
|
412
|
+
stage?.releasePointerCapture(event.pointerId);
|
|
413
|
+
} catch {
|
|
414
|
+
/* never captured */
|
|
415
|
+
}
|
|
416
|
+
const ended = gesture;
|
|
417
|
+
if (!ended) return;
|
|
418
|
+
|
|
419
|
+
if (ended.kind === "pinch") {
|
|
420
|
+
setZoom(settleZoom(zoom, fit(), viewSize()), true);
|
|
421
|
+
// The finger that stays does nothing more until it too has lifted.
|
|
422
|
+
gesture = pointers.size > 0 ? { kind: "spent" } : null;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (pointers.size > 0) return;
|
|
426
|
+
gesture = null;
|
|
427
|
+
|
|
428
|
+
switch (ended.kind) {
|
|
429
|
+
case "pending": {
|
|
430
|
+
const at = momentOf(event);
|
|
431
|
+
if (elapsed(ended.at, at) > TAP_MS) break;
|
|
432
|
+
const tap = { at, point: lifted };
|
|
433
|
+
const before = lastTap && { time: 0, point: lastTap.point };
|
|
434
|
+
if (isDoubleTap(before, { time: lastTap ? elapsed(lastTap.at, at) : 0, point: lifted })) {
|
|
435
|
+
lastTap = null;
|
|
436
|
+
setZoom(
|
|
437
|
+
doubleTapTransform(zoom, fromCentre(lifted.x, lifted.y), fit(), viewSize()),
|
|
438
|
+
true,
|
|
439
|
+
);
|
|
440
|
+
} else {
|
|
441
|
+
lastTap = tap;
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
case "swipe": {
|
|
446
|
+
speedX.add(lifted.x, timeOf(event));
|
|
447
|
+
const turn = swipeTurn({
|
|
448
|
+
distance: lifted.x - ended.start.x,
|
|
449
|
+
velocity: speedX.velocity(),
|
|
450
|
+
width: viewSize().width,
|
|
451
|
+
index: current,
|
|
452
|
+
count,
|
|
453
|
+
rtl,
|
|
454
|
+
flick: FLICK_VELOCITY,
|
|
455
|
+
});
|
|
456
|
+
void go(turn);
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case "close": {
|
|
460
|
+
speedY.add(lifted.y, timeOf(event));
|
|
461
|
+
const closes = swipeCloses({
|
|
462
|
+
distance: lifted.y - ended.start.y,
|
|
463
|
+
velocity: speedY.velocity(),
|
|
464
|
+
height: viewSize().height,
|
|
465
|
+
flick: FLICK_VELOCITY,
|
|
466
|
+
});
|
|
467
|
+
if (closes) close("swipe");
|
|
468
|
+
else {
|
|
469
|
+
animating = true;
|
|
470
|
+
dragY = 0;
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
default:
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function handlePointerCancel(event: PointerEvent) {
|
|
480
|
+
if (!pointers.has(event.pointerId)) return;
|
|
481
|
+
pointers.delete(event.pointerId);
|
|
482
|
+
if (pointers.size > 0) return;
|
|
483
|
+
gesture = null;
|
|
484
|
+
animating = true;
|
|
485
|
+
dragX = 0;
|
|
486
|
+
dragY = 0;
|
|
487
|
+
zoom = settleZoom(zoom, fit(), viewSize());
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Ctrl or Cmd with the wheel (and a pinch on a trackpad, which arrives as one) zooms about the pointer. */
|
|
491
|
+
function handleWheel(event: WheelEvent) {
|
|
492
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
493
|
+
event.preventDefault();
|
|
494
|
+
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
|
|
495
|
+
setZoom(
|
|
496
|
+
settleZoom(
|
|
497
|
+
zoomAbout(zoom, zoom.scale * factor, fromCentre(event.clientX, event.clientY), fit(), viewSize()),
|
|
498
|
+
fit(),
|
|
499
|
+
viewSize(),
|
|
500
|
+
),
|
|
501
|
+
false,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
$effect(() => {
|
|
506
|
+
const element = stage;
|
|
507
|
+
if (!element) return;
|
|
508
|
+
// Not passive: the browser must not zoom the page as well.
|
|
509
|
+
element.addEventListener("wheel", handleWheel, { passive: false });
|
|
510
|
+
return () => element.removeEventListener("wheel", handleWheel);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// --- Opening and closing -------------------------------------------------
|
|
514
|
+
|
|
515
|
+
/** The tile of the photo now showing, in the grid the viewer was opened from. */
|
|
516
|
+
function tileOfCurrent(): HTMLElement | null {
|
|
517
|
+
const grid = openerGrid?.isConnected ? openerGrid : null;
|
|
518
|
+
const key = photo ? String(photoKey(photo)) : null;
|
|
519
|
+
if (!grid || key === null) return null;
|
|
520
|
+
return (
|
|
521
|
+
Array.from(grid.querySelectorAll<HTMLElement>("[data-photo-key]")).find(
|
|
522
|
+
(tile) => tile.dataset.photoKey === key,
|
|
523
|
+
) ?? null
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function giveFocusBack() {
|
|
528
|
+
if (!focusActive) return;
|
|
529
|
+
focusActive = false;
|
|
530
|
+
const tile = tileOfCurrent();
|
|
531
|
+
returnFocus();
|
|
532
|
+
if (tile) {
|
|
533
|
+
tile.focus();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// The tile it was opened from is gone (its photo was deleted) and the
|
|
537
|
+
// photo showing has none: the grid's own Tab stop, or its first tile,
|
|
538
|
+
// so focus is not dropped onto the page.
|
|
539
|
+
if (opener?.isConnected || !openerGrid?.isConnected) return;
|
|
540
|
+
(
|
|
541
|
+
openerGrid.querySelector<HTMLElement>('[data-photo-grid-item][tabindex="0"]') ??
|
|
542
|
+
openerGrid.querySelector<HTMLElement>("[data-photo-grid-item]")
|
|
543
|
+
)?.focus();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function close(reason: PhotoViewerCloseReason) {
|
|
547
|
+
isOpen = false;
|
|
548
|
+
giveFocusBack();
|
|
549
|
+
onclose?.({ reason });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
$effect(() => {
|
|
553
|
+
const container = panel;
|
|
554
|
+
if (isOpen && container) {
|
|
555
|
+
opener = document.activeElement;
|
|
556
|
+
openerGrid = opener?.closest?.("[data-photo-grid]") ?? null;
|
|
557
|
+
saveFocus();
|
|
558
|
+
focusActive = true;
|
|
559
|
+
rtl = isRtl(container);
|
|
560
|
+
const unlockScroll = lockBodyScroll();
|
|
561
|
+
const overlay = joinOverlayStack(container);
|
|
562
|
+
depth = overlay.depth;
|
|
563
|
+
const t = setTimeout(() => {
|
|
564
|
+
focusFirstElement(container);
|
|
565
|
+
}, 0);
|
|
566
|
+
// If the focused control is disabled or removed, focus lands on
|
|
567
|
+
// `<body>`; take Tab and Escape back from there.
|
|
568
|
+
const stopRecovery = recoverStrayFocus(container, {
|
|
569
|
+
onEscape: () => close("escape"),
|
|
570
|
+
});
|
|
571
|
+
return () => {
|
|
572
|
+
clearTimeout(t);
|
|
573
|
+
stopRecovery();
|
|
574
|
+
overlay.leave();
|
|
575
|
+
unlockScroll();
|
|
576
|
+
pointers.clear();
|
|
577
|
+
gesture = null;
|
|
578
|
+
lastTap = null;
|
|
579
|
+
zoom = REST;
|
|
580
|
+
dragX = 0;
|
|
581
|
+
dragY = 0;
|
|
582
|
+
animating = false;
|
|
583
|
+
captionOpen = false;
|
|
584
|
+
menuOpen = false;
|
|
585
|
+
giveFocusBack();
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The list can change under an open viewer: a photo is deleted. Stay on a
|
|
592
|
+
* photo that exists, and with none left there is nothing to show.
|
|
593
|
+
*/
|
|
594
|
+
$effect(() => {
|
|
595
|
+
const total = count;
|
|
596
|
+
untrack(() => {
|
|
597
|
+
if (!isOpen) return;
|
|
598
|
+
if (total === 0) {
|
|
599
|
+
isOpen = false;
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (index !== clampIndex(index, total)) index = clampIndex(index, total);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
$effect(() => {
|
|
607
|
+
if (!isDevBuild() || !isOpen) return;
|
|
608
|
+
const unnamed = photos.filter((item) => !item.alt?.trim()).length;
|
|
609
|
+
if (unnamed > 0) {
|
|
610
|
+
console.warn(
|
|
611
|
+
`[zabi-components] PhotoViewer: ${unnamed} of ${photos.length} photos have no alt text. ` +
|
|
612
|
+
'Each is read out as "Photo 3 of 12" until it has one.',
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// --- Keys ----------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
620
|
+
trapTabKey(panel, event);
|
|
621
|
+
// A menu inside the viewer has the keys while it is open.
|
|
622
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
623
|
+
if ((event.target as Element | null)?.closest?.('[role="menu"], input, textarea, select')) return;
|
|
624
|
+
|
|
625
|
+
const view = viewSize();
|
|
626
|
+
const size = fit();
|
|
627
|
+
const pan = (x: number, y: number) => setZoom(panBy(zoom, { x, y }, size, view), true);
|
|
628
|
+
const centre = { x: 0, y: 0 };
|
|
629
|
+
// Left and Right follow what is on screen, which is mirrored in RTL.
|
|
630
|
+
const forward = rtl ? "ArrowLeft" : "ArrowRight";
|
|
631
|
+
const backward = rtl ? "ArrowRight" : "ArrowLeft";
|
|
632
|
+
|
|
633
|
+
switch (event.key) {
|
|
634
|
+
case "Escape":
|
|
635
|
+
// Out of the zoom first: one press never does two things.
|
|
636
|
+
if (zoomed) setZoom(REST, true);
|
|
637
|
+
else close("escape");
|
|
638
|
+
break;
|
|
639
|
+
case forward:
|
|
640
|
+
case backward:
|
|
641
|
+
// The photo moves the way the finger would push it: Right shows what is to the right.
|
|
642
|
+
if (zoomed) pan(event.key === "ArrowRight" ? -KEY_PAN : KEY_PAN, 0);
|
|
643
|
+
else void go(event.key === forward ? 1 : -1);
|
|
644
|
+
break;
|
|
645
|
+
case "ArrowUp":
|
|
646
|
+
case "ArrowDown":
|
|
647
|
+
if (!zoomed) return;
|
|
648
|
+
pan(0, event.key === "ArrowDown" ? -KEY_PAN : KEY_PAN);
|
|
649
|
+
break;
|
|
650
|
+
case "PageDown":
|
|
651
|
+
void go(1);
|
|
652
|
+
break;
|
|
653
|
+
case "PageUp":
|
|
654
|
+
void go(-1);
|
|
655
|
+
break;
|
|
656
|
+
case "Home":
|
|
657
|
+
goTo(0);
|
|
658
|
+
break;
|
|
659
|
+
case "End":
|
|
660
|
+
goTo(count - 1);
|
|
661
|
+
break;
|
|
662
|
+
case "+":
|
|
663
|
+
case "=":
|
|
664
|
+
setZoom(zoomAbout(zoom, zoom.scale * ZOOM_STEP, centre, size, view), true);
|
|
665
|
+
break;
|
|
666
|
+
case "-":
|
|
667
|
+
case "_":
|
|
668
|
+
setZoom(settleZoom(zoomAbout(zoom, zoom.scale / ZOOM_STEP, centre, size, view), size, view), true);
|
|
669
|
+
break;
|
|
670
|
+
case "0":
|
|
671
|
+
setZoom(REST, true);
|
|
672
|
+
break;
|
|
673
|
+
default:
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
event.preventDefault();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// --- Caption ---------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
/** The menu of the actions that do not fit in the bar. */
|
|
682
|
+
let menuOpen = $state(false);
|
|
683
|
+
|
|
684
|
+
let captionOpen = $state(false);
|
|
685
|
+
/** The caption is longer than the two lines it is given. */
|
|
686
|
+
let captionLong = $state(false);
|
|
687
|
+
|
|
688
|
+
$effect(() => {
|
|
689
|
+
const element = captionText;
|
|
690
|
+
// Read again for each photo, and when the screen turns.
|
|
691
|
+
void photo?.caption;
|
|
692
|
+
if (!element) {
|
|
693
|
+
captionLong = false;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const measure = () => {
|
|
697
|
+
if (!captionOpen) captionLong = element.scrollHeight > element.clientHeight + 1;
|
|
698
|
+
};
|
|
699
|
+
measure();
|
|
700
|
+
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(measure) : undefined;
|
|
701
|
+
observer?.observe(element);
|
|
702
|
+
return () => observer?.disconnect();
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* More and Less come and go with the caption, and Try again with its
|
|
707
|
+
* message. One of them may be holding the focus as it goes (an arrow key
|
|
708
|
+
* pressed on More shows a photo with a short caption): the dialog takes
|
|
709
|
+
* the focus then, so the keys go on working and it is not left on the page.
|
|
710
|
+
*/
|
|
711
|
+
$effect(() => {
|
|
712
|
+
void current;
|
|
713
|
+
void captionLong;
|
|
714
|
+
void captionOpen;
|
|
715
|
+
void failed;
|
|
716
|
+
const container = panel;
|
|
717
|
+
if (!isOpen || !container || !focusActive) return;
|
|
718
|
+
const active = document.activeElement;
|
|
719
|
+
if (!active || active === document.body) container.focus({ preventScroll: true });
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
function showPhoto(turn: number) {
|
|
723
|
+
captionOpen = false;
|
|
724
|
+
void go(turn);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function run(action: PhotoViewerAction) {
|
|
728
|
+
if (action.disabled || !photo) return;
|
|
729
|
+
action.onclick(photo, current);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* A plate: the theme's own overlay surface and text, opaque, with an edge
|
|
734
|
+
* that shows on black and on any photo. Everything in the viewer that is
|
|
735
|
+
* read or pressed sits on one.
|
|
736
|
+
*/
|
|
737
|
+
const plate = "border border-control-border bg-surface-overlay text-body";
|
|
738
|
+
const plateButton = cn(plate, "hover:bg-surface-overlay-hover active:bg-surface-overlay-hover");
|
|
739
|
+
/** Clear of the notch and the rounded corners, whichever side they are on. */
|
|
740
|
+
const sides =
|
|
741
|
+
"px-[max(8px,env(safe-area-inset-left),env(safe-area-inset-right))]";
|
|
742
|
+
|
|
743
|
+
const atFirst = $derived(current <= 0);
|
|
744
|
+
const atLast = $derived(current >= count - 1);
|
|
745
|
+
/**
|
|
746
|
+
* `aria-disabled`, not `disabled` or removed: the button may hold focus
|
|
747
|
+
* when the end is reached. Only the arrow is dimmed, to the colour a
|
|
748
|
+
* control's edge has (3:1 on the plate). The plate and its edge stay
|
|
749
|
+
* opaque: `opacity` on the whole button let the photo through and left
|
|
750
|
+
* the arrow at 1:1 to 2.5:1 over one. No hover or pressed fill either:
|
|
751
|
+
* nothing happens there.
|
|
752
|
+
*/
|
|
753
|
+
const endedButton =
|
|
754
|
+
"border border-control-border bg-surface-overlay text-control-border hover:bg-surface-overlay active:bg-surface-overlay cursor-not-allowed active:scale-100";
|
|
755
|
+
</script>
|
|
756
|
+
|
|
757
|
+
{#snippet slide(item: Photo, at: number, isCurrent: boolean)}
|
|
758
|
+
{@const source = item.src}
|
|
759
|
+
{@const ready = loaded.includes(source)}
|
|
760
|
+
{@const broken = failed.includes(source)}
|
|
761
|
+
<!-- The box has the photo's own proportions and is as large as fits, from
|
|
762
|
+
its width and height alone: the small image and the full one fill the same
|
|
763
|
+
box, so nothing jumps when one replaces the other. -->
|
|
764
|
+
<div
|
|
765
|
+
class="photo-box"
|
|
766
|
+
style:aspect-ratio="{item.width} / {item.height}"
|
|
767
|
+
style:--photo-ratio={item.width / item.height}
|
|
768
|
+
style:transform={isCurrent && (zoomed || zoom.x !== 0 || zoom.y !== 0)
|
|
769
|
+
? `translate3d(${zoom.x}px, ${zoom.y}px, 0) scale(${zoom.scale})`
|
|
770
|
+
: undefined}
|
|
771
|
+
style:transition={isCurrent && animating ? undefined : "none"}
|
|
772
|
+
data-photo-viewer-box
|
|
773
|
+
>
|
|
774
|
+
{#if broken}
|
|
775
|
+
{#if isCurrent}
|
|
776
|
+
<div class="flex size-full items-center justify-center p-4">
|
|
777
|
+
<div
|
|
778
|
+
class={cn(plate, "flex flex-col items-center gap-3 rounded-container p-4 text-center text-sm")}
|
|
779
|
+
role="alert"
|
|
780
|
+
>
|
|
781
|
+
<ImageOff size={24} aria-hidden="true" />
|
|
782
|
+
<p>{text.loadError}</p>
|
|
783
|
+
<Button variant="secondary" size="lg" onclick={() => retry(source)}>
|
|
784
|
+
{text.retry}
|
|
785
|
+
</Button>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
{/if}
|
|
789
|
+
{:else}
|
|
790
|
+
{#if !ready && item.thumbSrc}
|
|
791
|
+
<!-- The small image, blurred, until the full one is there. -->
|
|
792
|
+
<img
|
|
793
|
+
src={item.thumbSrc}
|
|
794
|
+
alt=""
|
|
795
|
+
aria-hidden="true"
|
|
796
|
+
draggable="false"
|
|
797
|
+
class="absolute inset-0 size-full scale-105 object-cover blur-md"
|
|
798
|
+
data-photo-viewer-thumb
|
|
799
|
+
/>
|
|
800
|
+
{/if}
|
|
801
|
+
{#key attempts[source] ?? 0}
|
|
802
|
+
<img
|
|
803
|
+
src={source}
|
|
804
|
+
alt={isCurrent ? nameOf(item, at) : ""}
|
|
805
|
+
aria-hidden={isCurrent ? undefined : "true"}
|
|
806
|
+
width={item.width}
|
|
807
|
+
height={item.height}
|
|
808
|
+
decoding="async"
|
|
809
|
+
draggable="false"
|
|
810
|
+
class={cn(
|
|
811
|
+
"absolute inset-0 size-full object-contain transition-opacity duration-200 motion-reduce:transition-none",
|
|
812
|
+
ready ? "opacity-100" : "opacity-0",
|
|
813
|
+
)}
|
|
814
|
+
onload={() => markLoaded(source)}
|
|
815
|
+
onerror={() => markFailed(source)}
|
|
816
|
+
data-photo-viewer-image
|
|
817
|
+
{@attach settled}
|
|
818
|
+
/>
|
|
819
|
+
{/key}
|
|
820
|
+
{/if}
|
|
821
|
+
</div>
|
|
822
|
+
{/snippet}
|
|
823
|
+
|
|
824
|
+
{#if isOpen && photo}
|
|
825
|
+
<div
|
|
826
|
+
bind:this={root}
|
|
827
|
+
class="fixed inset-x-0 top-0 z-modal h-dvh overflow-hidden"
|
|
828
|
+
style:z-index={depth > 0 ? `calc(var(--z-modal) + ${depth})` : undefined}
|
|
829
|
+
data-overlay-depth={depth}
|
|
830
|
+
use:portalTo={portal}
|
|
831
|
+
role="presentation"
|
|
832
|
+
>
|
|
833
|
+
<!-- Black, and opaque: the overlay colour alone lets the page show
|
|
834
|
+
through, and a photo over a page cannot be read. It thins while a swipe
|
|
835
|
+
down pulls the photo away, to show what is coming back. -->
|
|
836
|
+
<div
|
|
837
|
+
class="photo-backdrop absolute inset-0"
|
|
838
|
+
style:opacity={dragY > 0 ? Math.max(0.3, 1 - dragY / Math.max(1, viewSize().height)) : undefined}
|
|
839
|
+
style:transition={animating ? undefined : "none"}
|
|
840
|
+
aria-hidden="true"
|
|
841
|
+
></div>
|
|
842
|
+
<!-- `touch-none` on the whole dialog, not only the stage: two fingers
|
|
843
|
+
that land on the counter or a button would otherwise zoom the page
|
|
844
|
+
under the viewer, and leave it zoomed. Taps are not affected. -->
|
|
845
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
846
|
+
<div
|
|
847
|
+
bind:this={panel}
|
|
848
|
+
class={cn("absolute inset-0 touch-none outline-none", className)}
|
|
849
|
+
role="dialog"
|
|
850
|
+
aria-modal="true"
|
|
851
|
+
aria-label={label}
|
|
852
|
+
aria-describedby={statusId}
|
|
853
|
+
tabindex="-1"
|
|
854
|
+
data-zoomed={zoomed ? "true" : undefined}
|
|
855
|
+
{...restProps}
|
|
856
|
+
onkeydown={handleKeydown}
|
|
857
|
+
>
|
|
858
|
+
<!-- The stage takes every touch on the photo and the black around
|
|
859
|
+
it, and decides what it is: `touch-none`, or the browser would
|
|
860
|
+
scroll or zoom the page with it. A tap beside the photo does not
|
|
861
|
+
close: it is half of a double tap, and a near miss of the photo. -->
|
|
862
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
863
|
+
<div
|
|
864
|
+
bind:this={stage}
|
|
865
|
+
class="photo-stage absolute inset-0 touch-none overflow-hidden select-none"
|
|
866
|
+
class:cursor-grab={zoomed}
|
|
867
|
+
data-photo-viewer-stage
|
|
868
|
+
onpointerdown={handlePointerDown}
|
|
869
|
+
onpointermove={handlePointerMove}
|
|
870
|
+
onpointerup={handlePointerUp}
|
|
871
|
+
onpointercancel={handlePointerCancel}
|
|
872
|
+
>
|
|
873
|
+
{#each slides as entry (photoKey(entry.item))}
|
|
874
|
+
{@const isCurrent = entry.slot === 0}
|
|
875
|
+
<div
|
|
876
|
+
class="photo-slide absolute inset-0 flex items-center justify-center"
|
|
877
|
+
style:transform="translate3d(calc({entry.slot * (rtl ? -1 : 1)} * (100% + {GAP}px) + {dragX}px), {isCurrent ? dragY : 0}px, 0)"
|
|
878
|
+
style:transition={animating ? undefined : "none"}
|
|
879
|
+
data-photo-viewer-current={isCurrent ? "" : undefined}
|
|
880
|
+
aria-hidden={isCurrent ? undefined : "true"}
|
|
881
|
+
>
|
|
882
|
+
{@render slide(entry.item, entry.at, isCurrent)}
|
|
883
|
+
</div>
|
|
884
|
+
{/each}
|
|
885
|
+
{#if slow && !loaded.includes(photo.src) && !failed.includes(photo.src)}
|
|
886
|
+
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
887
|
+
<div class={cn(plate, "flex size-11 items-center justify-center rounded-pill")}>
|
|
888
|
+
<Spinner size="lg" label={text.loading} />
|
|
889
|
+
</div>
|
|
890
|
+
</div>
|
|
891
|
+
{/if}
|
|
892
|
+
</div>
|
|
893
|
+
|
|
894
|
+
<!-- Top: where you are, and the way out. Close is the first
|
|
895
|
+
control, so it takes the focus when the viewer opens. -->
|
|
896
|
+
<div
|
|
897
|
+
class={cn(
|
|
898
|
+
"pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-[8px] pt-[max(8px,env(safe-area-inset-top))]",
|
|
899
|
+
sides,
|
|
900
|
+
)}
|
|
901
|
+
>
|
|
902
|
+
<p
|
|
903
|
+
class={cn(plate, "pointer-events-auto flex min-h-11 items-center rounded-pill px-4 text-sm font-medium tabular-nums")}
|
|
904
|
+
aria-hidden="true"
|
|
905
|
+
data-photo-viewer-counter
|
|
906
|
+
>
|
|
907
|
+
{text.counter(current + 1, count)}
|
|
908
|
+
</p>
|
|
909
|
+
<IconButton
|
|
910
|
+
variant="outline"
|
|
911
|
+
size="lg"
|
|
912
|
+
label={text.close}
|
|
913
|
+
class={cn(plateButton, "pointer-events-auto")}
|
|
914
|
+
onclick={() => close("close-button")}
|
|
915
|
+
>
|
|
916
|
+
<X size={20} aria-hidden="true" />
|
|
917
|
+
</IconButton>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
{#if count > 1}
|
|
921
|
+
<!-- At either end the button stays, dimmed and aria-disabled:
|
|
922
|
+
taken away, it would drop the focus it may be holding. -->
|
|
923
|
+
<div
|
|
924
|
+
class={cn(
|
|
925
|
+
"pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 items-center justify-between",
|
|
926
|
+
sides,
|
|
927
|
+
)}
|
|
928
|
+
>
|
|
929
|
+
<IconButton
|
|
930
|
+
variant="outline"
|
|
931
|
+
size="lg"
|
|
932
|
+
label={text.previous}
|
|
933
|
+
class={cn(atFirst ? endedButton : plateButton, "pointer-events-auto")}
|
|
934
|
+
aria-disabled={atFirst ? "true" : undefined}
|
|
935
|
+
onclick={() => !atFirst && showPhoto(-1)}
|
|
936
|
+
>
|
|
937
|
+
<ChevronLeft size={20} class="rtl:rotate-180" aria-hidden="true" />
|
|
938
|
+
</IconButton>
|
|
939
|
+
<IconButton
|
|
940
|
+
variant="outline"
|
|
941
|
+
size="lg"
|
|
942
|
+
label={text.next}
|
|
943
|
+
class={cn(atLast ? endedButton : plateButton, "pointer-events-auto")}
|
|
944
|
+
aria-disabled={atLast ? "true" : undefined}
|
|
945
|
+
onclick={() => !atLast && showPhoto(1)}
|
|
946
|
+
>
|
|
947
|
+
<ChevronRight size={20} class="rtl:rotate-180" aria-hidden="true" />
|
|
948
|
+
</IconButton>
|
|
949
|
+
</div>
|
|
950
|
+
{/if}
|
|
951
|
+
|
|
952
|
+
<!-- Bottom: the caption, then what can be done with the photo. -->
|
|
953
|
+
{#if photo.caption || actions.length > 0}
|
|
954
|
+
<div
|
|
955
|
+
class={cn(
|
|
956
|
+
"pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center gap-[8px] pb-[max(8px,env(safe-area-inset-bottom))]",
|
|
957
|
+
sides,
|
|
958
|
+
)}
|
|
959
|
+
>
|
|
960
|
+
{#if photo.caption}
|
|
961
|
+
<div
|
|
962
|
+
class={cn(
|
|
963
|
+
plate,
|
|
964
|
+
"pointer-events-auto flex w-full items-end gap-[8px] rounded-container px-3 py-2 text-sm sm:w-[min(100%,40rem)]",
|
|
965
|
+
)}
|
|
966
|
+
data-photo-viewer-caption
|
|
967
|
+
>
|
|
968
|
+
<p
|
|
969
|
+
bind:this={captionText}
|
|
970
|
+
class={cn(
|
|
971
|
+
"min-w-0 flex-1 [overflow-wrap:anywhere]",
|
|
972
|
+
// The one thing in the viewer that scrolls under a finger.
|
|
973
|
+
captionOpen ? "max-h-[40dvh] touch-pan-y overflow-y-auto" : "line-clamp-2",
|
|
974
|
+
)}
|
|
975
|
+
>
|
|
976
|
+
{photo.caption}
|
|
977
|
+
</p>
|
|
978
|
+
{#if captionLong || captionOpen}
|
|
979
|
+
<button
|
|
980
|
+
type="button"
|
|
981
|
+
class="focus-ring -my-1 -me-1 min-h-11 shrink-0 cursor-pointer rounded-[calc(var(--radius-container)-4px)] px-2 text-sm font-medium text-link"
|
|
982
|
+
aria-expanded={captionOpen}
|
|
983
|
+
onclick={() => (captionOpen = !captionOpen)}
|
|
984
|
+
>
|
|
985
|
+
{captionOpen ? text.showLess : text.showMore}
|
|
986
|
+
</button>
|
|
987
|
+
{/if}
|
|
988
|
+
</div>
|
|
989
|
+
{/if}
|
|
990
|
+
{#if actions.length > 0}
|
|
991
|
+
<div
|
|
992
|
+
class="pointer-events-auto flex flex-wrap items-center justify-center gap-[8px]"
|
|
993
|
+
data-photo-viewer-actions
|
|
994
|
+
>
|
|
995
|
+
{#each split.bar as action (action.id)}
|
|
996
|
+
{@const Icon = action.icon}
|
|
997
|
+
<!-- `aria-disabled`: an action that turns unavailable under the finger keeps the focus. -->
|
|
998
|
+
<button
|
|
999
|
+
type="button"
|
|
1000
|
+
class={cn(
|
|
1001
|
+
"focus-ring inline-flex min-h-12 cursor-pointer items-center gap-2 rounded-control px-4 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none",
|
|
1002
|
+
plateButton,
|
|
1003
|
+
action.tone === "danger" && "text-error-text",
|
|
1004
|
+
action.disabled && "cursor-not-allowed opacity-50",
|
|
1005
|
+
)}
|
|
1006
|
+
aria-disabled={action.disabled ? "true" : undefined}
|
|
1007
|
+
data-action={action.id}
|
|
1008
|
+
onclick={() => run(action)}
|
|
1009
|
+
>
|
|
1010
|
+
{#if Icon}
|
|
1011
|
+
<span class="flex shrink-0" aria-hidden="true"><Icon size={20} /></span>
|
|
1012
|
+
{/if}
|
|
1013
|
+
{action.label}
|
|
1014
|
+
</button>
|
|
1015
|
+
{/each}
|
|
1016
|
+
{#if split.menu.length > 0}
|
|
1017
|
+
<Dropdown
|
|
1018
|
+
bind:isOpen={menuOpen}
|
|
1019
|
+
placement="top-end"
|
|
1020
|
+
ariaLabel={text.moreActions}
|
|
1021
|
+
options={split.menu.map((action) => ({
|
|
1022
|
+
value: action.id,
|
|
1023
|
+
label: action.label,
|
|
1024
|
+
icon: action.icon,
|
|
1025
|
+
tone: action.tone,
|
|
1026
|
+
disabled: action.disabled,
|
|
1027
|
+
}))}
|
|
1028
|
+
onOptionClick={(value) => {
|
|
1029
|
+
const action = split.menu.find((candidate) => candidate.id === value);
|
|
1030
|
+
if (!action || action.disabled) return;
|
|
1031
|
+
menuOpen = false;
|
|
1032
|
+
run(action);
|
|
1033
|
+
}}
|
|
1034
|
+
>
|
|
1035
|
+
{#snippet trigger(aria)}
|
|
1036
|
+
<IconButton
|
|
1037
|
+
variant="outline"
|
|
1038
|
+
size="lg"
|
|
1039
|
+
label={text.moreActions}
|
|
1040
|
+
class={plateButton}
|
|
1041
|
+
onclick={() => (menuOpen = !menuOpen)}
|
|
1042
|
+
{...aria}
|
|
1043
|
+
>
|
|
1044
|
+
<Ellipsis size={20} aria-hidden="true" />
|
|
1045
|
+
</IconButton>
|
|
1046
|
+
{/snippet}
|
|
1047
|
+
</Dropdown>
|
|
1048
|
+
{/if}
|
|
1049
|
+
</div>
|
|
1050
|
+
{/if}
|
|
1051
|
+
</div>
|
|
1052
|
+
{/if}
|
|
1053
|
+
|
|
1054
|
+
<!-- Read out when the photo changes: what it shows, and where it is
|
|
1055
|
+
in the set. A live region says nothing of what it holds when it
|
|
1056
|
+
arrives, so it is also the dialog's description: that is read with
|
|
1057
|
+
the dialog's name when the viewer opens. -->
|
|
1058
|
+
<div id={statusId} class="sr-only" aria-live="polite" aria-atomic="true" data-photo-viewer-status>
|
|
1059
|
+
{nameOf(photo, current)}, {text.position(current + 1, count)}
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
</div>
|
|
1063
|
+
{/if}
|
|
1064
|
+
|
|
1065
|
+
<style>
|
|
1066
|
+
/*
|
|
1067
|
+
* The viewer is dark whatever the theme. The overlay colour is black with
|
|
1068
|
+
* some of it let through; here it is taken whole, so nothing of the page
|
|
1069
|
+
* shows behind a photo. Where `rgb(from ...)` is not known, the overlay
|
|
1070
|
+
* colour itself is the fallback.
|
|
1071
|
+
*/
|
|
1072
|
+
.photo-backdrop {
|
|
1073
|
+
background-color: var(--color-overlay);
|
|
1074
|
+
background-color: rgb(from var(--color-overlay) r g b / 1);
|
|
1075
|
+
transition: opacity 200ms ease-out;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* The stage is the container the photo's box is sized against. */
|
|
1079
|
+
.photo-stage {
|
|
1080
|
+
container-type: size;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
.photo-slide {
|
|
1084
|
+
transition: transform 250ms ease-out;
|
|
1085
|
+
will-change: transform;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/*
|
|
1089
|
+
* As wide as the stage, or as wide as its height allows at the photo's
|
|
1090
|
+
* proportions, whichever is less. `aspect-ratio` on the element gives the
|
|
1091
|
+
* height.
|
|
1092
|
+
*/
|
|
1093
|
+
.photo-box {
|
|
1094
|
+
position: relative;
|
|
1095
|
+
flex: none;
|
|
1096
|
+
overflow: hidden;
|
|
1097
|
+
width: min(100cqw, calc(100cqh * var(--photo-ratio)));
|
|
1098
|
+
transform-origin: center;
|
|
1099
|
+
transition: transform 200ms ease-out;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1103
|
+
.photo-backdrop,
|
|
1104
|
+
.photo-slide,
|
|
1105
|
+
.photo-box {
|
|
1106
|
+
transition: none;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/* Where colours are forced the page's own background is the dark. */
|
|
1111
|
+
@media (forced-colors: active) {
|
|
1112
|
+
.photo-backdrop {
|
|
1113
|
+
background-color: Canvas;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
</style>
|