diff options
| author | Oliver <oliver@mohlin.dev> | 2025-05-29 09:15:13 +0200 |
|---|---|---|
| committer | Oliver <oliver@mohlin.dev> | 2025-06-05 10:26:46 +0200 |
| commit | dc8fcd776db1668b378c69988d88f12cc0bfe695 (patch) | |
| tree | f6bbb270c1cb8c9a7d80e53524c708b4ccc14c31 | |
| parent | fe325b16c5fb14235d4f8f76d59c1815add1fc67 (diff) | |
| download | mullvadvpn-dc8fcd776db1668b378c69988d88f12cc0bfe695.tar.xz mullvadvpn-dc8fcd776db1668b378c69988d88f12cc0bfe695.zip | |
Add Accordion
9 files changed, 228 insertions, 0 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx new file mode 100644 index 0000000000..2ed9043972 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { AccordionProvider } from './AccordionContext'; +import { AccordionHeader, AccordionTrigger } from './components'; +import { AccordionContent } from './components/AccordionContent'; +import { AccordionIcon } from './components/AccordionIcon'; +import { AccordionTitle } from './components/AccordionTitle'; + +export type AccordionProps = { + expanded?: boolean; + onExpandedChange?: (open: boolean) => void; + children?: React.ReactNode; +}; + +const StyledAccordion = styled.div` + display: flex; + flex: 1; + flex-direction: column; + width: 100%; +`; + +function Accordion({ expanded = false, onExpandedChange: onOpenChange, children }: AccordionProps) { + const triggerId = React.useId(); + const contentId = React.useId(); + return ( + <AccordionProvider + triggerId={triggerId} + contentId={contentId} + expanded={expanded} + onExpandedChange={onOpenChange}> + <StyledAccordion>{children}</StyledAccordion> + </AccordionProvider> + ); +} + +const AccordionNamespace = Object.assign(Accordion, { + Trigger: AccordionTrigger, + Header: AccordionHeader, + Content: AccordionContent, + Title: AccordionTitle, + Icon: AccordionIcon, +}); + +export { AccordionNamespace as Accordion }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx new file mode 100644 index 0000000000..e39295f1a8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { AccordionProps } from './Accordion'; + +interface AccordionContextProps { + triggerId: string; + contentId: string; + expanded: AccordionProps['expanded']; + onExpandedChange?: AccordionProps['onExpandedChange']; +} + +const AccordionContext = React.createContext<AccordionContextProps | undefined>(undefined); + +export const useAccordionContext = (): AccordionContextProps => { + const context = React.useContext(AccordionContext); + if (!context) { + throw new Error('useAccordionContext must be used within a AccordionProvider'); + } + return context; +}; + +interface AccordionProviderProps { + triggerId: string; + contentId: string; + expanded: boolean; + onExpandedChange?: (open: boolean) => void; + children: React.ReactNode; +} + +export function AccordionProvider({ + triggerId, + contentId, + expanded, + onExpandedChange, + children, +}: AccordionProviderProps) { + return ( + <AccordionContext.Provider value={{ triggerId, contentId, expanded, onExpandedChange }}> + {children} + </AccordionContext.Provider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionContent.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionContent.tsx new file mode 100644 index 0000000000..beeb3ff238 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionContent.tsx @@ -0,0 +1,26 @@ +import styled from 'styled-components'; + +import { Animate } from '../../animate'; +import { useAccordionContext } from '../AccordionContext'; + +export type AccordionContentProps = { + children?: React.ReactNode; +}; + +const StyledAccordionContent = styled.div` + width: 100%; +`; + +export function AccordionContent({ children }: AccordionContentProps) { + const { contentId, triggerId, expanded } = useAccordionContext(); + return ( + <Animate + present={expanded} + animations={[{ type: 'wipe', direction: 'vertical' }]} + duration="0.35s"> + <StyledAccordionContent id={contentId} aria-labelledby={triggerId} role="region"> + {children} + </StyledAccordionContent> + </Animate> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx new file mode 100644 index 0000000000..b71effcfba --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +import { colors } from '../../../foundations'; +import { Flex } from '../../flex'; +import { StyledAccordionIcon } from './AccordionIcon'; + +export type AccordionHeaderProps = { + children?: React.ReactNode; +}; + +export const StyledAccordionHeader = styled(Flex)` + background-color: ${colors.blue}; + width: 100%; + min-height: 48px; + margin-bottom: 1px; + + && > ${StyledAccordionIcon} { + margin-left: auto; + } +`; + +export function AccordionHeader({ children }: AccordionHeaderProps) { + return ( + <StyledAccordionHeader + $padding={{ horizontal: 'medium', vertical: 'small' }} + $alignItems="center"> + {children} + </StyledAccordionHeader> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx new file mode 100644 index 0000000000..043a72fa00 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +import { Icon, IconProps } from '../../icon'; +import { useAccordionContext } from '../AccordionContext'; + +export type AccordionIconProps = Omit<IconProps, 'icon'> & { + icon?: IconProps['icon']; +}; + +export const StyledAccordionIcon = styled(Icon)` + flex-shrink: 0; +`; + +export function AccordionIcon({ icon, color = 'whiteAlpha80', ...props }: AccordionIconProps) { + const { expanded: open } = useAccordionContext(); + const iconName = icon || (open ? 'chevron-up' : 'chevron-down'); + return <StyledAccordionIcon icon={iconName} color={color} {...props} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx new file mode 100644 index 0000000000..e9d287b261 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx @@ -0,0 +1,17 @@ +import styled from 'styled-components'; + +import { Text } from '../../typography'; + +export type AccordionTitleProps = { + children?: React.ReactNode; +}; + +export const StyledTitleLabel = styled(Text)``; + +export function AccordionTitle({ children }: AccordionTitleProps) { + return ( + <StyledTitleLabel $padding="medium" color="white" variant="titleMedium"> + {children} + </StyledTitleLabel> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx new file mode 100644 index 0000000000..30ba2092c0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../../foundations'; +import { ButtonBase } from '../../button'; +import { useAccordionContext } from '../AccordionContext'; +import { StyledAccordionHeader } from './AccordionHeader'; + +export type AccordionTriggerProps = { + children?: React.ReactNode; +}; + +const StyledAccordionTrigger = styled(ButtonBase)` + background-color: transparent; + &&:hover > ${StyledAccordionHeader} { + background-color: ${colors.blue60}; + } + &&:active > ${StyledAccordionHeader} { + background-color: ${colors.blue40}; + } + &&:focus-visible { + outline: 2px solid ${colors.white}; + outline-offset: -2px; + } +`; + +export function AccordionTrigger({ children }: AccordionTriggerProps) { + const { contentId, triggerId, expanded, onExpandedChange } = useAccordionContext(); + + const onClick = React.useCallback( + (e: React.MouseEvent<HTMLButtonElement>) => { + e.preventDefault(); + onExpandedChange?.(!expanded); + }, + [onExpandedChange, expanded], + ); + + return ( + <StyledAccordionTrigger + id={triggerId} + aria-controls={contentId} + aria-expanded={expanded ? 'true' : 'false'} + onClick={onClick}> + {children} + </StyledAccordionTrigger> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts new file mode 100644 index 0000000000..abd6b8865b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts @@ -0,0 +1,2 @@ +export * from './AccordionHeader'; +export * from './AccordionTrigger'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/index.ts new file mode 100644 index 0000000000..63f62bc659 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/index.ts @@ -0,0 +1 @@ +export * from './Accordion'; |
