diff options
| author | Oliver <oliver@mohlin.dev> | 2025-08-27 10:29:02 +0200 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-09-22 12:35:43 +0200 |
| commit | c52e39f1c38816667fd0949cf57354fb9a358761 (patch) | |
| tree | 23c8fce1ebfb48b1a64639cd03280736210b0ea7 | |
| parent | 444900c3581bcc538eccf28160053110700751ca (diff) | |
| download | mullvadvpn-c52e39f1c38816667fd0949cf57354fb9a358761.tar.xz mullvadvpn-c52e39f1c38816667fd0949cf57354fb9a358761.zip | |
Update Accordion component to support animations and disabled state
7 files changed, 107 insertions, 35 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 index 2ed9043972..25a8fd14cd 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx @@ -1,35 +1,50 @@ import React from 'react'; import styled from 'styled-components'; +import { FlexColumn } from '../flex-column'; 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 AccordionAnimation = 'flash' | 'dim'; + export type AccordionProps = { expanded?: boolean; onExpandedChange?: (open: boolean) => void; + disabled?: boolean; + animation?: AccordionAnimation; children?: React.ReactNode; -}; +} & React.ComponentPropsWithRef<'div'>; -const StyledAccordion = styled.div` - display: flex; - flex: 1; - flex-direction: column; +const StyledAccordion = styled(FlexColumn)` width: 100%; `; -function Accordion({ expanded = false, onExpandedChange: onOpenChange, children }: AccordionProps) { +function Accordion({ + expanded = false, + onExpandedChange: onOpenChange, + disabled, + animation, + children, + ...props +}: AccordionProps) { const triggerId = React.useId(); const contentId = React.useId(); + const titleId = React.useId(); return ( <AccordionProvider triggerId={triggerId} contentId={contentId} + titleId={titleId} expanded={expanded} - onExpandedChange={onOpenChange}> - <StyledAccordion>{children}</StyledAccordion> + onExpandedChange={onOpenChange} + disabled={disabled} + animation={animation}> + <StyledAccordion $flex={1} {...props}> + {children} + </StyledAccordion> </AccordionProvider> ); } 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 index e39295f1a8..acc5756102 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx @@ -1,12 +1,15 @@ import React from 'react'; -import { AccordionProps } from './Accordion'; +import { AccordionAnimation, AccordionProps } from './Accordion'; interface AccordionContextProps { triggerId: string; contentId: string; + titleId: string; expanded: AccordionProps['expanded']; onExpandedChange?: AccordionProps['onExpandedChange']; + disabled?: boolean; + animation?: AccordionAnimation; } const AccordionContext = React.createContext<AccordionContextProps | undefined>(undefined); @@ -22,21 +25,14 @@ export const useAccordionContext = (): AccordionContextProps => { interface AccordionProviderProps { triggerId: string; contentId: string; + titleId: string; expanded: boolean; onExpandedChange?: (open: boolean) => void; + disabled?: boolean; + animation?: AccordionAnimation; children: React.ReactNode; } -export function AccordionProvider({ - triggerId, - contentId, - expanded, - onExpandedChange, - children, -}: AccordionProviderProps) { - return ( - <AccordionContext.Provider value={{ triggerId, contentId, expanded, onExpandedChange }}> - {children} - </AccordionContext.Provider> - ); +export function AccordionProvider({ children, ...props }: AccordionProviderProps) { + return <AccordionContext.Provider value={props}>{children}</AccordionContext.Provider>; } 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 index b71effcfba..ce28143976 100644 --- 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 @@ -1,29 +1,42 @@ -import styled from 'styled-components'; +import styled, { css, RuleSet } from 'styled-components'; import { colors } from '../../../foundations'; import { Flex } from '../../flex'; -import { StyledAccordionIcon } from './AccordionIcon'; +import { useAccordionContext } from '../AccordionContext'; +import { useAnimation } from '../hooks'; export type AccordionHeaderProps = { children?: React.ReactNode; }; -export const StyledAccordionHeader = styled(Flex)` - background-color: ${colors.blue}; - width: 100%; - min-height: 48px; - margin-bottom: 1px; +export const StyledAccordionHeader = styled(Flex)<{ + $animation?: RuleSet<object>; + $disabled?: boolean; +}>` + ${({ $animation, $disabled }) => { + const backgroundColor = $disabled ? colors.blue40 : colors.blue; + return css` + --background-color: ${backgroundColor}; - && > ${StyledAccordionIcon} { - margin-left: auto; - } + margin-bottom: 1px; + background-color: var(--background-color); + min-height: 48px; + width: 100%; + ${$animation} + `; + }} `; export function AccordionHeader({ children }: AccordionHeaderProps) { + const animation = useAnimation(); + const { disabled } = useAccordionContext(); return ( <StyledAccordionHeader $padding={{ horizontal: 'medium', vertical: 'small' }} - $alignItems="center"> + $alignItems="center" + $justifyContent="space-between" + $animation={animation} + $disabled={disabled}> {children} </StyledAccordionHeader> ); 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 index e9d287b261..e1329dbf77 100644 --- 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 @@ -1,6 +1,7 @@ import styled from 'styled-components'; import { Text } from '../../typography'; +import { useAccordionContext } from '../AccordionContext'; export type AccordionTitleProps = { children?: React.ReactNode; @@ -9,8 +10,13 @@ export type AccordionTitleProps = { export const StyledTitleLabel = styled(Text)``; export function AccordionTitle({ children }: AccordionTitleProps) { + const { titleId, disabled } = useAccordionContext(); return ( - <StyledTitleLabel $padding="medium" color="white" variant="titleMedium"> + <StyledTitleLabel + id={titleId} + $padding="medium" + color={disabled ? 'whiteAlpha60' : '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 index ff852e4ea1..dfaffdf317 100644 --- 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 @@ -4,19 +4,26 @@ import styled from 'styled-components'; import { colors } from '../../../foundations'; import { useAccordionContext } from '../AccordionContext'; import { StyledAccordionHeader } from './AccordionHeader'; +import { StyledAccordionIcon } from './AccordionIcon'; export type AccordionTriggerProps = { children?: React.ReactNode; -}; +} & React.ButtonHTMLAttributes<HTMLButtonElement>; const StyledAccordionTrigger = styled.button` background-color: transparent; &&:hover > ${StyledAccordionHeader} { background-color: ${colors.blue60}; } + &&:hover > ${StyledAccordionIcon} { + background-color: ${colors.whiteAlpha60}; + } &&:active > ${StyledAccordionHeader} { background-color: ${colors.blue40}; } + &&:active > ${StyledAccordionIcon} { + background-color: ${colors.whiteAlpha40}; + } &&:focus-visible { outline: 2px solid ${colors.white}; outline-offset: -2px; @@ -24,7 +31,7 @@ const StyledAccordionTrigger = styled.button` `; export function AccordionTrigger({ children }: AccordionTriggerProps) { - const { contentId, triggerId, expanded, onExpandedChange } = useAccordionContext(); + const { contentId, triggerId, titleId, expanded, onExpandedChange } = useAccordionContext(); const onClick = React.useCallback( (e: React.MouseEvent<HTMLButtonElement>) => { @@ -37,6 +44,7 @@ export function AccordionTrigger({ children }: AccordionTriggerProps) { return ( <StyledAccordionTrigger id={triggerId} + aria-labelledby={titleId} aria-controls={contentId} aria-expanded={expanded ? 'true' : 'false'} onClick={onClick}> diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/hooks/index.ts new file mode 100644 index 0000000000..64aafcab29 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/hooks/index.ts @@ -0,0 +1 @@ +export * from './useAnimation'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/hooks/useAnimation.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/hooks/useAnimation.tsx new file mode 100644 index 0000000000..be34d7963b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/hooks/useAnimation.tsx @@ -0,0 +1,33 @@ +import { css, keyframes } from 'styled-components'; + +import { colors } from '../../../foundations'; +import { useAccordionContext } from '../AccordionContext'; + +const flash = keyframes` + 0% { background-color: var(--background-color) } + 50% { background-color: ${colors.whiteOnBlue20} } + 100% { background-color: var(--background-color) } +`; + +const dim = keyframes` + 0% { opacity: 100% } + 25% { opacity: 50% } + 50% { opacity: 50% } + 75% { opacity: 50% } + 100% { opacity: 100% } +`; + +export const useAnimation = () => { + const { animation } = useAccordionContext(); + if (animation === 'flash') { + return css` + animation: ${flash} 0.75s ease-in-out 0s 2 normal forwards; + `; + } + if (animation === 'dim') { + return css` + animation: ${dim} 1.5s ease-in-out 0s 1 normal forwards; + `; + } + return undefined; +}; |
