mirror of
				https://github.com/checktheroads/hyperglass
				synced 2024-05-11 05:55:08 +00:00 
			
		
		
		
	improve header layout [skip ci]
This commit is contained in:
		| @@ -3,3 +3,6 @@ import { motion } from 'framer-motion'; | |||||||
|  |  | ||||||
| export const AnimatedDiv = motion.custom(chakra.div); | export const AnimatedDiv = motion.custom(chakra.div); | ||||||
| export const AnimatedForm = motion.custom(chakra.form); | 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); | ||||||
|   | |||||||
							
								
								
									
										14
									
								
								hyperglass/ui/components/header/context.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								hyperglass/ui/components/header/context.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | import { createContext, useContext } from 'react'; | ||||||
|  | import { createState, useState } from '@hookstate/core'; | ||||||
|  | import type { THeaderCtx, THeaderState } from './types'; | ||||||
|  |  | ||||||
|  | const HeaderCtx = createContext<THeaderCtx>({ | ||||||
|  |   showSubtitle: true, | ||||||
|  |   titleRef: {} as React.MutableRefObject<HTMLHeadingElement>, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export const HeaderProvider = HeaderCtx.Provider; | ||||||
|  | export const useHeaderCtx = (): THeaderCtx => useContext(HeaderCtx); | ||||||
|  |  | ||||||
|  | const HeaderState = createState<THeaderState>({ fontSize: '' }); | ||||||
|  | export const useHeader = () => useState<THeaderState>(HeaderState); | ||||||
| @@ -1,168 +1,60 @@ | |||||||
| import { Flex } from '@chakra-ui/react'; | import { useRef } from 'react'; | ||||||
| import { motion, AnimatePresence } from 'framer-motion'; | import { Flex, ScaleFade } from '@chakra-ui/react'; | ||||||
| import { AnimatedDiv, Title, ResetButton, ColorModeToggle } from '~/components'; | import { ColorModeToggle } from '~/components'; | ||||||
| import { useColorValue, useConfig, useBreakpointValue } from '~/context'; | import { useColorValue, useBreakpointValue } from '~/context'; | ||||||
| import { useBooleanValue, useLGState } from '~/hooks'; | import { useBooleanValue, useLGState } from '~/hooks'; | ||||||
|  | import { HeaderProvider } from './context'; | ||||||
|  | import { Title } from './title'; | ||||||
|  |  | ||||||
| import type { ResponsiveValue } from '@chakra-ui/react'; | import type { THeader } from './types'; | ||||||
| import type { THeader, TTitleMode, THeaderLayout } from './types'; |  | ||||||
|  |  | ||||||
| const headerTransition = { |  | ||||||
|   type: 'spring', |  | ||||||
|   ease: 'anticipate', |  | ||||||
|   damping: 15, |  | ||||||
|   stiffness: 100, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| function getWidth(mode: TTitleMode): ResponsiveValue<string> { |  | ||||||
|   let width = '100%' as ResponsiveValue<string>; |  | ||||||
|  |  | ||||||
|   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; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const Header = (props: THeader) => { | export const Header = (props: THeader) => { | ||||||
|   const { resetForm, ...rest } = props; |   const { resetForm, ...rest } = props; | ||||||
|  |  | ||||||
|   const bg = useColorValue('white', 'black'); |   const bg = useColorValue('white', 'black'); | ||||||
|  |  | ||||||
|   const { web } = useConfig(); |  | ||||||
|   const { isSubmitting } = useLGState(); |   const { isSubmitting } = useLGState(); | ||||||
|  |  | ||||||
|   const mlResetButton = useBooleanValue(isSubmitting.value, { base: 0, md: 2 }, undefined); |   const titleRef = useRef({} as HTMLDivElement); | ||||||
|   const titleHeight = useBooleanValue(isSubmitting.value, undefined, { md: '20vh' }); |  | ||||||
|  |  | ||||||
|   const titleVariant = useBreakpointValue({ |   const titleWidth = useBooleanValue( | ||||||
|     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( |  | ||||||
|     isSubmitting.value, |     isSubmitting.value, | ||||||
|     { base: 'flex-end', md: 'center' }, |     { base: '75%', lg: '50%' }, | ||||||
|     { base: 'flex-start', md: 'center' }, |     { base: '75%', lg: '75%' }, | ||||||
|   ); |  | ||||||
|   const resetButton = ( |  | ||||||
|     <AnimatePresence key="resetButton"> |  | ||||||
|       <AnimatedDiv |  | ||||||
|         transition={headerTransition} |  | ||||||
|         initial={{ opacity: 0, x: -50 }} |  | ||||||
|         animate={{ opacity: 1, x: 0, width: 'unset' }} |  | ||||||
|         exit={{ opacity: 0, x: -50 }} |  | ||||||
|         alignItems="center" |  | ||||||
|         mb={{ md: 'auto' }} |  | ||||||
|         ml={mlResetButton} |  | ||||||
|         display={isSubmitting ? 'flex' : 'none'}> |  | ||||||
|         <motion.div> |  | ||||||
|           <ResetButton onClick={resetForm} /> |  | ||||||
|         </motion.div> |  | ||||||
|       </AnimatedDiv> |  | ||||||
|     </AnimatePresence> |  | ||||||
|   ); |  | ||||||
|   const title = ( |  | ||||||
|     <AnimatedDiv |  | ||||||
|       key="title" |  | ||||||
|       px={1} |  | ||||||
|       alignItems={isSubmitting ? 'center' : ['center', 'center', 'flex-end', 'flex-end']} |  | ||||||
|       transition={headerTransition} |  | ||||||
|       initial={{ scale: 0.5 }} |  | ||||||
|       animate={ |  | ||||||
|         isSubmitting && web.text.title_mode === 'text_only' |  | ||||||
|           ? 'smallText' |  | ||||||
|           : isSubmitting && web.text.title_mode !== 'text_only' |  | ||||||
|           ? 'smallLogo' |  | ||||||
|           : 'fullSize' |  | ||||||
|       } |  | ||||||
|       variants={titleVariant} |  | ||||||
|       justifyContent={titleJustify} |  | ||||||
|       mt={[null, isSubmitting ? null : 'auto']} |  | ||||||
|       maxW={getWidth(web.text.title_mode)} |  | ||||||
|       flex="1 0 0" |  | ||||||
|       minH={titleHeight}> |  | ||||||
|       <Title onClick={resetForm} /> |  | ||||||
|     </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> |  | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   const layout = useBooleanValue( |   const justify = useBreakpointValue({ base: 'flex-start', lg: 'center' }); | ||||||
|     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'; |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Flex |     <HeaderProvider value={{ showSubtitle: !isSubmitting.value, titleRef }}> | ||||||
|       px={2} |  | ||||||
|       zIndex="4" |  | ||||||
|       as="header" |  | ||||||
|       width="full" |  | ||||||
|       flex="0 1 auto" |  | ||||||
|       bg={bg} |  | ||||||
|       color="gray.500" |  | ||||||
|       {...rest}> |  | ||||||
|       <Flex |       <Flex | ||||||
|         w="100%" |         px={4} | ||||||
|         mx="auto" |  | ||||||
|         pt={6} |         pt={6} | ||||||
|         justify="space-between" |         bg={bg} | ||||||
|         flex="1 0 auto" |         minH={16} | ||||||
|         alignItems={isSubmitting ? 'center' : 'flex-start'}> |         zIndex={4} | ||||||
|         {layout[layoutBp]} |         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> | ||||||
|     </Flex> |     </HeaderProvider> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
							
								
								
									
										73
									
								
								hyperglass/ui/components/header/logo.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								hyperglass/ui/components/header/logo.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										20
									
								
								hyperglass/ui/components/header/subtitleOnly.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								hyperglass/ui/components/header/subtitleOnly.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										143
									
								
								hyperglass/ui/components/header/title.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								hyperglass/ui/components/header/title.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										19
									
								
								hyperglass/ui/components/header/titleOnly.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								hyperglass/ui/components/header/titleOnly.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -1,16 +1,31 @@ | |||||||
| import { FlexProps } from '@chakra-ui/react'; | import type { FlexProps, HeadingProps, ImageProps, StackProps } from '@chakra-ui/react'; | ||||||
|  | import type { MotionProps } from 'framer-motion'; | ||||||
| import { IConfig } from '~/types'; |  | ||||||
|  |  | ||||||
| export interface THeader extends FlexProps { | export interface THeader extends FlexProps { | ||||||
|   resetForm(): void; |   resetForm(): void; | ||||||
| } | } | ||||||
|  |  | ||||||
| export type TTitleMode = IConfig['web']['text']['title_mode']; |  | ||||||
|  |  | ||||||
| export type THeaderLayout = { | export type THeaderLayout = { | ||||||
|   sm: [JSX.Element, JSX.Element, JSX.Element]; |   sm: [JSX.Element, JSX.Element]; | ||||||
|   md: [JSX.Element, JSX.Element, JSX.Element]; |   md: [JSX.Element, JSX.Element]; | ||||||
|   lg: [JSX.Element, JSX.Element, JSX.Element]; |   lg: [JSX.Element, JSX.Element]; | ||||||
|   xl: [JSX.Element, 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; | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										59
									
								
								hyperglass/ui/components/header/useTitleSize.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								hyperglass/ui/components/header/useTitleSize.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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]); | ||||||
|  | } | ||||||
| @@ -1 +0,0 @@ | |||||||
| export * from './title'; |  | ||||||
| @@ -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} |  | ||||||
|     /> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| @@ -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> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| @@ -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> |  | ||||||
|   ); |  | ||||||
| }); |  | ||||||
| @@ -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> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| @@ -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; |  | ||||||
| } |  | ||||||
| @@ -8,3 +8,5 @@ export * from './useOpposingColor'; | |||||||
| export * from './useSessionStorage'; | export * from './useSessionStorage'; | ||||||
| export * from './useStrf'; | export * from './useStrf'; | ||||||
| export * from './useTableToString'; | export * from './useTableToString'; | ||||||
|  | export * from './useScaledText'; | ||||||
|  | export * from './useScaledTitle'; | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								hyperglass/ui/hooks/useScaledText.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								hyperglass/ui/hooks/useScaledText.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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]; | ||||||
|  | } | ||||||
							
								
								
									
										62
									
								
								hyperglass/ui/hooks/useScaledTitle.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								hyperglass/ui/hooks/useScaledTitle.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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]); | ||||||
|  | } | ||||||
| @@ -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 { | export function isQueryType(q: any): q is TValidQueryTypes { | ||||||
|   let result = false; |   let result = false; | ||||||
| @@ -22,3 +23,7 @@ export function isStructuredOutput(data: any): data is TStringTableData { | |||||||
| export function isStringOutput(data: any): data is TQueryResponseString { | export function isStringOutput(data: any): data is TQueryResponseString { | ||||||
|   return typeof data !== 'undefined' && 'output' in data && typeof data.output === 'string'; |   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; | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										29
									
								
								hyperglass/ui/types/react-textfit.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								hyperglass/ui/types/react-textfit.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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> {} | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user