@flamingo-stack/openframe-frontend-core 0.0.702 → 0.0.703-snapshot.20261006080901
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/{chunk-CYYTD5ZK.cjs → chunk-262YQFOS.cjs} +47 -47
- package/dist/{chunk-SAA7B3DA.js → chunk-2G546OND.js} +500 -67
- package/dist/{chunk-6JPQDYYY.js → chunk-2JDVL7UV.js} +2614 -2001
- package/dist/{chunk-ASN65P3F.cjs → chunk-4DQMTBMK.cjs} +15 -15
- package/dist/{chunk-CZ34HXDW.js → chunk-4KGXLVCF.js} +686 -725
- package/dist/{chunk-TCZCMD5Q.js → chunk-5HBHO5TB.js} +1 -1
- package/dist/{chunk-446KS5BG.js → chunk-5QJDM6UV.js} +5 -5
- package/dist/{chunk-UJJWTJ6S.js → chunk-6WQIUQUC.js} +30 -16
- package/dist/{chunk-YR6JPJF4.js → chunk-6XIF2Z7I.js} +4 -4
- package/dist/{chunk-YGRHITPY.cjs → chunk-7JIL3WOH.cjs} +24 -24
- package/dist/{chunk-RS7HWAYX.js → chunk-7LFIKJX2.js} +3 -3
- package/dist/{chunk-4OGAXAEG.cjs → chunk-ABU3TGXA.cjs} +3 -3
- package/dist/{chunk-3WSFIV5G.cjs → chunk-AMYY3PNL.cjs} +69 -55
- package/dist/{chunk-BRXOGCH3.cjs → chunk-BIZNSF3U.cjs} +3 -3
- package/dist/{chunk-2A4XES7R.cjs → chunk-BM5MVGH3.cjs} +3 -3
- package/dist/{chunk-IIMICN7L.cjs → chunk-C3BWIU7R.cjs} +4 -44
- package/dist/{chunk-LZNJH23A.cjs → chunk-C7XGRLT5.cjs} +12 -12
- package/dist/{chunk-6YCXA7CN.js → chunk-CH5232HV.js} +20 -19
- package/dist/{chunk-UIBSSVON.cjs → chunk-D7BTSVMS.cjs} +2 -2
- package/dist/{chunk-BO5ZHDA4.js → chunk-ERCV6HHJ.js} +7 -7
- package/dist/{chunk-5V6O73EH.js → chunk-FLKJYMVB.js} +3 -3
- package/dist/{chunk-Z5HDCZAS.js → chunk-FXENTYE5.js} +15 -15
- package/dist/{chunk-HIXGAY2L.cjs → chunk-HFJW24JJ.cjs} +121 -121
- package/dist/{chunk-HPJ4XS2L.js → chunk-HJAYVKWQ.js} +24 -24
- package/dist/{chunk-PLK6GHN7.js → chunk-I7DFUQMN.js} +10 -10
- package/dist/{chunk-KJ5LWATV.js → chunk-IHU5ANUY.js} +7 -7
- package/dist/{chunk-AEQANLBW.cjs → chunk-IZGDG5X6.cjs} +505 -72
- package/dist/{chunk-2V4Z44G6.js → chunk-JEYRXT5L.js} +17 -17
- package/dist/{chunk-B3LZJ6J3.cjs → chunk-L3IC42IF.cjs} +32 -32
- package/dist/{chunk-RTGPIHVO.cjs → chunk-LDPEQGKT.cjs} +2243 -1630
- package/dist/{chunk-FD4DBTFO.cjs → chunk-LTSTC3N5.cjs} +1253 -1292
- package/dist/{chunk-OGLCD3UL.js → chunk-MFFCUVWG.js} +6 -6
- package/dist/{chunk-3L7KCOGO.cjs → chunk-MGGGBHYD.cjs} +30 -30
- package/dist/{chunk-QFW3RUZS.cjs → chunk-MI3JUJ75.cjs} +33 -33
- package/dist/{chunk-EMCQGFXD.cjs → chunk-MMOODW57.cjs} +28 -27
- package/dist/{chunk-Q76ZTHNV.js → chunk-MP2C3LJ4.js} +1 -1
- package/dist/{chunk-E3DH543A.cjs → chunk-N3TO7JV3.cjs} +98 -98
- package/dist/{chunk-2T6QWNXU.js → chunk-NDESEPW5.js} +99 -11
- package/dist/{chunk-ETFFEA3J.js → chunk-NRLJUBCS.js} +7 -7
- package/dist/{chunk-HUOLB7ZN.cjs → chunk-OHFS7VOU.cjs} +9 -9
- package/dist/{chunk-MWUBBBWZ.js → chunk-QVSFBSWV.js} +10 -10
- package/dist/{chunk-MDBHZBW7.cjs → chunk-RKV5B7EU.cjs} +92 -4
- package/dist/{chunk-3G76LRGL.cjs → chunk-SYXQLETR.cjs} +29 -29
- package/dist/{chunk-FHM7GD5B.cjs → chunk-UK62YTH6.cjs} +28 -14
- package/dist/{chunk-HUMWQMCU.js → chunk-UT4YHQZW.js} +3 -3
- package/dist/{chunk-NGJCWEYX.cjs → chunk-VHCACDKP.cjs} +46 -46
- package/dist/{chunk-5FFNNJM7.js → chunk-VR2ZLFCP.js} +1 -1
- package/dist/{chunk-IZKOQVXO.js → chunk-VTASHWER.js} +14 -14
- package/dist/{chunk-64O4L7WN.js → chunk-WUJNYGO2.js} +11 -51
- package/dist/{chunk-JOY6HVPV.js → chunk-XATTBO5X.js} +25 -11
- package/dist/{chunk-L7KZA7ZD.cjs → chunk-XIFOBQRQ.cjs} +6 -6
- package/dist/{chunk-CXDJUKC6.cjs → chunk-XJKZ4IKT.cjs} +4 -4
- package/dist/{chunk-XCIFHW4E.cjs → chunk-XOYH2YQE.cjs} +2 -2
- package/dist/{chunk-HYFLTFFB.js → chunk-ZQFZSFQI.js} +2 -2
- package/dist/components/case-studies/index.cjs +24 -24
- package/dist/components/case-studies/index.js +10 -10
- package/dist/components/chat/chat-archive-page.d.ts +3 -3
- package/dist/components/chat/chat-archive-page.d.ts.map +1 -1
- package/dist/components/chat/chat-nav-sidebar.d.ts +4 -0
- package/dist/components/chat/chat-nav-sidebar.d.ts.map +1 -1
- package/dist/components/chat/chat-panel-header.d.ts +3 -2
- package/dist/components/chat/chat-panel-header.d.ts.map +1 -1
- package/dist/components/chat/embeddable-chat.d.ts +36 -2
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +14 -6
- package/dist/components/chat/index.d.ts +4 -1
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +13 -5
- package/dist/components/chat/mingo-archive-list.d.ts +17 -0
- package/dist/components/chat/mingo-archive-list.d.ts.map +1 -0
- package/dist/components/chat/mingo-chat-header.d.ts +31 -0
- package/dist/components/chat/mingo-chat-header.d.ts.map +1 -0
- package/dist/components/chat/mingo-chat-rail.d.ts +54 -0
- package/dist/components/chat/mingo-chat-rail.d.ts.map +1 -0
- package/dist/components/chat/model-display.d.ts.map +1 -1
- package/dist/components/contact/contact-form.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +11 -11
- package/dist/components/contact/index.js +10 -10
- package/dist/components/content-loading-container.d.ts +1 -1
- package/dist/components/content-loading-container.d.ts.map +1 -1
- package/dist/components/docs/index.cjs +14 -14
- package/dist/components/docs/index.js +13 -13
- package/dist/components/docs/skeletons.d.ts.map +1 -1
- package/dist/components/embeds/index.cjs +11 -11
- package/dist/components/embeds/index.js +10 -10
- package/dist/components/faq/index.cjs +12 -12
- package/dist/components/faq/index.js +11 -11
- package/dist/components/features/figma-prototype-viewer.d.ts.map +1 -1
- package/dist/components/features/index.cjs +10 -10
- package/dist/components/features/index.js +9 -9
- package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
- package/dist/components/features/parallax-image-showcase.d.ts.map +1 -1
- package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
- package/dist/components/features/section-selector.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +119 -119
- package/dist/components/help-center-pages/index.js +39 -39
- package/dist/components/help-center-pages/trust-center-sections.d.ts +2 -2
- package/dist/components/help-center-pages/trust-center-sections.d.ts.map +1 -1
- package/dist/components/index.cjs +94 -86
- package/dist/components/index.js +32 -24
- package/dist/components/layout/article-detail-layout.d.ts +1 -1
- package/dist/components/layout/article-detail-layout.d.ts.map +1 -1
- package/dist/components/layout/page-container.d.ts.map +1 -1
- package/dist/components/layout/title-block.d.ts +2 -2
- package/dist/components/layout/title-block.d.ts.map +1 -1
- package/dist/components/loading/page-layout-skeleton.d.ts.map +1 -1
- package/dist/components/loading/unified-skeleton.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/booking-form.d.ts +4 -4
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/context-panel.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +127 -127
- package/dist/components/meeting-scheduler/index.d.ts +2 -2
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +50 -50
- package/dist/components/navigation/app-header.d.ts +4 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -1
- package/dist/components/navigation/app-layout-side-panel.d.ts +142 -0
- package/dist/components/navigation/app-layout-side-panel.d.ts.map +1 -0
- package/dist/components/navigation/app-layout.d.ts +11 -1
- package/dist/components/navigation/app-layout.d.ts.map +1 -1
- package/dist/components/navigation/header-mingo-button.d.ts +4 -1
- package/dist/components/navigation/header-mingo-button.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +13 -13
- package/dist/components/navigation/index.d.ts +1 -0
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +12 -12
- package/dist/components/navigation/navigation-sidebar.d.ts +8 -1
- package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
- package/dist/components/navigation/sliding-sidebar.d.ts.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +14 -14
- package/dist/components/onboarding-guides/index.js +13 -13
- package/dist/components/persistent-filter-controls.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +7 -7
- package/dist/components/related-content/index.js +6 -6
- package/dist/components/shared/detail-page-skeleton.d.ts.map +1 -1
- package/dist/components/tickets/help-center-create-form.d.ts +4 -4
- package/dist/components/tickets/index.cjs +15 -15
- package/dist/components/tickets/index.js +14 -14
- package/dist/components/ui/announcement-bar-view.d.ts.map +1 -1
- package/dist/components/ui/breadcrumb.d.ts.map +1 -1
- package/dist/components/ui/checkbox-block.d.ts.map +1 -1
- package/dist/components/ui/dashboard-info-card.d.ts +1 -1
- package/dist/components/ui/dashboard-info-card.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-body.d.ts +2 -2
- package/dist/components/ui/data-table/data-table-skeleton.d.ts +2 -2
- package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
- package/dist/components/ui/data-table/utils.d.ts +2 -2
- package/dist/components/ui/dialog.d.ts.map +1 -1
- package/dist/components/ui/drawer.d.ts +7 -3
- package/dist/components/ui/drawer.d.ts.map +1 -1
- package/dist/components/ui/entity-image.d.ts +2 -2
- package/dist/components/ui/file-manager/index.cjs +36 -36
- package/dist/components/ui/file-manager/index.js +3 -3
- package/dist/components/ui/filter-modal.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +18 -10
- package/dist/components/ui/index.js +17 -9
- package/dist/components/ui/info-card-row.d.ts.map +1 -1
- package/dist/components/ui/menubar.d.ts +1 -1
- package/dist/components/ui/menubar.d.ts.map +1 -1
- package/dist/components/ui/modal-v2.d.ts.map +1 -1
- package/dist/components/ui/modal.d.ts.map +1 -1
- package/dist/components/ui/no-data/no-data-actions.d.ts.map +1 -1
- package/dist/components/ui/no-data/no-data-styles.d.ts +1 -1
- package/dist/components/ui/no-data/no-data-styles.d.ts.map +1 -1
- package/dist/components/ui/page-actions.d.ts.map +1 -1
- package/dist/components/ui/panel-resize-handle.d.ts +25 -0
- package/dist/components/ui/panel-resize-handle.d.ts.map +1 -0
- package/dist/components/ui/sheet.d.ts.map +1 -1
- package/dist/components/ui/table/table-skeleton.d.ts +1 -1
- package/dist/components/ui/table/table-skeleton.d.ts.map +1 -1
- package/dist/hooks/index.cjs +23 -9
- package/dist/hooks/index.js +24 -10
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-content-breakpoint.d.ts +32 -0
- package/dist/hooks/ui/use-content-breakpoint.d.ts.map +1 -0
- package/dist/hooks/ui/use-isomorphic-layout-effect.d.ts +2 -2
- package/dist/hooks/ui/use-isomorphic-layout-effect.d.ts.map +1 -1
- package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
- package/dist/hooks/ui/use-resizable-panel-size.d.ts +46 -0
- package/dist/hooks/ui/use-resizable-panel-size.d.ts.map +1 -0
- package/dist/index.cjs +39 -17
- package/dist/index.js +41 -19
- package/dist/styles/layout-steps.d.ts +30 -0
- package/dist/styles/layout-steps.d.ts.map +1 -0
- package/dist/tailwind.config.cjs +36 -11
- package/dist/tailwind.config.js +36 -11
- package/eslint-config/README.md +9 -0
- package/eslint-config/base.js +17 -1
- package/eslint-config/layout-variants.js +57 -0
- package/package.json +3 -1
- package/src/ODS_TOKEN_RULES.md +40 -0
- package/src/components/chat/chat-archive-page.tsx +9 -7
- package/src/components/chat/chat-nav-sidebar.tsx +10 -4
- package/src/components/chat/chat-panel-header.tsx +8 -5
- package/src/components/chat/embeddable-chat.tsx +686 -494
- package/src/components/chat/index.ts +4 -0
- package/src/components/chat/mingo-archive-list.tsx +108 -0
- package/src/components/chat/mingo-chat-header.tsx +104 -0
- package/src/components/chat/mingo-chat-rail.tsx +361 -0
- package/src/components/chat/model-display.tsx +8 -1
- package/src/components/contact/contact-form.tsx +11 -9
- package/src/components/content-loading-container.tsx +4 -2
- package/src/components/docs/doc-viewer.tsx +7 -7
- package/src/components/docs/skeletons.tsx +7 -4
- package/src/components/dynamic-skeleton.tsx +1 -1
- package/src/components/embeds/claude-embed.tsx +1 -1
- package/src/components/embeds/embed-viewer-frame.tsx +4 -4
- package/src/components/embeds/figma-embed.tsx +2 -2
- package/src/components/embeds/google-sheets-viewer.tsx +1 -1
- package/src/components/embeds/pdf-viewer.tsx +3 -3
- package/src/components/empty-state.tsx +6 -6
- package/src/components/faq/faq-section.tsx +2 -2
- package/src/components/faq-accordion.tsx +1 -1
- package/src/components/features/board/board-column-header.tsx +1 -1
- package/src/components/features/command-box.tsx +3 -3
- package/src/components/features/entity-video-section.tsx +2 -2
- package/src/components/features/figma-prototype-viewer.tsx +4 -1
- package/src/components/features/media-gallery-manager.tsx +3 -1
- package/src/components/features/parallax-image-showcase.tsx +9 -6
- package/src/components/features/policy-configuration-panel.tsx +9 -7
- package/src/components/features/provider-button.tsx +1 -1
- package/src/components/features/section-selector.tsx +11 -6
- package/src/components/features/select-button.tsx +4 -4
- package/src/components/features/seo-editor-preview.tsx +2 -2
- package/src/components/features/video-ratio-tabs.tsx +3 -3
- package/src/components/features/waitlist-form.tsx +1 -1
- package/src/components/help-center-pages/trust-center-page.tsx +1 -1
- package/src/components/help-center-pages/trust-center-sections.tsx +14 -14
- package/src/components/icon-display.tsx +1 -1
- package/src/components/layout/article-detail-layout.tsx +4 -2
- package/src/components/layout/list-page-layout.tsx +2 -2
- package/src/components/layout/page-container.tsx +26 -14
- package/src/components/layout/page-heading.tsx +1 -1
- package/src/components/layout/page-layout.tsx +1 -1
- package/src/components/layout/title-block.tsx +13 -13
- package/src/components/loading/card-skeleton.tsx +11 -11
- package/src/components/loading/category-vendor-selector-skeleton.tsx +1 -1
- package/src/components/loading/content-skeleton.tsx +6 -6
- package/src/components/loading/device-card-skeleton.tsx +1 -1
- package/src/components/loading/margin-report-skeleton.tsx +7 -7
- package/src/components/loading/msp-profile-form-skeleton.tsx +3 -3
- package/src/components/loading/organization-card-skeleton.tsx +2 -2
- package/src/components/loading/page-layout-skeleton.tsx +80 -68
- package/src/components/loading/unified-skeleton.tsx +16 -6
- package/src/components/media-carousel.tsx +3 -3
- package/src/components/meeting-scheduler/booking-form.tsx +9 -8
- package/src/components/meeting-scheduler/context-panel.tsx +8 -6
- package/src/components/meeting-scheduler/directory.tsx +4 -4
- package/src/components/meeting-scheduler/index.tsx +15 -15
- package/src/components/meeting-scheduler/slot-picker.tsx +12 -12
- package/src/components/navigation/app-header.tsx +6 -0
- package/src/components/navigation/app-layout-drawer.tsx +14 -237
- package/src/components/navigation/app-layout-side-panel.tsx +594 -0
- package/src/components/navigation/app-layout.tsx +69 -4
- package/src/components/navigation/header-mingo-button.tsx +13 -3
- package/src/components/navigation/index.ts +5 -0
- package/src/components/navigation/navigation-sidebar.tsx +17 -3
- package/src/components/navigation/sliding-sidebar.tsx +2 -1
- package/src/components/pagination.tsx +3 -3
- package/src/components/persistent-filter-controls.tsx +6 -2
- package/src/components/platform/ScriptArguments.tsx +1 -1
- package/src/components/platform/ScriptInfoSection.tsx +4 -4
- package/src/components/shared/article-author-byline.tsx +1 -1
- package/src/components/shared/delivery/delivery-lists.tsx +2 -2
- package/src/components/shared/delivery/delivery-row.tsx +7 -7
- package/src/components/shared/delivery/delivery-table.tsx +3 -3
- package/src/components/shared/detail-page-skeleton.tsx +11 -5
- package/src/components/shared/dev-section/dev-card-row.tsx +6 -6
- package/src/components/shared/dev-section/dev-section-view.tsx +1 -1
- package/src/components/shared/product-release/product-release-card-skeleton.tsx +8 -8
- package/src/components/shared/product-release/product-release-card.tsx +9 -9
- package/src/components/shared/product-release/product-releases-view.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +5 -5
- package/src/components/shared/roadmap/roadmap-grid-skeleton.tsx +1 -1
- package/src/components/shared/roadmap/roadmap-grid.tsx +3 -3
- package/src/components/tickets/help-center-card.tsx +1 -1
- package/src/components/tickets/help-center-create-form.tsx +12 -12
- package/src/components/ui/announcement-bar-view.tsx +2 -10
- package/src/components/ui/benefit-card.tsx +18 -18
- package/src/components/ui/brand-association-grid.tsx +4 -4
- package/src/components/ui/breadcrumb.tsx +4 -1
- package/src/components/ui/card.tsx +1 -1
- package/src/components/ui/checkbox-block.tsx +9 -7
- package/src/components/ui/content-loader.tsx +3 -3
- package/src/components/ui/cursor-pagination.tsx +3 -3
- package/src/components/ui/dashboard-info-card.tsx +13 -9
- package/src/components/ui/data-attribution.tsx +2 -2
- package/src/components/ui/data-table/data-table-body.tsx +2 -2
- package/src/components/ui/data-table/data-table-header.tsx +15 -15
- package/src/components/ui/data-table/data-table-skeleton.tsx +6 -6
- package/src/components/ui/data-table/utils.ts +5 -5
- package/src/components/ui/dialog.tsx +4 -1
- package/src/components/ui/drawer.tsx +43 -189
- package/src/components/ui/duration-input.tsx +1 -1
- package/src/components/ui/entity-image.tsx +3 -3
- package/src/components/ui/feature-card.tsx +10 -10
- package/src/components/ui/file-manager/file-manager-action-bar.tsx +1 -1
- package/src/components/ui/file-manager/file-manager.tsx +1 -1
- package/src/components/ui/filter-modal.tsx +8 -1
- package/src/components/ui/highlight-card.tsx +7 -7
- package/src/components/ui/info-card-row.tsx +10 -6
- package/src/components/ui/modal-v2.tsx +7 -2
- package/src/components/ui/modal.tsx +3 -2
- package/src/components/ui/no-data/no-data-actions.tsx +10 -4
- package/src/components/ui/no-data/no-data-styles.ts +1 -1
- package/src/components/ui/no-data/no-data.tsx +1 -1
- package/src/components/ui/organization-card.tsx +1 -1
- package/src/components/ui/page-actions.tsx +34 -5
- package/src/components/ui/panel-resize-handle.tsx +184 -0
- package/src/components/ui/progress-bar.tsx +2 -2
- package/src/components/ui/query-report-table/query-report-table.tsx +1 -1
- package/src/components/ui/search-input.tsx +2 -2
- package/src/components/ui/service-card.tsx +2 -2
- package/src/components/ui/sheet.tsx +2 -1
- package/src/components/ui/skeleton.tsx +3 -3
- package/src/components/ui/stacked-rows-panel.tsx +4 -4
- package/src/components/ui/tab-navigation.tsx +2 -2
- package/src/components/ui/tab-selector.tsx +2 -2
- package/src/components/ui/table/table-header.tsx +3 -3
- package/src/components/ui/table/table-skeleton.tsx +3 -3
- package/src/components/ui/table/utils.ts +5 -5
- package/src/components/ui/tag-search-input.tsx +3 -3
- package/src/components/ui/tags-manager.tsx +5 -5
- package/src/components/ui/test-run.tsx +2 -2
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/components/ui/ticket-notes-section.tsx +1 -1
- package/src/components/ui/ticket-status-config-row.tsx +15 -15
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-content-breakpoint.ts +58 -0
- package/src/hooks/ui/use-media-query.ts +3 -2
- package/src/hooks/ui/use-resizable-panel-size.ts +114 -0
- package/src/styles/index.css +2 -0
- package/src/styles/layout-steps.ts +14 -0
- package/src/styles/ods-colors.css +15 -0
- package/src/styles/ods-content-area-tokens.css +27 -0
- package/src/styles/ods-content-area.css +31 -0
- package/src/styles/ods-effects.css +4 -2
- package/src/styles/ods-responsive-tokens.css +14 -4
- package/tailwind.config.ts +42 -7
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type KeyboardEvent,
|
|
5
|
+
type PointerEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
useSyncExternalStore,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { useIsomorphicLayoutEffect } from '../../hooks/ui/use-isomorphic-layout-effect';
|
|
14
|
+
import { breakpoints, useMediaQuery } from '../../hooks/ui/use-media-query';
|
|
15
|
+
import { cn } from '../../utils/cn';
|
|
16
|
+
import { Menu01Icon } from '../icons-v2-generated';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* - `docked`: a column beside the content, resizable.
|
|
20
|
+
* - `full`: covers the content area (not the navigation). Reached by dragging
|
|
21
|
+
* past the content's minimum width, or from the header toggle when there is
|
|
22
|
+
* no room to dock.
|
|
23
|
+
* - `hidden`: no room to dock (or a phone) and not opened from the header.
|
|
24
|
+
* - `overlay`: a phone, opened from the header; covers the content area.
|
|
25
|
+
*/
|
|
26
|
+
export type AppLayoutSidePanelMode = 'docked' | 'full' | 'hidden' | 'overlay';
|
|
27
|
+
|
|
28
|
+
export interface AppLayoutSidePanelRenderState {
|
|
29
|
+
/** Width the panel body is drawn at, in px. */
|
|
30
|
+
width: number;
|
|
31
|
+
mode: AppLayoutSidePanelMode;
|
|
32
|
+
/**
|
|
33
|
+
* The panel was opened from the header (no room to dock, or a phone) and can
|
|
34
|
+
* be put away again: offer a close control. A docked panel cannot close.
|
|
35
|
+
*/
|
|
36
|
+
canClose: boolean;
|
|
37
|
+
/** Put the panel away again; a no-op while it is docked. */
|
|
38
|
+
close: () => void;
|
|
39
|
+
/**
|
|
40
|
+
* Where `collapse` takes the panel, one step back at a time: from the whole
|
|
41
|
+
* area to the column it was in before (`column`), from the column to its
|
|
42
|
+
* minimum (`minimum`, e.g. a list alone). Null when there is no step back.
|
|
43
|
+
*/
|
|
44
|
+
collapsesTo: 'column' | 'minimum' | null;
|
|
45
|
+
/** Step the panel back (see `collapsesTo`), animated. */
|
|
46
|
+
collapse: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AppLayoutSidePanelConfig {
|
|
50
|
+
/** Panel body. Told its width so it can choose what fits. */
|
|
51
|
+
children: (state: AppLayoutSidePanelRenderState) => ReactNode;
|
|
52
|
+
/** Narrowest docked width, and the width it starts at: the narrowest its content draws. */
|
|
53
|
+
minWidth: number;
|
|
54
|
+
/** Narrowest the content may get before the panel takes over. Default 400. */
|
|
55
|
+
minContentWidth?: number;
|
|
56
|
+
/** localStorage key for the chosen width. Without it the width lasts the session. */
|
|
57
|
+
storageKey?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Pages that need the whole content width set this: the panel starts at its
|
|
60
|
+
* minimum there. The user can still widen it; the width they chose
|
|
61
|
+
* elsewhere is kept for the other pages.
|
|
62
|
+
*/
|
|
63
|
+
collapsed?: boolean;
|
|
64
|
+
/** Accessible name of the panel region. Default "Side panel". */
|
|
65
|
+
label?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Opened from the header while it cannot dock (or on a phone). Pass it to
|
|
68
|
+
* open the panel from elsewhere (a deep link, a "send to chat" action);
|
|
69
|
+
* omit to let the header own it. Docked, it has no effect.
|
|
70
|
+
*/
|
|
71
|
+
open?: boolean;
|
|
72
|
+
/** Called whenever the header-open state should change. */
|
|
73
|
+
onOpenChange?: (open: boolean) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Inset of the docked card from the window edge and the header. */
|
|
77
|
+
export const SIDE_PANEL_INSET = 16;
|
|
78
|
+
const MOBILE_QUERY = `not all and ${breakpoints.md}`;
|
|
79
|
+
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
|
80
|
+
/** Length of the docked <-> full morph; keep in step with `duration-300` below. */
|
|
81
|
+
const MORPH_MS = 300;
|
|
82
|
+
const STORAGE_EVENT = 'of:side-panel-size';
|
|
83
|
+
|
|
84
|
+
export interface SidePanelSize {
|
|
85
|
+
/** Docked width the user chose. */
|
|
86
|
+
width: number;
|
|
87
|
+
/** Dragged past the content minimum: the panel takes the whole area. */
|
|
88
|
+
expanded: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SidePanelLayoutInput {
|
|
92
|
+
/** Width of the row the content and the panel share; 0 before it is measured. */
|
|
93
|
+
rowWidth: number;
|
|
94
|
+
isMobile: boolean;
|
|
95
|
+
/** Opened from the header (only meaningful while it cannot dock). */
|
|
96
|
+
isOpen: boolean;
|
|
97
|
+
size: SidePanelSize;
|
|
98
|
+
minWidth: number;
|
|
99
|
+
minContentWidth: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SidePanelLayout {
|
|
103
|
+
mode: AppLayoutSidePanelMode;
|
|
104
|
+
/** Width the panel body is drawn at. */
|
|
105
|
+
width: number;
|
|
106
|
+
/** The panel can sit beside the content. */
|
|
107
|
+
canDock: boolean;
|
|
108
|
+
/** Widest docked width; one pixel more takes the whole area. */
|
|
109
|
+
maxDockedWidth: number;
|
|
110
|
+
/** Width the panel docks at, kept while it is full so it can return there. */
|
|
111
|
+
dockedWidth: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where the panel goes for a given room. Pure, so the rules read in one place:
|
|
116
|
+
* a phone gets the overlay; a row with room for both minimums docks (or is
|
|
117
|
+
* full when dragged past); otherwise it hides until opened from the header.
|
|
118
|
+
* An unmeasured row docks, so the first paint matches the common case.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveSidePanelLayout({
|
|
121
|
+
rowWidth,
|
|
122
|
+
isMobile,
|
|
123
|
+
isOpen,
|
|
124
|
+
size,
|
|
125
|
+
minWidth,
|
|
126
|
+
minContentWidth,
|
|
127
|
+
}: SidePanelLayoutInput): SidePanelLayout {
|
|
128
|
+
const measured = rowWidth > 0;
|
|
129
|
+
const room = rowWidth - minContentWidth - SIDE_PANEL_INSET;
|
|
130
|
+
const canDock = !isMobile && (!measured || room >= minWidth);
|
|
131
|
+
const maxDockedWidth = measured ? Math.max(minWidth, room) : Number.POSITIVE_INFINITY;
|
|
132
|
+
|
|
133
|
+
let mode: AppLayoutSidePanelMode;
|
|
134
|
+
if (isMobile) mode = isOpen ? 'overlay' : 'hidden';
|
|
135
|
+
else if (!canDock) mode = isOpen ? 'full' : 'hidden';
|
|
136
|
+
else mode = size.expanded ? 'full' : 'docked';
|
|
137
|
+
|
|
138
|
+
const dockedWidth = Math.min(Math.max(size.width, minWidth), maxDockedWidth);
|
|
139
|
+
let width: number;
|
|
140
|
+
if (mode === 'docked') width = dockedWidth;
|
|
141
|
+
else if (mode === 'overlay') width = rowWidth;
|
|
142
|
+
else width = Math.max(0, rowWidth - 2 * SIDE_PANEL_INSET);
|
|
143
|
+
|
|
144
|
+
return { mode, width, canDock, maxDockedWidth, dockedWidth };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseSize(raw: string | null): SidePanelSize | null {
|
|
148
|
+
if (!raw) return null;
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(raw) as Partial<SidePanelSize> | null;
|
|
151
|
+
if (!parsed || typeof parsed.width !== 'number' || !Number.isFinite(parsed.width)) return null;
|
|
152
|
+
return { width: parsed.width, expanded: parsed.expanded === true };
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function subscribeToStoredSize(onChange: () => void) {
|
|
159
|
+
window.addEventListener('storage', onChange);
|
|
160
|
+
window.addEventListener(STORAGE_EVENT, onChange);
|
|
161
|
+
return () => {
|
|
162
|
+
window.removeEventListener('storage', onChange);
|
|
163
|
+
window.removeEventListener(STORAGE_EVENT, onChange);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The persisted size, read through `useSyncExternalStore` so the server and
|
|
169
|
+
* the hydrating client both render the default and the stored width arrives
|
|
170
|
+
* on the render after: a size in the first render would be a hydration
|
|
171
|
+
* mismatch on the panel's inline width.
|
|
172
|
+
*/
|
|
173
|
+
function useStoredSize(storageKey: string | undefined, fallback: SidePanelSize) {
|
|
174
|
+
const raw = useSyncExternalStore(
|
|
175
|
+
subscribeToStoredSize,
|
|
176
|
+
() => {
|
|
177
|
+
if (!storageKey) return null;
|
|
178
|
+
try {
|
|
179
|
+
return window.localStorage.getItem(storageKey);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
() => null,
|
|
185
|
+
);
|
|
186
|
+
const [sessionSize, setSessionSize] = useState<SidePanelSize | null>(null);
|
|
187
|
+
const size = (storageKey ? parseSize(raw) : sessionSize) ?? fallback;
|
|
188
|
+
|
|
189
|
+
const setSize = (next: SidePanelSize) => {
|
|
190
|
+
if (!storageKey) {
|
|
191
|
+
setSessionSize(next);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
window.localStorage.setItem(storageKey, JSON.stringify(next));
|
|
196
|
+
} catch {
|
|
197
|
+
// Storage refused (quota, privacy mode): keep it for the session.
|
|
198
|
+
setSessionSize(next);
|
|
199
|
+
}
|
|
200
|
+
window.dispatchEvent(new Event(STORAGE_EVENT));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return [size, setSize] as const;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The panel is growing into the whole area (`expand`), shrinking back into the
|
|
208
|
+
* column (`collapse`) or narrowing within it (`narrow`). While it grows the
|
|
209
|
+
* content stays where it is under it, and is hidden only once the panel covers
|
|
210
|
+
* it; while it shrinks the content is already laid out at its new width.
|
|
211
|
+
*/
|
|
212
|
+
export type SidePanelMorph = 'expand' | 'collapse' | 'narrow';
|
|
213
|
+
|
|
214
|
+
export interface AppLayoutSidePanelState extends SidePanelLayout {
|
|
215
|
+
minWidth: number;
|
|
216
|
+
/** Opened from the header while it cannot dock. */
|
|
217
|
+
isOpen: boolean;
|
|
218
|
+
morph: SidePanelMorph | null;
|
|
219
|
+
/**
|
|
220
|
+
* Dragged wider than any column can be and not yet let go: the panel follows
|
|
221
|
+
* the pointer over the content, laid out at its narrowest beneath it.
|
|
222
|
+
*/
|
|
223
|
+
peekWidth: number | null;
|
|
224
|
+
/** The panel covers the content area: the content can be hidden. */
|
|
225
|
+
coversContent: boolean;
|
|
226
|
+
/** The panel is drawn over part of the content (a morph or a peek). */
|
|
227
|
+
overlapsContent: boolean;
|
|
228
|
+
endMorph: () => void;
|
|
229
|
+
collapsesTo: AppLayoutSidePanelRenderState['collapsesTo'];
|
|
230
|
+
collapse: () => void;
|
|
231
|
+
toggle: () => void;
|
|
232
|
+
close: () => void;
|
|
233
|
+
resize: (next: number) => void;
|
|
234
|
+
/**
|
|
235
|
+
* A pointer drag to `next`. Wider than any column can be, the panel follows
|
|
236
|
+
* the pointer over the content instead of jumping to the whole area.
|
|
237
|
+
*/
|
|
238
|
+
drag: (next: number) => void;
|
|
239
|
+
/**
|
|
240
|
+
* The drag ended at `next`: a panel left between the widest column and the
|
|
241
|
+
* whole area settles on the nearer of the two, animated.
|
|
242
|
+
*/
|
|
243
|
+
release: (next: number) => void;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Owns the side panel's geometry. Lives in `AppLayout`, not the panel, because
|
|
248
|
+
* the header's toggle reads it too. Returns null without a config.
|
|
249
|
+
*/
|
|
250
|
+
export function useAppLayoutSidePanel(
|
|
251
|
+
config: AppLayoutSidePanelConfig | undefined,
|
|
252
|
+
row: HTMLElement | null,
|
|
253
|
+
): AppLayoutSidePanelState | null {
|
|
254
|
+
const enabled = config !== undefined;
|
|
255
|
+
const minWidth = config?.minWidth ?? 0;
|
|
256
|
+
const minContentWidth = config?.minContentWidth ?? 400;
|
|
257
|
+
const collapsed = config?.collapsed ?? false;
|
|
258
|
+
const minimum: SidePanelSize = { width: minWidth, expanded: false };
|
|
259
|
+
|
|
260
|
+
const [rowWidth, setRowWidth] = useState(0);
|
|
261
|
+
useIsomorphicLayoutEffect(() => {
|
|
262
|
+
if (!enabled || !row) return undefined;
|
|
263
|
+
const update = () => setRowWidth(row.clientWidth);
|
|
264
|
+
update();
|
|
265
|
+
const observer = new ResizeObserver(update);
|
|
266
|
+
observer.observe(row);
|
|
267
|
+
return () => observer.disconnect();
|
|
268
|
+
}, [enabled, row]);
|
|
269
|
+
|
|
270
|
+
const isMobile = useMediaQuery(MOBILE_QUERY) === true;
|
|
271
|
+
const reduceMotion = useMediaQuery(REDUCED_MOTION_QUERY) === true;
|
|
272
|
+
const [morph, setMorph] = useState<SidePanelMorph | null>(null);
|
|
273
|
+
const [liveWidth, setLiveWidth] = useState<number | null>(null);
|
|
274
|
+
const [storedSize, setStoredSize] = useStoredSize(config?.storageKey, minimum);
|
|
275
|
+
const [ownIsOpen, setOwnIsOpen] = useState(false);
|
|
276
|
+
const isOpen = config?.open ?? ownIsOpen;
|
|
277
|
+
const onOpenChange = config?.onOpenChange;
|
|
278
|
+
const setIsOpen = (next: boolean) => {
|
|
279
|
+
if (config?.open === undefined) setOwnIsOpen(next);
|
|
280
|
+
onOpenChange?.(next);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// A page that needs the full width starts the panel at its minimum without
|
|
284
|
+
// touching the width the user chose elsewhere: a drag there lasts until the
|
|
285
|
+
// page lets go.
|
|
286
|
+
const [collapsedSize, setCollapsedSize] = useState<SidePanelSize | null>(null);
|
|
287
|
+
const [prevCollapsed, setPrevCollapsed] = useState(collapsed);
|
|
288
|
+
if (collapsed !== prevCollapsed) {
|
|
289
|
+
setPrevCollapsed(collapsed);
|
|
290
|
+
setCollapsedSize(null);
|
|
291
|
+
}
|
|
292
|
+
const size = collapsed ? (collapsedSize ?? minimum) : storedSize;
|
|
293
|
+
const setSize = collapsed ? setCollapsedSize : setStoredSize;
|
|
294
|
+
|
|
295
|
+
const layout = resolveSidePanelLayout({ rowWidth, isMobile, isOpen, size, minWidth, minContentWidth });
|
|
296
|
+
|
|
297
|
+
// Room came back (the navigation collapsed, the window widened): return to
|
|
298
|
+
// the column. Dropping the header-open flag means losing the room again
|
|
299
|
+
// hides the panel rather than throwing it over the content.
|
|
300
|
+
const [prevCanDock, setPrevCanDock] = useState(layout.canDock);
|
|
301
|
+
if (layout.canDock !== prevCanDock) {
|
|
302
|
+
setPrevCanDock(layout.canDock);
|
|
303
|
+
setOwnIsOpen(false);
|
|
304
|
+
}
|
|
305
|
+
// A controlled host hears about the return after the commit (a parent's
|
|
306
|
+
// setState cannot run in this render), and only on the transition: a host
|
|
307
|
+
// may open the panel while it is docked (a deep link), which is not undone.
|
|
308
|
+
const prevCanDockRef = useRef(layout.canDock);
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
const couldDock = prevCanDockRef.current;
|
|
311
|
+
prevCanDockRef.current = layout.canDock;
|
|
312
|
+
if (!couldDock && layout.canDock && config?.open) onOpenChange?.(false);
|
|
313
|
+
}, [layout.canDock, config?.open, onOpenChange]);
|
|
314
|
+
|
|
315
|
+
if (!enabled) return null;
|
|
316
|
+
|
|
317
|
+
const animate = (next: SidePanelMorph) => {
|
|
318
|
+
if (!reduceMotion) setMorph(next);
|
|
319
|
+
};
|
|
320
|
+
const fullWidth = Math.max(0, rowWidth - 2 * SIDE_PANEL_INSET);
|
|
321
|
+
// Only while there is a column to peek out of: losing the room (or a phone)
|
|
322
|
+
// drops it.
|
|
323
|
+
const peekWidth = layout.canDock ? liveWidth : null;
|
|
324
|
+
const dock = (next: number) => setSize({ width: Math.max(minWidth, Math.round(next)), expanded: false });
|
|
325
|
+
|
|
326
|
+
const resize = (next: number) => {
|
|
327
|
+
// Past the content's minimum the panel takes the whole area; dragging
|
|
328
|
+
// back under it docks again at the dragged width.
|
|
329
|
+
const expand = next > layout.maxDockedWidth;
|
|
330
|
+
// Crossing between the column and the whole area is animated, not a
|
|
331
|
+
// jump. Only for a resize: a page load or room coming back just lands.
|
|
332
|
+
if (layout.canDock) {
|
|
333
|
+
if (expand && layout.mode === 'docked') animate('expand');
|
|
334
|
+
else if (!expand && layout.mode === 'full') animate('collapse');
|
|
335
|
+
}
|
|
336
|
+
if (expand) setSize({ width: size.width, expanded: true });
|
|
337
|
+
else dock(next);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
let collapsesTo: AppLayoutSidePanelRenderState['collapsesTo'] = null;
|
|
341
|
+
if (layout.mode === 'full' && layout.canDock) collapsesTo = layout.dockedWidth > minWidth ? 'column' : 'minimum';
|
|
342
|
+
else if (layout.mode === 'docked' && layout.width > minWidth) collapsesTo = 'minimum';
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
...layout,
|
|
346
|
+
minWidth,
|
|
347
|
+
isOpen,
|
|
348
|
+
morph,
|
|
349
|
+
peekWidth,
|
|
350
|
+
coversContent: layout.mode === 'full' && morph !== 'expand' && peekWidth === null,
|
|
351
|
+
overlapsContent: morph !== null || peekWidth !== null,
|
|
352
|
+
endMorph: () => setMorph(null),
|
|
353
|
+
collapsesTo,
|
|
354
|
+
collapse: () => {
|
|
355
|
+
if (collapsesTo === null) return;
|
|
356
|
+
if (layout.mode === 'full') {
|
|
357
|
+
// Back to the column it left, at the width it had there.
|
|
358
|
+
animate('collapse');
|
|
359
|
+
setSize({ width: size.width, expanded: false });
|
|
360
|
+
} else {
|
|
361
|
+
animate('narrow');
|
|
362
|
+
setSize({ width: minWidth, expanded: false });
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
toggle: () => setIsOpen(!isOpen),
|
|
366
|
+
close: () => setIsOpen(false),
|
|
367
|
+
resize,
|
|
368
|
+
drag: next => {
|
|
369
|
+
if (next > layout.maxDockedWidth) {
|
|
370
|
+
// Wider than a column can be: follow the pointer over the content, which
|
|
371
|
+
// is laid out at its narrowest (the widest column) underneath.
|
|
372
|
+
if (size.expanded || size.width !== layout.maxDockedWidth) dock(layout.maxDockedWidth);
|
|
373
|
+
setLiveWidth(Math.min(Math.round(next), fullWidth));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// Within the column: an ordinary resize, picking up where a peek was.
|
|
377
|
+
setLiveWidth(null);
|
|
378
|
+
dock(next);
|
|
379
|
+
},
|
|
380
|
+
release: next => {
|
|
381
|
+
setLiveWidth(null);
|
|
382
|
+
// Let go within the column: the drag already put it there.
|
|
383
|
+
if (next <= layout.maxDockedWidth) return;
|
|
384
|
+
// Settle on the nearer of the two, from where the pointer left it.
|
|
385
|
+
if (next > (layout.maxDockedWidth + fullWidth) / 2) {
|
|
386
|
+
// Dragged all the way: nothing left to animate.
|
|
387
|
+
if (next < fullWidth) animate('expand');
|
|
388
|
+
setSize({ width: layout.maxDockedWidth, expanded: true });
|
|
389
|
+
} else {
|
|
390
|
+
animate('narrow');
|
|
391
|
+
dock(layout.maxDockedWidth);
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface SidePanelResizeHandleProps {
|
|
398
|
+
state: AppLayoutSidePanelState;
|
|
399
|
+
label: string;
|
|
400
|
+
controls: string;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function SidePanelResizeHandle({ state, label, controls }: SidePanelResizeHandleProps) {
|
|
404
|
+
const { width, minWidth, maxDockedWidth, mode, peekWidth, resize, drag, release } = state;
|
|
405
|
+
const startRef = useRef<{ x: number; width: number } | null>(null);
|
|
406
|
+
// Pointer moves outpace frames; resize once per frame with the latest one.
|
|
407
|
+
const frameRef = useRef<number | null>(null);
|
|
408
|
+
const pendingRef = useRef<number | null>(null);
|
|
409
|
+
// Unmeasured row (first paint): no upper bound to announce or jump to yet.
|
|
410
|
+
const measured = Number.isFinite(maxDockedWidth);
|
|
411
|
+
// The width the keyboard steps from: in `full`, one past the docked maximum.
|
|
412
|
+
const current = peekWidth ?? (mode === 'full' && measured ? maxDockedWidth + 1 : width);
|
|
413
|
+
|
|
414
|
+
useEffect(
|
|
415
|
+
() => () => {
|
|
416
|
+
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
|
417
|
+
},
|
|
418
|
+
[],
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const scheduleDrag = (next: number) => {
|
|
422
|
+
pendingRef.current = next;
|
|
423
|
+
if (frameRef.current !== null) return;
|
|
424
|
+
frameRef.current = requestAnimationFrame(() => {
|
|
425
|
+
frameRef.current = null;
|
|
426
|
+
if (pendingRef.current !== null) drag(pendingRef.current);
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
431
|
+
if (event.button !== 0 && event.pointerType === 'mouse') return;
|
|
432
|
+
event.preventDefault();
|
|
433
|
+
// From the whole area the drag starts at the panel's real width, so its
|
|
434
|
+
// edge stays under the pointer instead of jumping to the widest column.
|
|
435
|
+
startRef.current = { x: event.clientX, width: mode === 'full' && measured ? width : current };
|
|
436
|
+
pendingRef.current = null;
|
|
437
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
438
|
+
document.body.style.cursor = 'col-resize';
|
|
439
|
+
document.body.style.userSelect = 'none';
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
443
|
+
const start = startRef.current;
|
|
444
|
+
if (!start) return;
|
|
445
|
+
// The handle sits on the panel's left edge: dragging left widens it.
|
|
446
|
+
scheduleDrag(start.width - (event.clientX - start.x));
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
|
|
450
|
+
const start = startRef.current;
|
|
451
|
+
if (!start) return;
|
|
452
|
+
startRef.current = null;
|
|
453
|
+
// Land the last move before deciding where the panel settles.
|
|
454
|
+
if (frameRef.current !== null) {
|
|
455
|
+
cancelAnimationFrame(frameRef.current);
|
|
456
|
+
frameRef.current = null;
|
|
457
|
+
if (pendingRef.current !== null) drag(pendingRef.current);
|
|
458
|
+
}
|
|
459
|
+
if (pendingRef.current !== null) release(pendingRef.current);
|
|
460
|
+
pendingRef.current = null;
|
|
461
|
+
try {
|
|
462
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
463
|
+
} catch {
|
|
464
|
+
// already released
|
|
465
|
+
}
|
|
466
|
+
document.body.style.cursor = '';
|
|
467
|
+
document.body.style.userSelect = '';
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
471
|
+
const step = event.shiftKey ? 40 : 16;
|
|
472
|
+
let next: number | null = null;
|
|
473
|
+
if (event.key === 'ArrowLeft') next = current + step;
|
|
474
|
+
else if (event.key === 'ArrowRight') next = Math.min(current, maxDockedWidth) - step;
|
|
475
|
+
else if (event.key === 'Home') next = minWidth;
|
|
476
|
+
else if (event.key === 'End' && measured) next = maxDockedWidth + 1;
|
|
477
|
+
if (next === null) return;
|
|
478
|
+
event.preventDefault();
|
|
479
|
+
resize(next);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<div
|
|
484
|
+
role="separator"
|
|
485
|
+
tabIndex={0}
|
|
486
|
+
aria-controls={controls}
|
|
487
|
+
aria-orientation="vertical"
|
|
488
|
+
aria-label={label}
|
|
489
|
+
aria-valuenow={Math.round(current)}
|
|
490
|
+
aria-valuemin={minWidth}
|
|
491
|
+
aria-valuemax={measured ? Math.round(maxDockedWidth + 1) : undefined}
|
|
492
|
+
aria-valuetext={mode === 'full' ? 'Full width' : `${Math.round(width)} pixels`}
|
|
493
|
+
onPointerDown={handlePointerDown}
|
|
494
|
+
onPointerMove={handlePointerMove}
|
|
495
|
+
onPointerUp={endDrag}
|
|
496
|
+
onPointerCancel={endDrag}
|
|
497
|
+
onKeyDown={handleKeyDown}
|
|
498
|
+
className="group absolute inset-y-0 left-0 z-10 flex w-4 -translate-x-1/2 cursor-col-resize touch-none select-none items-center justify-center outline-none"
|
|
499
|
+
>
|
|
500
|
+
<span
|
|
501
|
+
aria-hidden
|
|
502
|
+
className="flex h-8 w-4 items-center justify-center rounded-sm border border-ods-border bg-ods-card text-ods-text-secondary transition-colors group-hover:border-ods-border-hover group-focus-visible:border-ods-accent group-focus-visible:text-ods-accent"
|
|
503
|
+
>
|
|
504
|
+
<Menu01Icon size={12} />
|
|
505
|
+
</span>
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
interface AppLayoutSidePanelProps {
|
|
511
|
+
config: AppLayoutSidePanelConfig;
|
|
512
|
+
state: AppLayoutSidePanelState;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function AppLayoutSidePanel({ config, state }: AppLayoutSidePanelProps) {
|
|
516
|
+
const panelId = useId();
|
|
517
|
+
const { mode, width, dockedWidth, canDock, isOpen, close, morph, endMorph, peekWidth } = state;
|
|
518
|
+
|
|
519
|
+
// A safety net only: `transitionend` does not fire when the width ends up
|
|
520
|
+
// unchanged (a resize that lands where it started). Generous, so a busy or
|
|
521
|
+
// throttled page still finishes the animation before the content is hidden.
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
if (!morph) return undefined;
|
|
524
|
+
const timer = window.setTimeout(endMorph, MORPH_MS * 3);
|
|
525
|
+
return () => window.clearTimeout(timer);
|
|
526
|
+
}, [morph, endMorph]);
|
|
527
|
+
|
|
528
|
+
if (mode === 'hidden') return null;
|
|
529
|
+
const label = config.label ?? 'Side panel';
|
|
530
|
+
// The column's footprint: docked, and while growing over the content, which
|
|
531
|
+
// stays laid out at its width until the panel covers it.
|
|
532
|
+
const inColumn = mode === 'docked' || morph === 'expand' || peekWidth !== null;
|
|
533
|
+
|
|
534
|
+
// Opened from the header: Escape puts it away again.
|
|
535
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
536
|
+
if (event.key !== 'Escape' || !isOpen || event.defaultPrevented) return;
|
|
537
|
+
event.preventDefault();
|
|
538
|
+
close();
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<aside
|
|
543
|
+
id={panelId}
|
|
544
|
+
aria-label={label}
|
|
545
|
+
onKeyDown={handleKeyDown}
|
|
546
|
+
className={cn(
|
|
547
|
+
'relative min-w-0',
|
|
548
|
+
// Docked is decided before the viewport is known (server render, first
|
|
549
|
+
// paint): keep it off phones until JS says otherwise.
|
|
550
|
+
mode !== 'overlay' && inColumn && 'hidden shrink-0 md:block',
|
|
551
|
+
mode !== 'overlay' && !inColumn && 'flex-1',
|
|
552
|
+
// z-[99]: above the page's own layers (sticky headers, the z-50 bottom
|
|
553
|
+
// action bar), BELOW the mobile burger menu (backdrop z-[100], panel
|
|
554
|
+
// z-[101]), so the menu opens over a panel covering the content.
|
|
555
|
+
mode === 'overlay' && 'absolute inset-0 z-[99] bg-ods-bg',
|
|
556
|
+
)}
|
|
557
|
+
style={mode !== 'overlay' && inColumn ? { width: dockedWidth + SIDE_PANEL_INSET } : undefined}
|
|
558
|
+
>
|
|
559
|
+
{/* Pinned to the right edge with an explicit width, so the move between
|
|
560
|
+
the column and the whole area is one animated width. */}
|
|
561
|
+
<div
|
|
562
|
+
onTransitionEnd={event => {
|
|
563
|
+
if (event.target === event.currentTarget) endMorph();
|
|
564
|
+
}}
|
|
565
|
+
onTransitionCancel={event => {
|
|
566
|
+
if (event.target === event.currentTarget) endMorph();
|
|
567
|
+
}}
|
|
568
|
+
className={cn(
|
|
569
|
+
'absolute flex',
|
|
570
|
+
mode === 'overlay' ? 'inset-0' : 'inset-y-[var(--spacing-system-mf)] right-[var(--spacing-system-mf)] z-[1]',
|
|
571
|
+
morph && 'transition-[width] duration-300 ease-in-out motion-reduce:transition-none',
|
|
572
|
+
)}
|
|
573
|
+
style={mode === 'overlay' ? undefined : { width: peekWidth ?? width }}
|
|
574
|
+
>
|
|
575
|
+
{canDock && <SidePanelResizeHandle state={state} label={`Resize ${label}`} controls={panelId} />}
|
|
576
|
+
<div
|
|
577
|
+
className={cn(
|
|
578
|
+
'flex min-h-0 min-w-0 flex-1 overflow-hidden bg-ods-bg',
|
|
579
|
+
mode !== 'overlay' && 'rounded-md border border-ods-border',
|
|
580
|
+
)}
|
|
581
|
+
>
|
|
582
|
+
{config.children({
|
|
583
|
+
width: peekWidth ?? width,
|
|
584
|
+
mode,
|
|
585
|
+
canClose: mode === 'overlay' || (mode === 'full' && !canDock),
|
|
586
|
+
close,
|
|
587
|
+
collapsesTo: state.collapsesTo,
|
|
588
|
+
collapse: state.collapse,
|
|
589
|
+
})}
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
</aside>
|
|
593
|
+
);
|
|
594
|
+
}
|