mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-23 20:55:41 +00:00
feat: implement sidebar accordion sections (#6373)
* feat: implement sidebar accordion sections - Added SidebarAccordionContext for managing expanded sections. - Introduced SidebarContent component to render sections dynamically. - Created CollectionsSection and ApiSpecsSection for sidebar organization. - Updated Sidebar component to utilize new sections and context. - Enhanced StyledWrapper for improved layout and styling of sidebar sections. * refactor: streamline Sidebar component and enhance styling * feat: enhance SidebarSection with ActionIcon and improved hover styles * fix: update useEffect dependencies in SidebarAccordionContext and enhance accessibility in SidebarSection * style: increase gap in StyledWrapper and reintroduce cursor pointer for better user interaction * style: remove custom scrollbar styles from Sidebar components for a cleaner look
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { setActiveApiSpecUid } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { showApiSpecPage as _showApiSpecPage } from 'providers/ReduxStore/slices/app';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { IconDots } from '@tabler/icons';
|
||||
import { IconDots, IconX } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CloseApiSpec from '../CloseApiSpec/index';
|
||||
@@ -53,7 +53,10 @@ const ApiSpecItem = ({ apiSpec }) => {
|
||||
setCloseApiSpecModal(true);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
<span className="dropdown-icon">
|
||||
<IconX size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Remove
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
.api-specs-list {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.api-spec-item {
|
||||
height: 1.6rem;
|
||||
cursor: pointer;
|
||||
&.active {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.bg};
|
||||
}
|
||||
@@ -36,14 +56,6 @@ const Wrapper = styled.div`
|
||||
top: -0.625rem;
|
||||
}
|
||||
|
||||
div.dropdown-item.close-item {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.colors.bg.danger};
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import ApiSpecItem from './ApiSpecItem';
|
||||
import ApiSpecsBadge from './ApiSpecBadge';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -47,8 +46,7 @@ const ApiSpecs = () => {
|
||||
if (!apiSpecs || !apiSpecs.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<ApiSpecsBadge />
|
||||
<div className="text-xs text-center placeholder mt-4">
|
||||
<div className="text-xs text-center placeholder py-4">
|
||||
<div>No API Specs found.</div>
|
||||
<div className="mt-2">
|
||||
<OpenLink /> API Spec.
|
||||
@@ -60,15 +58,12 @@ const ApiSpecs = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="relative">
|
||||
<ApiSpecsBadge />
|
||||
<div className="flex flex-col top-32 bottom-10 left-0 right-0 py-4">
|
||||
{apiSpecs && apiSpecs.length
|
||||
? apiSpecs.map((apiSpec) => {
|
||||
return <ApiSpecItem apiSpec={apiSpec} key={apiSpec.uid} />;
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
<div className="api-specs-list">
|
||||
{apiSpecs && apiSpecs.length
|
||||
? apiSpecs.map((apiSpec) => {
|
||||
return <ApiSpecItem apiSpec={apiSpec} key={apiSpec.uid} />;
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -3,32 +3,20 @@ import styled from 'styled-components';
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
.collections-list {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${(props) => props.theme.scrollbar.color};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: ${(props) => props.theme.scrollbar.color};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import CreateOrOpenCollection from './CreateOrOpenCollection';
|
||||
import CollectionSearch from './CollectionSearch/index';
|
||||
import { useMemo } from 'react';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import ApiSpecs from '../ApiSpecs/index';
|
||||
|
||||
const Collections = ({ showSearch }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
@@ -44,7 +43,7 @@ const Collections = ({ showSearch }) => {
|
||||
<CollectionSearch searchText={searchText} setSearchText={setSearchText} />
|
||||
)}
|
||||
|
||||
<div className="collections-list flex flex-col flex-1 overflow-hidden hover:overflow-y-auto">
|
||||
<div className="collections-list">
|
||||
{workspaceCollections && workspaceCollections.length
|
||||
? workspaceCollections.map((c) => {
|
||||
return (
|
||||
@@ -52,8 +51,6 @@ const Collections = ({ showSearch }) => {
|
||||
);
|
||||
})
|
||||
: null}
|
||||
<div className="w-full my-2" style={{ height: 1 }}></div>
|
||||
<ApiSpecs />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconFileCode, IconPlus } from '@tabler/icons';
|
||||
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
import ApiSpecs from 'components/Sidebar/ApiSpecs';
|
||||
import SidebarSection from 'components/Sidebar/SidebarSection';
|
||||
|
||||
const ApiSpecsSection = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [createApiSpecModalOpen, setCreateApiSpecModalOpen] = useState(false);
|
||||
|
||||
const handleOpenApiSpec = () => {
|
||||
dispatch(openApiSpec()).catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while opening the API spec');
|
||||
});
|
||||
};
|
||||
|
||||
const addDropdownItems = [
|
||||
{
|
||||
id: 'create-api-spec',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create API Spec',
|
||||
onClick: () => {
|
||||
setCreateApiSpecModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open-api-spec',
|
||||
leftSection: IconFileCode,
|
||||
label: 'Open API Spec',
|
||||
onClick: () => {
|
||||
handleOpenApiSpec();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const sectionActions = (
|
||||
<>
|
||||
<MenuDropdown
|
||||
data-testid="api-specs-header-add-menu"
|
||||
items={addDropdownItems}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ActionIcon
|
||||
label="Add new API Spec"
|
||||
>
|
||||
<IconPlus size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{createApiSpecModalOpen && (
|
||||
<CreateApiSpec
|
||||
onClose={() => setCreateApiSpecModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<SidebarSection
|
||||
id="api-specs"
|
||||
title="API Specs"
|
||||
icon={IconFileCode}
|
||||
actions={sectionActions}
|
||||
className="api-specs-section"
|
||||
>
|
||||
<ApiSpecs />
|
||||
</SidebarSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSpecsSection;
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconArrowsSort,
|
||||
IconDotsVertical,
|
||||
IconDownload,
|
||||
IconFolder,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSortAscendingLetters,
|
||||
IconSortDescendingLetters,
|
||||
IconSquareX
|
||||
} from '@tabler/icons';
|
||||
|
||||
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import Collections from 'components/Sidebar/Collections';
|
||||
import SidebarSection from 'components/Sidebar/SidebarSection';
|
||||
import { IconBox } from '@tabler/icons';
|
||||
|
||||
const CollectionsSection = () => {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { collectionSortOrder } = useSelector((state) => state.collections);
|
||||
const [collectionsToClose, setCollectionsToClose] = useState([]);
|
||||
|
||||
const [importData, setImportData] = useState(null);
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.type !== 'default') {
|
||||
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
|
||||
.catch((err) => {
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
} else {
|
||||
setImportData({ rawData, type });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation))
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSearch = () => {
|
||||
setShowSearch((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSortCollections = () => {
|
||||
let order;
|
||||
switch (collectionSortOrder) {
|
||||
case 'default':
|
||||
order = 'alphabetical';
|
||||
break;
|
||||
case 'alphabetical':
|
||||
order = 'reverseAlphabetical';
|
||||
break;
|
||||
case 'reverseAlphabetical':
|
||||
order = 'default';
|
||||
break;
|
||||
default:
|
||||
order = 'default';
|
||||
break;
|
||||
}
|
||||
dispatch(sortCollections({ order }));
|
||||
};
|
||||
|
||||
const getSortIcon = () => {
|
||||
switch (collectionSortOrder) {
|
||||
case 'alphabetical':
|
||||
return IconSortDescendingLetters;
|
||||
case 'reverseAlphabetical':
|
||||
return IconArrowsSort;
|
||||
default:
|
||||
return IconSortAscendingLetters;
|
||||
}
|
||||
};
|
||||
|
||||
const getSortLabel = () => {
|
||||
switch (collectionSortOrder) {
|
||||
case 'alphabetical':
|
||||
return 'Sort Z-A';
|
||||
case 'reverseAlphabetical':
|
||||
return 'Clear sort';
|
||||
default:
|
||||
return 'Sort A-Z';
|
||||
}
|
||||
};
|
||||
|
||||
const selectAllCollectionsToClose = () => {
|
||||
setCollectionsToClose(collections.map((c) => c.uid));
|
||||
};
|
||||
|
||||
const clearCollectionsToClose = () => {
|
||||
setCollectionsToClose([]);
|
||||
};
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
const options = {};
|
||||
if (activeWorkspace?.pathname) {
|
||||
options.workspaceId = activeWorkspace.pathname;
|
||||
}
|
||||
|
||||
dispatch(openCollection(options)).catch((err) => {
|
||||
toast.error('An error occurred while opening the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const addDropdownItems = [
|
||||
{
|
||||
id: 'create',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create collection',
|
||||
onClick: () => {
|
||||
setCreateCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'import',
|
||||
leftSection: IconDownload,
|
||||
label: 'Import collection',
|
||||
onClick: () => {
|
||||
setImportCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
leftSection: IconFolder,
|
||||
label: 'Open collection',
|
||||
onClick: () => {
|
||||
handleOpenCollection();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const actionsDropdownItems = [
|
||||
{
|
||||
id: 'sort',
|
||||
leftSection: getSortIcon(),
|
||||
label: getSortLabel(),
|
||||
onClick: () => {
|
||||
handleSortCollections();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'close-all',
|
||||
leftSection: IconSquareX,
|
||||
label: 'Close all',
|
||||
onClick: () => {
|
||||
selectAllCollectionsToClose();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const sectionActions = (
|
||||
<>
|
||||
<ActionIcon
|
||||
onClick={handleToggleSearch}
|
||||
label="Search requests"
|
||||
>
|
||||
<IconSearch size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
|
||||
<MenuDropdown
|
||||
data-testid="collections-header-add-menu"
|
||||
items={addDropdownItems}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ActionIcon
|
||||
label="Add new collection"
|
||||
>
|
||||
<IconPlus size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
|
||||
<MenuDropdown
|
||||
data-testid="collections-header-actions-menu"
|
||||
items={actionsDropdownItems}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ActionIcon
|
||||
label="More actions"
|
||||
>
|
||||
<IconDotsVertical size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
|
||||
{collectionsToClose.length > 0 && (
|
||||
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollection}
|
||||
/>
|
||||
)}
|
||||
{importCollectionLocationModalOpen && importData && (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
<SidebarSection
|
||||
id="collections"
|
||||
title="Collections"
|
||||
icon={IconBox}
|
||||
actions={sectionActions}
|
||||
>
|
||||
<Collections showSearch={showSearch} />
|
||||
</SidebarSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionsSection;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
const SidebarAccordionContext = createContext();
|
||||
|
||||
export const useSidebarAccordion = () => {
|
||||
const context = useContext(SidebarAccordionContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebarAccordion must be used within SidebarAccordionProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const SidebarAccordionProvider = ({ children, defaultExpanded = ['collections'] }) => {
|
||||
const [expandedSections, setExpandedSections] = useState(new Set(defaultExpanded));
|
||||
|
||||
const toggleSection = useCallback((sectionId) => {
|
||||
setExpandedSections((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(sectionId)) {
|
||||
newSet.delete(sectionId);
|
||||
} else {
|
||||
newSet.add(sectionId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setSectionExpanded = useCallback((sectionId, expanded) => {
|
||||
setExpandedSections((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (expanded) {
|
||||
newSet.add(sectionId);
|
||||
} else {
|
||||
newSet.delete(sectionId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback((sectionId) => {
|
||||
return expandedSections.has(sectionId);
|
||||
}, [expandedSections]);
|
||||
|
||||
const getExpandedCount = useCallback(() => {
|
||||
return expandedSections.size;
|
||||
}, [expandedSections]);
|
||||
|
||||
return (
|
||||
<SidebarAccordionContext.Provider
|
||||
value={{
|
||||
expandedSections,
|
||||
toggleSection,
|
||||
setSectionExpanded,
|
||||
isExpanded,
|
||||
getExpandedCount
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarAccordionContext.Provider>
|
||||
);
|
||||
};
|
||||
69
packages/bruno-app/src/components/Sidebar/SidebarContent.js
Normal file
69
packages/bruno-app/src/components/Sidebar/SidebarContent.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useSidebarAccordion } from './SidebarAccordionContext';
|
||||
|
||||
/**
|
||||
* Sections configuration
|
||||
*
|
||||
* All sections use the same generic accordion behavior with the class 'accordion-section-wrapper'.
|
||||
* Layout behavior is fully automatic based on section order and expansion state:
|
||||
* - Single expanded: When only one section is expanded, it fills available space
|
||||
* - Multi-expanded: When multiple sections are expanded, they split space equally
|
||||
* - Automatic pinning: Sections below an expanded section are automatically pinned to bottom
|
||||
*
|
||||
* To add a new section, simply add a new entry to this array:
|
||||
*
|
||||
* {
|
||||
* id: 'my-section', // Unique identifier
|
||||
* component: MySectionComponent, // React component to render
|
||||
* getProps: (context) => ({ ... }) // Function to get props for component
|
||||
* }
|
||||
*/
|
||||
|
||||
const SidebarContent = ({ sections }) => {
|
||||
const { isExpanded, getExpandedCount } = useSidebarAccordion();
|
||||
|
||||
const expandedCount = getExpandedCount();
|
||||
|
||||
const getWrapperClassName = (section, sectionIndex) => {
|
||||
const sectionExpanded = isExpanded(section.id);
|
||||
// Use generic accordion-section-wrapper class for all sections
|
||||
const classes = ['accordion-section-wrapper'];
|
||||
|
||||
// Multi-expanded: when multiple sections are expanded
|
||||
if (expandedCount > 1 && sectionExpanded) {
|
||||
classes.push('multi-expanded');
|
||||
}
|
||||
|
||||
// Single expanded wrapper behavior: when only one section is expanded, it fills space
|
||||
if (sectionExpanded && expandedCount === 1) {
|
||||
classes.push('single-expanded-wrapper');
|
||||
}
|
||||
|
||||
// Automatic pinning: if section is not expanded and any section above it (earlier in array) is expanded
|
||||
if (!sectionExpanded) {
|
||||
// Check if any section before this one (earlier in array) is expanded
|
||||
const hasExpandedAbove = sections.slice(0, sectionIndex).some((s) => isExpanded(s.id));
|
||||
if (hasExpandedAbove) {
|
||||
classes.push('pinned-to-bottom');
|
||||
}
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.map((section, index) => {
|
||||
const SectionComponent = section.component;
|
||||
const wrapperClassName = getWrapperClassName(section, index);
|
||||
|
||||
return (
|
||||
<div key={section.id} className={wrapperClassName}>
|
||||
<SectionComponent />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarContent;
|
||||
@@ -0,0 +1,116 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
&.expanded {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&:not(.expanded) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&.multi-expanded {
|
||||
flex: 1 1 0%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 6px 4px 6px 8px;
|
||||
min-height: 28px;
|
||||
height: 28px;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
|
||||
.section-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
&:hover {
|
||||
.section-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.section-toggle {
|
||||
background: ${(props) => props.theme.dropdown.hoverBg};
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-icon-wrapper {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IconChevronRight, IconChevronDown } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useSidebarAccordion } from '../SidebarAccordionContext';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
|
||||
const SidebarSection = ({
|
||||
id,
|
||||
title,
|
||||
icon: Icon,
|
||||
actions,
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
const { isExpanded, setSectionExpanded, getExpandedCount } = useSidebarAccordion();
|
||||
const [localExpanded, setLocalExpanded] = useState(() => isExpanded(id));
|
||||
const sectionRef = useRef(null);
|
||||
|
||||
// Sync with context
|
||||
useEffect(() => {
|
||||
const expanded = isExpanded(id);
|
||||
setLocalExpanded(expanded);
|
||||
}, [id, isExpanded]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const newExpanded = !localExpanded;
|
||||
setLocalExpanded(newExpanded);
|
||||
setSectionExpanded(id, newExpanded);
|
||||
};
|
||||
|
||||
const expandedCount = getExpandedCount();
|
||||
// Check if this is the only expanded section
|
||||
const isOnlyExpanded = expandedCount === 1 && localExpanded;
|
||||
|
||||
return (
|
||||
<StyledWrapper className={className}>
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className={`sidebar-section ${localExpanded ? 'expanded' : ''} ${isOnlyExpanded ? 'single-expanded' : ''} ${expandedCount > 1 && localExpanded ? 'multi-expanded' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="section-header"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="section-header-left">
|
||||
<div
|
||||
className="section-icon-wrapper"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault(); handleToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActionIcon size="sm" className="section-toggle">
|
||||
{localExpanded ? (
|
||||
<IconChevronDown size={12} stroke={1.5} />
|
||||
) : (
|
||||
<IconChevronRight size={12} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
{Icon && <Icon size={14} stroke={1.5} className="section-icon" />}
|
||||
</div>
|
||||
<span className="section-title">{title}</span>
|
||||
</div>
|
||||
{actions && (
|
||||
<div
|
||||
className="section-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{localExpanded && (
|
||||
<div className="section-content">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarSection;
|
||||
@@ -2,32 +2,104 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
max-height: 100%;
|
||||
|
||||
aside {
|
||||
background-color: ${(props) => props.theme.sidebar.bg};
|
||||
overflow: hidden;
|
||||
|
||||
.collection-title {
|
||||
line-height: 1.5;
|
||||
.collection-dropdown {
|
||||
.dropdown-icon {
|
||||
display: none;
|
||||
color: rgb(110 110 110);
|
||||
}
|
||||
}
|
||||
.sidebar-sections-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f7f7f7;
|
||||
.dropdown-icon {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.sidebar-sections {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.tippy-box {
|
||||
position: relative;
|
||||
top: -0.625rem;
|
||||
/* Expanded sections grow to fill available space but are constrained */
|
||||
.sidebar-section.expanded {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
|
||||
.section-header {
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
/* Single expanded section: add margin-bottom to push others down */
|
||||
.sidebar-section.single-expanded {
|
||||
margin-bottom: auto !important;
|
||||
flex: 1 1 0% !important;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Multiple expanded sections: equal split, no margin-bottom */
|
||||
.sidebar-section.multi-expanded {
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 0% !important;
|
||||
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Collapsed sections only take header height */
|
||||
.sidebar-section:not(.expanded) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Always push bottom accordions wrapper to the bottom */
|
||||
.bottom-accordions-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Generic accordion section wrapper - applies to all accordion sections */
|
||||
.accordion-section-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Add border-top to all accordion items except the first child */
|
||||
.accordion-section-wrapper:not(:first-child) {
|
||||
border-top: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
/* When a section is single expanded, wrapper should fill space but respect pinned sections */
|
||||
.accordion-section-wrapper.single-expanded-wrapper {
|
||||
flex: 1 1 0% !important;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Normal flow: sections not pinned and not multi-expanded */
|
||||
.accordion-section-wrapper:not(.pinned-to-bottom):not(.multi-expanded) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* When a section is pinned to bottom */
|
||||
.accordion-section-wrapper.pinned-to-bottom {
|
||||
flex: 0 0 auto;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* When multiple sections are expanded, split space equally */
|
||||
.accordion-section-wrapper.multi-expanded {
|
||||
flex: 1 1 0% !important;
|
||||
min-height: 0;
|
||||
margin-top: 0 !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
div.sidebar-drag-handle {
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import Collections from './Collections';
|
||||
import { SidebarAccordionProvider } from './SidebarAccordionContext';
|
||||
import SidebarContent from './SidebarContent';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app';
|
||||
import CollectionsSection from './Sections/CollectionsSection/index';
|
||||
import ApiSpecsSection from './Sections/ApiSpecsSection/index';
|
||||
|
||||
const MIN_LEFT_SIDEBAR_WIDTH = 220;
|
||||
const MAX_LEFT_SIDEBAR_WIDTH = 600;
|
||||
|
||||
const SIDEBAR_SECTIONS = [
|
||||
{
|
||||
id: 'collections',
|
||||
component: CollectionsSection
|
||||
},
|
||||
{
|
||||
id: 'api-specs',
|
||||
component: ApiSpecsSection
|
||||
}
|
||||
];
|
||||
|
||||
const Sidebar = () => {
|
||||
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
|
||||
const lastWidthRef = useRef(leftSidebarWidth);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -77,26 +89,29 @@ const Sidebar = () => {
|
||||
}, [leftSidebarWidth]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex relative h-full">
|
||||
<aside className="sidebar" style={{ width: currentWidth, transition: dragging ? 'none' : 'width 0.2s ease-in-out' }}>
|
||||
<div className="flex flex-row h-full w-full">
|
||||
<div className="flex flex-col w-full" style={{ width: asideWidth }}>
|
||||
<div className="flex flex-col flex-grow" style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<SidebarHeader
|
||||
setShowSearch={setShowSearch}
|
||||
/>
|
||||
<Collections showSearch={showSearch} />
|
||||
<SidebarAccordionProvider defaultExpanded={['collections']}>
|
||||
<StyledWrapper className="flex relative h-full">
|
||||
<aside className="sidebar" style={{ width: currentWidth, transition: dragging ? 'none' : 'width 0.2s ease-in-out' }}>
|
||||
<div className="flex flex-row h-full w-full">
|
||||
<div className="flex flex-col w-full" style={{ width: asideWidth }}>
|
||||
<div className="flex flex-col flex-grow sidebar-sections-container" style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<div className="sidebar-sections flex flex-col flex-1">
|
||||
<SidebarContent
|
||||
sections={SIDEBAR_SECTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<div className="absolute sidebar-drag-handle h-full" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="drag-request-border" />
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="absolute sidebar-drag-handle h-full" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="drag-request-border" />
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
</SidebarAccordionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user