diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 04732c4..29bdaff 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -1,7 +1,7 @@ """Validate branding configuration variables.""" # Standard Library -from typing import Union, Optional, Sequence +import typing as t from pathlib import Path # Third Party @@ -33,13 +33,14 @@ ColorMode = constr(regex=r"light|dark") DOHProvider = constr(regex="|".join(DNS_OVER_HTTPS.keys())) Title = constr(max_length=32) Side = constr(regex=r"left|right") +LocationDisplayMode = t.Literal["auto", "dropdown", "gallery"] class Analytics(HyperglassModel): """Validation model for Google Analytics.""" enable: StrictBool = False - id: Optional[StrictStr] + id: t.Optional[StrictStr] @validator("id") def validate_id(cls, value, values): @@ -102,7 +103,7 @@ class Greeting(HyperglassModel): """Validation model for greeting modal.""" enable: StrictBool = False - file: Optional[FilePath] + file: t.Optional[FilePath] title: StrictStr = "Welcome" button: StrictStr = "Continue" required: StrictBool = False @@ -121,8 +122,8 @@ class Logo(HyperglassModel): light: FilePath = DEFAULT_IMAGES / "hyperglass-light.svg" dark: FilePath = DEFAULT_IMAGES / "hyperglass-dark.svg" favicon: FilePath = DEFAULT_IMAGES / "hyperglass-icon.svg" - width: Optional[Union[StrictInt, Percentage]] = "100%" - height: Optional[Union[StrictInt, Percentage]] + width: t.Optional[t.Union[StrictInt, Percentage]] = "100%" + height: t.Optional[t.Union[StrictInt, Percentage]] class LogoPublic(Logo): @@ -188,12 +189,12 @@ class ThemeColors(HyperglassModel): cyan: Color = "#118ab2" pink: Color = "#f2607d" purple: Color = "#8d30b5" - primary: Optional[Color] - secondary: Optional[Color] - success: Optional[Color] - warning: Optional[Color] - error: Optional[Color] - danger: Optional[Color] + primary: t.Optional[Color] + secondary: t.Optional[Color] + success: t.Optional[Color] + warning: t.Optional[Color] + error: t.Optional[Color] + danger: t.Optional[Color] @validator(*FUNC_COLOR_MAP.keys(), pre=True, always=True) def validate_colors(cls, value, values, field): @@ -226,7 +227,7 @@ class Theme(HyperglassModel): """Validation model for theme variables.""" colors: ThemeColors = ThemeColors() - default_color_mode: Optional[ColorMode] + default_color_mode: t.Optional[ColorMode] fonts: ThemeFonts = ThemeFonts() @@ -256,10 +257,10 @@ class Web(HyperglassModel): credit: Credit = Credit() dns_provider: DnsOverHttps = DnsOverHttps() - links: Sequence[Link] = [ + links: t.Sequence[Link] = [ Link(title="PeeringDB", url="https://www.peeringdb.com/asn/{primary_asn}") ] - menus: Sequence[Menu] = [ + menus: t.Sequence[Menu] = [ Menu(title="Terms", content=DEFAULT_TERMS), Menu(title="Help", content=DEFAULT_HELP), ] @@ -268,6 +269,7 @@ class Web(HyperglassModel): opengraph: OpenGraph = OpenGraph() text: Text = Text() theme: Theme = Theme() + location_display_mode: LocationDisplayMode = "auto" class WebPublic(Web): diff --git a/hyperglass/ui/components/form/field.tsx b/hyperglass/ui/components/form/field.tsx index 81b9920..5dce75c 100644 --- a/hyperglass/ui/components/form/field.tsx +++ b/hyperglass/ui/components/form/field.tsx @@ -38,13 +38,14 @@ export const FormField: React.FC = (props: TField) => { {...rest} > {label} diff --git a/hyperglass/ui/components/form/location-card.tsx b/hyperglass/ui/components/form/location-card.tsx new file mode 100644 index 0000000..a43e420 --- /dev/null +++ b/hyperglass/ui/components/form/location-card.tsx @@ -0,0 +1,99 @@ +import { useMemo, useState } from 'react'; +import { Flex, Box, Avatar, chakra } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { useColorValue } from '~/context'; +import { useOpposingColor } from '~/hooks'; + +import type { LocationOption } from './queryLocation'; +import type { LocationCardProps } from './types'; + +const MotionBox = motion(Box); + +export const LocationCard = (props: LocationCardProps): JSX.Element => { + const { option, onChange, defaultChecked, hasError } = props; + const { label } = option; + const [isChecked, setChecked] = useState(defaultChecked); + + function handleChange(value: LocationOption) { + if (isChecked) { + setChecked(false); + onChange('remove', value); + } else { + setChecked(true); + onChange('add', value); + } + } + + const bg = useColorValue('white', 'blackSolid.800'); + const imageBorder = useColorValue('gray.600', 'whiteAlpha.800'); + const fg = useOpposingColor(bg); + const checkedBorder = useColorValue('blue.400', 'blue.300'); + const errorBorder = useColorValue('red.500', 'red.300'); + + const borderColor = useMemo( + () => + hasError && isChecked + ? // Highlight red when there are no overlapping query types for the locations selected. + errorBorder + : isChecked && !hasError + ? // Highlight blue when any location is selected and there is no error. + checkedBorder + : // Otherwise, no border. + 'transparent', + + [hasError, isChecked, checkedBorder, errorBorder], + ); + return ( + { + e.preventDefault(); + handleChange(option); + }} + > + + + {label} + + + + + {option?.data?.description && ( + + {option.data.description as string} + + )} + + ); +}; diff --git a/hyperglass/ui/components/form/queryLocation.tsx b/hyperglass/ui/components/form/queryLocation.tsx index 9e90360..c816c88 100644 --- a/hyperglass/ui/components/form/queryLocation.tsx +++ b/hyperglass/ui/components/form/queryLocation.tsx @@ -1,18 +1,18 @@ -import { useMemo, useState } from 'react'; -import { Wrap, VStack, Flex, Box, Avatar, chakra } from '@chakra-ui/react'; -import { motion } from 'framer-motion'; +import { useMemo } from 'react'; +import { Wrap, VStack, Flex, chakra } from '@chakra-ui/react'; import { useFormContext } from 'react-hook-form'; import { Select } from '~/components'; -import { useConfig, useColorValue } from '~/context'; -import { useOpposingColor, useFormState } from '~/hooks'; +import { useConfig } from '~/context'; +import { useFormState } from '~/hooks'; import { isMultiValue, isSingleValue } from '~/components/select'; +import { LocationCard } from './location-card'; import type { DeviceGroup, SingleOption, OptionGroup, FormData } from '~/types'; import type { SelectOnChange } from '~/components/select'; -import type { TQuerySelectField, LocationCardProps } from './types'; +import type { TQuerySelectField } from './types'; /** Location option type alias for future extensions. */ -type LocationOption = SingleOption; +export type LocationOption = SingleOption; function buildOptions(devices: DeviceGroup[]): OptionGroup[] { return devices @@ -37,101 +37,13 @@ function buildOptions(devices: DeviceGroup[]): OptionGroup[] { .sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)); } -const MotionBox = motion(Box); - -const LocationCard = (props: LocationCardProps): JSX.Element => { - const { option, onChange, defaultChecked, hasError } = props; - const { label } = option; - const [isChecked, setChecked] = useState(defaultChecked); - - function handleChange(value: LocationOption) { - if (isChecked) { - setChecked(false); - onChange('remove', value); - } else { - setChecked(true); - onChange('add', value); - } - } - - const bg = useColorValue('whiteAlpha.300', 'blackSolid.700'); - const imageBorder = useColorValue('gray.600', 'whiteAlpha.800'); - const fg = useOpposingColor(bg); - const checkedBorder = useColorValue('blue.400', 'blue.300'); - const errorBorder = useColorValue('red.500', 'red.300'); - - const borderColor = useMemo( - () => - hasError && isChecked - ? // Highlight red when there are no overlapping query types for the locations selected. - errorBorder - : isChecked && !hasError - ? // Highlight blue when any location is selected and there is no error. - checkedBorder - : // Otherwise, no border. - 'transparent', - - [hasError, isChecked, checkedBorder, errorBorder], - ); - return ( - { - e.preventDefault(); - handleChange(option); - }} - > - - - {label} - - - - - {option?.data?.description && ( - - {option.data.description as string} - - )} - - ); -}; - export const QueryLocation = (props: TQuerySelectField): JSX.Element => { const { onChange, label } = props; - const { devices } = useConfig(); + const { + devices, + web: { locationDisplayMode }, + } = useConfig(); const { formState: { errors }, } = useFormContext(); @@ -139,12 +51,18 @@ export const QueryLocation = (props: TQuerySelectField): JSX.Element => { const setSelection = useFormState(s => s.setSelection); const { form, filtered } = useFormState(({ form, filtered }) => ({ form, filtered })); const options = useMemo(() => buildOptions(devices), [devices]); + const element = useMemo(() => { + if (locationDisplayMode === 'dropdown') { + return 'select'; + } else if (locationDisplayMode === 'gallery') { + return 'cards'; + } const groups = options.length; const maxOptionsPerGroup = Math.max(...options.map(opt => opt.options.length)); const showCards = groups < 5 && maxOptionsPerGroup < 6; return showCards ? 'cards' : 'select'; - }, [options]); + }, [options, locationDisplayMode]); const noOverlap = useMemo( () => form.queryLocation.length > 1 && filtered.types.length === 0, @@ -221,8 +139,8 @@ export const QueryLocation = (props: TQuerySelectField): JSX.Element => { options={options} aria-label={label} name="queryLocation" - onChange={handleSelectChange} closeMenuOnSelect={false} + onChange={handleSelectChange} value={selections.queryLocation} isError={typeof errors.queryLocation !== 'undefined'} /> diff --git a/hyperglass/ui/components/form/queryTarget.tsx b/hyperglass/ui/components/form/queryTarget.tsx index c6c1fa1..a93c50e 100644 --- a/hyperglass/ui/components/form/queryTarget.tsx +++ b/hyperglass/ui/components/form/queryTarget.tsx @@ -42,7 +42,7 @@ const Option = (props: OptionProps) => { export const QueryTarget = (props: TQueryTarget): JSX.Element => { const { name, register, onChange, placeholder } = props; - const bg = useColorValue('white', 'whiteAlpha.100'); + const bg = useColorValue('white', 'blackSolid.800'); const color = useColorValue('gray.400', 'whiteAlpha.800'); const border = useColorValue('gray.100', 'whiteAlpha.50'); const placeholderColor = useColorValue('gray.600', 'whiteAlpha.700'); diff --git a/hyperglass/ui/components/lookingGlass.tsx b/hyperglass/ui/components/lookingGlass.tsx index 6047ec5..a220bd3 100644 --- a/hyperglass/ui/components/lookingGlass.tsx +++ b/hyperglass/ui/components/lookingGlass.tsx @@ -189,7 +189,7 @@ export const LookingGlass = (): JSX.Element => { - 0} unmountOnExit> + 0} unmountOnExit> => { const { colorMode } = props; + const { isError } = useSelectContext(); const minHeight = useToken('space', 12); @@ -22,9 +23,10 @@ export const useControlStyle = { @@ -56,9 +58,12 @@ export const useMenuStyle = ( props: RSStyleCallbackProps, ): RSStyleFunction<'menu', Opt, IsMulti> => { const { colorMode } = props; + const { isOpen } = useSelectContext(); + const backgroundColor = useColorToken('colors', 'white', 'blackSolid.700'); const styles = { backgroundColor, zIndex: 1500 }; + return useCallback(base => mergeWith({}, base, styles), [colorMode, isOpen]); }; @@ -66,13 +71,14 @@ export const useMenuListStyle = => { const { colorMode } = props; + const { isOpen } = useSelectContext(); + const borderRadius = useToken('radii', 'md'); const backgroundColor = useColorToken('colors', 'white', 'blackSolid.700'); const scrollbarTrack = useColorToken('colors', 'blackAlpha.50', 'whiteAlpha.50'); const scrollbarThumb = useColorToken('colors', 'blackAlpha.300', 'whiteAlpha.300'); const scrollbarThumbHover = useColorToken('colors', 'blackAlpha.400', 'whiteAlpha.400'); - const styles = { borderRadius, backgroundColor, @@ -82,6 +88,7 @@ export const useMenuListStyle = mergeWith({}, base, styles), [colorMode, isOpen]); }; @@ -89,6 +96,7 @@ export const useOptionStyle = => { const { colorMode } = props; + const { isOpen } = useSelectContext(); const fontSize = useToken('fontSizes', 'lg'); @@ -136,8 +144,10 @@ export const useIndicatorSeparatorStyle = => { const { colorMode } = props; - const backgroundColor = useColorToken('colors', 'whiteAlpha.700', 'gray.600'); + const backgroundColor = useColorToken('colors', 'gray.200', 'whiteAlpha.300'); + // const backgroundColor = useColorToken('colors', 'gray.200', 'gray.600'); const styles = { backgroundColor }; + return useCallback(base => mergeWith({}, base, styles), [colorMode]); }; @@ -145,8 +155,10 @@ export const usePlaceholderStyle = => { const { colorMode } = props; + const color = useColorToken('colors', 'gray.600', 'whiteAlpha.700'); const fontSize = useToken('fontSizes', 'lg'); + return useCallback(base => mergeWith({}, base, { color, fontSize }), [colorMode]); }; @@ -157,8 +169,8 @@ export const useSingleValueStyle = mergeWith({}, base, styles), [color, colorMode]); }; @@ -169,8 +181,9 @@ export const useMultiValueStyle = mergeWith({}, base, styles), [backgroundColor, colorMode]); }; @@ -181,8 +194,8 @@ export const useMultiValueLabelStyle = mergeWith({}, base, styles), [colorMode]); }; @@ -193,16 +206,17 @@ export const useMultiValueRemoveStyle = mergeWith({}, base, styles), [colorMode]); }; export const useRSTheme = (): RSThemeFunction => { const borderRadius = useToken('radii', 'md'); + return useCallback((t: ReactSelect.Theme): ReactSelect.Theme => ({ ...t, borderRadius }), []); }; @@ -215,5 +229,6 @@ export const useMenuPortal = const styles = { zIndex: isMobile ? 1500 : 1, }; + return useCallback(base => merge(base, styles), [isMobile]); }; diff --git a/hyperglass/ui/types/config.ts b/hyperglass/ui/types/config.ts index 1a04f18..4a6f01d 100644 --- a/hyperglass/ui/types/config.ts +++ b/hyperglass/ui/types/config.ts @@ -98,6 +98,7 @@ interface _Web { terms: { enable: boolean; title: string }; text: _Text; theme: _ThemeConfig; + location_display_mode: 'auto' | 'gallery' | 'dropdown'; } type _DirectiveBase = {