From a28b8b39fb545356b40e81d97be033c46ec4b8b7 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 6 Jun 2025 10:25:00 -0400 Subject: [PATCH 1/7] chore: ts->tsx --- src/components/hv-image/{index.ts => index.tsx} | 0 src/components/hv-switch/{index.ts => index.tsx} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/components/hv-image/{index.ts => index.tsx} (100%) rename src/components/hv-switch/{index.ts => index.tsx} (100%) diff --git a/src/components/hv-image/index.ts b/src/components/hv-image/index.tsx similarity index 100% rename from src/components/hv-image/index.ts rename to src/components/hv-image/index.tsx diff --git a/src/components/hv-switch/index.ts b/src/components/hv-switch/index.tsx similarity index 100% rename from src/components/hv-switch/index.ts rename to src/components/hv-switch/index.tsx From 50ad455d5cb21678e36319a52a46568e1bbdaf79 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 6 Jun 2025 14:17:59 -0400 Subject: [PATCH 2/7] chore: migrate to functional components --- src/components/hv-image/index.tsx | 63 +++--- src/components/hv-option/index.tsx | 142 ++++++------ src/components/hv-option/types.ts | 4 - src/components/hv-picker-field/index.ios.tsx | 210 ++++++++--------- src/components/hv-picker-field/index.tsx | 226 +++++++++---------- src/components/hv-select-multiple/index.tsx | 167 +++++++------- src/components/hv-select-single/index.tsx | 179 +++++++-------- src/components/hv-switch/index.tsx | 123 +++++----- src/components/hv-text/index.tsx | 75 +++--- src/components/hv-view/View.tsx | 102 +++++++++ src/components/hv-view/index.tsx | 222 ++++++------------ src/components/hv-view/types.ts | 25 +- src/components/hv-web-view/index.tsx | 110 ++++----- 13 files changed, 820 insertions(+), 828 deletions(-) create mode 100644 src/components/hv-view/View.tsx diff --git a/src/components/hv-image/index.tsx b/src/components/hv-image/index.tsx index b0ccd740e..edda2c45d 100644 --- a/src/components/hv-image/index.tsx +++ b/src/components/hv-image/index.tsx @@ -4,43 +4,40 @@ import type { HvComponentProps, } from 'hyperview/src/types'; import { Image, ImageProps } from 'react-native'; -import React, { PureComponent } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; +import React from 'react'; 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; +const HvImage = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const { skipHref } = options || {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const imageProps: Record = {}; + let source = element.getAttribute('source'); + if (source) { + source = urlParse(source, options.screenUrl, true).toString(); + imageProps.source = { uri: source }; + } + const componentProps = { + ...createProps(element, stylesheets, options), + ...imageProps, + } as ImageProps; + const component = React.createElement(Image, componentProps); + return skipHref + ? component + : addHref( + component, + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + options, + ); +}; - static localName = LOCAL_NAME.IMAGE; +HvImage.namespaceURI = Namespaces.HYPERVIEW; +HvImage.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, - ); - } -} +export default HvImage; diff --git a/src/components/hv-option/index.tsx b/src/components/hv-option/index.tsx index 67313633a..83b6a7db2 100644 --- a/src/components/hv-option/index.tsx +++ b/src/components/hv-option/index.tsx @@ -5,10 +5,9 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import React, { useEffect, 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'; @@ -16,86 +15,79 @@ import { createProps } 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 = element.getAttribute('value'); + const selected = element.getAttribute('selected') === 'true'; + 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 = { + ...options, + pressed, + pressedSelected: pressed && selected, + selected, + } as const; + const componentProps = createProps(element, stylesheets, newOptions); - state: State = { - pressed: false, + // 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(() => setPressed(true)), + onPressOut: createEventHandler(() => setPressed(false)), + style: {}, }; + if (componentProps.style && componentProps.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: componentProps.style.flex }; + } - 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); + useEffect(() => { + if (selected && !prevSelected.current) { + Behaviors.trigger('select', element, onUpdate); } - if (!selected && prevSelected) { - Behaviors.trigger('deselect', this.props.element, this.props.onUpdate); + if (!selected && prevSelected.current) { + Behaviors.trigger('deselect', element, onUpdate); } - } - - render() { - const { onSelect, onToggle } = this.props.options; - - const value = this.props.element.getAttribute('value'); - const selected = this.props.element.getAttribute('selected') === 'true'; + prevSelected.current = selected; + }, [element, onUpdate, selected]); - // 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, - newOptions, - ); + // TODO: Replace with + return React.createElement( + TouchableWithoutFeedback, + outerProps, + React.createElement( + View, + componentProps, + ...Render.renderChildren( + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + newOptions, + ), + ), + ); +}; - // 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 }; - } +HvOption.namespaceURI = Namespaces.HYPERVIEW; +HvOption.localName = LOCAL_NAME.OPTION; - // 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, - ), - ), - ); - } -} +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/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index d768201cc..9b84d8ce5 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -5,7 +5,6 @@ import type { HvComponentProps, StyleSheet, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; import { createStyleProp, createTestProps, @@ -15,6 +14,7 @@ import Field from './field'; import { LOCAL_NAME } from 'hyperview/src/types'; import Modal from 'hyperview/src/core/components/modal'; import Picker from 'hyperview/src/core/components/picker'; +import React from 'react'; import { View } from 'react-native'; /** @@ -23,18 +23,13 @@ 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; - 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 getPickerInitialValue = (): string => { + const value = getValue(); + const pickerItems: Element[] = getPickerItems(); const valueExists = pickerItems.some( item => item.getAttribute('value') === value, ); @@ -49,14 +44,14 @@ export default class HvPickerField extends PureComponent { /** * Returns a string representing the value in the field. */ - getValue = (): string => this.props.element.getAttribute('value') || ''; + const getValue = (): string => element.getAttribute('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( + const getLabelForValue = (value: DOMString): string | null | undefined => { + const pickerItemElements: HTMLCollectionOf = element.getElementsByTagNameNS( Namespaces.HYPERVIEW, LOCAL_NAME.PICKER_ITEM, ); @@ -81,12 +76,12 @@ export default class HvPickerField extends PureComponent { /** * Returns a string representing the value in the picker. */ - getPickerValue = (): string => - this.props.element.getAttribute('picker-value') || ''; + const getPickerValue = (): string => + element.getAttribute('picker-value') || ''; - getPickerItems = (): Element[] => + const getPickerItems = (): Element[] => Array.from( - this.props.element.getElementsByTagNameNS( + element.getElementsByTagNameNS( Namespaces.HYPERVIEW, LOCAL_NAME.PICKER_ITEM, ), @@ -96,125 +91,120 @@ export default class HvPickerField extends PureComponent { * 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 = () => { + 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); }; /** * Hides the picker without applying the chosen value. */ - onCancel = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onCancel = () => { + 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); }; /** * 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 = () => { + 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); }; /** * Updates the picker value while keeping the picker open. */ - setPickerValue = (value: string) => { - const newElement = this.props.element.cloneNode(true) as Element; + const setPickerValue = (value: string) => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('picker-value', value); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); + onUpdate(null, 'swap', element, { newElement }); }; /** * 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 }); - } - - // 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} - - ); + const isFocused = (): boolean => element.getAttribute('focused') === 'true'; + + const style: Array = createStyleProp(element, stylesheets, { + ...options, + styleAttr: 'field-text-style', + }); + 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 ; + }); + + 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..65dd2842e 100644 --- a/src/components/hv-picker-field/index.tsx +++ b/src/components/hv-picker-field/index.tsx @@ -5,7 +5,6 @@ import type { HvComponentProps, StyleSheet, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; import { createStyleProp, createTestProps, @@ -13,6 +12,7 @@ import { } from 'hyperview/src/services'; import { LOCAL_NAME } from 'hyperview/src/types'; import Picker from 'hyperview/src/core/components/picker'; +import React from 'react'; import { View } from 'react-native'; /** @@ -21,168 +21,156 @@ 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 = (): string => element.getAttribute('value') || ''; /** * Returns a string representing the value in the picker. */ - getPickerValue = (): string => this.props.element.getAttribute('value') || ''; + const getPickerValue = (): string => element.getAttribute('value') || ''; - getPickerItems = (): Element[] => + const getPickerItems = (): Element[] => Array.from( - this.props.element.getElementsByTagNameNS( + element.getElementsByTagNameNS( Namespaces.HYPERVIEW, LOCAL_NAME.PICKER_ITEM, ), ); - onFocus = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onFocus = () => { + 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); }; - onBlur = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onBlur = () => { + 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); }; /** * Hides the picker without applying the chosen value. */ - onCancel = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onCancel = () => { + 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 }); }; /** * 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; + const onDone = (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'); - 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); } }; - render() { - const onChange = (value: string | null | undefined) => { - if (value === undefined) { - this.onCancel(); - } else { - this.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 onChange = (value: string | null | undefined) => { + if (value === undefined) { + onCancel(); + } else { + onDone(value || ''); } + }; - const fieldStyle: Array = createStyleProp( - this.props.element, - this.props.stylesheets, - { - ...this.props.options, - styleAttr: 'field-style', - }, - ); + const style: Array = createStyleProp(element, stylesheets, { + ...options, + styleAttr: 'field-text-style', + }); + 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 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 ( - - ); - }); - - // 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: Array = createStyleProp(element, stylesheets, { + ...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 = 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-select-multiple/index.tsx b/src/components/hv-select-multiple/index.tsx index 2410a2f0d..0ee7e487c 100644 --- a/src/components/hv-select-multiple/index.tsx +++ b/src/components/hv-select-multiple/index.tsx @@ -5,66 +5,27 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; import { createProps } 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( + const onToggle = (selectedValue?: DOMString | null) => { + const newElement = element.cloneNode(true) as Element; + const opts = newElement.getElementsByTagNameNS( Namespaces.HYPERVIEW, 'option', ); - for (let i = 0; i < options.length; i += 1) { - const option = options.item(i); + for (let i = 0; i < opts.length; i += 1) { + const option = opts.item(i); if (option) { const value = option.getAttribute('value'); if (value === selectedValue) { @@ -73,45 +34,85 @@ export default class HvSelectMultiple extends PureComponent { } } } - this.props.onUpdate('#', 'swap', this.props.element, { newElement }); + onUpdate('#', 'swap', element, { newElement }); }; - 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, - }, - ), - ); + if (element.getAttribute('hide') === 'true') { + return null; + } + const componentProps = createProps(element, stylesheets, { + ...options, + }); + + // TODO: Replace with + return React.createElement( + View, + componentProps, + ...Render.renderChildren( + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + { + ...options, + onToggle, + }, + ), + ); +}; + +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..2316f8eef 100644 --- a/src/components/hv-select-single/index.tsx +++ b/src/components/hv-select-single/index.tsx @@ -5,108 +5,109 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; import { createProps } from 'hyperview/src/services'; -export default class HvSelectSingle extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; - - 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 HvSelectSingle = (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 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]); + + if (element.getAttribute('hide') === 'true') { + return null; + } + const componentProps = createProps(element, stylesheets, { + ...options, + }); + + // TODO: Replace with + return React.createElement( + View, + componentProps, + ...Render.renderChildren( + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + { + ...options, + 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, - }, - ), - ); +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.tsx b/src/components/hv-switch/index.tsx index 048ff7249..807af26df 100644 --- a/src/components/hv-switch/index.tsx +++ b/src/components/hv-switch/index.tsx @@ -1,7 +1,6 @@ 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, @@ -9,6 +8,7 @@ import { import type { ColorValue } from './style-sheet'; import type { HvComponentProps } from 'hyperview/src/types'; import { LOCAL_NAME } from 'hyperview/src/types'; +import React from 'react'; import normalizeColor from './style-sheet'; /* eslint no-bitwise: ["error", { "allow": [">>", "&"] }] */ @@ -31,72 +31,71 @@ function darkenColor(color: ColorValue, percent: number): ColorValue { return `#${newRgb}${A}`; } -export default class HvSwitch extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; +const HvSwitch = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, stylesheets } = props; - static localName = LOCAL_NAME.SWITCH; + if (element.getAttribute('hide') === 'true') { + return null; + } - static getFormInputValues = (element: Element): Array<[string, string]> => { - return getNameValueFormInputValues(element); - }; + const unselectedStyle = StyleSheet.flatten( + createStyleProp(element, stylesheets, { + selected: false, + }), + ); + const selectedStyle = StyleSheet.flatten( + createStyleProp(element, stylesheets, { + selected: true, + }), + ); - render() { - if (this.props.element.getAttribute('hide') === 'true') { - return null; - } + const componentProps = { + 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', + }; - 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, - }), - ); + // android thumbColor default + if ( + Platform.OS === 'android' && + !componentProps.thumbColor && + componentProps.trackColor.true + ) { + componentProps.thumbColor = componentProps.value + ? darkenColor(componentProps.trackColor.true, 0.3) + : '#FFFFFF'; + } - 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 thumbColors are explicitly specified, override defaults + if (componentProps.value && selectedStyle?.color) { + componentProps.thumbColor = selectedStyle.color; + } else if (!componentProps.value && unselectedStyle?.color) { + componentProps.thumbColor = unselectedStyle.color; + } - // android thumbColor default - if ( - Platform.OS === 'android' && - !props.thumbColor && - props.trackColor.true - ) { - props.thumbColor = props.value - ? darkenColor(props.trackColor.true, 0.3) - : '#FFFFFF'; - } + return React.createElement(Switch, componentProps); +}; - // 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; - } +HvSwitch.namespaceURI = Namespaces.HYPERVIEW; +HvSwitch.localName = LOCAL_NAME.SWITCH; +HvSwitch.getFormInputValues = getNameValueFormInputValues; - return React.createElement(Switch, props); - } -} +export default HvSwitch; diff --git a/src/components/hv-text/index.tsx b/src/components/hv-text/index.tsx index cb6d253dd..d764818bb 100644 --- a/src/components/hv-text/index.tsx +++ b/src/components/hv-text/index.tsx @@ -4,55 +4,46 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; +import React from 'react'; import { Text } from 'react-native'; import { addHref } from 'hyperview/src/core/hyper-ref'; import { createProps } 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 = createProps(element, stylesheets, options); - Component = () => { - const props = createProps( - this.props.element, - this.props.stylesheets, - this.props.options, - ); + // TODO: Replace with + const component = React.createElement( + Text, + componentProps, + ...Render.renderChildren( + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + { + ...options, + preformatted: element.getAttribute('preformatted') === 'true', + }, + ), + ); - // 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', - }, - ), - ); - }; + 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/View.tsx b/src/components/hv-view/View.tsx new file mode 100644 index 000000000..f56fbd3ab --- /dev/null +++ b/src/components/hv-view/View.tsx @@ -0,0 +1,102 @@ +import * as Logging from 'hyperview/src/services/logging'; +import * as Render from 'hyperview/src/services/render'; +import { ATTRIBUTES, ViewProps } from './types'; +import { + KeyboardAvoidingView, + Platform, + SafeAreaView, + View, +} from 'react-native'; +import { + KeyboardAwareScrollView, + ScrollView, +} from 'hyperview/src/core/components/scroll'; +import React from 'react'; + +export default (props: ViewProps) => { + /** + * Useful when you want keyboard avoiding behavior in non-scrollable views. + * Note: Android has built-in support for avoiding keyboard. + */ + const keyboardAvoiding = + props.attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && + Platform.OS === 'ios'; + + const hasInputFields = props.hasInputFields(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputFieldRefs: Array = []; + const scrollable = props.attributes[ATTRIBUTES.SCROLL] === 'true'; + const safeArea = props.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( + props.element, + props.stylesheets, + props.onUpdate, + { + ...props.options, + ...(scrollable && hasInputFields + ? { + registerInputHandler: ref => { + if (ref !== null) { + inputFieldRefs.push(ref); + } + }, + } + : {}), + }, + ); + + /* eslint-disable react/jsx-props-no-spreading */ + if (scrollable) { + if (hasInputFields) { + // TODO: Replace with + return React.createElement( + KeyboardAwareScrollView, + { + element: props.element, + ...props.getCommonProps(), + ...props.getScrollViewProps(children), + ...props.getKeyboardAwareScrollViewProps(inputFieldRefs), + }, + ...children, + ); + } + // TODO: Replace with + return React.createElement( + ScrollView, + { + element: props.element, + ...props.getCommonProps(), + ...props.getScrollViewProps(children), + }, + ...children, + ); + } + if (!keyboardAvoiding && safeArea) { + // TODO: Replace with + return React.createElement( + SafeAreaView, + props.getCommonProps(), + ...children, + ); + } + if (keyboardAvoiding) { + // TODO: Replace with + return React.createElement( + KeyboardAvoidingView, + { + ...props.getCommonProps(), + behavior: 'position', + }, + ...children, + ); + } + // TODO: Replace with + return React.createElement(View, props.getCommonProps(), ...children); + /* eslint-enable react/jsx-props-no-spreading */ +}; diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index c1e66f8da..32f5196f9 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -1,7 +1,5 @@ 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, @@ -12,50 +10,31 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import { - KeyboardAvoidingView, - Platform, - SafeAreaView, - View, - ViewStyle, -} from 'react-native'; -import { - KeyboardAwareScrollView, - ScrollView, -} from 'hyperview/src/core/components/scroll'; -import React, { PureComponent } from 'react'; +import { Platform, ViewStyle } from 'react-native'; import { ATTRIBUTES } from './types'; import { LOCAL_NAME } from 'hyperview/src/types'; +import React from 'react'; +import View from './View'; 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 getAttributes = (): 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), + [name]: element.getAttribute(name), }), {}, ); - } + }; - hasInputFields = (): boolean => { - const textFields = this.props.element.getElementsByTagNameNS( + const hasInputFields = (): boolean => { + const textFields = element.getElementsByTagNameNS( Namespaces.HYPERVIEW, 'text-field', ); @@ -63,17 +42,17 @@ export default class HvView extends PureComponent { return textFields.length > 0; }; - getCommonProps = (): CommonProps => { + const 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, + element, + stylesheets, + options, ) as unknown) as ViewStyle; - const id = this.props.element.getAttribute('id'); + const id = element.getAttribute('id'); if (!id) { return { style }; } @@ -83,19 +62,18 @@ export default class HvView extends PureComponent { return { accessibilityLabel: id, style }; }; - getScrollViewProps = ( + const getScrollViewProps = ( children: Array | null | string>, ): ScrollViewProps => { + const attributes = getAttributes(); 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, + const contentContainerStyle = attributes[ATTRIBUTES.CONTENT_CONTAINER_STYLE] + ? createStyleProp(element, stylesheets, { + ...options, styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, }) : undefined; @@ -109,10 +87,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,7 +102,7 @@ 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, @@ -132,9 +110,9 @@ export default class HvView extends PureComponent { }; }; - getScrollToInputAdditionalOffsetProp = (): number => { + const getScrollToInputAdditionalOffsetProp = (): number => { const defaultOffset = 120; - const offsetStr = this.attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; + const offsetStr = getAttributes()[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; if (offsetStr) { const offset = parseInt(offsetStr, 10); return Number.isNaN(offset) ? 0 : defaultOffset; @@ -142,7 +120,7 @@ export default class HvView extends PureComponent { return defaultOffset; }; - getKeyboardAwareScrollViewProps = ( + const getKeyboardAwareScrollViewProps = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any inputFieldRefs: Array, ): KeyboardAwareScrollViewProps => ({ @@ -150,109 +128,43 @@ export default class HvView extends PureComponent { getTextInputRefs: () => inputFieldRefs, keyboardShouldPersistTaps: 'handled', scrollEventThrottle: 16, - scrollToInputAdditionalOffset: this.getScrollToInputAdditionalOffsetProp(), + scrollToInputAdditionalOffset: 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 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); - } - }, - } - : {}), - }, - ); - - /* eslint-disable react/jsx-props-no-spreading */ - if (scrollable) { - if (hasInputFields) { - // TODO: Replace with - return React.createElement( - KeyboardAwareScrollView, - { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), - ...this.getKeyboardAwareScrollViewProps(inputFieldRefs), - }, - ...children, - ); - } - // TODO: Replace with - return React.createElement( - ScrollView, - { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), - }, - ...children, - ); - } - if (!keyboardAvoiding && safeArea) { - // TODO: Replace with - return React.createElement( - SafeAreaView, - this.getCommonProps(), - ...children, - ); - } - if (keyboardAvoiding) { - // TODO: Replace with - return React.createElement( - KeyboardAvoidingView, - { - ...this.getCommonProps(), - behavior: 'position', - }, - ...children, + const content = ( + + ); + + return options?.skipHref + ? content + : addHref( + content, + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + options, ); - } - // TODO: Replace with - 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, - ) - ); - } -} +}; + +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-view/types.ts b/src/components/hv-view/types.ts index 68de6d102..65dc813ed 100644 --- a/src/components/hv-view/types.ts +++ b/src/components/hv-view/types.ts @@ -1,5 +1,11 @@ +import type { + DOMString, + HvComponentOnUpdate, + HvComponentOptions, + HvComponentProps, + StyleSheets, +} from 'hyperview/src/types'; import type { StyleProp, ViewStyle } from 'react-native'; -import type { DOMString } from 'hyperview/src/types'; export const ATTRIBUTES = { AVOID_KEYBOARD: 'avoid-keyboard', @@ -65,3 +71,20 @@ export type KeyboardAwareScrollViewProps = { scrollEventThrottle?: number | undefined; scrollToInputAdditionalOffset?: number | null | undefined; }; + +export type ViewProps = { + attributes: Attributes; + element: Element; + getCommonProps: () => CommonProps; + getKeyboardAwareScrollViewProps: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputFieldRefs: Array, + ) => KeyboardAwareScrollViewProps; + getScrollViewProps: ( + children: Array | null | string>, + ) => ScrollViewProps; + hasInputFields: () => boolean; + onUpdate: HvComponentOnUpdate; + options: HvComponentOptions; + stylesheets: StyleSheets; +}; diff --git a/src/components/hv-web-view/index.tsx b/src/components/hv-web-view/index.tsx index 756766c96..8709f2d52 100644 --- a/src/components/hv-web-view/index.tsx +++ b/src/components/hv-web-view/index.tsx @@ -1,22 +1,18 @@ 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, { 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'; -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; - - state = { - renderLoading: true, - }; - - onMessage = ( + const onMessage = ( event: { nativeEvent: { data: string; @@ -28,7 +24,7 @@ export default class HvWebView extends PureComponent { } if (event.nativeEvent.data === 'hv-web-view:render-loading:false') { - this.setState({ renderLoading: false }); + setRenderLoading(false); } const matches = event.nativeEvent.data.match(/^hyperview:(.*)$/); if (matches) { @@ -36,48 +32,52 @@ export default class HvWebView extends PureComponent { } }; - 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']; - 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} - /> - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const componentProps: any = createProps(element, stylesheets, options); + const allowsInlineMediaPlayback = componentProps[ + 'allows-inline-media-playback' + ] + ? componentProps['allows-inline-media-playback'] === 'true' + : undefined; + const color = componentProps['activity-indicator-color'] || '#8d9494'; + 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 = componentProps['shared-cookies-enabled'] + ? componentProps['shared-cookies-enabled'] === 'true' + : undefined; + const source = { + html: componentProps.html, + uri: componentProps.url, + } as const; + return ( + { + return renderLoading ? ( + + ) : ( + <> + ); + }} + sharedCookiesEnabled={sharedCookiesEnabled} + source={source} + startInLoadingState + testID={componentProps.testID} + /> + ); +}; + +HvWebView.namespaceURI = Namespaces.HYPERVIEW; +HvWebView.localName = LOCAL_NAME.WEB_VIEW; + +export default HvWebView; From 892066492b8b9eea5f9370187025579ca282d682 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 9 Jun 2025 10:14:26 -0400 Subject: [PATCH 3/7] chore: add hooks --- src/services/index.ts | 179 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 166 insertions(+), 13 deletions(-) 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, From 84ef65fdc18149d52e4bb5c97249b25f8d2d70e4 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 9 Jun 2025 10:32:26 -0400 Subject: [PATCH 4/7] chore: replace calls with hooks --- src/components/hv-date-field/field/index.tsx | 6 ++--- src/components/hv-image/index.tsx | 4 ++-- src/components/hv-option/index.tsx | 4 ++-- .../hv-picker-field/field-label/index.tsx | 4 ++-- .../hv-picker-field/field/index.tsx | 4 ++-- src/components/hv-picker-field/index.ios.tsx | 4 ++-- src/components/hv-picker-field/index.tsx | 6 ++--- src/components/hv-select-multiple/index.tsx | 9 ++++---- src/components/hv-select-single/index.tsx | 9 ++++---- src/components/hv-switch/index.tsx | 14 +++++------ src/components/hv-text-field/index.tsx | 23 ++++++++----------- src/components/hv-text/index.tsx | 4 ++-- src/components/hv-view/index.tsx | 23 +++++++++++-------- src/components/hv-web-view/index.tsx | 4 ++-- src/core/components/modal/index.tsx | 6 ++--- .../components/modal/modal-button/index.tsx | 4 ++-- 16 files changed, 65 insertions(+), 63 deletions(-) diff --git a/src/components/hv-date-field/field/index.tsx b/src/components/hv-date-field/field/index.tsx index 24dabbf3e..a052a871e 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'; @@ -15,7 +15,7 @@ export default (props: Props) => { const [pressed, setPressed] = useState(false); // Create the props (including styles) for the box of the input field. - const viewProps = createProps(props.element, props.stylesheets, { + const viewProps = useProps(props.element, props.stylesheets, { ...props.options, focused: props.focused, pressed, @@ -23,7 +23,7 @@ export default (props: Props) => { }); const labelStyle: StyleSheetType = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { + useStyleProp(props.element, props.stylesheets, { ...props.options, focused: props.focused, pressed, diff --git a/src/components/hv-image/index.tsx b/src/components/hv-image/index.tsx index edda2c45d..aa8a3ebd5 100644 --- a/src/components/hv-image/index.tsx +++ b/src/components/hv-image/index.tsx @@ -7,8 +7,8 @@ import { Image, ImageProps } from 'react-native'; import { LOCAL_NAME } from 'hyperview/src/types'; import React from 'react'; import { addHref } from 'hyperview/src/core/hyper-ref'; -import { createProps } from 'hyperview/src/services'; import urlParse from 'url-parse'; +import { useProps } from 'hyperview/src/services'; const HvImage = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment @@ -22,7 +22,7 @@ const HvImage = (props: HvComponentProps) => { imageProps.source = { uri: source }; } const componentProps = { - ...createProps(element, stylesheets, options), + ...useProps(element, stylesheets, options), ...imageProps, } as ImageProps; const component = React.createElement(Image, componentProps); diff --git a/src/components/hv-option/index.tsx b/src/components/hv-option/index.tsx index 83b6a7db2..419326557 100644 --- a/src/components/hv-option/index.tsx +++ b/src/components/hv-option/index.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; import { LOCAL_NAME } from 'hyperview/src/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. @@ -32,7 +32,7 @@ const HvOption = (props: HvComponentProps) => { pressedSelected: pressed && selected, selected, } as const; - const componentProps = createProps(element, stylesheets, newOptions); + const componentProps = useProps(element, stylesheets, newOptions); // Option renders as an outer TouchableWithoutFeedback view and inner view. // The outer view handles presses, the inner view handles styling. diff --git a/src/components/hv-picker-field/field-label/index.tsx b/src/components/hv-picker-field/field-label/index.tsx index 1a79e670d..9e7395ae5 100644 --- a/src/components/hv-picker-field/field-label/index.tsx +++ b/src/components/hv-picker-field/field-label/index.tsx @@ -3,7 +3,7 @@ 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 @@ -15,7 +15,7 @@ export default (props: Props) => { 'placeholderTextColor', ); const style: StyleSheetType = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { + useStyleProp(props.element, props.stylesheets, { ...props.options, focused: props.focused, pressed: props.pressed, diff --git a/src/components/hv-picker-field/field/index.tsx b/src/components/hv-picker-field/field/index.tsx index 7da7fa3e5..bc38e423d 100644 --- a/src/components/hv-picker-field/field/index.tsx +++ b/src/components/hv-picker-field/field/index.tsx @@ -2,7 +2,7 @@ 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. @@ -13,7 +13,7 @@ export default (props: Props) => { const [pressed, setPressed] = useState(false); // Create the props (including styles) for the box of the input field. - const viewProps = createProps(props.element, props.stylesheets, { + const viewProps = useProps(props.element, props.stylesheets, { ...props.options, focused: props.focused, pressed, diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index 9b84d8ce5..988c01458 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -6,9 +6,9 @@ import type { StyleSheet, } from 'hyperview/src/types'; import { - createStyleProp, createTestProps, getNameValueFormInputValues, + useStyleProp, } from 'hyperview/src/services'; import Field from './field'; import { LOCAL_NAME } from 'hyperview/src/types'; @@ -142,7 +142,7 @@ const HvPickerField = (props: HvComponentProps) => { */ const isFocused = (): boolean => element.getAttribute('focused') === 'true'; - const style: Array = createStyleProp(element, stylesheets, { + const style: Array = useStyleProp(element, stylesheets, { ...options, styleAttr: 'field-text-style', }); diff --git a/src/components/hv-picker-field/index.tsx b/src/components/hv-picker-field/index.tsx index 65dd2842e..140917d42 100644 --- a/src/components/hv-picker-field/index.tsx +++ b/src/components/hv-picker-field/index.tsx @@ -6,9 +6,9 @@ import type { StyleSheet, } from 'hyperview/src/types'; import { - createStyleProp, createTestProps, getNameValueFormInputValues, + useStyleProp, } from 'hyperview/src/services'; import { LOCAL_NAME } from 'hyperview/src/types'; import Picker from 'hyperview/src/core/components/picker'; @@ -93,7 +93,7 @@ const HvPickerField = (props: HvComponentProps) => { } }; - const style: Array = createStyleProp(element, stylesheets, { + const style: Array = useStyleProp(element, stylesheets, { ...options, styleAttr: 'field-text-style', }); @@ -107,7 +107,7 @@ const HvPickerField = (props: HvComponentProps) => { style.push({ color: placeholderTextColor }); } - const fieldStyle: Array = createStyleProp(element, stylesheets, { + const fieldStyle: Array = useStyleProp(element, stylesheets, { ...options, styleAttr: 'field-style', }); diff --git a/src/components/hv-select-multiple/index.tsx b/src/components/hv-select-multiple/index.tsx index 0ee7e487c..0255445e4 100644 --- a/src/components/hv-select-multiple/index.tsx +++ b/src/components/hv-select-multiple/index.tsx @@ -8,7 +8,7 @@ import type { import React, { useCallback, useEffect } 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'; const HvSelectMultiple = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment @@ -68,12 +68,13 @@ const HvSelectMultiple = (props: HvComponentProps) => { } }, [applyToAllOptions, element]); + const componentProps = useProps(element, stylesheets, { + ...options, + }); + if (element.getAttribute('hide') === 'true') { return null; } - const componentProps = createProps(element, stylesheets, { - ...options, - }); // TODO: Replace with return React.createElement( diff --git a/src/components/hv-select-single/index.tsx b/src/components/hv-select-single/index.tsx index 2316f8eef..4b4ed478c 100644 --- a/src/components/hv-select-single/index.tsx +++ b/src/components/hv-select-single/index.tsx @@ -8,7 +8,7 @@ import type { import React, { useCallback, useEffect } 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'; const HvSelectSingle = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment @@ -64,12 +64,13 @@ const HvSelectSingle = (props: HvComponentProps) => { } }, [element, onSelect]); + const componentProps = useProps(element, stylesheets, { + ...options, + }); + if (element.getAttribute('hide') === 'true') { return null; } - const componentProps = createProps(element, stylesheets, { - ...options, - }); // TODO: Replace with return React.createElement( diff --git a/src/components/hv-switch/index.tsx b/src/components/hv-switch/index.tsx index 807af26df..f133abeae 100644 --- a/src/components/hv-switch/index.tsx +++ b/src/components/hv-switch/index.tsx @@ -2,8 +2,8 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; import { Platform, StyleSheet, Switch } from 'react-native'; import { - createStyleProp, getNameValueFormInputValues, + useStyleProp, } from 'hyperview/src/services'; import type { ColorValue } from './style-sheet'; import type { HvComponentProps } from 'hyperview/src/types'; @@ -35,21 +35,21 @@ const HvSwitch = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, stylesheets } = props; - if (element.getAttribute('hide') === 'true') { - return null; - } - const unselectedStyle = StyleSheet.flatten( - createStyleProp(element, stylesheets, { + useStyleProp(element, stylesheets, { selected: false, }), ); const selectedStyle = StyleSheet.flatten( - createStyleProp(element, stylesheets, { + useStyleProp(element, stylesheets, { selected: true, }), ); + if (element.getAttribute('hide') === 'true') { + return null; + } + const componentProps = { ios_backgroundColor: unselectedStyle ? unselectedStyle.backgroundColor diff --git a/src/components/hv-text-field/index.tsx b/src/components/hv-text-field/index.tsx index 92995fbb0..54b6b4764 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,6 +11,14 @@ import TinyMask from 'hyperview/src/mask'; import debounce from 'lodash/debounce'; const HvTextField = (props: HvComponentProps) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const textInputRef: MutableRefObject = useRef( + null as TextInput | null, + ); + const elementProps = useProps(props.element, props.stylesheets, { + ...props.options, + focused: textInputRef.current?.isFocused(), + }); if (props.element.getAttribute('hide') === 'true') { return null; } @@ -64,11 +69,6 @@ const HvTextField = (props: HvComponentProps) => { 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 ( diff --git a/src/components/hv-text/index.tsx b/src/components/hv-text/index.tsx index d764818bb..77eae3af1 100644 --- a/src/components/hv-text/index.tsx +++ b/src/components/hv-text/index.tsx @@ -8,13 +8,13 @@ import { LOCAL_NAME } from 'hyperview/src/types'; import React from 'react'; 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'; const HvText = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, options, stylesheets } = props; - const componentProps = createProps(element, stylesheets, options); + const componentProps = useProps(element, stylesheets, options); // TODO: Replace with const component = React.createElement( diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index 32f5196f9..c00f312e5 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -16,7 +16,7 @@ import { LOCAL_NAME } from 'hyperview/src/types'; import React from 'react'; import View from './View'; import { addHref } from 'hyperview/src/core/hyper-ref'; -import { createStyleProp } from 'hyperview/src/services'; +import { useStyleProp } from 'hyperview/src/services'; const HvView = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment @@ -42,16 +42,17 @@ const HvView = (props: HvComponentProps) => { return textFields.length > 0; }; + const style = (useStyleProp( + element, + stylesheets, + options, + ) as unknown) as ViewStyle; + const 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( - element, - stylesheets, - options, - ) as unknown) as ViewStyle; const id = element.getAttribute('id'); if (!id) { return { style }; @@ -62,6 +63,11 @@ const HvView = (props: HvComponentProps) => { return { accessibilityLabel: id, style }; }; + const containerStyle = useStyleProp(element, stylesheets, { + ...options, + styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, + }); + const getScrollViewProps = ( children: Array | null | string>, ): ScrollViewProps => { @@ -72,10 +78,7 @@ const HvView = (props: HvComponentProps) => { attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; const contentContainerStyle = attributes[ATTRIBUTES.CONTENT_CONTAINER_STYLE] - ? createStyleProp(element, stylesheets, { - ...options, - styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, - }) + ? containerStyle : undefined; // Fix scrollbar rendering issue in iOS 13+ diff --git a/src/components/hv-web-view/index.tsx b/src/components/hv-web-view/index.tsx index 8709f2d52..5013a8041 100644 --- a/src/components/hv-web-view/index.tsx +++ b/src/components/hv-web-view/index.tsx @@ -5,7 +5,7 @@ import React, { 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'; const HvWebView = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment @@ -33,7 +33,7 @@ const HvWebView = (props: HvComponentProps) => { }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const componentProps: any = createProps(element, stylesheets, options); + const componentProps: any = useProps(element, stylesheets, options); const allowsInlineMediaPlayback = componentProps[ 'allows-inline-media-playback' ] diff --git a/src/core/components/modal/index.tsx b/src/core/components/modal/index.tsx index 822638e12..e1c1f0a48 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. @@ -30,7 +30,7 @@ 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( + const style: Array = useStyleProp( props.element, props.stylesheets, { @@ -44,7 +44,7 @@ export default (props: Props): JSX.Element => { const doneLabel: string = props.element.getAttribute('done-label') || 'Done'; const overlayStyle = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { + useStyleProp(props.element, props.stylesheets, { ...props.options, styleAttr: 'modal-overlay-style', }), diff --git a/src/core/components/modal/modal-button/index.tsx b/src/core/components/modal/modal-button/index.tsx index 3218bf598..87bea3298 100644 --- a/src/core/components/modal/modal-button/index.tsx +++ b/src/core/components/modal/modal-button/index.tsx @@ -3,7 +3,7 @@ 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. @@ -11,7 +11,7 @@ import { createStyleProp } from 'hyperview/src/services'; export default (props: Props) => { const [pressed, setPressed] = useState(false); - const style: Array = createStyleProp( + const style: Array = useStyleProp( props.element, props.stylesheets, { From 8507bb26e8cdea9163bed8bd81fb6009d57ad416 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 9 Jun 2025 11:05:16 -0400 Subject: [PATCH 5/7] chore: destructure props --- src/components/hv-date-field/field/index.tsx | 32 +++++--- .../hv-picker-field/field-label/index.tsx | 22 +++--- .../hv-picker-field/field/index.tsx | 30 +++++--- src/components/hv-text-field/index.tsx | 46 ++++++------ src/components/hv-view/View.tsx | 75 ++++++++++--------- src/core/components/modal/index.tsx | 60 ++++++++------- .../components/modal/modal-button/index.tsx | 22 +++--- 7 files changed, 156 insertions(+), 131 deletions(-) diff --git a/src/components/hv-date-field/field/index.tsx b/src/components/hv-date-field/field/index.tsx index a052a871e..f1427faf8 100644 --- a/src/components/hv-date-field/field/index.tsx +++ b/src/components/hv-date-field/field/index.tsx @@ -11,21 +11,31 @@ 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; // 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 = useProps(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( - useStyleProp(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, + useStyleProp(element, stylesheets, { + ...options, + focused, pressed, styleAttr: 'field-text-style', }), @@ -33,7 +43,7 @@ export default (props: Props) => { return ( setPressed(true)} onPressOut={() => setPressed(false)} > @@ -42,16 +52,16 @@ export default (props: Props) => { {formatter => ( )} - {props.children} + {children} ); diff --git a/src/components/hv-picker-field/field-label/index.tsx b/src/components/hv-picker-field/field-label/index.tsx index 9e7395ae5..74cac0859 100644 --- a/src/components/hv-picker-field/field-label/index.tsx +++ b/src/components/hv-picker-field/field-label/index.tsx @@ -10,29 +10,29 @@ import { useStyleProp } from 'hyperview/src/services'; * 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 placeholder = element.getAttribute('placeholder'); + const placeholderTextColor = element.getAttribute('placeholderTextColor'); const style: StyleSheetType = StyleSheet.flatten( - useStyleProp(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, - pressed: props.pressed, + useStyleProp(element, stylesheets, { + ...options, + focused, + pressed, 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 bc38e423d..3c8a26307 100644 --- a/src/components/hv-picker-field/field/index.tsx +++ b/src/components/hv-picker-field/field/index.tsx @@ -9,20 +9,30 @@ import { useProps } from 'hyperview/src/services'; * 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 = useProps(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-text-field/index.tsx b/src/components/hv-text-field/index.tsx index 54b6b4764..064ff1223 100644 --- a/src/components/hv-text-field/index.tsx +++ b/src/components/hv-text-field/index.tsx @@ -11,42 +11,42 @@ import TinyMask from 'hyperview/src/mask'; import debounce from 'lodash/debounce'; const HvTextField = (props: HvComponentProps) => { + // 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(props.element, props.stylesheets, { - ...props.options, + const elementProps = useProps(element, stylesheets, { + ...options, focused: textInputRef.current?.isFocused(), }); - if (props.element.getAttribute('hide') === 'true') { + 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); } }; @@ -54,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), [], ); @@ -62,10 +62,10 @@ 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); }; @@ -89,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-view/View.tsx b/src/components/hv-view/View.tsx index f56fbd3ab..efa3b497f 100644 --- a/src/components/hv-view/View.tsx +++ b/src/components/hv-view/View.tsx @@ -14,54 +14,59 @@ import { import React from 'react'; export default (props: ViewProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { + attributes, + element, + getCommonProps, + getScrollViewProps, + getKeyboardAwareScrollViewProps, + hasInputFields, + onUpdate, + options, + stylesheets, + } = props; /** * Useful when you want keyboard avoiding behavior in non-scrollable views. * Note: Android has built-in support for avoiding keyboard. */ const keyboardAvoiding = - props.attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && - Platform.OS === 'ios'; + attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && Platform.OS === 'ios'; - const hasInputFields = props.hasInputFields(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const inputFieldRefs: Array = []; - const scrollable = props.attributes[ATTRIBUTES.SCROLL] === 'true'; - const safeArea = props.attributes[ATTRIBUTES.SAFE_AREA] === 'true'; + 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( - props.element, - props.stylesheets, - props.onUpdate, - { - ...props.options, - ...(scrollable && hasInputFields - ? { - registerInputHandler: ref => { - if (ref !== null) { - inputFieldRefs.push(ref); - } - }, - } - : {}), - }, - ); + const children = Render.renderChildren(element, stylesheets, onUpdate, { + ...options, + ...(scrollable && hasInputFields() + ? { + registerInputHandler: ref => { + if (ref !== null) { + inputFieldRefs.push(ref); + } + }, + } + : {}), + }); /* eslint-disable react/jsx-props-no-spreading */ if (scrollable) { - if (hasInputFields) { + if (hasInputFields()) { // TODO: Replace with return React.createElement( KeyboardAwareScrollView, { - element: props.element, - ...props.getCommonProps(), - ...props.getScrollViewProps(children), - ...props.getKeyboardAwareScrollViewProps(inputFieldRefs), + element, + ...getCommonProps(), + ...getScrollViewProps(children), + ...getKeyboardAwareScrollViewProps(inputFieldRefs), }, ...children, ); @@ -70,33 +75,29 @@ export default (props: ViewProps) => { return React.createElement( ScrollView, { - element: props.element, - ...props.getCommonProps(), - ...props.getScrollViewProps(children), + element, + ...getCommonProps(), + ...getScrollViewProps(children), }, ...children, ); } if (!keyboardAvoiding && safeArea) { // TODO: Replace with - return React.createElement( - SafeAreaView, - props.getCommonProps(), - ...children, - ); + return React.createElement(SafeAreaView, getCommonProps(), ...children); } if (keyboardAvoiding) { // TODO: Replace with return React.createElement( KeyboardAvoidingView, { - ...props.getCommonProps(), + ...getCommonProps(), behavior: 'position', }, ...children, ); } // TODO: Replace with - return React.createElement(View, props.getCommonProps(), ...children); + return React.createElement(View, getCommonProps(), ...children); /* eslint-enable react/jsx-props-no-spreading */ }; diff --git a/src/core/components/modal/index.tsx b/src/core/components/modal/index.tsx index e1c1f0a48..d9b7ebb2f 100644 --- a/src/core/components/modal/index.tsx +++ b/src/core/components/modal/index.tsx @@ -19,33 +19,39 @@ import { useStyleProp } from 'hyperview/src/services'; * 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 = useStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - styleAttr: 'modal-style', - }, - ); + const style: Array = useStyleProp(element, stylesheets, { + ...options, + 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( - useStyleProp(props.element, props.stylesheets, { - ...props.options, + useStyleProp(element, stylesheets, { + ...options, styleAttr: 'modal-overlay-style', }), ); @@ -55,7 +61,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 +125,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 87bea3298..756d0b8ec 100644 --- a/src/core/components/modal/modal-button/index.tsx +++ b/src/core/components/modal/modal-button/index.tsx @@ -9,28 +9,26 @@ 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 [pressed, setPressed] = useState(false); - const style: Array = useStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - pressed, - styleAttr: 'modal-text-style', - }, - ); + const style: Array = useStyleProp(element, stylesheets, { + ...options, + pressed, + styleAttr: 'modal-text-style', + }); return ( setPressed(true)} onPressOut={() => setPressed(false)} > {/* eslint-disable-next-line react/jsx-props-no-spreading */} - - {props.label} + + {label} From 964ac8df50507b83b05fd4a55da800da0fa3d8b4 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 9 Jun 2025 12:21:09 -0400 Subject: [PATCH 6/7] chore: destructure options --- src/components/hv-date-field/field/index.tsx | 4 +++- src/components/hv-image/index.tsx | 4 ++-- .../hv-picker-field/field-label/index.tsx | 4 +++- src/components/hv-picker-field/index.ios.tsx | 6 +++++- src/components/hv-picker-field/index.tsx | 11 +++++++++-- src/components/hv-view/View.tsx | 2 ++ src/components/hv-view/index.tsx | 18 ++++++++++++------ src/core/components/modal/index.tsx | 10 ++++++++-- .../components/modal/modal-button/index.tsx | 5 ++++- 9 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/components/hv-date-field/field/index.tsx b/src/components/hv-date-field/field/index.tsx index f1427faf8..93a6e1d97 100644 --- a/src/components/hv-date-field/field/index.tsx +++ b/src/components/hv-date-field/field/index.tsx @@ -21,6 +21,7 @@ export default (props: Props) => { stylesheets, value, } = props; + const { pressedSelected, selected } = options; // Styles selected based on pressed state of the field. const [pressed, setPressed] = useState(false); @@ -34,9 +35,10 @@ export default (props: Props) => { const labelStyle: StyleSheetType = StyleSheet.flatten( useStyleProp(element, stylesheets, { - ...options, focused, pressed, + pressedSelected, + selected, styleAttr: 'field-text-style', }), ); diff --git a/src/components/hv-image/index.tsx b/src/components/hv-image/index.tsx index aa8a3ebd5..d9137cb69 100644 --- a/src/components/hv-image/index.tsx +++ b/src/components/hv-image/index.tsx @@ -13,12 +13,12 @@ import { useProps } from 'hyperview/src/services'; const HvImage = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, options, stylesheets } = props; - const { skipHref } = options || {}; + const { screenUrl, skipHref } = options; // eslint-disable-next-line @typescript-eslint/no-explicit-any const imageProps: Record = {}; let source = element.getAttribute('source'); if (source) { - source = urlParse(source, options.screenUrl, true).toString(); + source = urlParse(source, screenUrl, true).toString(); imageProps.source = { uri: source }; } const componentProps = { diff --git a/src/components/hv-picker-field/field-label/index.tsx b/src/components/hv-picker-field/field-label/index.tsx index 74cac0859..9838a74a0 100644 --- a/src/components/hv-picker-field/field-label/index.tsx +++ b/src/components/hv-picker-field/field-label/index.tsx @@ -12,13 +12,15 @@ import { useStyleProp } from 'hyperview/src/services'; export default (props: Props) => { // 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( useStyleProp(element, stylesheets, { - ...options, focused, pressed, + pressedSelected, + selected, styleAttr: 'field-text-style', }), ); diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index 988c01458..e02dda0fe 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -26,6 +26,7 @@ import { View } from 'react-native'; const HvPickerField = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, options, stylesheets } = props; + const { focused, pressed, pressedSelected, selected } = options; const getPickerInitialValue = (): string => { const value = getValue(); @@ -143,7 +144,10 @@ const HvPickerField = (props: HvComponentProps) => { const isFocused = (): boolean => element.getAttribute('focused') === 'true'; const style: Array = useStyleProp(element, stylesheets, { - ...options, + focused, + pressed, + pressedSelected, + selected, styleAttr: 'field-text-style', }); const { testID, accessibilityLabel } = createTestProps(element); diff --git a/src/components/hv-picker-field/index.tsx b/src/components/hv-picker-field/index.tsx index 140917d42..0d68c8201 100644 --- a/src/components/hv-picker-field/index.tsx +++ b/src/components/hv-picker-field/index.tsx @@ -24,6 +24,7 @@ import { View } from 'react-native'; 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. @@ -94,7 +95,10 @@ const HvPickerField = (props: HvComponentProps) => { }; const style: Array = useStyleProp(element, stylesheets, { - ...options, + focused, + pressed, + pressedSelected, + selected, styleAttr: 'field-text-style', }); const { testID, accessibilityLabel } = createTestProps(element); @@ -108,7 +112,10 @@ const HvPickerField = (props: HvComponentProps) => { } const fieldStyle: Array = useStyleProp(element, stylesheets, { - ...options, + focused, + pressed, + pressedSelected, + selected, styleAttr: 'field-style', }); diff --git a/src/components/hv-view/View.tsx b/src/components/hv-view/View.tsx index efa3b497f..2b80a407c 100644 --- a/src/components/hv-view/View.tsx +++ b/src/components/hv-view/View.tsx @@ -26,6 +26,8 @@ export default (props: ViewProps) => { options, stylesheets, } = props; + const { focused, pressed, pressedSelected, selected, styleAttr } = options; + /** * Useful when you want keyboard avoiding behavior in non-scrollable views. * Note: Android has built-in support for avoiding keyboard. diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index c00f312e5..f73fd664b 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -21,6 +21,7 @@ import { useStyleProp } from 'hyperview/src/services'; const HvView = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, options, stylesheets } = props; + const { focused, pressed, pressedSelected, selected, styleAttr } = options; const getAttributes = (): Attributes => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -42,11 +43,13 @@ const HvView = (props: HvComponentProps) => { return textFields.length > 0; }; - const style = (useStyleProp( - element, - stylesheets, - options, - ) as unknown) as ViewStyle; + const style = (useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr, + }) as unknown) as ViewStyle; const getCommonProps = (): CommonProps => { // TODO: fix type @@ -64,7 +67,10 @@ const HvView = (props: HvComponentProps) => { }; const containerStyle = useStyleProp(element, stylesheets, { - ...options, + focused, + pressed, + pressedSelected, + selected, styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, }); diff --git a/src/core/components/modal/index.tsx b/src/core/components/modal/index.tsx index d9b7ebb2f..9e36e4321 100644 --- a/src/core/components/modal/index.tsx +++ b/src/core/components/modal/index.tsx @@ -42,7 +42,10 @@ export default (props: Props): JSX.Element => { const contentOpacity = useRef(new Animated.Value(0)).current; const style: Array = useStyleProp(element, stylesheets, { - ...options, + focused, + pressed, + pressedSelected, + selected, styleAttr: 'modal-style', }); @@ -51,7 +54,10 @@ export default (props: Props): JSX.Element => { const overlayStyle = StyleSheet.flatten( useStyleProp(element, stylesheets, { - ...options, + focused, + pressed, + pressedSelected, + selected, styleAttr: 'modal-overlay-style', }), ); diff --git a/src/core/components/modal/modal-button/index.tsx b/src/core/components/modal/modal-button/index.tsx index 756d0b8ec..6162e24a2 100644 --- a/src/core/components/modal/modal-button/index.tsx +++ b/src/core/components/modal/modal-button/index.tsx @@ -11,11 +11,14 @@ import { useStyleProp } from 'hyperview/src/services'; 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 = useStyleProp(element, stylesheets, { - ...options, + focused, pressed, + pressedSelected, + selected, styleAttr: 'modal-text-style', }); From 2611fd95791a186f13227dad032bde61d9a1bd11 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 10 Jun 2025 11:37:03 -0400 Subject: [PATCH 7/7] chore: memoize FCs --- src/components/hv-image/index.tsx | 31 +-- src/components/hv-option/index.tsx | 119 ++++++---- src/components/hv-picker-field/index.ios.tsx | 196 ++++++++-------- src/components/hv-picker-field/index.tsx | 212 ++++++++++-------- src/components/hv-select-multiple/index.tsx | 61 ++--- src/components/hv-select-single/index.tsx | 30 ++- src/components/hv-switch/index.tsx | 118 ++++++---- src/components/hv-text/index.tsx | 30 +-- src/components/hv-view/View.tsx | 105 --------- src/components/hv-view/index.tsx | 224 +++++++++++++------ src/components/hv-view/types.ts | 25 +-- src/components/hv-web-view/index.tsx | 121 +++++----- 12 files changed, 684 insertions(+), 588 deletions(-) delete mode 100644 src/components/hv-view/View.tsx diff --git a/src/components/hv-image/index.tsx b/src/components/hv-image/index.tsx index d9137cb69..f2ce23af0 100644 --- a/src/components/hv-image/index.tsx +++ b/src/components/hv-image/index.tsx @@ -3,9 +3,9 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import { Image, ImageProps } from 'react-native'; +import React, { useMemo } from 'react'; +import { Image } from 'react-native'; import { LOCAL_NAME } from 'hyperview/src/types'; -import React from 'react'; import { addHref } from 'hyperview/src/core/hyper-ref'; import urlParse from 'url-parse'; import { useProps } from 'hyperview/src/services'; @@ -14,18 +14,21 @@ const HvImage = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, options, stylesheets } = props; const { screenUrl, skipHref } = options; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const imageProps: Record = {}; - let source = element.getAttribute('source'); - if (source) { - source = urlParse(source, screenUrl, true).toString(); - imageProps.source = { uri: source }; - } - const componentProps = { - ...useProps(element, stylesheets, options), - ...imageProps, - } as ImageProps; - const component = React.createElement(Image, componentProps); + 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( diff --git a/src/components/hv-option/index.tsx b/src/components/hv-option/index.tsx index 419326557..8d92992ee 100644 --- a/src/components/hv-option/index.tsx +++ b/src/components/hv-option/index.tsx @@ -5,7 +5,13 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { useEffect, useRef, useState } 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 { createEventHandler } from 'hyperview/src/core/hyper-ref'; @@ -20,44 +26,74 @@ const HvOption = (props: HvComponentProps) => { const { element, onUpdate, options, stylesheets } = props; const [pressed, setPressed] = useState(false); const { onSelect, onToggle } = options; - const value = element.getAttribute('value'); - const selected = element.getAttribute('selected') === 'true'; + const value = useMemo(() => element.getAttribute('value'), [element]); + const selected = useMemo(() => element.getAttribute('selected') === 'true', [ + element, + ]); const prevSelected = useRef(selected); // Updates options with pressed/selected state, so that child element can render // using the appropriate modifier styles. - const newOptions = { - ...options, - pressed, - pressedSelected: pressed && selected, - selected, - } as const; + const newOptions = useMemo(() => { + return { + ...options, + pressed, + pressedSelected: pressed && selected, + selected, + } as const; + }, [options, pressed, selected]); + const componentProps = useProps(element, stylesheets, newOptions); + 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, + ]); + + 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]); + + const handlePressIn = useCallback(() => setPressed(true), []); + const handlePressOut = useCallback(() => setPressed(false), []); + // 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(() => setPressed(true)), - onPressOut: createEventHandler(() => setPressed(false)), - style: {}, - }; - if (componentProps.style && componentProps.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: componentProps.style.flex }; - } + const outerProps = useMemo(() => { + return { + onPress: createEventHandler(handlePress, true), + onPressIn: createEventHandler(handlePressIn), + onPressOut: createEventHandler(handlePressOut), + + // 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]); useEffect(() => { if (selected && !prevSelected.current) { @@ -70,20 +106,13 @@ const HvOption = (props: HvComponentProps) => { prevSelected.current = selected; }, [element, onUpdate, selected]); - // TODO: Replace with - return React.createElement( - TouchableWithoutFeedback, - outerProps, - React.createElement( - View, - componentProps, - ...Render.renderChildren( - element, - stylesheets, - onUpdate as HvComponentOnUpdate, - newOptions, - ), - ), + const view = useMemo(() => { + return React.createElement(View, componentProps, ...children); + }, [componentProps, children]); + + return useMemo( + () => React.createElement(TouchableWithoutFeedback, outerProps, view), + [outerProps, view], ); }; diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index e02dda0fe..bbb71e1e2 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -1,10 +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 type { DOMString, HvComponentProps } from 'hyperview/src/types'; +import React, { useCallback, useMemo } from 'react'; import { createTestProps, getNameValueFormInputValues, @@ -14,7 +11,6 @@ import Field from './field'; import { LOCAL_NAME } from 'hyperview/src/types'; import Modal from 'hyperview/src/core/components/modal'; import Picker from 'hyperview/src/core/components/picker'; -import React from 'react'; import { View } from 'react-native'; /** @@ -28,9 +24,33 @@ const HvPickerField = (props: HvComponentProps) => { const { element, onUpdate, options, stylesheets } = props; const { focused, pressed, pressedSelected, selected } = options; - const getPickerInitialValue = (): string => { - const value = getValue(); - const pickerItems: Element[] = getPickerItems(); + /** + * Returns a string representing the value in the field. + */ + const value = useMemo((): string => element.getAttribute('value') || '', [ + 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], + ); + + const pickerInitialValue = useMemo((): string => { const valueExists = pickerItems.some( item => item.getAttribute('value') === value, ); @@ -40,83 +60,65 @@ const HvPickerField = (props: HvComponentProps) => { return pickerItems.length > 0 ? pickerItems[0].getAttribute('value') || '' : ''; - }; - - /** - * Returns a string representing the value in the field. - */ - const getValue = (): string => 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. */ - const getLabelForValue = (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; - }; - - /** - * Returns a string representing the value in the picker. - */ - const getPickerValue = (): string => - element.getAttribute('picker-value') || ''; - - const getPickerItems = (): Element[] => - Array.from( - 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. */ - const onFieldPress = () => { + const onFieldPress = useCallback(() => { const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'true'); - newElement.setAttribute('picker-value', getPickerInitialValue()); + 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. */ - const onCancel = () => { + const onCancel = useCallback(() => { const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); newElement.removeAttribute('picker-value'); onUpdate(null, 'swap', element, { newElement }); Behaviors.trigger('blur', newElement, onUpdate); - }; + }, [element, onUpdate]); /** * Hides the picker and applies the chosen value to the field. */ - const onDone = () => { - const pickerValue = getPickerValue(); - const value = getValue(); + const onDone = useCallback(() => { const newElement = element.cloneNode(true) as Element; newElement.setAttribute('value', pickerValue); newElement.removeAttribute('picker-value'); @@ -127,65 +129,81 @@ const HvPickerField = (props: HvComponentProps) => { Behaviors.trigger('change', newElement, onUpdate); } Behaviors.trigger('blur', newElement, onUpdate); - }; + }, [element, onUpdate, pickerValue, value]); /** * Updates the picker value while keeping the picker open. */ - const setPickerValue = (value: string) => { - const newElement = element.cloneNode(true) as Element; - newElement.setAttribute('picker-value', value); - onUpdate(null, 'swap', 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). */ - const isFocused = (): boolean => element.getAttribute('focused') === 'true'; + const isFocused = useMemo( + (): boolean => element.getAttribute('focused') === 'true', + [element], + ); - const style: Array = useStyleProp(element, stylesheets, { + const fieldTextStyle = useStyleProp(element, stylesheets, { focused, pressed, pressedSelected, selected, styleAttr: 'field-text-style', }); - 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 }); - } + + const placeholderTextColor: DOMString | null | undefined = useMemo( + () => element.getAttribute('placeholderTextColor'), + [element], + ); + + const style = useMemo(() => { + if (placeholderTextColor && [undefined, null, ''].includes(value)) { + return { ...fieldTextStyle, color: placeholderTextColor }; + } + 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 = 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 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 ; + }), + [pickerItems], + ); return ( - {isFocused() ? ( + {isFocused ? ( { {children} diff --git a/src/components/hv-picker-field/index.tsx b/src/components/hv-picker-field/index.tsx index 0d68c8201..3f561e63a 100644 --- a/src/components/hv-picker-field/index.tsx +++ b/src/components/hv-picker-field/index.tsx @@ -1,10 +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 type { DOMString, HvComponentProps } from 'hyperview/src/types'; +import React, { useCallback, useMemo } from 'react'; import { createTestProps, getNameValueFormInputValues, @@ -12,7 +9,6 @@ import { } from 'hyperview/src/services'; import { LOCAL_NAME } from 'hyperview/src/types'; import Picker from 'hyperview/src/core/components/picker'; -import React from 'react'; import { View } from 'react-native'; /** @@ -29,89 +25,125 @@ const HvPickerField = (props: HvComponentProps) => { /** * Returns a string representing the value in the field. */ - const getValue = (): string => element.getAttribute('value') || ''; + const value = useMemo((): string => element.getAttribute('value') || '', [ + element, + ]); /** * Returns a string representing the value in the picker. */ - const getPickerValue = (): string => element.getAttribute('value') || ''; - - const getPickerItems = (): Element[] => - Array.from( - 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], + ); - const onFocus = () => { + const onFocus = useCallback(() => { const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'true'); onUpdate(null, 'swap', element, { newElement }); Behaviors.trigger('focus', newElement, onUpdate); - }; + }, [element, onUpdate]); - const onBlur = () => { + const onBlur = useCallback(() => { const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); onUpdate(null, 'swap', element, { newElement }); Behaviors.trigger('blur', newElement, onUpdate); - }; + }, [element, onUpdate]); /** * Hides the picker without applying the chosen value. */ - const onCancel = () => { + const onCancel = useCallback(() => { const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); newElement.removeAttribute('picker-value'); onUpdate(null, 'swap', element, { newElement }); - }; + }, [element, onUpdate]); /** * Hides the picker and applies the chosen value to the field. */ - const onDone = (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); - } - }; + 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 = (value: string | null | undefined) => { - if (value === undefined) { - onCancel(); - } else { - onDone(value || ''); - } - }; + const onChange = useCallback( + (v: string | null | undefined) => { + if (v === undefined) { + onCancel(); + } else { + onDone(v || ''); + } + }, + [onCancel, onDone], + ); - const style: Array = useStyleProp(element, stylesheets, { + const fieldTextStyle = useStyleProp(element, stylesheets, { focused, pressed, pressedSelected, selected, styleAttr: 'field-text-style', }); - 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 }); - } - - const fieldStyle: Array = useStyleProp(element, stylesheets, { + + 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) { + return { + ...fieldTextStyle, + color: placeholderTextColor, + }; + } + 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, @@ -121,41 +153,45 @@ const HvPickerField = (props: HvComponentProps) => { // 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 children = useMemo(() => { + const items = pickerItems.reduce>((acc, item) => { + const l = item.getAttribute('label'); + const v = item.getAttribute('value'); + if (!l || typeof v !== 'string') { + return acc; + } + const enabled = ['', 'true', null].includes(item.getAttribute('enabled')); + 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 (needsEmptyOption) { + items.unshift( + , + ); } - const enabled = ['', 'true', null].includes(item.getAttribute('enabled')); - return ( - - ); - }); - // 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 items; + }, [pickerItems, needsEmptyOption, isFocused, placeholder]); return ( { onBlur={onBlur} onFocus={onFocus} onValueChange={onChange} - selectedValue={getPickerValue()} + selectedValue={pickerValue} style={style} > {children} diff --git a/src/components/hv-select-multiple/index.tsx b/src/components/hv-select-multiple/index.tsx index 0255445e4..7740640c3 100644 --- a/src/components/hv-select-multiple/index.tsx +++ b/src/components/hv-select-multiple/index.tsx @@ -5,7 +5,7 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; import { useProps } from 'hyperview/src/services'; @@ -18,24 +18,27 @@ const HvSelectMultiple = (props: HvComponentProps) => { * Callback passed to children. Option components invoke this callback when toggles. * Will update the XML DOM to toggle the option with the given value. */ - const onToggle = (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'); + 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'); + } } } - } - onUpdate('#', 'swap', element, { newElement }); - }; + onUpdate('#', 'swap', element, { newElement }); + }, + [element, onUpdate], + ); const applyToAllOptions = useCallback( (selected: boolean) => { @@ -72,15 +75,9 @@ const HvSelectMultiple = (props: HvComponentProps) => { ...options, }); - if (element.getAttribute('hide') === 'true') { - return null; - } - // TODO: Replace with - return React.createElement( - View, - componentProps, - ...Render.renderChildren( + const children = useMemo(() => { + return Render.renderChildren( element, stylesheets, onUpdate as HvComponentOnUpdate, @@ -88,8 +85,18 @@ const HvSelectMultiple = (props: HvComponentProps) => { ...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; diff --git a/src/components/hv-select-single/index.tsx b/src/components/hv-select-single/index.tsx index 4b4ed478c..847450721 100644 --- a/src/components/hv-select-single/index.tsx +++ b/src/components/hv-select-single/index.tsx @@ -5,7 +5,7 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; import { useProps } from 'hyperview/src/services'; @@ -14,6 +14,10 @@ const HvSelectSingle = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, options, stylesheets } = props; + 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 @@ -68,15 +72,9 @@ const HvSelectSingle = (props: HvComponentProps) => { ...options, }); - if (element.getAttribute('hide') === 'true') { - return null; - } - // TODO: Replace with - return React.createElement( - View, - componentProps, - ...Render.renderChildren( + const children = useMemo(() => { + return Render.renderChildren( element, stylesheets, onUpdate as HvComponentOnUpdate, @@ -84,8 +82,18 @@ const HvSelectSingle = (props: HvComponentProps) => { ...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; diff --git a/src/components/hv-switch/index.tsx b/src/components/hv-switch/index.tsx index f133abeae..cb44b5da0 100644 --- a/src/components/hv-switch/index.tsx +++ b/src/components/hv-switch/index.tsx @@ -1,6 +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, { useCallback, useMemo } from 'react'; import { getNameValueFormInputValues, useStyleProp, @@ -8,7 +9,6 @@ import { import type { ColorValue } from './style-sheet'; import type { HvComponentProps } from 'hyperview/src/types'; import { LOCAL_NAME } from 'hyperview/src/types'; -import React from 'react'; import normalizeColor from './style-sheet'; /* eslint no-bitwise: ["error", { "allow": [">>", "&"] }] */ @@ -35,63 +35,83 @@ const HvSwitch = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, stylesheets } = props; - const unselectedStyle = StyleSheet.flatten( - useStyleProp(element, stylesheets, { - selected: false, - }), - ); - const selectedStyle = StyleSheet.flatten( - useStyleProp(element, stylesheets, { - selected: true, - }), - ); + const hide = useMemo(() => element.getAttribute('hide') === 'true', [ + element, + ]); - if (element.getAttribute('hide') === 'true') { - return null; - } + const value = useMemo(() => element.getAttribute('value') === 'on', [ + element, + ]); - const componentProps = { - ios_backgroundColor: unselectedStyle - ? unselectedStyle.backgroundColor - : null, - onChange: () => { - const newElement = element.cloneNode(true) as Element; - Behaviors.trigger('change', newElement, onUpdate); - }, + 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 - onValueChange: (value: any) => { + (v: any) => { const newElement = element.cloneNode(true) as Element; - newElement.setAttribute('value', value ? 'on' : 'off'); + newElement.setAttribute('value', v ? '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', - }; - - // android thumbColor default - if ( - Platform.OS === 'android' && - !componentProps.thumbColor && - componentProps.trackColor.true - ) { - componentProps.thumbColor = componentProps.value - ? darkenColor(componentProps.trackColor.true, 0.3) - : '#FFFFFF'; - } + [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, + }; - // if thumbColors are explicitly specified, override defaults - if (componentProps.value && selectedStyle?.color) { - componentProps.thumbColor = selectedStyle.color; - } else if (!componentProps.value && unselectedStyle?.color) { - componentProps.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'; + } + + // 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 React.createElement(Switch, componentProps); + return view; }; HvSwitch.namespaceURI = Namespaces.HYPERVIEW; diff --git a/src/components/hv-text/index.tsx b/src/components/hv-text/index.tsx index 77eae3af1..69a07fdb6 100644 --- a/src/components/hv-text/index.tsx +++ b/src/components/hv-text/index.tsx @@ -4,8 +4,8 @@ import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; +import React, { useMemo } from 'react'; import { LOCAL_NAME } from 'hyperview/src/types'; -import React from 'react'; import { Text } from 'react-native'; import { addHref } from 'hyperview/src/core/hyper-ref'; import { useProps } from 'hyperview/src/services'; @@ -17,18 +17,22 @@ const HvText = (props: HvComponentProps) => { const componentProps = useProps(element, stylesheets, options); // TODO: Replace with - const component = React.createElement( - Text, - componentProps, - ...Render.renderChildren( - element, - stylesheets, - onUpdate as HvComponentOnUpdate, - { - ...options, - preformatted: element.getAttribute('preformatted') === 'true', - }, - ), + 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 || {}; diff --git a/src/components/hv-view/View.tsx b/src/components/hv-view/View.tsx deleted file mode 100644 index 2b80a407c..000000000 --- a/src/components/hv-view/View.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as Logging from 'hyperview/src/services/logging'; -import * as Render from 'hyperview/src/services/render'; -import { ATTRIBUTES, ViewProps } from './types'; -import { - KeyboardAvoidingView, - Platform, - SafeAreaView, - View, -} from 'react-native'; -import { - KeyboardAwareScrollView, - ScrollView, -} from 'hyperview/src/core/components/scroll'; -import React from 'react'; - -export default (props: ViewProps) => { - // eslint-disable-next-line react/destructuring-assignment - const { - attributes, - element, - getCommonProps, - getScrollViewProps, - getKeyboardAwareScrollViewProps, - hasInputFields, - onUpdate, - options, - stylesheets, - } = props; - const { focused, pressed, pressedSelected, selected, styleAttr } = options; - - /** - * 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'; - - // 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(element, stylesheets, onUpdate, { - ...options, - ...(scrollable && hasInputFields() - ? { - registerInputHandler: ref => { - if (ref !== null) { - inputFieldRefs.push(ref); - } - }, - } - : {}), - }); - - /* eslint-disable react/jsx-props-no-spreading */ - if (scrollable) { - if (hasInputFields()) { - // TODO: Replace with - return React.createElement( - KeyboardAwareScrollView, - { - element, - ...getCommonProps(), - ...getScrollViewProps(children), - ...getKeyboardAwareScrollViewProps(inputFieldRefs), - }, - ...children, - ); - } - // TODO: Replace with - return React.createElement( - ScrollView, - { - element, - ...getCommonProps(), - ...getScrollViewProps(children), - }, - ...children, - ); - } - 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 */ -}; diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index f73fd664b..b881770a3 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -1,20 +1,24 @@ 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 type { - Attributes, - CommonProps, - KeyboardAwareScrollViewProps, - ScrollViewProps, -} from './types'; +import * as Render from 'hyperview/src/services/render'; import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; -import { Platform, ViewStyle } from 'react-native'; +import { + KeyboardAvoidingView, + Platform, + SafeAreaView, + View, +} from 'react-native'; +import { + KeyboardAwareScrollView, + ScrollView, +} from 'hyperview/src/core/components/scroll'; +import React, { useCallback, useMemo, useRef } from 'react'; import { ATTRIBUTES } from './types'; import { LOCAL_NAME } from 'hyperview/src/types'; -import React from 'react'; -import View from './View'; import { addHref } from 'hyperview/src/core/hyper-ref'; import { useStyleProp } from 'hyperview/src/services'; @@ -22,40 +26,91 @@ 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>([]); - const getAttributes = (): Attributes => { + const attributes = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return Object.values(ATTRIBUTES).reduce>( - (attributes, name: string) => ({ - ...attributes, + (attrs, name: string) => ({ + ...attrs, [name]: element.getAttribute(name), }), {}, ); - }; + }, [element]); - const hasInputFields = (): boolean => { + const hasInputFields = useMemo(() => { const textFields = element.getElementsByTagNameNS( Namespaces.HYPERVIEW, 'text-field', ); return textFields.length > 0; - }; + }, [element]); - const style = (useStyleProp(element, stylesheets, { + const style = useStyleProp(element, stylesheets, { focused, pressed, pressedSelected, selected, styleAttr, - }) as unknown) as ViewStyle; + }); + + const containerStyle = useStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, + }); - const 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 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'; + + 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 }; @@ -64,20 +119,9 @@ const HvView = (props: HvComponentProps) => { return { style, testID: id }; } return { accessibilityLabel: id, style }; - }; - - const containerStyle = useStyleProp(element, stylesheets, { - focused, - pressed, - pressedSelected, - selected, - styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, - }); + }, [element, style]); - const getScrollViewProps = ( - children: Array | null | string>, - ): ScrollViewProps => { - const attributes = getAttributes(); + const scrollViewProps = useMemo(() => { const horizontal = attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; const showScrollIndicator = @@ -117,42 +161,84 @@ const HvView = (props: HvComponentProps) => { showsVerticalScrollIndicator: !horizontal && showScrollIndicator, stickyHeaderIndices, }; - }; - - const getScrollToInputAdditionalOffsetProp = (): number => { - const defaultOffset = 120; - const offsetStr = getAttributes()[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; - if (offsetStr) { - const offset = parseInt(offsetStr, 10); - return Number.isNaN(offset) ? 0 : defaultOffset; - } - return defaultOffset; - }; + }, [attributes, children, containerStyle, element]); - const getKeyboardAwareScrollViewProps = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - inputFieldRefs: Array, - ): KeyboardAwareScrollViewProps => ({ - automaticallyAdjustContentInsets: false, - getTextInputRefs: () => inputFieldRefs, - keyboardShouldPersistTaps: 'handled', - scrollEventThrottle: 16, - scrollToInputAdditionalOffset: getScrollToInputAdditionalOffsetProp(), - }); + 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 content = ( - - ); + 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 (viewConfig.scrollable) { + if (hasInputFields) { + // TODO: Replace with + return React.createElement( + KeyboardAwareScrollView, + { + element, + ...commonProps, + ...scrollViewProps, + ...keyboardAwareScrollViewProps, + }, + ...children, + ); + } + // TODO: Replace with + return React.createElement( + ScrollView, + { + element, + ...commonProps, + ...scrollViewProps, + }, + ...children, + ); + } + if (!viewConfig.keyboardAvoiding && viewConfig.safeArea) { + // TODO: Replace with + return React.createElement(SafeAreaView, commonProps, ...children); + } + if (viewConfig.keyboardAvoiding) { + // TODO: Replace with + return React.createElement( + KeyboardAvoidingView, + { + ...commonProps, + behavior: 'position', + }, + ...children, + ); + } + // TODO: Replace with + 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, + ]); return options?.skipHref ? content diff --git a/src/components/hv-view/types.ts b/src/components/hv-view/types.ts index 65dc813ed..68de6d102 100644 --- a/src/components/hv-view/types.ts +++ b/src/components/hv-view/types.ts @@ -1,11 +1,5 @@ -import type { - DOMString, - HvComponentOnUpdate, - HvComponentOptions, - HvComponentProps, - StyleSheets, -} from 'hyperview/src/types'; import type { StyleProp, ViewStyle } from 'react-native'; +import type { DOMString } from 'hyperview/src/types'; export const ATTRIBUTES = { AVOID_KEYBOARD: 'avoid-keyboard', @@ -71,20 +65,3 @@ export type KeyboardAwareScrollViewProps = { scrollEventThrottle?: number | undefined; scrollToInputAdditionalOffset?: number | null | undefined; }; - -export type ViewProps = { - attributes: Attributes; - element: Element; - getCommonProps: () => CommonProps; - getKeyboardAwareScrollViewProps: ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - inputFieldRefs: Array, - ) => KeyboardAwareScrollViewProps; - getScrollViewProps: ( - children: Array | null | string>, - ) => ScrollViewProps; - hasInputFields: () => boolean; - onUpdate: HvComponentOnUpdate; - options: HvComponentOptions; - stylesheets: StyleSheets; -}; diff --git a/src/components/hv-web-view/index.tsx b/src/components/hv-web-view/index.tsx index 5013a8041..a61a8f04e 100644 --- a/src/components/hv-web-view/index.tsx +++ b/src/components/hv-web-view/index.tsx @@ -1,7 +1,7 @@ import * as Events from 'hyperview/src/services/events'; import * as Namespaces from 'hyperview/src/services/namespaces'; import { ActivityIndicator, StyleSheet } from 'react-native'; -import React, { useState } 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'; @@ -12,67 +12,80 @@ const HvWebView = (props: HvComponentProps) => { const { element, options, stylesheets } = props; const [renderLoading, setRenderLoading] = useState(true); - const onMessage = ( - event: { - nativeEvent: { - data: string; - }; - } | null, - ) => { - if (!event) { - return; - } + const onMessage = useCallback( + ( + event: { + nativeEvent: { + data: string; + }; + } | null, + ) => { + if (!event) { + return; + } - 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]); - } - }; + 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]); + } + }, + [], + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const componentProps: any = useProps(element, stylesheets, options); - const allowsInlineMediaPlayback = componentProps[ - 'allows-inline-media-playback' - ] - ? componentProps['allows-inline-media-playback'] === 'true' - : undefined; - const color = componentProps['activity-indicator-color'] || '#8d9494'; - 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 = componentProps['shared-cookies-enabled'] - ? componentProps['shared-cookies-enabled'] === 'true' - : undefined; - const source = { - html: componentProps.html, - uri: componentProps.url, - } as const; + + 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");'; + } + 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 ( { - return renderLoading ? ( - - ) : ( - <> - ); - }} - sharedCookiesEnabled={sharedCookiesEnabled} - source={source} + renderLoading={onRenderLoading} + sharedCookiesEnabled={webViewProps.sharedCookiesEnabled} + source={webViewProps.source} startInLoadingState - testID={componentProps.testID} + testID={webViewProps.testID} /> ); };