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