@sovereignfs/ui 0.45.0 → 0.83.1
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/dist/Accordion.module.css +1 -1
- package/dist/Alert.module.css +1 -1
- package/dist/AppsLauncher.module.css +96 -0
- package/dist/Avatar.module.css +1 -1
- package/dist/Badge.module.css +30 -7
- package/dist/BalanceChip.module.css +1 -1
- package/dist/Button.module.css +18 -2
- package/dist/Card.module.css +1 -1
- package/dist/CardTile.module.css +181 -0
- package/dist/CodeTextarea.module.css +1 -1
- package/dist/Collapsible.module.css +1 -1
- package/dist/ColorPicker.module.css +132 -0
- package/dist/Combobox.module.css +2 -2
- package/dist/Command.module.css +1 -1
- package/dist/ConfirmDialog.module.css +2 -2
- package/dist/ConsentPrompt.module.css +21 -0
- package/dist/DatePicker.module.css +1 -1
- package/dist/Dialog.module.css +130 -21
- package/dist/Drawer.module.css +36 -7
- package/dist/Header.module.css +62 -0
- package/dist/HeaderFooterLayout.module.css +21 -0
- package/dist/IconPicker.module.css +1 -1
- package/dist/Input.module.css +1 -1
- package/dist/Kbd.module.css +1 -1
- package/dist/LegalLinks.module.css +24 -0
- package/dist/Markdown.module.css +73 -0
- package/dist/Menu.module.css +1 -0
- package/dist/Message.module.css +1 -1
- package/dist/MessageScroller.module.css +12 -1
- package/dist/MobileAppsDrawer.module.css +82 -0
- package/dist/MobileFooter.module.css +73 -0
- package/dist/MobileHeader.module.css +76 -0
- package/dist/NavList.module.css +130 -0
- package/dist/NavTabs.module.css +1 -1
- package/dist/NotificationsPanel.module.css +258 -0
- package/dist/OverlayHeader.module.css +1 -1
- package/dist/PageContainer.module.css +82 -0
- package/dist/PageHeader.module.css +54 -0
- package/dist/PageLayout.module.css +51 -0
- package/dist/Popover.module.css +1 -1
- package/dist/QuantityStepper.module.css +1 -1
- package/dist/Resizable.module.css +3 -3
- package/dist/RootLayout.module.css +12 -0
- package/dist/SegmentedControl.module.css +1 -1
- package/dist/Select.module.css +1 -1
- package/dist/Sheet.module.css +4 -1
- package/dist/SplitPane.module.css +4 -4
- package/dist/StatusBadge.module.css +1 -1
- package/dist/StepStrip.module.css +53 -0
- package/dist/SuggestionInput.module.css +2 -2
- package/dist/SwipableMobileCarousel.module.css +64 -0
- package/dist/SwipableMobileCarouselDots.module.css +64 -0
- package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/dist/SwipeStack.module.css +101 -0
- package/dist/SystemBanner.module.css +10 -0
- package/dist/Table.module.css +2 -2
- package/dist/Tabs.module.css +1 -1
- package/dist/TagInput.module.css +2 -2
- package/dist/Textarea.module.css +9 -1
- package/dist/ThreeColumnLayout.module.css +35 -0
- package/dist/Toast.module.css +62 -3
- package/dist/Tooltip.module.css +1 -1
- package/dist/UserMenu.module.css +90 -0
- package/dist/index.d.ts +1680 -30
- package/dist/index.js +4618 -1443
- package/dist/tokens/primitives.css +36 -0
- package/dist/tokens/semantic.css +32 -0
- package/dist/tokens/theme-presets.css +33 -0
- package/package.json +32 -12
- package/src/__tests__/package-json.test.ts +8 -0
- package/src/components/Accordion/Accordion.module.css +1 -1
- package/src/components/Alert/Alert.module.css +1 -1
- package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
- package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
- package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
- package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
- package/src/components/Avatar/Avatar.module.css +1 -1
- package/src/components/Badge/Badge.module.css +30 -7
- package/src/components/Badge/Badge.stories.tsx +16 -4
- package/src/components/Badge/Badge.tsx +8 -11
- package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
- package/src/components/BalanceChip/BalanceChip.module.css +1 -1
- package/src/components/Button/Button.module.css +18 -2
- package/src/components/Card/Card.module.css +1 -1
- package/src/components/CardTile/CardTile.module.css +181 -0
- package/src/components/CardTile/CardTile.stories.tsx +86 -0
- package/src/components/CardTile/CardTile.tsx +128 -0
- package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
- package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
- package/src/components/Collapsible/Collapsible.module.css +1 -1
- package/src/components/ColorPicker/ColorPicker.module.css +132 -0
- package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
- package/src/components/ColorPicker/ColorPicker.tsx +157 -0
- package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
- package/src/components/Combobox/Combobox.module.css +2 -2
- package/src/components/Command/Command.module.css +1 -1
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
- package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
- package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
- package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
- package/src/components/ContextMenu/ContextMenu.tsx +1 -1
- package/src/components/DatePicker/DatePicker.module.css +1 -1
- package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
- package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
- package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
- package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
- package/src/components/Dialog/Dialog.module.css +130 -21
- package/src/components/Dialog/Dialog.stories.tsx +179 -2
- package/src/components/Dialog/Dialog.tsx +137 -94
- package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
- package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
- package/src/components/Drawer/Drawer.module.css +36 -7
- package/src/components/Drawer/Drawer.stories.tsx +44 -0
- package/src/components/Drawer/Drawer.tsx +27 -66
- package/src/components/Drawer/__tests__/Drawer.test.tsx +55 -6
- package/src/components/GripIcon/GripIcon.tsx +27 -0
- package/src/components/Header/Header.module.css +62 -0
- package/src/components/Header/Header.stories.tsx +452 -0
- package/src/components/Header/Header.tsx +102 -0
- package/src/components/Header/__tests__/Header.test.tsx +104 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
- package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
- package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
- package/src/components/Icon/icons/bold.tsx +20 -0
- package/src/components/Icon/icons/calendar.tsx +4 -4
- package/src/components/Icon/icons/carrot.tsx +3 -3
- package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
- package/src/components/Icon/icons/code.tsx +21 -0
- package/src/components/Icon/icons/copy.tsx +21 -0
- package/src/components/Icon/icons/credit-card.tsx +21 -0
- package/src/components/Icon/icons/database.tsx +22 -0
- package/src/components/Icon/icons/download.tsx +22 -0
- package/src/components/Icon/icons/file-text.tsx +24 -0
- package/src/components/Icon/icons/folder-closed.tsx +21 -0
- package/src/components/Icon/icons/folder-open.tsx +20 -0
- package/src/components/Icon/icons/folder.tsx +20 -0
- package/src/components/Icon/icons/folders.tsx +21 -0
- package/src/components/Icon/icons/hat-glasses.tsx +24 -0
- package/src/components/Icon/icons/history.tsx +22 -0
- package/src/components/Icon/icons/inbox.tsx +21 -0
- package/src/components/Icon/icons/index.ts +169 -1
- package/src/components/Icon/icons/italic.tsx +22 -0
- package/src/components/Icon/icons/layers.tsx +22 -0
- package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
- package/src/components/Icon/icons/layout-grid.tsx +23 -0
- package/src/components/Icon/icons/link.tsx +21 -0
- package/src/components/Icon/icons/list-ordered.tsx +25 -0
- package/src/components/Icon/icons/list.tsx +25 -0
- package/src/components/Icon/icons/luggage.tsx +24 -0
- package/src/components/Icon/icons/map-pin.tsx +21 -0
- package/src/components/Icon/icons/menu.tsx +22 -0
- package/src/components/Icon/icons/minus.tsx +20 -0
- package/src/components/Icon/icons/paint-bucket.tsx +23 -0
- package/src/components/Icon/icons/panel-left.tsx +21 -0
- package/src/components/Icon/icons/pin.tsx +21 -0
- package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
- package/src/components/Icon/icons/refresh-cw.tsx +23 -0
- package/src/components/Icon/icons/rotate-cw.tsx +21 -0
- package/src/components/Icon/icons/route.tsx +22 -0
- package/src/components/Icon/icons/send.tsx +21 -0
- package/src/components/Icon/icons/share-2.tsx +24 -0
- package/src/components/Icon/icons/sheet.tsx +24 -0
- package/src/components/Icon/icons/smartphone.tsx +21 -0
- package/src/components/Icon/icons/square-kanban.tsx +23 -0
- package/src/components/Icon/icons/table.tsx +23 -0
- package/src/components/Icon/icons/user-round-plus.tsx +23 -0
- package/src/components/Icon/icons/users.tsx +23 -0
- package/src/components/IconPicker/IconPicker.module.css +1 -1
- package/src/components/Input/Input.module.css +1 -1
- package/src/components/Input/Input.tsx +8 -2
- package/src/components/Kbd/Kbd.module.css +1 -1
- package/src/components/LegalLinks/LegalLinks.module.css +24 -0
- package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
- package/src/components/LegalLinks/LegalLinks.tsx +40 -0
- package/src/components/Markdown/Markdown.module.css +73 -0
- package/src/components/Markdown/Markdown.stories.tsx +40 -0
- package/src/components/Markdown/Markdown.tsx +305 -0
- package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
- package/src/components/Menu/Menu.module.css +1 -0
- package/src/components/Menu/Menu.tsx +72 -22
- package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
- package/src/components/Menubar/Menubar.tsx +1 -1
- package/src/components/Message/Message.module.css +1 -1
- package/src/components/MessageScroller/MessageScroller.module.css +12 -1
- package/src/components/MessageScroller/MessageScroller.tsx +12 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +82 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
- package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
- package/src/components/MobileFooter/MobileFooter.module.css +73 -0
- package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
- package/src/components/MobileFooter/MobileFooter.tsx +122 -0
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +129 -0
- package/src/components/MobileHeader/MobileHeader.module.css +76 -0
- package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
- package/src/components/MobileHeader/MobileHeader.tsx +90 -0
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +113 -0
- package/src/components/NavList/NavList.module.css +130 -0
- package/src/components/NavList/NavList.tsx +136 -0
- package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
- package/src/components/NavTabs/NavTabs.module.css +1 -1
- package/src/components/NavTabs/NavTabs.tsx +39 -11
- package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
- package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
- package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
- package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
- package/src/components/OfflineGate/OfflineGate.tsx +44 -0
- package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
- package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
- package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
- package/src/components/PageContainer/PageContainer.module.css +82 -0
- package/src/components/PageContainer/PageContainer.tsx +64 -0
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +70 -0
- package/src/components/PageHeader/PageHeader.module.css +54 -0
- package/src/components/PageHeader/PageHeader.tsx +157 -2
- package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
- package/src/components/PageLayout/PageLayout.module.css +51 -0
- package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
- package/src/components/PageLayout/PageLayout.tsx +51 -0
- package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
- package/src/components/Popover/Popover.module.css +1 -1
- package/src/components/Popover/Popover.tsx +86 -4
- package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
- package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
- package/src/components/Resizable/Resizable.module.css +3 -3
- package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
- package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
- package/src/components/RootLayout/RootLayout.module.css +12 -0
- package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
- package/src/components/RootLayout/RootLayout.tsx +185 -0
- package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
- package/src/components/Select/Select.module.css +1 -1
- package/src/components/Sheet/Sheet.module.css +4 -1
- package/src/components/Sheet/Sheet.tsx +14 -56
- package/src/components/SplitPane/SplitPane.module.css +4 -4
- package/src/components/StatusBadge/StatusBadge.module.css +1 -1
- package/src/components/StepStrip/StepStrip.module.css +53 -0
- package/src/components/StepStrip/StepStrip.tsx +74 -0
- package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +64 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +243 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
- package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +214 -0
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
- package/src/components/SwipableMobileCarousel/context.ts +13 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +64 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +73 -0
- package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
- package/src/components/SwipeStack/SwipeStack.module.css +101 -0
- package/src/components/SwipeStack/SwipeStack.tsx +199 -0
- package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
- package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
- package/src/components/SystemBanner/SystemBanner.module.css +10 -0
- package/src/components/Table/Table.module.css +2 -2
- package/src/components/Tabs/Tabs.module.css +1 -1
- package/src/components/TagInput/TagInput.module.css +2 -2
- package/src/components/Textarea/Textarea.module.css +9 -1
- package/src/components/Textarea/Textarea.tsx +28 -5
- package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
- package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
- package/src/components/Toast/Toast.module.css +62 -3
- package/src/components/Toast/Toast.stories.tsx +1 -1
- package/src/components/Tooltip/Tooltip.module.css +1 -1
- package/src/components/UserMenu/UserMenu.module.css +90 -0
- package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
- package/src/components/UserMenu/UserMenu.tsx +132 -0
- package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
- package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
- package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
- package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
- package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
- package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
- package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +175 -0
- package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
- package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
- package/src/hooks/index.ts +27 -0
- package/src/hooks/useCarouselRouteSync.ts +92 -0
- package/src/hooks/useIsOffline.ts +41 -0
- package/src/hooks/useOfflineTileState.ts +45 -0
- package/src/hooks/usePublishShellChromeHeight.ts +96 -0
- package/src/hooks/useReorderSensors.ts +88 -0
- package/src/hooks/useResponsiveLayout.ts +36 -0
- package/src/hooks/useSnapCarousel.ts +284 -0
- package/src/hooks/useSwipeReveal.ts +107 -0
- package/src/hooks/useSwipeStack.ts +235 -0
- package/src/index.ts +124 -3
- package/src/overlay-shell.ts +159 -0
- package/src/stories/DesignSystemOverview.stories.tsx +933 -6
- package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
- package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
- package/src/stories/GripIcon.stories.tsx +29 -0
- package/src/stories/InteractionHooks.stories.tsx +483 -0
- package/src/stories/LayoutSystem.stories.tsx +639 -0
- package/src/stories/MenuEntries.stories.tsx +86 -0
- package/src/stories/MobilePatterns.stories.tsx +247 -2
- package/src/stories/NavList.stories.tsx +147 -0
- package/src/stories/NavTabs.stories.tsx +18 -0
- package/src/stories/OfflineGate.stories.tsx +42 -0
- package/src/stories/PageContainer.stories.tsx +82 -0
- package/src/stories/PageHeader.stories.tsx +65 -1
- package/src/stories/ResponsiveSurface.stories.tsx +47 -0
- package/src/stories/StepStrip.stories.tsx +76 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +362 -0
- package/src/stories/SwipeStack.stories.tsx +200 -0
- package/src/stories/Textarea.stories.tsx +12 -0
- package/src/stories/TokenGallery.stories.tsx +115 -0
- package/src/tokens/primitives.css +36 -0
- package/src/tokens/semantic.css +32 -0
- package/src/tokens/theme-presets.css +33 -0
- package/src/tokens/theme-presets.ts +72 -0
|
@@ -1,21 +1,176 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
1
3
|
import type { ReactNode } from 'react';
|
|
4
|
+
import { Button } from '../Button/Button';
|
|
5
|
+
import { Icon } from '../Icon/Icon';
|
|
6
|
+
import type { IconName } from '../Icon/Icon';
|
|
7
|
+
import { ResponsiveSurface } from '../ResponsiveSurface/ResponsiveSurface';
|
|
2
8
|
import styles from './PageHeader.module.css';
|
|
3
9
|
|
|
4
10
|
export interface PageHeaderProps {
|
|
5
11
|
title: string;
|
|
6
12
|
description?: string;
|
|
13
|
+
/** Optional icon rendered inline before the title text, on both breakpoints — e.g. `folder-open` next to a folder's name. */
|
|
14
|
+
icon?: IconName;
|
|
15
|
+
/**
|
|
16
|
+
* Page-level controls — anywhere from one button to a whole toolbar.
|
|
17
|
+
* Renders on both breakpoints unchanged; if it needs to look different on
|
|
18
|
+
* mobile (e.g. collapsing into its own "⋮" trigger), that's the action
|
|
19
|
+
* content's own responsibility — same pattern Shopper's `ListHeaderActions`
|
|
20
|
+
* already follows (it calls `useIsMobile()` internally). `PageHeader`
|
|
21
|
+
* itself has no opinion on what's inside this slot.
|
|
22
|
+
*/
|
|
7
23
|
action?: ReactNode;
|
|
8
24
|
className?: string;
|
|
25
|
+
/** Heading level for the title. Defaults to `1` for standalone use; pass
|
|
26
|
+
* `2` or `3` when the page already sits under a shell/plugin `<h1>`, to
|
|
27
|
+
* avoid a duplicate top-level heading. Visual style is unchanged at every
|
|
28
|
+
* level — only the rendered tag changes. Applies on both breakpoints. */
|
|
29
|
+
headingLevel?: 1 | 2 | 3;
|
|
30
|
+
/** Mobile only — shows a back button (calling this) to the left of the
|
|
31
|
+
* title when provided. Omit it to render without one. */
|
|
32
|
+
onBack?: () => void;
|
|
33
|
+
/** Mobile only — shows a vertical-ellipsis icon button (calling this) at
|
|
34
|
+
* the far right when provided, typically to open a drawer/menu. PageHeader
|
|
35
|
+
* has no opinion on what that menu contains — it only renders the trigger
|
|
36
|
+
* and calls back. Separate from `action`, which renders identically on
|
|
37
|
+
* both breakpoints; this is a mobile-only affordance with no web
|
|
38
|
+
* equivalent. */
|
|
39
|
+
onMenuClick?: () => void;
|
|
9
40
|
}
|
|
10
41
|
|
|
11
|
-
|
|
42
|
+
const HEADING_TAG = { 1: 'h1', 2: 'h2', 3: 'h3' } as const;
|
|
43
|
+
|
|
44
|
+
interface PageHeaderWebProps {
|
|
45
|
+
title: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
icon?: IconName;
|
|
48
|
+
action?: ReactNode;
|
|
49
|
+
headingLevel: 1 | 2 | 3;
|
|
50
|
+
className?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// internal — not exported
|
|
54
|
+
function PageHeaderWeb({
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
icon,
|
|
58
|
+
action,
|
|
59
|
+
headingLevel,
|
|
60
|
+
className,
|
|
61
|
+
}: PageHeaderWebProps) {
|
|
62
|
+
const Heading = HEADING_TAG[headingLevel];
|
|
12
63
|
return (
|
|
13
64
|
<header className={[styles.header, className].filter(Boolean).join(' ')}>
|
|
14
65
|
<div className={styles.text}>
|
|
15
|
-
<
|
|
66
|
+
<div className={styles.titleRow}>
|
|
67
|
+
{icon && <Icon name={icon} size="lg" aria-hidden={true} />}
|
|
68
|
+
<Heading className={styles.title}>{title}</Heading>
|
|
69
|
+
</div>
|
|
16
70
|
{description && <p className={styles.description}>{description}</p>}
|
|
17
71
|
</div>
|
|
18
72
|
{action && <div className={styles.action}>{action}</div>}
|
|
19
73
|
</header>
|
|
20
74
|
);
|
|
21
75
|
}
|
|
76
|
+
|
|
77
|
+
interface PageHeaderMobileProps {
|
|
78
|
+
title: string;
|
|
79
|
+
icon?: IconName;
|
|
80
|
+
action?: ReactNode;
|
|
81
|
+
headingLevel: 1 | 2 | 3;
|
|
82
|
+
onBack?: () => void;
|
|
83
|
+
onMenuClick?: () => void;
|
|
84
|
+
className?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// internal — not exported
|
|
88
|
+
// No `description` here, deliberately — matches the one real mobile
|
|
89
|
+
// page-header precedent this was generalized from (Kanban's own
|
|
90
|
+
// MobileBoardHeader), which has no subtitle handling either. A single
|
|
91
|
+
// fixed-shape row, consistent with every other mobile chrome bar in this
|
|
92
|
+
// design system rather than a variable-height two-line header.
|
|
93
|
+
function PageHeaderMobile({
|
|
94
|
+
title,
|
|
95
|
+
icon,
|
|
96
|
+
action,
|
|
97
|
+
headingLevel,
|
|
98
|
+
onBack,
|
|
99
|
+
onMenuClick,
|
|
100
|
+
className,
|
|
101
|
+
}: PageHeaderMobileProps) {
|
|
102
|
+
const Heading = HEADING_TAG[headingLevel];
|
|
103
|
+
return (
|
|
104
|
+
<header className={[styles.mobileHeader, className].filter(Boolean).join(' ')}>
|
|
105
|
+
{onBack && (
|
|
106
|
+
<Button
|
|
107
|
+
variant="ghost"
|
|
108
|
+
size="sm"
|
|
109
|
+
aria-label="Back"
|
|
110
|
+
onClick={onBack}
|
|
111
|
+
className={styles.mobileBack}
|
|
112
|
+
>
|
|
113
|
+
<Icon name="circle-chevron-left" size="md" aria-hidden />
|
|
114
|
+
</Button>
|
|
115
|
+
)}
|
|
116
|
+
{icon && <Icon name={icon} size="md" aria-hidden={true} className={styles.mobileTitleIcon} />}
|
|
117
|
+
<Heading className={styles.mobileTitle}>{title}</Heading>
|
|
118
|
+
{action && <div className={styles.mobileAction}>{action}</div>}
|
|
119
|
+
{onMenuClick && (
|
|
120
|
+
<Button
|
|
121
|
+
variant="ghost"
|
|
122
|
+
size="sm"
|
|
123
|
+
aria-label="Menu"
|
|
124
|
+
onClick={onMenuClick}
|
|
125
|
+
className={styles.mobileMenu}
|
|
126
|
+
>
|
|
127
|
+
<Icon name="ellipsis-vertical" size="md" aria-hidden />
|
|
128
|
+
</Button>
|
|
129
|
+
)}
|
|
130
|
+
</header>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* PageHeader — a page's title section. Web: title + description on the
|
|
136
|
+
* left, action on the right. Mobile: an optional back button, a single-line
|
|
137
|
+
* title, and the same action slot — a compact single row, matching Kanban's
|
|
138
|
+
* own hand-rolled MobileBoardHeader this was generalized from. The
|
|
139
|
+
* web/mobile fork is `ResponsiveSurface` — only one tree is ever mounted.
|
|
140
|
+
*/
|
|
141
|
+
export function PageHeader({
|
|
142
|
+
title,
|
|
143
|
+
description,
|
|
144
|
+
icon,
|
|
145
|
+
action,
|
|
146
|
+
className,
|
|
147
|
+
headingLevel = 1,
|
|
148
|
+
onBack,
|
|
149
|
+
onMenuClick,
|
|
150
|
+
}: PageHeaderProps) {
|
|
151
|
+
return (
|
|
152
|
+
<ResponsiveSurface
|
|
153
|
+
web={
|
|
154
|
+
<PageHeaderWeb
|
|
155
|
+
title={title}
|
|
156
|
+
description={description}
|
|
157
|
+
icon={icon}
|
|
158
|
+
action={action}
|
|
159
|
+
headingLevel={headingLevel}
|
|
160
|
+
className={className}
|
|
161
|
+
/>
|
|
162
|
+
}
|
|
163
|
+
mobile={
|
|
164
|
+
<PageHeaderMobile
|
|
165
|
+
title={title}
|
|
166
|
+
icon={icon}
|
|
167
|
+
action={action}
|
|
168
|
+
headingLevel={headingLevel}
|
|
169
|
+
onBack={onBack}
|
|
170
|
+
onMenuClick={onMenuClick}
|
|
171
|
+
className={className}
|
|
172
|
+
/>
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { PageHeader } from '../PageHeader';
|
|
5
|
+
|
|
6
|
+
let matches = false;
|
|
7
|
+
|
|
8
|
+
function installMatchMedia() {
|
|
9
|
+
vi.stubGlobal(
|
|
10
|
+
'matchMedia',
|
|
11
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
12
|
+
matches,
|
|
13
|
+
media: query,
|
|
14
|
+
addEventListener: vi.fn(),
|
|
15
|
+
removeEventListener: vi.fn(),
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
dispatchEvent: vi.fn(),
|
|
19
|
+
})),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('PageHeader', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
matches = false;
|
|
26
|
+
installMatchMedia();
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.unstubAllGlobals();
|
|
30
|
+
cleanup();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('web: renders title, description, and action', () => {
|
|
34
|
+
render(
|
|
35
|
+
<PageHeader
|
|
36
|
+
title="Users"
|
|
37
|
+
description="Manage who has access to this instance."
|
|
38
|
+
action={<button type="button">Invite user</button>}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(screen.getByRole('heading', { name: 'Users' })).toBeDefined();
|
|
43
|
+
expect(screen.getByText('Manage who has access to this instance.')).toBeDefined();
|
|
44
|
+
expect(screen.getByRole('button', { name: 'Invite user' })).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('web: renders no back button even when onBack is given', () => {
|
|
48
|
+
render(<PageHeader title="Users" onBack={() => undefined} />);
|
|
49
|
+
|
|
50
|
+
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('mobile: renders no description, no back button, no menu button by default', () => {
|
|
54
|
+
matches = true;
|
|
55
|
+
installMatchMedia();
|
|
56
|
+
|
|
57
|
+
render(<PageHeader title="Users" description="Manage who has access to this instance." />);
|
|
58
|
+
|
|
59
|
+
expect(screen.getByRole('heading', { name: 'Users' })).toBeDefined();
|
|
60
|
+
expect(screen.queryByText('Manage who has access to this instance.')).toBeNull();
|
|
61
|
+
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
|
|
62
|
+
expect(screen.queryByRole('button', { name: 'Menu' })).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('mobile: renders a back button that calls onBack when given', () => {
|
|
66
|
+
matches = true;
|
|
67
|
+
installMatchMedia();
|
|
68
|
+
const onBack = vi.fn();
|
|
69
|
+
|
|
70
|
+
render(<PageHeader title="Users" onBack={onBack} />);
|
|
71
|
+
|
|
72
|
+
const back = screen.getByRole('button', { name: 'Back' });
|
|
73
|
+
fireEvent.click(back);
|
|
74
|
+
expect(onBack).toHaveBeenCalledOnce();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('mobile: renders a menu button that calls onMenuClick when given', () => {
|
|
78
|
+
matches = true;
|
|
79
|
+
installMatchMedia();
|
|
80
|
+
const onMenuClick = vi.fn();
|
|
81
|
+
|
|
82
|
+
render(<PageHeader title="Users" onMenuClick={onMenuClick} />);
|
|
83
|
+
|
|
84
|
+
const menu = screen.getByRole('button', { name: 'Menu' });
|
|
85
|
+
fireEvent.click(menu);
|
|
86
|
+
expect(onMenuClick).toHaveBeenCalledOnce();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('web: renders no menu button even when onMenuClick is given', () => {
|
|
90
|
+
render(<PageHeader title="Users" onMenuClick={() => undefined} />);
|
|
91
|
+
|
|
92
|
+
expect(screen.queryByRole('button', { name: 'Menu' })).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('mobile: renders the same action content as web', () => {
|
|
96
|
+
matches = true;
|
|
97
|
+
installMatchMedia();
|
|
98
|
+
|
|
99
|
+
render(<PageHeader title="Users" action={<button type="button">Invite user</button>} />);
|
|
100
|
+
|
|
101
|
+
expect(screen.getByRole('button', { name: 'Invite user' })).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
min-width: 100%;
|
|
5
|
+
min-height: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.header {
|
|
9
|
+
flex-shrink: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.content {
|
|
13
|
+
flex: 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* ── Padding ────────────────────────────────────────────────────────────── */
|
|
17
|
+
/* Same four-step scale and --sv-page-gutter stand-down hook as
|
|
18
|
+
PageContainer (see that component's own CSS comment for the full
|
|
19
|
+
rationale) — kept identical so this can absorb PageContainer's role
|
|
20
|
+
later without a behavior change for existing consumers. */
|
|
21
|
+
|
|
22
|
+
.padNone {
|
|
23
|
+
padding: 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.padSm {
|
|
27
|
+
padding: var(--sv-page-gutter, var(--sv-space-4));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.padMd {
|
|
31
|
+
padding: var(--sv-page-gutter, var(--sv-space-8));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.padLg {
|
|
35
|
+
padding: var(--sv-page-gutter, var(--sv-space-12));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Matches the breakpoint Dialog, MobileHeader, and MobileFooter already use. */
|
|
39
|
+
@media (max-width: 768px) {
|
|
40
|
+
.padSm {
|
|
41
|
+
padding: var(--sv-page-gutter, var(--sv-space-3));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.padMd {
|
|
45
|
+
padding: var(--sv-page-gutter, var(--sv-space-4));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.padLg {
|
|
49
|
+
padding: var(--sv-page-gutter, var(--sv-space-6));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { PageLayout } from './PageLayout';
|
|
3
|
+
|
|
4
|
+
function Content() {
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
style={{
|
|
8
|
+
fontFamily: 'var(--sv-font-family)',
|
|
9
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
10
|
+
color: 'var(--sv-color-text-primary)',
|
|
11
|
+
}}
|
|
12
|
+
>
|
|
13
|
+
{Array.from({ length: 6 }, (_, index) => (
|
|
14
|
+
<p key={index}>Page content row {index + 1}.</p>
|
|
15
|
+
))}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function PageHeader() {
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
style={{
|
|
24
|
+
display: 'flex',
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
justifyContent: 'space-between',
|
|
27
|
+
padding: 'var(--sv-space-3) var(--sv-space-4)',
|
|
28
|
+
borderBottom: '1px solid var(--sv-color-border)',
|
|
29
|
+
fontFamily: 'var(--sv-font-family)',
|
|
30
|
+
fontSize: 'var(--sv-font-size-md)',
|
|
31
|
+
fontWeight: 600,
|
|
32
|
+
color: 'var(--sv-color-text-primary)',
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
Sv Wallet
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const meta = {
|
|
41
|
+
title: 'Components/PageLayout',
|
|
42
|
+
component: PageLayout,
|
|
43
|
+
parameters: {
|
|
44
|
+
layout: 'padded',
|
|
45
|
+
docs: {
|
|
46
|
+
description: {
|
|
47
|
+
component:
|
|
48
|
+
"A single page's content area, nested inside RootLayout's main slot — not a replacement for RootLayout. No padding by default; opt in via the padding prop (same scale as PageContainer). The optional header is page-specific (e.g. a board title/toolbar), not the app-level header RootLayout already renders.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
args: {
|
|
53
|
+
children: <Content />,
|
|
54
|
+
},
|
|
55
|
+
} satisfies Meta<typeof PageLayout>;
|
|
56
|
+
|
|
57
|
+
export default meta;
|
|
58
|
+
type Story = StoryObj<typeof meta>;
|
|
59
|
+
|
|
60
|
+
export const Default: Story = {};
|
|
61
|
+
|
|
62
|
+
export const WithPadding: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
padding: 'lg',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const WithHeader: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
header: <PageHeader />,
|
|
71
|
+
padding: 'md',
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import styles from './PageLayout.module.css';
|
|
5
|
+
|
|
6
|
+
export type PageLayoutPadding = 'none' | 'sm' | 'md' | 'lg';
|
|
7
|
+
|
|
8
|
+
export interface PageLayoutProps {
|
|
9
|
+
/**
|
|
10
|
+
* A page-specific header — e.g. a board title + search/share toolbar.
|
|
11
|
+
* Not the app-level header (that's `RootLayout`'s `header`/`shell`
|
|
12
|
+
* variants) — this is a second, contextual row scoped to this one page.
|
|
13
|
+
* Renders edge-to-edge above the padded content, outside `padding`.
|
|
14
|
+
*/
|
|
15
|
+
header?: ReactNode;
|
|
16
|
+
/** The page gutter. Default `'none'` — unlike `PageContainer`, a bare
|
|
17
|
+
* `PageLayout` applies no padding at all; opt in per page. Same four-step
|
|
18
|
+
* scale and `--sv-page-gutter` stand-down hook as `PageContainer` (steps
|
|
19
|
+
* down at the same 768px breakpoint `Dialog`/`MobileHeader` use). */
|
|
20
|
+
padding?: PageLayoutPadding;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const paddingClass: Record<PageLayoutPadding, string> = {
|
|
26
|
+
none: styles.padNone as string,
|
|
27
|
+
sm: styles.padSm as string,
|
|
28
|
+
md: styles.padMd as string,
|
|
29
|
+
lg: styles.padLg as string,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* PageLayout — a single page's content area within a plugin (nested inside
|
|
34
|
+
* `RootLayout`'s `main` slot, not a replacement for it). Enforces only
|
|
35
|
+
* structure and dimensions; content always comes from props/children.
|
|
36
|
+
*
|
|
37
|
+
* `min-width: 100%; min-height: 100%` — safe here (unlike `RootLayout`'s own
|
|
38
|
+
* root) because `PageLayout` is always nested inside an already
|
|
39
|
+
* flex-stretched region (`ThreeColumnLayout`/`HeaderFooterLayout`'s
|
|
40
|
+
* `.main`), and a stretched flex item's cross-size is definite for its
|
|
41
|
+
* descendants' percentage resolution, so no separate height-cascading fix
|
|
42
|
+
* is needed the way `RootLayout`'s root required one.
|
|
43
|
+
*/
|
|
44
|
+
export function PageLayout({ header, padding = 'none', children, className }: PageLayoutProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
47
|
+
{header && <div className={styles.header}>{header}</div>}
|
|
48
|
+
<div className={[styles.content, paddingClass[padding]].join(' ')}>{children}</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { PageLayout } from '../PageLayout';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('PageLayout', () => {
|
|
9
|
+
it('renders children with no header by default', () => {
|
|
10
|
+
render(<PageLayout>Content</PageLayout>);
|
|
11
|
+
|
|
12
|
+
expect(screen.getByText('Content')).toBeDefined();
|
|
13
|
+
expect(screen.queryByText('Header')).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('renders a page-specific header when given', () => {
|
|
17
|
+
render(<PageLayout header={<span>Header</span>}>Content</PageLayout>);
|
|
18
|
+
|
|
19
|
+
expect(screen.getByText('Header')).toBeDefined();
|
|
20
|
+
expect(screen.getByText('Content')).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('applies no padding by default', () => {
|
|
24
|
+
render(<PageLayout>Content</PageLayout>);
|
|
25
|
+
|
|
26
|
+
expect(screen.getByText('Content').className).toContain('padNone');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('applies the requested padding step', () => {
|
|
30
|
+
render(<PageLayout padding="lg">Content</PageLayout>);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByText('Content').className).toContain('padLg');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
top: calc(100% + var(--sv-space-2));
|
|
9
9
|
z-index: 60;
|
|
10
10
|
background: var(--sv-color-surface);
|
|
11
|
-
border:
|
|
11
|
+
border: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
12
12
|
border-radius: var(--sv-radius-2xl);
|
|
13
13
|
box-shadow: var(--sv-shadow-popover);
|
|
14
14
|
/* auto, not hidden: Popover.tsx applies an inline max-height only when the
|
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
type ReactElement,
|
|
10
10
|
type ReactNode,
|
|
11
11
|
} from 'react';
|
|
12
|
+
import {
|
|
13
|
+
isTopmostOpenOverlay,
|
|
14
|
+
registerOpenOverlay,
|
|
15
|
+
unregisterOpenOverlay,
|
|
16
|
+
} from '../../overlay-shell';
|
|
12
17
|
import styles from './Popover.module.css';
|
|
13
18
|
|
|
14
19
|
export interface PopoverProps {
|
|
@@ -30,6 +35,17 @@ export interface PopoverProps {
|
|
|
30
35
|
* regardless of CSS module load order, unlike passing an extra className.
|
|
31
36
|
*/
|
|
32
37
|
panelStyle?: CSSProperties;
|
|
38
|
+
/**
|
|
39
|
+
* Class applied to the popover's own wrapper around `trigger` — the
|
|
40
|
+
* element that participates in the caller's layout.
|
|
41
|
+
*
|
|
42
|
+
* Mostly needed for flex sizing: the wrapper is an `inline-flex` box with
|
|
43
|
+
* the default `min-width: auto`, which pins it to the trigger's content
|
|
44
|
+
* width. Inside a flex row that has to shrink, that stops a truncatable
|
|
45
|
+
* trigger from ever giving up space, and pushes its siblings out of the
|
|
46
|
+
* container instead. Pass a class setting `min-width: 0` there.
|
|
47
|
+
*/
|
|
48
|
+
className?: string;
|
|
33
49
|
'aria-label': string;
|
|
34
50
|
children: ReactNode;
|
|
35
51
|
}
|
|
@@ -78,6 +94,7 @@ export function Popover({
|
|
|
78
94
|
align = 'right',
|
|
79
95
|
width = 288,
|
|
80
96
|
panelStyle,
|
|
97
|
+
className,
|
|
81
98
|
'aria-label': ariaLabel,
|
|
82
99
|
children,
|
|
83
100
|
}: PopoverProps) {
|
|
@@ -92,6 +109,36 @@ export function Popover({
|
|
|
92
109
|
// when the panel is taller than that — null leaves it at its natural
|
|
93
110
|
// height, unconstrained, for the common case where it already fits.
|
|
94
111
|
const [maxHeight, setMaxHeight] = useState<number | null>(null);
|
|
112
|
+
// Horizontal viewport clamp, as a `left` override (container-relative px)
|
|
113
|
+
// that replaces the `align`-driven `left: 0` / `right: 0` class rule —
|
|
114
|
+
// null leaves alignment at its natural, class-driven position, for the
|
|
115
|
+
// common case where the panel already fits. Needed because a fixed-width
|
|
116
|
+
// panel anchored to one edge of a trigger near the *opposite* edge of a
|
|
117
|
+
// narrow viewport (e.g. a 340px panel under a bell sitting near the right
|
|
118
|
+
// edge of a 375px mobile screen) has nowhere to go but off-screen — the
|
|
119
|
+
// existing vertical flip only ever solves the above/below axis, never
|
|
120
|
+
// this one.
|
|
121
|
+
const [horizontalOffset, setHorizontalOffset] = useState<number | null>(null);
|
|
122
|
+
const overlayIdRef = useRef<string | null>(null);
|
|
123
|
+
|
|
124
|
+
// Registers into the same open-overlay stack Dialog/Drawer/Sheet/
|
|
125
|
+
// ConfirmDialog use (overlay-shell.ts) purely so a Dialog this Popover was
|
|
126
|
+
// opened from inside of (e.g. a DatePicker/Combobox field in a modal form —
|
|
127
|
+
// Popover is what Menu/Combobox/DatePicker/ContextMenu/HoverCard/UserMenu
|
|
128
|
+
// are all built on) knows it is no longer the topmost overlay, and defers
|
|
129
|
+
// Escape to this one instead of closing itself too. A separate effect keyed
|
|
130
|
+
// only on `open`, matching useOverlayKeyboardTrap's own pattern — identity
|
|
131
|
+
// churn on `onClose` shouldn't re-register and bump this popover back to
|
|
132
|
+
// the top of the stack.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!open) return;
|
|
135
|
+
const id = registerOpenOverlay();
|
|
136
|
+
overlayIdRef.current = id;
|
|
137
|
+
return () => {
|
|
138
|
+
unregisterOpenOverlay(id);
|
|
139
|
+
overlayIdRef.current = null;
|
|
140
|
+
};
|
|
141
|
+
}, [open]);
|
|
95
142
|
|
|
96
143
|
// width="trigger": measure the trigger's own rendered width and keep the
|
|
97
144
|
// panel matching it, including live resizes (e.g. rotating the device, or
|
|
@@ -127,6 +174,7 @@ export function Popover({
|
|
|
127
174
|
if (!open) {
|
|
128
175
|
setOpenUpward(false);
|
|
129
176
|
setMaxHeight(null);
|
|
177
|
+
setHorizontalOffset(null);
|
|
130
178
|
return;
|
|
131
179
|
}
|
|
132
180
|
const container = containerRef.current;
|
|
@@ -155,10 +203,37 @@ export function Popover({
|
|
|
155
203
|
setOpenUpward(upward);
|
|
156
204
|
const available = upward ? spaceAbove : spaceBelow;
|
|
157
205
|
setMaxHeight(panelHeight > available ? Math.max(available, 0) : null);
|
|
206
|
+
|
|
207
|
+
// Horizontal clamp. Deliberately computed from `containerRect` (the
|
|
208
|
+
// trigger's own position, never affected by anything this effect does)
|
|
209
|
+
// and the panel's rendered *width* (also offset-independent) rather than
|
|
210
|
+
// reading the panel's current `left`/`right` off the DOM — those reflect
|
|
211
|
+
// whatever `horizontalOffset` a *previous* run of this same effect may
|
|
212
|
+
// already have applied. Measuring the rendered position directly is not
|
|
213
|
+
// idempotent: re-running this effect after it already nudged the panel
|
|
214
|
+
// on-screen would see an already-corrected rect that looks like it never
|
|
215
|
+
// needed correcting, and clear the very offset it just set (confirmed
|
|
216
|
+
// live — React StrictMode's dev-only double-invoke of layout effects hit
|
|
217
|
+
// exactly this: the second pass measured the first pass's fix as "fits
|
|
218
|
+
// fine" and reset `horizontalOffset` back to null before first paint).
|
|
219
|
+
// Rebuilding the *natural*, un-offset position analytically from the
|
|
220
|
+
// same `align`-driven rule the CSS class itself encodes (`right: 0` /
|
|
221
|
+
// `left: 0` relative to the container) sidesteps that entirely — the
|
|
222
|
+
// same inputs always produce the same output, however many times this
|
|
223
|
+
// runs.
|
|
224
|
+
const containerRect = container.getBoundingClientRect();
|
|
225
|
+
const panelWidth = panel.getBoundingClientRect().width;
|
|
226
|
+
const naturalLeft = align === 'right' ? containerRect.right - panelWidth : containerRect.left;
|
|
227
|
+
const clampedLeft = Math.min(
|
|
228
|
+
Math.max(naturalLeft, VIEWPORT_MARGIN),
|
|
229
|
+
window.innerWidth - VIEWPORT_MARGIN - panelWidth,
|
|
230
|
+
);
|
|
231
|
+
setHorizontalOffset(clampedLeft !== naturalLeft ? clampedLeft - containerRect.left : null);
|
|
158
232
|
// children included: content that changes the panel's height (e.g. a
|
|
159
233
|
// calendar switching months to a row with fewer/more weeks) should
|
|
160
234
|
// re-run this measurement, not just the open/close transition itself.
|
|
161
|
-
|
|
235
|
+
// align included: the horizontal clamp above reads it directly.
|
|
236
|
+
}, [open, children, align]);
|
|
162
237
|
|
|
163
238
|
// Outside-click dismissal
|
|
164
239
|
useEffect(() => {
|
|
@@ -172,18 +247,24 @@ export function Popover({
|
|
|
172
247
|
return () => document.removeEventListener('mousedown', handleMouseDown);
|
|
173
248
|
}, [open, onClose]);
|
|
174
249
|
|
|
175
|
-
// Escape key dismissal
|
|
250
|
+
// Escape key dismissal — deferred to a nested overlay (if any) via the same
|
|
251
|
+
// topmost-overlay check useOverlayKeyboardTrap uses, so e.g. opening a
|
|
252
|
+
// ConfirmDialog from inside this panel's content doesn't also close the
|
|
253
|
+
// popover out from under it on the same Escape press.
|
|
176
254
|
useEffect(() => {
|
|
177
255
|
if (!open) return;
|
|
178
256
|
function handleKeyDown(e: KeyboardEvent) {
|
|
179
|
-
if (e.key
|
|
257
|
+
if (e.key !== 'Escape') return;
|
|
258
|
+
const id = overlayIdRef.current;
|
|
259
|
+
if (id !== null && !isTopmostOpenOverlay(id)) return;
|
|
260
|
+
onClose();
|
|
180
261
|
}
|
|
181
262
|
document.addEventListener('keydown', handleKeyDown);
|
|
182
263
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
183
264
|
}, [open, onClose]);
|
|
184
265
|
|
|
185
266
|
return (
|
|
186
|
-
<div ref={containerRef} className={styles.container}>
|
|
267
|
+
<div ref={containerRef} className={[styles.container, className].filter(Boolean).join(' ')}>
|
|
187
268
|
{trigger}
|
|
188
269
|
{open && (
|
|
189
270
|
<div
|
|
@@ -197,6 +278,7 @@ export function Popover({
|
|
|
197
278
|
style={{
|
|
198
279
|
width: width === 'trigger' ? (triggerWidth ?? undefined) : width,
|
|
199
280
|
maxHeight: maxHeight ?? undefined,
|
|
281
|
+
...(horizontalOffset != null ? { left: horizontalOffset, right: 'auto' } : {}),
|
|
200
282
|
...panelStyle,
|
|
201
283
|
}}
|
|
202
284
|
>
|