diff --git a/src/components/hv-date-field/field/index.tsx b/src/components/hv-date-field/field/index.tsx index 24dabbf3e..93a6e1d97 100644 --- a/src/components/hv-date-field/field/index.tsx +++ b/src/components/hv-date-field/field/index.tsx @@ -1,7 +1,7 @@ import * as Contexts from 'hyperview/src/contexts'; import React, { useState } from 'react'; import { StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; -import { createProps, createStyleProp } from 'hyperview/src/services'; +import { useProps, useStyleProp } from 'hyperview/src/services'; import FieldLabel from '../field-label'; import type { Props } from './types'; import type { StyleSheet as StyleSheetType } from 'hyperview/src/types'; @@ -11,29 +11,41 @@ import type { StyleSheet as StyleSheetType } from 'hyperview/src/types'; * Tapping the box focuses the field and brings up the date picker. */ export default (props: Props) => { + // eslint-disable-next-line react/destructuring-assignment + const { + children, + element, + focused, + onPress, + options, + stylesheets, + value, + } = props; + const { pressedSelected, selected } = options; // Styles selected based on pressed state of the field. const [pressed, setPressed] = useState(false); // Create the props (including styles) for the box of the input field. - const viewProps = createProps(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, + const viewProps = useProps(element, stylesheets, { + ...options, + focused, pressed, styleAttr: 'field-style', }); const labelStyle: StyleSheetType = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, + useStyleProp(element, stylesheets, { + focused, pressed, + pressedSelected, + selected, styleAttr: 'field-text-style', }), ); return ( setPressed(true)} onPressOut={() => setPressed(false)} > @@ -42,16 +54,16 @@ export default (props: Props) => { {formatter => ( )} - {props.children} + {children} ); diff --git a/src/components/hv-image/index.ts b/src/components/hv-image/index.ts deleted file mode 100644 index b0ccd740e..000000000 --- a/src/components/hv-image/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as Namespaces from 'hyperview/src/services/namespaces'; -import type { - HvComponentOnUpdate, - HvComponentProps, -} from 'hyperview/src/types'; -import { Image, ImageProps } from 'react-native'; -import React, { PureComponent } from 'react'; -import { LOCAL_NAME } from 'hyperview/src/types'; -import { addHref } from 'hyperview/src/core/hyper-ref'; -import { createProps } from 'hyperview/src/services'; -import urlParse from 'url-parse'; - -export default class HvImage extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; - - static localName = LOCAL_NAME.IMAGE; - - render() { - const { skipHref } = this.props.options || {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const imageProps: Record = {}; - let source = this.props.element.getAttribute('source'); - if (source) { - source = urlParse(source, this.props.options.screenUrl, true).toString(); - imageProps.source = { uri: source }; - } - const props = { - ...createProps( - this.props.element, - this.props.stylesheets, - this.props.options, - ), - ...imageProps, - } as ImageProps; - const component = React.createElement(Image, props); - return skipHref - ? component - : addHref( - component, - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - this.props.options, - ); - } -} diff --git a/src/components/hv-image/index.tsx b/src/components/hv-image/index.tsx new file mode 100644 index 000000000..f2ce23af0 --- /dev/null +++ b/src/components/hv-image/index.tsx @@ -0,0 +1,46 @@ +import * as Namespaces from 'hyperview/src/services/namespaces'; +import type { + HvComponentOnUpdate, + HvComponentProps, +} from 'hyperview/src/types'; +import React, { useMemo } from 'react'; +import { Image } from 'react-native'; +import { LOCAL_NAME } from 'hyperview/src/types'; +import { addHref } from 'hyperview/src/core/hyper-ref'; +import urlParse from 'url-parse'; +import { useProps } from 'hyperview/src/services'; + +const HvImage = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const { screenUrl, skipHref } = options; + const baseProps = useProps(element, stylesheets, options); + const source = useMemo(() => element.getAttribute('source'), [element]); + const componentProps = useMemo(() => { + if (!source) { + return baseProps; + } + return { + ...baseProps, + source: { uri: urlParse(source, screenUrl, true).toString() }, + }; + }, [baseProps, screenUrl, source]); + + const component = useMemo(() => React.createElement(Image, componentProps), [ + componentProps, + ]); + return skipHref + ? component + : addHref( + component, + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + options, + ); +}; + +HvImage.namespaceURI = Namespaces.HYPERVIEW; +HvImage.localName = LOCAL_NAME.IMAGE; + +export default HvImage; diff --git a/src/components/hv-option/index.tsx b/src/components/hv-option/index.tsx index 67313633a..8d92992ee 100644 --- a/src/components/hv-option/index.tsx +++ b/src/components/hv-option/index.tsx @@ -5,97 +5,118 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; import { LOCAL_NAME } from 'hyperview/src/types'; -import type { State } from './types'; import { createEventHandler } from 'hyperview/src/core/hyper-ref'; -import { createProps } from 'hyperview/src/services'; +import { useProps } from 'hyperview/src/services'; /** * A component representing an option in a single-select or multiple-select list. * Has a local pressed state. The selected state is read from the element attribute. */ -export default class HvOption extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; +const HvOption = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const [pressed, setPressed] = useState(false); + const { onSelect, onToggle } = options; + const value = useMemo(() => element.getAttribute('value'), [element]); + const selected = useMemo(() => element.getAttribute('selected') === 'true', [ + element, + ]); + const prevSelected = useRef(selected); - static localName = LOCAL_NAME.OPTION; + // Updates options with pressed/selected state, so that child element can render + // using the appropriate modifier styles. + const newOptions = useMemo(() => { + return { + ...options, + pressed, + pressedSelected: pressed && selected, + selected, + } as const; + }, [options, pressed, selected]); - state: State = { - pressed: false, - }; + const componentProps = useProps(element, stylesheets, newOptions); - componentDidUpdate(prevProps: HvComponentProps) { - const selected = this.props.element.getAttribute('selected') === 'true'; - const prevSelected = prevProps.element.getAttribute('selected') === 'true'; - if (selected && !prevSelected) { - Behaviors.trigger('select', this.props.element, this.props.onUpdate); - } + const flex = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TODO: fix this + return componentProps.style?.flex; + }, [ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TODO: fix this + componentProps.style?.flex, + ]); - if (!selected && prevSelected) { - Behaviors.trigger('deselect', this.props.element, this.props.onUpdate); + const handlePress = useCallback(() => { + if (onSelect) { + // Updates the DOM state, causing this element to re-render as selected. + // Used in select-single context. + onSelect(value); } - } + if (onToggle) { + // Updates the DOM state, toggling this element. + // Used in select-multiple context. + onToggle(value); + } + }, [onSelect, onToggle, value]); - render() { - const { onSelect, onToggle } = this.props.options; + const handlePressIn = useCallback(() => setPressed(true), []); + const handlePressOut = useCallback(() => setPressed(false), []); - const value = this.props.element.getAttribute('value'); - const selected = this.props.element.getAttribute('selected') === 'true'; + // Option renders as an outer TouchableWithoutFeedback view and inner view. + // The outer view handles presses, the inner view handles styling. + const outerProps = useMemo(() => { + return { + onPress: createEventHandler(handlePress, true), + onPressIn: createEventHandler(handlePressIn), + onPressOut: createEventHandler(handlePressOut), - // Updates options with pressed/selected state, so that child element can render - // using the appropriate modifier styles. - const newOptions = { - ...this.props.options, - pressed: this.state.pressed, - pressedSelected: this.state.pressed && selected, - selected, - } as const; - const props = createProps( - this.props.element, - this.props.stylesheets, + // Flex is a style that needs to be lifted from the inner component to the outer + // component to ensure proper layout. + style: flex ? { flex } : {}, + }; + }, [flex, handlePress, handlePressIn, handlePressOut]); + + // TODO: Replace with + const children = useMemo(() => { + return Render.renderChildren( + element, + stylesheets, + onUpdate as HvComponentOnUpdate, newOptions, ); + }, [element, newOptions, onUpdate, stylesheets]); - // Option renders as an outer TouchableWithoutFeedback view and inner view. - // The outer view handles presses, the inner view handles styling. - const outerProps = { - onPress: createEventHandler(() => { - if (onSelect) { - // Updates the DOM state, causing this element to re-render as selected. - // Used in select-single context. - onSelect(value); - } - if (onToggle) { - // Updates the DOM state, toggling this element. - // Used in select-multiple context. - onToggle(value); - } - }, true), - onPressIn: createEventHandler(() => this.setState({ pressed: true })), - onPressOut: createEventHandler(() => this.setState({ pressed: false })), - style: {}, - }; - if (props.style && props.style.flex) { - // Flex is a style that needs to be lifted from the inner component to the outer - // component to ensure proper layout. - outerProps.style = { flex: props.style.flex }; + useEffect(() => { + if (selected && !prevSelected.current) { + Behaviors.trigger('select', element, onUpdate); } - // TODO: Replace with - return React.createElement( - TouchableWithoutFeedback, - outerProps, - React.createElement( - View, - props, - ...Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - newOptions, - ), - ), - ); - } -} + if (!selected && prevSelected.current) { + Behaviors.trigger('deselect', element, onUpdate); + } + prevSelected.current = selected; + }, [element, onUpdate, selected]); + + const view = useMemo(() => { + return React.createElement(View, componentProps, ...children); + }, [componentProps, children]); + + return useMemo( + () => React.createElement(TouchableWithoutFeedback, outerProps, view), + [outerProps, view], + ); +}; + +HvOption.namespaceURI = Namespaces.HYPERVIEW; +HvOption.localName = LOCAL_NAME.OPTION; + +export default HvOption; diff --git a/src/components/hv-option/types.ts b/src/components/hv-option/types.ts index 2cf9c4f7b..3dfc2c74e 100644 --- a/src/components/hv-option/types.ts +++ b/src/components/hv-option/types.ts @@ -1,7 +1,3 @@ -export type State = { - pressed: boolean; -}; - // https://reactnative.dev/docs/flatlist#scrolltoindex export type ScrollParams = { animated?: boolean | undefined; diff --git a/src/components/hv-picker-field/field-label/index.tsx b/src/components/hv-picker-field/field-label/index.tsx index 1a79e670d..9838a74a0 100644 --- a/src/components/hv-picker-field/field-label/index.tsx +++ b/src/components/hv-picker-field/field-label/index.tsx @@ -3,36 +3,38 @@ import { StyleSheet, Text } from 'react-native'; import type { Props } from './types'; import React from 'react'; import type { StyleSheet as StyleSheetType } from 'hyperview/src/types'; -import { createStyleProp } from 'hyperview/src/services'; +import { useStyleProp } from 'hyperview/src/services'; /** * This text label of the field. Contains logic to decide how to format the value * or show the placeholder, including applying the right styles. */ export default (props: Props) => { - const placeholder = props.element.getAttribute('placeholder'); - const placeholderTextColor = props.element.getAttribute( - 'placeholderTextColor', - ); + // eslint-disable-next-line react/destructuring-assignment + const { element, focused, pressed, options, stylesheets, value } = props; + const { pressedSelected, selected } = options; + const placeholder = element.getAttribute('placeholder'); + const placeholderTextColor = element.getAttribute('placeholderTextColor'); const style: StyleSheetType = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, - pressed: props.pressed, + useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, styleAttr: 'field-text-style', }), ); const labelStyles: Array = [style]; - if (!props.value && placeholderTextColor) { + if (!value && placeholderTextColor) { labelStyles.push({ color: placeholderTextColor }); } - const label: string = props.value ? props.value : placeholder || ''; + const label: string = value || placeholder || ''; return ( // eslint-disable-next-line react/jsx-props-no-spreading - + {label} ); diff --git a/src/components/hv-picker-field/field/index.tsx b/src/components/hv-picker-field/field/index.tsx index 7da7fa3e5..3c8a26307 100644 --- a/src/components/hv-picker-field/field/index.tsx +++ b/src/components/hv-picker-field/field/index.tsx @@ -2,27 +2,37 @@ import React, { useState } from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; import FieldLabel from '../field-label'; import type { Props } from './types'; -import { createProps } from 'hyperview/src/services'; +import { useProps } from 'hyperview/src/services'; /** * The input field component. This is a box with text in it. * Tapping the box focuses the field and brings up the date picker. */ export default (props: Props) => { + // eslint-disable-next-line react/destructuring-assignment + const { + children, + element, + focused, + onPress, + options, + stylesheets, + value, + } = props; // Styles selected based on pressed state of the field. const [pressed, setPressed] = useState(false); // Create the props (including styles) for the box of the input field. - const viewProps = createProps(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, + const viewProps = useProps(element, stylesheets, { + ...options, + focused, pressed, styleAttr: 'field-style', }); return ( setPressed(true)} onPressOut={() => setPressed(false)} > @@ -31,14 +41,14 @@ export default (props: Props) => { {...viewProps} > - {props.children} + {children} ); diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index d768201cc..bbb71e1e2 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -1,15 +1,11 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; -import type { - DOMString, - HvComponentProps, - StyleSheet, -} from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import type { DOMString, HvComponentProps } from 'hyperview/src/types'; +import React, { useCallback, useMemo } from 'react'; import { - createStyleProp, createTestProps, getNameValueFormInputValues, + useStyleProp, } from 'hyperview/src/services'; import Field from './field'; import { LOCAL_NAME } from 'hyperview/src/types'; @@ -23,18 +19,38 @@ import { View } from 'react-native'; * - On Android, the system picker is rendered inline on the screen. Pressing the picker * opens a system dialog. */ -export default class HvPickerField extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; +const HvPickerField = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const { focused, pressed, pressedSelected, selected } = options; - static localName = LOCAL_NAME.PICKER_FIELD; + /** + * Returns a string representing the value in the field. + */ + const value = useMemo((): string => element.getAttribute('value') || '', [ + element, + ]); - static getFormInputValues = (element: Element): Array<[string, string]> => { - return getNameValueFormInputValues(element); - }; + /** + * Returns a string representing the value in the picker. + */ + const pickerValue = useMemo( + (): string => element.getAttribute('picker-value') || '', + [element], + ); + + const pickerItems = useMemo( + (): Element[] => + Array.from( + element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.PICKER_ITEM, + ), + ), + [element], + ); - getPickerInitialValue = (): string => { - const value = this.getValue(); - const pickerItems: Element[] = this.getPickerItems(); + const pickerInitialValue = useMemo((): string => { const valueExists = pickerItems.some( item => item.getAttribute('value') === value, ); @@ -44,177 +60,173 @@ export default class HvPickerField extends PureComponent { return pickerItems.length > 0 ? pickerItems[0].getAttribute('value') || '' : ''; - }; - - /** - * Returns a string representing the value in the field. - */ - getValue = (): string => this.props.element.getAttribute('value') || ''; + }, [pickerItems, value]); /** * Gets the label from the picker items for the given value. * If the value doesn't have a picker item, returns null. */ - getLabelForValue = (value: DOMString): string | null | undefined => { - const pickerItemElements: HTMLCollectionOf = this.props.element.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - LOCAL_NAME.PICKER_ITEM, - ); - - let item: Element | null | undefined = null; - for (let i = 0; i < pickerItemElements.length; i += 1) { - const pickerItemElement: - | Element - | null - | undefined = pickerItemElements.item(i); - if ( - pickerItemElement && - pickerItemElement.getAttribute('value') === value - ) { - item = pickerItemElement; - break; - } - } - return item ? item.getAttribute('label') : null; - }; - - /** - * Returns a string representing the value in the picker. - */ - getPickerValue = (): string => - this.props.element.getAttribute('picker-value') || ''; - - getPickerItems = (): Element[] => - Array.from( - this.props.element.getElementsByTagNameNS( + const getLabelForValue = useCallback( + (v: DOMString): string | null | undefined => { + const pickerItemElements: HTMLCollectionOf = element.getElementsByTagNameNS( Namespaces.HYPERVIEW, LOCAL_NAME.PICKER_ITEM, - ), - ); + ); + + let item: Element | null | undefined = null; + for (let i = 0; i < pickerItemElements.length; i += 1) { + const pickerItemElement: + | Element + | null + | undefined = pickerItemElements.item(i); + if ( + pickerItemElement && + pickerItemElement.getAttribute('value') === v + ) { + item = pickerItemElement; + break; + } + } + return item ? item.getAttribute('label') : null; + }, + [element], + ); /** * Shows the picker, defaulting to the field's value. * If the field is not set, use the first value in the picker. */ - onFieldPress = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onFieldPress = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'true'); - newElement.setAttribute('picker-value', this.getPickerInitialValue()); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - Behaviors.trigger('focus', newElement, this.props.onUpdate); - }; + newElement.setAttribute('picker-value', pickerInitialValue); + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('focus', newElement, onUpdate); + }, [element, onUpdate, pickerInitialValue]); /** * Hides the picker without applying the chosen value. */ - onCancel = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onCancel = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); newElement.removeAttribute('picker-value'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - Behaviors.trigger('blur', newElement, this.props.onUpdate); - }; + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('blur', newElement, onUpdate); + }, [element, onUpdate]); /** * Hides the picker and applies the chosen value to the field. */ - onDone = () => { - const pickerValue = this.getPickerValue(); - const value = this.getValue(); - const newElement = this.props.element.cloneNode(true) as Element; + const onDone = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('value', pickerValue); newElement.removeAttribute('picker-value'); newElement.setAttribute('focused', 'false'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); + onUpdate(null, 'swap', element, { newElement }); const hasChanged = value !== pickerValue; if (hasChanged) { - Behaviors.trigger('change', newElement, this.props.onUpdate); + Behaviors.trigger('change', newElement, onUpdate); } - Behaviors.trigger('blur', newElement, this.props.onUpdate); - }; + Behaviors.trigger('blur', newElement, onUpdate); + }, [element, onUpdate, pickerValue, value]); /** * Updates the picker value while keeping the picker open. */ - setPickerValue = (value: string) => { - const newElement = this.props.element.cloneNode(true) as Element; - newElement.setAttribute('picker-value', value); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - }; + const setPickerValue = useCallback( + (v: string) => { + const newElement = element.cloneNode(true) as Element; + newElement.setAttribute('picker-value', v); + onUpdate(null, 'swap', element, { newElement }); + }, + [element, onUpdate], + ); /** * Returns true if the field is focused (and picker is showing). */ - isFocused = (): boolean => - this.props.element.getAttribute('focused') === 'true'; - - render() { - const style: Array = createStyleProp( - this.props.element, - this.props.stylesheets, - { - ...this.props.options, - styleAttr: 'field-text-style', - }, - ); - const { testID, accessibilityLabel } = createTestProps(this.props.element); - const value: DOMString | null | undefined = this.props.element.getAttribute( - 'value', - ); - const placeholderTextColor: - | DOMString - | null - | undefined = this.props.element.getAttribute('placeholderTextColor'); - if ([undefined, null, ''].includes(value) && placeholderTextColor) { - style.push({ color: placeholderTextColor }); + const isFocused = useMemo( + (): boolean => element.getAttribute('focused') === 'true', + [element], + ); + + const fieldTextStyle = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'field-text-style', + }); + + const placeholderTextColor: DOMString | null | undefined = useMemo( + () => element.getAttribute('placeholderTextColor'), + [element], + ); + + const style = useMemo(() => { + if (placeholderTextColor && [undefined, null, ''].includes(value)) { + return { ...fieldTextStyle, color: placeholderTextColor }; } - - // Gets all of the elements. All picker item elements - // with a value and label are turned into options for the picker. - const children = this.getPickerItems() - .filter(Boolean) - .map((item: Element) => { + return fieldTextStyle; + }, [fieldTextStyle, placeholderTextColor, value]); + + const { testID, accessibilityLabel } = useMemo( + () => createTestProps(element), + [element], + ); + + // Gets all of the elements. All picker item elements + // with a value and label are turned into options for the picker. + const children = useMemo( + () => + pickerItems.filter(Boolean).map((item: Element) => { const l: DOMString | null | undefined = item.getAttribute('label'); const v: DOMString | null | undefined = item.getAttribute('value'); if (!l || typeof v !== 'string') { return null; } return ; - }); - - const focused = this.props.element.getAttribute('focused') === 'true'; - - return ( - - {focused ? ( - - - - {children} - - - - ) : null} - - ); - } -} + }), + [pickerItems], + ); + + return ( + + {isFocused ? ( + + + + {children} + + + + ) : null} + + ); +}; + +HvPickerField.namespaceURI = Namespaces.HYPERVIEW; +HvPickerField.localName = LOCAL_NAME.PICKER_FIELD; +HvPickerField.getFormInputValues = getNameValueFormInputValues; + +export default HvPickerField; diff --git a/src/components/hv-picker-field/index.tsx b/src/components/hv-picker-field/index.tsx index c2e4d0bb4..3f561e63a 100644 --- a/src/components/hv-picker-field/index.tsx +++ b/src/components/hv-picker-field/index.tsx @@ -1,15 +1,11 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; -import type { - DOMString, - HvComponentProps, - StyleSheet, -} from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import type { DOMString, HvComponentProps } from 'hyperview/src/types'; +import React, { useCallback, useMemo } from 'react'; import { - createStyleProp, createTestProps, getNameValueFormInputValues, + useStyleProp, } from 'hyperview/src/services'; import { LOCAL_NAME } from 'hyperview/src/types'; import Picker from 'hyperview/src/core/components/picker'; @@ -21,168 +17,203 @@ import { View } from 'react-native'; * - On Android, the system picker is rendered inline on the screen. Pressing the picker * opens a system dialog. */ -export default class HvPickerField extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; - - static localName = LOCAL_NAME.PICKER_FIELD; - - static getFormInputValues = (element: Element): Array<[string, string]> => { - return getNameValueFormInputValues(element); - }; +const HvPickerField = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const { focused, pressed, pressedSelected, selected } = options; /** * Returns a string representing the value in the field. */ - getValue = (): string => this.props.element.getAttribute('value') || ''; + const value = useMemo((): string => element.getAttribute('value') || '', [ + element, + ]); /** * Returns a string representing the value in the picker. */ - getPickerValue = (): string => this.props.element.getAttribute('value') || ''; - - getPickerItems = (): Element[] => - Array.from( - this.props.element.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - LOCAL_NAME.PICKER_ITEM, + const pickerValue = value; + + const pickerItems = useMemo( + (): Element[] => + Array.from( + element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.PICKER_ITEM, + ), ), - ); + [element], + ); + + const isFocused = useMemo( + (): boolean => element.getAttribute('focused') === 'true', + [element], + ); - onFocus = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onFocus = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'true'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - Behaviors.trigger('focus', newElement, this.props.onUpdate); - }; + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('focus', newElement, onUpdate); + }, [element, onUpdate]); - onBlur = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onBlur = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - Behaviors.trigger('blur', newElement, this.props.onUpdate); - }; + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('blur', newElement, onUpdate); + }, [element, onUpdate]); /** * Hides the picker without applying the chosen value. */ - onCancel = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onCancel = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); newElement.removeAttribute('picker-value'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - }; + onUpdate(null, 'swap', element, { newElement }); + }, [element, onUpdate]); /** * Hides the picker and applies the chosen value to the field. */ - onDone = (newValue?: string) => { - const pickerValue = - newValue !== undefined ? newValue : this.getPickerValue(); - const value = this.getValue(); - const newElement = this.props.element.cloneNode(true) as Element; - newElement.setAttribute('value', pickerValue); - newElement.removeAttribute('picker-value'); - newElement.setAttribute('focused', 'false'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - - const hasChanged = value !== pickerValue; - if (hasChanged) { - Behaviors.trigger('change', newElement, this.props.onUpdate); - } - }; - - render() { - const onChange = (value: string | null | undefined) => { - if (value === undefined) { - this.onCancel(); + const onDone = useCallback( + (newValue?: string) => { + const pValue = newValue !== undefined ? newValue : pickerValue; + const newElement = element.cloneNode(true) as Element; + newElement.setAttribute('value', pValue); + newElement.removeAttribute('picker-value'); + newElement.setAttribute('focused', 'false'); + onUpdate(null, 'swap', element, { newElement }); + + const hasChanged = value !== pValue; + if (hasChanged) { + Behaviors.trigger('change', newElement, onUpdate); + } + }, + [element, onUpdate, value, pickerValue], + ); + + const onChange = useCallback( + (v: string | null | undefined) => { + if (v === undefined) { + onCancel(); } else { - this.onDone(value || ''); + onDone(v || ''); } - }; - - const style: Array = createStyleProp( - this.props.element, - this.props.stylesheets, - { - ...this.props.options, - styleAttr: 'field-text-style', - }, - ); - const { testID, accessibilityLabel } = createTestProps(this.props.element); - const value: DOMString | null | undefined = this.props.element.getAttribute( - 'value', - ); - const placeholderTextColor: - | DOMString - | null - | undefined = this.props.element.getAttribute('placeholderTextColor'); + }, + [onCancel, onDone], + ); + + const fieldTextStyle = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'field-text-style', + }); + + const { testID, accessibilityLabel } = useMemo( + () => createTestProps(element), + [element], + ); + + const placeholderTextColor: DOMString | null | undefined = useMemo( + () => element.getAttribute('placeholderTextColor'), + [element], + ); + + const style = useMemo(() => { if ([undefined, null, ''].includes(value) && placeholderTextColor) { - style.push({ color: placeholderTextColor }); + return { + ...fieldTextStyle, + color: placeholderTextColor, + }; } - - const fieldStyle: Array = createStyleProp( - this.props.element, - this.props.stylesheets, - { - ...this.props.options, - styleAttr: 'field-style', - }, - ); - - // Gets all of the elements. All picker item elements - // with a value and label are turned into options for the picker. - const items = this.getPickerItems(); - const children = items.filter(Boolean).map((item: Element) => { - const l: DOMString | null | undefined = item.getAttribute('label'); - const v: DOMString | null | undefined = item.getAttribute('value'); + return fieldTextStyle; + }, [fieldTextStyle, placeholderTextColor, value]); + + const needsEmptyOption = useMemo( + () => pickerItems.length > 0 && pickerItems[0].getAttribute('value') !== '', + [pickerItems], + ); + + const placeholder = useMemo( + () => element.getAttribute('placeholder') || undefined, + [element], + ); + + const fieldStyle = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'field-style', + }); + + // Gets all of the elements. All picker item elements + // with a value and label are turned into options for the picker. + const children = useMemo(() => { + const items = pickerItems.reduce>((acc, item) => { + const l = item.getAttribute('label'); + const v = item.getAttribute('value'); if (!l || typeof v !== 'string') { - return null; + return acc; } const enabled = ['', 'true', null].includes(item.getAttribute('enabled')); - return ( + acc.push( + />, ); - }); + return acc; + }, []); // If there are no items, or the first item has a value, // we need to add an empty option that acts as a placeholder. - if (items.length > 0 && items[0].getAttribute('value') !== '') { - children.unshift( + if (needsEmptyOption) { + items.unshift( , ); } - return ( - + - - {children} - - - ); - } -} + {children} + + + ); +}; + +HvPickerField.namespaceURI = Namespaces.HYPERVIEW; +HvPickerField.localName = LOCAL_NAME.PICKER_FIELD; +HvPickerField.getFormInputValues = getNameValueFormInputValues; + +export default HvPickerField; diff --git a/src/components/hv-select-multiple/index.tsx b/src/components/hv-select-multiple/index.tsx index 2410a2f0d..7740640c3 100644 --- a/src/components/hv-select-multiple/index.tsx +++ b/src/components/hv-select-multiple/index.tsx @@ -5,113 +5,122 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; -import { createProps } from 'hyperview/src/services'; +import { useProps } from 'hyperview/src/services'; -export default class HvSelectMultiple extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; - - static localName = LOCAL_NAME.SELECT_MULTIPLE; - - static getFormInputValues = (element: Element): Array<[string, string]> => { - const values: Array<[string, string]> = []; - const name = element.getAttribute('name'); - if (!name) { - return values; - } - // Add each selected option to the form data - const optionElements = element.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - LOCAL_NAME.OPTION, - ); - for (let i = 0; i < optionElements.length; i += 1) { - const optionElement = optionElements.item(i); - if (optionElement && optionElement.getAttribute('selected') === 'true') { - values.push([name, optionElement.getAttribute('value') || '']); - } - } - return values; - }; - - constructor(props: HvComponentProps) { - super(props); - this.onToggle = this.onToggle.bind(this); - } - - componentDidUpdate() { - // NOTE: we need to remove the attribute before - // (un)selecting all, since (un)selecting all will update the component. - if (this.props.element.hasAttribute('select-all')) { - this.props.element.removeAttribute('select-all'); - this.applyToAllOptions(true); - } - if (this.props.element.hasAttribute('unselect-all')) { - this.props.element.removeAttribute('unselect-all'); - this.applyToAllOptions(false); - } - } +const HvSelectMultiple = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; /** * Callback passed to children. Option components invoke this callback when toggles. * Will update the XML DOM to toggle the option with the given value. */ - onToggle = (selectedValue?: DOMString | null) => { - const newElement = this.props.element.cloneNode(true) as Element; - const options = newElement.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - 'option', - ); - for (let i = 0; i < options.length; i += 1) { - const option = options.item(i); - if (option) { - const value = option.getAttribute('value'); - if (value === selectedValue) { - const selected = option.getAttribute('selected') === 'true'; - option.setAttribute('selected', selected ? 'false' : 'true'); + const onToggle = useCallback( + (selectedValue?: DOMString | null) => { + const newElement = element.cloneNode(true) as Element; + const opts = newElement.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'option', + ); + for (let i = 0; i < opts.length; i += 1) { + const option = opts.item(i); + if (option) { + const value = option.getAttribute('value'); + if (value === selectedValue) { + const selected = option.getAttribute('selected') === 'true'; + option.setAttribute('selected', selected ? 'false' : 'true'); + } } } - } - this.props.onUpdate('#', 'swap', this.props.element, { newElement }); - }; + onUpdate('#', 'swap', element, { newElement }); + }, + [element, onUpdate], + ); - applyToAllOptions = (selected: boolean) => { - const newElement = this.props.element.cloneNode(true) as Element; - const options = newElement.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - 'option', - ); - for (let i = 0; i < options.length; i += 1) { - const option = options.item(i); - if (option) { - option.setAttribute('selected', selected ? 'true' : 'false'); + const applyToAllOptions = useCallback( + (selected: boolean) => { + const newElement = element.cloneNode(true) as Element; + const opts = newElement.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'option', + ); + for (let i = 0; i < opts.length; i += 1) { + const option = opts.item(i); + if (option) { + option.setAttribute('selected', selected ? 'true' : 'false'); + } } - } - this.props.onUpdate('#', 'swap', this.props.element, { newElement }); - }; + onUpdate('#', 'swap', element, { newElement }); + }, + [element, onUpdate], + ); - render() { - if (this.props.element.getAttribute('hide') === 'true') { - return null; + useEffect(() => { + // NOTE: we need to remove the attribute before + // (un)selecting all, since (un)selecting all will update the component. + if (element.hasAttribute('select-all')) { + element.removeAttribute('select-all'); + applyToAllOptions(true); + } + if (element.hasAttribute('unselect-all')) { + element.removeAttribute('unselect-all'); + applyToAllOptions(false); } - const props = createProps(this.props.element, this.props.stylesheets, { - ...this.props.options, - }); + }, [applyToAllOptions, element]); - // TODO: Replace with - return React.createElement( - View, - props, - ...Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - { - ...this.props.options, - onToggle: this.onToggle, - }, - ), + const componentProps = useProps(element, stylesheets, { + ...options, + }); + + // TODO: Replace with + const children = useMemo(() => { + return Render.renderChildren( + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + { + ...options, + onToggle, + }, ); + }, [element, onUpdate, options, stylesheets, onToggle]); + + const view = useMemo(() => { + return React.createElement(View, componentProps, ...children); + }, [componentProps, children]); + + if (element.getAttribute('hide') === 'true') { + return null; + } + + return view; +}; + +HvSelectMultiple.namespaceURI = Namespaces.HYPERVIEW; +HvSelectMultiple.localName = LOCAL_NAME.SELECT_MULTIPLE; +HvSelectMultiple.getFormInputValues = ( + element: Element, +): Array<[string, string]> => { + const values: Array<[string, string]> = []; + const name = element.getAttribute('name'); + if (!name) { + return values; + } + // Add each selected option to the form data + const optionElements = element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.OPTION, + ); + for (let i = 0; i < optionElements.length; i += 1) { + const optionElement = optionElements.item(i); + if (optionElement && optionElement.getAttribute('selected') === 'true') { + values.push([name, optionElement.getAttribute('value') || '']); + } } -} + return values; +}; + +export default HvSelectMultiple; diff --git a/src/components/hv-select-single/index.tsx b/src/components/hv-select-single/index.tsx index ea92cb162..847450721 100644 --- a/src/components/hv-select-single/index.tsx +++ b/src/components/hv-select-single/index.tsx @@ -5,108 +5,118 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; -import { createProps } from 'hyperview/src/services'; +import { useProps } from 'hyperview/src/services'; -export default class HvSelectSingle extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; +const HvSelectSingle = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; - static localName = LOCAL_NAME.SELECT_SINGLE; - - static getFormInputValues = (element: Element): Array<[string, string]> => { - const name = element.getAttribute('name'); - if (!name) { - return []; - } - // Add each selected option to the form data - const optionElements = element.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - LOCAL_NAME.OPTION, - ); - for (let i = 0; i < optionElements.length; i += 1) { - const optionElement = optionElements.item(i); - if (optionElement && optionElement.getAttribute('selected') === 'true') { - return [[name, optionElement.getAttribute('value') || '']]; - } - } - return []; - }; - - constructor(props: HvComponentProps) { - super(props); - this.onSelect = this.onSelect.bind(this); - } - - componentDidUpdate() { - // NOTE(adam): we need to remove the attribute before - // selection, since selection will update the component. - if (this.props.element.hasAttribute('value')) { - const newValue = this.props.element.getAttribute('value'); - this.props.element.removeAttribute('value'); - this.onSelect(newValue); - } - if (this.props.element.hasAttribute('unselect-all')) { - this.props.element.removeAttribute('unselect-all'); - this.onSelect(null); - } - } + const hide = useMemo(() => element.getAttribute('hide') === 'true', [ + element, + ]); /** * Callback passed to children. Option components invoke this callback when selected. * SingleSelect will update the XML DOM so that only the selected option is has a * selected=true attribute. */ - onSelect = (selectedValue: DOMString | null | undefined) => { - const newElement = this.props.element.cloneNode(true) as Element; - const allowDeselect = this.props.element.getAttribute('allow-deselect'); - const options = newElement.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - 'option', - ); - for (let i = 0; i < options.length; i += 1) { - const opt = options.item(i); - if (opt) { - const value = opt.getAttribute('value'); - const current = value === selectedValue; - if (current && allowDeselect === 'true') { - // when deselection is allowed and user presses the option - const selected = opt.getAttribute('selected') === 'true'; - opt.setAttribute('selected', selected ? 'false' : 'true'); - } else if (current) { - // when deselection is not allowed and user presses the option - opt.setAttribute('selected', 'true'); - } else { - // untouched option - opt.setAttribute('selected', 'false'); + const onSelect = useCallback( + (selectedValue: DOMString | null | undefined) => { + const newElement = element.cloneNode(true) as Element; + const allowDeselect = element.getAttribute('allow-deselect'); + const opts = newElement.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'option', + ); + for (let i = 0; i < opts.length; i += 1) { + const opt = opts.item(i); + if (opt) { + const value = opt.getAttribute('value'); + const current = value === selectedValue; + if (current && allowDeselect === 'true') { + // when deselection is allowed and user presses the option + const selected = opt.getAttribute('selected') === 'true'; + opt.setAttribute('selected', selected ? 'false' : 'true'); + } else if (current) { + // when deselection is not allowed and user presses the option + opt.setAttribute('selected', 'true'); + } else { + // untouched option + opt.setAttribute('selected', 'false'); + } } } - } - this.props.onUpdate('#', 'swap', this.props.element, { newElement }); - }; + onUpdate('#', 'swap', element, { newElement }); + }, + [element, onUpdate], + ); - render() { - if (this.props.element.getAttribute('hide') === 'true') { - return null; + useEffect(() => { + // NOTE(adam): we need to remove the attribute before + // selection, since selection will update the component. + if (element.hasAttribute('value')) { + const newValue = element.getAttribute('value'); + element.removeAttribute('value'); + onSelect(newValue); + } + if (element.hasAttribute('unselect-all')) { + element.removeAttribute('unselect-all'); + onSelect(null); } - const props = createProps(this.props.element, this.props.stylesheets, { - ...this.props.options, - }); + }, [element, onSelect]); - // TODO: Replace with - return React.createElement( - View, - props, - ...Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - { - ...this.props.options, - onSelect: this.onSelect, - }, - ), + const componentProps = useProps(element, stylesheets, { + ...options, + }); + + // TODO: Replace with + const children = useMemo(() => { + return Render.renderChildren( + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + { + ...options, + onSelect, + }, ); + }, [element, onUpdate, options, stylesheets, onSelect]); + + const view = useMemo(() => { + return React.createElement(View, componentProps, ...children); + }, [componentProps, children]); + + if (hide) { + return null; } -} + + return view; +}; + +HvSelectSingle.namespaceURI = Namespaces.HYPERVIEW; +HvSelectSingle.localName = LOCAL_NAME.SELECT_SINGLE; +HvSelectSingle.getFormInputValues = ( + element: Element, +): Array<[string, string]> => { + const name = element.getAttribute('name'); + if (!name) { + return []; + } + // Add each selected option to the form data + const optionElements = element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.OPTION, + ); + for (let i = 0; i < optionElements.length; i += 1) { + const optionElement = optionElements.item(i); + if (optionElement && optionElement.getAttribute('selected') === 'true') { + return [[name, optionElement.getAttribute('value') || '']]; + } + } + return []; +}; + +export default HvSelectSingle; diff --git a/src/components/hv-switch/index.ts b/src/components/hv-switch/index.ts deleted file mode 100644 index 048ff7249..000000000 --- a/src/components/hv-switch/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as Behaviors from 'hyperview/src/services/behaviors'; -import * as Namespaces from 'hyperview/src/services/namespaces'; -import { Platform, StyleSheet, Switch } from 'react-native'; -import React, { PureComponent } from 'react'; -import { - createStyleProp, - getNameValueFormInputValues, -} from 'hyperview/src/services'; -import type { ColorValue } from './style-sheet'; -import type { HvComponentProps } from 'hyperview/src/types'; -import { LOCAL_NAME } from 'hyperview/src/types'; -import normalizeColor from './style-sheet'; - -/* eslint no-bitwise: ["error", { "allow": [">>", "&"] }] */ -function darkenColor(color: ColorValue, percent: number): ColorValue { - const normalized = Number(normalizeColor(color)).toString(16); - const A = String(normalized).slice(6); - const RGB = parseInt(String(normalized).slice(0, 6), 16); - const R = RGB >> 16; - const G = (RGB >> 8) & 0x00ff; - const B = RGB & 0x0000ff; - const newRgb = ( - 0x1000000 + - (Math.round((0 - R) * percent) + R) * 0x10000 + - (Math.round((0 - G) * percent) + G) * 0x100 + - (Math.round((0 - B) * percent) + B) - ) - .toString(16) - .slice(1); - - return `#${newRgb}${A}`; -} - -export default class HvSwitch extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; - - static localName = LOCAL_NAME.SWITCH; - - static getFormInputValues = (element: Element): Array<[string, string]> => { - return getNameValueFormInputValues(element); - }; - - render() { - if (this.props.element.getAttribute('hide') === 'true') { - return null; - } - - const unselectedStyle = StyleSheet.flatten( - createStyleProp(this.props.element, this.props.stylesheets, { - selected: false, - }), - ); - const selectedStyle = StyleSheet.flatten( - createStyleProp(this.props.element, this.props.stylesheets, { - selected: true, - }), - ); - - const props = { - ios_backgroundColor: unselectedStyle - ? unselectedStyle.backgroundColor - : null, - onChange: () => { - const newElement = this.props.element.cloneNode(true) as Element; - Behaviors.trigger('change', newElement, this.props.onUpdate); - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onValueChange: (value: any) => { - const newElement = this.props.element.cloneNode(true) as Element; - newElement.setAttribute('value', value ? 'on' : 'off'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - }, - // iOS thumbColor default - thumbColor: unselectedStyle?.color || selectedStyle?.color, - trackColor: { - false: unselectedStyle ? unselectedStyle.backgroundColor : null, - true: selectedStyle ? selectedStyle.backgroundColor : null, - }, - value: this.props.element.getAttribute('value') === 'on', - }; - - // android thumbColor default - if ( - Platform.OS === 'android' && - !props.thumbColor && - props.trackColor.true - ) { - props.thumbColor = props.value - ? darkenColor(props.trackColor.true, 0.3) - : '#FFFFFF'; - } - - // if thumbColors are explicitly specified, override defaults - if (props.value && selectedStyle?.color) { - props.thumbColor = selectedStyle.color; - } else if (!props.value && unselectedStyle?.color) { - props.thumbColor = unselectedStyle.color; - } - - return React.createElement(Switch, props); - } -} diff --git a/src/components/hv-switch/index.tsx b/src/components/hv-switch/index.tsx new file mode 100644 index 000000000..cb44b5da0 --- /dev/null +++ b/src/components/hv-switch/index.tsx @@ -0,0 +1,121 @@ +import * as Behaviors from 'hyperview/src/services/behaviors'; +import * as Namespaces from 'hyperview/src/services/namespaces'; +import { Platform, StyleSheet, Switch } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { + getNameValueFormInputValues, + useStyleProp, +} from 'hyperview/src/services'; +import type { ColorValue } from './style-sheet'; +import type { HvComponentProps } from 'hyperview/src/types'; +import { LOCAL_NAME } from 'hyperview/src/types'; +import normalizeColor from './style-sheet'; + +/* eslint no-bitwise: ["error", { "allow": [">>", "&"] }] */ +function darkenColor(color: ColorValue, percent: number): ColorValue { + const normalized = Number(normalizeColor(color)).toString(16); + const A = String(normalized).slice(6); + const RGB = parseInt(String(normalized).slice(0, 6), 16); + const R = RGB >> 16; + const G = (RGB >> 8) & 0x00ff; + const B = RGB & 0x0000ff; + const newRgb = ( + 0x1000000 + + (Math.round((0 - R) * percent) + R) * 0x10000 + + (Math.round((0 - G) * percent) + G) * 0x100 + + (Math.round((0 - B) * percent) + B) + ) + .toString(16) + .slice(1); + + return `#${newRgb}${A}`; +} + +const HvSwitch = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, stylesheets } = props; + + const hide = useMemo(() => element.getAttribute('hide') === 'true', [ + element, + ]); + + const value = useMemo(() => element.getAttribute('value') === 'on', [ + element, + ]); + + const unselStyle = useStyleProp(element, stylesheets, { + selected: false, + }); + + const unselectedStyle = useMemo(() => StyleSheet.flatten(unselStyle), [ + unselStyle, + ]); + + const selStyle = useStyleProp(element, stylesheets, { + selected: true, + }); + + const selectedStyle = useMemo(() => StyleSheet.flatten(selStyle), [selStyle]); + + const onChange = useCallback(() => { + const newElement = element.cloneNode(true) as Element; + Behaviors.trigger('change', newElement, onUpdate); + }, [element, onUpdate]); + + const onValueChange = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (v: any) => { + const newElement = element.cloneNode(true) as Element; + newElement.setAttribute('value', v ? 'on' : 'off'); + onUpdate(null, 'swap', element, { newElement }); + }, + [element, onUpdate], + ); + + const componentProps = useMemo(() => { + const p = { + ios_backgroundColor: unselectedStyle + ? unselectedStyle.backgroundColor + : null, + onChange, + onValueChange, + // iOS thumbColor default + thumbColor: unselectedStyle?.color || selectedStyle?.color, + trackColor: { + false: unselectedStyle ? unselectedStyle.backgroundColor : null, + true: selectedStyle ? selectedStyle.backgroundColor : null, + }, + value, + }; + + // android thumbColor default + if (Platform.OS === 'android' && !p.thumbColor && p.trackColor.true) { + p.thumbColor = p.value ? darkenColor(p.trackColor.true, 0.3) : '#FFFFFF'; + } + + // if thumbColors are explicitly specified, override defaults + if (p.value && selectedStyle?.color) { + p.thumbColor = selectedStyle.color; + } else if (!p.value && unselectedStyle?.color) { + p.thumbColor = unselectedStyle.color; + } + + return p; + }, [onChange, onValueChange, unselectedStyle, selectedStyle, value]); + + const view = useMemo(() => React.createElement(Switch, componentProps), [ + componentProps, + ]); + + if (hide) { + return null; + } + + return view; +}; + +HvSwitch.namespaceURI = Namespaces.HYPERVIEW; +HvSwitch.localName = LOCAL_NAME.SWITCH; +HvSwitch.getFormInputValues = getNameValueFormInputValues; + +export default HvSwitch; diff --git a/src/components/hv-text-field/index.tsx b/src/components/hv-text-field/index.tsx index 92995fbb0..064ff1223 100644 --- a/src/components/hv-text-field/index.tsx +++ b/src/components/hv-text-field/index.tsx @@ -2,10 +2,7 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; import type { HvComponentProps, TextContextType } from 'hyperview/src/types'; import React, { MutableRefObject, useCallback, useEffect, useRef } from 'react'; -import { - createProps, - getNameValueFormInputValues, -} from 'hyperview/src/services'; +import { getNameValueFormInputValues, useProps } from 'hyperview/src/services'; import type { ElementRef } from 'react'; import type { KeyboardTypeOptions } from 'react-native'; import { LOCAL_NAME } from 'hyperview/src/types'; @@ -14,34 +11,42 @@ import TinyMask from 'hyperview/src/mask'; import debounce from 'lodash/debounce'; const HvTextField = (props: HvComponentProps) => { - if (props.element.getAttribute('hide') === 'true') { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + // eslint-disable-next-line react-hooks/rules-of-hooks + const textInputRef: MutableRefObject = useRef( + null as TextInput | null, + ); + const elementProps = useProps(element, stylesheets, { + ...options, + focused: textInputRef.current?.isFocused(), + }); + if (element.getAttribute('hide') === 'true') { return null; } // Extract known attributes into their own variables - const autoFocus = props.element.getAttribute('auto-focus') === 'true'; + const autoFocus = element.getAttribute('auto-focus') === 'true'; const debounceTimeMs = - parseInt(props.element.getAttribute('debounce') || '', 10) || 0; - const defaultValue = props.element.getAttribute('value') || undefined; - const editable = props.element.getAttribute('editable') !== 'false'; + parseInt(element.getAttribute('debounce') || '', 10) || 0; + const defaultValue = element.getAttribute('value') || undefined; + const editable = element.getAttribute('editable') !== 'false'; const keyboardType = - (props.element.getAttribute('keyboard-type') as KeyboardTypeOptions) || - undefined; - const multiline = props.element.getAttribute('multiline') === 'true'; - const secureTextEntry = props.element.getAttribute('secure-text') === 'true'; + (element.getAttribute('keyboard-type') as KeyboardTypeOptions) || undefined; + const multiline = element.getAttribute('multiline') === 'true'; + const secureTextEntry = element.getAttribute('secure-text') === 'true'; const textContentType = - (props.element.getAttribute('text-content-type') as TextContextType) || - 'none'; + (element.getAttribute('text-content-type') as TextContextType) || 'none'; // Handlers const setFocus = (focused: boolean) => { - const newElement = props.element.cloneNode(true) as Element; - props.onUpdate(null, 'swap', props.element, { newElement }); + const newElement = element.cloneNode(true) as Element; + onUpdate(null, 'swap', element, { newElement }); if (focused) { - Behaviors.trigger('focus', newElement, props.onUpdate); + Behaviors.trigger('focus', newElement, onUpdate); } else { - Behaviors.trigger('blur', newElement, props.onUpdate); + Behaviors.trigger('blur', newElement, onUpdate); } }; @@ -49,7 +54,7 @@ const HvTextField = (props: HvComponentProps) => { // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps const triggerChangeBehaviors = useCallback( debounce((newElement: Element) => { - Behaviors.trigger('change', newElement, props.onUpdate); + Behaviors.trigger('change', newElement, onUpdate); }, debounceTimeMs), [], ); @@ -57,18 +62,13 @@ const HvTextField = (props: HvComponentProps) => { // This handler takes care of handling the state, so it shouldn't be debounced // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps const onChangeText = (value: string) => { - const formattedValue = HvTextField.getFormattedValue(props.element, value); - const newElement = props.element.cloneNode(true) as Element; + const formattedValue = HvTextField.getFormattedValue(element, value); + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('value', formattedValue); - props.onUpdate(null, 'swap', props.element, { newElement }); + onUpdate(null, 'swap', element, { newElement }); triggerChangeBehaviors(newElement); }; - // eslint-disable-next-line react-hooks/rules-of-hooks - const textInputRef: MutableRefObject = useRef( - null as TextInput | null, - ); - // eslint-disable-next-line react-hooks/rules-of-hooks const prevDefaultValue = useRef(defaultValue); // eslint-disable-next-line react-hooks/rules-of-hooks @@ -80,10 +80,7 @@ const HvTextField = (props: HvComponentProps) => { }, [defaultValue, onChangeText]); const p = { - ...createProps(props.element, props.stylesheets, { - ...props.options, - focused: textInputRef.current?.isFocused(), - }), + ...elementProps, }; return ( @@ -92,8 +89,8 @@ const HvTextField = (props: HvComponentProps) => { {...p} ref={(ref: ElementRef | null) => { textInputRef.current = ref; - if (props.options?.registerInputHandler) { - props.options.registerInputHandler(ref); + if (options?.registerInputHandler) { + options.registerInputHandler(ref); } }} autoFocus={autoFocus} diff --git a/src/components/hv-text/index.tsx b/src/components/hv-text/index.tsx index cb6d253dd..69a07fdb6 100644 --- a/src/components/hv-text/index.tsx +++ b/src/components/hv-text/index.tsx @@ -4,55 +4,50 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import React, { useMemo } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; import { Text } from 'react-native'; import { addHref } from 'hyperview/src/core/hyper-ref'; -import { createProps } from 'hyperview/src/services'; +import { useProps } from 'hyperview/src/services'; -export default class HvText extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; +const HvText = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; - static localName = LOCAL_NAME.TEXT; + const componentProps = useProps(element, stylesheets, options); - Component = () => { - const props = createProps( - this.props.element, - this.props.stylesheets, - this.props.options, - ); - - // TODO: Replace with - return React.createElement( - Text, - props, - ...Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - { - ...this.props.options, - preformatted: - this.props.element.getAttribute('preformatted') === 'true', - }, + // TODO: Replace with + const component = useMemo( + () => + React.createElement( + Text, + componentProps, + ...Render.renderChildren( + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + { + ...options, + preformatted: element.getAttribute('preformatted') === 'true', + }, + ), ), - ); - }; + [componentProps, element, onUpdate, options, stylesheets], + ); + + const { skipHref } = options || {}; + return skipHref + ? component + : addHref( + component, + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + options, + ); +}; - render() { - const { Component } = this; - const { skipHref } = this.props.options || {}; +HvText.namespaceURI = Namespaces.HYPERVIEW; +HvText.localName = LOCAL_NAME.TEXT; - return skipHref ? ( - - ) : ( - addHref( - , - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - this.props.options, - ) - ); - } -} +export default HvText; diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index c1e66f8da..b881770a3 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -2,12 +2,6 @@ import * as Keyboard from 'hyperview/src/services/keyboard'; import * as Logging from 'hyperview/src/services/logging'; import * as Namespaces from 'hyperview/src/services/namespaces'; import * as Render from 'hyperview/src/services/render'; -import type { - Attributes, - CommonProps, - KeyboardAwareScrollViewProps, - ScrollViewProps, -} from './types'; import type { HvComponentOnUpdate, HvComponentProps, @@ -17,63 +11,107 @@ import { Platform, SafeAreaView, View, - ViewStyle, } from 'react-native'; import { KeyboardAwareScrollView, ScrollView, } from 'hyperview/src/core/components/scroll'; -import React, { PureComponent } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { ATTRIBUTES } from './types'; import { LOCAL_NAME } from 'hyperview/src/types'; import { addHref } from 'hyperview/src/core/hyper-ref'; -import { createStyleProp } from 'hyperview/src/services'; - -export default class HvView extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; +import { useStyleProp } from 'hyperview/src/services'; - static localName = LOCAL_NAME.VIEW; +const HvView = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const { focused, pressed, pressedSelected, selected, styleAttr } = options; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputFieldRefs = useRef>([]); - static localNameAliases = [ - LOCAL_NAME.BODY, - LOCAL_NAME.FORM, - LOCAL_NAME.HEADER, - LOCAL_NAME.ITEM, - LOCAL_NAME.ITEMS, - LOCAL_NAME.SECTION_TITLE, - ]; - - get attributes(): Attributes { + const attributes = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return Object.values(ATTRIBUTES).reduce>( - (attributes, name: string) => ({ - ...attributes, - [name]: this.props.element.getAttribute(name), + (attrs, name: string) => ({ + ...attrs, + [name]: element.getAttribute(name), }), {}, ); - } + }, [element]); - hasInputFields = (): boolean => { - const textFields = this.props.element.getElementsByTagNameNS( + const hasInputFields = useMemo(() => { + const textFields = element.getElementsByTagNameNS( Namespaces.HYPERVIEW, 'text-field', ); return textFields.length > 0; - }; + }, [element]); + + const style = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr, + }); + + const containerStyle = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, + }); + + const viewConfig = useMemo(() => { + /** + * Useful when you want keyboard avoiding behavior in non-scrollable views. + * Note: Android has built-in support for avoiding keyboard. + */ + const keyboardAvoiding = + attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && Platform.OS === 'ios'; + const scrollable = attributes[ATTRIBUTES.SCROLL] === 'true'; + const safeArea = attributes[ATTRIBUTES.SAFE_AREA] === 'true'; - getCommonProps = (): CommonProps => { - // TODO: fix type - // createStyleProp returns an array of StyleSheet, - // but it appears something wants a ViewStyle, which is not - // not an array type. Does a type need to get fixed elsewhere? - const style = (createStyleProp( - this.props.element, - this.props.stylesheets, - this.props.options, - ) as unknown) as ViewStyle; - const id = this.props.element.getAttribute('id'); + if (safeArea && (keyboardAvoiding || scrollable)) { + Logging.warn('safe-area is incompatible with scroll or avoid-keyboard'); + } + + return { keyboardAvoiding, safeArea, scrollable }; + }, [attributes]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registerInputHandler = useCallback((ref: any) => { + if (ref !== null) { + inputFieldRefs.current.push(ref); + } + }, []); + + const children = useMemo( + () => + Render.renderChildren(element, stylesheets, onUpdate, { + ...options, + ...(viewConfig.scrollable && hasInputFields + ? { + registerInputHandler, + } + : {}), + }), + [ + element, + hasInputFields, + onUpdate, + options, + registerInputHandler, + stylesheets, + viewConfig.scrollable, + ], + ); + + const commonProps = useMemo(() => { + const id = element.getAttribute('id'); if (!id) { return { style }; } @@ -81,23 +119,16 @@ export default class HvView extends PureComponent { return { style, testID: id }; } return { accessibilityLabel: id, style }; - }; + }, [element, style]); - getScrollViewProps = ( - children: Array | null | string>, - ): ScrollViewProps => { + const scrollViewProps = useMemo(() => { const horizontal = - this.attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; + attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; const showScrollIndicator = - this.attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; + attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; - const contentContainerStyle = this.attributes[ - ATTRIBUTES.CONTENT_CONTAINER_STYLE - ] - ? createStyleProp(this.props.element, this.props.stylesheets, { - ...this.props.options, - styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, - }) + const contentContainerStyle = attributes[ATTRIBUTES.CONTENT_CONTAINER_STYLE] + ? containerStyle : undefined; // Fix scrollbar rendering issue in iOS 13+ @@ -109,10 +140,10 @@ export default class HvView extends PureComponent { // add sticky indices const stickyHeaderIndices = children.reduce>( - (acc, element, index) => { + (acc, ele, index) => { if ( - typeof element !== 'string' && - element?.props.element?.getAttribute?.('sticky') === 'true' + typeof ele !== 'string' && + ele?.props.element?.getAttribute?.('sticky') === 'true' ) { return [...acc, index]; } @@ -124,84 +155,46 @@ export default class HvView extends PureComponent { return { contentContainerStyle, horizontal, - keyboardDismissMode: Keyboard.getKeyboardDismissMode(this.props.element), + keyboardDismissMode: Keyboard.getKeyboardDismissMode(element), scrollIndicatorInsets, showsHorizontalScrollIndicator: horizontal && showScrollIndicator, showsVerticalScrollIndicator: !horizontal && showScrollIndicator, stickyHeaderIndices, }; - }; + }, [attributes, children, containerStyle, element]); - getScrollToInputAdditionalOffsetProp = (): number => { - const defaultOffset = 120; - const offsetStr = this.attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; - if (offsetStr) { - const offset = parseInt(offsetStr, 10); - return Number.isNaN(offset) ? 0 : defaultOffset; - } - return defaultOffset; - }; - - getKeyboardAwareScrollViewProps = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - inputFieldRefs: Array, - ): KeyboardAwareScrollViewProps => ({ - automaticallyAdjustContentInsets: false, - getTextInputRefs: () => inputFieldRefs, - keyboardShouldPersistTaps: 'handled', - scrollEventThrottle: 16, - scrollToInputAdditionalOffset: this.getScrollToInputAdditionalOffsetProp(), - }); - - Content = () => { - /** - * Useful when you want keyboard avoiding behavior in non-scrollable views. - * Note: Android has built-in support for avoiding keyboard. - */ - const keyboardAvoiding = - this.attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && - Platform.OS === 'ios'; - - const hasInputFields = this.hasInputFields(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const inputFieldRefs: Array = []; - const scrollable = this.attributes[ATTRIBUTES.SCROLL] === 'true'; - const safeArea = this.attributes[ATTRIBUTES.SAFE_AREA] === 'true'; - if (safeArea) { - if (keyboardAvoiding || scrollable) { - Logging.warn('safe-area is incompatible with scroll or avoid-keyboard'); + const keyboardAwareScrollViewProps = useMemo(() => { + const getScrollToInputAdditionalOffsetProp = (): number => { + const defaultOffset = 120; + const offsetStr = attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; + if (offsetStr) { + const offset = parseInt(offsetStr, 10); + return Number.isNaN(offset) ? 0 : defaultOffset; } - } + return defaultOffset; + }; - const children = Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate, - { - ...this.props.options, - ...(scrollable && hasInputFields - ? { - registerInputHandler: ref => { - if (ref !== null) { - inputFieldRefs.push(ref); - } - }, - } - : {}), - }, - ); + return { + automaticallyAdjustContentInsets: false, + getTextInputRefs: () => inputFieldRefs.current, + keyboardShouldPersistTaps: 'handled', + scrollEventThrottle: 16, + scrollToInputAdditionalOffset: getScrollToInputAdditionalOffsetProp(), + }; + }, [attributes, inputFieldRefs]); + const content = useMemo(() => { /* eslint-disable react/jsx-props-no-spreading */ - if (scrollable) { + if (viewConfig.scrollable) { if (hasInputFields) { // TODO: Replace with return React.createElement( KeyboardAwareScrollView, { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), - ...this.getKeyboardAwareScrollViewProps(inputFieldRefs), + element, + ...commonProps, + ...scrollViewProps, + ...keyboardAwareScrollViewProps, }, ...children, ); @@ -210,49 +203,63 @@ export default class HvView extends PureComponent { return React.createElement( ScrollView, { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), + element, + ...commonProps, + ...scrollViewProps, }, ...children, ); } - if (!keyboardAvoiding && safeArea) { + if (!viewConfig.keyboardAvoiding && viewConfig.safeArea) { // TODO: Replace with - return React.createElement( - SafeAreaView, - this.getCommonProps(), - ...children, - ); + return React.createElement(SafeAreaView, commonProps, ...children); } - if (keyboardAvoiding) { + if (viewConfig.keyboardAvoiding) { // TODO: Replace with return React.createElement( KeyboardAvoidingView, { - ...this.getCommonProps(), + ...commonProps, behavior: 'position', }, ...children, ); } // TODO: Replace with - return React.createElement(View, this.getCommonProps(), ...children); + return React.createElement(View, commonProps, ...children); /* eslint-enable react/jsx-props-no-spreading */ - }; + }, [ + children, + element, + hasInputFields, + commonProps, + scrollViewProps, + keyboardAwareScrollViewProps, + viewConfig.scrollable, + viewConfig.keyboardAvoiding, + viewConfig.safeArea, + ]); - render() { - const { Content } = this; - return this.props.options?.skipHref ? ( - - ) : ( - addHref( - , - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - this.props.options, - ) - ); - } -} + return options?.skipHref + ? content + : addHref( + content, + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + options, + ); +}; + +HvView.namespaceURI = Namespaces.HYPERVIEW; +HvView.localName = LOCAL_NAME.VIEW; +HvView.localNameAliases = [ + LOCAL_NAME.BODY, + LOCAL_NAME.FORM, + LOCAL_NAME.HEADER, + LOCAL_NAME.ITEM, + LOCAL_NAME.ITEMS, + LOCAL_NAME.SECTION_TITLE, +]; + +export default HvView; diff --git a/src/components/hv-web-view/index.tsx b/src/components/hv-web-view/index.tsx index 756766c96..a61a8f04e 100644 --- a/src/components/hv-web-view/index.tsx +++ b/src/components/hv-web-view/index.tsx @@ -1,83 +1,96 @@ import * as Events from 'hyperview/src/services/events'; import * as Namespaces from 'hyperview/src/services/namespaces'; import { ActivityIndicator, StyleSheet } from 'react-native'; -import React, { PureComponent } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import type { HvComponentProps } from 'hyperview/src/types'; import { LOCAL_NAME } from 'hyperview/src/types'; import WebView from 'hyperview/src/core/components/web-view'; -import { createProps } from 'hyperview/src/services'; +import { useProps } from 'hyperview/src/services'; -export default class HvWebView extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; +const HvWebView = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, options, stylesheets } = props; + const [renderLoading, setRenderLoading] = useState(true); - static localName = LOCAL_NAME.WEB_VIEW; + const onMessage = useCallback( + ( + event: { + nativeEvent: { + data: string; + }; + } | null, + ) => { + if (!event) { + return; + } - state = { - renderLoading: true, - }; + if (event.nativeEvent.data === 'hv-web-view:render-loading:false') { + setRenderLoading(false); + } + const matches = event.nativeEvent.data.match(/^hyperview:(.*)$/); + if (matches) { + Events.dispatch(matches[1]); + } + }, + [], + ); - onMessage = ( - event: { - nativeEvent: { - data: string; - }; - } | null, - ) => { - if (!event) { - return; - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const componentProps: any = useProps(element, stylesheets, options); - if (event.nativeEvent.data === 'hv-web-view:render-loading:false') { - this.setState({ renderLoading: false }); - } - const matches = event.nativeEvent.data.match(/^hyperview:(.*)$/); - if (matches) { - Events.dispatch(matches[1]); - } - }; - - render() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const props: any = createProps( - this.props.element, - this.props.stylesheets, - this.props.options, - ); - const allowsInlineMediaPlayback = props['allows-inline-media-playback'] - ? props['allows-inline-media-playback'] === 'true' - : undefined; - const color = props['activity-indicator-color'] || '#8d9494'; - const loadBehavior = props['show-loading-indicator']; - let injectedJavaScript = props['injected-java-script']; + const webViewProps = useMemo(() => { + const loadBehavior = componentProps['show-loading-indicator']; + let injectedJavaScript = componentProps['injected-java-script']; if (loadBehavior === 'document-only') { injectedJavaScript += 'window.ReactNativeWebView.postMessage("hv-web-view:render-loading:false");'; } - const sharedCookiesEnabled = props['shared-cookies-enabled'] - ? props['shared-cookies-enabled'] === 'true' - : undefined; - const source = { html: props.html, uri: props.url } as const; - return ( - { - return this.state.renderLoading ? ( - - ) : ( - <> - ); - }} - sharedCookiesEnabled={sharedCookiesEnabled} - source={source} - startInLoadingState - testID={props.testID} + return { + accessibilityLabel: componentProps.accessibilityLabel, + allowsInlineMediaPlayback: componentProps['allows-inline-media-playback'] + ? componentProps['allows-inline-media-playback'] === 'true' + : undefined, + color: componentProps['activity-indicator-color'] || '#8d9494', + injectedJavaScript, + loadBehavior, + sharedCookiesEnabled: componentProps['shared-cookies-enabled'] + ? componentProps['shared-cookies-enabled'] === 'true' + : undefined, + source: { + html: componentProps.html, + uri: componentProps.url, + }, + testID: componentProps.testID, + }; + }, [componentProps]); + + const onRenderLoading = useCallback(() => { + return renderLoading ? ( + + ) : ( + <> ); - } -} + }, [renderLoading, webViewProps.color]); + + return ( + + ); +}; + +HvWebView.namespaceURI = Namespaces.HYPERVIEW; +HvWebView.localName = LOCAL_NAME.WEB_VIEW; + +export default HvWebView; diff --git a/src/core/components/modal/index.tsx b/src/core/components/modal/index.tsx index 822638e12..9e36e4321 100644 --- a/src/core/components/modal/index.tsx +++ b/src/core/components/modal/index.tsx @@ -10,8 +10,8 @@ import ModalButton from './modal-button'; import Overlay from './overlay'; import type { Props } from './types'; import type { StyleSheet as StyleSheetType } from 'hyperview/src/types'; -import { createStyleProp } from 'hyperview/src/services'; import styles from './styles'; +import { useStyleProp } from 'hyperview/src/services'; /** * Renders a bottom sheet with cancel/done buttons and a picker component. @@ -19,33 +19,45 @@ import styles from './styles'; * This is used on iOS only. */ export default (props: Props): JSX.Element => { - const [visible, setVisible] = useState(props.focused); + // eslint-disable-next-line react/destructuring-assignment + const { + children, + element, + focused: propsFocused, + onModalCancel, + onModalDone, + options, + stylesheets, + } = props; + const { focused, pressed, pressedSelected, selected } = options; + const [visible, setVisible] = useState(propsFocused); const [height, setHeight] = useState(0); useEffect(() => { - setVisible(props.focused); - }, [props.focused]); + setVisible(propsFocused); + }, [propsFocused]); const translateY = useRef(new Animated.Value(0)).current; const overlayOpacity = useRef(new Animated.Value(0)).current; const contentOpacity = useRef(new Animated.Value(0)).current; - const style: Array = createStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - styleAttr: 'modal-style', - }, - ); + const style: Array = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'modal-style', + }); - const cancelLabel: string = - props.element.getAttribute('cancel-label') || 'Cancel'; - const doneLabel: string = props.element.getAttribute('done-label') || 'Done'; + const cancelLabel: string = element.getAttribute('cancel-label') || 'Cancel'; + const doneLabel: string = element.getAttribute('done-label') || 'Done'; const overlayStyle = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, + useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, styleAttr: 'modal-overlay-style', }), ); @@ -55,7 +67,7 @@ export default (props: Props): JSX.Element => { }; const getDuration = (attribute: string, defaultValue: number) => { - const value = parseInt(props.element.getAttribute(attribute) || '', 10); + const value = parseInt(element.getAttribute(attribute) || '', 10); return Number.isNaN(value) || value < 0 ? defaultValue : value; }; @@ -119,12 +131,12 @@ export default (props: Props): JSX.Element => { }; const onShow = animateOpen(); - const onDismiss = animateClose(props.onModalCancel); - const onDone = animateClose(props.onModalDone); + const onDismiss = animateClose(onModalCancel); + const onDone = animateClose(onModalDone); return ( { - {props.children} + {children} diff --git a/src/core/components/modal/modal-button/index.tsx b/src/core/components/modal/modal-button/index.tsx index 3218bf598..6162e24a2 100644 --- a/src/core/components/modal/modal-button/index.tsx +++ b/src/core/components/modal/modal-button/index.tsx @@ -3,34 +3,35 @@ import React, { useState } from 'react'; import { Text, TouchableWithoutFeedback, View } from 'react-native'; import type { Props } from './types'; import type { StyleSheet } from 'hyperview/src/types'; -import { createStyleProp } from 'hyperview/src/services'; +import { useStyleProp } from 'hyperview/src/services'; /** * Component used to render the Cancel/Done buttons in the picker modal. */ export default (props: Props) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, label, onPress, options, stylesheets } = props; + const { focused, pressedSelected, selected } = options; const [pressed, setPressed] = useState(false); - const style: Array = createStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - pressed, - styleAttr: 'modal-text-style', - }, - ); + const style: Array = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'modal-text-style', + }); return ( setPressed(true)} onPressOut={() => setPressed(false)} > {/* eslint-disable-next-line react/jsx-props-no-spreading */} - - {props.label} + + {label} diff --git a/src/services/index.ts b/src/services/index.ts index 6d7270dda..d40646abf 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,19 +1,106 @@ import * as Xml from 'hyperview/src/services/xml'; import { DEFAULT_PRESS_OPACITY, HV_TIMEOUT_ID_ATTR } from './types'; import type { - DOMString, HvComponentOptions, StyleSheet, StyleSheets, } from 'hyperview/src/types'; import { NODE_TYPE } from 'hyperview/src/types'; import { Platform } from 'react-native'; +import { useMemo } from 'react'; /** * This file is currently a dumping place for every functions used accross * various Hyperview components. */ +type StyleRule = Record; + +const getStyleRules = ( + styleIds: string[], + stylesheet: Record, + fallback?: StyleRule, +): StyleRule[] => { + if (styleIds.length === 0) { + return []; + } + const rules = styleIds.map(styleId => stylesheet[styleId]).filter(Boolean); + if (rules.length > 0) { + return rules; + } + return fallback ? [fallback] : []; +}; + +export const useStyleProp = ( + element: Element, + stylesheets: StyleSheets, + options: HvComponentOptions, +): Array => { + const { styleAttr, focused, pressed, pressedSelected, selected } = options; + + const styleIds = useMemo(() => { + const value = element.getAttribute(styleAttr || 'style'); + if (typeof value !== 'string') { + return []; + } + return Xml.splitAttributeList(value); + }, [element, styleAttr]); + + const baseStyleRules = useMemo(() => { + return styleIds.map(styleId => stylesheets.regular[styleId]); + }, [styleIds, stylesheets.regular]); + + const pressedRules = useMemo(() => { + if (!pressed) { + return []; + } + return getStyleRules(styleIds, stylesheets.pressed, { + opacity: DEFAULT_PRESS_OPACITY, + }); + }, [pressed, styleIds, stylesheets.pressed]); + + const focusedRules = useMemo(() => { + if (!focused) { + return []; + } + return getStyleRules(styleIds, stylesheets.focused); + }, [focused, styleIds, stylesheets.focused]); + + const selectedRules = useMemo(() => { + if (!selected) { + return []; + } + return getStyleRules(styleIds, stylesheets.selected); + }, [selected, styleIds, stylesheets.selected]); + + const pressedSelectedRules = useMemo(() => { + if (!pressedSelected) { + return []; + } + return getStyleRules(styleIds, stylesheets.pressedSelected); + }, [pressedSelected, styleIds, stylesheets.pressedSelected]); + + return useMemo(() => { + return [ + ...baseStyleRules, + ...pressedRules, + ...focusedRules, + ...selectedRules, + ...pressedSelectedRules, + ]; + }, [ + baseStyleRules, + pressedRules, + focusedRules, + selectedRules, + pressedSelectedRules, + ]); +}; + +/** + * This is the legacy createStyleProp function. Replace implementations + * with useStyleProp for better performance. + */ export const createStyleProp = ( element: Element, stylesheets: StyleSheets, @@ -68,23 +155,89 @@ export const createStyleProp = ( * Sets the element's id attribute as a test id and accessibility label * (for testing automation purposes). */ -export const createTestProps = ( - element: Element, -): { - testID?: string; - accessibilityLabel?: string; -} => { - const testProps = {}; - const id: DOMString | null | undefined = element.getAttribute('id'); +export const createTestProps = (element: Element) => { + const id = element.getAttribute('id'); if (!id) { - return testProps; + return {}; + } + return Platform.OS === 'ios' ? { testID: id } : { accessibilityLabel: id }; +}; + +const ATTRIBUTE_TYPES: Record< + string, + 'numeric' | 'boolean' | 'float' | 'string' +> = { + adjustsFontSizeToFit: 'boolean', + allowFontScaling: 'boolean', + maxFontSizeMultiplier: 'float', + minimumFontScale: 'float', + multiline: 'boolean', + numberOfLines: 'numeric', + selectable: 'boolean', +}; + +const parseAttributeValue = (attr: Attr) => { + const type = ATTRIBUTE_TYPES[attr.name]; + if (!type) { + return attr.value; } - if (Platform.OS === 'ios') { - return { testID: id }; + + switch (type) { + case 'numeric': + return parseInt(attr.value, 10) || 0; + case 'boolean': + return attr.value === 'true'; + case 'float': + return parseFloat(attr.value) || 0; + default: + return attr.value; } - return { accessibilityLabel: id }; }; +export const useProps = ( + element: Element, + stylesheets: StyleSheets, + options: HvComponentOptions, +) => { + const { attributes } = element; + const { focused, pressed, pressedSelected, selected, styleAttr } = options; + + const style = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr, + }); + + const testProps = useMemo(() => createTestProps(element), [element]); + + const parsedAttributes = useMemo(() => { + if (!attributes) { + return {}; + } + + const props: Record = {}; + Array.from(attributes).forEach(attr => { + props[attr.name] = parseAttributeValue(attr); + }); + return props; + }, [attributes]); + + return useMemo( + () => ({ + ...parsedAttributes, + style, + ...testProps, + }), + [parsedAttributes, style, testProps], + ); +}; + +/** + * This is the legacy createProps function. Replace implementations + * with useProps for better performance. + */ export const createProps = ( element: Element, stylesheets: StyleSheets,