├── demo-components ├── styles │ ├── FlexDemo.scss │ ├── HideDemo.scss │ ├── PreventOverflow.scss │ ├── FrameworkSupport.scss │ ├── CustomDividerDemo.scss │ ├── Section.scss │ ├── Checkbox.scss │ ├── UseItForImageComparison.scss │ ├── DemoDivider.scss │ ├── RawDashboard.scss │ ├── UseItForDashboards.scss │ ├── LettersDemo.scss │ └── App.scss ├── vue │ ├── vite-env.d.ts │ ├── main.ts │ ├── DemoDivider.vue │ ├── Section.vue │ ├── Checkbox.vue │ ├── FrameworkSupport.vue │ ├── FlexDemo.vue │ ├── PreventOverflow.vue │ ├── CustomDividerDemo.vue │ ├── HideDemo.vue │ ├── App.vue │ ├── LettersDemo.vue │ ├── RawDashboard.vue │ ├── UseItForImageComparison.vue │ └── UseItForDashboards.vue ├── react │ ├── main.tsx │ ├── DemoDivider.tsx │ ├── Checkbox.tsx │ ├── Section.tsx │ ├── FrameworkSupport.tsx │ ├── FlexDemo.tsx │ ├── PreventOverflow.tsx │ ├── App.tsx │ ├── UseItForImageComparison.tsx │ ├── LettersDemo.tsx │ ├── RawDashboard.tsx │ ├── CustomDividerDemo.tsx │ ├── HideDemo.tsx │ └── UseItForDashboards.tsx ├── style.css └── useTracking.ts ├── .prettierrc ├── packages ├── core │ ├── setupTests.ts │ ├── src │ │ ├── index.ts │ │ ├── constants │ │ │ └── index.ts │ │ ├── helpers │ │ │ ├── useLogs.ts │ │ │ └── interactionHelpers.ts │ │ ├── types │ │ │ └── index.ts │ │ └── state │ │ │ ├── context.spec.ts │ │ │ ├── context.ts │ │ │ ├── contextHelpers.ts │ │ │ └── contextHelpers.spec.ts │ ├── vitest.config.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── package.json ├── react │ ├── src │ │ ├── react-dom-client.d.ts │ │ ├── react-state-adapter.tsx │ │ ├── style.scss │ │ ├── hooks │ │ │ └── usePaneComputedHooks.react.ts │ │ ├── Divider.tsx │ │ ├── Panes.tsx │ │ └── Pane.tsx │ ├── tsconfig.json │ ├── vite.config.ts │ └── package.json └── vue │ ├── src │ ├── index.ts │ ├── vue-shims.d.ts │ ├── vue-state-adapter.ts │ ├── hooks │ │ └── usePaneComputedHooks.ts │ ├── Panes.vue │ ├── Divider.vue │ └── Pane.vue │ ├── tsconfig.json │ ├── vite.config.ts │ └── package.json ├── public ├── icon.png ├── old-mustang.png ├── scroll-down.lottie └── restored-old-mustang.png ├── .env ├── tsconfig.json ├── vite.config.ts ├── tsconfig.node.json ├── index.react.html ├── index.html ├── .gitignore ├── vite.react.config.ts ├── tsconfig.react.json ├── tsconfig.app.json ├── mergeDist.js ├── docs ├── vue │ ├── exposed-functions.md │ ├── properties.md │ └── getting-started.md ├── .vitepress │ └── config.mts ├── docs.md └── react │ ├── exposed-functions.md │ ├── getting-started.md │ └── properties.md ├── package.json ├── README.md └── CONTRIBUTING.md /demo-components/styles/FlexDemo.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-components/styles/HideDemo.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-components/styles/PreventOverflow.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/setupTests.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /demo-components/vue/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/react/src/react-dom-client.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react-dom/client"; 2 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altinselimi/turtle-panes/HEAD/public/icon.png -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_GITHUB_EXAMPLES_REPO=https://github.com/altinselimi/turtle-panes/tree/main/demo-components -------------------------------------------------------------------------------- /public/old-mustang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altinselimi/turtle-panes/HEAD/public/old-mustang.png -------------------------------------------------------------------------------- /public/scroll-down.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altinselimi/turtle-panes/HEAD/public/scroll-down.lottie -------------------------------------------------------------------------------- /public/restored-old-mustang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altinselimi/turtle-panes/HEAD/public/restored-old-mustang.png -------------------------------------------------------------------------------- /demo-components/vue/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import "../style.css"; 3 | import App from "./App.vue"; 4 | createApp(App).mount("#app"); 5 | -------------------------------------------------------------------------------- /packages/vue/src/index.ts: -------------------------------------------------------------------------------- 1 | import Pane from "./Pane.vue"; 2 | import Panes from "./Panes.vue"; 3 | 4 | export { Panes as TurtlePanes, Pane as TurtlePane }; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/vue/src/vue-shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import type { DefineComponent } from "vue"; 3 | const component: DefineComponent<{}, {}, any>; 4 | export default component; 5 | } 6 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | }); 8 | -------------------------------------------------------------------------------- /demo-components/styles/FrameworkSupport.scss: -------------------------------------------------------------------------------- 1 | .demo-framework-support__icons { 2 | margin: 40px 0px; 3 | display: flex; 4 | gap: 20px; 5 | svg { 6 | width: 20px; 7 | height: 32px; 8 | } 9 | } -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state/context'; 2 | export * from './state/contextHelpers'; 3 | export * from './helpers/interactionHelpers'; 4 | export * from './helpers/useLogs'; 5 | export * from './constants'; 6 | export * from './types'; -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /demo-components/react/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "../style.css"; 4 | import App from "./App"; 5 | 6 | createRoot(document.getElementById("app")!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /index.react.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%- title %> 6 | 7 | 8 |
9 | <%- injectScript %> 10 | 11 | -------------------------------------------------------------------------------- /packages/core/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | test: { 7 | globals: true, 8 | environment: "jsdom", 9 | setupFiles: "src/setupTests.ts", 10 | coverage: { 11 | include: ['src/**/*.ts'] 12 | } 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /demo-components/styles/CustomDividerDemo.scss: -------------------------------------------------------------------------------- 1 | .turtle-panes__custom-divider { 2 | align-self: center; 3 | font-size: 2rem; 4 | &::before { 5 | content: " "; 6 | position: absolute; 7 | top: 0; 8 | width: 1px; 9 | height: 100%; 10 | background-color: rgba(0, 0, 0, 0.2); 11 | left: 50%; 12 | transform: translateX(-50%); 13 | } 14 | } -------------------------------------------------------------------------------- /packages/core/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import { PaneComponentProps } from "../types"; 2 | 3 | export const defaultPaneProps: PaneComponentProps = { 4 | minWidth: 10, 5 | initialWidth: undefined, 6 | maxWidth: undefined, 7 | hideOnMinWidthExceeded: false, 8 | preventContentOverflow: false, 9 | isVisible: true, 10 | isFlex: false, 11 | allowOverflow: false, 12 | hideDivider: false, 13 | }; 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TurtlePanes 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/vue/src/vue-state-adapter.ts: -------------------------------------------------------------------------------- 1 | import { createState, createActions } from "@turtle-panes/core"; 2 | import { ContextType } from "@turtle-panes/core/types"; 3 | import { reactive } from "vue"; 4 | import type { Reactive } from "vue"; 5 | 6 | export const createContext = (): Reactive => { 7 | const state = reactive(createState()); 8 | const actions = createActions(state); 9 | return { state, ...actions }; 10 | }; 11 | -------------------------------------------------------------------------------- /demo-components/vue/DemoDivider.vue: -------------------------------------------------------------------------------- 1 | 9 | 12 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .vitepress/cache 15 | .vitepress/dist 16 | docs/.vitepress/dist 17 | docs/.vitepress/cache 18 | 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /demo-components/react/DemoDivider.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeft, ChevronRight } from "lucide-react"; 2 | import "../styles/DemoDivider.scss"; 3 | 4 | const DemoDivider: React.FC = () => { 5 | return ( 6 |
7 |
8 | 9 | 10 |
11 |
12 | ); 13 | }; 14 | 15 | export default DemoDivider; 16 | -------------------------------------------------------------------------------- /demo-components/styles/Section.scss: -------------------------------------------------------------------------------- 1 | .demo-section { 2 | min-height: clamp(100px, 100vh, 800px); 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: center; 7 | &__container { 8 | flex: 1; 9 | display: flex; 10 | // padding: 2rem; 11 | container-type: inline-size; 12 | width: 100%; 13 | overflow: hidden; 14 | } 15 | &__content { 16 | flex: 1; 17 | display: flex; 18 | justify-content: center; 19 | flex-direction: column; 20 | max-width: 100%; 21 | } 22 | } -------------------------------------------------------------------------------- /vite.react.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { createHtmlPlugin } from "vite-plugin-html"; 4 | 5 | const htmlConfig = { 6 | minify: false, 7 | entry: "demo-components/react/main.tsx", 8 | template: "/index.react.html", 9 | inject: { 10 | data: { 11 | title: "TurtlePanes - React", 12 | injectScript: ``, 13 | }, 14 | }, 15 | }; 16 | 17 | // https://vite.dev/config/ 18 | export default defineConfig({ 19 | plugins: [react(), createHtmlPlugin(htmlConfig)] 20 | }); 21 | -------------------------------------------------------------------------------- /demo-components/vue/Section.vue: -------------------------------------------------------------------------------- 1 | 10 | 21 | 24 | -------------------------------------------------------------------------------- /demo-components/react/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircle2 } from "lucide-react"; 2 | import '../styles/Checkbox.scss'; 3 | 4 | type CheckboxProps = { 5 | value: boolean; 6 | onChange: (value: boolean) => void; 7 | children?: React.ReactNode; 8 | }; 9 | 10 | const Checkbox: React.FC = ({ 11 | value, 12 | onChange, 13 | children, 14 | }) => { 15 | 16 | return ( 17 |
18 | onChange(e.target.checked)} /> 19 | 20 | {children} 21 |
22 | ); 23 | }; 24 | 25 | export default Checkbox; 26 | -------------------------------------------------------------------------------- /demo-components/vue/Checkbox.vue: -------------------------------------------------------------------------------- 1 | 16 | 21 | 23 | -------------------------------------------------------------------------------- /packages/core/src/helpers/useLogs.ts: -------------------------------------------------------------------------------- 1 | export const useLogs = (): { 2 | logInfo: (...args: any[]) => void; 3 | logWarning: (...args: any[]) => void; 4 | logError: (...args: any[]) => void; 5 | } => { 6 | const isDevelopment = process.env.NODE_ENV === "development"; 7 | 8 | if (!isDevelopment) { 9 | return { 10 | logInfo: () => {}, 11 | logWarning: () => {}, 12 | logError: () => {}, 13 | }; 14 | } 15 | 16 | return { 17 | logInfo: (...args) => { 18 | console.log(...args); 19 | }, 20 | logWarning: (...args) => { 21 | console.warn(...args); 22 | }, 23 | logError: (...args) => { 24 | console.error(...args); 25 | }, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "declaration": true, 12 | "declarationDir": "./dist", 13 | "outDir": "./dist", 14 | "baseUrl": ".", 15 | "paths": { 16 | "@/*": ["src/*"] 17 | }, 18 | "esModuleInterop": true, 19 | "allowSyntheticDefaultImports": true 20 | }, 21 | "include": ["src", "src/index.ts"], 22 | "exclude": ["node_modules", "dist", "**/*.spec.ts"] 23 | } -------------------------------------------------------------------------------- /demo-components/styles/Checkbox.scss: -------------------------------------------------------------------------------- 1 | .turtle-panes { 2 | &__checkbox { 3 | display: flex; 4 | align-items: center; 5 | gap: 5px; 6 | border: solid 1px; 7 | padding: 4px 8px; 8 | border-radius: 20px; 9 | position: relative; 10 | font-size: clamp(14px, 2cqw, 2rem); 11 | input { 12 | position: absolute; 13 | left: 0; 14 | top: 0; 15 | width: 100%; 16 | height: 100%; 17 | opacity: 0; 18 | cursor: pointer; 19 | z-index: 2; 20 | &:not(:checked) + svg path { 21 | stroke: transparent; 22 | } 23 | } 24 | svg { 25 | width: 20px; 26 | height: 20px; 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /tsconfig.react.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | "jsxImportSource": "react", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": false, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["demo-components/**/*.tsx", "demo-components/**/*.react.ts"], 25 | } 26 | -------------------------------------------------------------------------------- /demo-components/styles/UseItForImageComparison.scss: -------------------------------------------------------------------------------- 1 | .before-after { 2 | &__wrapper { 3 | background: whitesmoke; 4 | border-radius: 20px; 5 | position: relative; 6 | container-type: inline-size; 7 | margin-top: 20px; 8 | border-radius: 20px; 9 | overflow: hidden; 10 | display: flex; 11 | align-items: center; 12 | img { 13 | width: 100%; 14 | } 15 | @media (max-width: 768px) { 16 | min-height: 300px; 17 | } 18 | } 19 | &__after-container { 20 | background-color: whitesmoke; 21 | height: 100%; 22 | position: relative; 23 | flex: 1; 24 | img { 25 | position: absolute; 26 | top:50%; 27 | transform: translateY(-50%); 28 | right:0; 29 | width: 100cqw; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "declaration": true, 19 | "declarationDir": "./dist", 20 | "outDir": "./dist", 21 | "esModuleInterop": true 22 | }, 23 | "include": ["src"], 24 | "exclude": ["node_modules", "dist"] 25 | } -------------------------------------------------------------------------------- /demo-components/react/Section.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/Section.scss'; 2 | type SectionProps = React.HTMLAttributes & { // used to allow for implicit passing of HTML attributes like style 3 | maxWidth?: string; 4 | contentStyle?: React.CSSProperties; 5 | }; 6 | 7 | const Section: React.FC = ({ 8 | maxWidth = "clamp(100px, 90cqw, 800px)", 9 | contentStyle, 10 | children, 11 | ...rest // also used for implicit passing of attributes 12 | }) => { 13 | return ( 14 |
15 |
16 |
17 | {children} 18 |
19 |
20 |
21 | ); 22 | }; 23 | 24 | export default Section; 25 | -------------------------------------------------------------------------------- /packages/vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "declaration": true, 18 | "declarationDir": "./dist", 19 | "outDir": "./dist", 20 | "esModuleInterop": true, 21 | "jsx": "preserve" // Changed for Vue 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"], 24 | "exclude": ["node_modules", "dist"] 25 | } -------------------------------------------------------------------------------- /demo-components/styles/DemoDivider.scss: -------------------------------------------------------------------------------- 1 | .turtle-panes__demo-divider { 2 | align-self: center; 3 | display: inline-flex; 4 | align-items: center; 5 | height: 100%; 6 | width: 8px; 7 | svg { 8 | width: 12px; 9 | height: 12px; 10 | stroke: rgba(255, 255, 255, 0.75); 11 | stroke-width: 1px; 12 | position: absolute; 13 | top: 50%; 14 | transform: translate(0%, -50%); 15 | &.lucide:first-child { 16 | transform: translate(-100%, -50%); 17 | } 18 | &.lucide:last-child { 19 | left: 1px; // account for divider width 20 | } 21 | } 22 | &-divider { 23 | width: 1px; 24 | background-color: rgba(255, 255, 255, 0.15); 25 | height: 100%; 26 | position: absolute; 27 | left: 50%; 28 | transform: translateX(-50%); 29 | } 30 | } -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "node", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "typeRoots": ["node_modules/@types", "types"] 23 | }, 24 | "include": ["demo-components/react/*.tsx", "demo-components/**/*.ts", "demo-components/vue/vite-env.d.ts"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /demo-components/styles/RawDashboard.scss: -------------------------------------------------------------------------------- 1 | .is-placeholder { 2 | background-color: var(--black); 3 | opacity: 0.1; 4 | border-radius: 10px; 5 | width: 100%; 6 | } 7 | 8 | .dashboard-demo { 9 | &__panes { 10 | margin: 20px 0px; 11 | border-radius: 20px; 12 | align-self: stretch; 13 | background-color: var(--white); 14 | height: 40cqw; 15 | } 16 | &__first-pane { 17 | padding: 10px; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: space-between; 21 | justify-content: flex-start; 22 | width: 100%; 23 | overflow-y: hidden; 24 | } 25 | } 26 | 27 | .dashboard-demo { 28 | &__hide-manually { 29 | display: flex; 30 | gap: 15px; 31 | align-self: center; 32 | flex-wrap: wrap; 33 | margin-bottom: 20px; 34 | } 35 | } -------------------------------------------------------------------------------- /demo-components/react/FrameworkSupport.tsx: -------------------------------------------------------------------------------- 1 | import Section from "./Section.tsx"; 2 | import { SiReact, SiVuedotjs } from "react-icons/si"; 3 | import '../styles/FrameworkSupport.scss'; 4 | 5 | const FrameWorkSupport: React.FC = () => { 6 | return ( 7 |
11 |
14 |

Framework support

15 |

16 | Turtle-Panes plays nicely with some of the major frameworks 17 |

18 |
19 |
20 | < SiVuedotjs /> 21 | < SiReact /> 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default FrameWorkSupport; -------------------------------------------------------------------------------- /packages/vue/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import dts from 'vite-plugin-dts'; 3 | import vue from '@vitejs/plugin-vue'; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: './src/index.ts', 9 | formats: ['es'], 10 | fileName: 'index' 11 | }, 12 | outDir: 'dist', 13 | minify: 'esbuild', 14 | cssMinify: 'esbuild', 15 | rollupOptions: { 16 | external: ['vue', '@turtle-panes/core'], 17 | output: { 18 | preserveModules: false, 19 | assetFileNames: 'index.[ext]', 20 | entryFileNames: 'index.js', 21 | globals: { 22 | vue: 'Vue' 23 | } 24 | } 25 | }, 26 | cssCodeSplit: false 27 | }, 28 | plugins: [ 29 | vue(), 30 | dts({ 31 | entryRoot: './src', 32 | tsconfigPath: './tsconfig.json', 33 | rollupTypes: true 34 | }) 35 | ] 36 | }); -------------------------------------------------------------------------------- /packages/react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import dts from 'vite-plugin-dts'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: './src/Panes.tsx', 9 | formats: ['es'], 10 | fileName: 'index' 11 | }, 12 | outDir: 'dist', 13 | minify: 'esbuild', 14 | cssMinify: 'esbuild', 15 | rollupOptions: { 16 | external: ['react', 'react-dom', '@turtle-panes/core'], 17 | output: { 18 | preserveModules: false, 19 | assetFileNames: 'index.[ext]', // This will output CSS as index.css 20 | entryFileNames: 'index.js' 21 | } 22 | }, 23 | cssCodeSplit: false // Bundle all CSS into a single file 24 | }, 25 | plugins: [ 26 | react(), 27 | dts({ 28 | entryRoot: './src', 29 | tsconfigPath: './tsconfig.json', 30 | rollupTypes: true 31 | }) 32 | ] 33 | }); -------------------------------------------------------------------------------- /demo-components/styles/UseItForDashboards.scss: -------------------------------------------------------------------------------- 1 | .is-placeholder { 2 | background-color: var(--black); 3 | opacity: 0.1; 4 | border-radius: 10px; 5 | width: 100%; 6 | } 7 | 8 | .dashboard-demo { 9 | &__panes { 10 | margin: 20px 0px; 11 | border-radius: 20px; 12 | align-self: stretch; 13 | background-color: var(--white); 14 | border: solid 1px var(--black); 15 | .turtle-panes__divider-target:before { 16 | background-color: var(--black); 17 | } 18 | } 19 | &__first-pane { 20 | padding: 10px; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: space-between; 24 | justify-content: flex-start; 25 | width: 100%; 26 | overflow-y: hidden; 27 | } 28 | } 29 | 30 | .dashboard-demo { 31 | &__hide-manually { 32 | display: flex; 33 | gap: 15px; 34 | align-self: center; 35 | flex-wrap: wrap; 36 | margin-bottom: 20px; 37 | } 38 | } -------------------------------------------------------------------------------- /demo-components/vue/FrameworkSupport.vue: -------------------------------------------------------------------------------- 1 | 25 | 29 | -------------------------------------------------------------------------------- /demo-components/styles/LettersDemo.scss: -------------------------------------------------------------------------------- 1 | .demo-intro { 2 | &__github-star { 3 | position: absolute; 4 | top: 20px; 5 | right: 20px; 6 | } 7 | &__logo { 8 | display: flex; 9 | flex-direction: column; 10 | align-self: stretch; 11 | align-items: center; 12 | } 13 | &__logo-letter { 14 | flex: 1; 15 | text-align: center; 16 | font-size: 8cqw; 17 | font-weight: 900; 18 | color: var(--white); 19 | background-color: var(--black); 20 | padding: 0px 4cqw; 21 | @media screen and (max-width: 650px) { 22 | font-size: 12cqw; 23 | } 24 | } 25 | &__logo-text { 26 | font-size: 7cqw; 27 | font-weight: 900; 28 | text-align: center; 29 | margin: 2cqw; 30 | } 31 | &__tagline { 32 | text-align: center; 33 | font-weight: 600; 34 | } 35 | &__social-links svg { 36 | width: 20px; 37 | height: 20px; 38 | } 39 | &__docs { 40 | display: flex; 41 | flex-wrap: wrap; 42 | gap: 20px; 43 | } 44 | } -------------------------------------------------------------------------------- /packages/core/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import dts from 'vite-plugin-dts'; 3 | 4 | export default defineConfig({ 5 | build: { 6 | lib: { 7 | entry: { 8 | 'index': './src/index.ts', 9 | 'state/context': './src/state/context.ts', 10 | 'state/contextHelpers': './src/state/contextHelpers.ts', 11 | 'helpers/interactionHelpers': './src/helpers/interactionHelpers.ts', 12 | 'helpers/useLogs': './src/helpers/useLogs.ts', 13 | 'constants': './src/constants/index.ts', 14 | 'types': './src/types/index.ts' 15 | }, 16 | formats: ['es'] 17 | }, 18 | minify: 'esbuild', 19 | rollupOptions: { 20 | output: { 21 | preserveModules: true, 22 | entryFileNames: '[name].js' 23 | } 24 | } 25 | }, 26 | plugins: [ 27 | dts({ 28 | entryRoot: './src', 29 | tsconfigPath: './tsconfig.json', 30 | insertTypesEntry: true, 31 | copyDtsFiles: true, 32 | bundledPackages: [], 33 | include: ['src/**/*'], 34 | exclude: ['src/**/*.spec.ts', 'src/**/*.test.ts'] 35 | }) 36 | ] 37 | }); -------------------------------------------------------------------------------- /mergeDist.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = path.dirname(__filename); 7 | 8 | const sourceDir = path.join(__dirname, "./docs/.vitepress/dist"); 9 | const targetDir = path.join(__dirname, "./dist"); 10 | 11 | function copyRecursiveSync(src, dest) { 12 | if (!fs.existsSync(dest)) { 13 | fs.mkdirSync(dest, { recursive: true }); 14 | } 15 | 16 | const entries = fs.readdirSync(src, { withFileTypes: true }); 17 | 18 | for (let entry of entries) { 19 | const srcPath = path.join(src, entry.name); 20 | const destPath = path.join(dest, entry.name); 21 | 22 | if (entry.isDirectory()) { 23 | copyRecursiveSync(srcPath, destPath); 24 | } else { 25 | fs.copyFileSync(srcPath, destPath); 26 | } 27 | } 28 | } 29 | 30 | if (fs.existsSync(sourceDir)) { 31 | console.log(`Merging ${sourceDir} into ${targetDir}...`); 32 | copyRecursiveSync(sourceDir, targetDir); 33 | console.log("Merge complete!"); 34 | } else { 35 | console.error(`Source directory ${sourceDir} does not exist!`); 36 | } -------------------------------------------------------------------------------- /docs/vue/exposed-functions.md: -------------------------------------------------------------------------------- 1 | # Exposed Functions 2 | 3 | The Vue version of the panes component provides three key functions on the component instance/reference: 4 | 5 | - **`reShowPane(id: number)`** 6 | Makes a previously hidden pane visible again. 7 | 8 | - **`hidePane(id: number)`** 9 | Manually hides a currently visible pane. 10 | 11 | - **`hiddenPanes`**: 12 | This is exposed as a computed property that will always have up-to-date information about the currently hidden panes. 13 | 14 | ## Obtaining a Reference to the `` Component 15 | 16 | Use the `ref` directive with ` 33 | ``` 34 | 35 | Use these methods to interact with the `` component programmatically. 36 | 37 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "TurtlePanes 🐢", 6 | description: "Easily manage multi pane views", 7 | themeConfig: { 8 | // https://vitepress.dev/reference/default-theme-config 9 | nav: [ 10 | { text: 'Home', link: '/docs' }, 11 | ], 12 | 13 | sidebar: [ 14 | { 15 | text: 'React', 16 | items: [ 17 | { text: 'Getting Started', link: '/react/getting-started' }, 18 | { text: 'Properties', link: '/react/properties' }, 19 | { text: 'Exposed Functions', link: '/react/exposed-functions' }, 20 | ] 21 | }, 22 | { 23 | text: 'Vue', 24 | items: [ 25 | { text: 'Getting Started', link: '/vue/getting-started' }, 26 | { text: 'Properties', link: '/vue/properties' }, 27 | { text: 'Exposed Functions', link: '/vue/exposed-functions' }, 28 | ] 29 | } 30 | ], 31 | 32 | socialLinks: [ 33 | { icon: 'github', link: 'https://github.com/altinselimi/turtle-panes' } 34 | ] 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /docs/docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "TurtlePanes" 7 | tagline: Easily work with multi pane views 8 | actions: 9 | - theme: alt 10 | text: React docs 11 | link: /react/getting-started 12 | - theme: brand 13 | text: Vue docs 14 | link: /vue/getting-started 15 | 16 | features: 17 | - title: Overflow Prevention 🧠 18 | details: Keep important content visible by preventing overflows. 19 | - title: Flexible Layouts 🌊 20 | details: Spread your panes flexibly or set precise widths. 21 | - title: Hide/Show Panes 👁️ 22 | details: Minimize or hide panes when they exceed a certain width limit. 23 | - title: Custom Resizers 🍢 24 | details: Replace default divider with your own. 25 | - title: React ❤️ Vue 26 | details: Uses a composable approach, works well with the Vue and React ecosystem. 27 | - title: Responsive Design 🤳🏻 28 | details: Automatically adjusts to browser resizing for a seamless user experience. 29 | - title: Fine-Grained Control 🎛️ 30 | details: Exposes functions to give developers precise control over pane behavior and interactions. 31 | --- 32 | 33 | -------------------------------------------------------------------------------- /demo-components/react/FlexDemo.tsx: -------------------------------------------------------------------------------- 1 | import Section from "./Section.tsx"; 2 | import Panes from "@turtle-panes/react"; 3 | import '@turtle-panes/react/style' 4 | 5 | import '../styles/FlexDemo.scss'; 6 | 7 | const FlexDemo: React.FC = () => { 8 | return ( 9 |
12 |
15 |

Give it all the space

16 |

17 | You can let a pane breathe as much as it 18 | wants with flex='true' 19 |

20 |
21 | 22 | 23 |
24 |

I'll eat all i can

25 |
26 |
27 | 28 |
29 |

30 | I'll take
31 | what i need 32 |

33 |
34 |
35 |
36 |
37 | ); 38 | }; 39 | 40 | export default FlexDemo; -------------------------------------------------------------------------------- /demo-components/react/PreventOverflow.tsx: -------------------------------------------------------------------------------- 1 | import Panes from "@turtle-panes/react"; 2 | import '@turtle-panes/react/style' 3 | 4 | import Section from "./Section.tsx"; 5 | import '../styles/PreventOverflow.scss'; 6 | 7 | const PreventOverflow: React.FC = () => { 8 | return ( 9 |
12 |
13 |

Let the content decide

14 |

15 | There is a prevent-overflow behavior you can use to make sure pane 16 | content is always visible. 17 |

18 |
19 | 20 | 21 |
22 |

I will always be visible

23 |
24 |
25 | 26 |
27 |

I might be clipped at certain widths

28 |
29 |
30 |
31 |
32 | ); 33 | }; 34 | 35 | export default PreventOverflow; 36 | -------------------------------------------------------------------------------- /docs/vue/properties.md: -------------------------------------------------------------------------------- 1 | # Properties 2 | Below is a brief overview of the Vue component props: 3 | 4 | ### Pane: 5 | - **minWidth** (`number`, default: `10`): The smallest allowed width for the pane. 6 | - **initialWidth** (`number | undefined`): The pane’s width on creation if provided; otherwise falls back to either content width or `minWidth` (whichever is bigger). 7 | - **maxWidth** (`number | undefined`): The largest allowed width for the pane. 8 | - **hideOnMinWidthExceeded** (`boolean`, default: `false`): If true, the pane hides itself when forced below its `minWidth`. 9 | - **preventContentOverflow** (`boolean`, default: `false`): If true, will prevent shrinking the pane when content starts overflowing. If it reaches `minWidth` then it follows whatever `hideOnMinWidthExceeded` specifies. 10 | - **isVisible** (`boolean`, default: `true`): Whether the pane is visible. 11 | - **isFlex** (`boolean`, default: `false`): If true, behaves flexibly in layouts (takes available space). 12 | - **allowOverflow** (`boolean`, default: `false`): If true, pane content can extend beyond its boundary and scroll. 13 | - **hideDivider** (`boolean`, default: `false`): If true, hides the divider associated with this pane. 14 | 15 | ### Divider 16 | - **paneId** (`number`): Automatically populated. The numeric ID linked to the pane the divider resizes. 17 | 18 | These props work together to control pane visibility, sizing constraints, and behavior when resizing. -------------------------------------------------------------------------------- /docs/react/exposed-functions.md: -------------------------------------------------------------------------------- 1 | # Exposed Functions 2 | 3 | The React version of the panes component provides three key functions on the component instance/reference: 4 | 5 | - **`reShowPane(id: number)`** 6 | Makes a previously hidden pane visible again. 7 | 8 | - **`hidePane(id: number)`** 9 | Manually hides a currently visible pane. 10 | 11 | - **`hiddenPanes`**: 12 | This is a static function and it's not reactive. Read further for how to listen to changes on hidden panes. 13 | 14 | The `` component provides a special property, `onPanesHidden`, which offers a workaround for tracking hidden panes. For example: 15 | 16 | ```jsx 17 | setIsPaneHidden(!!hiddenPaneIds?.length)} 19 | > 20 | ``` 21 | 22 | ## Obtaining a Reference to the `` Component 23 | 24 | Use the `useRef` hook to obtain a reference to the component instance. 25 | Example: 26 | 27 | ```jsx 28 | import React, { useRef, useEffect } from 'react'; 29 | import TurtlePanes from './TurtlePanes'; 30 | 31 | const App = () => { 32 | const turtlePanesRef = useRef(null); 33 | 34 | useEffect(() => { 35 | console.log(turtlePanesRef.current); // Access the component instance 36 | }, []); 37 | 38 | return ; 39 | }; 40 | 41 | export default App; 42 | ``` 43 | 44 | Use this method to interact with the `` component programmatically. 45 | 46 | -------------------------------------------------------------------------------- /packages/react/src/react-state-adapter.tsx: -------------------------------------------------------------------------------- 1 | import { createContext,useRef, useContext, useState, useMemo, FC, ReactNode } from "react"; 2 | import { createState, createProxyState, createActions } from "@turtle-panes/core"; 3 | import { ContextType } from "@turtle-panes/core/types"; 4 | 5 | const StateContext = createContext(undefined); 6 | 7 | export const StateProvider: FC<{ children: ReactNode }> = ({ children }) => { 8 | const [triggerRerender, setTriggerRerender] = useState(0); 9 | const state = useRef(null); 10 | if(!state.current) { 11 | const initialState = createState(); 12 | state.current = createProxyState(initialState, () => setTriggerRerender(prevCount => prevCount + 1)); 13 | } 14 | const actions = useRef(null); 15 | if(!actions.current) { 16 | actions.current = createActions(state.current); 17 | } 18 | 19 | const contextValue = useMemo(() => { 20 | return { state: state.current, ...actions.current }; 21 | }, [triggerRerender]); 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | }; 29 | 30 | // Custom hook to use the state context 31 | export const useStateContext = (): ContextType => { 32 | const context = useContext(StateContext); 33 | if (context === undefined) { 34 | throw new Error("useStateContext must be used within a StateProvider"); 35 | } 36 | return context; 37 | }; 38 | -------------------------------------------------------------------------------- /demo-components/react/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import RawDashboard from "./RawDashboard"; 3 | import LettersDemo from "./LettersDemo"; 4 | import PreventOverflow from "./PreventOverflow"; 5 | import FlexDemo from "./FlexDemo"; 6 | import HideDemo from "./HideDemo"; 7 | import CustomDividerDemo from "./CustomDividerDemo"; 8 | import UseItForDashboards from "./UseItForDashboards"; 9 | import UseItForImageComparison from "./UseItForImageComparison"; 10 | import "../styles/App.scss"; 11 | import "../style.css"; 12 | import FrameWorkSupport from "./FrameworkSupport"; 13 | 14 | const DemoWrapper: React.FC = () => { 15 | const [isTesting, setIsTesting] = useState(false); 16 | const isDev = process.env.NODE_ENV === "development"; 17 | 18 | const handleDoubleClick = () => { 19 | if (isDev) { 20 | setIsTesting((prev) => !prev); 21 | } 22 | }; 23 | 24 | return ( 25 |
29 | {isTesting ? ( 30 | 31 | ) : ( 32 | <> 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | )} 43 |
44 | ); 45 | }; 46 | 47 | export default DemoWrapper; 48 | -------------------------------------------------------------------------------- /demo-components/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Inter"; 3 | font-style: normal; 4 | font-weight: 100 400 500 900; 5 | src: url(https://fonts.gstatic.com/s/inter/v12/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2) 6 | format("woff2"); 7 | } 8 | 9 | :root { 10 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 11 | line-height: 1.5; 12 | font-weight: 400; 13 | font-size: 20px; 14 | 15 | font-synthesis: none; 16 | text-rendering: optimizeLegibility; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | -webkit-text-size-adjust: 100%; 20 | 21 | --yellow: #ffd14d; 22 | --red: #ff004d; 23 | --blue: #0e3ff2; 24 | --black: #000; 25 | --white: #fff; 26 | } 27 | 28 | html, 29 | body { 30 | height: 100%; 31 | width: 100%; 32 | } 33 | body { 34 | margin: 0px; 35 | padding: 0px; 36 | } 37 | 38 | #app { 39 | margin: 0 auto; 40 | height: 100%; 41 | display: flex; 42 | flex-direction: column; 43 | } 44 | 45 | ul { 46 | list-style: none; 47 | margin: 0px; 48 | padding: 0px; 49 | display: flex; 50 | flex-direction: column; 51 | } 52 | li { 53 | display: inline-flex; 54 | } 55 | nav { 56 | list-style: none; 57 | } 58 | 59 | a { 60 | text-decoration: none; 61 | color: inherit; 62 | } 63 | 64 | input, 65 | button, 66 | submit { 67 | border: none; 68 | } 69 | button { 70 | border-radius: 4cqw; 71 | font-size: clamp(1rem, 1.8cqw, 1.5rem); 72 | padding: 1cqw 2cqw; 73 | cursor: pointer; 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | } 79 | -------------------------------------------------------------------------------- /demo-components/vue/FlexDemo.vue: -------------------------------------------------------------------------------- 1 | 35 | 45 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@turtle-panes/react", 3 | "version": "1.0.9", 4 | "private": false, 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "style": "./dist/index.css", 10 | "exports": { 11 | ".": { 12 | "types": "./dist/index.d.ts", 13 | "import": "./dist/index.js", 14 | "default": "./dist/index.js" 15 | }, 16 | "./style": "./dist/index.css", 17 | "./components/*": { 18 | "types": "./dist/components/*.d.ts", 19 | "import": "./dist/components/*.js", 20 | "default": "./dist/components/*.js" 21 | }, 22 | "./hooks/*": { 23 | "types": "./dist/hooks/*.d.ts", 24 | "import": "./dist/hooks/*.js", 25 | "default": "./dist/hooks/*.js" 26 | }, 27 | "./state/*": { 28 | "types": "./dist/state/*.d.ts", 29 | "import": "./dist/state/*.js", 30 | "default": "./dist/state/*.js" 31 | } 32 | }, 33 | "files": [ 34 | "dist" 35 | ], 36 | "scripts": { 37 | "build": "tsc && vite build", 38 | "dev": "vite", 39 | "test": "vitest", 40 | "lint": "tsc --noEmit" 41 | }, 42 | "dependencies": { 43 | "@turtle-panes/core": "^1" 44 | }, 45 | "peerDependencies": { 46 | "react": ">=19.0.0", 47 | "react-dom": ">=19.0.0" 48 | }, 49 | "devDependencies": { 50 | "@types/react": "^19.0.0", 51 | "@types/react-dom": "^19.0.0", 52 | "@vitejs/plugin-react": "^4.3.4", 53 | "typescript": "^5.0.0", 54 | "vite": "^6.0.0", 55 | "vite-plugin-dts": "^3.3.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /demo-components/react/UseItForImageComparison.tsx: -------------------------------------------------------------------------------- 1 | import Panes from "@turtle-panes/react"; 2 | import '@turtle-panes/react/style' 3 | 4 | import Section from "./Section.tsx"; 5 | import "../styles/UseItForImageComparison.scss"; 6 | 7 | const UseItForImageComparison: React.FC = () => { 8 | const panesStyle: React.CSSProperties = { 9 | position: "absolute", 10 | top: "0", 11 | left: "0", 12 | width: "100%", 13 | height: "100%", 14 | }; 15 | 16 | const leftImgSource = "/old-mustang.png"; 17 | const leftImgInitialWidth = 18 | document.body.clientWidth > 768 ? 300 : document.body.clientWidth / 2; 19 | 20 | const rightImgSource = "/restored-old-mustang.png"; 21 | 22 | return ( 23 |
24 |
25 |

Or compare images

26 |

Its all up to you really

27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 |
35 | 36 |
37 |
38 |
39 |
40 |
41 | ); 42 | }; 43 | 44 | export default UseItForImageComparison; 45 | -------------------------------------------------------------------------------- /demo-components/vue/PreventOverflow.vue: -------------------------------------------------------------------------------- 1 | 32 | 43 | -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@turtle-panes/vue", 3 | "version": "1.0.2", 4 | "private": false, 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "style": "./dist/index.css", 10 | "exports": { 11 | ".": { 12 | "types": "./dist/index.d.ts", 13 | "import": "./dist/index.js", 14 | "default": "./dist/index.js" 15 | }, 16 | "./style": "./dist/index.css", 17 | "./components/*": { 18 | "types": "./dist/components/*.d.ts", 19 | "import": "./dist/components/*.js", 20 | "default": "./dist/components/*.js" 21 | }, 22 | "./hooks/*": { 23 | "types": "./dist/hooks/*.d.ts", 24 | "import": "./dist/hooks/*.js", 25 | "default": "./dist/hooks/*.js" 26 | }, 27 | "./state/*": { 28 | "types": "./dist/state/*.d.ts", 29 | "import": "./dist/state/*.js", 30 | "default": "./dist/state/*.js" 31 | } 32 | }, 33 | "files": [ 34 | "dist" 35 | ], 36 | "scripts": { 37 | "build": "tsc && vite build", 38 | "dev": "vite", 39 | "test": "vitest", 40 | "lint": "tsc --noEmit" 41 | }, 42 | "peerDependencies": { 43 | "vue": "^3.0.0" 44 | }, 45 | "dependencies": { 46 | "@turtle-panes/core": "^1" 47 | }, 48 | "devDependencies": { 49 | "@vitejs/plugin-vue": "^5.2.3", 50 | "typescript": "^5.0.0", 51 | "vite": "^6.0.0", 52 | "vite-plugin-dts": "^3.3.0", 53 | "vue": "^3.5.13" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /demo-components/react/LettersDemo.tsx: -------------------------------------------------------------------------------- 1 | import { SiGithub, SiNpm, SiBluesky } from "react-icons/si"; 2 | import Panes from "@turtle-panes/react"; 3 | import '@turtle-panes/react/style'; 4 | 5 | import Section from "./Section.tsx"; 6 | import DemoDivider from "./DemoDivider.tsx"; 7 | import '../styles/LettersDemo.scss'; 8 | 9 | const LettersDemo: React.FC = () => { 10 | return ( 11 |
14 |
18 | 22 | {["T", "U", "R", "T", "L", "E"].map((letter, index) => ( 23 | 27 | 28 | 29 | } 30 | > 31 |
{letter}
32 |
33 | ))} 34 |
35 |

PANES

36 |
37 |

38 | Easily build and manage multi-pane views 39 |

40 | 51 |
52 | ); 53 | }; 54 | 55 | export default LettersDemo; 56 | -------------------------------------------------------------------------------- /docs/vue/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | Turtle-Panes simplifies creating and managing multi-pane layouts in Vue. It’s useful when you need split-pane functionality, collapsible sidebars, or resizable panels in dashboards or comparison views. Use it whenever you need to split your UI into multiple sections that you can hide, show, or resize dynamically. 4 | 5 | ## Installation 6 | 7 | You can install this package by running this command in your project: 8 | `npm install @turtle-panes/vue` 9 | 10 | ## Features 11 | 12 | - **Overflow Prevention**: Keep important content visible by preventing overflows. 13 | - **Flexible Layouts**: Spread your panes flexibly or set precise widths. 14 | - **Hide/Show Panes**: Minimize or hide panes when they exceed a certain width limit. 15 | - **Custom Resizers**: Replace default divider with your own. 16 | - **Vue Integration**: Uses a composable approach, works well with the Vue ecosystem. 17 | - **Responsive Design**: Automatically adjusts to browser resizing for a seamless user experience. 18 | - **Fine-Grained Control**: Exposes functions to give developers precise control over pane behavior and interactions. 19 | 20 | ## Usage 21 | 22 | A very simple setup would be: 23 | 24 | #### Vue 25 | 26 | ```vue 27 | 31 | 37 | ``` 38 | 39 | You should see horizontally placed panes with a divider in the middle, which should give you the ability to drag it left and right. 40 | -------------------------------------------------------------------------------- /demo-components/react/RawDashboard.tsx: -------------------------------------------------------------------------------- 1 | import Panes from "@turtle-panes/react"; 2 | import '@turtle-panes/react/style' 3 | 4 | import '../styles/RawDashboard.scss'; 5 | 6 | const RawDashboard: React.FC = () => { 7 | return ( 8 | 9 | 15 |
16 |
17 |
18 | {[1, 2, 3, 4, 5].map((_) => ( 19 |
23 | ))} 24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 |
32 |
33 | 34 |
35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 |
43 |
44 | ); 45 | }; 46 | 47 | export default RawDashboard; -------------------------------------------------------------------------------- /docs/react/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | Turtle-Panes simplifies creating and managing multi-pane layouts in React. It’s useful when you need split-pane functionality, collapsible sidebars, or resizable panels in dashboards or comparison views. Use it whenever you need to split your UI into multiple sections that you can hide, show, or resize dynamically. 4 | 5 | ## Installation 6 | 7 | You can install this package by running this command in your project: 8 | `npm install @turtle-panes/react` 9 | 10 | ## Features 11 | - **Overflow Prevention**: Keep important content visible by preventing overflows. 12 | - **Flexible Layouts**: Spread your panes flexibly or set precise widths. 13 | - **Hide/Show Panes**: Minimize or hide panes when they exceed a certain width limit. 14 | - **Custom Resizers**: Replace default divider with your own. 15 | - **React Integration**: Uses a composable approach, works well with the React ecosystem. 16 | - **Responsive Design**: Automatically adjusts to browser resizing for a seamless user experience. 17 | - **Fine-Grained Control**: Exposes functions to give developers precise control over pane behavior and interactions. 18 | 19 | ## Usage 20 | 21 | A very simple setup would be: 22 | 23 | #### React 24 | ```tsx 25 | import TurtlePanes from '@turtle-panes/react'; 26 | import '@turtle-panes/react/style' 27 | 28 | const MyComponent = () => { 29 | return ( 30 | 31 | 32 | Hello World from Pane 1 33 | 34 | 35 | Hello World from Pane 2 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default MyComponent; 42 | ``` 43 | 44 | You should see horizontally placed panes with a divider in the middle, which should give you the ability to drag it left and right. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turtle-panes-ts", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "tsc --project tsconfig.json && vite", 8 | "dev:react": "tsc --project tsconfig.react.json && vite --config vite.react.config.ts", 9 | "build": "vue-tsc && vite build", 10 | "preview": "vite preview", 11 | "test": "vitest", 12 | "coverage": "vitest run --coverage", 13 | "docs:build": "vitepress build docs", 14 | "docs:preview": "vitepress preview docs", 15 | "docs:dev": "vitepress dev docs", 16 | "merge-dist": "node ./mergeDist.js", 17 | "netlify-build": "npm run build && npm run docs:build && npm run merge-dist" 18 | }, 19 | "dependencies": { 20 | "@turtle-panes/react": "^1.0.4", 21 | "@turtle-panes/vue": "^1.0.1", 22 | "vue": "^3.5.12" 23 | }, 24 | "devDependencies": { 25 | "@icons-pack/react-simple-icons": "^11.0.1", 26 | "@jest/types": "^29.6.3", 27 | "@lottiefiles/dotlottie-vue": "^0.5.8", 28 | "@testing-library/jest-dom": "^6.6.2", 29 | "@testing-library/vue": "^8.1.0", 30 | "@types/react": "^19.0.7", 31 | "@types/react-dom": "^19.0.3", 32 | "@vitejs/plugin-react": "^4.3.4", 33 | "@vitejs/plugin-vue": "^5.1.4", 34 | "@vitest/coverage-v8": "^2.1.8", 35 | "@welldone-software/why-did-you-render": "^10.0.1", 36 | "jsdom": "^25.0.1", 37 | "lucide-react": "^0.473.0", 38 | "lucide-vue-next": "^0.408.0", 39 | "prettier": "^3.3.3", 40 | "react": "^19.0.0", 41 | "react-dom": "^19.0.0", 42 | "react-icons": "^5.4.0", 43 | "sass": "^1.83.1", 44 | "typescript": "^5.2.2", 45 | "vite": "^5.4.8", 46 | "vite-plugin-html": "^3.2.2", 47 | "vitepress": "^1.6.3", 48 | "vitest": "^2.1.8", 49 | "vue-tsc": "^2.1.6", 50 | "vue3-simple-icons": "^13.2.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/core/src/helpers/interactionHelpers.ts: -------------------------------------------------------------------------------- 1 | export const attachPaneDividerInteractionListeners = ({ 2 | mouseMoveCallback, 3 | mouseUpCallback, 4 | }: { 5 | mouseMoveCallback: (e: MouseEvent | TouchEvent) => void; 6 | mouseUpCallback: () => void; 7 | }) => { 8 | const handleMouseUp = () => { 9 | mouseUpCallback(); 10 | window.removeEventListener("mouseup", handleMouseUp as EventListener); 11 | window.removeEventListener("mousemove", mouseMoveCallback as EventListener); 12 | window.removeEventListener("dragstart", handleDragStart as EventListener); 13 | window.removeEventListener("touchend", handleMouseUp as EventListener); 14 | window.removeEventListener("touchmove", mouseMoveCallback as EventListener); 15 | }; 16 | 17 | const handleDragStart = (event: Event) => { 18 | event.preventDefault(); // Disable the browser's default drag behavior. 19 | }; 20 | 21 | window.addEventListener("mouseup", handleMouseUp as EventListener); 22 | window.addEventListener("mousemove", mouseMoveCallback as EventListener); 23 | window.addEventListener("dragstart", handleDragStart as EventListener); 24 | window.addEventListener("touchend", handleMouseUp as EventListener); 25 | window.addEventListener("touchmove", mouseMoveCallback as EventListener); 26 | }; 27 | 28 | export const endInteraction = () => { 29 | window.dispatchEvent(new Event("mouseup")); 30 | window.dispatchEvent(new Event("touchend")); 31 | }; 32 | 33 | export const convertReactEventToNative = ( 34 | event: React.MouseEvent | React.TouchEvent, 35 | ): MouseEvent | TouchEvent => { 36 | if (event.nativeEvent instanceof MouseEvent) { 37 | return event.nativeEvent; 38 | } else if (event.nativeEvent instanceof TouchEvent) { 39 | return event.nativeEvent; 40 | } 41 | throw new Error("Unsupported event type"); 42 | }; 43 | -------------------------------------------------------------------------------- /demo-components/vue/CustomDividerDemo.vue: -------------------------------------------------------------------------------- 1 | 40 | 50 | 52 | -------------------------------------------------------------------------------- /packages/vue/src/hooks/usePaneComputedHooks.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "vue"; 2 | import { Pane, ContextType } from "@turtle-panes/core/types"; 3 | import type { Ref } from "vue"; 4 | import { getPaneSiblingId, getVisiblePanes } from "@turtle-panes/core"; 5 | 6 | export const useComputedHooks = ( 7 | paneId: Ref, 8 | context: ContextType, 9 | ) => { 10 | const dividerTravelledPx = computed( 11 | () => context.state.pixelsTravelled, 12 | ); 13 | const isInteractingWithADivider = computed( 14 | () => context.state.activePaneId != null, 15 | ); 16 | const isDependentOnCurrentActiveDivider = computed(() => { 17 | const activePaneId = context.state.activePaneId; 18 | if (!activePaneId) return false; 19 | const visiblePanes = getVisiblePanes(context.state.panes); 20 | const dependendOnPanes = [ 21 | paneId.value, 22 | getPaneSiblingId(paneId.value!, visiblePanes, "left"), 23 | ].filter((paneId) => paneId); 24 | return dependendOnPanes.includes(activePaneId); 25 | }); 26 | 27 | const isPaneVisible = computed(() => { 28 | return paneId.value && context.state.panes[paneId.value]?.isVisible; 29 | }); 30 | 31 | const isContainerMounted = computed(() => { 32 | return context.state.containerWidth > 0; 33 | }); 34 | 35 | const isDividerActive = computed(() => { 36 | return context.state.activePaneId === paneId.value; 37 | }); 38 | 39 | const isLastPane = computed(() => { 40 | return getVisiblePanes(context.state.panes).at(-1)?.id === paneId.value; 41 | }); 42 | 43 | const widthFromContext = computed(() => { 44 | return paneId.value && context.state.panes[paneId.value].width; 45 | }); 46 | 47 | return { 48 | isPaneVisible, 49 | dividerTravelledPx, 50 | isInteractingWithADivider, 51 | isDependentOnCurrentActiveDivider, 52 | isContainerMounted, 53 | isDividerActive, 54 | isLastPane, 55 | widthFromContext, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /demo-components/react/CustomDividerDemo.tsx: -------------------------------------------------------------------------------- 1 | import Panes from "@turtle-panes/react"; 2 | import '@turtle-panes/react/style' 3 | 4 | import Section from "./Section.tsx"; 5 | import '../styles/CustomDividerDemo.scss'; 6 | 7 | const CustomDividerDemo: React.FC = () => { 8 | const panesStyle = { 9 | margin: "20px 0px", 10 | borderRadius: "20px", 11 | backgroundColor: "var(--white)", 12 | height: "40cqw", 13 | }; 14 | 15 | 16 | const divWidth = { 17 | width: "30cqw", 18 | } 19 | 20 | return ( 21 |
24 |
27 |

Custom divider ?

28 |

We got you covered.

29 |
30 | 31 | 34 | {" "} 35 |
🐢
36 | 37 | } 38 | > 39 |
43 |
44 | 47 | {" "} 48 |
🏎️
49 | 50 | } 51 | > 52 |
56 |
57 | 58 |
62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | export default CustomDividerDemo; 69 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@turtle-panes/core", 3 | "version": "1.0.3", 4 | "private": false, 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "scripts": { 10 | "build": "tsc && vite build", 11 | "dev": "vite", 12 | "test": "vitest", 13 | "test:watch": "vitest watch", 14 | "test:coverage": "vitest run --coverage", 15 | "lint": "tsc --noEmit", 16 | "clean": "rm -rf dist" 17 | }, 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "import": "./dist/index.js", 22 | "default": "./dist/index.js" 23 | }, 24 | "./state/context": { 25 | "types": "./dist/state/context.d.ts", 26 | "import": "./dist/state/context.js", 27 | "default": "./dist/state/context.js" 28 | }, 29 | "./state/contextHelpers": { 30 | "types": "./dist/state/contextHelpers.d.ts", 31 | "import": "./dist/state/contextHelpers.js", 32 | "default": "./dist/state/contextHelpers.js" 33 | }, 34 | "./helpers/interactionHelpers": { 35 | "types": "./dist/helpers/interactionHelpers.d.ts", 36 | "import": "./dist/helpers/interactionHelpers.js", 37 | "default": "./dist/helpers/interactionHelpers.js" 38 | }, 39 | "./helpers/useLogs": { 40 | "types": "./dist/helpers/useLogs.d.ts", 41 | "import": "./dist/helpers/useLogs.js", 42 | "default": "./dist/helpers/useLogs.js" 43 | }, 44 | "./constants": { 45 | "types": "./dist/constants/index.d.ts", 46 | "import": "./dist/constants/index.js", 47 | "default": "./dist/constants/index.js" 48 | }, 49 | "./types": { 50 | "types": "./dist/types/index.d.ts", 51 | "import": "./dist/types/index.js", 52 | "default": "./dist/types/index.js" 53 | } 54 | }, 55 | "devDependencies": { 56 | "vite": "^6.2.3", 57 | "vite-plugin-dts": "^4.5.3" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /demo-components/react/HideDemo.tsx: -------------------------------------------------------------------------------- 1 | import Panes from "@turtle-panes/react"; 2 | import '@turtle-panes/react/style' 3 | 4 | import Section from "./Section.tsx"; 5 | import type { ExposedFunctions } from "../../packages/core/src/types/index.ts"; 6 | import { useRef, useState } from "react"; 7 | import "../styles/HideDemo.scss"; 8 | 9 | const HideDemo: React.FC = () => { 10 | const turtlePanesRef = useRef(null); 11 | const [isPaneHidden, setIsPaneHidden] = useState(false); 12 | 13 | const panesStyle = { 14 | margin: "40px 0px", 15 | borderRadius: "20px", 16 | }; 17 | 18 | return ( 19 |
20 |
24 |

Or maybe hide it?

25 |

26 | Once a pane reaches its min-width, you can also choose to hide it. 27 |

28 | 34 |
35 | 39 | setIsPaneHidden(!!hiddenPaneIds?.length) 40 | } 41 | > 42 | 43 |
44 |

If you don’t want me I’ll just leave

45 |
46 |
47 | 48 |
49 |

My min width is very important to me

50 |
51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | export default HideDemo; 58 | -------------------------------------------------------------------------------- /docs/react/properties.md: -------------------------------------------------------------------------------- 1 | # Properties 2 | Below is a brief overview of the Vue component props: 3 | 4 | ### Panes Component Props 5 | - **style?** (`React.CSSProperties`) 6 | Inline styles for the container. 7 | 8 | - **className?** (`string`) 9 | Additional CSS class(es) for the container element. 10 | 11 | - **onPanesHidden?** (`(hiddenPaneIds: number[]) => void`) 12 | Callback triggered when pane visibility changes and some panes become hidden. 13 | --- 14 | 15 | ### Pane Props 16 | 17 | - **minWidth** (`number`, default: `10`) 18 | The smallest allowed width for the pane. 19 | 20 | - **initialWidth** (`number | undefined`) 21 | The pane’s width on creation if provided; otherwise falls back to either content width or `minWidth` (whichever is bigger). 22 | 23 | - **maxWidth** (`number | undefined`) 24 | The largest allowed width for the pane. 25 | 26 | - **hideOnMinWidthExceeded** (`boolean`, default: `false`) 27 | If true, the pane hides itself when forced below its `minWidth`. 28 | 29 | - **preventContentOverflow** (`boolean`, default: `false`) 30 | If true, will prevent shrinking the pane when content starts overflowing. If it reaches `minWidth` then it follows whatever `hideOnMinWidthExceeded` specifies. 31 | 32 | - **isVisible** (`boolean`, default: `true`) 33 | Whether the pane is visible. 34 | 35 | - **isFlex** (`boolean`, default: `false`) 36 | If true, behaves flexibly in layouts (takes available space). 37 | 38 | - **allowOverflow** (`boolean`, default: `false`) 39 | If true, pane content can extend beyond its boundary and scroll. 40 | 41 | - **hideDivider** (`boolean`, default: `false`) 42 | If true, hides the divider associated with this pane. 43 | 44 | --- 45 | 46 | ### Divider Props 47 | 48 | - **paneId** (`number`) 49 | Automatically populated. The numeric ID linked to the pane the divider resizes. 50 | 51 | These props work together to control pane visibility, sizing constraints, and behavior when resizing. -------------------------------------------------------------------------------- /packages/react/src/style.scss: -------------------------------------------------------------------------------- 1 | .turtle-panes { 2 | &__wrapper { 3 | display: flex; 4 | overflow: hidden; 5 | align-self: flex-start; 6 | touch-action: none; 7 | max-width: 100%; 8 | &:has(> .turtle-panes__pane.is-flex) { 9 | align-self: stretch; 10 | } 11 | &.is-resizing { 12 | pointer-events: none; 13 | user-select: none; 14 | -webkit-user-select: none; 15 | } 16 | } 17 | } 18 | 19 | .turtle-panes { 20 | &__pane { 21 | display: flex; 22 | justify-content: space-between; 23 | &:nth-last-child(1 of .is-visible) .turtle-panes__divider-wrapper { 24 | display: none; 25 | } 26 | &.is-hidden { 27 | display: none; 28 | } 29 | &.is-flex { 30 | flex: 1; 31 | } 32 | } 33 | 34 | &__pane-content { 35 | // WORKAROUND 36 | // This element is added as a workaround for content.scrollWidth not reporting correct values: 37 | // When the immediate rendered block element has justify-content applied, the scrollWidth 38 | // does not return the full width of the content. 39 | &-wrapper { 40 | display: flex; 41 | height: 100%; 42 | } 43 | } 44 | } 45 | 46 | .turtle-panes__divider { 47 | &-wrapper { 48 | width: 0px; 49 | overflow: visible; 50 | z-index: 2; 51 | position: relative; 52 | pointer-events: all; 53 | } 54 | &-target { 55 | cursor: col-resize; 56 | position: absolute; 57 | top: 0; 58 | bottom: 0; 59 | width: 10px; 60 | height: 100%; 61 | left: 50%; 62 | transform: translateX(-50%); 63 | &:before { 64 | content: ""; 65 | position: absolute; 66 | top: 0; 67 | bottom: 0; 68 | left: 50%; 69 | transform: translateX(-50%); 70 | width: 1px; 71 | background-color: rgba(0, 0, 0, 0.2); 72 | } 73 | } 74 | &-custom { 75 | cursor: col-resize; 76 | height: 100%; 77 | position: absolute; 78 | top: 0; 79 | bottom: 0; 80 | left: 50%; 81 | transform: translate(-50%); 82 | display: flex; 83 | justify-content: stretch; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/react/src/hooks/usePaneComputedHooks.react.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { Pane, ContextType } from "@turtle-panes/core/types"; 3 | import { getPaneSiblingId, getVisiblePanes } from "@turtle-panes/core"; 4 | 5 | export const useComputedHooks = ( 6 | paneId: Pane["id"] | null, 7 | context: ContextType, 8 | ) => { 9 | const dividerTravelledPx = useMemo( 10 | () => context.state.pixelsTravelled, 11 | [context.state.pixelsTravelled], 12 | ); 13 | 14 | const isInteractingWithADivider = useMemo( 15 | () => context.state.activePaneId != null, 16 | [context.state.activePaneId], 17 | ); 18 | 19 | const isDependentOnCurrentActiveDivider = useMemo(() => { 20 | const activePaneId = context.state.activePaneId; 21 | if (!activePaneId) return false; 22 | const visiblePanes = getVisiblePanes(context.state.panes); 23 | const dependendOnPanes = [ 24 | paneId, 25 | getPaneSiblingId(paneId!, visiblePanes, "left"), 26 | ].filter((paneId) => paneId); 27 | return dependendOnPanes.includes(activePaneId); 28 | }, [context.state.activePaneId, paneId, context.state.panes]); 29 | 30 | const isPaneVisible = useMemo(() => { 31 | return paneId && context.state.panes[paneId]?.isVisible; 32 | }, [paneId, context.state.panes]); 33 | 34 | const isContainerMounted = useMemo(() => { 35 | return context.state.containerWidth > 0; 36 | }, [context.state.containerWidth]); 37 | 38 | const isDividerActive = useMemo(() => { 39 | return context.state.activePaneId === paneId; 40 | }, [context.state.activePaneId, paneId]); 41 | 42 | const isLastPane = useMemo(() => { 43 | return getVisiblePanes(context.state.panes).at(-1)?.id === paneId; 44 | }, [context.state.panes, paneId]); 45 | 46 | 47 | const widthFromContext = useMemo(() => { 48 | return paneId && context.state.panes[paneId]?.width; 49 | }, [paneId, context.state.panes]); 50 | 51 | return { 52 | dividerTravelledPx, 53 | isInteractingWithADivider, 54 | isDependentOnCurrentActiveDivider, 55 | isPaneVisible, 56 | isContainerMounted, 57 | isDividerActive, 58 | isLastPane, 59 | widthFromContext, 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /demo-components/vue/HideDemo.vue: -------------------------------------------------------------------------------- 1 | 41 | 60 | -------------------------------------------------------------------------------- /demo-components/vue/App.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 57 | 58 | -------------------------------------------------------------------------------- /demo-components/vue/LettersDemo.vue: -------------------------------------------------------------------------------- 1 | 54 | 63 | 66 | -------------------------------------------------------------------------------- /demo-components/vue/RawDashboard.vue: -------------------------------------------------------------------------------- 1 | 51 | 78 | 81 | -------------------------------------------------------------------------------- /packages/vue/src/Panes.vue: -------------------------------------------------------------------------------- 1 | 10 | 73 | 90 | -------------------------------------------------------------------------------- /demo-components/vue/UseItForImageComparison.vue: -------------------------------------------------------------------------------- 1 | 39 | 76 | 78 | -------------------------------------------------------------------------------- /demo-components/useTracking.ts: -------------------------------------------------------------------------------- 1 | const useUmami = () => { 2 | return (window as typeof window & { umami: { track: (event_name: string, event_data: Object) => void } }).umami; 3 | }; 4 | 5 | const debounce = (func: (...args: any[]) => void, wait: number) => { 6 | let timeout: ReturnType | null = null; 7 | return (...args: any[]) => { 8 | if (timeout) { 9 | clearTimeout(timeout); 10 | } 11 | timeout = setTimeout(() => { 12 | func(...args); 13 | }, wait); 14 | }; 15 | }; 16 | 17 | const getElementHierarchy = (element: HTMLElement | null) => { 18 | const hierarchy = []; 19 | let current = element; 20 | for (let i = 0; i < 3 && current; i++) { 21 | hierarchy.push(`${current.tagName.toLowerCase()}.${current.className}`); 22 | current = current.parentElement; 23 | } 24 | while (current && !hierarchy.some((h) => h.includes("section-"))) { 25 | if (current.className.includes("section-")) { 26 | hierarchy.push(`${current.tagName.toLowerCase()}.${current.className}`); 27 | break; 28 | } 29 | current = current.parentElement; 30 | } 31 | return hierarchy.reverse().join(" > "); 32 | }; 33 | 34 | export default function useTracking(): { onMountCallback: () => void; onUnmountCallback: () => void } | {} { 35 | let eventCount = 0; 36 | const maxEvents = 50; 37 | 38 | const trackEvent = (eventName: string, eventData: Object) => { 39 | if (eventCount < maxEvents) { 40 | useUmami().track(eventName, eventData); 41 | eventCount++; 42 | } 43 | }; 44 | 45 | const handleClick = (event: MouseEvent) => { 46 | const target = event.target as HTMLElement; 47 | if (target.tagName.toLowerCase() === "a" && (target as HTMLAnchorElement).href) { 48 | trackEvent('link_open', { 49 | url: (target as HTMLAnchorElement).href, 50 | content: target.textContent 51 | }); 52 | return; 53 | } 54 | const hierarchy = getElementHierarchy(target); 55 | trackEvent('click', { 56 | element: hierarchy, 57 | content: target.textContent 58 | }); 59 | }; 60 | 61 | const handleTap = debounce((event: TouchEvent) => { 62 | const target = event.target as HTMLElement; 63 | const hierarchy = getElementHierarchy(target); 64 | trackEvent('tap', { 65 | element: hierarchy 66 | }); 67 | }, 200); 68 | 69 | const onMountCallback = () => { 70 | document.addEventListener("click", handleClick); 71 | document.addEventListener("touchend", handleTap); 72 | }; 73 | 74 | const onUnmountCallback = () => { 75 | document.removeEventListener("click", handleClick); 76 | document.removeEventListener("touchend", handleTap); 77 | }; 78 | 79 | return { 80 | onMountCallback, 81 | onUnmountCallback 82 | }; 83 | } -------------------------------------------------------------------------------- /packages/core/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Pane = { 2 | width: number; 3 | minWidth: number; 4 | maxWidth?: number; 5 | isVisible?: boolean; 6 | isFlex: boolean; 7 | id: number; 8 | visiblePaneIndex?: number; 9 | hiddenFromSide?: "left" | "right"; 10 | widthAtStartOfInteraction?: number; 11 | widthOfContent?: number; 12 | widthProvidedByPane?: number; 13 | hideOnMinWidthExceeded?: boolean; 14 | preventContentOverflow?: boolean; 15 | isOverflowing?: boolean; 16 | }; 17 | 18 | export type PaneWithId = Pane & { id: number }; 19 | 20 | export type PartialPaneWithId = Partial & { id: number }; 21 | 22 | export type PaneComponentProps = { 23 | minWidth?: Pane["minWidth"]; 24 | initialWidth?: number; 25 | maxWidth?: Pane["maxWidth"]; 26 | hideOnMinWidthExceeded?: Pane["hideOnMinWidthExceeded"]; 27 | preventContentOverflow?: Pane["preventContentOverflow"]; 28 | isVisible?: Pane["isVisible"]; 29 | isFlex?: boolean; 30 | allowOverflow?: boolean; 31 | hideDivider?: boolean; 32 | }; 33 | 34 | export interface PaneMap { 35 | [id: number]: Pane; 36 | } 37 | export interface ContextState { 38 | state: { 39 | panes: PaneMap; 40 | containerWidth: number; 41 | activePaneId?: number | null; 42 | pixelsTravelled: number; 43 | }; 44 | } 45 | 46 | export interface ContextAsyncMethods { 47 | addPane: ( 48 | pane: Omit & { id: Pane["id"] | null }, 49 | ) => Promise; 50 | } 51 | export interface ContextSyncMethods { 52 | getPanes: () => PaneMap; 53 | getNextId: () => number; 54 | setPanes: (newPanes: { [id: Pane["id"]]: Pane }) => void; 55 | addPaneSync: (pane: Omit & { id: Pane["id"] | null }) => number; 56 | reShowPane: (paneId: Pane["id"]) => void; 57 | updatePane: (paneId: Pane["id"], newProps: Partial) => void; 58 | showPane: (paneId: Pane["id"]) => void; 59 | hidePane: (paneId: Pane["id"]) => void; 60 | hidePaneManually: (paneId: Pane["id"]) => void; 61 | setActivePane: (paneId: Pane["id"]) => void; 62 | setPixelsTravelled: (pixelsTravelled: number) => void; 63 | updateWidthsAtStartOfInteraction: () => void; 64 | updatePaneWidth: (paneId: Pane["id"], newWidth: number) => void; 65 | updatePaneContentWidth: (paneId: Pane["id"], widthOfContent: number, widthProvidedByPane: number) => void; 66 | updatePaneWidthAlternate?: (paneId: Pane["id"], newWidth: number) => void; 67 | setContainerWidth: (width: number) => void; 68 | handleContainerResize: ( 69 | width: number, 70 | widthUsedFromContent: number, 71 | ) => void; 72 | resetInteractionState: () => void; 73 | resetState: () => void; 74 | } 75 | 76 | export interface ContextMethods 77 | extends ContextAsyncMethods, 78 | ContextSyncMethods {} 79 | 80 | export interface ContextType extends ContextState, ContextMethods {} 81 | 82 | export interface ExposedFunctions { 83 | reShowPane: (paneId: Pane["id"]) => void; 84 | hidePane: (paneId: Pane["id"]) => void; 85 | hiddenPanes: () => Pane[]; 86 | } 87 | 88 | export interface PaneWithUpdatedWidth { 89 | id: Pane["id"]; 90 | width: number; 91 | } 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turtle Panes 🐢 2 | 3 | **Turtle Panes** is a pane management library designed for **Vue** and **React**, enabling flexible and efficient management of UI panes. It provides a simple and scalable API to create, organize, and control panes in your application. 4 | 5 | ![demo](https://github.com/user-attachments/assets/c171c7b5-4da6-4542-b739-e0d207cf3800) 6 | 7 | 8 | --- 9 | 10 | ## 🚀 Features 11 | 12 | - 🖼️ **Flexible Pane Management:** Easily create and control panes in your UI. 13 | - 🔌 **Works with Vue & React:** Designed specifically for these two frameworks. 14 | - ⚡ **Lightweight & Fast:** Minimal overhead with a smooth user experience. 15 | - 🚀 **Overflow Prevention:** Keep important content visible by preventing overflows. 16 | - 📐 **Flexible Layouts:** Spread your panes flexibly or set precise widths. 17 | - 👁️ **Hide/Show Panes:** Minimize or hide panes when they exceed a certain width limit. 18 | - 🎨 **Custom Resizers:** Replace the default divider with your own. 19 | - 🏗 **Vue Integration:** Uses a composable approach, works well with the Vue ecosystem. 20 | - 📱 **Responsive Design:** Automatically adjusts to browser resizing for a seamless user experience. 21 | - 🎛 **Fine-Grained Control:** Exposes functions to give developers precise control over pane behavior and interactions. 22 | - 🛠 Zero Dependencies: No external dependencies for a leaner and more maintainable codebase. 23 | 24 | --- 25 | 26 | ## 📦 Installation 27 | 28 | Turtle Panes provides separate packages for Vue and React. Install the package for your preferred framework: 29 | 30 | ### Vue: 31 | ```sh 32 | npm install @turtlepanes/vue 33 | ``` 34 | 35 | ### React: 36 | ```sh 37 | npm install @turtlepanes/react 38 | ``` 39 | 40 | --- 41 | 42 | ## 🛠 Getting Started 43 | 44 | ### Vue Example 45 | ```ts 46 | 49 | 55 | ``` 56 | 🔗 More details in the [Vue documentation](https://turtlepanes.altinselimi.com/vue/getting-started.html). 57 | 58 | ### React Example 59 | ```tsx 60 | import TurtlePanes from '@turtle-panes/react'; 61 | 62 | const MyComponent = () => { 63 | return ( 64 | 65 | 66 | Hello World from Pane 1 67 | 68 | 69 | Hello World from Pane 2 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default MyComponent; 76 | ``` 77 | 🔗 More details in the [React documentation](https://turtlepanes.altinselimi.com/react/getting-started.html). 78 | 79 | --- 80 | 81 | ## 📖 API Reference 82 | Refer to the full API documentation: 83 | - [Vue Docs](https://turtlepanes.altinselimi.com/vue/getting-started.html) 84 | - [React Docs](https://turtlepanes.altinselimi.com/react/getting-started.html) 85 | 86 | --- 87 | 88 | ## 🤝 Contributing 89 | Contributions are welcome! Please check out our [contribution guidelines](CONTRIBUTING.md) before making a pull request. 90 | 91 | --- 92 | 93 | ## 📜 License 94 | Turtle Panes is licensed under the [GPL-3.0 license](LICENSE). 95 | 96 | --- 97 | 98 | 🎯 **Ready to simplify pane management?** Get started with Turtle Panes today! 99 | 100 | --- 101 | 102 | -------------------------------------------------------------------------------- /packages/react/src/Divider.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useRef } from "react"; 2 | import { useStateContext } from "./react-state-adapter"; 3 | import { attachPaneDividerInteractionListeners, useLogs } from "@turtle-panes/core"; 4 | const { logError } = useLogs(); 5 | 6 | export interface DividerProps { 7 | paneId?: number | null; 8 | children?: React.ReactNode; 9 | } 10 | 11 | const Divider: FC = ({ children, ...props }) => { 12 | const dividerRef = useRef(null); 13 | const clientXOnMouseDown = useRef(null); 14 | const isDraggingToGrowth = useRef(null); 15 | const context = useStateContext(); 16 | 17 | if (!context) { 18 | throw new Error("Pane is not wrapped in Panes component"); 19 | } 20 | 21 | const getMouseClientX = (e: MouseEvent | TouchEvent): number => { 22 | if (e instanceof MouseEvent) { 23 | return e.clientX; 24 | } 25 | return e.touches[0].clientX; 26 | }; 27 | 28 | const handleMouseMove = (e: MouseEvent | TouchEvent) => { 29 | const eClientX = getMouseClientX(e); 30 | const movementFromStart = Math.abs( 31 | eClientX - (clientXOnMouseDown.current as number), 32 | ); 33 | const dividerRect = dividerRef.current?.getBoundingClientRect(); 34 | const mouseMoveStartThreshold = (dividerRect?.width || 0) / 2; 35 | const isInteractingWithDivider = 36 | context.state.activePaneId && context.state.activePaneId === props.paneId; 37 | if ( 38 | !isInteractingWithDivider || 39 | movementFromStart < mouseMoveStartThreshold 40 | ) 41 | return; 42 | try { 43 | isDraggingToGrowth.current = true; 44 | const dividerClientX = (dividerRect?.left || 0) + mouseMoveStartThreshold; 45 | const mouseMovementInPx = eClientX - dividerClientX; 46 | const newWidth = 47 | context.state.panes[props.paneId as number].width + mouseMovementInPx; 48 | context.updatePaneWidth(props.paneId as number, newWidth); 49 | context.setPixelsTravelled(mouseMovementInPx); 50 | } catch (e) { 51 | logError(e); 52 | handleMouseUp(); 53 | } 54 | }; 55 | 56 | const handleMouseUp = () => { 57 | isDraggingToGrowth.current = null; 58 | context.resetInteractionState(); 59 | }; 60 | 61 | const handleMouseDown = (e: MouseEvent | TouchEvent) => { 62 | const isInteractingWithAnotherDivider = 63 | context.state.activePaneId && context.state.activePaneId !== props.paneId; 64 | if (isInteractingWithAnotherDivider) return; 65 | context.setActivePane(props.paneId as number); 66 | clientXOnMouseDown.current = getMouseClientX(e); 67 | 68 | if (window.getSelection) { 69 | window.getSelection()?.removeAllRanges(); 70 | } 71 | 72 | attachPaneDividerInteractionListeners({ 73 | mouseMoveCallback: handleMouseMove, 74 | mouseUpCallback: handleMouseUp, 75 | }); 76 | }; 77 | 78 | return ( 79 |
80 | {!children ? ( 81 |
handleMouseDown(e.nativeEvent)} 84 | onTouchStart={(e) => handleMouseDown(e.nativeEvent)} 85 | className="turtle-panes__divider-target" 86 | data-testid={`divider-${props.paneId}`} 87 | >
88 | ) : ( 89 |
handleMouseDown(e.nativeEvent)} 92 | onTouchStart={(e) => handleMouseDown(e.nativeEvent)} 93 | className="turtle-panes__divider-custom" 94 | data-testid={`divider-${props.paneId}`} 95 | > 96 | {children} 97 |
98 | )} 99 |
100 | ); 101 | }; 102 | 103 | export default Divider; 104 | -------------------------------------------------------------------------------- /demo-components/vue/UseItForDashboards.vue: -------------------------------------------------------------------------------- 1 | 74 | 108 | 111 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Turtle Panes 2 | 3 | Thank you for your interest in contributing to **Turtle Panes**! This project is designed to be a framework-agnostic state management library for both React and Vue, and we welcome high-quality contributions. Please read this guide carefully before submitting a pull request. 4 | 5 | ## Table of Contents 6 | 7 | - [Contributing to Turtle Panes](#contributing-to-turtle-panes) 8 | - [Table of Contents](#table-of-contents) 9 | - [Code of Conduct](#code-of-conduct) 10 | - [Development](#development) 11 | - [How to Contribute](#how-to-contribute) 12 | - [Feature Requests \& Proposals](#feature-requests--proposals) 13 | - [Bug Reports](#bug-reports) 14 | - [Submitting Code Changes](#submitting-code-changes) 15 | - [Code Standards](#code-standards) 16 | - [Testing Requirements](#testing-requirements) 17 | - [Pull Request Checklist](#pull-request-checklist) 18 | 19 | --- 20 | 21 | ## Code of Conduct 22 | 23 | By participating in this project, you agree to uphold a respectful and collaborative environment. Any form of harassment or discrimination will not be tolerated. 24 | 25 | ## Development 26 | 27 | To start development: 28 | 29 | 1. Install dependencies: 30 | ```bash 31 | npm install 32 | ``` 33 | 34 | 2. Run development server: 35 | - For Vue demo: 36 | ```bash 37 | npm run dev 38 | ``` 39 | - For React demo: 40 | ```bash 41 | npm run dev:react 42 | ``` 43 | 44 | Both servers will hot-reload as you make changes. 45 | 46 | ## How to Contribute 47 | 48 | ### Feature Requests & Proposals 49 | 50 | If you have an idea for a new feature, please **open an issue** first. Each feature request should clearly describe: 51 | 52 | - **What the feature is** (detailed description) 53 | - **Why it is needed** (problem it solves, use cases) 54 | - **How it aligns with the project's goals** (ensure compatibility with React and Vue) 55 | - **Any alternatives considered** (if applicable) 56 | 57 | We will discuss and approve features before implementation to ensure they align with the vision of *Turtle Panes*. 58 | 59 | ### Bug Reports 60 | 61 | If you find a bug, please open an issue with: 62 | 63 | - A **clear description** of the issue 64 | - Steps to reproduce the problem 65 | - Expected vs. actual behavior 66 | - A minimal reproducible example (CodeSandbox, GitHub repo, or inline code snippet) 67 | 68 | ### Submitting Code Changes 69 | 70 | Once a feature or bug fix is approved, follow these steps: 71 | 72 | 1. **Fork the repository** and create a feature branch (`feature/your-feature-name` or `bugfix/issue-number`). 73 | 2. Follow the **Code Standards** and **Testing Requirements** (see below). 74 | 3. Ensure your changes **do not introduce external dependencies**. 75 | 4. Submit a **pull request (PR)** with: 76 | - A **clear description** of the changes 77 | - Reference to the issue it addresses (if applicable) 78 | - A summary of new/updated unit tests 79 | 80 | ## Code Standards 81 | 82 | All contributions must adhere to the following: 83 | 84 | - **No external libraries.** 85 | - Code should be **modular, readable, and maintainable**. 86 | - Use **TypeScript** for type safety. 87 | - Maintain the **existing project structure** (`helpers`, `state`, `types`). 88 | - Keep as much logic as possible in the @turtle-panes/core package. 89 | 90 | ## Testing Requirements 91 | 92 | Every new feature or bug fix **must include unit tests**: 93 | 94 | - Tests should be added in the appropriate file. 95 | - Use **Jest** as the test runner. 96 | - Anything added to @turtle-panes/core must be tested well 97 | - Ensure **100% test coverage** for new code. 98 | - Run tests locally (`npm run test`) before submitting. 99 | 100 | ## Pull Request Checklist 101 | 102 | Before submitting your PR, make sure you: 103 | 104 | - Have an Issue linked to it 105 | - Have tested properly 106 | - Have written clear description on what the PR contains 107 | - What is the problem 108 | - What is expected 109 | 110 | We appreciate your contributions and look forward to building *Turtle Panes* together! 111 | 112 | -------------------------------------------------------------------------------- /demo-components/react/UseItForDashboards.tsx: -------------------------------------------------------------------------------- 1 | import Panes from "@turtle-panes/react"; 2 | import '@turtle-panes/react/style' 3 | 4 | import Section from "./Section.tsx"; 5 | import Checkbox from "./Checkbox.tsx"; 6 | import { useRef, useMemo, useState } from "react"; 7 | import { ExposedFunctions } from "../../packages/core/src/types/index.ts"; 8 | import '../styles/UseItForDashboards.scss'; 9 | 10 | const UseItForDashboards: React.FC = () => { 11 | const panesRef = useRef(null); 12 | const panes = useMemo(() => { 13 | return [1, 2, 3, 4].map((id) => ({ 14 | id, 15 | isChecked: true, 16 | })); 17 | }, [panesRef.current]); 18 | const [paneVisibilityState, setPaneVisibilityState] = useState( 19 | panes.map((p) => ({ id: p.id, value: true })), 20 | ); 21 | 22 | const handlePaneCheckbox = (id: number, value: boolean) => { 23 | const cloned = structuredClone(paneVisibilityState); 24 | const idx = cloned.findIndex((p) => p.id === id); 25 | cloned[idx].value = value; 26 | setPaneVisibilityState(cloned); 27 | 28 | if (value) { 29 | panesRef.current?.reShowPane(id); 30 | } else { 31 | panesRef.current?.hidePane(id); 32 | } 33 | }; 34 | 35 | const handleOnPanesHidden = (hiddenPaneIds: number[]) => { 36 | const cloned = structuredClone(paneVisibilityState); 37 | cloned.forEach((p) => { 38 | p.value = !hiddenPaneIds.includes(p.id); 39 | }); 40 | setPaneVisibilityState(cloned); 41 | }; 42 | 43 | return ( 44 |
45 |
48 |

Use it to make dashboards

49 |

50 | This library was inspired first and foremost for building multi pane 51 | dashboards. 52 |

53 |
54 | 55 | 60 | 61 |
62 |
63 |
64 | {[1, 2, 3, 4, 5].map((_, index) => ( 65 |
66 |
70 |
71 | ))} 72 |
73 |
74 |
75 |
76 | 77 | 78 |
79 |
80 |
81 |
82 | 83 | 84 |
85 |
86 |
90 |
91 |
92 | 93 | 94 |
95 |
96 |
100 |
101 |
102 |
103 |
104 | {paneVisibilityState.map((pane) => ( 105 | handlePaneCheckbox(pane.id, value)} 109 | > 110 | Pane {pane.id} 111 | 112 | ))} 113 |
114 |
115 | ); 116 | }; 117 | 118 | export default UseItForDashboards; 119 | -------------------------------------------------------------------------------- /packages/react/src/Panes.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FC, 3 | useImperativeHandle, 4 | forwardRef, 5 | ReactNode, 6 | useEffect, 7 | useRef, 8 | useMemo, 9 | } from "react"; 10 | import Pane, { PaneProps } from "./Pane"; 11 | import Divider, { DividerProps } from "./Divider"; 12 | import "./style.scss"; 13 | import { StateProvider, useStateContext } from "./react-state-adapter"; 14 | import { ExposedFunctions } from "@turtle-panes/core/types"; 15 | import { useComputedHooks } from "./hooks/usePaneComputedHooks.react"; 16 | 17 | interface PanesProps { 18 | children: ReactNode; 19 | style?: React.CSSProperties; 20 | className?: string; 21 | onPanesHidden?: (...args: any[]) => any; 22 | Panes?: FC; 23 | Divider?: FC; 24 | } 25 | 26 | const Panes = forwardRef((props, ref) => { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | }); 33 | 34 | const PanesInner = forwardRef( 35 | ({ children, style, className, onPanesHidden }, ref) => { 36 | const panesWrapperRef = useRef(null); 37 | const context = useStateContext(); 38 | const previousHiddenPanesRef = useRef([]); 39 | const { isInteractingWithADivider } = useComputedHooks(null, context); 40 | 41 | let resizeObserver: ResizeObserver | null = null; 42 | const updateContainerWidthOnResize = () => { 43 | if (!panesWrapperRef.current || window.ResizeObserver === undefined) 44 | return; 45 | resizeObserver = new ResizeObserver((entries) => { 46 | const resizedPanesWrapperRef = entries.find( 47 | (entry: ResizeObserverEntry) => 48 | entry.target.isSameNode(panesWrapperRef.current), 49 | ); 50 | if (!resizedPanesWrapperRef) return; 51 | const { scrollWidth } = resizedPanesWrapperRef.target as HTMLElement; 52 | context.handleContainerResize( 53 | resizedPanesWrapperRef.contentRect.width, 54 | scrollWidth, 55 | ); 56 | }); 57 | 58 | resizeObserver.observe(panesWrapperRef.current as Element); 59 | }; 60 | 61 | useEffect(() => { 62 | const wrapperElement = panesWrapperRef.current; 63 | const { width = 0 } = wrapperElement?.getBoundingClientRect() || {}; 64 | context.setContainerWidth(width); 65 | updateContainerWidthOnResize(); 66 | return () => { 67 | // TODO: this throws errors, figure out why 68 | // context.resetState(); 69 | resizeObserver?.disconnect(); 70 | }; 71 | }, []); 72 | 73 | useEffect(() => { 74 | const currentHiddenPanes = Object.values(context.state.panes) 75 | .filter((pane) => !pane.isVisible) 76 | .map((pane) => pane.id); 77 | 78 | const prevHiddenPanes = previousHiddenPanesRef.current; 79 | const hasChanges = 80 | JSON.stringify(currentHiddenPanes) !== JSON.stringify(prevHiddenPanes); 81 | 82 | if (hasChanges) { 83 | previousHiddenPanesRef.current = currentHiddenPanes; 84 | onPanesHidden && onPanesHidden(currentHiddenPanes); 85 | } 86 | }, [context.state.panes]); 87 | 88 | const reShowPane = (id: number) => { 89 | if (context.state.panes[id].isVisible) return; 90 | context.reShowPane(id); 91 | }; 92 | 93 | const hidePane = (id: number) => { 94 | if (!context.state.panes[id].isVisible) return; 95 | context.hidePaneManually(id); 96 | }; 97 | 98 | const hiddenPanes = () => { 99 | return Object.values(context.state.panes).filter( 100 | (pane) => !pane.isVisible, 101 | ); 102 | }; 103 | 104 | useImperativeHandle(ref, () => ({ 105 | reShowPane, 106 | hidePane, 107 | hiddenPanes, 108 | })); 109 | 110 | const classNames = useMemo(() => { 111 | return [ 112 | "turtle-panes__wrapper", 113 | className, 114 | isInteractingWithADivider && "is-resizing", 115 | ].filter((i) => i); 116 | }, [isInteractingWithADivider]); 117 | 118 | return ( 119 |
120 | {children} 121 |
122 | ); 123 | }, 124 | ); 125 | 126 | Panes.displayName = "Panes"; 127 | 128 | // Define a type that includes both the component and the static properties 129 | type PanesComponent = typeof Panes & { 130 | Pane: typeof Pane; 131 | Divider: typeof Divider; 132 | }; 133 | 134 | // Assign static properties to Panes using Object.assign 135 | Object.assign(Panes, { 136 | Pane, 137 | Divider, 138 | }); 139 | 140 | export default Panes as PanesComponent; 141 | -------------------------------------------------------------------------------- /packages/core/src/state/context.spec.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "./context"; 2 | import { Pane, ContextType } from "../types"; 3 | import { describe, beforeEach, it, expect } from "vitest"; 4 | 5 | export const getDefaultPane = (id: number): Pane => ({ 6 | width: 100, 7 | minWidth: 50, 8 | hideOnMinWidthExceeded: true, 9 | preventContentOverflow: true, 10 | isVisible: true, 11 | id: id, 12 | isFlex: false, 13 | }); 14 | 15 | describe("Context", () => { 16 | let context: ContextType; 17 | 18 | beforeEach(() => { 19 | context = createContext(); 20 | }); 21 | 22 | it("should add a new pane and assign an id to it", async () => { 23 | await context.addPane({ 24 | ...getDefaultPane(1), 25 | id: null, 26 | }); 27 | await context.addPane({ 28 | ...getDefaultPane(1), 29 | id: null, 30 | width: 150, 31 | }); 32 | 33 | expect(Object.keys(context.state.panes).length).toBe(2); 34 | expect(context.state.panes[1].width).toBe(100); 35 | expect(context.state.panes[2].width).toBe(150); 36 | }); 37 | 38 | it("should update a pane's properties correctly", () => { 39 | context.setPanes({ 40 | 1: getDefaultPane(1), 41 | }); 42 | context.updatePane(1, { width: 150 }); 43 | 44 | expect(context.state.panes[1].width).toBe(150); 45 | }); 46 | 47 | it("should set container width correctly", () => { 48 | context.setContainerWidth(300); 49 | 50 | expect(context.state.containerWidth).toBe(300); 51 | }); 52 | 53 | it("should reset interaction state correctly", () => { 54 | context.setActivePane(1); 55 | context.setPixelsTravelled(50); 56 | context.resetInteractionState(); 57 | 58 | expect(context.state.activePaneId).toBeNull(); 59 | expect(context.state.pixelsTravelled).toBe(0); 60 | }); 61 | 62 | it("should reset the entire state correctly", () => { 63 | context.setPanes({ 64 | 1: getDefaultPane(1), 65 | }); 66 | context.setContainerWidth(300); 67 | context.setActivePane(1); 68 | context.setPixelsTravelled(50); 69 | context.resetState(); 70 | 71 | expect(Object.keys(context.state.panes).length).toBe(0); 72 | expect(context.state.containerWidth).toBe(0); 73 | expect(context.state.activePaneId).toBeNull(); 74 | expect(context.state.pixelsTravelled).toBe(0); 75 | }); 76 | 77 | it("on interaction start -> should update widthAtStartOfInteraction for all panes", () => { 78 | context.setPanes({ 79 | 1: getDefaultPane(1), 80 | 2: getDefaultPane(2), 81 | }); 82 | context.setActivePane(1); 83 | 84 | expect(context.state.panes[1].widthAtStartOfInteraction).toBe(100); 85 | expect(context.state.panes[2].widthAtStartOfInteraction).toBe(100); 86 | }); 87 | describe("hiding and reshowing panes", () => { 88 | it("when a pane is hidden from its own divider -> should distribute width appropriately to closest siblings", () => { 89 | context.setPanes({ 90 | 1: getDefaultPane(1), 91 | 2: getDefaultPane(2), 92 | 3: getDefaultPane(3), 93 | 4: getDefaultPane(4), 94 | }); 95 | context.setContainerWidth(400); 96 | context.setActivePane(2); 97 | context.hidePane(2); 98 | 99 | expect(context.state.panes[3].width).toBe(200); 100 | }); 101 | it("when a pane is manually hidden -> should distribute width appropriately to closest siblings ", () => { 102 | context.setPanes({ 103 | 1: getDefaultPane(1), 104 | 2: getDefaultPane(2), 105 | 3: getDefaultPane(3), 106 | 4: getDefaultPane(4), 107 | }); 108 | context.setContainerWidth(400); 109 | context.hidePaneManually(4); 110 | 111 | expect(context.state.panes[3].width).toBe(200); 112 | 113 | context.reShowPane(4); 114 | expect(context.state.panes[4].width).toBe(100); 115 | 116 | context.hidePaneManually(1); 117 | expect(context.state.panes[2].width).toBe(200); 118 | context.reShowPane(1); 119 | expect(context.state.panes[2].width).toBe(100); 120 | 121 | context.hidePaneManually(3); 122 | expect(context.state.panes[2].width).toBe(200); 123 | }); 124 | it("when a hidden pane is re-shown -> should allocate enough space to re-show it", () => { 125 | context.setPanes({ 126 | 1: getDefaultPane(1), 127 | 2: getDefaultPane(2), 128 | }); 129 | context.setContainerWidth(200); 130 | context.setActivePane(1); 131 | context.hidePaneManually(1); 132 | expect(context.state.panes[2].width).toBe(200); 133 | 134 | context.reShowPane(1); 135 | expect(context.state.panes[1].width).toBe(100); 136 | expect(context.state.panes[2].width).toBe(100); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /packages/vue/src/Divider.vue: -------------------------------------------------------------------------------- 1 | 23 | 26 | 110 | 152 | -------------------------------------------------------------------------------- /packages/react/src/Pane.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useMemo, useEffect, useState, useRef } from "react"; 2 | import { useStateContext } from "./react-state-adapter"; 3 | import Divider, { DividerProps } from "./Divider"; 4 | import { PaneComponentProps } from "@turtle-panes/core/types"; 5 | import { defaultPaneProps } from "@turtle-panes/core"; 6 | import * as React from "react"; 7 | import { useComputedHooks } from "./hooks/usePaneComputedHooks.react"; 8 | 9 | export interface PaneProps extends PaneComponentProps { 10 | children?: React.ReactNode; 11 | divider?: React.ReactElement; 12 | style?: React.CSSProperties; 13 | id?: number; 14 | } 15 | 16 | const Pane: FC = ({ children, style, id: propId, ...props }) => { 17 | props = { ...defaultPaneProps, ...props }; 18 | 19 | const context = useStateContext(); 20 | const [id, setId] = useState(propId ?? null); 21 | const isAddedToContext = useRef(false); 22 | const paneWrapperRef = useRef(null); 23 | const paneContentRef = useRef(null); 24 | 25 | useEffect(() => { 26 | if (isAddedToContext.current || id) return; 27 | isAddedToContext.current = true; 28 | 29 | const targetRef = props.isFlex ? paneWrapperRef : paneContentRef; 30 | const clientRect = targetRef.current?.getBoundingClientRect() || null; 31 | if (!clientRect) return; 32 | 33 | let newWidth = clientRect.width || props.minWidth || 0; 34 | if (props.minWidth != null && newWidth < props.minWidth) { 35 | newWidth = props.minWidth; 36 | } else if (props.maxWidth != null && newWidth > props.maxWidth) { 37 | newWidth = props.maxWidth; 38 | } 39 | 40 | const paneId = context.addPaneSync({ 41 | width: props.initialWidth ? props.initialWidth : newWidth, 42 | minWidth: props.minWidth || 0, 43 | maxWidth: props.maxWidth, 44 | isVisible: props.isVisible || true, 45 | hideOnMinWidthExceeded: props.hideOnMinWidthExceeded, 46 | preventContentOverflow: props.preventContentOverflow, 47 | isFlex: props.isFlex || false, 48 | id, 49 | }); 50 | 51 | setId(paneId); 52 | }, [id, props, context]); 53 | 54 | const { isPaneVisible, isContainerMounted, widthFromContext } = 55 | useComputedHooks(id, context); 56 | 57 | const classNames = useMemo(() => { 58 | const map: { [key: string]: boolean } = { 59 | "turtle-panes__pane": true, 60 | "is-hidden": !isPaneVisible && isContainerMounted, 61 | 'is-visible': !!isPaneVisible && !!isContainerMounted, 62 | "is-flex": props.isFlex || false, 63 | [`pane-${id}`]: true, 64 | }; 65 | return Object.keys(map) 66 | .filter((key) => map[key]) 67 | .join(" "); 68 | }, [isPaneVisible, isContainerMounted, id]); 69 | 70 | const computedContentStyle = useMemo(() => { 71 | if (!id) { 72 | const initialStyles: React.CSSProperties = { 73 | minWidth: `${props.minWidth}px`, 74 | }; 75 | if (props.maxWidth) initialStyles["maxWidth"] = `${props.maxWidth}px`; 76 | if (props.initialWidth) 77 | initialStyles["width"] = `${props.initialWidth}px`; 78 | 79 | return initialStyles; 80 | } 81 | 82 | const pane = context.state.panes[id]; 83 | return { 84 | width: pane?.isVisible ? `${pane?.width}px` : "0px", 85 | visibility: pane?.isVisible ? "visible" : "hidden", 86 | overflow: props.allowOverflow ? "auto" : "hidden", 87 | }; 88 | }, [id, props, isPaneVisible]); 89 | 90 | // TODO: better naming 91 | const styleToPreventFlexOvergrowingOnPaneReappearance = 92 | useMemo(() => { 93 | return props.isFlex && widthFromContext 94 | ? { maxWidth: `${widthFromContext}px` } 95 | : {}; 96 | }, [props.isFlex, widthFromContext]); 97 | 98 | const combinedWrapperStyle = useMemo(() => { 99 | return { ...style, ...styleToPreventFlexOvergrowingOnPaneReappearance }; 100 | }, [style, styleToPreventFlexOvergrowingOnPaneReappearance]); 101 | 102 | useEffect(() => { 103 | if(!id) return; 104 | const widthOfContent = paneContentRef.current?.scrollWidth || 0; 105 | const widthProvidedByPane = paneContentRef.current?.clientWidth || 0; 106 | context.updatePaneContentWidth( 107 | id as number, 108 | widthOfContent, 109 | widthProvidedByPane, 110 | ); 111 | }, [context.state.pixelsTravelled, id]); 112 | 113 | return ( 114 |
119 |
124 |
{children}
125 |
126 | {props.divider ? ( 127 | React.cloneElement(props.divider, { paneId: id }) 128 | ) : ( 129 | 130 | )} 131 |
132 | ); 133 | }; 134 | 135 | export default Pane; 136 | -------------------------------------------------------------------------------- /demo-components/styles/App.scss: -------------------------------------------------------------------------------- 1 | @mixin center-demo-pane { 2 | .turtle-panes__section-description { 3 | align-self: center; 4 | text-align: center; 5 | } 6 | @media (max-width: 768px) { 7 | .turtle-panes__section-description { 8 | align-self: flex-start; 9 | text-align: left; 10 | } 11 | } 12 | } 13 | @mixin right-demo-pane { 14 | .turtle-panes__section-description { 15 | align-self: flex-end; 16 | text-align: right; 17 | } 18 | @media (max-width: 768px) { 19 | .turtle-panes__section-description { 20 | align-self: flex-start; 21 | text-align: left; 22 | } 23 | } 24 | } 25 | @mixin right-demo-pane-with-non-flex-container { 26 | .turtle-panes__section-description { 27 | align-self: flex-end; 28 | text-align: right; 29 | } 30 | .turtle-panes__wrapper { 31 | margin-left: auto !important; 32 | } 33 | @media (max-width: 768px) { 34 | .turtle-panes__section-description { 35 | align-self: flex-start; 36 | text-align: left; 37 | } 38 | .turtle-panes__wrapper { 39 | margin-left: 0px !important; 40 | } 41 | } 42 | } 43 | 44 | @mixin divider-on-mobile-increased-area { 45 | @media (max-width: 768px) { 46 | .turtle-panes__divider-target { 47 | width: 30px; 48 | } 49 | } 50 | } 51 | 52 | @mixin minimum-height-on-mobile { 53 | @media (max-width: 768px) { 54 | .demo-section:not(:first-child) .turtle-panes__wrapper { 55 | min-height: 45vh; 56 | } 57 | } 58 | } 59 | 60 | .turtle-panes { 61 | &__demo-wrapper { 62 | display: flex; 63 | flex-direction: column; 64 | width: 100%; 65 | @include divider-on-mobile-increased-area(); 66 | @include minimum-height-on-mobile(); 67 | } 68 | &__demo-wrapper > .demo-section { 69 | &:nth-child(8n + 1) { 70 | background-color: var(--yellow); 71 | color: var(--black); 72 | } 73 | &:nth-child(8n + 2) { 74 | background-color: var(--white); 75 | .turtle-panes__wrapper { 76 | min-height: 250px; 77 | } 78 | @include center-demo-pane(); 79 | } 80 | &:nth-child(8n + 3) { 81 | background-color: var(--blue); 82 | color: var(--white); 83 | .turtle-panes__wrapper { 84 | color: var(--black); 85 | } 86 | @include right-demo-pane(); 87 | .turtle-panes__wrapper { 88 | min-height: 250px; 89 | } 90 | } 91 | &:nth-child(8n + 4) { 92 | background-color: var(--black); 93 | color: var(--white); 94 | .turtle-panes__wrapper { 95 | color: var(--black); 96 | } 97 | } 98 | &:nth-child(8n + 5) { 99 | background-color: var(--red); 100 | @include right-demo-pane-with-non-flex-container(); 101 | } 102 | &:nth-child(8n + 6) { 103 | background-color: var(--yellow); 104 | color: var(--black); 105 | } 106 | &:nth-child(8n + 7) { 107 | background-color: var(--blue); 108 | color: var(--white); 109 | .turtle-panes__wrapper { 110 | color: var(--black); 111 | } 112 | @include right-demo-pane-with-non-flex-container(); 113 | } 114 | &:nth-child(8n + 8) { 115 | background-color: var(--white); 116 | @include center-demo-pane(); 117 | } 118 | } 119 | &__description { 120 | margin: 4cqw; 121 | font-size: clamp(1rem, 3cqw, 2rem); 122 | font-weight: 300; 123 | } 124 | &__links { 125 | display: flex; 126 | padding: 40px 0px; 127 | align-items: center; 128 | justify-content: center; 129 | gap: 20px; 130 | a { 131 | display: flex; 132 | } 133 | } 134 | &__section-description { 135 | h1, 136 | p { 137 | padding: 0px; 138 | margin-left: 0px; 139 | margin-right: 0px; 140 | margin-bottom: 10px; 141 | } 142 | h1 { 143 | font-size: clamp(2rem, 6cqw, 3rem); 144 | font-weight: 900; 145 | margin-bottom: 10px; 146 | line-height: 1.2; 147 | } 148 | p { 149 | max-width: 600px; 150 | font-weight: 300; 151 | font-size: clamp(1rem, 3cqw, 3rem); 152 | margin-top: 0px; 153 | } 154 | @media (max-width: 768px) { 155 | h1 { 156 | font-size: clamp(1.8rem, 6cqw, 3rem); 157 | } 158 | p { 159 | font-size: clamp(0.8rem, 3cqw, 3rem); 160 | } 161 | } 162 | } 163 | 164 | &__demo-button { 165 | background-color: var(--black); 166 | color: var(--white); 167 | padding: 10px 20px; 168 | border: none; 169 | border-radius: 5px; 170 | cursor: pointer; 171 | outline: none; 172 | } 173 | 174 | &__demo-box { 175 | display: flex; 176 | justify-content: center; 177 | align-items: center; 178 | background-color: var(--white); 179 | padding: 20px; 180 | flex: 1; 181 | min-height: 240px; 182 | height: 100%; 183 | } 184 | } 185 | 186 | .demo-intro { 187 | &__source-code { 188 | display: inline-flex; 189 | gap: 10px; 190 | svg { 191 | width: 18px; 192 | height: 18px; 193 | } 194 | } 195 | &__button { 196 | display: flex; 197 | align-items: center; 198 | border: solid 2px var(--black); 199 | padding: 8px 16px; 200 | gap: 8px; 201 | border-radius: 20px; 202 | position: relative; 203 | font-size: clamp(16px, 2cqw, 2rem); 204 | background-color: var(--white); 205 | cursor: pointer; 206 | svg { 207 | width: 20px; 208 | height: 20px; 209 | } 210 | &:hover { 211 | background-color: var(--black); 212 | color: var(--white); 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /packages/vue/src/Pane.vue: -------------------------------------------------------------------------------- 1 | 29 | 137 | 166 | -------------------------------------------------------------------------------- /packages/core/src/state/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ContextAsyncMethods, 3 | ContextSyncMethods, 4 | ContextType, 5 | Pane, 6 | } from "../types"; 7 | import { 8 | getNewPanesWidthToFillVacuumAfterPaneIsHidden, 9 | getNewPanesWidthToAccomodateReaddedPanes, 10 | calculateNewWidthForPanes, 11 | getPaneSiblingId, 12 | sortByClosestToPane, 13 | getVisiblePanes, 14 | getNewPanesWidthToAccomodateContainerShrinking, 15 | getNewPanesWidthToAccommodateContainerGrowing, 16 | getNewPanesWidthToAccomodateTargetShrinking, 17 | } from "./contextHelpers"; 18 | import { useLogs } from "../helpers/useLogs"; 19 | 20 | const { logInfo } = useLogs(); 21 | 22 | export const createState = () => ({ 23 | panes: {}, 24 | containerWidth: 0, 25 | activePaneId: null, 26 | pixelsTravelled: 0, 27 | }); 28 | 29 | export const createProxyState = ( 30 | initialState: ContextType["state"], 31 | onUpdate: any, 32 | ) => { 33 | return new Proxy(initialState, { 34 | set( 35 | target: ContextType["state"], 36 | prop: keyof ContextType["state"], 37 | value: any, 38 | ) { 39 | target[prop] = value; 40 | onUpdate && onUpdate({ ...target }); 41 | return true; 42 | }, 43 | }); 44 | }; 45 | 46 | export const createActions = ( 47 | state: ContextType["state"], 48 | ): ContextAsyncMethods & ContextSyncMethods => ({ 49 | getPanes() { 50 | return JSON.parse(JSON.stringify(state.panes)); 51 | }, 52 | getNextId() { 53 | return Object.keys(state.panes).length + 1; 54 | }, 55 | setPanes(newPanes) { 56 | state.panes = newPanes; 57 | }, 58 | addPane(pane) { 59 | return new Promise((resolve) => { 60 | const id = pane?.id || Object.keys(state.panes).length + 1; 61 | state.panes[id] = { ...pane, id }; 62 | resolve(id); 63 | }); 64 | }, 65 | addPaneSync(pane) { 66 | const id = pane?.id || Object.keys(state.panes).length + 1; 67 | state.panes[id] = { ...pane, id }; 68 | return id; 69 | }, 70 | updatePane(paneId, newProps) { 71 | if (!paneId) return; 72 | state.panes[paneId] = { 73 | ...state.panes[paneId], 74 | ...newProps, 75 | }; 76 | }, 77 | updatePaneWidth(paneId, newWidth) { 78 | const panes = this.getPanes(); 79 | if (panes[paneId].isVisible === false) { 80 | logInfo("Pane is hidden. Cannot update width."); 81 | return; 82 | } 83 | const rightSiblingId = getPaneSiblingId( 84 | paneId, 85 | getVisiblePanes(panes), 86 | "right", 87 | ); 88 | if (!rightSiblingId) { 89 | logInfo("Pane has no right sibling. Cannot update width."); 90 | return; 91 | } 92 | const [targetPane, siblingPane] = [panes[paneId], panes[rightSiblingId]]; 93 | 94 | const { 95 | targetPane: updatedTargetPane, 96 | siblingPane: updatedSiblingPane, 97 | undistributedSpace, 98 | } = calculateNewWidthForPanes(targetPane, siblingPane, newWidth); 99 | if (undistributedSpace) { 100 | const otherPanesSorted = sortByClosestToPane( 101 | getVisiblePanes(state.panes), 102 | paneId, 103 | ).filter((p) => ![paneId, rightSiblingId].includes(p.id)); 104 | getNewPanesWidthToAccomodateTargetShrinking( 105 | otherPanesSorted, 106 | undistributedSpace, 107 | ).forEach((updatedPane) => { 108 | panes[updatedPane.id].width = updatedPane.width; 109 | }); 110 | } 111 | panes[paneId] = updatedTargetPane; 112 | panes[rightSiblingId] = updatedSiblingPane; 113 | this.setPanes(panes); 114 | }, 115 | updatePaneContentWidth(paneId, widthOfContent, widthProvidedByPane) { 116 | const panes = this.getPanes(); 117 | const isOverflowing = widthOfContent > widthProvidedByPane; 118 | const wasPaneAlreadyOverflowing = panes[paneId].isOverflowing; 119 | if (isOverflowing && wasPaneAlreadyOverflowing) { 120 | logInfo("Pane is already overflowing. Cannot update width."); 121 | return; 122 | } 123 | if (wasPaneAlreadyOverflowing && widthProvidedByPane === widthOfContent) { 124 | logInfo( 125 | "Pane width was reset to content. State is still overflowing.", 126 | ); 127 | return; 128 | } 129 | panes[paneId].widthOfContent = widthOfContent; 130 | panes[paneId].widthProvidedByPane = widthProvidedByPane; 131 | panes[paneId].isOverflowing = isOverflowing; 132 | this.setPanes(panes); 133 | }, 134 | reShowPane(paneId) { 135 | const currentPanes = this.getPanes(); 136 | currentPanes[paneId].isVisible = true; 137 | 138 | const currentPanesArr = getVisiblePanes(currentPanes); 139 | const wasHiddenFromRight = currentPanes[paneId].hiddenFromSide === "right"; 140 | if (wasHiddenFromRight) currentPanesArr.reverse(); 141 | 142 | const sortedByClosestToTargetPane = sortByClosestToPane( 143 | currentPanesArr, 144 | paneId, 145 | ); 146 | 147 | logInfo("Container width", state.containerWidth); 148 | const emptySpace = 149 | state.containerWidth - 150 | sortedByClosestToTargetPane.reduce((acc, curr) => (acc += curr.width), 0); 151 | sortedByClosestToTargetPane.length && 152 | getNewPanesWidthToAccomodateReaddedPanes( 153 | currentPanes[paneId], 154 | sortedByClosestToTargetPane, 155 | emptySpace, 156 | ).forEach((updatedPane) => { 157 | currentPanes[updatedPane.id].width = updatedPane.width; 158 | }); 159 | 160 | this.setPanes(currentPanes); 161 | }, 162 | showPane(paneId) { 163 | const panes = this.getPanes(); 164 | panes[paneId].isVisible = true; 165 | this.setPanes(panes); 166 | }, 167 | hidePane(paneId) { 168 | if (!state.activePaneId) { 169 | throw new Error( 170 | "hidePane can only be called during interaction. Use hidePaneManually to hide panes without interaction.", 171 | ); 172 | } 173 | const currentPanes = this.getPanes(); 174 | currentPanes[paneId].hiddenFromSide = 175 | state.activePaneId === paneId ? "right" : "left"; 176 | 177 | const currentPanesArr = getVisiblePanes(currentPanes); 178 | const wasHiddenFromRight = currentPanes[paneId].hiddenFromSide === "right"; 179 | if (wasHiddenFromRight) currentPanesArr.reverse(); 180 | 181 | const sortedByClosestToTargetPane = sortByClosestToPane( 182 | currentPanesArr, 183 | paneId, 184 | ); 185 | 186 | const { width, minWidth } = currentPanes[paneId]; 187 | const freedUpSpace = width || minWidth; 188 | 189 | sortedByClosestToTargetPane.length && 190 | getNewPanesWidthToFillVacuumAfterPaneIsHidden( 191 | freedUpSpace, 192 | sortedByClosestToTargetPane, 193 | ).forEach((updatedPane) => { 194 | currentPanes[updatedPane.id].width = updatedPane.width; 195 | }); 196 | 197 | currentPanes[paneId].width = currentPanes[paneId] 198 | .widthAtStartOfInteraction as number; 199 | currentPanes[paneId].isVisible = false; 200 | this.setPanes(currentPanes); 201 | }, 202 | hidePaneManually(paneId) { 203 | const currentPanes = this.getPanes(); 204 | const isFirstVisiblePane = getVisiblePanes(currentPanes)[0]?.id === paneId; 205 | currentPanes[paneId].hiddenFromSide = isFirstVisiblePane ? "right" : "left"; 206 | 207 | const currentPanesArr = getVisiblePanes(currentPanes); 208 | const wasHiddenFromRight = currentPanes[paneId].hiddenFromSide === "right"; 209 | if (wasHiddenFromRight) currentPanesArr.reverse(); 210 | 211 | const sortedByClosestToTargetPane = sortByClosestToPane( 212 | currentPanesArr, 213 | paneId, 214 | ); 215 | 216 | const { width, minWidth } = currentPanes[paneId]; 217 | const freedUpSpace = width || minWidth; 218 | 219 | sortedByClosestToTargetPane.length && 220 | getNewPanesWidthToFillVacuumAfterPaneIsHidden( 221 | freedUpSpace, 222 | sortedByClosestToTargetPane, 223 | ).forEach((updatedPane) => { 224 | currentPanes[updatedPane.id].width = updatedPane.width; 225 | }); 226 | 227 | currentPanes[paneId].isVisible = false; 228 | this.setPanes(currentPanes); 229 | }, 230 | setActivePane(paneId) { 231 | state.activePaneId = paneId; 232 | this.updateWidthsAtStartOfInteraction(); 233 | }, 234 | updateWidthsAtStartOfInteraction() { 235 | const panes = this.getPanes(); 236 | Object.values(panes).forEach((pane: Pane) => { 237 | pane.widthAtStartOfInteraction = pane.width; 238 | }); 239 | this.setPanes(panes); 240 | }, 241 | setPixelsTravelled(pixels) { 242 | state.pixelsTravelled = pixels; 243 | }, 244 | setContainerWidth(width) { 245 | state.containerWidth = width; 246 | }, 247 | handleContainerResize(containerSize, widthUsedFromContent) { 248 | if (!state.containerWidth || state.activePaneId) return; 249 | const panes = this.getPanes(); 250 | const difference = containerSize - state.containerWidth;// - overflownValue; 251 | if (difference === 0) return; 252 | 253 | const visiblePanes = getVisiblePanes(panes); 254 | const [isShrinking, isGrowing] = [difference < 0, difference > 0]; 255 | this.setContainerWidth(containerSize); 256 | if (isGrowing && containerSize < widthUsedFromContent) { 257 | logInfo("Container is growing. Content will be expanding."); 258 | } else if (isShrinking && containerSize < widthUsedFromContent) { 259 | logInfo("Content is overflowing. Container is shrinking."); 260 | } 261 | 262 | if (isShrinking) { 263 | getNewPanesWidthToAccomodateContainerShrinking( 264 | visiblePanes, 265 | Math.abs(difference), 266 | ).forEach((updatedPane) => { 267 | panes[updatedPane.id] = { 268 | ...panes[updatedPane.id], 269 | ...updatedPane, 270 | }; 271 | }); 272 | } else if (isGrowing) { 273 | getNewPanesWidthToAccommodateContainerGrowing( 274 | visiblePanes, 275 | difference, 276 | ).forEach((updatedPane) => { 277 | panes[updatedPane.id] = { 278 | ...panes[updatedPane.id], 279 | ...updatedPane, 280 | }; 281 | }); 282 | } 283 | this.setPanes(panes); 284 | }, 285 | resetInteractionState() { 286 | state.activePaneId = null; 287 | state.pixelsTravelled = 0; 288 | }, 289 | resetState() { 290 | this.setPanes({}); 291 | this.setContainerWidth(0); 292 | this.resetInteractionState(); 293 | }, 294 | }); 295 | 296 | export const createContext = () => { 297 | const state = createState(); 298 | const actions = createActions(state); 299 | return { state, ...actions }; 300 | }; 301 | -------------------------------------------------------------------------------- /packages/core/src/state/contextHelpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Pane, 3 | PaneMap, 4 | PaneWithUpdatedWidth, 5 | PartialPaneWithId, 6 | } from "../types"; 7 | import { useLogs } from "../helpers/useLogs"; 8 | const { logError, logInfo } = useLogs(); 9 | 10 | export const getNewPanesWidthToAccomodateTargetShrinking = ( 11 | sortedByClosestToTargetPane: Pane[], 12 | newSpaceAvailable: number, 13 | ): PaneWithUpdatedWidth[] => { 14 | const panesThatNeedUpdates = []; 15 | let filledUpSpace = 0; 16 | for (const pane of sortedByClosestToTargetPane) { 17 | const remainingSpaceToBeFilled = newSpaceAvailable - filledUpSpace; 18 | const { width, maxWidth, id } = pane; 19 | const newPaneWidth = maxWidth 20 | ? Math.min(width + remainingSpaceToBeFilled, maxWidth) 21 | : width + remainingSpaceToBeFilled; 22 | panesThatNeedUpdates.push({ 23 | id, 24 | width: newPaneWidth, 25 | }); 26 | filledUpSpace += newPaneWidth - width; 27 | if (filledUpSpace === newSpaceAvailable) break; 28 | } 29 | return panesThatNeedUpdates; 30 | }; 31 | 32 | export const getNewPanesWidthToAccomodateTargetGrowing = ( 33 | sortedByClosestToTargetPane: Pane[], 34 | newSpaceRequired: number, 35 | ): PaneWithUpdatedWidth[] => { 36 | const panesThatNeedUpdates = []; 37 | let widthFilled = 0; 38 | for (const pane of sortedByClosestToTargetPane) { 39 | const remainingSpaceToBeAdded = newSpaceRequired - widthFilled; 40 | const { width, minWidth, id } = pane; 41 | const newPaneWidth = Math.max(width - remainingSpaceToBeAdded, minWidth); 42 | panesThatNeedUpdates.push({ 43 | id, 44 | width: newPaneWidth, 45 | }); 46 | widthFilled += width - newPaneWidth; 47 | if (widthFilled === newSpaceRequired) break; 48 | } 49 | if (widthFilled < newSpaceRequired) { 50 | throw new Error("Not enough space to accommodate the target pane growing"); 51 | } 52 | return panesThatNeedUpdates; 53 | }; 54 | 55 | export const calculateNewWidthForPanes = ( 56 | targetPane: Pane, 57 | siblingPane: Pane, 58 | newWidth: number, 59 | ): { 60 | targetPane: Pane; 61 | siblingPane: Pane; 62 | undistributedSpace?: number; 63 | } => { 64 | const pixelsDiffFromLastWidth = newWidth - targetPane.width; 65 | 66 | const [targetPaneProposedNewWidth, siblingPaneProposedNewWidth] = [ 67 | targetPane.width + pixelsDiffFromLastWidth, 68 | siblingPane.width - pixelsDiffFromLastWidth, 69 | ]; 70 | 71 | const updatedPanes = [ 72 | { 73 | ...targetPane, 74 | proposedNewWidth: targetPaneProposedNewWidth, 75 | correctionDifference: 0, 76 | }, 77 | { 78 | ...siblingPane, 79 | proposedNewWidth: siblingPaneProposedNewWidth, 80 | correctionDifference: 0, 81 | }, 82 | ].map((pane) => { 83 | const useContentWidthAsMinWidth = 84 | pane.preventContentOverflow && 85 | pane.widthOfContent && 86 | pane.widthProvidedByPane && 87 | pane.widthOfContent > pane.widthProvidedByPane && 88 | pane.widthOfContent > pane.minWidth; 89 | const minWidth = useContentWidthAsMinWidth 90 | ? pane.widthOfContent! 91 | : pane.minWidth; 92 | if (pane.proposedNewWidth < minWidth) { 93 | pane.correctionDifference = pane.proposedNewWidth - minWidth; 94 | pane.proposedNewWidth = minWidth; 95 | if (pane.hideOnMinWidthExceeded) { 96 | pane.isVisible = false; 97 | pane.correctionDifference += pane.proposedNewWidth; 98 | } 99 | } 100 | if (pane.maxWidth != null && pane.proposedNewWidth > pane.maxWidth) { 101 | pane.correctionDifference = pane.proposedNewWidth - pane.maxWidth; 102 | pane.proposedNewWidth = pane.maxWidth; 103 | } 104 | return pane; 105 | }); 106 | 107 | const correctedPane = updatedPanes.find( 108 | (pane) => pane.correctionDifference !== 0, 109 | ); 110 | const toBeAdjustedPane = updatedPanes.find( 111 | (pane) => pane.correctionDifference === 0, 112 | ); 113 | if (correctedPane && toBeAdjustedPane) { 114 | toBeAdjustedPane.proposedNewWidth += correctedPane.correctionDifference; 115 | } 116 | 117 | const [oldTotalWidth, newTotalWidth] = [ 118 | updatedPanes.reduce((acc, curr) => acc + curr.width, 0), 119 | updatedPanes 120 | .filter((p) => p.isVisible) 121 | .reduce((acc, curr) => acc + curr.proposedNewWidth, 0), 122 | ]; 123 | 124 | // when panes get hidden upon minWidth, total width can be less (if the other pane has a maxWidth) 125 | const undistributedSpace = oldTotalWidth - newTotalWidth; 126 | 127 | const result = { 128 | targetPane: { 129 | ...targetPane, 130 | width: updatedPanes[0].proposedNewWidth, 131 | isVisible: updatedPanes[0].isVisible, 132 | }, 133 | siblingPane: { 134 | ...siblingPane, 135 | width: updatedPanes[1].proposedNewWidth, 136 | isVisible: updatedPanes[1].isVisible, 137 | }, 138 | }; 139 | return undistributedSpace ? { ...result, undistributedSpace } : result; 140 | }; 141 | 142 | export const getPaneSiblingId = ( 143 | paneId: Pane["id"], 144 | visiblePanes: Pane[], 145 | direction: "left" | "right", 146 | ) => { 147 | const paneIndex = visiblePanes.findIndex((p) => p.id === paneId); 148 | return visiblePanes[paneIndex + (direction === "left" ? -1 : 1)]?.id || null; 149 | }; 150 | 151 | export const sortByClosestToPane = (panes: Pane[], paneId: Pane["id"]) => { 152 | const panesWithVisibleIndex: Pane[] = panes.map((pane, idx) => ({ 153 | ...pane, 154 | visiblePaneIndex: idx, 155 | })); 156 | const targetVisibleIndex = panesWithVisibleIndex.find( 157 | (pane) => pane.id === paneId, 158 | )?.visiblePaneIndex; 159 | if (targetVisibleIndex == null) { 160 | throw new Error("Pane to be compared with is not visible"); 161 | } 162 | return panesWithVisibleIndex 163 | .filter((_, idx) => targetVisibleIndex !== idx) 164 | .sort((a, b) => { 165 | return ( 166 | Math.abs(a.visiblePaneIndex! - targetVisibleIndex!) - 167 | Math.abs(b.visiblePaneIndex! - targetVisibleIndex!) 168 | ); 169 | }); 170 | }; 171 | 172 | export const getVisiblePanes = (panes: PaneMap): Pane[] => { 173 | const paneValues: Pane[] = Object.values(JSON.parse(JSON.stringify(panes))); 174 | return paneValues 175 | .filter((pane) => pane.isVisible) 176 | .sort((a, b) => a.id - b.id); 177 | }; 178 | 179 | export const getNewPanesWidthToAccomodateReaddedPanes = ( 180 | paneToBeReadded: Pane, 181 | sortedByClosestToTargetPane: Pane[], 182 | emptySpace: number = 0, 183 | ): PaneWithUpdatedWidth[] => { 184 | if (sortedByClosestToTargetPane.some((p) => !p.isVisible)) { 185 | throw new Error( 186 | "Panes passed to getNewPanesWidthToAccomodateReaddedPanes must all be visible", 187 | ); 188 | } 189 | const { width: requiredWidth, minWidth: requiredMinWidth } = paneToBeReadded; 190 | let allocatedWidthForReaddingPane = emptySpace; 191 | const updatedVisiblePanes = []; 192 | for (let i = 0; i < sortedByClosestToTargetPane.length; i++) { 193 | const remainingAllocationNeeded = 194 | requiredWidth - allocatedWidthForReaddingPane; 195 | const toHaveWidthUpdatedPane = sortedByClosestToTargetPane[i]; 196 | const { width, minWidth, id } = toHaveWidthUpdatedPane; 197 | const proposedNewWidth = Math.max( 198 | width - remainingAllocationNeeded, 199 | minWidth, 200 | ); 201 | allocatedWidthForReaddingPane += width - proposedNewWidth; 202 | proposedNewWidth !== width && 203 | updatedVisiblePanes.push({ 204 | id, 205 | width: proposedNewWidth, 206 | }); 207 | if (allocatedWidthForReaddingPane >= requiredWidth) break; 208 | } 209 | if ( 210 | allocatedWidthForReaddingPane >= requiredMinWidth && 211 | allocatedWidthForReaddingPane !== requiredWidth 212 | ) { 213 | updatedVisiblePanes.push({ 214 | id: paneToBeReadded.id, 215 | width: allocatedWidthForReaddingPane, 216 | }); 217 | } 218 | 219 | if (allocatedWidthForReaddingPane < requiredMinWidth) { 220 | throw new Error("Not enough space to readd the pane"); 221 | } 222 | 223 | return updatedVisiblePanes; 224 | }; 225 | 226 | export const getNewPanesWidthToFillVacuumAfterPaneIsHidden = ( 227 | freedUpSpace: number, 228 | sortedByClosestToTargetPane: Pane[], 229 | ): PaneWithUpdatedWidth[] => { 230 | if (sortedByClosestToTargetPane.some((p) => !p.isVisible)) { 231 | throw new Error( 232 | "Panes passed to getNewPanesWidthToFillVacuumAfterPaneIsHidden must all be visible", 233 | ); 234 | } 235 | const panesThatNeedUpdates = []; 236 | let filledUpSpace = 0; 237 | for (let i = 0; i < sortedByClosestToTargetPane.length; i++) { 238 | const remainingFreeSpace = freedUpSpace - filledUpSpace; 239 | const pane = sortedByClosestToTargetPane[i]; 240 | const { width, maxWidth, id } = pane; 241 | let newPaneWidth = maxWidth 242 | ? Math.min(width + remainingFreeSpace, maxWidth) 243 | : width + remainingFreeSpace; 244 | panesThatNeedUpdates.push({ 245 | id, 246 | width: newPaneWidth, 247 | }); 248 | filledUpSpace += newPaneWidth - width; 249 | if (filledUpSpace === freedUpSpace) break; 250 | } 251 | 252 | return panesThatNeedUpdates; 253 | }; 254 | 255 | export const getNewPanesWidthToAccomodateContainerShrinking = ( 256 | visiblePanes: Pane[], 257 | widthRemovedFromContainer: number, 258 | ): PartialPaneWithId[] => { 259 | const panesFromRight = [...visiblePanes].reverse(); 260 | const panesThatNeedUpdates: { [key: number]: PartialPaneWithId } = {}; 261 | let removedSpace = 0; 262 | let remainingSpaceToBeRemoved = widthRemovedFromContainer; 263 | for (let idx in panesFromRight) { 264 | const pane = panesFromRight[parseInt(idx)]; 265 | remainingSpaceToBeRemoved = widthRemovedFromContainer - removedSpace; 266 | const { width, minWidth, id, hideOnMinWidthExceeded } = pane; 267 | let proposedNewPaneWidth = width - remainingSpaceToBeRemoved; 268 | const newPaneWidth = Math.max(proposedNewPaneWidth, minWidth); 269 | panesThatNeedUpdates[id] = { 270 | id, 271 | width: newPaneWidth, 272 | }; 273 | if (hideOnMinWidthExceeded && proposedNewPaneWidth < minWidth) { 274 | panesThatNeedUpdates[id].isVisible = false; 275 | panesFromRight[parseInt(idx) + 1].width += minWidth; 276 | removedSpace += width - minWidth; 277 | } else removedSpace += width - newPaneWidth; 278 | if (removedSpace >= widthRemovedFromContainer) break; 279 | } 280 | if (removedSpace > widthRemovedFromContainer) { 281 | logError("Removed too much space."); 282 | } else { 283 | logInfo('Necessary space to remove', widthRemovedFromContainer); 284 | logInfo('Removed space', removedSpace); 285 | } 286 | return Object.values(panesThatNeedUpdates); 287 | }; 288 | 289 | const sortByMiddleOut = (items: T[]): T[] => { 290 | const middleIndex = Math.floor(items.length / 2); 291 | const sortedItems: T[] = []; 292 | 293 | for (let i = 0; i < items.length; i++) { 294 | const isEvenIndex = i % 2 === 0; 295 | const offset = Math.floor((i + 1) / 2); 296 | const index = middleIndex + (isEvenIndex ? -offset : offset); 297 | sortedItems.push(items[index]); 298 | } 299 | 300 | return sortedItems; 301 | }; 302 | 303 | export const getNewPanesWidthToAccommodateContainerGrowing = ( 304 | visiblePanes: Pane[], 305 | widthAddedToContainer: number, 306 | ): PaneWithUpdatedWidth[] => { 307 | const panesThatNeedUpdates: { [key: number]: PaneWithUpdatedWidth } = {}; 308 | let widthFilled = 0; 309 | 310 | // Separate panes into flex and non-flex groups 311 | const flexPanes = sortByMiddleOut(visiblePanes.filter((pane) => pane.isFlex)); 312 | const nonFlexPanes = sortByMiddleOut( 313 | visiblePanes.filter((pane) => !pane.isFlex), 314 | ); 315 | 316 | // Reorder panes: flex first (middle-out), then non-flex (middle-out) 317 | const panesInOrder = [...flexPanes, ...nonFlexPanes]; 318 | 319 | // Distribute the added width 320 | for (const pane of panesInOrder) { 321 | const remainingSpaceToBeAdded = widthAddedToContainer - widthFilled; 322 | if (remainingSpaceToBeAdded <= 0) break; 323 | const { width, maxWidth, id } = pane; 324 | let newPaneWidth = Math.min( 325 | width + remainingSpaceToBeAdded, 326 | maxWidth || Infinity, 327 | ); 328 | 329 | panesThatNeedUpdates[id] = { 330 | id, 331 | width: newPaneWidth, 332 | }; 333 | 334 | widthFilled += newPaneWidth - width; 335 | if (widthFilled >= widthAddedToContainer) break; 336 | } 337 | 338 | if (widthFilled < widthAddedToContainer) { 339 | logError("Too much space left"); 340 | // throw new Error("Too much space to add to panes"); 341 | } 342 | 343 | return Object.values(panesThatNeedUpdates); 344 | }; 345 | 346 | export function getObjectDifferences(obj1: any, obj2: any): any { 347 | function compareObjects(o1: any, o2: any): any { 348 | const diffs: any = {}; 349 | 350 | for (const key in o1) { 351 | if (o1.hasOwnProperty(key)) { 352 | if ( 353 | typeof o1[key] === "object" && 354 | o1[key] !== null && 355 | !Array.isArray(o1[key]) 356 | ) { 357 | const nestedDiffs = compareObjects(o1[key], o2[key]); 358 | if (Object.keys(nestedDiffs).length > 0) { 359 | diffs[key] = nestedDiffs; 360 | } 361 | } else if (o1[key] !== o2[key]) { 362 | diffs[key] = { old: o1[key], new: o2[key] }; 363 | } 364 | } 365 | } 366 | 367 | for (const key in o2) { 368 | if (o2.hasOwnProperty(key) && !o1.hasOwnProperty(key)) { 369 | diffs[key] = { old: undefined, new: o2[key] }; 370 | } 371 | } 372 | 373 | return diffs; 374 | } 375 | 376 | return compareObjects(obj1, obj2); 377 | } 378 | -------------------------------------------------------------------------------- /packages/core/src/state/contextHelpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { Pane, PaneMap } from "../types"; 3 | 4 | import { getDefaultPane } from "./context.spec"; 5 | import { 6 | calculateNewWidthForPanes, 7 | getPaneSiblingId, 8 | sortByClosestToPane, 9 | getVisiblePanes, 10 | getNewPanesWidthToAccomodateReaddedPanes, 11 | getNewPanesWidthToFillVacuumAfterPaneIsHidden, 12 | getNewPanesWidthToAccomodateTargetShrinking, 13 | getNewPanesWidthToAccomodateTargetGrowing, 14 | getNewPanesWidthToAccomodateContainerShrinking, 15 | getNewPanesWidthToAccommodateContainerGrowing, 16 | } from "./contextHelpers"; 17 | 18 | describe("contextHelpers", () => { 19 | describe("getNewPanesWidthToAccomodateTargetShrinking : When a target pane is shrinking, the ordered siblings should get their widths updated to fill up the freed-up space", () => { 20 | it("should distribute 100 pixels of freed-up space properly to the array of panes", () => { 21 | const panes: Pane[] = [ 22 | { ...getDefaultPane(1), width: 100, maxWidth: 200 }, 23 | { ...getDefaultPane(2), width: 150, maxWidth: 250 }, 24 | { ...getDefaultPane(3), width: 200, maxWidth: 300 }, 25 | ]; 26 | const newSpaceAvailable = 100; 27 | 28 | const result = getNewPanesWidthToAccomodateTargetShrinking( 29 | panes, 30 | newSpaceAvailable, 31 | ); 32 | 33 | expect(result).toEqual([{ id: 1, width: 200 }]); 34 | }); 35 | 36 | it("should distribute remaining space to the next pane if giving all to one exceeds its maxWidth", () => { 37 | const panes: Pane[] = [ 38 | { ...getDefaultPane(1), width: 100, maxWidth: 150 }, 39 | { ...getDefaultPane(2), width: 150, maxWidth: 250 }, 40 | { ...getDefaultPane(3), width: 200, maxWidth: 300 }, 41 | ]; 42 | const newSpaceAvailable = 100; 43 | 44 | const result = getNewPanesWidthToAccomodateTargetShrinking( 45 | panes, 46 | newSpaceAvailable, 47 | ); 48 | 49 | expect(result).toEqual([ 50 | { id: 1, width: 150 }, 51 | { id: 2, width: 200 }, 52 | ]); 53 | }); 54 | }); 55 | 56 | describe("getNewPanesWidthToAccomodateTargetGrowing : When a target pane is growing, the ordered siblings should get their widths updated to provide the required space", () => { 57 | it("should take 100 pixels of space properly from the array of panes", () => { 58 | const panes: Pane[] = [ 59 | { ...getDefaultPane(1), width: 200, minWidth: 100 }, 60 | { ...getDefaultPane(2), width: 250, minWidth: 150 }, 61 | { ...getDefaultPane(3), width: 300, minWidth: 200 }, 62 | ]; 63 | const spaceNeeded = 100; 64 | 65 | const result = getNewPanesWidthToAccomodateTargetGrowing( 66 | panes, 67 | spaceNeeded, 68 | ); 69 | 70 | expect(result).toEqual([{ id: 1, width: 100 }]); 71 | }); 72 | 73 | it("should take remaining space from the next pane if taking all from one exceeds its minWidth", () => { 74 | const panes: Pane[] = [ 75 | { ...getDefaultPane(1), width: 150, minWidth: 100 }, 76 | { ...getDefaultPane(2), width: 250, minWidth: 150 }, 77 | { ...getDefaultPane(3), width: 300, minWidth: 200 }, 78 | ]; 79 | const spaceNeeded = 100; 80 | 81 | const result = getNewPanesWidthToAccomodateTargetGrowing( 82 | panes, 83 | spaceNeeded, 84 | ); 85 | 86 | expect(result).toEqual([ 87 | { id: 1, width: 100 }, 88 | { id: 2, width: 200 }, 89 | ]); 90 | }); 91 | 92 | it("should throw an error if there is not enough space to accommodate the target pane growing", () => { 93 | const panes: Pane[] = [ 94 | { ...getDefaultPane(1), width: 150, minWidth: 150 }, 95 | { ...getDefaultPane(2), width: 250, minWidth: 250 }, 96 | { ...getDefaultPane(3), width: 300, minWidth: 300 }, 97 | ]; 98 | const spaceNeeded = 100; 99 | 100 | expect(() => 101 | getNewPanesWidthToAccomodateTargetGrowing(panes, spaceNeeded), 102 | ).toThrow("Not enough space to accommodate the target pane growing"); 103 | }); 104 | }); 105 | describe("calculateNewWidthForPanes : When dragging divider, two panes will have their widths updated", () => { 106 | it("should calculate new widths correctly", () => { 107 | const targetPane: Pane = getDefaultPane(1); 108 | const siblingPane: Pane = { 109 | ...getDefaultPane(2), 110 | width: 200, 111 | }; 112 | const newWidth = 150; 113 | 114 | const result = calculateNewWidthForPanes( 115 | targetPane, 116 | siblingPane, 117 | newWidth, 118 | ); 119 | 120 | expect(result).toEqual({ 121 | targetPane: { 122 | ...targetPane, 123 | width: 150, 124 | isVisible: true, 125 | }, 126 | siblingPane: { 127 | ...siblingPane, 128 | width: 150, 129 | isVisible: true, 130 | }, 131 | }); 132 | }); 133 | 134 | it("should respect minWidth and maxWidth constraints", () => { 135 | const targetPane: Pane = { 136 | ...getDefaultPane(1), 137 | maxWidth: 120, 138 | }; 139 | const siblingPane: Pane = { 140 | ...getDefaultPane(2), 141 | width: 200, 142 | }; 143 | const newWidth = 130; 144 | 145 | const result = calculateNewWidthForPanes( 146 | targetPane, 147 | siblingPane, 148 | newWidth, 149 | ); 150 | 151 | expect(result).toEqual({ 152 | targetPane: { 153 | ...targetPane, 154 | width: 120, 155 | isVisible: true, 156 | }, 157 | siblingPane: { 158 | ...siblingPane, 159 | width: 180, 160 | isVisible: true, 161 | }, 162 | }); 163 | }); 164 | 165 | it("should apply correctionDifference when new width is less than minWidth", () => { 166 | const targetPane: Pane = { 167 | ...getDefaultPane(1), 168 | width: 150, 169 | minWidth: 100, 170 | maxWidth: 300, 171 | }; 172 | const siblingPane: Pane = { 173 | ...getDefaultPane(2), 174 | width: 200, 175 | minWidth: 100, 176 | maxWidth: 300, 177 | }; 178 | const newWidth = 80; // Less than targetPane.minWidth 179 | 180 | const result = calculateNewWidthForPanes( 181 | targetPane, 182 | siblingPane, 183 | newWidth, 184 | ); 185 | 186 | expect(result).toEqual({ 187 | targetPane: { 188 | ...targetPane, 189 | width: 100, 190 | isVisible: false, 191 | }, 192 | siblingPane: { 193 | ...siblingPane, 194 | width: 350, 195 | isVisible: true, 196 | }, 197 | }); 198 | }); 199 | 200 | it("should apply correctionDifference when new width is more than maxWidth", () => { 201 | const targetPane: Pane = { 202 | ...getDefaultPane(1), 203 | width: 150, 204 | minWidth: 100, 205 | maxWidth: 200, 206 | }; 207 | const siblingPane: Pane = { 208 | ...getDefaultPane(2), 209 | width: 200, 210 | minWidth: 100, 211 | maxWidth: 300, 212 | }; 213 | const newWidth = 250; // More than targetPane.maxWidth 214 | 215 | const result = calculateNewWidthForPanes( 216 | targetPane, 217 | siblingPane, 218 | newWidth, 219 | ); 220 | 221 | expect(result).toEqual({ 222 | targetPane: { 223 | ...targetPane, 224 | width: 200, 225 | isVisible: true, 226 | }, 227 | siblingPane: { 228 | ...siblingPane, 229 | width: 150, 230 | isVisible: true, 231 | }, 232 | }); 233 | }); 234 | it("should return unallocatedSpace when maxWidth is reached and correction can't be applied anymore", () => { 235 | const targetPane: Pane = { 236 | ...getDefaultPane(1), 237 | width: 200, 238 | minWidth: 100, 239 | maxWidth: 300, 240 | hideOnMinWidthExceeded: true, 241 | }; 242 | const siblingPane: Pane = { 243 | ...getDefaultPane(2), 244 | width: 100, 245 | minWidth: 100, 246 | maxWidth: 200, 247 | }; 248 | const newWidth = 50; 249 | 250 | expect( 251 | calculateNewWidthForPanes(targetPane, siblingPane, newWidth), 252 | ).toEqual({ 253 | targetPane: { 254 | ...targetPane, 255 | width: 100, 256 | isVisible: false, 257 | }, 258 | siblingPane: { 259 | ...siblingPane, 260 | width: 200, 261 | isVisible: true, 262 | }, 263 | undistributedSpace: 100, 264 | }); 265 | }); 266 | 267 | it("should use content width as minWidth when preventContentOverflow is true and content is overflowing", () => { 268 | const targetPane: Pane = { 269 | ...getDefaultPane(1), 270 | width: 200, 271 | minWidth: 50, 272 | widthOfContent: 200, 273 | widthProvidedByPane: 199, 274 | preventContentOverflow: true, 275 | hideOnMinWidthExceeded: false 276 | }; 277 | const siblingPane: Pane = { 278 | ...getDefaultPane(2), 279 | width: 200, 280 | }; 281 | 282 | const result = calculateNewWidthForPanes( 283 | targetPane, 284 | siblingPane, 285 | 190, 286 | ); 287 | 288 | expect(result).toEqual({ 289 | targetPane: { 290 | ...targetPane, 291 | width: 200, // Should use widthOfContent as minimum 292 | }, 293 | siblingPane: { 294 | ...siblingPane, 295 | width: 200, 296 | }, 297 | }); 298 | }); 299 | 300 | it("should not use content width as minWidth when preventContentOverflow is false", () => { 301 | const targetPane: Pane = { 302 | ...getDefaultPane(1), 303 | width: 200, 304 | minWidth: 50, 305 | widthOfContent: 150, 306 | widthProvidedByPane: 100, 307 | preventContentOverflow: false, 308 | }; 309 | const siblingPane: Pane = { 310 | ...getDefaultPane(2), 311 | width: 200, 312 | }; 313 | const newWidth = 80; 314 | 315 | const result = calculateNewWidthForPanes( 316 | targetPane, 317 | siblingPane, 318 | newWidth, 319 | ); 320 | 321 | expect(result).toEqual({ 322 | targetPane: { 323 | ...targetPane, 324 | width: 80, // Should use requested width since it's above minWidth 325 | isVisible: true, 326 | }, 327 | siblingPane: { 328 | ...siblingPane, 329 | width: 320, 330 | isVisible: true, 331 | }, 332 | }); 333 | }); 334 | 335 | it("should not use content width if it's smaller than minWidth", () => { 336 | const targetPane: Pane = { 337 | ...getDefaultPane(1), 338 | width: 200, 339 | minWidth: 100, 340 | widthOfContent: 80, 341 | widthProvidedByPane: 60, 342 | preventContentOverflow: true, 343 | hideOnMinWidthExceeded: false, 344 | }; 345 | const siblingPane: Pane = { 346 | ...getDefaultPane(2), 347 | preventContentOverflow: true, 348 | hideOnMinWidthExceeded: false, 349 | width: 200, 350 | }; 351 | const newWidth = 50; 352 | 353 | const result = calculateNewWidthForPanes( 354 | targetPane, 355 | siblingPane, 356 | newWidth, 357 | ); 358 | 359 | expect(result).toEqual({ 360 | targetPane: { 361 | ...targetPane, 362 | width: 100, // Should use minWidth since it's larger than content width 363 | isVisible: true, 364 | }, 365 | siblingPane: { 366 | ...siblingPane, 367 | width: 300, 368 | isVisible: true, 369 | }, 370 | }); 371 | }); 372 | 373 | it("should not use content width if it's smaller than provided width", () => { 374 | const targetPane: Pane = { 375 | ...getDefaultPane(1), 376 | width: 200, 377 | minWidth: 50, 378 | widthOfContent: 120, 379 | widthProvidedByPane: 150, 380 | preventContentOverflow: true, 381 | }; 382 | const siblingPane: Pane = { 383 | ...getDefaultPane(2), 384 | width: 200, 385 | }; 386 | const newWidth = 80; 387 | 388 | const result = calculateNewWidthForPanes( 389 | targetPane, 390 | siblingPane, 391 | newWidth, 392 | ); 393 | 394 | expect(result).toEqual({ 395 | targetPane: { 396 | ...targetPane, 397 | width: 80, // Should use requested width since content is narrower than provided width 398 | isVisible: true, 399 | }, 400 | siblingPane: { 401 | ...siblingPane, 402 | width: 320, 403 | isVisible: true, 404 | }, 405 | }); 406 | }); 407 | 408 | it("should ignore content width when widthOfContent is undefined", () => { 409 | const targetPane: Pane = { 410 | ...getDefaultPane(1), 411 | width: 200, 412 | minWidth: 50, 413 | widthProvidedByPane: 150, 414 | preventContentOverflow: true, 415 | }; 416 | const siblingPane: Pane = { 417 | ...getDefaultPane(2), 418 | width: 200, 419 | }; 420 | const newWidth = 80; 421 | 422 | const result = calculateNewWidthForPanes( 423 | targetPane, 424 | siblingPane, 425 | newWidth, 426 | ); 427 | 428 | expect(result).toEqual({ 429 | targetPane: { 430 | ...targetPane, 431 | width: 80, // Should use requested width since content width is undefined 432 | isVisible: true, 433 | }, 434 | siblingPane: { 435 | ...siblingPane, 436 | width: 320, 437 | isVisible: true, 438 | }, 439 | }); 440 | }); 441 | }); 442 | 443 | describe("getPaneSiblingId : Used in multiple functions to get the sibling of a pane (to its left, or right)", () => { 444 | it("should return the correct sibling id", () => { 445 | const panes: Pane[] = [ 446 | getDefaultPane(1), 447 | { 448 | ...getDefaultPane(2), 449 | width: 200, 450 | }, 451 | { 452 | ...getDefaultPane(3), 453 | width: 300, 454 | }, 455 | ]; 456 | 457 | expect(getPaneSiblingId(2, panes, "left")).toBe(1); 458 | expect(getPaneSiblingId(2, panes, "right")).toBe(3); 459 | }); 460 | 461 | it("should return null if there is no sibling", () => { 462 | const panes: Pane[] = [ 463 | getDefaultPane(1), 464 | { 465 | ...getDefaultPane(2), 466 | width: 200, 467 | }, 468 | ]; 469 | 470 | expect(getPaneSiblingId(1, panes, "left")).toBeNull(); 471 | expect(getPaneSiblingId(2, panes, "right")).toBeNull(); 472 | }); 473 | }); 474 | 475 | describe("sortByClosestToPane : Used when hiding or re-showing panes to distribute freed up width, or allocate new width", () => { 476 | it("should sort panes by closest to target pane", () => { 477 | const panes: Pane[] = [ 478 | getDefaultPane(1), 479 | { 480 | ...getDefaultPane(2), 481 | width: 200, 482 | }, 483 | { 484 | ...getDefaultPane(3), 485 | width: 300, 486 | }, 487 | { 488 | ...getDefaultPane(4), 489 | width: 400, 490 | }, 491 | ]; 492 | 493 | const result = sortByClosestToPane(panes, 2); 494 | 495 | expect(result.map((pane) => pane.id)).toEqual([1, 3, 4]); 496 | }); 497 | 498 | it("should throw an error if target pane is not visible", () => { 499 | const panes: Pane[] = [ 500 | getDefaultPane(1), 501 | { 502 | ...getDefaultPane(3), 503 | width: 300, 504 | }, 505 | ]; 506 | 507 | expect(() => sortByClosestToPane(panes, 2)).toThrow( 508 | "Pane to be compared with is not visible", 509 | ); 510 | }); 511 | }); 512 | 513 | describe("getVisiblePanes : Used to make calculations based on visible panes", () => { 514 | it("should return only visible panes", () => { 515 | const panes: PaneMap = { 516 | 1: getDefaultPane(1), 517 | 2: { 518 | ...getDefaultPane(2), 519 | isVisible: false, 520 | }, 521 | 3: getDefaultPane(3), 522 | }; 523 | 524 | const result = getVisiblePanes(panes); 525 | 526 | expect(result.map((pane) => pane.id)).toEqual([1, 3]); 527 | }); 528 | }); 529 | 530 | describe("getNewPanesWidthToAccomodateReaddedPanes : When a pane is re-added, there needs to be new space allocated", () => { 531 | it("should return panes that need updating widths for accomodating new pane", () => { 532 | const panes: Pane[] = [ 533 | getDefaultPane(1), 534 | { 535 | ...getDefaultPane(2), 536 | width: 200, 537 | }, 538 | { 539 | ...getDefaultPane(3), 540 | width: 300, 541 | }, 542 | { 543 | ...getDefaultPane(4), 544 | isVisible: false, 545 | }, 546 | ]; 547 | const paneToBeReadded = panes[3]; 548 | 549 | paneToBeReadded.isVisible = true; 550 | const sortedByClosestToTargetPane = sortByClosestToPane(panes, 4); 551 | const result = getNewPanesWidthToAccomodateReaddedPanes( 552 | paneToBeReadded, 553 | sortedByClosestToTargetPane, 554 | ); 555 | 556 | expect(result).toEqual([{ id: 3, width: 200 }]); 557 | }); 558 | 559 | it("if preferred width (last known) of readded pane cannot be accomodated, it should use its minWidth", () => { 560 | const panes: Pane[] = [ 561 | { 562 | ...getDefaultPane(1), 563 | width: 100, 564 | minWidth: 50, 565 | }, 566 | { 567 | ...getDefaultPane(2), 568 | width: 100, 569 | minWidth: 100, 570 | }, 571 | { 572 | ...getDefaultPane(3), 573 | width: 100, 574 | minWidth: 100, 575 | }, 576 | { 577 | ...getDefaultPane(4), 578 | width: 100, 579 | isVisible: false, 580 | minWidth: 50, 581 | }, 582 | ]; 583 | const paneToBeReadded = panes[3]; 584 | 585 | paneToBeReadded.isVisible = true; 586 | const sortedByClosestToTargetPane = sortByClosestToPane(panes, 4); 587 | const result = getNewPanesWidthToAccomodateReaddedPanes( 588 | paneToBeReadded, 589 | sortedByClosestToTargetPane, 590 | ); 591 | 592 | expect(result).toEqual([ 593 | { id: 1, width: 50 }, 594 | { id: 4, width: 50 }, 595 | ]); 596 | }); 597 | 598 | it("if there is empty space in the container, it should utilize that to fit the preferred size of the readded pane", () => { 599 | const panes: Pane[] = [ 600 | { 601 | ...getDefaultPane(1), 602 | width: 100, 603 | minWidth: 50, 604 | }, 605 | { 606 | ...getDefaultPane(2), 607 | width: 100, 608 | minWidth: 100, 609 | }, 610 | { 611 | ...getDefaultPane(3), 612 | width: 100, 613 | minWidth: 100, 614 | }, 615 | { 616 | ...getDefaultPane(4), 617 | width: 100, 618 | isVisible: false, 619 | minWidth: 50, 620 | }, 621 | ]; 622 | const paneToBeReadded = panes[3]; 623 | 624 | paneToBeReadded.isVisible = true; 625 | const sortedByClosestToTargetPane = sortByClosestToPane(panes, 4); 626 | const result = getNewPanesWidthToAccomodateReaddedPanes( 627 | paneToBeReadded, 628 | sortedByClosestToTargetPane, 629 | 50, 630 | ); 631 | 632 | expect(result).toEqual([{ id: 1, width: 50 }]); 633 | }); 634 | 635 | it("if there is empty space in the container enough for preferred size, it should not re-size other panes", () => { 636 | const panes: Pane[] = [ 637 | { 638 | ...getDefaultPane(1), 639 | width: 100, 640 | minWidth: 50, 641 | }, 642 | { 643 | ...getDefaultPane(2), 644 | width: 100, 645 | minWidth: 100, 646 | }, 647 | { 648 | ...getDefaultPane(3), 649 | width: 100, 650 | minWidth: 100, 651 | }, 652 | { 653 | ...getDefaultPane(4), 654 | width: 100, 655 | isVisible: false, 656 | minWidth: 50, 657 | }, 658 | ]; 659 | const paneToBeReadded = panes[3]; 660 | 661 | paneToBeReadded.isVisible = true; 662 | const sortedByClosestToTargetPane = sortByClosestToPane(panes, 4); 663 | const result = getNewPanesWidthToAccomodateReaddedPanes( 664 | paneToBeReadded, 665 | sortedByClosestToTargetPane, 666 | 100, 667 | ); 668 | 669 | expect(result).toEqual([]); 670 | }); 671 | 672 | it("should throw an error if there is not enough space to readd the pane", () => { 673 | const panes: Pane[] = [ 674 | getDefaultPane(1), 675 | { 676 | ...getDefaultPane(2), 677 | width: 100, 678 | minWidth: 100, 679 | }, 680 | { 681 | ...getDefaultPane(3), 682 | width: 100, 683 | minWidth: 100, 684 | }, 685 | { 686 | ...getDefaultPane(4), 687 | isVisible: false, 688 | minWidth: 100, 689 | }, 690 | ]; 691 | 692 | const paneToBeReadded = panes[3]; 693 | paneToBeReadded.isVisible = true; 694 | const sortedByClosestToTargetPane = sortByClosestToPane(panes, 4); 695 | expect(() => 696 | getNewPanesWidthToAccomodateReaddedPanes( 697 | paneToBeReadded, 698 | sortedByClosestToTargetPane, 699 | ), 700 | ).toThrow("Not enough space to readd the pane"); 701 | }); 702 | 703 | it("should throw an error if any of the panes passed are not visible", () => { 704 | const panes: Pane[] = [ 705 | getDefaultPane(1), 706 | { 707 | ...getDefaultPane(2), 708 | }, 709 | { 710 | ...getDefaultPane(3), 711 | width: 300, 712 | }, 713 | { 714 | ...getDefaultPane(4), 715 | isVisible: false, 716 | }, 717 | ]; 718 | 719 | expect(() => 720 | getNewPanesWidthToAccomodateReaddedPanes(panes[3], panes), 721 | ).toThrow( 722 | "Panes passed to getNewPanesWidthToAccomodateReaddedPanes must all be visible", 723 | ); 724 | }); 725 | }); 726 | 727 | describe("getNewPanesWidthToFillVacuumAfterPaneIsHidden : When a pane is hidden, the space needs to be distributed to its siblings", () => { 728 | it("should calculate new widths to fill vacuum after pane is hidden", () => { 729 | const panes: Pane[] = [ 730 | getDefaultPane(1), 731 | { 732 | ...getDefaultPane(2), 733 | width: 200, 734 | }, 735 | { 736 | ...getDefaultPane(3), 737 | width: 300, 738 | }, 739 | ]; 740 | const freedUpSpace = 100; 741 | const sortedByClosestToTargetPane = sortByClosestToPane(panes, 1); 742 | 743 | const panesThatNeedUpdates = 744 | getNewPanesWidthToFillVacuumAfterPaneIsHidden( 745 | freedUpSpace, 746 | sortedByClosestToTargetPane, 747 | ); 748 | 749 | expect(panesThatNeedUpdates).toEqual([{ id: 2, width: 300 }]); 750 | }); 751 | 752 | it("should throw an error if any of the panes passed are not visible", () => { 753 | const panes: Pane[] = [ 754 | getDefaultPane(1), 755 | { 756 | ...getDefaultPane(2), 757 | isVisible: false, 758 | }, 759 | { 760 | ...getDefaultPane(3), 761 | width: 300, 762 | }, 763 | ]; 764 | const freedUpSpace = 100; 765 | 766 | expect(() => 767 | getNewPanesWidthToFillVacuumAfterPaneIsHidden(freedUpSpace, panes), 768 | ).toThrow( 769 | "Panes passed to getNewPanesWidthToFillVacuumAfterPaneIsHidden must all be visible", 770 | ); 771 | }); 772 | }); 773 | 774 | describe("getNewPanesWidthToAccomodateContainerShrinking: When the container is shrinking, the panes need to adjust their widths", () => { 775 | it("should calculate new widths for panes to accomodate container shrinking", () => { 776 | const panes: Pane[] = [ 777 | getDefaultPane(1), 778 | { 779 | ...getDefaultPane(2), 780 | }, 781 | { 782 | ...getDefaultPane(3), 783 | }, 784 | ]; 785 | const difference = 20; 786 | 787 | const newPanes = getNewPanesWidthToAccomodateContainerShrinking( 788 | panes, 789 | difference, 790 | ); 791 | 792 | expect(newPanes).toEqual([{ id: 3, width: 80 }]); 793 | }); 794 | 795 | it("should hide a pane if hideOnMinWidth is true, and give that width to its sibling, which then gets reduced", () => { 796 | const panes: Pane[] = [ 797 | getDefaultPane(1), 798 | { 799 | ...getDefaultPane(2), 800 | }, 801 | { 802 | ...getDefaultPane(3), 803 | }, 804 | ]; 805 | const difference = 80; 806 | 807 | const newPanes = getNewPanesWidthToAccomodateContainerShrinking( 808 | panes, 809 | difference, 810 | ); 811 | 812 | expect(newPanes).toEqual([ 813 | { 814 | id: 2, 815 | width: 120, 816 | }, 817 | { id: 3, width: 50, isVisible: false }, 818 | // On first iteration, we attempt to remove 100 from 3 819 | // But minWidth is 50, so we remove 50 and then set its isVisible to false 820 | // hiding it and giving the remaining 50 to 2 821 | // and then continuing to remove the remaining 30 from 2 822 | ]); 823 | }); 824 | }); 825 | 826 | describe("getNewPanesWidthToAccommodateContainerGrowing: When the container is growing, the panes need to adjust their widths", () => { 827 | it("should spread added space from middle out", () => { 828 | const panes: Pane[] = [ 829 | getDefaultPane(1), 830 | { 831 | ...getDefaultPane(2), 832 | }, 833 | { 834 | ...getDefaultPane(3), 835 | }, 836 | ]; 837 | const addedWidth = 20; 838 | 839 | const newPanes = getNewPanesWidthToAccommodateContainerGrowing( 840 | panes, 841 | addedWidth, 842 | ); 843 | 844 | expect(newPanes).toEqual([{ id: 2, width: 120 }]); 845 | }); 846 | 847 | it("should prioritize flex panes over non-flex panes when growing", () => { 848 | const panes: Pane[] = [ 849 | getDefaultPane(1), 850 | { 851 | ...getDefaultPane(2), 852 | }, 853 | { 854 | ...getDefaultPane(3), 855 | isFlex: true, 856 | }, 857 | ]; 858 | const addedWidth = 20; 859 | 860 | const newPanes = getNewPanesWidthToAccommodateContainerGrowing( 861 | panes, 862 | addedWidth, 863 | ); 864 | 865 | expect(newPanes).toEqual([{ id: 3, width: 120 }]); 866 | }); 867 | 868 | it("should spread width over the next pane if maxWidth is reached", () => { 869 | const panes: Pane[] = [ 870 | getDefaultPane(1), 871 | { 872 | ...getDefaultPane(2), 873 | maxWidth: 150, 874 | }, 875 | { 876 | ...getDefaultPane(3), 877 | }, 878 | ]; 879 | const addedWidth = 100; 880 | 881 | const newPanes = getNewPanesWidthToAccommodateContainerGrowing( 882 | panes, 883 | addedWidth, 884 | ); 885 | 886 | expect(newPanes).toEqual([ 887 | { id: 2, width: 150 }, 888 | { id: 3, width: 150 }, 889 | ]); 890 | }); 891 | }); 892 | }); 893 | --------------------------------------------------------------------------------