diff --git a/demo/backend/advanced/case-studies/basic-forms/index.xml.njk b/demo/backend/advanced/case-studies/basic-forms/index.xml.njk
index d9ddcd2c8..6eccf4c6d 100644
--- a/demo/backend/advanced/case-studies/basic-forms/index.xml.njk
+++ b/demo/backend/advanced/case-studies/basic-forms/index.xml.njk
@@ -22,6 +22,7 @@ hv_button_behavior: "back"
/>
+
+
+
+
+
+
+
diff --git a/demo/backend/advanced/case-studies/scroll-to-input/index.xml.njk b/demo/backend/advanced/case-studies/scroll-to-input/index.xml.njk
new file mode 100644
index 000000000..f8d061787
--- /dev/null
+++ b/demo/backend/advanced/case-studies/scroll-to-input/index.xml.njk
@@ -0,0 +1,44 @@
+---
+permalink: "/advanced/case-studies/scroll-to-input/index.xml"
+tags: "Advanced/Case Studies"
+hv_title: "Scroll to Input Offset"
+hv_button_behavior: "back"
+---
+{% extends 'templates/base.xml.njk' %}
+{% from 'macros/description/index.xml.njk' import description %}
+
+{% block styles %}
+ {% include './_styles.xml.njk' %}
+{% endblock %}
+
+{% block container %}
+
+
+{% endblock %}
diff --git a/demo/backend/advanced/case-studies/scroll-to-input/submit.xml b/demo/backend/advanced/case-studies/scroll-to-input/submit.xml
new file mode 100644
index 000000000..d27b06bf8
--- /dev/null
+++ b/demo/backend/advanced/case-studies/scroll-to-input/submit.xml
@@ -0,0 +1,18 @@
+
diff --git a/demo/backend/ui/ui-elements/forms/text-field/index.xml.njk b/demo/backend/ui/ui-elements/forms/text-field/index.xml.njk
index f28c53fd1..8e9834318 100644
--- a/demo/backend/ui/ui-elements/forms/text-field/index.xml.njk
+++ b/demo/backend/ui/ui-elements/forms/text-field/index.xml.njk
@@ -23,6 +23,6 @@ hv_button_behavior: "back"
{% endblock %}
{% block content %}
- {{ about('Text field are used in forms to collect textual data') }}
+ {{ about('Text fields are used in forms to collect textual data') }}
{% include './_form.xml.njk' %}
{% endblock %}
diff --git a/demo/src/Behaviors/AddStyles/AddStyles.ts b/demo/src/Behaviors/AddStyles/AddStyles.ts
index 84f0e85b0..161b85553 100644
--- a/demo/src/Behaviors/AddStyles/AddStyles.ts
+++ b/demo/src/Behaviors/AddStyles/AddStyles.ts
@@ -10,6 +10,44 @@ import {
shallowCloneToRoot,
} from 'hyperview/src/services';
+/**
+ * Checks if a style element has changed by comparing its attributes and children
+ */
+const hasStyleChanged = (oldStyle: Element, newStyle: Element): boolean => {
+ // Compare attributes
+ const oldAttrs = oldStyle.attributes ? Array.from(oldStyle.attributes) : [];
+ const newAttrs = newStyle.attributes ? Array.from(newStyle.attributes) : [];
+
+ if (oldAttrs.length !== newAttrs.length) {
+ return true;
+ }
+
+ // Check if any attribute values differ
+ const hasAttrChanged = newAttrs.some(
+ newAttr => oldStyle.getAttribute(newAttr.name) !== newAttr.value,
+ );
+ if (hasAttrChanged) {
+ return true;
+ }
+
+ // Compare children recursively
+ const oldChildren = oldStyle.childNodes
+ ? Array.from(oldStyle.childNodes)
+ : [];
+ const newChildren = newStyle.childNodes
+ ? Array.from(newStyle.childNodes)
+ : [];
+
+ if (oldChildren.length !== newChildren.length) {
+ return true;
+ }
+
+ // Check if any child elements differ
+ return newChildren.some((newChild, index) =>
+ hasStyleChanged(oldChildren[index] as Element, newChild as Element),
+ );
+};
+
/**
* This behavior allows injecting styles into the screen's styles element.
* It is useful when loading partial Hyperview documents that need their own styles.
@@ -33,17 +71,41 @@ export const AddStyles: HvBehavior = {
if (newStyles) {
const screen = getAncestorByTagName(element, 'screen');
if (screen) {
- const styles = Dom.getFirstTag(screen, 'styles');
- const styleElements = newStyles.getElementsByTagName('style');
+ const styles: Element | null | undefined = Dom.getFirstTag(
+ screen,
+ 'styles',
+ );
+ const styleElements = Array.from(
+ newStyles.getElementsByTagName('style'),
+ ).filter(e => !!e.getAttribute('id'));
if (styles && styleElements) {
- Array.from(styleElements).forEach(style => {
- styles.appendChild(style);
+ const existingStyles = Array.from(
+ styles.getElementsByTagName('style') || [],
+ );
+ let hasChanges = false;
+ styleElements.forEach(style => {
+ const styleId = style.getAttribute('id');
+ const existingStyle = existingStyles.find(
+ e => e.getAttribute('id') === styleId,
+ );
+ if (!existingStyle) {
+ styles.appendChild(style);
+ hasChanges = true;
+ } else if (style && hasStyleChanged(existingStyle, style)) {
+ styles.replaceChild(style, existingStyle);
+ hasChanges = true;
+ }
});
- const newRoot = shallowCloneToRoot(styles);
- updateRoot(newRoot, true);
+
+ if (hasChanges) {
+ // Only perform cloning if styles have changed
+ const newRoot: Document = shallowCloneToRoot(styles);
+ updateRoot(newRoot, true);
+ }
}
}
}
+
const ranOnce = element.getAttribute('ran-once');
const once = element.getAttribute('once');
if (once === 'true') {
diff --git a/docs/reference_view.md b/docs/reference_view.md
index 598fb14df..03de9f61d 100644
--- a/docs/reference_view.md
+++ b/docs/reference_view.md
@@ -111,9 +111,9 @@ An attribute indicating the direction in which the view will scroll.
#### `scroll-to-input-offset`
-| Type | Required |
-| ------ | ----------------------- |
-| number | No (defauls to **120**) |
+| Type | Required |
+| ------ | ------------------------ |
+| number | No (defaults to **120**) |
An attribute defining an additional scroll offset to be applied to the view, when a `` is focused. Only valid in combination with attribute `scroll` set to `"true"`.
diff --git a/schema/core.xsd b/schema/core.xsd
index 94e8a239c..ba0358eff 100644
--- a/schema/core.xsd
+++ b/schema/core.xsd
@@ -456,6 +456,7 @@
+
diff --git a/src/components/hv-section-list/index.tsx b/src/components/hv-section-list/index.tsx
index 9a6edb935..a2df802b2 100644
--- a/src/components/hv-section-list/index.tsx
+++ b/src/components/hv-section-list/index.tsx
@@ -13,50 +13,14 @@ import {
RefreshControl as DefaultRefreshControl,
Platform,
} from 'react-native';
-import { LOCAL_NAME, NODE_TYPE } from 'hyperview/src/types';
import React, { PureComponent } from 'react';
import type { ScrollParams, State } from './types';
import { createTestProps, getAncestorByTagName } from 'hyperview/src/services';
import { DOMParser } from '@instawork/xmldom';
import type { ElementRef } from 'react';
+import { FlatList } from 'hyperview/src/core/components/scroll';
import HvElement from 'hyperview/src/core/components/hv-element';
-import { SectionList } from 'hyperview/src/core/components/scroll';
-
-const getSectionIndex = (
- sectionTitle: Element,
- sectionTitles: HTMLCollectionOf,
-): number => {
- const sectionIndex = Array.from(sectionTitles).indexOf(sectionTitle);
-
- // If first section did not have an explicit title, we still need to account for it
- const previousElement = Dom.getPreviousNodeOfType(
- sectionTitles[0],
- NODE_TYPE.ELEMENT_NODE,
- );
- if ((previousElement as Element)?.localName === LOCAL_NAME.ITEM) {
- return sectionIndex + 1;
- }
-
- return sectionIndex;
-};
-
-const getPreviousSectionTitle = (
- element: Element,
- itemIndex: number,
-): [Element | null, number] => {
- const { previousSibling } = element;
- if (!previousSibling) {
- return [null, itemIndex];
- }
- if ((previousSibling as Element).localName === LOCAL_NAME.SECTION_TITLE) {
- return [previousSibling as Element, itemIndex];
- }
- if ((previousSibling as Element).localName === LOCAL_NAME.ITEM) {
- // eslint-disable-next-line no-param-reassign
- itemIndex += 1;
- }
- return getPreviousSectionTitle(previousSibling as Element, itemIndex);
-};
+import { LOCAL_NAME } from 'hyperview/src/types';
export default class HvSectionList extends PureComponent<
HvComponentProps,
@@ -72,13 +36,17 @@ export default class HvSectionList extends PureComponent<
parser: DOMParser = new DOMParser();
- ref: ElementRef | null = null;
+ startTime: number = Date.now();
+
+ renderCount: number = 0;
+
+ ref: ElementRef | null = null;
state: State = {
refreshing: false,
};
- onRef = (ref: ElementRef | null) => {
+ onRef = (ref: ElementRef | null) => {
this.ref = ref;
};
@@ -154,85 +122,29 @@ export default class HvSectionList extends PureComponent<
return;
}
- // find index of target in section-list
- // first, check legacy section-list format, where items are nested under a
- const targetElementParentSection = getAncestorByTagName(
- targetElement,
- LOCAL_NAME.SECTION,
+ // eslint-disable-next-line max-len
+ // No parent section? Check new section-list format, where items are nested under the section-list
+ const items = this.props.element.getElementsByTagNameNS(
+ Namespaces.HYPERVIEW,
+ LOCAL_NAME.ITEM,
);
- if (targetElementParentSection) {
- const sections = this.props.element.getElementsByTagNameNS(
- Namespaces.HYPERVIEW,
- LOCAL_NAME.SECTION,
- );
- const sectionIndex = Array.from(sections).indexOf(
- targetElementParentSection,
- );
- if (sectionIndex === -1) {
- return;
- }
- const itemsInSection = Array.from(
- targetElementParentSection.getElementsByTagNameNS(
- Namespaces.HYPERVIEW,
- LOCAL_NAME.ITEM,
- ),
- );
- const itemIndex = itemsInSection.indexOf(targetListItem);
- if (itemIndex === -1) {
- return;
- }
-
- const params: ScrollParams = {
- animated,
- itemIndex: itemIndex + 1,
- sectionIndex,
- };
- if (typeof viewOffset === 'number') {
- params.viewOffset = viewOffset;
- }
-
- if (typeof viewPosition === 'number') {
- params.viewPosition = viewPosition;
- }
+ const itemIndex = Array.from(items).indexOf(targetListItem);
+ if (itemIndex === -1) {
+ return;
+ }
- this.ref?.scrollToLocation(params);
- } else {
- // eslint-disable-next-line max-len
- // No parent section? Check new section-list format, where items are nested under the section-list
- const items = this.props.element.getElementsByTagNameNS(
- Namespaces.HYPERVIEW,
- LOCAL_NAME.ITEM,
- );
- if (Array.from(items).indexOf(targetListItem) === -1) {
- return;
- }
- const [sectionTitle, itemIndex] = getPreviousSectionTitle(
- targetListItem,
- 1, // 1 instead of 0 as it appears itemIndex is 1-based
- );
- const sectionTitles = this.props.element.getElementsByTagNameNS(
- Namespaces.HYPERVIEW,
- LOCAL_NAME.SECTION_TITLE,
- );
- const sectionIndex = sectionTitle
- ? getSectionIndex(sectionTitle, sectionTitles)
- : 0;
- if (sectionIndex === -1) {
- return;
- }
- const params: ScrollParams = {
- animated,
- itemIndex,
- sectionIndex,
- };
- if (viewOffset) {
- params.viewOffset = viewOffset;
- }
- if (viewPosition) {
- params.viewPosition = viewPosition;
- }
- this.ref?.scrollToLocation(params);
+ const params: ScrollParams = {
+ animated,
+ index: itemIndex,
+ };
+ if (viewOffset) {
+ params.viewOffset = viewOffset;
}
+ if (viewPosition) {
+ params.viewPosition = viewPosition;
+ }
+
+ this.ref?.scrollToIndex(params);
};
getStickySectionHeadersEnabled = (): boolean => {
@@ -280,7 +192,20 @@ export default class HvSectionList extends PureComponent<
});
};
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ renderListItem = (item: any) => {
+ return (
+
+ );
+ };
+
render() {
+ this.renderCount += 1;
const styleAttr = this.props.element.getAttribute('style');
const style = styleAttr
? styleAttr.split(' ').map(s => this.props.stylesheets.regular[s])
@@ -309,35 +234,21 @@ export default class HvSectionList extends PureComponent<
addNodes(this.props.element);
- let items = [];
- let titleElement = null;
- const sections: { data: Element[]; title: Element | null }[] = [];
+ const data: Element[] = [];
+ const headerIndeces: number[] = [];
for (let j = 0; j < flattened.length; j += 1) {
const sectionElement = flattened[j];
if (sectionElement) {
if (sectionElement.nodeName === LOCAL_NAME.ITEM) {
- items.push(sectionElement);
+ data.push(sectionElement);
} else if (sectionElement.nodeName === LOCAL_NAME.SECTION_TITLE) {
- if (items.length > 0) {
- sections.push({
- data: items,
- title: titleElement,
- });
- items = [];
- }
- titleElement = sectionElement;
+ headerIndeces.push(j);
+ data.push(sectionElement);
}
}
}
- if (items.length > 0) {
- sections.push({
- data: items,
- title: titleElement,
- });
- }
-
// Fix scrollbar rendering issue in iOS 13+
// https://github.com/facebook/react-native/issues/26610#issuecomment-539843444
const scrollIndicatorInsets =
@@ -347,6 +258,13 @@ export default class HvSectionList extends PureComponent<
const { testID, accessibilityLabel } = createTestProps(this.props.element);
+ console.log(
+ '>> SectionList render time:',
+ Date.now() - this.startTime,
+ 'render count:',
+ this.renderCount,
+ );
+
return (
{ContextRefreshControl => {
@@ -354,9 +272,10 @@ export default class HvSectionList extends PureComponent<
const hasRefreshTrigger =
this.props.element.getAttribute('trigger') === 'refresh';
return (
- (
-
- )}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- renderSectionHeader={({ section: { title } }: any): any => (
-
- )}
+ renderItem={this.renderListItem}
scrollIndicatorInsets={scrollIndicatorInsets}
- sections={sections}
- stickySectionHeadersEnabled={this.getStickySectionHeadersEnabled()}
+ stickyHeaderIndices={
+ this.getStickySectionHeadersEnabled()
+ ? headerIndeces
+ : undefined
+ }
style={style}
testID={testID}
/>
diff --git a/src/components/hv-section-list/types.ts b/src/components/hv-section-list/types.ts
index 5cc0419e9..ddf33f76c 100644
--- a/src/components/hv-section-list/types.ts
+++ b/src/components/hv-section-list/types.ts
@@ -5,8 +5,7 @@ export type State = {
// https://reactnative.dev/docs/sectionlist#scrolltolocation
export type ScrollParams = {
animated?: boolean | undefined;
- itemIndex: number;
- sectionIndex: number;
+ index: number;
viewOffset?: number;
viewPosition?: number;
};
diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx
index c1e66f8da..cb57c1dce 100644
--- a/src/components/hv-view/index.tsx
+++ b/src/components/hv-view/index.tsx
@@ -137,7 +137,7 @@ export default class HvView extends PureComponent {
const offsetStr = this.attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET];
if (offsetStr) {
const offset = parseInt(offsetStr, 10);
- return Number.isNaN(offset) ? 0 : defaultOffset;
+ return Number.isNaN(offset) ? defaultOffset : offset;
}
return defaultOffset;
};
diff --git a/src/core/components/hv-root/index.tsx b/src/core/components/hv-root/index.tsx
index bef922895..abdab37d5 100644
--- a/src/core/components/hv-root/index.tsx
+++ b/src/core/components/hv-root/index.tsx
@@ -27,6 +27,7 @@ import {
import React, { PureComponent } from 'react';
import HvRoute from 'hyperview/src/core/components/hv-route';
import { Linking } from 'react-native';
+import { XMLSerializer } from '@instawork/xmldom';
import { XNetworkRetryAction } from 'hyperview/src/services/dom/types';
/**
@@ -488,8 +489,19 @@ export default class Hyperview extends PureComponent {
onUpdateCallbacks.getDoc(),
behaviorElement,
);
+
if (targetElement) {
Services.setTimeoutId(targetElement, timeoutId.toString());
+ } else {
+ // Warn developers if the behavior element is not found
+ Logging.error(
+ `Cannot find a behavior element to perform "${action}". It may be missing an id.`,
+ Logging.deferredToString(() => {
+ return new XMLSerializer().serializeToString(
+ behaviorElement as Element,
+ );
+ }),
+ );
}
} else {
// If there's no delay, fetch immediately and update the doc when done.