diff --git a/src/components/hv-date-field/field/index.tsx b/src/components/hv-date-field/field/index.tsx index 24dabbf3e..bbd3c7d4e 100644 --- a/src/components/hv-date-field/field/index.tsx +++ b/src/components/hv-date-field/field/index.tsx @@ -1,5 +1,5 @@ import * as Contexts from 'hyperview/src/contexts'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import { createProps, createStyleProp } from 'hyperview/src/services'; import FieldLabel from '../field-label'; @@ -22,13 +22,17 @@ export default (props: Props) => { styleAttr: 'field-style', }); - const labelStyle: StyleSheetType = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, - pressed, - styleAttr: 'field-text-style', - }), + const labelStyle: StyleSheetType = useMemo( + () => + StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + ...props.options, + focused: props.focused, + pressed, + styleAttr: 'field-text-style', + }), + ), + [props.element, props.stylesheets, props.options, props.focused, pressed], ); return ( diff --git a/src/components/hv-list/index.tsx b/src/components/hv-list/index.tsx index 81ab96398..e58f7a794 100644 --- a/src/components/hv-list/index.tsx +++ b/src/components/hv-list/index.tsx @@ -3,7 +3,6 @@ import * as Dom from 'hyperview/src/services/dom'; 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 { DOMString, HvComponentOnUpdate, @@ -20,6 +19,7 @@ import { createTestProps, getAncestorByTagName } from 'hyperview/src/services'; import { DOMParser } from '@instawork/xmldom'; import type { ElementRef } from 'react'; import { FlatList } from 'hyperview/src/core/components/scroll'; +import HvElement from 'hyperview/src/core/components/hv-element'; import { LOCAL_NAME } from 'hyperview/src/types'; export default class HvList extends PureComponent { @@ -265,12 +265,13 @@ export default class HvList extends PureComponent { removeClippedSubviews={false} // eslint-disable-next-line @typescript-eslint/no-explicit-any renderItem={({ item }: any) => - item && - Render.renderElement( - item, - this.props.stylesheets, - this.onUpdate, - this.props.options, + item && ( + ) } scrollIndicatorInsets={scrollIndicatorInsets} diff --git a/src/components/hv-option/index.ts b/src/components/hv-option/index.tsx similarity index 94% rename from src/components/hv-option/index.ts rename to src/components/hv-option/index.tsx index efa64c0fb..a1df9a0d6 100644 --- a/src/components/hv-option/index.ts +++ b/src/components/hv-option/index.tsx @@ -1,12 +1,9 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; import * as Render from 'hyperview/src/services/render'; -import type { - HvComponentOnUpdate, - HvComponentProps, -} from 'hyperview/src/types'; import React, { PureComponent } from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; +import type { HvComponentProps } from 'hyperview/src/types'; import { LOCAL_NAME } from 'hyperview/src/types'; import type { State } from './types'; import { createEventHandler } from 'hyperview/src/core/hyper-ref'; @@ -82,17 +79,18 @@ export default class HvOption extends PureComponent { outerProps.style = { flex: props.style.flex }; } + // TODO: Replace with return React.createElement( TouchableWithoutFeedback, outerProps, React.createElement( View, props, - ...Render.renderChildren( + ...Render.buildChildArray( this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, + this.props.onUpdate, newOptions, + this.props.stylesheets, ), ), ); diff --git a/src/components/hv-picker-field/field-label/index.tsx b/src/components/hv-picker-field/field-label/index.tsx index 1a79e670d..eb8dcddbd 100644 --- a/src/components/hv-picker-field/field-label/index.tsx +++ b/src/components/hv-picker-field/field-label/index.tsx @@ -1,7 +1,7 @@ import * as FontScale from 'hyperview/src/services/font-scale'; +import React, { useMemo } from 'react'; 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'; @@ -14,13 +14,23 @@ export default (props: Props) => { const placeholderTextColor = props.element.getAttribute( 'placeholderTextColor', ); - const style: StyleSheetType = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, - pressed: props.pressed, - styleAttr: 'field-text-style', - }), + const style: StyleSheetType = useMemo( + () => + StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + ...props.options, + focused: props.focused, + pressed: props.pressed, + styleAttr: 'field-text-style', + }), + ), + [ + props.element, + props.stylesheets, + props.options, + props.focused, + props.pressed, + ], ); const labelStyles: Array = [style]; diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index d768201cc..dbc1f407e 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -1,11 +1,7 @@ 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, @@ -23,198 +19,215 @@ 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); - }; - - getPickerInitialValue = (): string => { - const value = this.getValue(); - const pickerItems: Element[] = this.getPickerItems(); - const valueExists = pickerItems.some( - item => item.getAttribute('value') === value, - ); - if (valueExists) { - return value; - } - return pickerItems.length > 0 - ? pickerItems[0].getAttribute('value') || '' - : ''; - }; +const HvPickerField = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; /** * Returns a string representing the value in the field. */ - getValue = (): string => this.props.element.getAttribute('value') || ''; + const getValue = useCallback( + (): string => element.getAttribute('value') || '', + [element], + ); /** * 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; + const getLabelForValue = useCallback( + (value: 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') === value + ) { + item = pickerItemElement; + break; + } } - } - return item ? item.getAttribute('label') : null; - }; + return item ? item.getAttribute('label') : null; + }, + [element], + ); /** * 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( - Namespaces.HYPERVIEW, - LOCAL_NAME.PICKER_ITEM, + const getPickerValue = useCallback( + (): string => element.getAttribute('picker-value') || '', + [element], + ); + + const getPickerItems = useCallback( + (): Element[] => + Array.from( + element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.PICKER_ITEM, + ), ), + [element], + ); + + const getPickerInitialValue = useCallback((): string => { + const value = getValue(); + const pickerItems: Element[] = getPickerItems(); + const valueExists = pickerItems.some( + item => item.getAttribute('value') === value, ); + if (valueExists) { + return value; + } + return pickerItems.length > 0 + ? pickerItems[0].getAttribute('value') || '' + : ''; + }, [getPickerItems, getValue]); /** * 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', getPickerInitialValue()); + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('focus', newElement, onUpdate); + }, [element, getPickerInitialValue, 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 }); - 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 pickerValue = getPickerValue(); + const value = getValue(); + 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, getPickerValue, getValue]); /** * 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( + (value: string) => { + const newElement = element.cloneNode(true) as Element; + newElement.setAttribute('picker-value', value); + 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, + const isFocused = useCallback( + (): boolean => element.getAttribute('focused') === 'true', + [element], + ); + + const { focused, pressed, pressedSelected, selected } = options; + const style = useMemo( + () => + createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, 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 }); - } - - // 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) => { - 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} - - ); + }), + [element, focused, pressed, pressedSelected, selected, stylesheets], + ); + const { testID, accessibilityLabel } = createTestProps(element); + const value: DOMString | null | undefined = element.getAttribute('value'); + const placeholderTextColor: + | DOMString + | null + | undefined = element.getAttribute('placeholderTextColor'); + if ([undefined, null, ''].includes(value) && placeholderTextColor) { + style.push({ 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 = getPickerItems() + .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 focusedVal = isFocused(); + + return ( + + {focusedVal ? ( + + + + {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..372140574 100644 --- a/src/components/hv-picker-field/index.tsx +++ b/src/components/hv-picker-field/index.tsx @@ -1,11 +1,7 @@ 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, @@ -21,168 +17,182 @@ 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; /** * Returns a string representing the value in the field. */ - getValue = (): string => this.props.element.getAttribute('value') || ''; + const getValue = useCallback( + (): 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 getPickerValue = useCallback( + (): string => element.getAttribute('value') || '', + [element], + ); + + const getPickerItems = useCallback( + (): Element[] => + Array.from( + element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.PICKER_ITEM, + ), ), - ); + [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); - } - }; + const onDone = useCallback( + (newValue?: string) => { + const pickerValue = newValue !== undefined ? newValue : getPickerValue(); + const value = getValue(); + const newElement = element.cloneNode(true) as Element; + newElement.setAttribute('value', pickerValue); + newElement.removeAttribute('picker-value'); + newElement.setAttribute('focused', 'false'); + onUpdate(null, 'swap', element, { newElement }); + + const hasChanged = value !== pickerValue; + if (hasChanged) { + Behaviors.trigger('change', newElement, onUpdate); + } + }, + [element, onUpdate, getPickerValue, getValue], + ); - render() { - const onChange = (value: string | null | undefined) => { + const onChange = useCallback( + (value: string | null | undefined) => { if (value === undefined) { - this.onCancel(); + onCancel(); } else { - this.onDone(value || ''); + onDone(value || ''); } - }; - - 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 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'); - if (!l || typeof v !== 'string') { - return null; - } - const enabled = ['', 'true', null].includes(item.getAttribute('enabled')); - return ( - - ); + }, + [onCancel, onDone], + ); + + const { focused, pressed, pressedSelected, selected } = options; + const style = useMemo(() => { + return createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'field-text-style', }); + }, [element, focused, pressed, pressedSelected, selected, stylesheets]); + const { testID, accessibilityLabel } = createTestProps(element); + const value: DOMString | null | undefined = element.getAttribute('value'); + const placeholderTextColor: + | DOMString + | null + | undefined = element.getAttribute('placeholderTextColor'); + if ([undefined, null, ''].includes(value) && placeholderTextColor) { + style.push({ color: placeholderTextColor }); + } - // 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( - , - ); + const fieldStyle = useMemo(() => { + return createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'field-style', + }); + }, [element, focused, pressed, pressedSelected, selected, stylesheets]); + + // Gets all of the elements. All picker item elements + // with a value and label are turned into options for the picker. + const items = 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'); + if (!l || typeof v !== 'string') { + return null; } - + const enabled = ['', 'true', null].includes(item.getAttribute('enabled')); return ( - - - {children} - - + + ); + }); + + // 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( + , ); } -} + + return ( + + + {children} + + + ); +}; + +HvPickerField.namespaceURI = Namespaces.HYPERVIEW; +HvPickerField.localName = LOCAL_NAME.PICKER_FIELD; +HvPickerField.getFormInputValues = getNameValueFormInputValues; + +export default HvPickerField; diff --git a/src/components/hv-section-list/index.tsx b/src/components/hv-section-list/index.tsx index d1323e330..9a6edb935 100644 --- a/src/components/hv-section-list/index.tsx +++ b/src/components/hv-section-list/index.tsx @@ -3,7 +3,6 @@ import * as Dom from 'hyperview/src/services/dom'; 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 { DOMString, HvComponentOnUpdate, @@ -20,6 +19,7 @@ import type { ScrollParams, State } from './types'; import { createTestProps, getAncestorByTagName } from 'hyperview/src/services'; import { DOMParser } from '@instawork/xmldom'; import type { ElementRef } from 'react'; +import HvElement from 'hyperview/src/core/components/hv-element'; import { SectionList } from 'hyperview/src/core/components/scroll'; const getSectionIndex = ( @@ -376,23 +376,23 @@ export default class HvSectionList extends PureComponent< } removeClippedSubviews={false} // eslint-disable-next-line @typescript-eslint/no-explicit-any - renderItem={({ item }: any): any => - Render.renderElement( - item, - this.props.stylesheets, - this.onUpdate, - this.props.options, - ) - } + renderItem={({ item }: any): any => ( + + )} // eslint-disable-next-line @typescript-eslint/no-explicit-any - renderSectionHeader={({ section: { title } }: any): any => - Render.renderElement( - title, - this.props.stylesheets, - this.onUpdate, - this.props.options, - ) - } + renderSectionHeader={({ section: { title } }: any): any => ( + + )} scrollIndicatorInsets={scrollIndicatorInsets} sections={sections} stickySectionHeadersEnabled={this.getStickySectionHeadersEnabled()} diff --git a/src/components/hv-select-multiple/index.ts b/src/components/hv-select-multiple/index.tsx similarity index 84% rename from src/components/hv-select-multiple/index.ts rename to src/components/hv-select-multiple/index.tsx index 27243e924..0b19269f5 100644 --- a/src/components/hv-select-multiple/index.ts +++ b/src/components/hv-select-multiple/index.tsx @@ -1,11 +1,7 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; -import * as Render from 'hyperview/src/services/render'; -import type { - DOMString, - HvComponentOnUpdate, - HvComponentProps, -} from 'hyperview/src/types'; +import type { DOMString, HvComponentProps } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; +import HvChildren from 'hyperview/src/core/components/hv-children'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; import { createProps } from 'hyperview/src/services'; @@ -98,18 +94,23 @@ export default class HvSelectMultiple extends PureComponent { const props = createProps(this.props.element, this.props.stylesheets, { ...this.props.options, }); - 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 { key, ...otherProps } = props; + return ( + + + ); } } diff --git a/src/components/hv-select-single/index.ts b/src/components/hv-select-single/index.tsx similarity index 95% rename from src/components/hv-select-single/index.ts rename to src/components/hv-select-single/index.tsx index 19c7bc4b1..1161a962d 100644 --- a/src/components/hv-select-single/index.ts +++ b/src/components/hv-select-single/index.tsx @@ -1,10 +1,6 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; import * as Render from 'hyperview/src/services/render'; -import type { - DOMString, - HvComponentOnUpdate, - HvComponentProps, -} from 'hyperview/src/types'; +import type { DOMString, HvComponentProps } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; @@ -93,17 +89,19 @@ export default class HvSelectSingle extends PureComponent { const props = createProps(this.props.element, this.props.stylesheets, { ...this.props.options, }); + + // TODO: Replace with return React.createElement( View, props, - ...Render.renderChildren( + ...Render.buildChildArray( this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, + this.props.onUpdate, { ...this.props.options, onSelect: this.onSelect, }, + this.props.stylesheets, ), ); } diff --git a/src/components/hv-switch/index.ts b/src/components/hv-switch/index.ts index 048ff7249..bb3772839 100644 --- a/src/components/hv-switch/index.ts +++ b/src/components/hv-switch/index.ts @@ -1,7 +1,7 @@ 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 React, { useMemo } from 'react'; import { createStyleProp, getNameValueFormInputValues, @@ -31,72 +31,72 @@ function darkenColor(color: ColorValue, percent: number): ColorValue { 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, { +const HvSwitch = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const { styleAttr } = options; + const unselectedStyle = useMemo(() => { + return StyleSheet.flatten( + createStyleProp(element, stylesheets, { selected: false, + styleAttr, }), ); - const selectedStyle = StyleSheet.flatten( - createStyleProp(this.props.element, this.props.stylesheets, { + }, [element, styleAttr, stylesheets]); + + const selectedStyle = useMemo(() => { + return StyleSheet.flatten( + createStyleProp(element, stylesheets, { selected: true, + styleAttr, }), ); + }, [element, styleAttr, stylesheets]); - 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', - }; + if (element.getAttribute('hide') === 'true') { + return null; + } - // android thumbColor default - if ( - Platform.OS === 'android' && - !props.thumbColor && - props.trackColor.true - ) { - props.thumbColor = props.value - ? darkenColor(props.trackColor.true, 0.3) - : '#FFFFFF'; - } + const p = { + ios_backgroundColor: unselectedStyle + ? unselectedStyle.backgroundColor + : null, + onChange: () => { + const newElement = element.cloneNode(true) as Element; + Behaviors.trigger('change', newElement, onUpdate); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onValueChange: (value: any) => { + const newElement = element.cloneNode(true) as Element; + newElement.setAttribute('value', value ? 'on' : 'off'); + onUpdate(null, 'swap', element, { newElement }); + }, + // iOS thumbColor default + thumbColor: unselectedStyle?.color || selectedStyle?.color, + trackColor: { + false: unselectedStyle ? unselectedStyle.backgroundColor : null, + true: selectedStyle ? selectedStyle.backgroundColor : null, + }, + value: element.getAttribute('value') === 'on', + }; - // 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; - } + // android thumbColor default + if (Platform.OS === 'android' && !p.thumbColor && p.trackColor.true) { + p.thumbColor = p.value ? darkenColor(p.trackColor.true, 0.3) : '#FFFFFF'; + } - return React.createElement(Switch, props); + // 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 React.createElement(Switch, p); +}; + +HvSwitch.namespaceURI = Namespaces.HYPERVIEW; +HvSwitch.localName = LOCAL_NAME.SWITCH; +HvSwitch.getFormInputValues = getNameValueFormInputValues; + +export default HvSwitch; diff --git a/src/components/hv-text/index.ts b/src/components/hv-text/index.tsx similarity index 73% rename from src/components/hv-text/index.ts rename to src/components/hv-text/index.tsx index 3a10deaed..ac26a5a03 100644 --- a/src/components/hv-text/index.ts +++ b/src/components/hv-text/index.tsx @@ -15,36 +15,44 @@ export default class HvText extends PureComponent { static localName = LOCAL_NAME.TEXT; - render() { - const { skipHref } = this.props.options || {}; + Component = () => { const props = createProps( this.props.element, this.props.stylesheets, this.props.options, ); - const component = React.createElement( + + // TODO: Replace with + return React.createElement( Text, props, - ...Render.renderChildren( + ...Render.buildChildArray( this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, + this.props.onUpdate, { ...this.props.options, preformatted: this.props.element.getAttribute('preformatted') === 'true', }, + this.props.stylesheets, ), ); + }; - return skipHref - ? component - : addHref( - component, - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - this.props.options, - ); + render() { + const { Component } = this; + const { skipHref } = this.props.options || {}; + + return skipHref ? ( + + ) : ( + addHref( + , + this.props.element, + this.props.stylesheets, + this.props.onUpdate as HvComponentOnUpdate, + this.props.options, + ) + ); } } diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index 142c5e070..d9b583908 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -23,57 +23,73 @@ import { KeyboardAwareScrollView, ScrollView, } from 'hyperview/src/core/components/scroll'; -import React, { PureComponent } from 'react'; +import React, { useCallback, useMemo } 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; +const HvView = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; - static localName = LOCAL_NAME.VIEW; - - 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((): Attributes => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return Object.values(ATTRIBUTES).reduce>( - (attributes, name: string) => ({ - ...attributes, - [name]: this.props.element.getAttribute(name), + (acc, name: string) => ({ + ...acc, + [name]: element.getAttribute(name), }), {}, ); - } + }, [element]); + + const { focused, pressed, pressedSelected, selected } = options; + const { styleAttr } = options; - hasInputFields = (): boolean => { - const textFields = this.props.element.getElementsByTagNameNS( + // 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 = useMemo(() => { + return (createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr, + }) as unknown) as ViewStyle; + }, [ + element, + focused, + pressed, + pressedSelected, + selected, + styleAttr, + stylesheets, + ]); + + const containerStyle = useMemo(() => { + return createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, + }); + }, [element, focused, pressed, pressedSelected, selected, stylesheets]); + + const checkHasInputFields = useCallback((): boolean => { + const textFields = element.getElementsByTagNameNS( Namespaces.HYPERVIEW, 'text-field', ); return textFields.length > 0; - }; - - 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'); + }, [element]); + + const getCommonProps = useCallback((): CommonProps => { + const id = element.getAttribute('id'); if (!id) { return { style }; } @@ -81,170 +97,208 @@ export default class HvView extends PureComponent { return { style, testID: id }; } return { accessibilityLabel: id, style }; - }; - - getScrollViewProps = ( - children: Array | null | string>, - ): ScrollViewProps => { - const horizontal = - this.attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; - const showScrollIndicator = - this.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, - }) - : undefined; - - // Fix scrollbar rendering issue in iOS 13+ - // https://github.com/facebook/react-native/issues/26610#issuecomment-539843444 - const scrollIndicatorInsets = - Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 13 - ? { right: 1 } + }, [element, style]); + + const getScrollViewProps = useCallback( + ( + children: Array | null | string>, + ): ScrollViewProps => { + const horizontal = + attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; + const showScrollIndicator = + attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; + + const contentContainerStyle = attributes[ + ATTRIBUTES.CONTENT_CONTAINER_STYLE + ] + ? containerStyle : undefined; - // add sticky indices - const stickyHeaderIndices = children.reduce>( - (acc, element, index) => { - if ( - typeof element !== 'string' && - element?.props.element?.getAttribute('sticky') === 'true' - ) { - return [...acc, index]; - } - return acc; - }, - [], - ); + // Fix scrollbar rendering issue in iOS 13+ + // https://github.com/facebook/react-native/issues/26610#issuecomment-539843444 + const scrollIndicatorInsets = + Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 13 + ? { right: 1 } + : undefined; + + // add sticky indices + const stickyHeaderIndices = children.reduce>( + (acc, el, index) => { + if ( + typeof el !== 'string' && + el?.props.element?.getAttribute?.('sticky') === 'true' + ) { + return [...acc, index]; + } + return acc; + }, + [], + ); + + return { + contentContainerStyle, + horizontal, + keyboardDismissMode: Keyboard.getKeyboardDismissMode(element), + scrollIndicatorInsets, + showsHorizontalScrollIndicator: horizontal && showScrollIndicator, + showsVerticalScrollIndicator: !horizontal && showScrollIndicator, + stickyHeaderIndices, + }; + }, + [attributes, containerStyle, element], + ); - return { - contentContainerStyle, - horizontal, - keyboardDismissMode: Keyboard.getKeyboardDismissMode(this.props.element), - scrollIndicatorInsets, - showsHorizontalScrollIndicator: horizontal && showScrollIndicator, - showsVerticalScrollIndicator: !horizontal && showScrollIndicator, - stickyHeaderIndices, - }; - }; - - getScrollToInputAdditionalOffsetProp = (): number => { + const getScrollToInputAdditionalOffsetProp = useCallback((): number => { const defaultOffset = 120; - const offsetStr = this.attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; + const offsetStr = attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; if (offsetStr) { const offset = parseInt(offsetStr, 10); return Number.isNaN(offset) ? 0 : defaultOffset; } return defaultOffset; - }; + }, [attributes]); - 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 getKeyboardAwareScrollViewProps = useCallback( + ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputFieldRefs: Array, + ): KeyboardAwareScrollViewProps => ({ + automaticallyAdjustContentInsets: false, + getTextInputRefs: () => inputFieldRefs, + keyboardShouldPersistTaps: 'handled', + scrollEventThrottle: 16, + scrollToInputAdditionalOffset: getScrollToInputAdditionalOffsetProp(), + }), + [getScrollToInputAdditionalOffsetProp], + ); + + const Content = useCallback( + (p: HvComponentProps) => { + /** + * 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 hasInputFields = checkHasInputFields(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputFieldRefs: Array = []; + const scrollable = attributes[ATTRIBUTES.SCROLL] === 'true'; + const safeArea = attributes[ATTRIBUTES.SAFE_AREA] === 'true'; + if (safeArea) { + if (keyboardAvoiding || scrollable) { + Logging.warn( + 'safe-area is incompatible with scroll or avoid-keyboard', + ); + } } - } - const children = Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - { - ...this.props.options, - ...(scrollable && hasInputFields - ? { - registerInputHandler: ref => { - if (ref !== null) { - inputFieldRefs.push(ref); - } - }, - } - : {}), - }, - ); + const children = Render.buildChildArray( + p.element, + p.onUpdate, + { + ...p.options, + ...(scrollable && hasInputFields + ? { + registerInputHandler: ref => { + if (ref !== null) { + inputFieldRefs.push(ref); + } + }, + } + : {}), + }, + p.stylesheets, + ); - /* eslint-disable react/jsx-props-no-spreading */ - if (scrollable) { - if (hasInputFields) { + /* eslint-disable react/jsx-props-no-spreading */ + if (scrollable) { + if (hasInputFields) { + // TODO: Replace with + return React.createElement( + KeyboardAwareScrollView, + { + element: p.element, + ...getCommonProps(), + ...getScrollViewProps(children), + ...getKeyboardAwareScrollViewProps(inputFieldRefs), + }, + ...children, + ); + } + // TODO: Replace with return React.createElement( - KeyboardAwareScrollView, + ScrollView, { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), - ...this.getKeyboardAwareScrollViewProps(inputFieldRefs), + element: p.element, + ...getCommonProps(), + ...getScrollViewProps(children), }, ...children, ); } - return React.createElement( - ScrollView, - { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), - }, - ...children, - ); - } - if (!keyboardAvoiding && safeArea) { - return React.createElement( - SafeAreaView, - this.getCommonProps(), - ...children, - ); - } - if (keyboardAvoiding) { - return React.createElement( - KeyboardAvoidingView, - { ...this.getCommonProps(), behavior: 'position' }, - ...children, - ); - } - return React.createElement(View, this.getCommonProps(), ...children); - /* eslint-enable react/jsx-props-no-spreading */ - }; - - render() { - const { Content } = this; - return this.props.options?.skipHref ? ( - - ) : ( - addHref( - , - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - this.props.options, - ) - ); - } -} + if (!keyboardAvoiding && safeArea) { + // TODO: Replace with + return React.createElement(SafeAreaView, getCommonProps(), ...children); + } + if (keyboardAvoiding) { + // TODO: Replace with + return React.createElement( + KeyboardAvoidingView, + { + ...getCommonProps(), + behavior: 'position', + }, + ...children, + ); + } + // TODO: Replace with + return React.createElement(View, getCommonProps(), ...children); + /* eslint-enable react/jsx-props-no-spreading */ + }, + [ + attributes, + checkHasInputFields, + getCommonProps, + getKeyboardAwareScrollViewProps, + getScrollViewProps, + ], + ); + + return options?.skipHref ? ( + + ) : ( + addHref( + , + 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/core/components/hv-children/index.tsx b/src/core/components/hv-children/index.tsx new file mode 100644 index 000000000..b05b7e23f --- /dev/null +++ b/src/core/components/hv-children/index.tsx @@ -0,0 +1,18 @@ +import * as Render from 'hyperview/src/services/render'; +import type { HvComponentProps } from 'hyperview/src/types'; + +/** + * Returns the children of an element as an array of HvElement components + * @param {HvComponentProps} props - The props of the component + * @returns {Array} - The array of children + */ +export default ( + props: HvComponentProps, +): Array => { + return Render.buildChildArray( + props.element, + props.onUpdate, + props.options, + props.stylesheets, + ); +}; diff --git a/src/core/components/hv-element/index.tsx b/src/core/components/hv-element/index.tsx new file mode 100644 index 000000000..55d5497de --- /dev/null +++ b/src/core/components/hv-element/index.tsx @@ -0,0 +1,102 @@ +import * as InlineContext from 'hyperview/src/services/inline-context'; +import * as Logging from 'hyperview/src/services/logging'; +import * as Namespaces from 'hyperview/src/services/namespaces'; +import { LOCAL_NAME, NODE_TYPE } from 'hyperview/src/types'; +import React, { useMemo } from 'react'; +import type { HvComponentProps } from 'hyperview/src/types'; +import { isRenderableElement } from 'hyperview/src/core/utils'; + +export default (props: HvComponentProps): JSX.Element | null | string => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const { localName, namespaceURI, nodeType } = element; + const { componentRegistry, inlineFormattingContext, preformatted } = options; + + const formattingContext = useMemo(() => { + if ( + !preformatted && + !inlineFormattingContext && + nodeType === NODE_TYPE.ELEMENT_NODE && + localName === LOCAL_NAME.TEXT + ) { + return InlineContext.formatter(element); + } + return inlineFormattingContext; + }, [element, inlineFormattingContext, localName, nodeType, preformatted]); + + const componentProps = useMemo(() => { + return { + element, + onUpdate, + options: { + ...options, + inlineFormattingContext: formattingContext, + }, + stylesheets, + }; + }, [element, formattingContext, onUpdate, options, stylesheets]); + + const Component = useMemo(() => { + if (nodeType === NODE_TYPE.ELEMENT_NODE && namespaceURI && localName) { + return componentRegistry?.getComponent(namespaceURI, localName); + } + return undefined; + }, [localName, namespaceURI, nodeType, componentRegistry]); + + // Check if the element is renderable before rendering the component + if (!isRenderableElement(element, options, formattingContext)) { + return null; + } + + if (nodeType === NODE_TYPE.ELEMENT_NODE) { + if (Component) { + // Prepare props for the component + + // Conditionally render the component with a key if it exists, to avoid + // warnings with current React versions, when the key attribute is set + // using the spread operator. + const key = element.getAttribute('key'); + + if (key) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; + } + return ; // eslint-disable-line react/jsx-props-no-spreading + } + } + + if (nodeType === NODE_TYPE.TEXT_NODE) { + // Render non-empty text nodes, when wrapped inside a element + if (element.nodeValue) { + if ( + ((element.parentNode as Element)?.namespaceURI === + Namespaces.HYPERVIEW && + (element.parentNode as Element)?.localName === LOCAL_NAME.TEXT) || + (element.parentNode as Element)?.namespaceURI !== Namespaces.HYPERVIEW + ) { + if (preformatted) { + return element.nodeValue; + } + // When inline formatting context exists, lookup formatted value using node's index. + if (formattingContext) { + const index = formattingContext[0].indexOf(element); + return formattingContext[1][index]; + } + + // Other strings might be whitespaces in non text elements, which we ignore + // However we raise a warning when the string isn't just composed of whitespaces. + const trimmedValue = element.nodeValue.trim(); + if (trimmedValue.length > 0) { + Logging.warn( + `Text string "${trimmedValue}" must be rendered within a element`, + ); + } + } + } + } + + if (nodeType === NODE_TYPE.CDATA_SECTION_NODE) { + return element.nodeValue; + } + return null; +}; diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 447caf25d..87a210f94 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -4,7 +4,6 @@ import * as Helpers from 'hyperview/src/services/dom/helpers'; import * as Namespaces from 'hyperview/src/services/namespaces'; import * as NavigationContext from 'hyperview/src/contexts/navigation'; import * as NavigatorService from 'hyperview/src/services/navigator'; -import * as Render from 'hyperview/src/services/render'; import * as Stylesheets from 'hyperview/src/services/stylesheets'; import * as Types from './types'; import * as UrlService from 'hyperview/src/services/url'; @@ -13,12 +12,8 @@ import { BackBehaviorProvider, } from 'hyperview/src/contexts/back-behaviors'; import HvDoc, { StateContext } from 'hyperview/src/core/components/hv-doc'; -import React, { - JSXElementConstructor, - PureComponent, - useContext, - useMemo, -} from 'react'; +import React, { PureComponent, useContext, useMemo } from 'react'; +import HvElement from 'hyperview/src/core/components/hv-element'; import HvNavigator from 'hyperview/src/core/components/hv-navigator'; import HvScreen from 'hyperview/src/core/components/hv-screen'; import { LOCAL_NAME } from 'hyperview/src/types'; @@ -102,12 +97,14 @@ class HvRouteInner extends PureComponent { const styleSheet = Stylesheets.createStylesheets( (preloadElement as unknown) as Document, ); - const component: - | string - | React.ReactElement> - | null = Render.renderElement(body, styleSheet, () => noop, { - componentRegistry: this.componentRegistry, - }); + const component = ( + + ); if (component) { return ( ); } if (!screenElement) { diff --git a/src/core/components/keyboard-aware-scroll-view/types.ts b/src/core/components/keyboard-aware-scroll-view/types.ts index bf95b5e11..b21c98ffb 100644 --- a/src/core/components/keyboard-aware-scroll-view/types.ts +++ b/src/core/components/keyboard-aware-scroll-view/types.ts @@ -1,6 +1,7 @@ import { ScrollView, TextInput } from 'react-native'; export type Props = { + children?: React.ReactNode; getTextInputRefs?: () => Array | null | undefined; onScroll?: ScrollView['props']['onScroll']; scrollToBottomOnKBShow?: boolean; diff --git a/src/core/components/modal/index.tsx b/src/core/components/modal/index.tsx index 822638e12..b5f796846 100644 --- a/src/core/components/modal/index.tsx +++ b/src/core/components/modal/index.tsx @@ -5,7 +5,7 @@ import { StyleSheet, View, } from 'react-native'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import ModalButton from './modal-button'; import Overlay from './overlay'; import type { Props } from './types'; @@ -30,24 +30,28 @@ export default (props: Props): JSX.Element => { 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 = useMemo( + () => + createStyleProp(props.element, props.stylesheets, { + ...props.options, + styleAttr: 'modal-style', + }), + [props.element, props.stylesheets, props.options], ); const cancelLabel: string = props.element.getAttribute('cancel-label') || 'Cancel'; const doneLabel: string = props.element.getAttribute('done-label') || 'Done'; - const overlayStyle = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, - styleAttr: 'modal-overlay-style', - }), + const overlayStyle = useMemo( + () => + StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + ...props.options, + styleAttr: 'modal-overlay-style', + }), + ), + [props.element, props.stylesheets, props.options], ); const onLayout = (event: LayoutChangeEvent) => { diff --git a/src/core/components/modal/modal-button/index.tsx b/src/core/components/modal/modal-button/index.tsx index 3218bf598..4942b8c79 100644 --- a/src/core/components/modal/modal-button/index.tsx +++ b/src/core/components/modal/modal-button/index.tsx @@ -1,5 +1,5 @@ import * as FontScale from 'hyperview/src/services/font-scale'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Text, TouchableWithoutFeedback, View } from 'react-native'; import type { Props } from './types'; import type { StyleSheet } from 'hyperview/src/types'; @@ -11,14 +11,14 @@ import { createStyleProp } from 'hyperview/src/services'; export default (props: Props) => { const [pressed, setPressed] = useState(false); - const style: Array = createStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - pressed, - styleAttr: 'modal-text-style', - }, + const style: Array = useMemo( + () => + createStyleProp(props.element, props.stylesheets, { + ...props.options, + pressed, + styleAttr: 'modal-text-style', + }), + [props.element, props.stylesheets, props.options, pressed], ); return ( diff --git a/src/core/hyper-ref/index.tsx b/src/core/hyper-ref/index.tsx index a272b1996..3f2a345f6 100644 --- a/src/core/hyper-ref/index.tsx +++ b/src/core/hyper-ref/index.tsx @@ -3,7 +3,6 @@ import * as Dom from 'hyperview/src/services/dom'; import * as Events from 'hyperview/src/services/events'; 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 { BEHAVIOR_ATTRIBUTES, LOCAL_NAME, @@ -22,6 +21,7 @@ import type { PressHandlers, PressPropName, Props, State } from './types'; import React, { PureComponent } from 'react'; import { RefreshControl, Text, TouchableOpacity } from 'react-native'; import { BackBehaviorContext } from 'hyperview/src/contexts/back-behaviors'; +import HvElement from 'hyperview/src/core/components/hv-element'; import { PRESS_TRIGGERS_PROP_NAMES } from './types'; import { ScrollView } from 'hyperview/src/core/components/scroll'; import VisibilityDetectingView from 'hyperview/src/VisibilityDetectingView'; @@ -422,13 +422,18 @@ export default class HyperRef extends PureComponent { render() { // Render the component based on the XML element. Depending on the applied behaviors, // this component will be wrapped with others to provide the necessary interaction. - const children = Render.renderElement( - this.props.element, - this.props.stylesheets, - this.props.onUpdate, - { ...this.props.options, pressed: this.state.pressed, skipHref: true }, + const children = ( + ); - const { ScrollableView, TouchableView, VisibilityView } = this; return ( @@ -460,9 +465,12 @@ export const addHref = ( return component; } - return React.createElement( - HyperRef, - { element, onUpdate, options, stylesheets }, - ...Render.renderChildren(element, stylesheets, onUpdate, options), + return ( + ); }; diff --git a/src/core/utils.ts b/src/core/utils.ts index 3dd007333..c05a1549f 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,3 +1,7 @@ +import * as Logging from 'hyperview/src/services/logging'; +import * as Namespaces from 'hyperview/src/services/namespaces'; +import { HvComponentOptions, LOCAL_NAME, NODE_TYPE } from 'hyperview/src/types'; + /** * Provides a random UUID string. * @returns {string} @@ -17,3 +21,98 @@ export const uuid = (): string => { export const uuidNumber = (): number => { return parseInt(uuid().replace(/-/g, ''), 16); }; + +/** + * Checks if an element should be rendered based on the element type and options + * Logging warnings for unexpected conditions + * @param {Element} element + * @param {HvComponentOptions} options + * @param {InlineContext} inlineFormattingContext + * @returns {boolean} + */ +export const isRenderableElement = ( + element: Element, + options: HvComponentOptions, + inlineFormattingContext: [Node[], string[]] | null | undefined, +): boolean => { + if (!element) { + return false; + } + if ( + element.nodeType === NODE_TYPE.ELEMENT_NODE && + element.getAttribute('hide') === 'true' + ) { + // Hidden elements don't get rendered + return false; + } + if (element.nodeType === NODE_TYPE.COMMENT_NODE) { + // XML comments don't get rendered. + return false; + } + if ( + element.nodeType === NODE_TYPE.ELEMENT_NODE && + element.namespaceURI === Namespaces.HYPERVIEW + ) { + switch (element.localName) { + case LOCAL_NAME.BEHAVIOR: + case LOCAL_NAME.MODIFIER: + case LOCAL_NAME.STYLES: + case LOCAL_NAME.STYLE: + // Non-UI elements don't get rendered + return false; + default: + break; + } + } + + if (element.nodeType === NODE_TYPE.ELEMENT_NODE) { + if (!element.namespaceURI) { + Logging.warn('`namespaceURI` missing for node:', element.toString()); + return false; + } + if (!element.localName) { + Logging.warn('`localName` missing for node:', element.toString()); + return false; + } + + if ( + options.componentRegistry?.getComponent( + element.namespaceURI, + element.localName, + ) + ) { + // Has a component registered for the namespace/local name. + return true; + } + // No component registered for the namespace/local name. + // Warn in case this was an unintended mistake. + Logging.warn( + `No component registered for tag <${element.localName}> (namespace: ${element.namespaceURI})`, + ); + } + + if (element.nodeType === NODE_TYPE.TEXT_NODE) { + // Render non-empty text nodes, when wrapped inside a element + if (element.nodeValue) { + if ( + ((element.parentNode as Element)?.namespaceURI === + Namespaces.HYPERVIEW && + (element.parentNode as Element)?.localName === LOCAL_NAME.TEXT) || + (element.parentNode as Element)?.namespaceURI !== Namespaces.HYPERVIEW + ) { + if (options.preformatted) { + return true; + } + // When inline formatting context exists, lookup formatted value using node's index. + if (inlineFormattingContext) { + return true; + } + } + } + } + + if (element.nodeType === NODE_TYPE.CDATA_SECTION_NODE) { + return true; + } + return false; +}; diff --git a/src/services/render/index.tsx b/src/services/render/index.tsx index 8d1729128..6aa5dc319 100644 --- a/src/services/render/index.tsx +++ b/src/services/render/index.tsx @@ -1,15 +1,14 @@ import * as InlineContext from 'hyperview/src/services/inline-context'; -import * as Logging from 'hyperview/src/services/logging'; -import * as Namespaces from 'hyperview/src/services/namespaces'; import type { - HvComponent, HvComponentOnUpdate, HvComponentOptions, HvComponentProps, StyleSheets, } from 'hyperview/src/types'; import { LOCAL_NAME, NODE_TYPE } from 'hyperview/src/types'; +import HvElement from 'hyperview/src/core/components/hv-element'; import React from 'react'; +import { isRenderableElement } from 'hyperview/src/core/utils'; export const renderElement = ( element: Element | null | undefined, @@ -20,125 +19,40 @@ export const renderElement = ( if (!element) { return null; } - if (element.nodeType === NODE_TYPE.ELEMENT_NODE) { - // Hidden elements don't get rendered - if (element.getAttribute('hide') === 'true') { - return null; - } - } - if (element.nodeType === NODE_TYPE.COMMENT_NODE) { - // XML comments don't get rendered. - return null; - } - if ( - element.nodeType === NODE_TYPE.ELEMENT_NODE && - element.namespaceURI === Namespaces.HYPERVIEW - ) { - switch (element.localName) { - case LOCAL_NAME.BEHAVIOR: - case LOCAL_NAME.MODIFIER: - case LOCAL_NAME.STYLES: - case LOCAL_NAME.STYLE: - // Non-UI elements don't get rendered - return null; - default: - break; - } - } - // Initialize inline formatting context for elements when not already defined - let { inlineFormattingContext } = options; - if ( + const inlineFormattingContext = !options.preformatted && - !inlineFormattingContext && + !options.inlineFormattingContext && element.nodeType === NODE_TYPE.ELEMENT_NODE && element.localName === LOCAL_NAME.TEXT - ) { - inlineFormattingContext = InlineContext.formatter(element); - } - - if (element.nodeType === NODE_TYPE.ELEMENT_NODE) { - if (!element.namespaceURI) { - Logging.warn('`namespaceURI` missing for node:', element.toString()); - return null; - } - if (!element.localName) { - Logging.warn('`localName` missing for node:', element.toString()); - return null; - } - - const Component: - | HvComponent - | undefined = options.componentRegistry?.getComponent( - element.namespaceURI, - element.localName, - ); - - if (Component) { - // Prepare props for the component - const props = { - element, - onUpdate, - options: { - ...options, - inlineFormattingContext, - }, - stylesheets, - }; - - // Conditionally render the component with a key if it exists, to avoid - // warnings with current React versions, when the key attribute is set - // using the spread operator. - const key = element.getAttribute('key'); - - if (key) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; - } - return ; // eslint-disable-line react/jsx-props-no-spreading - } - - // No component registered for the namespace/local name. - // Warn in case this was an unintended mistake. - Logging.warn( - `No component registered for tag <${element.localName}> (namespace: ${element.namespaceURI})`, - ); - } - - if (element.nodeType === NODE_TYPE.TEXT_NODE) { - // Render non-empty text nodes, when wrapped inside a element - if (element.nodeValue) { - if ( - ((element.parentNode as Element)?.namespaceURI === - Namespaces.HYPERVIEW && - (element.parentNode as Element)?.localName === LOCAL_NAME.TEXT) || - (element.parentNode as Element)?.namespaceURI !== Namespaces.HYPERVIEW - ) { - if (options.preformatted) { - return element.nodeValue; - } - // When inline formatting context exists, lookup formatted value using node's index. - if (inlineFormattingContext) { - const index = inlineFormattingContext[0].indexOf(element); - return inlineFormattingContext[1][index]; - } + ? InlineContext.formatter(element) + : options.inlineFormattingContext; - // Other strings might be whitespaces in non text elements, which we ignore - // However we raise a warning when the string isn't just composed of whitespaces. - const trimmedValue = element.nodeValue.trim(); - if (trimmedValue.length > 0) { - Logging.warn( - `Text string "${trimmedValue}" must be rendered within a element`, - ); - } - } - } + // Check if the element is renderable before rendering the component + if (!isRenderableElement(element, options, inlineFormattingContext)) { + return null; } - if (element.nodeType === NODE_TYPE.CDATA_SECTION_NODE) { - return element.nodeValue; + const key = element.getAttribute?.('key'); + if (key && key !== '') { + return ( + + ); } - return null; + return ( + + ); }; export const renderChildren = ( @@ -180,3 +94,41 @@ export const renderChildNodes = ( } return children; }; + +/** + * Converts an element's childNodes into an array of HvElement components. + * @returns An array of HvElement components. + */ +export const buildChildArray = ( + element: Element, + onUpdate: HvComponentOnUpdate, + options: HvComponentOptions, + stylesheets: StyleSheets, +): Array | null | string> => { + if (!element || !element.childNodes) { + return []; + } + return Array.from(element.childNodes).map(node => { + const nodeElement = node as Element; + const key = nodeElement?.getAttribute?.('key'); + if (key && key !== '') { + return ( + + ); + } + return ( + + ); + }); +};