@justanarthur/modo-ui 0.5.0 → 0.6.0-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +608 -0
- package/README.md +61 -39
- package/blocks/sidebar-app/examples.mdx +33 -0
- package/blocks/sidebar-app/index.tsx +218 -0
- package/blocks/sidebar-app/inset-topbar.tsx +25 -0
- package/blocks/sidebar-app/menu-grid.ts +26 -0
- package/blocks/sidebar-app/nav-data.ts +36 -0
- package/blocks/sidebar-app/search-field.tsx +48 -0
- package/blocks/sidebar-app/user-footer.tsx +67 -0
- package/blocks/sidebar-app/workspace-header.tsx +129 -0
- package/components/AI/input-message/examples.mdx +124 -0
- package/components/AI/input-message/file-thumbnail.tsx +173 -0
- package/components/AI/input-message/index.tsx +1383 -0
- package/components/AI/thinking-indicator/examples.mdx +27 -0
- package/components/AI/thinking-indicator/index.tsx +210 -0
- package/components/accordion/examples.mdx +148 -0
- package/components/accordion/index.tsx +1034 -0
- package/components/avatar/examples.mdx +95 -0
- package/components/avatar/index.tsx +129 -0
- package/components/badge/change-demo.tsx +29 -0
- package/components/badge/examples.mdx +120 -0
- package/components/badge/index.tsx +225 -0
- package/components/badge/variants.ts +73 -0
- package/components/button/examples.mdx +164 -0
- package/components/button/index.tsx +416 -0
- package/components/button/loading-demo.tsx +25 -0
- package/components/button/variants.ts +166 -0
- package/components/card/examples.mdx +212 -0
- package/components/card/index.tsx +1037 -0
- package/components/copy/examples.mdx +87 -0
- package/components/copy/index.tsx +482 -0
- package/components/inputs/checkbox/examples.mdx +83 -0
- package/components/inputs/checkbox/index.tsx +611 -0
- package/components/inputs/checkbox/select-all-demo.tsx +48 -0
- package/components/inputs/color-picker/examples.mdx +72 -0
- package/components/inputs/color-picker/index.tsx +1955 -0
- package/components/inputs/color-picker/variants.ts +401 -0
- package/components/inputs/combobox/examples.mdx +273 -0
- package/components/inputs/combobox/index.tsx +1436 -0
- package/components/inputs/combobox/remote-demo.tsx +101 -0
- package/components/inputs/date-time/calendar/examples.mdx +34 -0
- package/components/inputs/date-time/calendar/index.tsx +351 -0
- package/components/inputs/date-time/calendar/use-month-window.ts +163 -0
- package/components/inputs/date-time/date-picker/examples.mdx +166 -0
- package/components/inputs/date-time/date-picker/index.tsx +385 -0
- package/components/inputs/date-time/date-picker/masked-date-input.tsx +336 -0
- package/components/inputs/date-time/date-range-picker/examples.mdx +29 -0
- package/components/inputs/date-time/date-range-picker/index.tsx +217 -0
- package/components/inputs/date-time/duration-picker/examples.mdx +16 -0
- package/components/inputs/date-time/duration-picker/index.tsx +189 -0
- package/components/inputs/date-time/month-picker/examples.mdx +16 -0
- package/components/inputs/date-time/month-picker/index.tsx +190 -0
- package/components/inputs/date-time/picker-shell.tsx +395 -0
- package/components/inputs/date-time/time-columns.tsx +91 -0
- package/components/inputs/date-time/time-picker/examples.mdx +26 -0
- package/components/inputs/date-time/time-picker/index.tsx +166 -0
- package/components/inputs/field/examples.mdx +113 -0
- package/components/inputs/field/index.tsx +267 -0
- package/components/inputs/file-dropzone/examples.mdx +73 -0
- package/components/inputs/file-dropzone/file-row.tsx +201 -0
- package/components/inputs/file-dropzone/index.tsx +296 -0
- package/components/inputs/file-dropzone/upload-demo.tsx +67 -0
- package/components/inputs/file-dropzone/variants.ts +94 -0
- package/components/inputs/input/examples.mdx +135 -0
- package/components/inputs/input/index.tsx +494 -0
- package/components/inputs/input/password-demo.tsx +30 -0
- package/components/inputs/label/examples.mdx +67 -0
- package/components/inputs/label/index.tsx +85 -0
- package/components/inputs/number-input/examples.mdx +56 -0
- package/components/inputs/number-input/index.tsx +214 -0
- package/components/inputs/radio/examples.mdx +77 -0
- package/components/inputs/radio/index.tsx +636 -0
- package/components/inputs/search/examples.mdx +74 -0
- package/components/inputs/search/index.tsx +333 -0
- package/components/inputs/select/examples.mdx +218 -0
- package/components/inputs/select/index.tsx +862 -0
- package/components/inputs/select/variants.ts +32 -0
- package/components/inputs/slider/examples.mdx +127 -0
- package/components/inputs/slider/index.tsx +1725 -0
- package/components/inputs/switch/examples.mdx +94 -0
- package/components/inputs/switch/index.tsx +504 -0
- package/components/inputs/textarea/examples.mdx +88 -0
- package/components/inputs/textarea/index.tsx +161 -0
- package/components/navigation/navigation-menu/examples.mdx +109 -0
- package/components/navigation/navigation-menu/index.tsx +613 -0
- package/components/navigation/pagination/examples.mdx +64 -0
- package/components/navigation/pagination/index.tsx +395 -0
- package/components/navigation/sidebar/examples.mdx +319 -0
- package/components/navigation/sidebar/index.tsx +453 -0
- package/components/navigation/sidebar/sidebar-core.tsx +1408 -0
- package/components/navigation/sidebar/sidebar-menu.tsx +1495 -0
- package/components/navigation/sidebar/sidebar.css +74 -0
- package/components/navigation/sidebar/variants.ts +55 -0
- package/components/navigation/tabs/examples.mdx +89 -0
- package/components/navigation/tabs/index.tsx +494 -0
- package/components/navigation/tabs-subtle/examples.mdx +49 -0
- package/components/navigation/tabs-subtle/index.tsx +512 -0
- package/components/overlays/alert-dialog/examples.mdx +123 -0
- package/components/overlays/alert-dialog/index.tsx +258 -0
- package/components/overlays/alert-dialog/pending-demo.tsx +36 -0
- package/components/overlays/command/examples.mdx +113 -0
- package/components/overlays/command/index.tsx +416 -0
- package/components/overlays/context-menu/examples.mdx +56 -0
- package/components/overlays/context-menu/index.tsx +279 -0
- package/components/overlays/dialog/examples.mdx +144 -0
- package/components/overlays/dialog/index.tsx +366 -0
- package/components/overlays/dropdown/dropdown-search.tsx +395 -0
- package/components/overlays/dropdown/examples.mdx +294 -0
- package/components/overlays/dropdown/index.tsx +1168 -0
- package/components/overlays/dropdown/menu-item.tsx +464 -0
- package/components/overlays/dropdown/menu-parts.tsx +660 -0
- package/components/overlays/popover/anchored-demo.tsx +59 -0
- package/components/overlays/popover/examples.mdx +180 -0
- package/components/overlays/popover/index.tsx +315 -0
- package/components/overlays/preview-card/examples.mdx +61 -0
- package/components/overlays/preview-card/index.tsx +204 -0
- package/components/overlays/sheet/examples.mdx +89 -0
- package/components/overlays/sheet/index.tsx +247 -0
- package/components/overlays/toast/examples.mdx +68 -0
- package/components/overlays/toast/global-demo.tsx +72 -0
- package/components/overlays/toast/index.tsx +474 -0
- package/components/overlays/toast/manager.ts +193 -0
- package/components/overlays/tooltip/examples.mdx +106 -0
- package/components/overlays/tooltip/index.tsx +519 -0
- package/components/progress/examples.mdx +59 -0
- package/components/progress/index.tsx +174 -0
- package/components/progress/progress.css +49 -0
- package/components/progress/step-demo.tsx +27 -0
- package/components/table/examples.mdx +184 -0
- package/components/table/index.tsx +510 -0
- package/components/table/sort-demo.tsx +61 -0
- package/dist/base.css +100 -0
- package/dist/blocks/sidebar-app/index.js +39 -26
- package/dist/components/AI/input-message/index.js +22 -16
- package/dist/components/AI/thinking-indicator/index.js +9 -6
- package/dist/components/accordion/index.js +16 -13
- package/dist/components/avatar/index.d.ts +46 -0
- package/dist/components/avatar/index.js +18 -0
- package/dist/components/badge/index.d.ts +18 -11
- package/dist/components/badge/index.js +11 -7
- package/dist/components/badge/variants.d.ts +33 -0
- package/dist/components/badge/variants.js +10 -0
- package/dist/components/button/index.d.ts +53 -17
- package/dist/components/button/index.js +10 -6
- package/dist/components/button/variants.d.ts +39 -0
- package/dist/components/button/variants.js +8 -0
- package/dist/components/card/index.d.ts +16 -3
- package/dist/components/card/index.js +16 -11
- package/dist/components/copy/index.js +13 -9
- package/dist/components/inputs/checkbox/index.d.ts +28 -9
- package/dist/components/inputs/checkbox/index.js +17 -13
- package/dist/components/inputs/color-picker/index.d.ts +13 -13
- package/dist/components/inputs/color-picker/index.js +25 -18
- package/dist/components/inputs/color-picker/variants.d.ts +61 -0
- package/dist/components/inputs/color-picker/variants.js +28 -0
- package/dist/components/inputs/combobox/index.d.ts +60 -17
- package/dist/components/inputs/combobox/index.js +26 -18
- package/dist/components/inputs/date-time/calendar/index.js +14 -11
- package/dist/components/inputs/date-time/date-picker/index.d.ts +53 -11
- package/dist/components/inputs/date-time/date-picker/index.js +33 -25
- package/dist/components/inputs/date-time/date-range-picker/index.d.ts +3 -3
- package/dist/components/inputs/date-time/date-range-picker/index.js +32 -24
- package/dist/components/inputs/date-time/duration-picker/index.d.ts +3 -3
- package/dist/components/inputs/date-time/duration-picker/index.js +32 -24
- package/dist/components/inputs/date-time/month-picker/index.d.ts +3 -3
- package/dist/components/inputs/date-time/month-picker/index.js +31 -23
- package/dist/components/inputs/date-time/time-picker/index.d.ts +3 -3
- package/dist/components/inputs/date-time/time-picker/index.js +32 -24
- package/dist/components/inputs/field/index.d.ts +116 -0
- package/dist/components/inputs/field/index.js +28 -0
- package/dist/components/inputs/file-dropzone/index.d.ts +165 -0
- package/dist/components/inputs/file-dropzone/index.js +32 -0
- package/dist/components/inputs/file-dropzone/variants.d.ts +35 -0
- package/dist/components/inputs/file-dropzone/variants.js +12 -0
- package/dist/components/inputs/input/index.d.ts +95 -13
- package/dist/components/inputs/input/index.js +17 -9
- package/dist/components/inputs/label/index.d.ts +32 -0
- package/dist/components/inputs/label/index.js +11 -0
- package/dist/components/inputs/number-input/index.d.ts +84 -0
- package/dist/components/inputs/number-input/index.js +20 -0
- package/dist/components/inputs/radio/index.d.ts +20 -5
- package/dist/components/inputs/radio/index.js +15 -12
- package/dist/components/inputs/search/index.js +25 -18
- package/dist/components/inputs/select/index.d.ts +41 -11
- package/dist/components/inputs/select/index.js +27 -14
- package/dist/components/inputs/select/variants.d.ts +5 -0
- package/dist/components/inputs/select/variants.js +6 -0
- package/dist/components/inputs/slider/index.js +7 -5
- package/dist/components/inputs/switch/index.d.ts +8 -2
- package/dist/components/inputs/switch/index.js +14 -12
- package/dist/components/inputs/textarea/index.d.ts +4 -0
- package/dist/components/inputs/textarea/index.js +8 -6
- package/dist/components/navigation/navigation-menu/index.d.ts +114 -0
- package/dist/components/navigation/navigation-menu/index.js +40 -0
- package/dist/components/navigation/pagination/index.d.ts +73 -0
- package/dist/components/navigation/pagination/index.js +39 -0
- package/dist/components/navigation/sidebar/index.d.ts +44 -21
- package/dist/components/navigation/sidebar/index.js +41 -31
- package/dist/components/navigation/sidebar/variants.d.ts +25 -0
- package/dist/components/navigation/sidebar/variants.js +28 -0
- package/dist/components/navigation/tabs/index.d.ts +8 -2
- package/dist/components/navigation/tabs/index.js +14 -10
- package/dist/components/navigation/tabs-subtle/index.js +12 -10
- package/dist/components/overlays/alert-dialog/index.d.ts +121 -15
- package/dist/components/overlays/alert-dialog/index.js +22 -13
- package/dist/components/overlays/command/index.js +24 -17
- package/dist/components/overlays/context-menu/index.d.ts +33 -17
- package/dist/components/overlays/context-menu/index.js +27 -18
- package/dist/components/overlays/dialog/index.js +17 -12
- package/dist/components/overlays/dropdown/index.d.ts +257 -31
- package/dist/components/overlays/dropdown/index.js +42 -17
- package/dist/components/overlays/popover/index.d.ts +31 -9
- package/dist/components/overlays/popover/index.js +12 -9
- package/dist/components/overlays/preview-card/index.js +11 -8
- package/dist/components/overlays/sheet/index.js +18 -13
- package/dist/components/overlays/toast/index.d.ts +123 -47
- package/dist/components/overlays/toast/index.js +18 -10
- package/dist/components/overlays/tooltip/index.d.ts +113 -21
- package/dist/components/overlays/tooltip/index.js +15 -6
- package/dist/components/progress/index.d.ts +56 -0
- package/dist/components/progress/index.js +15 -0
- package/dist/components/table/index.d.ts +63 -8
- package/dist/components/table/index.js +19 -9
- package/dist/index.d.ts +2201 -736
- package/dist/index.js +223 -82
- package/dist/lib/date-locale.d.ts +14 -0
- package/dist/lib/date-locale.js +16 -0
- package/dist/lib/field-classes.d.ts +144 -0
- package/dist/lib/field-classes.js +40 -0
- package/dist/lib/field-variants.d.ts +47 -0
- package/dist/lib/field-variants.js +26 -0
- package/dist/lib/fluid-hover-highlight.d.ts +112 -0
- package/dist/lib/fluid-hover-highlight.js +17 -0
- package/dist/lib/focus-ring.d.ts +18 -0
- package/dist/lib/focus-ring.js +10 -0
- package/dist/lib/goo-indicator.d.ts +46 -0
- package/dist/lib/goo-indicator.js +14 -0
- package/dist/lib/icon-context.d.ts +30 -0
- package/dist/lib/icon-context.js +12 -0
- package/dist/lib/inert.d.ts +9 -0
- package/dist/lib/inert.js +8 -0
- package/dist/lib/morph-layers.d.ts +93 -0
- package/dist/lib/morph-layers.js +13 -0
- package/dist/lib/morph-part.d.ts +21 -0
- package/dist/lib/morph-part.js +12 -0
- package/dist/lib/popup.d.ts +20 -0
- package/dist/lib/popup.js +12 -0
- package/dist/lib/reduced-motion.d.ts +9 -0
- package/dist/lib/reduced-motion.js +6 -0
- package/dist/lib/row-index.d.ts +10 -0
- package/dist/lib/row-index.js +8 -0
- package/dist/lib/row-order.d.ts +28 -0
- package/dist/lib/row-order.js +10 -0
- package/dist/lib/scroll-column.d.ts +27 -0
- package/dist/lib/scroll-column.js +18 -0
- package/dist/lib/shape-context.d.ts +30 -0
- package/dist/lib/shape-context.js +12 -0
- package/dist/lib/size-context.d.ts +94 -0
- package/dist/lib/size-context.js +20 -0
- package/dist/lib/size-scale.d.ts +79 -0
- package/dist/lib/size-scale.js +8 -0
- package/dist/lib/slot.d.ts +12 -0
- package/dist/lib/slot.js +6 -0
- package/dist/lib/springs.d.ts +44 -0
- package/dist/lib/springs.js +8 -0
- package/dist/lib/surface-classes.d.ts +19 -0
- package/dist/lib/surface-classes.js +10 -0
- package/dist/lib/surface-context.d.ts +9 -0
- package/dist/lib/surface-context.js +8 -0
- package/dist/lib/use-controllable-state.d.ts +2 -0
- package/dist/lib/use-controllable-state.js +6 -0
- package/dist/lib/use-fluid-hover.d.ts +138 -0
- package/dist/lib/use-fluid-hover.js +14 -0
- package/dist/lib/use-is-mobile.d.ts +2 -0
- package/dist/lib/use-is-mobile.js +6 -0
- package/dist/lib/use-keyboard-nav-gate.d.ts +20 -0
- package/dist/lib/use-keyboard-nav-gate.js +7 -0
- package/dist/lib/use-merge-split.d.ts +47 -0
- package/dist/lib/use-merge-split.js +15 -0
- package/dist/lib/use-morph.d.ts +101 -0
- package/dist/lib/use-morph.js +20 -0
- package/dist/lib/use-touch-primary.d.ts +2 -0
- package/dist/lib/use-touch-primary.js +6 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.js +6 -0
- package/dist/preset.d.ts +17 -0
- package/dist/preset.js +22 -2
- package/dist/primitives/code/index.js +7 -5
- package/dist/primitives/craft/index.js +9 -6
- package/dist/primitives/fluid-hover/index.js +9 -8
- package/dist/primitives/morph/index.js +15 -10
- package/dist/primitives/motion/index.js +10 -8
- package/dist/primitives/scroll-area/index.js +4 -3
- package/dist/primitives/separator/index.d.ts +25 -0
- package/dist/primitives/separator/index.js +9 -0
- package/dist/primitives/sizes/index.js +6 -4
- package/dist/primitives/skeleton/index.d.ts +35 -0
- package/dist/primitives/skeleton/index.js +12 -0
- package/dist/primitives/surface/index.js +6 -4
- package/dist/shared/{components-3mkepka5.js → components-0jmqhx2k.js} +28 -25
- package/dist/shared/components-124epb7f.js +53 -0
- package/dist/shared/{components-gz8zf3ec.js → components-1k0wv53d.js} +15 -43
- package/dist/shared/components-26snnbs9.js +322 -0
- package/dist/shared/{components-2dxkv6zm.js → components-2c0qcfx4.js} +8 -7
- package/dist/shared/{components-473g814w.js → components-2mt9te6d.js} +243 -137
- package/dist/shared/{components-hawvy2qz.js → components-2z48g4fp.js} +9 -30
- package/dist/shared/components-34txk9rj.js +35 -0
- package/dist/shared/components-3h92d94m.js +23 -0
- package/dist/shared/{components-zfnmn5d4.js → components-3sp623ae.js} +36 -31
- package/dist/shared/{components-jwst3vtj.js → components-3wxkjgqn.js} +186 -76
- package/dist/shared/{components-s2bz1xpk.js → components-49bsynbw.js} +8 -5
- package/dist/shared/components-4bwsd46w.js +71 -0
- package/dist/shared/components-4n4f5m3e.js +297 -0
- package/dist/shared/{components-0q0sqfh4.js → components-4y816hwp.js} +27 -49
- package/dist/shared/components-52ngsgca.js +399 -0
- package/dist/shared/{components-y9y0g8er.js → components-52r5763g.js} +22 -19
- package/dist/shared/components-559tf7yb.js +28 -0
- package/dist/shared/components-5mkga9hj.js +440 -0
- package/dist/shared/{components-kwa6mrf9.js → components-5s0nfqvw.js} +14 -9
- package/dist/shared/{components-dk0gjr5a.js → components-60atskhp.js} +19 -18
- package/dist/shared/{components-rr7a6ja9.js → components-658b26nb.js} +21 -18
- package/dist/shared/{components-xxvdw7k1.js → components-65vwfjs6.js} +37 -34
- package/dist/shared/{components-frw34b65.js → components-6j2fh74k.js} +114 -123
- package/dist/shared/components-6zg1yft7.js +167 -0
- package/dist/shared/components-6zng858q.js +53 -0
- package/dist/shared/components-72w4ge68.js +64 -0
- package/dist/shared/{components-cv16mryv.js → components-78x40gee.js} +13 -7
- package/dist/shared/{components-b2wyq2cq.js → components-7d4r7hh1.js} +39 -57
- package/dist/shared/components-7ntvvegr.js +316 -0
- package/dist/shared/components-8nwsqjn8.js +34 -0
- package/dist/shared/{components-5xc2j8jz.js → components-8tt3xbb5.js} +19 -18
- package/dist/shared/{components-vc7vq5nz.js → components-9as3rf68.js} +6 -3
- package/dist/shared/components-9mys9jna.js +255 -0
- package/dist/shared/components-adkarj8n.js +35 -0
- package/dist/shared/{components-b3c5et5b.js → components-ae9fncz4.js} +32 -31
- package/dist/shared/{components-hqeq3w9q.js → components-awra5sff.js} +24 -16
- package/dist/shared/components-b1e239g3.js +28 -0
- package/dist/shared/{components-q5xj5c16.js → components-b1f2bjf1.js} +9 -8
- package/dist/shared/{components-qmmjx1qw.js → components-bn42vq6d.js} +71 -72
- package/dist/shared/components-c96g82th.js +1444 -0
- package/dist/shared/components-c9yr4ed1.js +44 -0
- package/dist/shared/components-d3shbwqq.js +95 -0
- package/dist/shared/{components-jjw69c4c.js → components-dcc02hs2.js} +7 -4
- package/dist/shared/{components-men63rwj.js → components-de99ykt7.js} +3 -2
- package/dist/shared/components-dhgk6mbt.js +16 -0
- package/dist/shared/components-drxrtnp2.js +25 -0
- package/dist/shared/{components-6p8tfcd6.js → components-ds9fqat9.js} +21 -18
- package/dist/shared/{components-mjthm1kf.js → components-egy4tyjt.js} +11 -8
- package/dist/shared/components-f0xfpkhh.js +82 -0
- package/dist/shared/components-f7h57m6t.js +59 -0
- package/dist/shared/{components-d6xtjht3.js → components-fjkpbg6n.js} +33 -30
- package/dist/shared/{components-gc5vtq2f.js → components-gh0qeey3.js} +63 -29
- package/dist/shared/{components-39jq9va1.js → components-gvbapfen.js} +29 -24
- package/dist/shared/{components-5vgpnxgy.js → components-gzesg7hy.js} +19 -16
- package/dist/shared/components-h6p86be3.js +19 -0
- package/dist/shared/components-h83az0bz.js +33 -0
- package/dist/shared/components-jjrj6ymn.js +104 -0
- package/dist/shared/{components-wvp1m52g.js → components-jp5v0m0f.js} +15 -14
- package/dist/shared/{components-zrkzcayz.js → components-jqpefjsn.js} +54 -6
- package/dist/shared/{components-1z6tdadk.js → components-k74j4hwh.js} +20 -17
- package/dist/shared/components-kswxnm3h.js +24 -0
- package/dist/shared/{components-jbbmdr74.js → components-mkkbbgb7.js} +36 -17
- package/dist/shared/{components-pbmqctfa.js → components-mpw667rb.js} +169 -65
- package/dist/shared/{components-s5ngg32c.js → components-paa08427.js} +121 -47
- package/dist/shared/{components-vfbhxf5r.js → components-pd42emw3.js} +6 -5
- package/dist/shared/{components-hw9yk32z.js → components-pmfszv7a.js} +35 -23
- package/dist/shared/{components-chacnk61.js → components-pw06rbxq.js} +19 -19
- package/dist/shared/components-q03cktx8.js +246 -0
- package/dist/shared/{components-kvr7f9hy.js → components-qdm3yh26.js} +19 -10
- package/dist/shared/{components-27nnvrwe.js → components-qs4rkqsj.js} +15 -14
- package/dist/shared/{components-s083f38z.js → components-r6gbmkew.js} +22 -21
- package/dist/shared/{components-ta7shw28.js → components-r8eqs7gz.js} +4 -4
- package/dist/shared/{components-sn1rxx1d.js → components-rm02h9nt.js} +12 -11
- package/dist/shared/{components-c6s6b43g.js → components-rrbpmfx4.js} +3 -2
- package/dist/shared/{components-n7mjr357.js → components-tz1fv35x.js} +3 -2
- package/dist/shared/components-tz4gaf1v.js +23 -0
- package/dist/shared/{components-zx6b6yby.js → components-vf45j12c.js} +4 -18
- package/dist/shared/{components-3ky44908.js → components-vfjc9jzz.js} +2 -3
- package/dist/shared/{components-39vmfq9s.js → components-vsa625vk.js} +21 -20
- package/dist/shared/components-wawnmkph.js +500 -0
- package/dist/shared/components-wktrrb4m.js +38 -0
- package/dist/shared/{components-8snj490h.js → components-wwd363me.js} +78 -32
- package/dist/shared/{components-5pnw8183.js → components-xcwvgcrd.js} +23 -22
- package/dist/shared/components-xdqmt6ed.js +20 -0
- package/dist/shared/{components-h2ajmhwn.js → components-y1w3a6wx.js} +107 -35
- package/dist/shared/{components-ar66gnt0.js → components-y6578b8h.js} +37 -34
- package/dist/shared/{components-59efxjd9.js → components-yhgq0qxx.js} +13 -12
- package/dist/shared/{components-jthe1pwb.js → components-yz84gpkq.js} +101 -404
- package/dist/shared/components-z3v0n0de.js +11 -0
- package/dist/shared/{components-7r3etddy.js → components-z9n19fyk.js} +19 -54
- package/dist/shared/components-zbvjzh1p.js +141 -0
- package/dist/styles.css +241 -16
- package/dist/tailwind.css +13 -0
- package/dist/tokens.css +34 -0
- package/index.ts +60 -0
- package/lib/date-locale.ts +50 -0
- package/lib/field-classes.ts +115 -0
- package/lib/field-variants.ts +117 -0
- package/lib/fluid-hover-highlight.tsx +139 -0
- package/lib/focus-ring.tsx +46 -0
- package/lib/goo-indicator.tsx +261 -0
- package/lib/icon-context.tsx +310 -0
- package/lib/inert.ts +22 -0
- package/lib/morph-layers.tsx +102 -0
- package/lib/morph-part.tsx +49 -0
- package/lib/popup.ts +52 -0
- package/lib/reduced-motion.ts +19 -0
- package/lib/row-index.tsx +29 -0
- package/lib/row-order.tsx +115 -0
- package/lib/scroll-column.tsx +244 -0
- package/lib/shape-context.tsx +124 -0
- package/lib/size-context.tsx +66 -0
- package/lib/size-scale.ts +115 -0
- package/lib/slot.ts +25 -0
- package/lib/springs.ts +62 -0
- package/lib/surface-classes.ts +58 -0
- package/lib/surface-context.tsx +21 -0
- package/lib/use-controllable-state.ts +44 -0
- package/lib/use-fluid-hover.ts +541 -0
- package/lib/use-is-mobile.ts +25 -0
- package/lib/use-keyboard-nav-gate.ts +45 -0
- package/lib/use-merge-split.tsx +209 -0
- package/lib/use-morph.ts +557 -0
- package/lib/use-touch-primary.tsx +43 -0
- package/lib/utils.ts +37 -0
- package/package.json +177 -4
- package/preset.ts +196 -0
- package/primitives/code/examples.mdx +27 -0
- package/primitives/code/index.tsx +80 -0
- package/primitives/craft/demos.tsx +173 -0
- package/primitives/craft/examples.mdx +138 -0
- package/primitives/craft/index.tsx +76 -0
- package/primitives/fluid-hover/examples.mdx +84 -0
- package/primitives/fluid-hover/index.tsx +316 -0
- package/primitives/morph/examples.mdx +135 -0
- package/primitives/morph/index.tsx +192 -0
- package/primitives/motion/examples.mdx +100 -0
- package/primitives/motion/index.tsx +160 -0
- package/primitives/scroll-area/examples.mdx +58 -0
- package/primitives/scroll-area/index.tsx +200 -0
- package/primitives/separator/examples.mdx +30 -0
- package/primitives/separator/index.tsx +53 -0
- package/primitives/sizes/examples.mdx +167 -0
- package/primitives/sizes/index.tsx +89 -0
- package/primitives/skeleton/examples.mdx +51 -0
- package/primitives/skeleton/index.tsx +72 -0
- package/primitives/skeleton/skeleton.css +15 -0
- package/primitives/surface/examples.mdx +173 -0
- package/primitives/surface/index.tsx +109 -0
- package/primitives/surface/surface.css +176 -0
- package/dist/shared/components-0sn7ypjw.js +0 -61
- package/dist/shared/components-1434p1mh.js +0 -123
- package/dist/shared/components-a86dytt0.js +0 -986
- package/dist/shared/components-gf5s6ap9.js +0 -42
- package/dist/shared/components-hyq23phv.js +0 -310
- package/dist/shared/components-ng72k97e.js +0 -128
- package/dist/shared/components-raknhppb.js +0 -64
- package/dist/shared/components-vynjtrk6.js +0 -19
- package/dist/shared/components-wy9jpqny.js +0 -146
- /package/dist/shared/{components-1v7rxytx.js → components-3ey0hzpb.js} +0 -0
- /package/dist/shared/{components-ezxhrmer.js → components-jcnjay5g.js} +0 -0
|
@@ -0,0 +1,1408 @@
|
|
|
1
|
+
// biome-ignore-all lint: vendored upstream code keeps its own patterns (see the header)
|
|
2
|
+
/*
|
|
3
|
+
* Ported from Fluid Functionalism — github.com/mickadesign/fluid-functionalism
|
|
4
|
+
* `registry/default/sidebar-core.tsx` @ b3587bdbd83fc66c2a6aae3817ffb856cb09260b
|
|
5
|
+
* MIT License © 2026 Micka Touillaud — fluidfunctionalism.com (notice: LICENSE.fluid-functionalism)
|
|
6
|
+
* Local modifications:
|
|
7
|
+
* - `"use client"` dropped; `framer-motion` → `motion/react`; `@/lib/*`
|
|
8
|
+
* imports rewritten to `../../../lib/*`, `@/components/ui/{button,tooltip}`
|
|
9
|
+
* to the Base siblings `../../button` / `../../overlays/tooltip`.
|
|
10
|
+
* - Styling reads DS tokens (AGENTS.md styling): inline
|
|
11
|
+
* `fontVariationSettings` → `weight-*`; `text-[10px]` →
|
|
12
|
+
* `text-micro-compact`, the group label's `text-[11px]` / `text-[12px]` →
|
|
13
|
+
* `text-caption-compact` / `text-caption`; the hex focus-ring fallback →
|
|
14
|
+
* `ring-focus-ring`; `duration-80|160|240` → `duration-<tier>`.
|
|
15
|
+
* - No collapsed peek (the card floated out of the edge strip, `peek`):
|
|
16
|
+
* `openOnHover` on the provider opens the sidebar itself, in flow, while
|
|
17
|
+
* the pointer is between its edge and the far side of a trigger beside it;
|
|
18
|
+
* the trigger then switches between open and on hover (dashed glyph), and
|
|
19
|
+
* the rail collapses or resizes the hover without keeping it open.
|
|
20
|
+
* - What is folded or collapsed away is `inert`: a folded `SidebarGroup`, and
|
|
21
|
+
* the rail panel of a collapsed sidebar. Their rows were still
|
|
22
|
+
* tab stops while clipped away (React 18 has no `inert` prop type, hence
|
|
23
|
+
* the spreads). `inertProps` (`lib/inert.ts`) picks the value per React
|
|
24
|
+
* major: 18 passes `inert` through as an unknown attribute (a string), 19
|
|
25
|
+
* reads it as a boolean prop and drops `''`.
|
|
26
|
+
* - `SidebarInput`'s field ladder is the shared `lib/field-classes.ts` recipe.
|
|
27
|
+
* - `useIsMobile` moved to `lib/use-is-mobile.ts`, shared with the pickers.
|
|
28
|
+
* - The `SIDEBAR_*` constants live in `./variants.ts` (no JSX, hooks or
|
|
29
|
+
* contexts, so server code can read them) and are re-exported here.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { motion, useReducedMotion } from 'motion/react'
|
|
33
|
+
import {
|
|
34
|
+
Children,
|
|
35
|
+
type CSSProperties,
|
|
36
|
+
cloneElement,
|
|
37
|
+
createContext,
|
|
38
|
+
type ElementType,
|
|
39
|
+
forwardRef,
|
|
40
|
+
type HTMLAttributes,
|
|
41
|
+
isValidElement,
|
|
42
|
+
type MutableRefObject,
|
|
43
|
+
type ReactElement,
|
|
44
|
+
type ReactNode,
|
|
45
|
+
type Ref,
|
|
46
|
+
useCallback,
|
|
47
|
+
useContext,
|
|
48
|
+
useEffect,
|
|
49
|
+
useId,
|
|
50
|
+
useLayoutEffect,
|
|
51
|
+
useMemo,
|
|
52
|
+
useRef,
|
|
53
|
+
useState,
|
|
54
|
+
} from 'react'
|
|
55
|
+
import { fieldVariants } from '../../../lib/field-classes'
|
|
56
|
+
import { useIcon } from '../../../lib/icon-context'
|
|
57
|
+
import { inertProps } from '../../../lib/inert'
|
|
58
|
+
import { useShape } from '../../../lib/shape-context'
|
|
59
|
+
import { useSize, useSizeVariant } from '../../../lib/size-context'
|
|
60
|
+
import { exitFallbackMs, spring } from '../../../lib/springs'
|
|
61
|
+
import { surfaceClasses } from '../../../lib/surface-classes'
|
|
62
|
+
import { SurfaceProvider, useSurface } from '../../../lib/surface-context'
|
|
63
|
+
import { useIsMobile } from '../../../lib/use-is-mobile'
|
|
64
|
+
import { cn } from '../../../lib/utils'
|
|
65
|
+
import { Button, type ButtonProps } from '../../button'
|
|
66
|
+
import { Tooltip } from '../../overlays/tooltip'
|
|
67
|
+
import {
|
|
68
|
+
SIDEBAR_COLLAPSE_SLOP,
|
|
69
|
+
SIDEBAR_COOKIE_MAX_AGE,
|
|
70
|
+
SIDEBAR_COOKIE_NAME,
|
|
71
|
+
SIDEBAR_HOVER_STRIP,
|
|
72
|
+
SIDEBAR_HOVER_TRIGGER_GAP,
|
|
73
|
+
SIDEBAR_KEYBOARD_SHORTCUT,
|
|
74
|
+
SIDEBAR_KEYBOARD_SHORTCUT_RIGHT,
|
|
75
|
+
SIDEBAR_MAX_WIDTH,
|
|
76
|
+
SIDEBAR_MIN_WIDTH,
|
|
77
|
+
SIDEBAR_WIDTH,
|
|
78
|
+
SIDEBAR_WIDTH_MOBILE,
|
|
79
|
+
} from './variants'
|
|
80
|
+
|
|
81
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
// Plain values, in `./variants.ts` so server code can read them.
|
|
84
|
+
export {
|
|
85
|
+
SIDEBAR_COLLAPSE_SLOP,
|
|
86
|
+
SIDEBAR_COOKIE_MAX_AGE,
|
|
87
|
+
SIDEBAR_COOKIE_NAME,
|
|
88
|
+
SIDEBAR_HOVER_STRIP,
|
|
89
|
+
SIDEBAR_HOVER_TRIGGER_GAP,
|
|
90
|
+
SIDEBAR_KEYBOARD_SHORTCUT,
|
|
91
|
+
SIDEBAR_KEYBOARD_SHORTCUT_RIGHT,
|
|
92
|
+
SIDEBAR_MAX_WIDTH,
|
|
93
|
+
SIDEBAR_MIN_WIDTH,
|
|
94
|
+
SIDEBAR_WIDTH,
|
|
95
|
+
SIDEBAR_WIDTH_MOBILE,
|
|
96
|
+
} from './variants'
|
|
97
|
+
|
|
98
|
+
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export type SidebarSide = 'left' | 'right'
|
|
101
|
+
export type SidebarVariant = 'sidebar' | 'floating' | 'inset'
|
|
102
|
+
export type SidebarCollapsible = 'offcanvas' | 'none'
|
|
103
|
+
|
|
104
|
+
export interface SidebarContextValue {
|
|
105
|
+
/** What shows: expanded while kept open or open on hover. */
|
|
106
|
+
state: 'expanded' | 'collapsed'
|
|
107
|
+
/** Kept open. */
|
|
108
|
+
open: boolean
|
|
109
|
+
setOpen: (open: boolean | ((prev: boolean) => boolean)) => void
|
|
110
|
+
openMobile: boolean
|
|
111
|
+
setOpenMobile: React.Dispatch<React.SetStateAction<boolean>>
|
|
112
|
+
isMobile: boolean
|
|
113
|
+
toggleSidebar: () => void
|
|
114
|
+
/** Live rail width — the rail's drag-resize updates it. */
|
|
115
|
+
width: string
|
|
116
|
+
setWidth: (width: string) => void
|
|
117
|
+
widthMobile: string
|
|
118
|
+
mobileBreakpoint: number
|
|
119
|
+
/** Which edge the rendered Sidebar sits on (registered by <Sidebar/>). */
|
|
120
|
+
side: SidebarSide
|
|
121
|
+
/** Internal: <Sidebar/> reports its side so the provider can resolve the
|
|
122
|
+
* default shortcut and the rail can mirror. */
|
|
123
|
+
registerSide: (side: SidebarSide) => void
|
|
124
|
+
/** The resolved toggle key ("[" / "]" / custom / null when disabled). */
|
|
125
|
+
shortcut: string | null
|
|
126
|
+
/** The collapsed sidebar opens on hover (see the provider's prop). */
|
|
127
|
+
openOnHover: boolean
|
|
128
|
+
/** True while the collapsed sidebar is open on hover. */
|
|
129
|
+
hoverOpen: boolean
|
|
130
|
+
setHoverOpen: React.Dispatch<React.SetStateAction<boolean>>
|
|
131
|
+
/** Internal: the shell and the triggers register their nodes; the hover
|
|
132
|
+
* zone is measured off them. */
|
|
133
|
+
shellRef: MutableRefObject<HTMLElement | null>
|
|
134
|
+
registerTrigger: (node: HTMLElement) => () => void
|
|
135
|
+
/** Internal: true while the rail is being drag-resized (disables the
|
|
136
|
+
* width spring so the panel tracks the pointer 1:1). */
|
|
137
|
+
isResizing: boolean
|
|
138
|
+
setIsResizing: React.Dispatch<React.SetStateAction<boolean>>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Mounted-provider registry for the global toggle shortcut. The listener has
|
|
142
|
+
// to be global (the key should work without focus in the sidebar), but only
|
|
143
|
+
// ONE provider may answer a keypress: the innermost one containing focus, or —
|
|
144
|
+
// when focus is outside every provider — the OUTERMOST mounted one (the
|
|
145
|
+
// app-shell provider that wraps everything else; mount order can't be used
|
|
146
|
+
// because a persistent layout provider mounts once while demos mount later
|
|
147
|
+
// on navigation). Same pattern as AskUserQuestions' 1-9 shortcuts.
|
|
148
|
+
const mountedProviders: HTMLElement[] = []
|
|
149
|
+
|
|
150
|
+
const SidebarContext = createContext<SidebarContextValue | null>(null)
|
|
151
|
+
|
|
152
|
+
export function useSidebar(): SidebarContextValue {
|
|
153
|
+
const ctx = useContext(SidebarContext)
|
|
154
|
+
if (!ctx) throw new Error('useSidebar must be used within a SidebarProvider')
|
|
155
|
+
return ctx
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Open on hover's zone: from the frame's edge to the sidebar's inner edge,
|
|
159
|
+
* plus the strip past it, widened to the far side of a trigger sitting
|
|
160
|
+
* right beside that edge (an inset topbar's, which rides the edge as the
|
|
161
|
+
* sidebar opens), within the frame's height. */
|
|
162
|
+
function inHoverZone(
|
|
163
|
+
event: PointerEvent,
|
|
164
|
+
side: SidebarSide,
|
|
165
|
+
frame: HTMLElement | null,
|
|
166
|
+
shell: HTMLElement | null,
|
|
167
|
+
triggers: Set<HTMLElement>,
|
|
168
|
+
): boolean {
|
|
169
|
+
if (!frame || !shell) return false
|
|
170
|
+
const bounds = frame.getBoundingClientRect()
|
|
171
|
+
if (event.clientY < bounds.top || event.clientY > bounds.bottom) return false
|
|
172
|
+
const box = shell.getBoundingClientRect()
|
|
173
|
+
const inner = side === 'left' ? box.right : box.left
|
|
174
|
+
let reach = SIDEBAR_HOVER_STRIP
|
|
175
|
+
for (const trigger of triggers) {
|
|
176
|
+
const button = trigger.getBoundingClientRect()
|
|
177
|
+
const gap = side === 'left' ? button.left - inner : inner - button.right
|
|
178
|
+
if (gap >= 0 && gap <= SIDEBAR_HOVER_TRIGGER_GAP) reach = Math.max(reach, gap + button.width)
|
|
179
|
+
}
|
|
180
|
+
return side === 'left'
|
|
181
|
+
? event.clientX >= bounds.left && event.clientX < inner + reach
|
|
182
|
+
: event.clientX <= bounds.right && event.clientX > inner - reach
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── SidebarProvider ─────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export interface SidebarProviderProps extends HTMLAttributes<HTMLDivElement> {
|
|
188
|
+
defaultOpen?: boolean
|
|
189
|
+
open?: boolean
|
|
190
|
+
onOpenChange?: (open: boolean) => void
|
|
191
|
+
/** Persist the desktop open state to the `sidebar_state` cookie so a server
|
|
192
|
+
* layout can read it back into `defaultOpen`. Mobile state never persists. */
|
|
193
|
+
persist?: boolean
|
|
194
|
+
/** Bare-key toggle shortcut. Defaults to "[" for a left sidebar and "]"
|
|
195
|
+
* for a right one; `null` disables it. */
|
|
196
|
+
shortcut?: string | null
|
|
197
|
+
/** Viewport width (px) below which the sidebar renders as a drawer. */
|
|
198
|
+
mobileBreakpoint?: number
|
|
199
|
+
/** While collapsed, open the sidebar when the pointer reaches its edge or
|
|
200
|
+
* the trigger beside it, and close it a beat after the pointer leaves.
|
|
201
|
+
* `open` then says whether it is kept open, and the trigger switches
|
|
202
|
+
* between the two. Hovering never writes the cookie. @default false */
|
|
203
|
+
openOnHover?: boolean
|
|
204
|
+
width?: string
|
|
205
|
+
widthMobile?: string
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const SidebarProvider = forwardRef<HTMLDivElement, SidebarProviderProps>(
|
|
209
|
+
(
|
|
210
|
+
{
|
|
211
|
+
defaultOpen = true,
|
|
212
|
+
open: openProp,
|
|
213
|
+
onOpenChange,
|
|
214
|
+
persist = true,
|
|
215
|
+
shortcut: shortcutProp,
|
|
216
|
+
mobileBreakpoint = 768,
|
|
217
|
+
openOnHover = false,
|
|
218
|
+
width: widthProp = SIDEBAR_WIDTH,
|
|
219
|
+
widthMobile = SIDEBAR_WIDTH_MOBILE,
|
|
220
|
+
className,
|
|
221
|
+
style,
|
|
222
|
+
children,
|
|
223
|
+
...props
|
|
224
|
+
},
|
|
225
|
+
ref,
|
|
226
|
+
) => {
|
|
227
|
+
const isMobile = useIsMobile(mobileBreakpoint)
|
|
228
|
+
const [openMobile, setOpenMobile] = useState(false)
|
|
229
|
+
const [side, setSide] = useState<SidebarSide>('left')
|
|
230
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
const el = wrapperRef.current
|
|
234
|
+
if (!el) return
|
|
235
|
+
mountedProviders.push(el)
|
|
236
|
+
return () => {
|
|
237
|
+
const i = mountedProviders.indexOf(el)
|
|
238
|
+
if (i !== -1) mountedProviders.splice(i, 1)
|
|
239
|
+
}
|
|
240
|
+
}, [])
|
|
241
|
+
const registerSide = useCallback((next: SidebarSide) => setSide(next), [])
|
|
242
|
+
|
|
243
|
+
// Live width: the prop is the starting point, the rail's drag-resize
|
|
244
|
+
// updates it at runtime.
|
|
245
|
+
const [width, setWidth] = useState(widthProp)
|
|
246
|
+
useEffect(() => setWidth(widthProp), [widthProp])
|
|
247
|
+
const [isResizing, setIsResizing] = useState(false)
|
|
248
|
+
|
|
249
|
+
// Default shortcut mirrors the sidebar's edge: "[" left, "]" right.
|
|
250
|
+
const shortcut =
|
|
251
|
+
shortcutProp === undefined
|
|
252
|
+
? side === 'right'
|
|
253
|
+
? SIDEBAR_KEYBOARD_SHORTCUT_RIGHT
|
|
254
|
+
: SIDEBAR_KEYBOARD_SHORTCUT
|
|
255
|
+
: shortcutProp
|
|
256
|
+
|
|
257
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
258
|
+
const open = openProp ?? internalOpen
|
|
259
|
+
const openRef = useRef(open)
|
|
260
|
+
openRef.current = open
|
|
261
|
+
|
|
262
|
+
const setOpen = useCallback(
|
|
263
|
+
(value: boolean | ((prev: boolean) => boolean)) => {
|
|
264
|
+
const next = typeof value === 'function' ? value(openRef.current) : value
|
|
265
|
+
if (onOpenChange) onOpenChange(next)
|
|
266
|
+
else setInternalOpen(next)
|
|
267
|
+
if (persist) {
|
|
268
|
+
document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
[onOpenChange, persist],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
const toggleSidebar = useCallback(() => {
|
|
275
|
+
if (isMobile) setOpenMobile(prev => !prev)
|
|
276
|
+
else setOpen(prev => !prev)
|
|
277
|
+
}, [isMobile, setOpen])
|
|
278
|
+
|
|
279
|
+
// Open on hover. Keeping it open ends the hover; the zone arms only once
|
|
280
|
+
// the pointer has been outside it, so collapsing from the trigger or the
|
|
281
|
+
// rail under the pointer doesn't open it straight back.
|
|
282
|
+
const [hoverOpen, setHoverOpen] = useState(false)
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (open || !openOnHover) setHoverOpen(false)
|
|
285
|
+
}, [open, openOnHover])
|
|
286
|
+
const shellRef = useRef<HTMLElement | null>(null)
|
|
287
|
+
const triggers = useRef(new Set<HTMLElement>())
|
|
288
|
+
const registerTrigger = useCallback((node: HTMLElement) => {
|
|
289
|
+
triggers.current.add(node)
|
|
290
|
+
return () => {
|
|
291
|
+
triggers.current.delete(node)
|
|
292
|
+
}
|
|
293
|
+
}, [])
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (!openOnHover || open || isMobile || isResizing) return
|
|
296
|
+
let armed = false
|
|
297
|
+
let close: ReturnType<typeof setTimeout> | undefined
|
|
298
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
299
|
+
if (event.pointerType !== 'mouse') return
|
|
300
|
+
if (!inHoverZone(event, side, wrapperRef.current, shellRef.current, triggers.current)) {
|
|
301
|
+
armed = true
|
|
302
|
+
if (hoverOpen) close ??= setTimeout(() => setHoverOpen(false), 300)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
clearTimeout(close)
|
|
306
|
+
close = undefined
|
|
307
|
+
if (armed) setHoverOpen(true)
|
|
308
|
+
}
|
|
309
|
+
document.addEventListener('pointermove', onPointerMove)
|
|
310
|
+
return () => {
|
|
311
|
+
document.removeEventListener('pointermove', onPointerMove)
|
|
312
|
+
clearTimeout(close)
|
|
313
|
+
}
|
|
314
|
+
}, [openOnHover, open, isMobile, isResizing, hoverOpen, side])
|
|
315
|
+
|
|
316
|
+
// The bare shortcut key toggles the sidebar app-wide. Bound to the
|
|
317
|
+
// provider's lifetime (not a docs-only global), skipped while typing, and
|
|
318
|
+
// skipped when a modifier is held so ⌘[ / ⌘] keep their browser meaning.
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (shortcut == null) return
|
|
321
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
322
|
+
if (event.key.toLowerCase() !== shortcut.toLowerCase()) return
|
|
323
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
324
|
+
const target = event.target as HTMLElement
|
|
325
|
+
if (
|
|
326
|
+
target.tagName === 'INPUT' ||
|
|
327
|
+
target.tagName === 'TEXTAREA' ||
|
|
328
|
+
target.tagName === 'SELECT' ||
|
|
329
|
+
target.isContentEditable
|
|
330
|
+
)
|
|
331
|
+
return
|
|
332
|
+
// Only one mounted provider answers (see mountedProviders). Providers
|
|
333
|
+
// can NEST (an app shell wrapping doc previews), so containment alone
|
|
334
|
+
// isn't enough: the innermost provider containing focus wins.
|
|
335
|
+
const root = wrapperRef.current
|
|
336
|
+
if (!root) return
|
|
337
|
+
if (root.contains(target)) {
|
|
338
|
+
if (mountedProviders.some(el => el !== root && root.contains(el) && el.contains(target))) return
|
|
339
|
+
// The focused element itself WRAPS another provider (a docs
|
|
340
|
+
// preview frame holding keyboard scope for the demo inside it —
|
|
341
|
+
// see click-to-focus): the wrapped provider answers, not this
|
|
342
|
+
// ancestor shell.
|
|
343
|
+
if (mountedProviders.some(el => el !== root && target.contains(el))) return
|
|
344
|
+
} else {
|
|
345
|
+
// Focus outside this provider. A focused element that WRAPS
|
|
346
|
+
// providers — a docs preview frame holding keyboard scope for the
|
|
347
|
+
// demo inside it (see click-to-focus) — scopes the key to what it
|
|
348
|
+
// wraps: the outermost wrapped provider answers, and this check
|
|
349
|
+
// must come BEFORE the containment guard below (an app-shell
|
|
350
|
+
// provider always contains the preview frame, and must not steal
|
|
351
|
+
// the key from the demo the frame scopes to). Focus on <body>
|
|
352
|
+
// wraps every provider, which degenerates to the original rule:
|
|
353
|
+
// the OUTERMOST provider overall answers — mount order is
|
|
354
|
+
// unreliable here (a persistent app-shell provider mounts once,
|
|
355
|
+
// while doc demos mount later on client-side navigation).
|
|
356
|
+
const wrapped = mountedProviders.filter(el => target.contains(el))
|
|
357
|
+
if (wrapped.length > 0) {
|
|
358
|
+
const outermost = wrapped.find(el => !wrapped.some(other => other !== el && other.contains(el)))
|
|
359
|
+
if (outermost !== root) return
|
|
360
|
+
} else {
|
|
361
|
+
if (mountedProviders.some(el => el !== root && el.contains(target))) return
|
|
362
|
+
const outermost = mountedProviders.find(
|
|
363
|
+
el => !mountedProviders.some(other => other !== el && other.contains(el)),
|
|
364
|
+
)
|
|
365
|
+
if (outermost !== root) return
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
event.preventDefault()
|
|
369
|
+
toggleSidebar()
|
|
370
|
+
}
|
|
371
|
+
window.addEventListener('keydown', onKeyDown)
|
|
372
|
+
return () => window.removeEventListener('keydown', onKeyDown)
|
|
373
|
+
}, [shortcut, toggleSidebar])
|
|
374
|
+
|
|
375
|
+
const value = useMemo<SidebarContextValue>(
|
|
376
|
+
() => ({
|
|
377
|
+
state: open || hoverOpen ? 'expanded' : 'collapsed',
|
|
378
|
+
open,
|
|
379
|
+
setOpen,
|
|
380
|
+
openMobile,
|
|
381
|
+
setOpenMobile,
|
|
382
|
+
isMobile,
|
|
383
|
+
toggleSidebar,
|
|
384
|
+
width,
|
|
385
|
+
setWidth,
|
|
386
|
+
widthMobile,
|
|
387
|
+
mobileBreakpoint,
|
|
388
|
+
side,
|
|
389
|
+
registerSide,
|
|
390
|
+
shortcut,
|
|
391
|
+
openOnHover,
|
|
392
|
+
hoverOpen,
|
|
393
|
+
setHoverOpen,
|
|
394
|
+
shellRef,
|
|
395
|
+
registerTrigger,
|
|
396
|
+
isResizing,
|
|
397
|
+
setIsResizing,
|
|
398
|
+
}),
|
|
399
|
+
[
|
|
400
|
+
open,
|
|
401
|
+
setOpen,
|
|
402
|
+
openMobile,
|
|
403
|
+
isMobile,
|
|
404
|
+
toggleSidebar,
|
|
405
|
+
width,
|
|
406
|
+
widthMobile,
|
|
407
|
+
mobileBreakpoint,
|
|
408
|
+
side,
|
|
409
|
+
registerSide,
|
|
410
|
+
shortcut,
|
|
411
|
+
openOnHover,
|
|
412
|
+
hoverOpen,
|
|
413
|
+
registerTrigger,
|
|
414
|
+
isResizing,
|
|
415
|
+
],
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<SidebarContext.Provider value={value}>
|
|
420
|
+
<div
|
|
421
|
+
ref={node => {
|
|
422
|
+
wrapperRef.current = node
|
|
423
|
+
if (typeof ref === 'function') ref(node)
|
|
424
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
425
|
+
}}
|
|
426
|
+
data-slot="sidebar-wrapper"
|
|
427
|
+
className={cn('group/sidebar-wrapper relative flex min-h-svh w-full', className)}
|
|
428
|
+
style={
|
|
429
|
+
{
|
|
430
|
+
'--sidebar-width': width,
|
|
431
|
+
'--sidebar-width-mobile': widthMobile,
|
|
432
|
+
...style,
|
|
433
|
+
} as CSSProperties
|
|
434
|
+
}
|
|
435
|
+
{...props}
|
|
436
|
+
>
|
|
437
|
+
{children}
|
|
438
|
+
</div>
|
|
439
|
+
</SidebarContext.Provider>
|
|
440
|
+
)
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
SidebarProvider.displayName = 'SidebarProvider'
|
|
444
|
+
|
|
445
|
+
// ─── Slot helpers (render / asChild polymorphism) ────────────────────────────
|
|
446
|
+
//
|
|
447
|
+
// A local slot instead of a primitive-library one so every menu part exists in
|
|
448
|
+
// exactly one flavor-neutral copy: Radix's Slot would leak into the Base UI
|
|
449
|
+
// flavor, and Base UI's useRender the other way around. Supports both the
|
|
450
|
+
// library's `render={<Link/>}` convention and shadcn's `asChild`.
|
|
451
|
+
|
|
452
|
+
type SlotProps = {
|
|
453
|
+
className?: string
|
|
454
|
+
style?: CSSProperties
|
|
455
|
+
children?: ReactNode
|
|
456
|
+
} & Record<string, unknown>
|
|
457
|
+
|
|
458
|
+
function composeRefs<T>(...refs: (Ref<T> | undefined)[]): Ref<T> {
|
|
459
|
+
return (node: T | null) => {
|
|
460
|
+
for (const r of refs) {
|
|
461
|
+
if (typeof r === 'function') r(node)
|
|
462
|
+
else if (r) (r as React.MutableRefObject<T | null>).current = node
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Resolves the element to clone: `render` wins, else `asChild`'s single
|
|
468
|
+
* element child. `content` is what should render inside it — for `asChild`
|
|
469
|
+
* the child element's own children, otherwise the caller's. */
|
|
470
|
+
export function resolveSlotTemplate(
|
|
471
|
+
render: ReactElement | undefined,
|
|
472
|
+
asChild: boolean | undefined,
|
|
473
|
+
children: ReactNode,
|
|
474
|
+
): { template: ReactElement<SlotProps> | null; content: ReactNode } {
|
|
475
|
+
if (render && isValidElement(render)) {
|
|
476
|
+
return { template: render as ReactElement<SlotProps>, content: children }
|
|
477
|
+
}
|
|
478
|
+
if (asChild) {
|
|
479
|
+
const only = Children.toArray(children)[0]
|
|
480
|
+
if (isValidElement(only)) {
|
|
481
|
+
return {
|
|
482
|
+
template: only as ReactElement<SlotProps>,
|
|
483
|
+
content: (only.props as SlotProps).children,
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return { template: null, content: children }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Renders `content` into the template element (merging class/style/handlers,
|
|
491
|
+
* composing refs) or into the default tag when there is no template. */
|
|
492
|
+
export function slotElement(
|
|
493
|
+
template: ReactElement<SlotProps> | null,
|
|
494
|
+
DefaultTag: ElementType,
|
|
495
|
+
props: SlotProps & { ref?: Ref<HTMLElement> },
|
|
496
|
+
content: ReactNode,
|
|
497
|
+
): ReactElement {
|
|
498
|
+
if (!template) {
|
|
499
|
+
const Tag = DefaultTag as ElementType
|
|
500
|
+
return <Tag {...props}>{content}</Tag>
|
|
501
|
+
}
|
|
502
|
+
const templateProps = template.props
|
|
503
|
+
const merged: SlotProps & { ref?: Ref<HTMLElement> } = {
|
|
504
|
+
...props,
|
|
505
|
+
...templateProps,
|
|
506
|
+
className: cn(props.className, templateProps.className),
|
|
507
|
+
style: { ...props.style, ...(templateProps.style as CSSProperties | undefined) },
|
|
508
|
+
}
|
|
509
|
+
// Chain duplicated event handlers, template's first (it owns the element).
|
|
510
|
+
for (const key of Object.keys(props)) {
|
|
511
|
+
if (!/^on[A-Z]/.test(key)) continue
|
|
512
|
+
const ours = props[key]
|
|
513
|
+
const theirs = templateProps[key]
|
|
514
|
+
if (typeof ours === 'function' && typeof theirs === 'function') {
|
|
515
|
+
merged[key] = (...args: unknown[]) => {
|
|
516
|
+
;(theirs as (...a: unknown[]) => void)(...args)
|
|
517
|
+
;(ours as (...a: unknown[]) => void)(...args)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const templateRef =
|
|
522
|
+
(templateProps as { ref?: Ref<HTMLElement> }).ref ?? (template as unknown as { ref?: Ref<HTMLElement> }).ref
|
|
523
|
+
merged.ref = composeRefs(props.ref, templateRef)
|
|
524
|
+
return cloneElement(template, merged, content)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ─── SidebarShell (shared desktop DOM for both flavors) ──────────────────────
|
|
528
|
+
|
|
529
|
+
// Literal map so Tailwind's scanner emits the utilities: for the standard
|
|
530
|
+
// breakpoints the shell is also hidden by CSS, avoiding a pre-hydration flash
|
|
531
|
+
// of the rail on small screens. Non-standard breakpoints fall back to a
|
|
532
|
+
// JS-driven `hidden` in the shell (no dissolve, but never rail + drawer at
|
|
533
|
+
// once).
|
|
534
|
+
// Crossing the drawer breakpoint DISSOLVES the desktop rail instead of
|
|
535
|
+
// snapping it away: opacity fades while `display` rides the same transition
|
|
536
|
+
// with allow-discrete, so none applies only once the fade lands (and
|
|
537
|
+
// @starting-style fades it back in when the window grows). Literal classes
|
|
538
|
+
// per breakpoint — Tailwind's scanner can't see composed strings.
|
|
539
|
+
const BREAKPOINT_FADE_BASE =
|
|
540
|
+
'transition-[opacity,display] ease-out [transition-behavior:allow-discrete] motion-reduce:transition-none'
|
|
541
|
+
const BREAKPOINT_HIDDEN: Record<number, string> = {
|
|
542
|
+
640: 'max-sm:hidden max-sm:opacity-0 max-sm:duration-moderate sm:duration-slow sm:starting:opacity-0',
|
|
543
|
+
768: 'max-md:hidden max-md:opacity-0 max-md:duration-moderate md:duration-slow md:starting:opacity-0',
|
|
544
|
+
1024: 'max-lg:hidden max-lg:opacity-0 max-lg:duration-moderate lg:duration-slow lg:starting:opacity-0',
|
|
545
|
+
1280: 'max-xl:hidden max-xl:opacity-0 max-xl:duration-moderate xl:duration-slow xl:starting:opacity-0',
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Props framer-motion redefines with incompatible signatures; they must not
|
|
549
|
+
// be forwarded onto a motion.div.
|
|
550
|
+
type MotionSafeDivProps = Omit<
|
|
551
|
+
HTMLAttributes<HTMLDivElement>,
|
|
552
|
+
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart' | 'onAnimationEnd' | 'onAnimationIteration'
|
|
553
|
+
>
|
|
554
|
+
|
|
555
|
+
export interface SidebarShellProps extends MotionSafeDivProps {
|
|
556
|
+
side: SidebarSide
|
|
557
|
+
variant: SidebarVariant
|
|
558
|
+
/** The `sidebar` variant's inner-edge border. Default true. */
|
|
559
|
+
bordered?: boolean
|
|
560
|
+
/** Render the built-in resize/collapse rail handle. `false` hides it and
|
|
561
|
+
* disables drag-resize — the trigger and keyboard shortcut still toggle. */
|
|
562
|
+
rail?: boolean
|
|
563
|
+
/** Pin the rail's tooltip open (`true`) or closed (`false`); `undefined`
|
|
564
|
+
* leaves it on hover. Dragging always hides it. */
|
|
565
|
+
railTooltipOpen?: boolean
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Internal: the expanded/collapsed desktop rail. An in-flow sticky column
|
|
569
|
+
* animates its width (this is what reflows the inset) while the fixed-width
|
|
570
|
+
* panel inside slides out under overflow clipping — container-relative, so
|
|
571
|
+
* the whole sidebar works inside any bounded frame, not just the viewport.
|
|
572
|
+
* Ships the resize/collapse rail handle on its inner edge by default. */
|
|
573
|
+
const SidebarShell = forwardRef<HTMLDivElement, SidebarShellProps>(
|
|
574
|
+
({ side, variant, bordered = true, rail = true, railTooltipOpen, className, children, ...props }, ref) => {
|
|
575
|
+
const { open: kept, hoverOpen, width, mobileBreakpoint, isMobile, isResizing, shellRef } = useSidebar()
|
|
576
|
+
const open = kept || hoverOpen
|
|
577
|
+
const shape = useShape()
|
|
578
|
+
|
|
579
|
+
const substrate = useSurface()
|
|
580
|
+
const floatingLevel = Math.min(substrate + 1, 8)
|
|
581
|
+
// Drag-resize needs the panel glued to the pointer; the spring resumes
|
|
582
|
+
// for open/close. Reduced motion snaps instead of sliding — the state
|
|
583
|
+
// change stays legible without the 256px of travel. The open/close ride
|
|
584
|
+
// the SLOW tier: a whole column moving is the largest thing this
|
|
585
|
+
// component animates (the sheet stays on moderate — drawers
|
|
586
|
+
// settle precisely, per the tier notes).
|
|
587
|
+
const reduceMotion = useReducedMotion() ?? false
|
|
588
|
+
// Mid-drag open flips — the collapse preview and its drag-back rescue —
|
|
589
|
+
// ride the moderate tier instead of the drag's glued duration-0 tracking.
|
|
590
|
+
// The flip is detected synchronously (transition must be right on the
|
|
591
|
+
// very commit whose animate target changes; effects run too late), then
|
|
592
|
+
// `dragFlip` holds the spring through its settle so pointer moves landing
|
|
593
|
+
// right after a flip retarget the spring instead of snapping.
|
|
594
|
+
const [dragFlip, setDragFlip] = useState(false)
|
|
595
|
+
const prevOpenRef = useRef(open)
|
|
596
|
+
const openFlipped = prevOpenRef.current !== open
|
|
597
|
+
useEffect(() => {
|
|
598
|
+
const flipped = prevOpenRef.current !== open
|
|
599
|
+
prevOpenRef.current = open
|
|
600
|
+
if (!isResizing) {
|
|
601
|
+
setDragFlip(false)
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
if (!flipped) return
|
|
605
|
+
setDragFlip(true)
|
|
606
|
+
const id = setTimeout(() => setDragFlip(false), exitFallbackMs(spring.moderate))
|
|
607
|
+
return () => clearTimeout(id)
|
|
608
|
+
}, [open, isResizing])
|
|
609
|
+
const widthTransition = reduceMotion
|
|
610
|
+
? { duration: 0 }
|
|
611
|
+
: isResizing
|
|
612
|
+
? openFlipped || dragFlip
|
|
613
|
+
? open
|
|
614
|
+
? spring.moderate
|
|
615
|
+
: spring.moderate.exit
|
|
616
|
+
: { duration: 0 }
|
|
617
|
+
: open
|
|
618
|
+
? spring.slow
|
|
619
|
+
: spring.slow.exit
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<motion.div
|
|
623
|
+
ref={(node: HTMLDivElement | null) => {
|
|
624
|
+
shellRef.current = node
|
|
625
|
+
if (typeof ref === 'function') ref(node)
|
|
626
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
627
|
+
}}
|
|
628
|
+
data-slot="sidebar"
|
|
629
|
+
data-state={open ? 'expanded' : 'collapsed'}
|
|
630
|
+
data-collapsible={open ? '' : 'offcanvas'}
|
|
631
|
+
data-variant={variant}
|
|
632
|
+
data-side={side}
|
|
633
|
+
className={cn(
|
|
634
|
+
// No bare `group` here: an unnamed group on the whole rail would
|
|
635
|
+
// fire every descendant's group-hover (Button fills, icon strokes)
|
|
636
|
+
// on rail hover. Named groups (menu-item etc.) handle row states.
|
|
637
|
+
'peer shrink-0 sticky top-0 h-svh overflow-hidden',
|
|
638
|
+
// Flex order (not DOM order) decides the side, so consumers can
|
|
639
|
+
// keep Sidebar before SidebarInset regardless of `side`.
|
|
640
|
+
side === 'right' && 'order-last',
|
|
641
|
+
// The inset rail has no card edge of its own, so its scroll hairline
|
|
642
|
+
// hugs the rows' 8px gutter instead of running panel-wide.
|
|
643
|
+
variant === 'inset' && '[--scroll-divider-inset:8px]',
|
|
644
|
+
BREAKPOINT_FADE_BASE,
|
|
645
|
+
BREAKPOINT_HIDDEN[mobileBreakpoint],
|
|
646
|
+
// A non-standard breakpoint has no literal utility in the map, so
|
|
647
|
+
// JS hides the shell (no fade, but never rail + drawer at once).
|
|
648
|
+
!BREAKPOINT_HIDDEN[mobileBreakpoint] && isMobile && 'hidden',
|
|
649
|
+
className,
|
|
650
|
+
)}
|
|
651
|
+
initial={false}
|
|
652
|
+
animate={{ width: open ? width : '0rem' }}
|
|
653
|
+
transition={widthTransition}
|
|
654
|
+
{...props}
|
|
655
|
+
>
|
|
656
|
+
<motion.div
|
|
657
|
+
className={cn(
|
|
658
|
+
'absolute inset-y-0 flex h-full flex-col',
|
|
659
|
+
side === 'left' ? 'left-0' : 'right-0',
|
|
660
|
+
// Floating floats its card inside a full gutter; inset only needs
|
|
661
|
+
// the vertical inset (horizontal room belongs to the nav rows).
|
|
662
|
+
variant === 'floating' && 'p-2',
|
|
663
|
+
variant === 'inset' && 'py-2',
|
|
664
|
+
)}
|
|
665
|
+
style={{ width }}
|
|
666
|
+
initial={false}
|
|
667
|
+
animate={{ x: open ? '0%' : side === 'left' ? '-100%' : '100%' }}
|
|
668
|
+
transition={widthTransition}
|
|
669
|
+
// Not mid-drag: the rail inside holds the pointer through a collapse preview.
|
|
670
|
+
{...inertProps(!(open || isResizing))}
|
|
671
|
+
>
|
|
672
|
+
{variant === 'floating' ? (
|
|
673
|
+
<div
|
|
674
|
+
data-sidebar="sidebar"
|
|
675
|
+
className={cn('flex h-full w-full min-h-0 flex-col', shape.container, surfaceClasses(floatingLevel, 3))}
|
|
676
|
+
>
|
|
677
|
+
<SurfaceProvider value={floatingLevel}>{children}</SurfaceProvider>
|
|
678
|
+
</div>
|
|
679
|
+
) : (
|
|
680
|
+
<div
|
|
681
|
+
data-sidebar="sidebar"
|
|
682
|
+
className={cn(
|
|
683
|
+
'flex h-full w-full min-h-0 flex-col',
|
|
684
|
+
bordered &&
|
|
685
|
+
variant === 'sidebar' &&
|
|
686
|
+
(side === 'left' ? 'border-r border-border' : 'border-l border-border'),
|
|
687
|
+
)}
|
|
688
|
+
>
|
|
689
|
+
{children}
|
|
690
|
+
</div>
|
|
691
|
+
)}
|
|
692
|
+
{rail && (
|
|
693
|
+
<SidebarRail
|
|
694
|
+
tooltipOpen={railTooltipOpen}
|
|
695
|
+
className={cn(
|
|
696
|
+
// The floating card sits inside the panel's p-2 gutter, so the
|
|
697
|
+
// grab strip (and its hover hairline) moves in to straddle the
|
|
698
|
+
// card's edge instead of the panel's.
|
|
699
|
+
variant === 'floating' &&
|
|
700
|
+
(side === 'left' ? 'right-1 after:right-[3.5px]' : 'left-1 after:left-[3.5px]'),
|
|
701
|
+
// Cards are vertically inset and rounded — the hover hairline
|
|
702
|
+
// hugs the card's straight run: fully transparent through the
|
|
703
|
+
// corner radius, then fading in over 24px (mirrored at the
|
|
704
|
+
// bottom). The radius rides the shape system via CSS vars.
|
|
705
|
+
variant !== 'sidebar' &&
|
|
706
|
+
'after:inset-y-2 after:[mask-image:linear-gradient(to_bottom,transparent_var(--rail-fade-start),black_var(--rail-fade-end),black_calc(100%-var(--rail-fade-end)),transparent_calc(100%-var(--rail-fade-start)))]',
|
|
707
|
+
)}
|
|
708
|
+
style={
|
|
709
|
+
variant !== 'sidebar'
|
|
710
|
+
? ({
|
|
711
|
+
'--rail-fade-start': `${shape.bgRadius >= 20 ? 24 : 12}px`,
|
|
712
|
+
'--rail-fade-end': `${(shape.bgRadius >= 20 ? 24 : 12) + 24}px`,
|
|
713
|
+
} as CSSProperties)
|
|
714
|
+
: undefined
|
|
715
|
+
}
|
|
716
|
+
/>
|
|
717
|
+
)}
|
|
718
|
+
</motion.div>
|
|
719
|
+
</motion.div>
|
|
720
|
+
)
|
|
721
|
+
},
|
|
722
|
+
)
|
|
723
|
+
SidebarShell.displayName = 'SidebarShell'
|
|
724
|
+
|
|
725
|
+
// ─── SidebarTrigger ──────────────────────────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
export type SidebarTriggerProps = ButtonProps
|
|
728
|
+
|
|
729
|
+
/** Keystroke chip rendered inside the (inverted) tooltip surface. */
|
|
730
|
+
function ShortcutKbd({ children }: { children: ReactNode }) {
|
|
731
|
+
return (
|
|
732
|
+
<kbd className="-my-1 flex h-4 min-w-4 items-center justify-center rounded border border-background/30 px-1 font-sans text-micro-compact text-background/80">
|
|
733
|
+
{children}
|
|
734
|
+
</kbd>
|
|
735
|
+
)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** The tooltips always show the toggle keystroke, falling back to the
|
|
739
|
+
* side's default key even when the provider's binding is disabled. */
|
|
740
|
+
function useShortcutKey(): string {
|
|
741
|
+
const { side, shortcut } = useSidebar()
|
|
742
|
+
return shortcut ?? (side === 'right' ? SIDEBAR_KEYBOARD_SHORTCUT_RIGHT : SIDEBAR_KEYBOARD_SHORTCUT)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Ghost icon button calling toggleSidebar(). The icon mirrors the
|
|
746
|
+
* sidebar's side, and its tooltip names the action with the toggle
|
|
747
|
+
* keystroke by default. With `openOnHover` it switches between open and on
|
|
748
|
+
* hover (a dashed glyph), and the hover zone reaches its far side when it
|
|
749
|
+
* sits beside the sidebar's edge. */
|
|
750
|
+
const SidebarTrigger = forwardRef<HTMLButtonElement, SidebarTriggerProps>(
|
|
751
|
+
({ onClick, size, children, ...props }, ref) => {
|
|
752
|
+
const { toggleSidebar, open, openMobile, isMobile, side, openOnHover, registerTrigger } = useSidebar()
|
|
753
|
+
const shortcutKey = useShortcutKey()
|
|
754
|
+
const hoverMode = openOnHover && !isMobile
|
|
755
|
+
const TriggerIcon = useIcon(`panel-${side}${hoverMode && !open ? '-dashed' : ''}`)
|
|
756
|
+
const iconSize = useSizeVariant() === 'compact' ? ('icon-compact' as const) : ('icon' as const)
|
|
757
|
+
const collapsed = isMobile ? !openMobile : !open
|
|
758
|
+
const label = hoverMode
|
|
759
|
+
? open
|
|
760
|
+
? 'Show sidebar on hover'
|
|
761
|
+
: 'Keep sidebar open'
|
|
762
|
+
: collapsed
|
|
763
|
+
? 'Expand sidebar'
|
|
764
|
+
: 'Collapse sidebar'
|
|
765
|
+
const node = useRef<HTMLButtonElement | null>(null)
|
|
766
|
+
useEffect(() => (node.current ? registerTrigger(node.current) : undefined), [registerTrigger])
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<Tooltip
|
|
770
|
+
side="bottom"
|
|
771
|
+
content={
|
|
772
|
+
<span className="flex items-center gap-1.5">
|
|
773
|
+
{/* A flex row escapes the surface's text-box trim, so the label
|
|
774
|
+
re-applies it — otherwise the shortcut row would sit taller
|
|
775
|
+
than a tooltip without a chip. */}
|
|
776
|
+
<span className="[text-box:trim-both_cap_alphabetic]">{label}</span>
|
|
777
|
+
<ShortcutKbd>{shortcutKey}</ShortcutKbd>
|
|
778
|
+
</span>
|
|
779
|
+
}
|
|
780
|
+
>
|
|
781
|
+
<Button
|
|
782
|
+
ref={(element: HTMLButtonElement | null) => {
|
|
783
|
+
node.current = element
|
|
784
|
+
if (typeof ref === 'function') ref(element)
|
|
785
|
+
else if (ref) ref.current = element
|
|
786
|
+
}}
|
|
787
|
+
variant="ghost"
|
|
788
|
+
size={size ?? iconSize}
|
|
789
|
+
data-sidebar="trigger"
|
|
790
|
+
aria-label={hoverMode ? label : 'Toggle Sidebar'}
|
|
791
|
+
onClick={event => {
|
|
792
|
+
onClick?.(event)
|
|
793
|
+
toggleSidebar()
|
|
794
|
+
}}
|
|
795
|
+
{...props}
|
|
796
|
+
>
|
|
797
|
+
{children ?? <TriggerIcon />}
|
|
798
|
+
</Button>
|
|
799
|
+
</Tooltip>
|
|
800
|
+
)
|
|
801
|
+
},
|
|
802
|
+
)
|
|
803
|
+
SidebarTrigger.displayName = 'SidebarTrigger'
|
|
804
|
+
|
|
805
|
+
// ─── SidebarRail ─────────────────────────────────────────────────────────────
|
|
806
|
+
|
|
807
|
+
export interface SidebarRailProps extends HTMLAttributes<HTMLButtonElement> {
|
|
808
|
+
/** Pin the tooltip open/closed; `undefined` leaves it on hover. */
|
|
809
|
+
tooltipOpen?: boolean
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The grab strip on the sidebar's inner edge, rendered by default inside
|
|
813
|
+
* the desktop shell: drag it to resize (clamped), click it to collapse, and
|
|
814
|
+
* its tooltip explains both with the toggle keystroke. Hovering it
|
|
815
|
+
* brightens the edge border. */
|
|
816
|
+
const SidebarRail = forwardRef<HTMLButtonElement, SidebarRailProps>(({ className, tooltipOpen, ...props }, ref) => {
|
|
817
|
+
const { toggleSidebar, open, setOpen, setHoverOpen, setWidth, side, setIsResizing } = useSidebar()
|
|
818
|
+
const shortcutKey = useShortcutKey()
|
|
819
|
+
const railRef = useRef<HTMLButtonElement | null>(null)
|
|
820
|
+
const dragRef = useRef<{
|
|
821
|
+
startX: number
|
|
822
|
+
startWidth: number
|
|
823
|
+
moved: boolean
|
|
824
|
+
collapsed: boolean
|
|
825
|
+
/** Kept open at the press; open on hover, the rail collapses and drags the hover, never keeping it open. */
|
|
826
|
+
kept: boolean
|
|
827
|
+
} | null>(null)
|
|
828
|
+
const [dragging, setDragging] = useState(false)
|
|
829
|
+
|
|
830
|
+
const onPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
|
831
|
+
const panel = railRef.current?.closest('[data-slot="sidebar"]') as HTMLElement | null
|
|
832
|
+
if (!panel) return
|
|
833
|
+
dragRef.current = {
|
|
834
|
+
startX: event.clientX,
|
|
835
|
+
startWidth: panel.offsetWidth,
|
|
836
|
+
moved: false,
|
|
837
|
+
collapsed: false,
|
|
838
|
+
kept: open,
|
|
839
|
+
}
|
|
840
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const onPointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
|
|
844
|
+
const drag = dragRef.current
|
|
845
|
+
if (!drag) return
|
|
846
|
+
const dx = event.clientX - drag.startX
|
|
847
|
+
if (!drag.moved && Math.abs(dx) < 4) return
|
|
848
|
+
if (!drag.moved) {
|
|
849
|
+
drag.moved = true
|
|
850
|
+
setDragging(true)
|
|
851
|
+
setIsResizing(true)
|
|
852
|
+
}
|
|
853
|
+
const delta = side === 'left' ? dx : -dx
|
|
854
|
+
const raw = drag.startWidth + delta
|
|
855
|
+
// Dragged well past the minimum, toward the edge: preview the collapse
|
|
856
|
+
// but keep the drag session alive — pulling back past the threshold
|
|
857
|
+
// re-expands, so an overshoot isn't committed until release.
|
|
858
|
+
if (raw < SIDEBAR_MIN_WIDTH - SIDEBAR_COLLAPSE_SLOP) {
|
|
859
|
+
if (!drag.collapsed) {
|
|
860
|
+
drag.collapsed = true
|
|
861
|
+
setWidth(`${SIDEBAR_MIN_WIDTH}px`)
|
|
862
|
+
;(drag.kept ? setOpen : setHoverOpen)(false)
|
|
863
|
+
}
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
if (drag.collapsed) {
|
|
867
|
+
drag.collapsed = false
|
|
868
|
+
;(drag.kept ? setOpen : setHoverOpen)(true)
|
|
869
|
+
}
|
|
870
|
+
const next = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, raw))
|
|
871
|
+
setWidth(`${next}px`)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const onPointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
|
|
875
|
+
const drag = dragRef.current
|
|
876
|
+
dragRef.current = null
|
|
877
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
878
|
+
setDragging(false)
|
|
879
|
+
setIsResizing(false)
|
|
880
|
+
// A press that never turned into a drag is the collapse click.
|
|
881
|
+
if (drag && !drag.moved) {
|
|
882
|
+
if (drag.kept) toggleSidebar()
|
|
883
|
+
else setHoverOpen(false)
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// A cancelled pointer (touch interruption, capture loss) ends the drag
|
|
888
|
+
// where it stands — no collapse-click, capture is already released.
|
|
889
|
+
const onPointerCancel = () => {
|
|
890
|
+
dragRef.current = null
|
|
891
|
+
setDragging(false)
|
|
892
|
+
setIsResizing(false)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return (
|
|
896
|
+
<Tooltip
|
|
897
|
+
side={side === 'left' ? 'right' : 'left'}
|
|
898
|
+
sideOffset={8}
|
|
899
|
+
followCursor="y"
|
|
900
|
+
forceOpen={dragging ? false : tooltipOpen}
|
|
901
|
+
content={
|
|
902
|
+
// The flex column escapes the tooltip surface's text-box trim, so
|
|
903
|
+
// BOTH lines re-apply it and the gap alone carries the line rhythm
|
|
904
|
+
// — an untrimmed line would smuggle its half-leading back in as
|
|
905
|
+
// lopsided padding.
|
|
906
|
+
<span className="flex flex-col items-start gap-2">
|
|
907
|
+
<span className="[text-box:trim-both_cap_alphabetic]">
|
|
908
|
+
<span className="weight-semibold">Drag</span> to resize
|
|
909
|
+
</span>
|
|
910
|
+
<span className="flex items-center gap-1.5">
|
|
911
|
+
<span className="[text-box:trim-both_cap_alphabetic]">
|
|
912
|
+
<span className="weight-semibold">Click</span> to collapse
|
|
913
|
+
</span>
|
|
914
|
+
<ShortcutKbd>{shortcutKey}</ShortcutKbd>
|
|
915
|
+
</span>
|
|
916
|
+
</span>
|
|
917
|
+
}
|
|
918
|
+
>
|
|
919
|
+
<button
|
|
920
|
+
ref={node => {
|
|
921
|
+
railRef.current = node
|
|
922
|
+
if (typeof ref === 'function') ref(node)
|
|
923
|
+
else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node
|
|
924
|
+
}}
|
|
925
|
+
type="button"
|
|
926
|
+
data-sidebar="rail"
|
|
927
|
+
aria-label="Resize or collapse sidebar"
|
|
928
|
+
tabIndex={-1}
|
|
929
|
+
onPointerDown={onPointerDown}
|
|
930
|
+
onPointerMove={onPointerMove}
|
|
931
|
+
onPointerUp={onPointerUp}
|
|
932
|
+
onPointerCancel={onPointerCancel}
|
|
933
|
+
className={cn(
|
|
934
|
+
// touch-none: without it the browser claims a touch drag for
|
|
935
|
+
// scrolling and pointercancels the resize mid-gesture.
|
|
936
|
+
'absolute inset-y-0 z-20 w-2 cursor-col-resize touch-none outline-none',
|
|
937
|
+
// Positioned from context (not group-data selectors) so variant
|
|
938
|
+
// offsets passed via className can win the merge.
|
|
939
|
+
side === 'left' ? 'right-0' : 'left-0',
|
|
940
|
+
// Hovering brightens the edge border the shell draws by default;
|
|
941
|
+
// a pinned tooltip brightens it too, so the spotlight reads as
|
|
942
|
+
// the hover it stands in for.
|
|
943
|
+
'after:absolute after:inset-y-0 after:w-px after:bg-transparent hover:after:bg-foreground/25 after:transition-colors after:duration-fast',
|
|
944
|
+
tooltipOpen && 'after:bg-foreground/25',
|
|
945
|
+
side === 'left' ? 'after:right-0' : 'after:left-0',
|
|
946
|
+
className,
|
|
947
|
+
)}
|
|
948
|
+
{...props}
|
|
949
|
+
/>
|
|
950
|
+
</Tooltip>
|
|
951
|
+
)
|
|
952
|
+
})
|
|
953
|
+
SidebarRail.displayName = 'SidebarRail'
|
|
954
|
+
|
|
955
|
+
// ─── SidebarInset ────────────────────────────────────────────────────────────
|
|
956
|
+
|
|
957
|
+
export type SidebarInsetProps = HTMLAttributes<HTMLElement>
|
|
958
|
+
|
|
959
|
+
const SidebarInset = forwardRef<HTMLElement, SidebarInsetProps>(({ className, ...props }, ref) => {
|
|
960
|
+
const shape = useShape()
|
|
961
|
+
return (
|
|
962
|
+
<main
|
|
963
|
+
ref={ref}
|
|
964
|
+
data-slot="sidebar-inset"
|
|
965
|
+
className={cn(
|
|
966
|
+
'relative flex min-h-0 w-full min-w-0 flex-1 flex-col bg-background',
|
|
967
|
+
'peer-data-[variant=inset]:m-2 peer-data-[variant=inset]:peer-data-[side=left]:ml-0 peer-data-[variant=inset]:peer-data-[side=right]:mr-0',
|
|
968
|
+
// With the rail collapsed away, restore the sidebar-side margin so
|
|
969
|
+
// the card keeps symmetric insets.
|
|
970
|
+
'peer-data-[variant=inset]:peer-data-[state=collapsed]:peer-data-[side=left]:ml-2 peer-data-[variant=inset]:peer-data-[state=collapsed]:peer-data-[side=right]:mr-2',
|
|
971
|
+
'transition-[margin] duration-fast',
|
|
972
|
+
// Container radius follows the shape system (literal classes so
|
|
973
|
+
// Tailwind's scanner emits both).
|
|
974
|
+
shape.bgRadius >= 20 ? 'peer-data-[variant=inset]:rounded-3xl' : 'peer-data-[variant=inset]:rounded-xl',
|
|
975
|
+
'peer-data-[variant=inset]:bg-surface-2 peer-data-[variant=inset]:shadow-surface-2',
|
|
976
|
+
className,
|
|
977
|
+
)}
|
|
978
|
+
{...props}
|
|
979
|
+
/>
|
|
980
|
+
)
|
|
981
|
+
})
|
|
982
|
+
SidebarInset.displayName = 'SidebarInset'
|
|
983
|
+
|
|
984
|
+
// ─── SidebarInput ────────────────────────────────────────────────────────────
|
|
985
|
+
|
|
986
|
+
export type SidebarInputProps = React.InputHTMLAttributes<HTMLInputElement>
|
|
987
|
+
|
|
988
|
+
const SidebarInput = forwardRef<HTMLInputElement, SidebarInputProps>(({ className, ...props }, ref) => {
|
|
989
|
+
const shape = useShape()
|
|
990
|
+
const size = useSize()
|
|
991
|
+
return (
|
|
992
|
+
<input
|
|
993
|
+
ref={ref}
|
|
994
|
+
data-sidebar="input"
|
|
995
|
+
className={cn(
|
|
996
|
+
fieldVariants(),
|
|
997
|
+
'w-full px-3 text-foreground placeholder:text-muted-foreground outline-none',
|
|
998
|
+
'focus-visible:ring-focus-ring',
|
|
999
|
+
size.variant === 'compact' ? 'h-7' : 'h-8',
|
|
1000
|
+
size.text,
|
|
1001
|
+
shape.input,
|
|
1002
|
+
className,
|
|
1003
|
+
)}
|
|
1004
|
+
{...props}
|
|
1005
|
+
/>
|
|
1006
|
+
)
|
|
1007
|
+
})
|
|
1008
|
+
SidebarInput.displayName = 'SidebarInput'
|
|
1009
|
+
|
|
1010
|
+
// ─── SidebarHeader / SidebarFooter / SidebarSeparator ────────────────────────
|
|
1011
|
+
|
|
1012
|
+
export type SidebarSectionProps = HTMLAttributes<HTMLDivElement>
|
|
1013
|
+
|
|
1014
|
+
const SidebarHeader = forwardRef<HTMLDivElement, SidebarSectionProps>(({ className, ...props }, ref) => (
|
|
1015
|
+
<div ref={ref} data-sidebar="header" className={cn('flex shrink-0 flex-col gap-2 p-2', className)} {...props} />
|
|
1016
|
+
))
|
|
1017
|
+
SidebarHeader.displayName = 'SidebarHeader'
|
|
1018
|
+
|
|
1019
|
+
const SidebarFooter = forwardRef<HTMLDivElement, SidebarSectionProps>(({ className, ...props }, ref) => (
|
|
1020
|
+
<div
|
|
1021
|
+
ref={ref}
|
|
1022
|
+
data-sidebar="footer"
|
|
1023
|
+
className={cn('mt-auto flex shrink-0 flex-col gap-2 p-2', className)}
|
|
1024
|
+
{...props}
|
|
1025
|
+
/>
|
|
1026
|
+
))
|
|
1027
|
+
SidebarFooter.displayName = 'SidebarFooter'
|
|
1028
|
+
|
|
1029
|
+
const SidebarSeparator = forwardRef<HTMLDivElement, SidebarSectionProps>(({ className, ...props }, ref) => (
|
|
1030
|
+
<div
|
|
1031
|
+
ref={ref}
|
|
1032
|
+
data-sidebar="separator"
|
|
1033
|
+
role="separator"
|
|
1034
|
+
aria-orientation="horizontal"
|
|
1035
|
+
className={cn('mx-2 h-px shrink-0 bg-border', className)}
|
|
1036
|
+
{...props}
|
|
1037
|
+
/>
|
|
1038
|
+
))
|
|
1039
|
+
SidebarSeparator.displayName = 'SidebarSeparator'
|
|
1040
|
+
|
|
1041
|
+
// ─── SidebarGroup family ─────────────────────────────────────────────────────
|
|
1042
|
+
|
|
1043
|
+
// SSR-safe layout effect (client components still server-render in Next).
|
|
1044
|
+
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
|
|
1045
|
+
|
|
1046
|
+
interface SidebarGroupContextValue {
|
|
1047
|
+
open: boolean
|
|
1048
|
+
toggle: () => void
|
|
1049
|
+
contentId: string
|
|
1050
|
+
/** How many header action buttons overlay the label's right edge — the
|
|
1051
|
+
* collapsible label pads itself so its chevron clears them. */
|
|
1052
|
+
actionsCount: number
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const SidebarGroupContext = createContext<SidebarGroupContextValue | null>(null)
|
|
1056
|
+
|
|
1057
|
+
export interface SidebarGroupProps extends SidebarSectionProps {
|
|
1058
|
+
/** Makes the group's SidebarGroupLabel a toggle that collapses everything
|
|
1059
|
+
* rendered after it — a group-level accordion. Uncontrolled by default;
|
|
1060
|
+
* pass `open`/`onOpenChange` to control it. */
|
|
1061
|
+
collapsible?: boolean
|
|
1062
|
+
open?: boolean
|
|
1063
|
+
defaultOpen?: boolean
|
|
1064
|
+
onOpenChange?: (open: boolean) => void
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const SidebarGroup = forwardRef<HTMLDivElement, SidebarGroupProps>(
|
|
1068
|
+
({ className, collapsible = false, open: openProp, defaultOpen = true, onOpenChange, children, ...props }, ref) => {
|
|
1069
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
|
1070
|
+
const open = openProp ?? uncontrolledOpen
|
|
1071
|
+
const contentId = useId()
|
|
1072
|
+
const toggle = useCallback(() => {
|
|
1073
|
+
const next = !(openProp ?? uncontrolledOpen)
|
|
1074
|
+
setUncontrolledOpen(next)
|
|
1075
|
+
onOpenChange?.(next)
|
|
1076
|
+
}, [openProp, uncontrolledOpen, onOpenChange])
|
|
1077
|
+
// Measured-height collapse: animate between 0 and the content's real
|
|
1078
|
+
// offsetHeight — never to "auto", which framer measures wrong under a
|
|
1079
|
+
// scaled ancestor.
|
|
1080
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
1081
|
+
const [contentHeight, setContentHeight] = useState<number | null>(null)
|
|
1082
|
+
useIsoLayoutEffect(() => {
|
|
1083
|
+
if (!collapsible) return
|
|
1084
|
+
const el = contentRef.current
|
|
1085
|
+
if (!el || typeof ResizeObserver === 'undefined') return
|
|
1086
|
+
const measure = () => setContentHeight(el.offsetHeight)
|
|
1087
|
+
measure()
|
|
1088
|
+
const ro = new ResizeObserver(measure)
|
|
1089
|
+
ro.observe(el)
|
|
1090
|
+
return () => ro.disconnect()
|
|
1091
|
+
}, [collapsible])
|
|
1092
|
+
const measured = contentHeight !== null
|
|
1093
|
+
// The collapse wrapper must clip while animating, but a permanently
|
|
1094
|
+
// clipped box shaves the 2px focus ring off a group's first and last
|
|
1095
|
+
// rows — so clipping lifts once an open group has settled.
|
|
1096
|
+
const [settled, setSettled] = useState(open)
|
|
1097
|
+
useEffect(() => {
|
|
1098
|
+
if (!open) setSettled(false)
|
|
1099
|
+
}, [open])
|
|
1100
|
+
|
|
1101
|
+
// Height animates only when THIS group toggles. When the measured height
|
|
1102
|
+
// changes underneath it instead — a nested sub-menu collapsing inside the
|
|
1103
|
+
// group — the wrapper must snap: a spring re-targeted every frame chases
|
|
1104
|
+
// the child's own animation, lands well after it, and drags everything
|
|
1105
|
+
// below the group along late. Tracked with a ref so a controlled `open`
|
|
1106
|
+
// is covered too, and cleared once the toggle's animation lands.
|
|
1107
|
+
const prevOpenRef = useRef(open)
|
|
1108
|
+
const togglingRef = useRef(false)
|
|
1109
|
+
if (prevOpenRef.current !== open) {
|
|
1110
|
+
prevOpenRef.current = open
|
|
1111
|
+
togglingRef.current = true
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// The label and any header actions stay put; everything else after the
|
|
1115
|
+
// label rides in the collapse wrapper. If no SidebarGroupLabel child is
|
|
1116
|
+
// found the group renders untouched.
|
|
1117
|
+
const isHeaderAction = (k: ReactNode) =>
|
|
1118
|
+
isValidElement(k) && (k.type === SidebarGroupAction || k.type === SidebarGroupActions)
|
|
1119
|
+
let inner: ReactNode = children
|
|
1120
|
+
let actionsCount = 0
|
|
1121
|
+
if (collapsible) {
|
|
1122
|
+
const kids = Children.toArray(children)
|
|
1123
|
+
const labelIdx = kids.findIndex(k => isValidElement(k) && k.type === SidebarGroupLabel)
|
|
1124
|
+
if (labelIdx !== -1) {
|
|
1125
|
+
const tail = kids.slice(labelIdx + 1)
|
|
1126
|
+
const headerActions = tail.filter(isHeaderAction)
|
|
1127
|
+
const rest = tail.filter(k => !isHeaderAction(k))
|
|
1128
|
+
actionsCount = headerActions.reduce<number>(
|
|
1129
|
+
(n, k) =>
|
|
1130
|
+
n +
|
|
1131
|
+
(isValidElement(k) && k.type === SidebarGroupActions
|
|
1132
|
+
? Children.count((k.props as { children?: ReactNode }).children)
|
|
1133
|
+
: 1),
|
|
1134
|
+
0,
|
|
1135
|
+
)
|
|
1136
|
+
inner = (
|
|
1137
|
+
<>
|
|
1138
|
+
{kids.slice(0, labelIdx)}
|
|
1139
|
+
{/* Header hover scope: hovering anywhere on the row — the
|
|
1140
|
+
overlaid action cluster included, which never :hovers the
|
|
1141
|
+
label element itself — reveals the label's chevron. Kept
|
|
1142
|
+
position-static so the cluster's absolute box still anchors
|
|
1143
|
+
to the group and stays on the rows' action axis. */}
|
|
1144
|
+
<div className="group/group-header w-full">
|
|
1145
|
+
{kids[labelIdx]}
|
|
1146
|
+
{headerActions}
|
|
1147
|
+
</div>
|
|
1148
|
+
<motion.div
|
|
1149
|
+
id={contentId}
|
|
1150
|
+
aria-hidden={open ? undefined : true}
|
|
1151
|
+
className={cn(open && settled ? 'overflow-visible' : 'overflow-hidden', !measured && !open && 'h-0')}
|
|
1152
|
+
initial={false}
|
|
1153
|
+
animate={
|
|
1154
|
+
measured ? { height: open ? contentHeight : 0, opacity: open ? 1 : 0 } : { opacity: open ? 1 : 0 }
|
|
1155
|
+
}
|
|
1156
|
+
// Do NOT simplify this to `open ? spring.moderate : …`. The
|
|
1157
|
+
// togglingRef arm is what stops a re-measure from springing —
|
|
1158
|
+
// without it a nested collapse makes this wrapper chase its own
|
|
1159
|
+
// child and everything below the group moves late.
|
|
1160
|
+
transition={togglingRef.current ? (open ? spring.moderate : spring.moderate.exit) : { duration: 0 }}
|
|
1161
|
+
onAnimationComplete={() => {
|
|
1162
|
+
togglingRef.current = false
|
|
1163
|
+
if (open) setSettled(true)
|
|
1164
|
+
}}
|
|
1165
|
+
>
|
|
1166
|
+
<div ref={contentRef} className="flex w-full min-w-0 flex-col" {...inertProps(!open)}>
|
|
1167
|
+
{rest}
|
|
1168
|
+
</div>
|
|
1169
|
+
</motion.div>
|
|
1170
|
+
</>
|
|
1171
|
+
)
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const ctx = useMemo(() => ({ open, toggle, contentId, actionsCount }), [open, toggle, contentId, actionsCount])
|
|
1176
|
+
|
|
1177
|
+
return (
|
|
1178
|
+
<div
|
|
1179
|
+
ref={ref}
|
|
1180
|
+
data-sidebar="group"
|
|
1181
|
+
data-state={collapsible ? (open ? 'open' : 'closed') : undefined}
|
|
1182
|
+
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
|
1183
|
+
{...props}
|
|
1184
|
+
>
|
|
1185
|
+
<SidebarGroupContext.Provider value={collapsible ? ctx : null}>{inner}</SidebarGroupContext.Provider>
|
|
1186
|
+
</div>
|
|
1187
|
+
)
|
|
1188
|
+
},
|
|
1189
|
+
)
|
|
1190
|
+
SidebarGroup.displayName = 'SidebarGroup'
|
|
1191
|
+
|
|
1192
|
+
export interface SidebarGroupLabelProps extends HTMLAttributes<HTMLDivElement> {
|
|
1193
|
+
render?: ReactElement
|
|
1194
|
+
asChild?: boolean
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const SidebarGroupLabel = forwardRef<HTMLDivElement, SidebarGroupLabelProps>(
|
|
1198
|
+
({ className, render, asChild, children, ...props }, ref) => {
|
|
1199
|
+
const sizeVariant = useSizeVariant()
|
|
1200
|
+
const sizeClasses = useSize()
|
|
1201
|
+
const group = useContext(SidebarGroupContext)
|
|
1202
|
+
const shape = useShape()
|
|
1203
|
+
const ChevronRightIcon = useIcon('chevron-right')
|
|
1204
|
+
const { template, content } = resolveSlotTemplate(render, asChild, children)
|
|
1205
|
+
|
|
1206
|
+
// Truncate only the leading text; element children (count badges,
|
|
1207
|
+
// trailing controls) stay flex siblings so the row's gap keeps spacing
|
|
1208
|
+
// them — same split MenuRowLabel does for menu rows.
|
|
1209
|
+
const nodes = Children.toArray(content)
|
|
1210
|
+
let textEnd = 0
|
|
1211
|
+
while (textEnd < nodes.length && (typeof nodes[textEnd] === 'string' || typeof nodes[textEnd] === 'number')) {
|
|
1212
|
+
textEnd++
|
|
1213
|
+
}
|
|
1214
|
+
const leadingText = nodes.slice(0, textEnd).join('')
|
|
1215
|
+
const labelContent = leadingText ? (
|
|
1216
|
+
<>
|
|
1217
|
+
<span className="min-w-0 truncate">{leadingText}</span>
|
|
1218
|
+
{nodes.slice(textEnd)}
|
|
1219
|
+
</>
|
|
1220
|
+
) : (
|
|
1221
|
+
content
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
// Inside a collapsible group the label becomes the toggle. Design
|
|
1225
|
+
// treatment is unchanged — hover only raises the label's contrast and
|
|
1226
|
+
// reveals a chevron (kept visible while collapsed as the reopen cue).
|
|
1227
|
+
if (group) {
|
|
1228
|
+
return slotElement(
|
|
1229
|
+
template,
|
|
1230
|
+
'button',
|
|
1231
|
+
{
|
|
1232
|
+
ref: ref as Ref<HTMLElement>,
|
|
1233
|
+
type: template ? undefined : 'button',
|
|
1234
|
+
'data-sidebar': 'group-label',
|
|
1235
|
+
'aria-expanded': group.open,
|
|
1236
|
+
'aria-controls': group.contentId,
|
|
1237
|
+
onClick: group.toggle,
|
|
1238
|
+
// The action cluster overlays the label's right edge, so the label
|
|
1239
|
+
// pads past it — far enough that the hover-revealed chevron lands
|
|
1240
|
+
// one cluster gap (4px) to its left and the whole trailing run
|
|
1241
|
+
// keeps a single rhythm. Cluster width is 24px per action plus 4px
|
|
1242
|
+
// between them; add that gap again, less the 8px the group's
|
|
1243
|
+
// padding already gives back: 28n + 6. The cluster is always
|
|
1244
|
+
// visible, so the reservation is permanent.
|
|
1245
|
+
style:
|
|
1246
|
+
group.actionsCount > 0
|
|
1247
|
+
? ({ '--group-actions-pad': `${group.actionsCount * 28 + 6}px` } as CSSProperties)
|
|
1248
|
+
: undefined,
|
|
1249
|
+
className: cn(
|
|
1250
|
+
'flex h-8 w-full shrink-0 cursor-pointer select-none items-center gap-2 px-2 text-left text-muted-foreground/70 outline-none',
|
|
1251
|
+
'transition-colors duration-fast hover:text-muted-foreground',
|
|
1252
|
+
group.actionsCount > 0 && 'pr-[var(--group-actions-pad)]',
|
|
1253
|
+
'focus-visible:ring-1 focus-visible:ring-focus-ring',
|
|
1254
|
+
shape.item,
|
|
1255
|
+
sizeVariant === 'compact' ? 'text-caption-compact' : 'text-caption',
|
|
1256
|
+
className,
|
|
1257
|
+
),
|
|
1258
|
+
...props,
|
|
1259
|
+
},
|
|
1260
|
+
<>
|
|
1261
|
+
{labelContent}
|
|
1262
|
+
{/* The chevron occupies an action-sized box, so it reads as one more
|
|
1263
|
+
icon in the row rather than a smaller glyph tacked on the end.
|
|
1264
|
+
One chevron-right glyph for both states, sprung 90° to point
|
|
1265
|
+
down while the group is open — the motion wrapper is what
|
|
1266
|
+
animates: Tailwind's rotate-* sets the standalone CSS `rotate`
|
|
1267
|
+
property, which transition-transform never covers. While open
|
|
1268
|
+
the whole box collapses to zero width at rest so the label text
|
|
1269
|
+
keeps the full row; hover/focus (or an open action popup)
|
|
1270
|
+
reveals it. Collapsed keeps it visible as the reopen cue. */}
|
|
1271
|
+
{/* No width/opacity transition: animating the box's width slides
|
|
1272
|
+
the glyph in from the side — the chevron should simply be
|
|
1273
|
+
there once the header is hovered. */}
|
|
1274
|
+
<span
|
|
1275
|
+
className={cn(
|
|
1276
|
+
'ml-auto flex h-6 shrink-0 items-center justify-center overflow-hidden',
|
|
1277
|
+
group.open
|
|
1278
|
+
? 'w-0 opacity-0 group-hover/group-header:w-6 group-hover/group-header:opacity-100 group-focus-within/group-header:w-6 group-focus-within/group-header:opacity-100 group-has-[[data-sidebar=group-action]:is([data-state=open],[data-popup-open],[aria-expanded=true])]/group-header:w-6 group-has-[[data-sidebar=group-action]:is([data-state=open],[data-popup-open],[aria-expanded=true])]/group-header:opacity-100 pointer-coarse:w-6 pointer-coarse:opacity-100'
|
|
1279
|
+
: 'w-6 opacity-100',
|
|
1280
|
+
)}
|
|
1281
|
+
>
|
|
1282
|
+
<motion.span className="inline-flex" animate={{ rotate: group.open ? 90 : 0 }} transition={spring.fast}>
|
|
1283
|
+
<ChevronRightIcon size={sizeClasses.icon} strokeWidth={1.5} className="shrink-0" />
|
|
1284
|
+
</motion.span>
|
|
1285
|
+
</span>
|
|
1286
|
+
</>,
|
|
1287
|
+
)
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
return slotElement(
|
|
1291
|
+
template,
|
|
1292
|
+
'div',
|
|
1293
|
+
{
|
|
1294
|
+
ref: ref as Ref<HTMLElement>,
|
|
1295
|
+
'data-sidebar': 'group-label',
|
|
1296
|
+
className: cn(
|
|
1297
|
+
'flex h-8 shrink-0 items-center gap-2 px-2 text-muted-foreground/70 outline-none',
|
|
1298
|
+
sizeVariant === 'compact' ? 'text-caption-compact' : 'text-caption',
|
|
1299
|
+
className,
|
|
1300
|
+
),
|
|
1301
|
+
...props,
|
|
1302
|
+
},
|
|
1303
|
+
labelContent,
|
|
1304
|
+
)
|
|
1305
|
+
},
|
|
1306
|
+
)
|
|
1307
|
+
SidebarGroupLabel.displayName = 'SidebarGroupLabel'
|
|
1308
|
+
|
|
1309
|
+
export interface SidebarGroupActionProps extends HTMLAttributes<HTMLButtonElement> {
|
|
1310
|
+
render?: ReactElement
|
|
1311
|
+
asChild?: boolean
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** True while rendering inside a SidebarGroupActions cluster, where each
|
|
1315
|
+
* action sits in the flex row instead of positioning itself absolutely. */
|
|
1316
|
+
const GroupActionsContext = createContext(false)
|
|
1317
|
+
|
|
1318
|
+
const SidebarGroupAction = forwardRef<HTMLButtonElement, SidebarGroupActionProps>(
|
|
1319
|
+
({ className, render, asChild, children, ...props }, ref) => {
|
|
1320
|
+
const shape = useShape()
|
|
1321
|
+
const sizeClasses = useSize()
|
|
1322
|
+
const inCluster = useContext(GroupActionsContext)
|
|
1323
|
+
const { template, content } = resolveSlotTemplate(render, asChild, children)
|
|
1324
|
+
return slotElement(
|
|
1325
|
+
template,
|
|
1326
|
+
'button',
|
|
1327
|
+
{
|
|
1328
|
+
ref: ref as Ref<HTMLElement>,
|
|
1329
|
+
type: template ? undefined : 'button',
|
|
1330
|
+
'data-sidebar': 'group-action',
|
|
1331
|
+
className: cn(
|
|
1332
|
+
inCluster
|
|
1333
|
+
? 'relative flex size-6 items-center justify-center text-muted-foreground outline-none'
|
|
1334
|
+
: // size-6 matches the rows' action hit-box, and right-3.5 puts that
|
|
1335
|
+
// 24px box's centre 26px from the sidebar's inner edge — the axis
|
|
1336
|
+
// the rows' badges and actions already sit on.
|
|
1337
|
+
'absolute right-3.5 top-3 flex size-6 items-center justify-center text-muted-foreground outline-none',
|
|
1338
|
+
'hover:bg-hover hover:text-foreground transition-colors duration-fast',
|
|
1339
|
+
'focus-visible:ring-1 focus-visible:ring-focus-ring',
|
|
1340
|
+
// Normalize bare icons to the site's 1.5 stroke (library defaults
|
|
1341
|
+
// vary), thickening to 2 on hover — the same treatment Button's
|
|
1342
|
+
// icon-only span applies.
|
|
1343
|
+
'[&_svg]:size-[var(--icon-size)] [&_svg]:shrink-0 [&_svg]:stroke-[1.5] [&_svg]:transition-[stroke-width] [&_svg]:duration-fast hover:[&_svg]:stroke-[2]',
|
|
1344
|
+
shape.item,
|
|
1345
|
+
className,
|
|
1346
|
+
),
|
|
1347
|
+
...props,
|
|
1348
|
+
// After ...props: the spread would otherwise replace this object
|
|
1349
|
+
// wholesale and drop the icon-size the glyph is sized from.
|
|
1350
|
+
style: {
|
|
1351
|
+
...({ '--icon-size': `${sizeClasses.icon}px` } as CSSProperties),
|
|
1352
|
+
...(props.style ?? {}),
|
|
1353
|
+
},
|
|
1354
|
+
},
|
|
1355
|
+
content,
|
|
1356
|
+
)
|
|
1357
|
+
},
|
|
1358
|
+
)
|
|
1359
|
+
SidebarGroupAction.displayName = 'SidebarGroupAction'
|
|
1360
|
+
|
|
1361
|
+
/** Header action cluster: 1–3 SidebarGroupActions laid out in a row over the
|
|
1362
|
+
* group label's right edge. Use instead of a lone SidebarGroupAction when a
|
|
1363
|
+
* section needs several controls. */
|
|
1364
|
+
export type SidebarGroupActionsProps = HTMLAttributes<HTMLDivElement>
|
|
1365
|
+
|
|
1366
|
+
const SidebarGroupActions = forwardRef<HTMLDivElement, SidebarGroupActionsProps>(
|
|
1367
|
+
({ className, children, ...props }, ref) => {
|
|
1368
|
+
return (
|
|
1369
|
+
<div
|
|
1370
|
+
ref={ref}
|
|
1371
|
+
data-sidebar="group-actions"
|
|
1372
|
+
className={cn(
|
|
1373
|
+
// right-3.5 lands the last 24px action's centre 26px from the
|
|
1374
|
+
// sidebar's inner edge — the rows' badge/action axis, so the header's
|
|
1375
|
+
// controls line up with the column below them.
|
|
1376
|
+
'absolute right-3.5 top-2 z-10 flex h-8 items-center gap-1',
|
|
1377
|
+
className,
|
|
1378
|
+
)}
|
|
1379
|
+
{...props}
|
|
1380
|
+
>
|
|
1381
|
+
<GroupActionsContext.Provider value={true}>{children}</GroupActionsContext.Provider>
|
|
1382
|
+
</div>
|
|
1383
|
+
)
|
|
1384
|
+
},
|
|
1385
|
+
)
|
|
1386
|
+
SidebarGroupActions.displayName = 'SidebarGroupActions'
|
|
1387
|
+
|
|
1388
|
+
const SidebarGroupContent = forwardRef<HTMLDivElement, SidebarSectionProps>(({ className, ...props }, ref) => (
|
|
1389
|
+
<div ref={ref} data-sidebar="group-content" className={cn('w-full', className)} {...props} />
|
|
1390
|
+
))
|
|
1391
|
+
SidebarGroupContent.displayName = 'SidebarGroupContent'
|
|
1392
|
+
|
|
1393
|
+
export {
|
|
1394
|
+
SidebarFooter,
|
|
1395
|
+
SidebarGroup,
|
|
1396
|
+
SidebarGroupAction,
|
|
1397
|
+
SidebarGroupActions,
|
|
1398
|
+
SidebarGroupContent,
|
|
1399
|
+
SidebarGroupLabel,
|
|
1400
|
+
SidebarHeader,
|
|
1401
|
+
SidebarInput,
|
|
1402
|
+
SidebarInset,
|
|
1403
|
+
SidebarProvider,
|
|
1404
|
+
SidebarRail,
|
|
1405
|
+
SidebarSeparator,
|
|
1406
|
+
SidebarShell,
|
|
1407
|
+
SidebarTrigger,
|
|
1408
|
+
}
|