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 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 { 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<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; | ||||
| } | ||||
| 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 = ( | ||||
|     <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> | ||||
|     { 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 ( | ||||
|     <HeaderProvider value={{ showSubtitle: !isSubmitting.value, titleRef }}> | ||||
|       <Flex | ||||
|       px={2} | ||||
|       zIndex="4" | ||||
|         px={4} | ||||
|         pt={6} | ||||
|         bg={bg} | ||||
|         minH={16} | ||||
|         zIndex={4} | ||||
|         as="header" | ||||
|         width="full" | ||||
|         flex="0 1 auto" | ||||
|       bg={bg} | ||||
|         color="gray.500" | ||||
|         {...rest}> | ||||
|         <ScaleFade in initialScale={0.5} style={{ width: '100%' }}> | ||||
|           <Flex | ||||
|         w="100%" | ||||
|         mx="auto" | ||||
|         pt={6} | ||||
|         justify="space-between" | ||||
|         flex="1 0 auto" | ||||
|         alignItems={isSubmitting ? 'center' : 'flex-start'}> | ||||
|         {layout[layoutBp]} | ||||
|             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> | ||||
|     </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 { 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; | ||||
| } | ||||
|   | ||||
							
								
								
									
										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 './useStrf'; | ||||
| 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 { | ||||
|   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; | ||||
| } | ||||
|   | ||||
							
								
								
									
										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