diff --git a/hyperglass/ui/components/Util/animated.ts b/hyperglass/ui/components/Util/animated.ts index 43d29ed..99fb7dc 100644 --- a/hyperglass/ui/components/Util/animated.ts +++ b/hyperglass/ui/components/Util/animated.ts @@ -3,3 +3,6 @@ import { motion } from 'framer-motion'; export const AnimatedDiv = motion.custom(chakra.div); export const AnimatedForm = motion.custom(chakra.form); +export const AnimatedH1 = motion.custom(chakra.h1); +export const AnimatedH3 = motion.custom(chakra.h3); +export const AnimatedButton = motion.custom(chakra.button); diff --git a/hyperglass/ui/components/header/context.ts b/hyperglass/ui/components/header/context.ts new file mode 100644 index 0000000..4f81c2b --- /dev/null +++ b/hyperglass/ui/components/header/context.ts @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react'; +import { createState, useState } from '@hookstate/core'; +import type { THeaderCtx, THeaderState } from './types'; + +const HeaderCtx = createContext({ + showSubtitle: true, + titleRef: {} as React.MutableRefObject, +}); + +export const HeaderProvider = HeaderCtx.Provider; +export const useHeaderCtx = (): THeaderCtx => useContext(HeaderCtx); + +const HeaderState = createState({ fontSize: '' }); +export const useHeader = () => useState(HeaderState); diff --git a/hyperglass/ui/components/header/header.tsx b/hyperglass/ui/components/header/header.tsx index 22dd042..64b9792 100644 --- a/hyperglass/ui/components/header/header.tsx +++ b/hyperglass/ui/components/header/header.tsx @@ -1,168 +1,60 @@ -import { Flex } from '@chakra-ui/react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { AnimatedDiv, Title, ResetButton, ColorModeToggle } from '~/components'; -import { useColorValue, useConfig, useBreakpointValue } from '~/context'; +import { useRef } from 'react'; +import { Flex, ScaleFade } from '@chakra-ui/react'; +import { ColorModeToggle } from '~/components'; +import { useColorValue, useBreakpointValue } from '~/context'; import { useBooleanValue, useLGState } from '~/hooks'; +import { HeaderProvider } from './context'; +import { Title } from './title'; -import type { ResponsiveValue } from '@chakra-ui/react'; -import type { THeader, TTitleMode, THeaderLayout } from './types'; - -const headerTransition = { - type: 'spring', - ease: 'anticipate', - damping: 15, - stiffness: 100, -}; - -function getWidth(mode: TTitleMode): ResponsiveValue { - let width = '100%' as ResponsiveValue; - - switch (mode) { - case 'text_only': - width = '100%'; - break; - case 'logo_only': - width = { base: '90%', lg: '50%' }; - break; - case 'logo_subtitle': - width = { base: '90%', lg: '50%' }; - break; - case 'all': - width = { base: '90%', lg: '50%' }; - break; - } - return width; -} +import type { THeader } from './types'; export const Header = (props: THeader) => { const { resetForm, ...rest } = props; + const bg = useColorValue('white', 'black'); - const { web } = useConfig(); const { isSubmitting } = useLGState(); - const mlResetButton = useBooleanValue(isSubmitting.value, { base: 0, md: 2 }, undefined); - const titleHeight = useBooleanValue(isSubmitting.value, undefined, { md: '20vh' }); + const titleRef = useRef({} as HTMLDivElement); - const titleVariant = useBreakpointValue({ - base: { - fullSize: { scale: 1, marginLeft: 0 }, - smallLogo: { marginLeft: 'auto' }, - smallText: { marginLeft: 'auto' }, - }, - md: { - fullSize: { scale: 1 }, - smallLogo: { scale: 0.5 }, - smallText: { scale: 0.8 }, - }, - lg: { - fullSize: { scale: 1 }, - smallLogo: { scale: 0.5 }, - smallText: { scale: 0.8 }, - }, - xl: { - fullSize: { scale: 1 }, - smallLogo: { scale: 0.5 }, - smallText: { scale: 0.8 }, - }, - }); - - const titleJustify = useBooleanValue( + const titleWidth = useBooleanValue( isSubmitting.value, - { base: 'flex-end', md: 'center' }, - { base: 'flex-start', md: 'center' }, - ); - const resetButton = ( - - - - - - - - ); - const title = ( - - - </AnimatedDiv> - ); - const colorModeToggle = ( - <AnimatedDiv - transition={headerTransition} - key="colorModeToggle" - alignItems="center" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - mb={[null, 'auto']} - mr={isSubmitting ? undefined : 2}> - <ColorModeToggle /> - </AnimatedDiv> + { base: '75%', lg: '50%' }, + { base: '75%', lg: '75%' }, ); - const layout = useBooleanValue( - isSubmitting.value, - { - sm: [resetButton, colorModeToggle, title], - md: [resetButton, title, colorModeToggle], - lg: [resetButton, title, colorModeToggle], - xl: [resetButton, title, colorModeToggle], - }, - { - sm: [title, resetButton, colorModeToggle], - md: [resetButton, title, colorModeToggle], - lg: [resetButton, title, colorModeToggle], - xl: [resetButton, title, colorModeToggle], - }, - ) as THeaderLayout; - - const layoutBp: keyof THeaderLayout = - useBreakpointValue({ base: 'sm', md: 'md', lg: 'lg', xl: 'xl' }) ?? 'sm'; + const justify = useBreakpointValue({ base: 'flex-start', lg: 'center' }); return ( - <Flex - px={2} - zIndex="4" - as="header" - width="full" - flex="0 1 auto" - bg={bg} - color="gray.500" - {...rest}> + <HeaderProvider value={{ showSubtitle: !isSubmitting.value, titleRef }}> <Flex - w="100%" - mx="auto" + px={4} pt={6} - justify="space-between" - flex="1 0 auto" - alignItems={isSubmitting ? 'center' : 'flex-start'}> - {layout[layoutBp]} + bg={bg} + minH={16} + zIndex={4} + as="header" + width="full" + flex="0 1 auto" + color="gray.500" + {...rest}> + <ScaleFade in initialScale={0.5} style={{ width: '100%' }}> + <Flex + d="flex" + key="title" + height="100%" + ref={titleRef} + mx={{ base: 0, lg: 'auto' }} + maxW={titleWidth} + // This is here for the logo + justifyContent={justify}> + <Title /> + </Flex> + </ScaleFade> + {/* <Flex pos="absolute" right={0} top={0} m={4}> + <ColorModeToggle /> + </Flex> */} </Flex> - </Flex> + </HeaderProvider> ); }; diff --git a/hyperglass/ui/components/header/logo.tsx b/hyperglass/ui/components/header/logo.tsx new file mode 100644 index 0000000..4e409b5 --- /dev/null +++ b/hyperglass/ui/components/header/logo.tsx @@ -0,0 +1,73 @@ +import { useMemo, useState } from 'react'; +import { Image, Skeleton } from '@chakra-ui/react'; +import { useColorValue, useConfig, useColorMode } from '~/context'; + +import type { TLogo } from './types'; + +/** + * Custom hook to handle loading the user's logo, errors loading the logo, and color mode changes. + */ +function useLogo(): [string, () => void] { + const { web } = useConfig(); + const { dark_format, light_format } = web.logo; + const { colorMode } = useColorMode(); + + const src = useColorValue(`/images/dark${dark_format}`, `/images/light${light_format}`); + + // Use the hyperglass logo if the user's logo can't be loaded for whatever reason. + const fallbackSrc = useColorValue( + 'https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-dark.svg', + 'https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-light.svg', + ); + + const [fallback, setSource] = useState<string | null>(null); + + /** + * If the user image cannot be loaded, log an error to the console and set the fallback image. + */ + function setFallback() { + console.warn(`Error loading image from '${src}'`); + setSource(fallbackSrc); + } + + // Only return the fallback image if it's been set. + return useMemo(() => [fallback ?? src, setFallback], [colorMode]); +} + +export const Logo = (props: TLogo) => { + const { web } = useConfig(); + const { width } = web.logo; + + const skeletonA = useColorValue('whiteFaded.100', 'blackFaded.800'); + const skeletonB = useColorValue('original.light', 'original.dark'); + + const [source, setFallback] = useLogo(); + + return ( + <Image + src={source} + alt={web.text.title} + onError={setFallback} + width={width ?? 'auto'} + css={{ + userDrag: 'none', + userSelect: 'none', + msUserSelect: 'none', + MozUserSelect: 'none', + WebkitUserDrag: 'none', + WebkitUserSelect: 'none', + }} + fallback={ + <Skeleton + isLoaded={false} + borderRadius="md" + endColor={skeletonB} + startColor={skeletonA} + width={{ base: 64, lg: 80 }} + height={{ base: 12, lg: 16 }} + /> + } + {...props} + /> + ); +}; diff --git a/hyperglass/ui/components/header/subtitleOnly.tsx b/hyperglass/ui/components/header/subtitleOnly.tsx new file mode 100644 index 0000000..68f7982 --- /dev/null +++ b/hyperglass/ui/components/header/subtitleOnly.tsx @@ -0,0 +1,20 @@ +import { Heading } from '@chakra-ui/react'; +import { useConfig, useBreakpointValue } from '~/context'; +import { useTitleSize } from './useTitleSize'; + +export const SubtitleOnly = () => { + const { web } = useConfig(); + const sizeSm = useTitleSize(web.text.subtitle, 'sm'); + const fontSize = useBreakpointValue({ base: sizeSm, lg: 'xl' }); + + return ( + <Heading + as="h3" + fontWeight="normal" + fontSize={fontSize} + whiteSpace="break-spaces" + textAlign={{ base: 'left', xl: 'center' }}> + {web.text.subtitle} + </Heading> + ); +}; diff --git a/hyperglass/ui/components/header/title.tsx b/hyperglass/ui/components/header/title.tsx new file mode 100644 index 0000000..49ac1fe --- /dev/null +++ b/hyperglass/ui/components/header/title.tsx @@ -0,0 +1,143 @@ +import { Flex, Button, VStack } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { If } from '~/components'; +import { useConfig, useMobile } from '~/context'; +import { useBooleanValue, useLGState } from '~/hooks'; +import { Logo } from './logo'; +import { TitleOnly } from './titleOnly'; +import { useHeaderCtx } from './context'; +import { SubtitleOnly } from './subtitleOnly'; + +import type { StackProps } from '@chakra-ui/react'; +import type { TTitle, TTitleWrapper, TDWrapper } from './types'; + +const AnimatedVStack = motion.custom(VStack); + +/** + * Title wrapper for mobile devices, breakpoints sm & md. + */ +const MWrapper = (props: StackProps) => <VStack spacing={1} alignItems="flex-start" {...props} />; + +/** + * Title wrapper for desktop devices, breakpoints lg & xl. + */ +const DWrapper = (props: TDWrapper) => { + const { showSubtitle } = useHeaderCtx(); + return ( + <AnimatedVStack + spacing={1} + initial="main" + alignItems="center" + animate={showSubtitle ? 'main' : 'submitting'} + transition={{ damping: 15, type: 'spring', stiffness: 100 }} + variants={{ submitting: { scale: 0.5 }, main: { scale: 1 } }} + {...props} + /> + ); +}; + +/** + * Universal wrapper for title sub-components, which will be different depending on the + * `title_mode` configuration variable. + */ +const TitleWrapper = (props: TDWrapper | StackProps) => { + const isMobile = useMobile(); + return ( + <> + {isMobile ? <MWrapper {...(props as StackProps)} /> : <DWrapper {...(props as TDWrapper)} />} + </> + ); +}; + +/** + * Title sub-component if `title_mode` is set to `text_only`. + */ +const TextOnly = (props: TTitleWrapper) => { + return ( + <TitleWrapper {...props}> + <TitleOnly /> + <SubtitleOnly /> + </TitleWrapper> + ); +}; + +/** + * Title sub-component if `title_mode` is set to `logo_only`. Renders only the logo. + */ +const LogoOnly = () => ( + <TitleWrapper> + <Logo /> + </TitleWrapper> +); + +/** + * Title sub-component if `title_mode` is set to `logo_subtitle`. Renders the logo with the + * subtitle underneath. + */ +const LogoSubtitle = () => ( + <TitleWrapper> + <Logo /> + <SubtitleOnly /> + </TitleWrapper> +); + +/** + * Title sub-component if `title_mode` is set to `all`. Renders the logo, title, and subtitle. + */ +const All = () => ( + <TitleWrapper> + <Logo /> + <TextOnly mt={2} /> + </TitleWrapper> +); + +/** + * Title component which renders sub-components based on the `title_mode` configuration variable. + */ +export const Title = (props: TTitle) => { + const { fontSize, ...rest } = props; + const { web } = useConfig(); + const titleMode = web.text.title_mode; + + const { isSubmitting, resetForm } = useLGState(); + + const titleHeight = useBooleanValue(isSubmitting.value, undefined, { md: '20vh' }); + + function handleClick(): void { + isSubmitting.set(false); + resetForm(); + } + + return ( + <Flex + px={0} + flexWrap="wrap" + flexDir="column" + minH={titleHeight} + justifyContent="center" + mt={[null, isSubmitting.value ? null : 'auto']} + {...rest}> + <Button + px={0} + variant="link" + flexWrap="wrap" + flexDir="column" + onClick={handleClick} + _focus={{ boxShadow: 'none' }} + _hover={{ textDecoration: 'none' }}> + <If c={titleMode === 'text_only'}> + <TextOnly /> + </If> + <If c={titleMode === 'logo_only'}> + <LogoOnly /> + </If> + <If c={titleMode === 'logo_subtitle'}> + <LogoSubtitle /> + </If> + <If c={titleMode === 'all'}> + <All /> + </If> + </Button> + </Flex> + ); +}; diff --git a/hyperglass/ui/components/header/titleOnly.tsx b/hyperglass/ui/components/header/titleOnly.tsx new file mode 100644 index 0000000..dda7d63 --- /dev/null +++ b/hyperglass/ui/components/header/titleOnly.tsx @@ -0,0 +1,19 @@ +import { Heading } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { useBooleanValue } from '~/hooks'; +import { useHeaderCtx } from './context'; +import { useTitleSize } from './useTitleSize'; + +export const TitleOnly = () => { + const { showSubtitle } = useHeaderCtx(); + const { web } = useConfig(); + + const margin = useBooleanValue(showSubtitle, 2, 0); + const sizeSm = useTitleSize(web.text.title, '2xl', []); + + return ( + <Heading as="h1" mb={margin} fontSize={{ base: sizeSm, lg: '5xl' }}> + {web.text.title} + </Heading> + ); +}; diff --git a/hyperglass/ui/components/header/types.ts b/hyperglass/ui/components/header/types.ts index 26475ff..67e3359 100644 --- a/hyperglass/ui/components/header/types.ts +++ b/hyperglass/ui/components/header/types.ts @@ -1,16 +1,31 @@ -import { FlexProps } from '@chakra-ui/react'; - -import { IConfig } from '~/types'; +import type { FlexProps, HeadingProps, ImageProps, StackProps } from '@chakra-ui/react'; +import type { MotionProps } from 'framer-motion'; export interface THeader extends FlexProps { resetForm(): void; } -export type TTitleMode = IConfig['web']['text']['title_mode']; - export type THeaderLayout = { - sm: [JSX.Element, JSX.Element, JSX.Element]; - md: [JSX.Element, JSX.Element, JSX.Element]; - lg: [JSX.Element, JSX.Element, JSX.Element]; - xl: [JSX.Element, JSX.Element, JSX.Element]; + sm: [JSX.Element, JSX.Element]; + md: [JSX.Element, JSX.Element]; + lg: [JSX.Element, JSX.Element]; + xl: [JSX.Element, JSX.Element]; }; +export type TDWrapper = Omit<StackProps, 'transition'> & MotionProps; + +export interface TTitle extends FlexProps {} + +export interface TTitleOnly extends HeadingProps {} + +export interface TLogo extends ImageProps {} + +export interface TTitleWrapper extends Partial<MotionProps & Omit<StackProps, 'transition'>> {} + +export interface THeaderCtx { + showSubtitle: boolean; + titleRef: React.MutableRefObject<HTMLHeadingElement>; +} + +export interface THeaderState { + fontSize: string; +} diff --git a/hyperglass/ui/components/header/useTitleSize.ts b/hyperglass/ui/components/header/useTitleSize.ts new file mode 100644 index 0000000..cde6a05 --- /dev/null +++ b/hyperglass/ui/components/header/useTitleSize.ts @@ -0,0 +1,59 @@ +import { useMemo, useState } from 'react'; +import { useToken } from '@chakra-ui/react'; +import { useMobile } from '~/context'; + +// Mobile: +// xs: 32 +// sm: 28 +// md: 24 +// lg: 20 +// xl: 16 +// 2xl: 14 +// 3xl: 12 +// 4xl: 10 +// 5xl: 7 +type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; + +export function useTitleSize(title: string, defaultSize: Sizes, deps: any[] = []) { + const [size, setSize] = useState<Sizes>(defaultSize); + const realSize = useToken('fontSizes', size); + const isMobile = useMobile(); + function getSize(l: number): void { + switch (true) { + case l > 32: + setSize('xs'); + break; + case l <= 32 && l > 28: + setSize('xs'); + break; + case l <= 28 && l > 24: + setSize('sm'); + break; + case l <= 24 && l > 20: + setSize('md'); + break; + case l <= 20 && l > 16: + setSize('lg'); + break; + case l <= 16 && l > 14: + setSize('xl'); + break; + case l <= 14 && l > 12: + setSize('2xl'); + break; + case l <= 12 && l > 10: + setSize('3xl'); + break; + case l <= 10 && l > 7: + setSize('4xl'); + break; + case l <= 7: + setSize('5xl'); + break; + } + } + return useMemo(() => { + getSize(title.length); + return realSize; + }, [title, isMobile, ...deps]); +} diff --git a/hyperglass/ui/components/title/index.ts b/hyperglass/ui/components/title/index.ts deleted file mode 100644 index f71556e..0000000 --- a/hyperglass/ui/components/title/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './title'; diff --git a/hyperglass/ui/components/title/logo.tsx b/hyperglass/ui/components/title/logo.tsx deleted file mode 100644 index a2a1b7e..0000000 --- a/hyperglass/ui/components/title/logo.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Image } from '@chakra-ui/react'; -import { useColorValue, useConfig } from '~/context'; - -import type { TLogo } from './types'; - -export const Logo = (props: TLogo) => { - const { web } = useConfig(); - const { width, dark_format, light_format } = web.logo; - - const src = useColorValue(`/images/dark${dark_format}`, `/images/light${light_format}`); - const fallbackSrc = useColorValue( - 'https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-dark.svg', - 'https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-light.svg', - ); - - return ( - <Image - css={{ - userDrag: 'none', - userSelect: 'none', - msUserSelect: 'none', - MozUserSelect: 'none', - WebkitUserDrag: 'none', - WebkitUserSelect: 'none', - }} - fallbackSrc={fallbackSrc} - width={width ?? 'auto'} - alt={web.text.title} - src={src} - {...props} - /> - ); -}; diff --git a/hyperglass/ui/components/title/subtitleOnly.tsx b/hyperglass/ui/components/title/subtitleOnly.tsx deleted file mode 100644 index 85f84e2..0000000 --- a/hyperglass/ui/components/title/subtitleOnly.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Heading } from '@chakra-ui/react'; -import { useConfig } from '~/context'; - -import type { TSubtitleOnly } from './types'; - -export const SubtitleOnly = (props: TSubtitleOnly) => { - const { web } = useConfig(); - return ( - <Heading - as="h3" - whiteSpace="break-spaces" - fontSize={{ base: 'md', lg: 'xl' }} - textAlign={{ base: 'left', xl: 'center' }} - {...props}> - {web.text.subtitle} - </Heading> - ); -}; diff --git a/hyperglass/ui/components/title/title.tsx b/hyperglass/ui/components/title/title.tsx deleted file mode 100644 index 3a793b3..0000000 --- a/hyperglass/ui/components/title/title.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { forwardRef } from 'react'; -import { Button, Stack } from '@chakra-ui/react'; -import { If } from '~/components'; -import { useConfig } from '~/context'; -import { useBooleanValue, useLGState } from '~/hooks'; -import { TitleOnly } from './titleOnly'; -import { SubtitleOnly } from './subtitleOnly'; -import { Logo } from './logo'; - -import type { TTitle, TTextOnly } from './types'; - -const TextOnly = (props: TTextOnly) => { - const { showSubtitle, ...rest } = props; - - return ( - <Stack - spacing={2} - maxW="100%" - textAlign={showSubtitle ? ['right', 'center'] : ['left', 'center']} - {...rest}> - <TitleOnly showSubtitle={showSubtitle} /> - <If c={showSubtitle}> - <SubtitleOnly /> - </If> - </Stack> - ); -}; - -const LogoSubtitle = () => ( - <> - <Logo /> - <SubtitleOnly mt={6} /> - </> -); - -const All = (props: TTextOnly) => { - const { showSubtitle, ...rest } = props; - return ( - <> - <Logo /> - <TextOnly showSubtitle={showSubtitle} mt={2} {...rest} /> - </> - ); -}; - -export const Title = forwardRef<HTMLButtonElement, TTitle>((props, ref) => { - const { web } = useConfig(); - const titleMode = web.text.title_mode; - - const { isSubmitting } = useLGState(); - - const justify = useBooleanValue( - isSubmitting.value, - { base: 'flex-end', md: 'center' }, - { base: 'flex-start', md: 'center' }, - ); - - return ( - <Button - px={0} - w="100%" - ref={ref} - variant="link" - flexWrap="wrap" - flexDir="column" - justifyContent={justify} - _focus={{ boxShadow: 'none' }} - _hover={{ textDecoration: 'none' }} - alignItems={{ base: 'flex-start', lg: 'center' }} - {...props}> - <If c={titleMode === 'text_only'}> - <TextOnly showSubtitle={!isSubmitting.value} /> - </If> - <If c={titleMode === 'logo_only'}> - <Logo /> - </If> - <If c={titleMode === 'logo_subtitle'}> - <LogoSubtitle /> - </If> - <If c={titleMode === 'all'}> - <All showSubtitle={!isSubmitting.value} /> - </If> - </Button> - ); -}); diff --git a/hyperglass/ui/components/title/titleOnly.tsx b/hyperglass/ui/components/title/titleOnly.tsx deleted file mode 100644 index eb842cc..0000000 --- a/hyperglass/ui/components/title/titleOnly.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Heading } from '@chakra-ui/react'; -import { useConfig } from '~/context'; -import { useBooleanValue } from '~/hooks'; - -import type { TTitleOnly } from './types'; - -export const TitleOnly = (props: TTitleOnly) => { - const { showSubtitle, ...rest } = props; - const { web } = useConfig(); - const fontSize = useBooleanValue(showSubtitle, { base: '2xl', lg: '5xl' }, '2xl'); - const margin = useBooleanValue(showSubtitle, 2, 0); - return ( - <Heading as="h1" mb={margin} fontSize={fontSize} {...rest}> - {web.text.title} - </Heading> - ); -}; diff --git a/hyperglass/ui/components/title/types.ts b/hyperglass/ui/components/title/types.ts deleted file mode 100644 index 426a5c0..0000000 --- a/hyperglass/ui/components/title/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ButtonProps, HeadingProps, ImageProps, StackProps } from '@chakra-ui/react'; - -export interface TTitle extends ButtonProps {} - -export interface TTitleOnly extends HeadingProps { - showSubtitle: boolean; -} - -export interface TSubtitleOnly extends HeadingProps {} - -export interface TLogo extends ImageProps {} - -export interface TTextOnly extends StackProps { - showSubtitle: boolean; -} diff --git a/hyperglass/ui/hooks/index.ts b/hyperglass/ui/hooks/index.ts index 67337ed..f5861ca 100644 --- a/hyperglass/ui/hooks/index.ts +++ b/hyperglass/ui/hooks/index.ts @@ -8,3 +8,5 @@ export * from './useOpposingColor'; export * from './useSessionStorage'; export * from './useStrf'; export * from './useTableToString'; +export * from './useScaledText'; +export * from './useScaledTitle'; diff --git a/hyperglass/ui/hooks/useScaledText.ts b/hyperglass/ui/hooks/useScaledText.ts new file mode 100644 index 0000000..888fa33 --- /dev/null +++ b/hyperglass/ui/hooks/useScaledText.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { useMeasure } from 'react-use'; + +import type { UseMeasureRef as UM } from 'react-use/esm/useMeasure'; + +/** + * These type aliases are for the readability of the function below. + */ +type DC = HTMLElement; +type DT = HTMLHeadingElement; +type A = any; +type B = boolean; + +/** + * Wrapper for useMeasure() which determines if a text element should be scaled down due to its + * size relative to its parent's size. + */ +export function useScaledText<C extends DC = DC, T extends DT = DT>(deps: A[]): [UM<C>, UM<T>, B] { + // Get a ref & state object for the containing element. + const [containerRef, container] = useMeasure<C>(); + + // Get a ref & state object for the text element. + const [textRef, text] = useMeasure<T>(); + + // Memoize the values. + const textWidth = useMemo(() => text.width, [...deps, text.width !== 0]); + const containerWidth = useMemo(() => container.width, [...deps, container.width]); + + // If the text element is the same size or larger than the container, it should be resized. + const shouldResize = textWidth !== 0 && textWidth >= containerWidth; + + return [containerRef, textRef, shouldResize]; +} diff --git a/hyperglass/ui/hooks/useScaledTitle.ts b/hyperglass/ui/hooks/useScaledTitle.ts new file mode 100644 index 0000000..f65aabb --- /dev/null +++ b/hyperglass/ui/hooks/useScaledTitle.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from 'react'; + +type ScaledTitleCallback = (f: string) => void; + +function getWidthPx<R extends React.MutableRefObject<HTMLElement>>(ref: R) { + const computedStyle = window.getComputedStyle(ref.current); + const widthStr = computedStyle.width.replaceAll('px', ''); + const width = parseFloat(widthStr); + return width; +} + +function reducePx(px: number) { + return px * 0.9; +} + +function reducer(val: number, tooBig: () => boolean): number { + let r = val; + if (tooBig()) { + r = reducePx(val); + } + return r; +} + +/** + * + * useScaledTitle( + * f => { + * setFontsize(f); + * }, + * titleRef, + * ref, + * [showSubtitle], + * ); + */ +export function useScaledTitle< + P extends React.MutableRefObject<HTMLDivElement>, + T extends React.MutableRefObject<HTMLHeadingElement> +>(callback: ScaledTitleCallback, parentRef: P, titleRef: T, deps: any[] = []) { + console.log(deps); + const [fontSize, setFontSize] = useState(''); + const calcSize = useRef(0); + + function effect() { + const computedSize = window.getComputedStyle(titleRef.current).getPropertyValue('font-size'); + + const fontPx = parseFloat(computedSize.replaceAll('px', '')); + calcSize.current = fontPx; + + if (typeof window !== 'undefined') { + calcSize.current = reducer( + calcSize.current, + () => getWidthPx(titleRef) >= getWidthPx(parentRef), + ); + + setFontSize(`${calcSize.current}px`); + + return callback(fontSize); + } + } + + return useEffect(effect, [...deps, callback]); +} diff --git a/hyperglass/ui/types/guards.ts b/hyperglass/ui/types/guards.ts index d02092c..edf2149 100644 --- a/hyperglass/ui/types/guards.ts +++ b/hyperglass/ui/types/guards.ts @@ -1,4 +1,5 @@ -import { TValidQueryTypes, TStringTableData, TQueryResponseString } from './data'; +import type { TValidQueryTypes, TStringTableData, TQueryResponseString } from './data'; +import type { TQueryContent } from './config'; export function isQueryType(q: any): q is TValidQueryTypes { let result = false; @@ -22,3 +23,7 @@ export function isStructuredOutput(data: any): data is TStringTableData { export function isStringOutput(data: any): data is TQueryResponseString { return typeof data !== 'undefined' && 'output' in data && typeof data.output === 'string'; } + +export function isQueryContent(c: any): c is TQueryContent { + return typeof c !== 'undefined' && c !== null && 'content' in c; +} diff --git a/hyperglass/ui/types/react-textfit.d.ts b/hyperglass/ui/types/react-textfit.d.ts new file mode 100644 index 0000000..e712e2f --- /dev/null +++ b/hyperglass/ui/types/react-textfit.d.ts @@ -0,0 +1,29 @@ +declare module 'react-textfit' { + type RenderFunction = (text: string) => React.ReactNode; + interface TextfitProps { + children: React.ReactNode | RenderFunction; + text?: string; + /** + * @default number 1 + */ + min?: number; + /** + * @default number 100 + */ + max?: number; + /** + * @default single|multi multi + */ + mode?: 'single' | 'multi'; + /** + * @default boolean true + */ + forceSingleModeWidth?: boolean; + /** + * @default number 50 + */ + throttle?: number; + onReady?: (mid: number) => void; + } + class Textfit extends React.Component<TextfitProps> {} +}