import { ColorLike } from '@o/color' import { CSSPropertySet } from '@o/css' import { isDefined, selectDefined } from '@o/utils' import { Box, CompiledTheme, Flex, gloss, GlossProps, propsToStyles, pseudoProps, PseudoStyle, PseudoStyleProps, pseudoStyleTheme, ThemeFn, ThemeSelect, ThemeValue, useTheme } from 'gloss' import React, { HTMLProps, useEffect, useMemo, useState } from 'react' import { Badge } from './Badge' import { useBreadcrumb, useBreadcrumbReset } from './Breadcrumbs' import { Glint } from './effects/Glint' import { HoverGlow } from './effects/HoverGlow' import { themeable } from './helpers/themeable' import { useSizedSurfaceProps } from './hooks/useSizedSurface' import { Icon, IconProps } from './Icon' import { IconPropsContext } from './IconPropsContext' import { InvertScale } from './InvertScale' import { PassProps } from './PassProps' import { PopoverProps } from './Popover' import { getSegmentedStyle } from './SegmentedRow' import { getSize } from './Sizes' import { Size, Space } from './Space' import { SizedSurfacePropsContext } from './SurfacePropsContext' import { textSizeTheme } from './text/textSizeTheme' import { Tooltip } from './Tooltip' import { elevationTheme } from './View/elevation' import { marginTheme } from './View/marginTheme' import { ViewProps, ViewPropsPlain } from './View/types' import { View } from './View/View' // an element for creating surfaces that look like buttons // they basically can control a prefix/postfix icon, and a few other bells /** Controlled height, relative adjusted to size */ export type SizedSurfaceSpecificProps = { /** size affects all other sizing props */ size?: Size sizeHeight?: boolean | number /** Controlled font size, relative adjusted to size */ sizeFont?: boolean | number /** Controlled horizontal padding, relative adjusted to size */ sizePadding?: boolean | number /** Controlled margin, relative adjusted to size */ sizeMargin?: boolean | number /** Controlled border radius size, relative adjusted to size */ sizeRadius?: boolean | number /** Controlled icon size, relative adjusted to size */ sizeIcon?: boolean | number } export type SurfaceSpecificProps = SizedSurfaceSpecificProps & { /** Inside uses a shadow instead of border for finder borders */ borderPosition?: 'inside' | 'outside' /** Force focus state on */ focus?: boolean /** Force hover state on */ hover?: boolean /** Force active state on */ active?: boolean /** Ellipse text used inside children of surface */ ellipse?: boolean /** Element before surface elements */ before?: React.ReactNode /** Element after surface elements */ after?: React.ReactNode /** Add a badge to surface, see */ badge?: React.ReactNode /** Extra props for badge */ badgeProps?: Object /** Button children / text */ children?: React.ReactNode /** Name for surface */ name?: string /** Removes background, border, glint styles */ chromeless?: boolean /** Forces surface into circle shape */ circular?: boolean /** Add extra props to the inner element */ elementProps?: Object /** Props for , shown at bottom of surface */ glintBottom?: boolean /** Props for , shown at top of surface */ glint?: boolean /** Add a to the surface */ glow?: boolean /** Add props if glow enabled */ glowProps?: Object /** Add a to the surface */ hovered?: boolean /** Name for element, or custom element */ icon?: React.ReactNode /** Show icon after text */ iconAfter?: boolean /** Set icon color separately */ iconColor?: ColorLike /** Extra props for element */ iconProps?: Partial /** Set icon size separately */ iconSize?: number /** Can force to show or never show an inner element */ showInnerElement?: 'always' | 'never' theme?: CompiledTheme /** Adds a on the surface */ tooltip?: React.ReactNode /** Extra props for the */ tooltipProps?: PopoverProps /** Text alpha */ alpha?: number /** Text alpha on hover */ alphaHover?: number /** Force disabled state of surface */ disabled?: boolean /** HTML prop type */ type?: string /** Select a subset theme easily */ subTheme?: ThemeSelect /** Amount to pad icon */ iconPadding?: number /** Force ignore grouping */ ignoreSegment?: boolean /** Override space between sizing between Icon/Element */ space?: Size /** Override space between sizing between Icon/Element */ spaceAround?: Size /** Add an element between the icon and inner element */ betweenIconElement?: React.ReactNode /** Style as part of a group */ segment?: 'first' | 'last' | 'middle' | 'single' /** [Advanced] Add an extra theme to the inner element */ elementTheme?: ThemeFn } export type SurfaceProps = GlossProps< Omit< ViewPropsPlain, 'size' | 'activeStyle' | 'focusStyle' | 'focusWithinStyle' | 'disabledStyle' | 'selectedStyle' > & SurfaceSpecificProps & PseudoThemeProps > const getBorderRadius = (t, b, l, r, tl, tr, bl, br) => { return { borderTopLeftRadius: selectDefined(tl, t, l), borderTopRightRadius: selectDefined(tr, t, r), borderBottomRightRadius: selectDefined(br, b, r), borderBottomLeftRadius: selectDefined(bl, b, l), } } type ThroughProps = Pick< SurfaceProps, | 'iconPadding' | 'alignItems' | 'justifyContent' | 'sizeIcon' | 'iconSize' | 'iconAfter' | 'fontWeight' | 'ellipse' | 'overflow' | 'textDecoration' | 'elementTheme' > & { hasIcon: boolean tagName?: string } const acceptsIcon = child => child && child.type.acceptsProps && child.type.acceptsProps.icon === true // why? need to document bug that led to this hackty patch // im guessing popover is looking for selector too early, that should be patched in popover const setTooltip = (tooltip, setTooltipState) => { if (tooltip) { setTooltipState(prev => { prev.id = prev.id || `Surface-${Math.round(Math.random() * 100000000)}` prev.show = false return prev }) let tm = setTimeout(() => { setTooltipState(prev => { prev.show = true return prev }) }) return () => clearTimeout(tm) } } export const Surface = themeable(function Surface(direct: SurfaceProps) { const sizedProps = useSizedSurfaceProps(direct) const props = SizedSurfacePropsContext.useProps(sizedProps) as SurfaceProps const crumb = useBreadcrumb() const [tooltipState, setTooltipState] = useState({ id: null, show: false }) const theme = useTheme() const { alignItems, children, className, disabled, elementProps, elementTheme, glintBottom, glint, glow, glowProps, height, icon, iconAfter, iconPadding, iconProps, justifyContent, showInnerElement, size: ogSize, sizeLineHeight, tagName, subTheme: subTheme, tooltip, tooltipProps, padding, badgeProps, badge, after, borderPosition = 'outside', borderWidth, coat, before, dangerouslySetInnerHTML, space, spaceAround, betweenIconElement, borderTopRadius, borderBottomRadius, ...viewProps } = props const size = getSize(selectDefined(ogSize, 1)) const segmentedStyle = getSegmentedStyle(props, crumb) const stringIcon = typeof icon === 'string' useEffect(() => setTooltip(tooltip, setTooltipState), [tooltip]) // goes to BOTH the outer element and inner element let throughProps: ThroughProps = { elementTheme, iconPadding: typeof iconPadding === 'number' ? iconPadding : size * 8, alignItems, justifyContent, sizeIcon: props.sizeIcon, iconSize: props.iconSize, iconAfter: props.iconAfter, hasIcon: !!props.icon, fontWeight: props.fontWeight, ellipse: props.ellipse, overflow: props.overflow, textDecoration: props.textDecoration, } let lineHeight = props.lineHeight if (!props.lineHeight && sizeLineHeight && +height == +height) { // @ts-ignore lineHeight = typeof height === 'number' ? `${height * 0.92}px` : height } const childrenProps: HTMLProps = {} const pxHeight = +height == +height const borderLeftRadius = selectDefined( props.borderLeftRadius ? +props.borderLeftRadius : undefined, segmentedStyle ? segmentedStyle.borderLeftRadius : +props.borderRadius, pxHeight ? +height / 2 : undefined, 0, ) const borderRightRadius = selectDefined( props.borderRightRadius ? +props.borderRightRadius : undefined, segmentedStyle ? segmentedStyle.borderRightRadius : +props.borderRadius, pxHeight ? +height / 2 : undefined, 0, ) const borderProps = getBorderRadius( borderTopRadius, borderBottomRadius, borderLeftRadius, borderRightRadius, props.borderTopLeftRadius, props.borderTopRightRadius, props.borderBottomRightRadius, props.borderBottomLeftRadius, ) const disableGlint = theme.disableGlint ? theme.disableGlint.get() : false const hasAnyGlint = !disableGlint && !props.chromeless && !!(glint || glintBottom) let showElement = false // because we can't define children at all on tags like input // we conditionally set children here to avoid having children: undefined if (dangerouslySetInnerHTML) { childrenProps.dangerouslySetInnerHTML = dangerouslySetInnerHTML } else if (showInnerElement === 'never') { if (isDefined(before, after)) { childrenProps.children = ( <> {before} {children || null} {after} ) } else { childrenProps.children = children || null } } else { showElement = !!(hasChildren(children) || showInnerElement === 'always') const spaceElement = const innerElements = !!icon && ( {!stringIcon && icon} {stringIcon && } ) childrenProps.children = ( <> {before} {!!badge && ( {badge} )} {!!tooltip && tooltipState.show && ( {`.${tooltipState.id}`} )} {/* TODO: this can be one element i think */} {hasAnyGlint && ( 0 && { height: roundHalf(+height - size / 2) - 1, transform: { y: roundHalf(size), }, })} > {glint && !props.chromeless && ( )} {glintBottom && !props.chromeless && ( )} )} {!!icon && iconAfter ? ( {showElement && spaceElement} {!!betweenIconElement && ( <> {betweenIconElement} {spaceElement} )} {innerElements} ) : ( <> {innerElements} {!!betweenIconElement && ( <> {spaceElement} {betweenIconElement} )} {showElement && icon && spaceElement} )} {!!glow && !disabled && ( )} {showElement && ( {children} )} {!!after && ( <> {spaceElement} {after} )} ) } // automatically invert transition if (props.layoutTransition) { const ogChildren = childrenProps.children childrenProps.children = {ogChildren} } const iconOpacity = props.alpha ?? props.opacity const iconColor = props.iconProps?.color ?? props.color const iconColorHover = props?.iconProps?.colorHover ?? props?.colorHover const iconContext = useMemo>(() => { return { coat, opacity: iconOpacity, color: iconColor ?? (theme => theme.color), colorHover: iconColorHover ?? (theme => theme.colorHover), justifyContent: 'center', } }, [coat, iconOpacity, iconColor, iconColorHover]) // @ts-ignore const surfaceFrameProps: SurfaceFrameProps = { className: `${tooltipState.id ?? ''} ${(crumb && crumb.selector) ?? ''} ${className ?? ''}`.trim(), subTheme: subTheme, lineHeight, padding, borderWidth, borderPosition, coat, height, applyPsuedoColors: true, disabled, tagName: !showElement ? tagName : 'div', opacity: crumb && crumb.total === 0 ? 0 : props.opacity, ...(!showElement && elementProps), ...throughProps, ...viewProps, ...segmentedStyle, // ensure borderTopRadius, borderBottomRadius override borderTopRadius, borderBottomRadius, ...childrenProps, } if (props.debug) { console.log('ok', showElement, props, surfaceFrameProps) } return useBreadcrumbReset( SizedSurfacePropsContext.useReset( , ), ) }) Surface['defaultProps'] = { // todo better pattern here baseOverridesPsuedo: true, } const hasChildren = (children: React.ReactNode) => { if (Array.isArray(children)) { return children.some(x => isDefined(x) && x !== null && x !== false) } return !!children } const chromelessStyle = { borderColor: 'transparent', background: 'transparent', } type PseudoThemeProps = { [K in keyof PseudoStyleProps]: ThemeFn | PseudoStyle } /** * Allows you to pass theme functions as props */ const pseudoFunctionThemes /* : ThemeFn */ = (props, prev) => { for (const key in pseudoProps) { if (typeof props[key] === 'function') { const val = props[key](props, prev) if (val) { prev[key] = prev[key] || {} Object.assign(prev[key], val) } } } } // fontFamily: inherit on both fixes elements const SurfaceFrame = gloss(View, { fontFamily: 'inherit', position: 'relative', whiteSpace: 'pre', conditional: { circular: { alignItems: 'center', justifyContent: 'center', padding: 0, }, disabled: { cursor: 'not-allowed', }, }, }).theme(pseudoFunctionThemes, pseudoStyleTheme, (props, prev) => { // todo fix types here const marginStyle = marginTheme(props as any) const theme = textSizeTheme(props as any) if (!theme) return const fontSize = theme!.fontSize const lineHeight = theme!.lineHeight if (prev && props.chromeless) { delete prev.hoverStyle delete prev.activeStyle } let styles: CSSPropertySet = {} let boxShadow = [].concat(props.boxShadow || null).filter(Boolean) const borderWidth = selectDefined(props.borderWidth, 0) // borderPosition controls putting borders inside vs outside // useful for having nice looking buttons (inside) vs container-like views (outside) if (props.borderColor && !props.chromeless) { if (props.borderPosition === 'inside') { const borderWidthValue = borderWidth instanceof ThemeValue ? borderWidth.getSafe() : borderWidth const borderWidthCalculated = typeof borderWidthValue === 'number' ? `calc(${borderWidthValue} * 1px)` : borderWidthValue // inside boxShadow = [...(boxShadow || []), ['inset', 0, 0, 0, borderWidthCalculated, props.borderColor]] styles.borderWidth = 0 } else { // outside styles.border = [borderWidth, props.borderStyle || 'solid', props.borderColor] } } if (props.elevation) { // @ts-ignore boxShadow = [...(boxShadow || []), ...elevationTheme(props as any)?.boxShadow] } const res = { ...(props.chromeless && chromelessStyle), ...(props.circular && { width: props.height, }), fontSize, lineHeight, ...marginStyle, ...styles, boxShadow, } return res }) const applyElementTheme: ThemeFn = props => props.elementTheme ? props.elementTheme(props) : null const Element = gloss({ display: 'flex', // in case they change tagName flex: 1, overflow: 'hidden', fontSize: 'inherit', lineHeight: 'inherit', textAlign: 'inherit', fontFamily: 'inherit', padding: 0, flexDirection: 'row', border: 'none', background: 'transparent', // otherwise it wont be full height so impossible to position things at start/end height: 'inherit', conditional: { ellipse: { display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, }, }).theme(propsToStyles, applyElementTheme) const getIconSize = (props: SurfaceProps) => { if (isDefined(props.iconSize)) return props.iconSize const iconSize = props.height ? +props.height * 0.1 + 8 : 12 const size = getSize(props.size) * iconSize * (props.sizeIcon === true ? 1 : selectDefined(props.sizeIcon, 1)) return Math.floor(size) } const GlintContain = gloss(Flex, { height: '100%', position: 'absolute', top: 0, left: 0, right: 0, pointerEvents: 'none', zIndex: 10, overflow: 'hidden', }) const roundHalf = (x: number) => { const oneDec = Math.round((x % 1) * 10) / 10 const roundedToPointFive = oneDec > 6 ? 0.5 : 0 return Math.floor(x) + roundedToPointFive }