@octane-xplat/ui 0.7.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +4 -3
  2. package/dist/native/Button.tsrx.js +1 -1
  3. package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
  4. package/dist/native/Markdown.tsrx.js +233 -0
  5. package/dist/native/Overlay.tsrx.js +15 -26
  6. package/dist/native/PinInput.tsrx.js +25 -7
  7. package/dist/native/Popover.tsrx.js +17 -18
  8. package/dist/native/Pressable.tsrx.js +4 -1
  9. package/dist/native/SearchInput.tsrx.js +6 -3
  10. package/dist/native/Sheet.tsrx.js +22 -37
  11. package/dist/native/TextArea.tsrx.js +6 -3
  12. package/dist/native/TextInput.tsrx.js +6 -3
  13. package/dist/native/VirtualList.tsrx.js +305 -121
  14. package/dist/native/index.shared.js +7 -5
  15. package/dist/native/keyboard-inset.mobile.js +5 -5
  16. package/dist/native/navigation-request.js +26 -0
  17. package/dist/native/octane-client-build.json +1 -1
  18. package/dist/native/overlay-lifetime.mobile.js +65 -0
  19. package/dist/native/pan.tsrx.js +16 -2
  20. package/dist/native/route-table.js +90 -10
  21. package/dist/native/route.js +26 -16
  22. package/dist/native/shared.js +7 -5
  23. package/dist/native/sheet-service.js +1 -1
  24. package/dist/native/svg.mobile.js +1 -1
  25. package/dist/native/text-focus.js +10 -0
  26. package/dist/native/text-write.js +18 -11
  27. package/dist/native/toast-anchor.tsrx.js +1 -1
  28. package/dist/native/ui.js +7 -6
  29. package/dist/native/vendor/ui-svg/index.android.js +2 -2
  30. package/dist/native/vendor/ui-svg/index.ios.js +2 -2
  31. package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
  32. package/dist/native/virtual-list-cell-pool.js +63 -0
  33. package/dist/web/Button.web.tsrx.js +1 -1
  34. package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
  35. package/dist/web/Markdown.tsrx.js +98 -0
  36. package/dist/web/Overlay.web.tsrx.js +81 -32
  37. package/dist/web/PinInput.web.tsrx.js +24 -10
  38. package/dist/web/Pressable.web.tsrx.js +169 -53
  39. package/dist/web/SearchInput.web.tsrx.js +2 -2
  40. package/dist/web/Sheet.web.tsrx.js +20 -7
  41. package/dist/web/TextArea.web.tsrx.js +22 -17
  42. package/dist/web/TextInput.web.tsrx.js +2 -2
  43. package/dist/web/VirtualList.web.tsrx.js +220 -109
  44. package/dist/web/WebView.web.tsrx.js +3 -3
  45. package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
  46. package/dist/web/modal-focus.web.js +95 -0
  47. package/dist/web/navigation-request.js +26 -0
  48. package/dist/web/octane-client-build.json +1 -1
  49. package/dist/web/pan.web.tsrx.js +27 -10
  50. package/dist/web/route-table.js +90 -10
  51. package/dist/web/route.web.js +88 -22
  52. package/dist/web/sheet-service.web.js +8 -0
  53. package/dist/web/theme/colorScheme.web.js +31 -4
  54. package/dist/web/ui.js +8 -6
  55. package/dist/web/virtual-list-cell-pool.js +63 -0
  56. package/package.json +21 -19
  57. package/platforms/android/native-api-usage.json +7 -7
  58. package/src/CommandPalette.tsrx +1 -0
  59. package/src/Grid.tsrx +2 -0
  60. package/src/Icon.macos.tsrx +1 -0
  61. package/src/KeyboardAvoiding.macos.tsrx +7 -1
  62. package/src/KeyboardAvoiding.mobile.tsrx +2 -2
  63. package/src/KeyboardAvoiding.tsrx +15 -0
  64. package/src/KeyboardAvoiding.web.tsrx +15 -0
  65. package/src/Markdown.tsrx +121 -0
  66. package/src/Meter.tsrx +1 -0
  67. package/src/Overlay.tsrx +15 -30
  68. package/src/Overlay.web.tsrx +12 -3
  69. package/src/PinInput.macos.tsrx +20 -6
  70. package/src/PinInput.mobile.test.ts +107 -0
  71. package/src/PinInput.tsrx +20 -6
  72. package/src/PinInput.web.test.tsrx +42 -0
  73. package/src/PinInput.web.tsrx +20 -6
  74. package/src/Popover.tsrx +17 -17
  75. package/src/Pressable.tsrx +1 -1
  76. package/src/Pressable.web.test.tsrx +36 -0
  77. package/src/Pressable.web.tsrx +37 -6
  78. package/src/RouteHost.macos.tsrx +1 -0
  79. package/src/SearchInput.tsrx +4 -3
  80. package/src/SearchInput.web.tsrx +1 -1
  81. package/src/Sheet.tsrx +20 -41
  82. package/src/Sheet.web.tsrx +9 -1
  83. package/src/Text.tsrx +2 -0
  84. package/src/TextArea.tsrx +6 -5
  85. package/src/TextArea.web.test.tsrx +39 -0
  86. package/src/TextArea.web.tsrx +7 -5
  87. package/src/TextInput.tsrx +6 -6
  88. package/src/TextInput.web.tsrx +1 -1
  89. package/src/VirtualList.macos.tsrx +93 -6
  90. package/src/VirtualList.mobile.test.tsrx +232 -0
  91. package/src/VirtualList.tsrx +285 -81
  92. package/src/VirtualList.web.tsrx +131 -25
  93. package/src/WebView.tsrx +1 -0
  94. package/src/WebView.web.tsrx +3 -0
  95. package/src/android/BottomNavigationView.android.tsrx +1 -0
  96. package/src/android/MaterialDialog.android.tsrx +1 -0
  97. package/src/anim.macos.tsrx +1 -0
  98. package/src/apply-native-modifiers.native.ts +4 -1
  99. package/src/child-array.ts +12 -7
  100. package/src/colorScheme.macos.ts +1 -0
  101. package/src/controls.macos.tsrx +4 -1
  102. package/src/data-driver.mobile.test.ts +120 -0
  103. package/src/data-lifecycle.mobile.test.ts +251 -0
  104. package/src/escape-props.ts +2 -0
  105. package/src/grid-auto-placement.ts +54 -12
  106. package/src/grid-placement.ts +1 -1
  107. package/src/hosted-unsupported.macos.tsrx +1 -0
  108. package/src/index.macos.ts +23 -3
  109. package/src/index.mobile.ts +0 -1
  110. package/src/index.shared.ts +22 -1
  111. package/src/index.shared.web.ts +291 -0
  112. package/src/index.web.ts +1 -1
  113. package/src/input-readiness.web.test.tsrx +82 -0
  114. package/src/ios/UITabBar.ios.tsrx +1 -0
  115. package/src/keyboard-inset.mobile.test.ts +62 -0
  116. package/src/keyboard-inset.mobile.ts +63 -43
  117. package/src/layout-shells.macos.tsrx +1 -5
  118. package/src/macos-extras.macos.tsrx +2 -0
  119. package/src/modal-focus.web.test.tsrx +63 -0
  120. package/src/modal-focus.web.ts +162 -0
  121. package/src/navigation-request.ts +37 -0
  122. package/src/overlay-lifecycle.mobile.test.ts +316 -0
  123. package/src/overlay-lifetime.mobile.ts +107 -0
  124. package/src/pan.mobile.test.tsrx +116 -0
  125. package/src/pan.tsrx +20 -2
  126. package/src/pan.web.tsrx +35 -7
  127. package/src/platformProps.ts +1 -0
  128. package/src/popover.mobile.test.ts +9 -1
  129. package/src/pressable-accessibility.mobile.test.ts +30 -0
  130. package/src/props.ts +163 -3
  131. package/src/refresh.ts +6 -1
  132. package/src/refresh.web.test.ts +77 -77
  133. package/src/route-android.test.ts +162 -0
  134. package/src/route-history.web.test.ts +80 -0
  135. package/src/route-navigation.web.test.ts +173 -0
  136. package/src/route-table.test.ts +188 -9
  137. package/src/route-table.ts +138 -10
  138. package/src/route.macos.ts +56 -13
  139. package/src/route.ts +54 -24
  140. package/src/route.web.ts +141 -24
  141. package/src/sheet-detents.ts +5 -3
  142. package/src/sheet-service.macos.ts +5 -1
  143. package/src/sheet-service.web.ts +8 -0
  144. package/src/sheet-snap.test.ts +23 -23
  145. package/src/sheet-snap.ts +6 -1
  146. package/src/svg.mobile.ts +9 -3
  147. package/src/tap-to-blur.ios.ts +48 -32
  148. package/src/tap-to-blur.mobile.ts +23 -15
  149. package/src/text-focus.ts +12 -0
  150. package/src/text-input.mobile.test.ts +89 -0
  151. package/src/text-write.ts +21 -11
  152. package/src/theme/colorScheme.linux.ts +2 -4
  153. package/src/theme/colorScheme.web.ts +51 -4
  154. package/src/theme/theme-scheme.macos.ts +8 -2
  155. package/src/theme/tokens.css +1736 -1730
  156. package/src/translate.macos.ts +4 -1
  157. package/src/virtual-list-cell-pool.test.ts +53 -0
  158. package/src/virtual-list-cell-pool.ts +81 -0
  159. package/src/virtual-list-layout.test.ts +11 -8
  160. package/src/virtual-list-layout.ts +4 -8
  161. package/src/windows.linux.ts +7 -2
  162. package/src/windows.macos.ts +1 -0
  163. package/types/generated/.tsrx-typegen-manifest.json +13 -3
  164. package/types/generated/KeyboardAvoiding.d.ts +3 -0
  165. package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
  166. package/types/generated/Markdown.d.ts +59 -0
  167. package/types/generated/TextArea.d.ts +2 -2
  168. package/types/generated/TextArea.web.d.ts +1 -1
  169. package/types/generated/TextInput.d.ts +2 -3
  170. package/types/generated/VirtualList.d.ts +3 -3
  171. package/types/generated/index.mobile.d.ts +0 -1
  172. package/types/generated/index.shared.d.ts +6 -3
  173. package/types/generated/index.shared.web.d.ts +114 -0
  174. package/types/generated/index.web.d.ts +1 -1
  175. package/types/generated/modal-focus.web.d.ts +3 -0
  176. package/types/generated/navigation-request.d.ts +10 -0
  177. package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
  178. package/types/generated/props.d.ts +111 -3
  179. package/types/generated/route-table.d.ts +14 -3
  180. package/types/generated/svg.mobile.d.ts +2 -2
  181. package/types/generated/text-focus.d.ts +3 -0
  182. package/types/generated/text-write.d.ts +3 -1
  183. package/types/generated/theme/colorScheme.web.d.ts +5 -0
  184. package/types/generated/virtual-list-cell-pool.d.ts +18 -0
  185. package/types/index.macos.d.ts +15 -3
  186. /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
  187. /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
  188. /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
  189. /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
  190. /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
  191. /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
  192. /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
  193. /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
  194. /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
  195. /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
@@ -0,0 +1,107 @@
1
+ import { afterEach, expect, it, vi } from 'vitest'
2
+ import {
3
+ createObjectContainer,
4
+ createObjectDriver,
5
+ createUniversalRoot,
6
+ flushUniversalSync,
7
+ } from 'octane/universal/native'
8
+
9
+ vi.mock('./escape-props', () => ({ applyEscapeProps() {} }))
10
+ vi.mock('./TextInput.tsrx', async () => {
11
+ const { defineUniversalComponent, universalPlan, universalValue, useRef } =
12
+ await import('octane/universal/native')
13
+
14
+ const plan = universalPlan('nativescript', {
15
+ kind: 'host',
16
+ type: 'test-input',
17
+ bindings: [
18
+ ['value', 0],
19
+ ['editable', 1],
20
+ ['cell', 2],
21
+ ],
22
+ })
23
+
24
+ return {
25
+ TextInput: defineUniversalComponent('nativescript', (props: any) => {
26
+ const handle = useRef({ focus: vi.fn(), blur: vi.fn(), native: {} })
27
+ props.bind?.(handle.current)
28
+ return universalValue(plan, [
29
+ props.value,
30
+ props.editable,
31
+ { onChange: props.onChange, focus: handle.current.focus },
32
+ ])
33
+ }),
34
+ }
35
+ })
36
+
37
+ import { PinInput } from './PinInput.tsrx'
38
+ const roots: any[] = []
39
+ function mount(props: any = {}) {
40
+ const container = createObjectContainer('nativescript')
41
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
42
+ roots.push(root)
43
+ root.render(PinInput as any, props)
44
+ flushUniversalSync(() => {})
45
+ const fields = (): any[] => {
46
+ const found: any[] = []
47
+ const walk = (node: any) => {
48
+ if (node.type === 'test-input') {
49
+ found.push(node)
50
+ }
51
+
52
+ for (const child of node.children ?? []) {
53
+ walk(child)
54
+ }
55
+ }
56
+
57
+ walk(container)
58
+ return found
59
+ }
60
+
61
+ const enter = (i: number, value: string) =>
62
+ flushUniversalSync(() => {
63
+ fields()[i].props.cell.onChange(value)
64
+ })
65
+
66
+ return { fields, enter }
67
+ }
68
+
69
+ afterEach(() => {
70
+ for (const root of roots.splice(0)) {
71
+ root.unmount()
72
+ }
73
+ })
74
+
75
+ it('clears a native controlled cell and its suffix', () => {
76
+ const change = vi.fn()
77
+ const { enter } = mount({ value: '1234', onValueChange: change })
78
+ enter(1, '')
79
+ expect(change).toHaveBeenCalledWith('1')
80
+ })
81
+
82
+ it('advances native focus after enabling the next cell and preserves handles across edits', async () => {
83
+ const complete = vi.fn()
84
+ const { fields, enter } = mount({ onComplete: complete })
85
+ expect(fields().map((field) => field.props.editable)).toEqual([true, false, false, false])
86
+ for (let i = 0; i < 4; i++) {
87
+ enter(i, String(i + 1))
88
+ await vi.waitFor(() => expect(fields()[i].props.value).toBe(String(i + 1)))
89
+ if (i < 3) {
90
+ await vi.waitFor(() => expect(fields()[i + 1].props.cell.focus).toHaveBeenCalled())
91
+ }
92
+ }
93
+
94
+ expect(complete).toHaveBeenCalledWith('1234')
95
+ enter(1, '')
96
+ await vi.waitFor(() => expect(fields().map((f) => f.props.value)).toEqual(['1', '', '', '']))
97
+ })
98
+
99
+ it('ignores disabled edits and edits beyond the next empty cell', () => {
100
+ const change = vi.fn()
101
+ const { enter } = mount({ onValueChange: change })
102
+ enter(3, '9')
103
+ expect(change).not.toHaveBeenCalled()
104
+ const disabled = mount({ disabled: true, value: '1234', onValueChange: change })
105
+ disabled.enter(1, '9')
106
+ expect(change).not.toHaveBeenCalled()
107
+ })
package/src/PinInput.tsrx CHANGED
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @nativescript-community/octane */
2
- import { useState } from 'octane';
2
+ import { useEffect, useRef, useState } from 'octane';
3
3
  import type { PinInputProps, TextInputHandle } from "./props";
4
4
  import { cx } from "./cx";
5
5
  import { applyEscapeProps } from "./escape-props";
@@ -12,7 +12,15 @@ export function PinInput(props: PinInputProps) @{
12
12
  const [internal, setInternal] = useState('');
13
13
  const controlled = props.value !== undefined;
14
14
  const value = controlled ? props.value! : internal;
15
- const handles: (TextInputHandle | null)[] = [];
15
+ const handles = useRef<(TextInputHandle | null)[]>([]);
16
+ const pendingFocus = useRef<number | null>(null);
17
+ useEffect(() => {
18
+ const next = pendingFocus.current;
19
+ if (next !== null && next <= value.length) {
20
+ pendingFocus.current = null;
21
+ handles.current[next]?.focus();
22
+ }
23
+ }, [value]);
16
24
 
17
25
  const setValue = (next: string) => {
18
26
  const clipped = next.slice(0, length);
@@ -24,10 +32,16 @@ export function PinInput(props: PinInputProps) @{
24
32
  const cellValue = (i: number) => value[i] ?? '';
25
33
 
26
34
  const enter = (i: number, text: string) => {
35
+ if (props.disabled || i > value.length) return;
27
36
  const ch = text.slice(-1);
28
- const next = (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length);
37
+ const next = ch ? (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length) : value.slice(0, i);
38
+ pendingFocus.current = ch && i < length - 1 ? i + 1 : null;
29
39
  setValue(next);
30
- if (ch && i < length - 1) handles[i + 1]?.focus();
40
+ if (pendingFocus.current !== null && pendingFocus.current <= value.length) {
41
+ const index = pendingFocus.current;
42
+ pendingFocus.current = null;
43
+ handles.current[index]?.focus();
44
+ }
31
45
  };
32
46
 
33
47
  <flexboxlayout ref={(v: any) => applyEscapeProps(v, props)} id={props.id} flexDirection="row" className={cx('vx-pininput', props.disabled && 'vx-pininput--disabled', props.className)} style={props.style}>
@@ -36,10 +50,10 @@ export function PinInput(props: PinInputProps) @{
36
50
  className="vx-pininput-cell"
37
51
  keyboardType="number"
38
52
  secure={props.secure}
39
- editable={!props.disabled}
53
+ editable={!props.disabled && i <= value.length}
40
54
  value={cellValue(i)}
41
55
  onChange={(text: string) => enter(i, text)}
42
- bind={(h: TextInputHandle) => { handles[i] = h; }}
56
+ bind={(h: TextInputHandle) => { handles.current[i] = h; }}
43
57
  />
44
58
  }
45
59
  </flexboxlayout>
@@ -0,0 +1,42 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { act, createRoot } from 'octane'
4
+ import { PinInput } from './PinInput.web.tsrx'
5
+ const roots:any[]=[]
6
+ function mount(props:any={}) {
7
+ const el=document.createElement('div');document.body.append(el);const root=createRoot(el);roots.push(root)
8
+ const render=(next:any={}) => act(() => root.render(<PinInput {...props} {...next} />));render()
9
+ const inputs=() => Array.from(el.querySelectorAll('input'))
10
+ const enter=(i:number,value:string) => {const field=inputs()[i];field.value=value;act(() => field.dispatchEvent(new Event('input',{bubbles:true})))}
11
+ return {render,inputs,enter}
12
+ }
13
+
14
+ afterEach(() => {for (const root of roots.splice(0)) act(() => root.unmount());document.body.innerHTML=''})
15
+ it('clears the edited cell and suffix of a controlled PIN', () => {
16
+ const change=vi.fn(),complete=vi.fn();const {enter}=mount({value:'1234',onValueChange:change,onComplete:complete});enter(1,'')
17
+ expect(change).toHaveBeenCalledWith('1');expect(complete).not.toHaveBeenCalled()
18
+ })
19
+
20
+ it('keeps editing contiguous and advances focus through an uncontrolled PIN', () => {
21
+ const change=vi.fn(),complete=vi.fn();const {inputs,enter}=mount({onValueChange:change,onComplete:complete})
22
+ expect(inputs().map(i => i.readOnly)).toEqual([false,true,true,true])
23
+ for (let i=0;i<4;i++) {enter(i,String(i+1));if (i<3) expect(document.activeElement).toBe(inputs()[i+1])}
24
+ expect(inputs().map(i=>i.value)).toEqual(['1','2','3','4']);expect(complete).toHaveBeenCalledTimes(1);expect(complete).toHaveBeenCalledWith('1234')
25
+ enter(1,'');expect(inputs().map(i=>i.value)).toEqual(['1','','','']);expect(change).toHaveBeenLastCalledWith('1')
26
+ enter(1,'5');expect(document.activeElement).toBe(inputs()[2]);expect(inputs().map(i=>i.value)).toEqual(['1','5','',''])
27
+ expect(complete).toHaveBeenCalledTimes(1)
28
+ })
29
+
30
+ it('replaces a filled cell without shifting later cells', () => {
31
+ const change=vi.fn(),complete=vi.fn();const {enter}=mount({value:'1234',onValueChange:change,onComplete:complete});enter(1,'29')
32
+ expect(change).toHaveBeenCalledWith('1934');expect(complete).toHaveBeenCalledWith('1934')
33
+ })
34
+
35
+ it('does not relocate input from a later empty cell to the beginning', () => {
36
+ const change=vi.fn();const {enter}=mount({onValueChange:change});enter(3,'9');expect(change).not.toHaveBeenCalled()
37
+ })
38
+
39
+ it('respects disabled cells and controlled updates', () => {
40
+ const {render,inputs}=mount({value:'1234',disabled:true});expect(inputs().every(i=>i.readOnly)).toBe(true)
41
+ render({value:'12',disabled:false});expect(inputs().map(i=>i.value)).toEqual(['1','2','','']);expect(inputs().map(i=>i.readOnly)).toEqual([false,false,false,true])
42
+ })
@@ -1,4 +1,4 @@
1
- import { useState } from 'octane';
1
+ import { useEffect, useRef, useState } from 'octane';
2
2
  import type { PinInputProps, TextInputHandle } from "./props";
3
3
  import { applyEscapeProps } from "./escape-props";
4
4
  import { TextInput } from './TextInput.web.tsrx';
@@ -10,7 +10,15 @@ export function PinInput(props: PinInputProps) @{
10
10
  const [internal, setInternal] = useState('');
11
11
  const controlled = props.value !== undefined;
12
12
  const value = controlled ? props.value! : internal;
13
- const handles: (TextInputHandle | null)[] = [];
13
+ const handles = useRef<(TextInputHandle | null)[]>([]);
14
+ const pendingFocus = useRef<number | null>(null);
15
+ useEffect(() => {
16
+ const next = pendingFocus.current;
17
+ if (next !== null && next <= value.length) {
18
+ pendingFocus.current = null;
19
+ handles.current[next]?.focus();
20
+ }
21
+ }, [value]);
14
22
 
15
23
  const setValue = (next: string) => {
16
24
  const clipped = next.slice(0, length);
@@ -22,10 +30,16 @@ export function PinInput(props: PinInputProps) @{
22
30
  const cellValue = (i: number) => value[i] ?? '';
23
31
 
24
32
  const enter = (i: number, text: string) => {
33
+ if (props.disabled || i > value.length) return;
25
34
  const ch = text.slice(-1);
26
- const next = (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length);
35
+ const next = ch ? (value.slice(0, i) + ch + value.slice(i + 1)).slice(0, length) : value.slice(0, i);
36
+ pendingFocus.current = ch && i < length - 1 ? i + 1 : null;
27
37
  setValue(next);
28
- if (ch && i < length - 1) handles[i + 1]?.focus();
38
+ if (pendingFocus.current !== null && pendingFocus.current <= value.length) {
39
+ const index = pendingFocus.current;
40
+ pendingFocus.current = null;
41
+ handles.current[index]?.focus();
42
+ }
29
43
  };
30
44
 
31
45
  <div ref={(e: any) => applyEscapeProps(e, props)} id={props.id} className={['vx-pininput', props.disabled ? 'vx-pininput--disabled' : undefined, props.className]} style={props.style} role="group">
@@ -35,10 +49,10 @@ export function PinInput(props: PinInputProps) @{
35
49
  className="vx-pininput-cell"
36
50
  keyboardType="number"
37
51
  secure={props.secure}
38
- editable={!props.disabled}
52
+ editable={!props.disabled && i <= value.length}
39
53
  value={cellValue(i)}
40
54
  onChange={(text: string) => enter(i, text)}
41
- bind={(h: TextInputHandle) => { handles[i] = h; }}
55
+ bind={(h: TextInputHandle) => { handles.current[i] = h; }}
42
56
  />
43
57
  ))}
44
58
  </div>
package/src/Popover.tsrx CHANGED
@@ -9,6 +9,7 @@ import { cx } from './cx';
9
9
  import { positionPopover, type PopoverPosition } from './popover-position';
10
10
  import { applyThemeClasses, useThemeScheme } from './theme/theme-scheme';
11
11
  import { rootLayoutFor } from './root-layout.mobile';
12
+ import { createOverlayLifetime } from './overlay-lifetime.mobile';
12
13
 
13
14
  function PopoverLayer(props: PopoverProps & { anchorView: any }) @{
14
15
  const panelRef = useRef<any>(null);
@@ -38,6 +39,7 @@ function PopoverLayer(props: PopoverProps & { anchorView: any }) @{
38
39
  },
39
40
  props.placement ?? 'bottom',
40
41
  );
42
+
41
43
  const insets = isIOS ? panelRef.current?.parent?.getSafeAreaInsets?.() : undefined;
42
44
  const scale = isIOS ? Number(Screen.mainScreen.scale) || 1 : 1;
43
45
  const panelPosition = {
@@ -45,6 +47,7 @@ function PopoverLayer(props: PopoverProps & { anchorView: any }) @{
45
47
  left: next.left - Number(insets?.left ?? 0) / scale,
46
48
  top: next.top - Number(insets?.top ?? 0) / scale,
47
49
  };
50
+
48
51
  setPosition((current) =>
49
52
  current?.left === panelPosition.left &&
50
53
  current?.top === panelPosition.top &&
@@ -125,22 +128,15 @@ function PopoverLayer(props: PopoverProps & { anchorView: any }) @{
125
128
  * Overlay: a transparent host opened on the RootLayout enclosing the
126
129
  * anchor, with PopoverLayer positioning itself over the anchor's rect. */
127
130
  export function Popover(props: PopoverProps) @{
128
- const shown = useRef<{ host: any; root: any; unbindTheme?: () => void } | null>(null);
131
+ const shown = useRef<{ host: any; root: any; lifetime: ReturnType<typeof createOverlayLifetime> } | null>(null);
132
+
133
+ const dismiss = useRef(props.onDismiss);
134
+ dismiss.current = props.onDismiss;
129
135
 
130
136
  useEffect(() => {
131
137
  const cur = shown.current;
132
138
  if (!props.open) {
133
- if (!cur) return;
134
- shown.current = null;
135
- cur.unbindTheme?.();
136
- const owner = cur.host.parent as any;
137
- if (owner?.hasChild?.(cur.host)) {
138
- owner.close(cur.host).then(
139
- () => cur.root.unmount?.(),
140
- (error: any) => { console.error('[Popover] close failed', error); cur.root.unmount?.(); },
141
- );
142
- } else cur.root.unmount?.();
143
-
139
+ cur?.lifetime.close();
144
140
  return;
145
141
  }
146
142
 
@@ -158,14 +154,18 @@ export function Popover(props: PopoverProps) @{
158
154
  host.horizontalAlignment = 'stretch';
159
155
  host.verticalAlignment = 'stretch';
160
156
  const root = createNativeScriptRoot(host);
161
- shown.current = { host, root, unbindTheme };
162
- root.render(PopoverLayer as unknown as UniversalComponent, { ...props, anchorView: anchor });
163
- rl.open(host).catch((error: unknown) => {
157
+ const lifetime = createOverlayLifetime(rl, host, root, 'Popover', (dismissed) => {
164
158
  if (shown.current?.host === host) shown.current = null;
165
- console.error('[Popover] open failed', error);
166
- root.unmount?.();
159
+ if (dismissed) dismiss.current?.();
167
160
  });
161
+
162
+ lifetime.own(unbindTheme);
163
+ shown.current = { host, root, lifetime };
164
+ root.render(PopoverLayer as unknown as UniversalComponent, { ...props, anchorView: anchor });
165
+ lifetime.open();
168
166
  }, [props.open, props.children, props.placement, props.className, props.style, props.dismissOnOutsideTap]);
169
167
 
168
+ useEffect(() => () => shown.current?.lifetime.close(), []);
169
+
170
170
  <></>
171
171
  }
@@ -20,7 +20,7 @@ export function Pressable(props: PressableProps) @{
20
20
  accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
21
21
  accessibilityHint={props.accessibilityHint}
22
22
  accessibilityValue={props.accessibilityValue}
23
- accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
23
+ accessibilityState={nativeAccessibilityState({ ...props.accessibilityState, disabled: props.disabled || props.accessibilityState?.disabled }) as any}
24
24
  accessibilityLiveRegion={props.accessibilityLiveRegion as any}
25
25
  onPan={usePan(props.disabled ? undefined : props.onPan)}
26
26
  onSwipe={props.disabled ? undefined : props.onSwipe}
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { act, createRoot } from 'octane'
4
+ import { Pressable } from './Pressable.web.tsrx'
5
+ const roots: any[] = []
6
+ function mount(props: any = {}) {
7
+ vi.useFakeTimers()
8
+ const el = document.createElement('div');document.body.append(el);const root=createRoot(el);roots.push(root)
9
+ const render = (next:any={}) => act(() => root.render(<Pressable {...props} {...next} />))
10
+ render();const pointer=(type:string) => act(() => el.firstElementChild!.dispatchEvent(new Event(type,{bubbles:true})))
11
+ const advance=() => act(() => vi.advanceTimersByTime(500))
12
+ return {root,render,pointer,advance}
13
+ }
14
+
15
+ afterEach(() => {for (const root of roots.splice(0)) act(() => root.unmount());document.body.innerHTML='';vi.useRealTimers()})
16
+ it.each(['pointerup','pointerleave','pointercancel'])('cancels on %s after a rerender', (event) => {
17
+ const callback=vi.fn();const {render,pointer,advance}=mount({onLongPress:callback})
18
+ pointer('pointerdown');render({className:'updated'});pointer(event);advance();expect(callback).not.toHaveBeenCalled()
19
+ })
20
+
21
+ it('cancels on unmount', () => {
22
+ const callback=vi.fn();const {root,pointer,advance}=mount({onLongPress:callback});pointer('pointerdown');act(() => root.unmount());advance();expect(callback).not.toHaveBeenCalled()
23
+ })
24
+
25
+ it.each([{disabled:true},{onLongPress:undefined}])('cancels when long press becomes unavailable (%j)', (next) => {
26
+ const callback=vi.fn();const {render,pointer,advance}=mount({onLongPress:callback});pointer('pointerdown');render(next);advance();expect(callback).not.toHaveBeenCalled()
27
+ })
28
+
29
+ it('reports one long press through the latest callback', () => {
30
+ const old=vi.fn(),current=vi.fn();const {render,pointer,advance}=mount({onLongPress:old});pointer('pointerdown');render({onLongPress:current});advance();advance()
31
+ expect(old).not.toHaveBeenCalled();expect(current).toHaveBeenCalledTimes(1)
32
+ })
33
+
34
+ it('does not start a disabled long press', () => {
35
+ const callback=vi.fn();const {pointer,advance}=mount({onLongPress:callback,disabled:true});pointer('pointerdown');advance();expect(callback).not.toHaveBeenCalled()
36
+ })
@@ -1,4 +1,4 @@
1
- import { useRef } from 'octane';
1
+ import { useEffect, useRef } from 'octane';
2
2
  import type { PressableProps } from "./props";
3
3
  import { applyEscapeProps } from "./escape-props";
4
4
  import { layoutChildProps } from './layout-child.web';
@@ -6,17 +6,27 @@ import { usePan } from './pan.web.tsrx';
6
6
 
7
7
  export function Pressable(props: PressableProps) @{
8
8
  const el = useRef<HTMLElement | null>(null);
9
- let timer: ReturnType<typeof setTimeout> | null = null;
9
+ const spacePressed = useRef(false);
10
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
11
+ const latest = useRef(props);
12
+ latest.current = props;
10
13
 
11
14
  usePan(el, props.disabled ? undefined : props.onPan);
12
15
 
13
- const cancel = () => { if (timer !== null) { clearTimeout(timer); timer = null; } };
16
+ const cancel = () => { if (timer.current !== null) { clearTimeout(timer.current); timer.current = null; } };
17
+ useEffect(() => { if (props.disabled) spacePressed.current = false; }, [props.disabled]);
18
+ const enabled = !props.disabled && Boolean(props.onLongPress);
19
+ useEffect(() => { if (!enabled) cancel(); }, [enabled]);
20
+ useEffect(() => () => cancel(), []);
14
21
 
15
22
  // Long-press parity with native: ~500ms hold over pointerdown/up.
16
23
  const down = () => {
17
- if (props.disabled || !props.onLongPress) return;
24
+ if (latest.current.disabled || !latest.current.onLongPress) return;
18
25
  cancel();
19
- timer = setTimeout(() => { timer = null; props.onLongPress?.(); }, 500);
26
+ timer.current = setTimeout(() => {
27
+ timer.current = null;
28
+ if (!latest.current.disabled) latest.current.onLongPress?.();
29
+ }, 500);
20
30
  };
21
31
 
22
32
  <div
@@ -28,11 +38,32 @@ export function Pressable(props: PressableProps) @{
28
38
  aria-label={props.accessibilityLabel}
29
39
  aria-description={props.accessibilityHint}
30
40
  aria-valuetext={props.accessibilityValue}
31
- aria-disabled={props.accessibilityState?.disabled ?? props.disabled ?? undefined}
41
+ aria-disabled={props.disabled || props.accessibilityState?.disabled || undefined}
32
42
  aria-selected={props.accessibilityState?.selected}
33
43
  aria-checked={props.accessibilityState?.checked}
34
44
  aria-live={props.accessibilityLiveRegion === 'none' ? 'off' : props.accessibilityLiveRegion}
35
45
  aria-hidden={props.accessible === false ? true : undefined}
46
+ tabIndex={props.onPress && !props.disabled && props.accessible !== false ? 0 : -1}
47
+ onKeyDown={(e: KeyboardEvent) => {
48
+ if (e.target !== e.currentTarget || props.disabled || e.isComposing || !props.onPress) return;
49
+ if (e.key === 'Enter') {
50
+ e.preventDefault();
51
+ if (!e.repeat) props.onPress();
52
+ } else if (e.key === ' ') {
53
+ e.preventDefault();
54
+ spacePressed.current = true;
55
+ }
56
+ }}
57
+ onKeyUp={(e: KeyboardEvent) => {
58
+ if (e.target !== e.currentTarget || e.key !== ' ') return;
59
+ const activate = spacePressed.current;
60
+ spacePressed.current = false;
61
+ if (activate && !props.disabled && props.onPress) {
62
+ e.preventDefault();
63
+ props.onPress();
64
+ }
65
+ }}
66
+ onBlur={() => { spacePressed.current = false; }}
36
67
  onClick={props.disabled ? undefined : props.onPress}
37
68
  onDoubleClick={props.disabled ? undefined : props.onDoublePress}
38
69
  onPointerDown={(e: any) => { if (!props.disabled) { props.onPressIn?.(); down(); } }}
@@ -14,5 +14,6 @@ export function RouteHost(props: RouteHostProps) @{
14
14
  const nested = child
15
15
  child = <Layout>{nested}</Layout>
16
16
  }
17
+
17
18
  <>{child}</>
18
19
  }
@@ -4,7 +4,8 @@ import type { SearchInputProps } from './props';
4
4
  import { Icon } from './Icon.tsrx';
5
5
  import { cx } from './cx';
6
6
  import { applyEscapeProps } from './escape-props';
7
- import { writeText } from './text-write';
7
+ import { isWritingText, writeText } from './text-write';
8
+ import { blurText } from './text-focus';
8
9
 
9
10
  /** Chrome-reset search field — a styled TextField (not UISearchBar) with a
10
11
  * leading glyph and a self-drawn clear button, matching the web leaf.
@@ -40,13 +41,13 @@ export function SearchInput(props: SearchInputProps) @{
40
41
  ref={(v: any) => {
41
42
  el.current = v;
42
43
  if (!v) return;
43
- props.bind?.({ focus: () => v.focus(), blur: () => v.dismissSoftInput?.() ?? v.blur(), native: v });
44
+ props.bind?.({ focus: () => v.focus(), blur: () => blurText(v), native: v });
44
45
  }}
45
46
  className="vx-search-field"
46
47
  hint={props.placeholder}
47
48
  returnKeyType="search"
48
49
  editable={props.editable ?? true}
49
- onTextChange={(e: any) => setValue(e.value)}
50
+ onTextChange={(e: any) => { if (!isWritingText(e.object)) setValue(e.value); }}
50
51
  onReturnPress={props.onSubmit}
51
52
  onFocus={props.onFocus}
52
53
  onBlur={props.onBlur}
@@ -41,7 +41,7 @@ export function SearchInput(props: SearchInputProps) @{
41
41
  enterKeyHint="search"
42
42
  onInput={(e: any) => setValue(e.target.value)}
43
43
  onKeyDown={(e: any) => {
44
- if (e.key === 'Enter' && !e.shiftKey) props.onSubmit?.();
44
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) props.onSubmit?.();
45
45
  }}
46
46
  onFocus={props.onFocus}
47
47
  onBlur={props.onBlur}
package/src/Sheet.tsrx CHANGED
@@ -7,6 +7,7 @@ import type { SheetProps } from './props';
7
7
  import { cx } from './cx';
8
8
  import { applyThemeClasses } from './theme/theme-scheme';
9
9
  import { rootLayoutFor } from './root-layout.mobile';
10
+ import { createOverlayLifetime } from './overlay-lifetime.mobile';
10
11
  import { attachSheetDetents } from './sheet-detents';
11
12
  import { bindBottomInsetToKeyboard } from './keyboard-inset.mobile';
12
13
  import { attachTapToBlur } from './tap-to-blur';
@@ -23,28 +24,16 @@ export function Sheet(props: SheetProps) @{
23
24
  const shown = useRef<{
24
25
  host: GridLayout;
25
26
  root: any;
26
- unbindTheme?: () => void;
27
- unbindKeyboard?: () => void;
28
- programmatic?: boolean;
29
- detach?: () => void;
27
+ lifetime: ReturnType<typeof createOverlayLifetime>;
30
28
  } | null>(null);
31
29
 
30
+ const dismiss = useRef(props.onDismiss);
31
+ dismiss.current = props.onDismiss;
32
+
32
33
  useEffect(() => {
33
34
  const cur = shown.current;
34
35
  if (!props.open) {
35
- if (!cur) return;
36
- shown.current = null;
37
- cur.programmatic = true;
38
- cur.unbindTheme?.();
39
- cur.unbindKeyboard?.();
40
- cur.root.unmount?.();
41
- const owner = cur.host.parent as any;
42
- if (owner?.hasChild?.(cur.host)) {
43
- owner.close(cur.host).catch((error: unknown) => {
44
- console.error('[Sheet] close failed', error);
45
- });
46
- }
47
-
36
+ cur?.lifetime.close();
48
37
  return;
49
38
  }
50
39
 
@@ -63,6 +52,12 @@ export function Sheet(props: SheetProps) @{
63
52
  if (props.style) Object.assign(host.style, props.style);
64
53
  Object.assign(host, isIOS ? props.ios : isAndroid ? props.android : undefined);
65
54
  const root = createNativeScriptRoot(host);
55
+ const lifetime = createOverlayLifetime(rl, host, root, 'Sheet', (dismissed) => {
56
+ if (shown.current?.host === host) shown.current = null;
57
+ if (dismissed) dismiss.current?.();
58
+ });
59
+
60
+ lifetime.own(unbindTheme);
66
61
  // Detents: the controller owns enter/slide-out — RootLayout's open
67
62
  // animation always lands translateY at 0, which would fight the
68
63
  // detent offsets, so it's skipped while detents are attached.
@@ -77,23 +72,13 @@ export function Sheet(props: SheetProps) @{
77
72
 
78
73
  // Lift the host above the software keyboard (todo 'Add' row inside a
79
74
  // demo sheet is bottom-anchored and was covered on iOS).
80
- const unbindKeyboard = bindBottomInsetToKeyboard(host);
81
- attachTapToBlur(host);
82
- const opened = { host, root, unbindTheme, unbindKeyboard, programmatic: false, detach: detents?.detach };
83
- shown.current = opened;
75
+ lifetime.own(bindBottomInsetToKeyboard(host));
76
+ lifetime.own(attachTapToBlur(host));
77
+ if (detents) lifetime.own(detents.detach);
78
+ shown.current = { host, root, lifetime };
84
79
  root.render(SheetBody as unknown as UniversalComponent, { children: props.children });
85
- host.on('closed', () => {
86
- if (shown.current === opened) shown.current = null;
87
- opened.detach?.();
88
- opened.unbindKeyboard?.();
89
- opened.unbindTheme?.();
90
- opened.root.unmount?.();
91
- // 'closed' fires for programmatic closes too — onDismiss only when
92
- // the platform dismissed it (shade tap or detent drag-out).
93
- if (!opened.programmatic) props.onDismiss?.();
94
- });
95
80
 
96
- rl.open(host, {
81
+ lifetime.open({
97
82
  ...(props.shadeCover ?? true)
98
83
  ? { shadeCover: { opacity: 0.4, tapToClose: true } }
99
84
  : {},
@@ -105,17 +90,11 @@ export function Sheet(props: SheetProps) @{
105
90
  exitTo: { translateY: 400, duration: 200 },
106
91
  },
107
92
  }),
108
- })
109
- .then(() => detents?.enter())
110
- .catch((error: unknown) => {
111
- if (shown.current === opened) shown.current = null;
112
- opened.unbindKeyboard?.();
113
- opened.unbindTheme?.();
114
- opened.root.unmount?.();
115
- console.error('[Sheet] open failed', error);
116
- });
93
+ }, () => detents?.enter());
117
94
  }, [props.open, props.children, props.className, props.style, props.shadeCover, props.ios, props.android]);
118
95
 
96
+ useEffect(() => () => shown.current?.lifetime.close(), []);
97
+
119
98
  // Zero-size sentinel — the sheet resolves its owning RootLayout from the
120
99
  // declaring component's ancestry, not getRootLayout() (first-mounted shell).
121
100
  <stacklayout ref={(v: any) => { sentinel.current = v; }} style={{ width: 0, height: 0 }} />
@@ -4,6 +4,7 @@ import { cx } from './cx';
4
4
  import { useThemeScheme } from './theme/theme-scheme';
5
5
  import { normalizeDetents } from './sheet-snap';
6
6
  import { attachSheetDetents } from './sheet-detents.web';
7
+ import { isolateModalFocus } from './modal-focus.web';
7
8
 
8
9
  /** In-window bottom sheet — portal layer with a backdrop and a bottom-
9
10
  * anchored panel. Same vocabulary as the native RootLayout sheet.
@@ -13,8 +14,13 @@ import { attachSheetDetents } from './sheet-detents.web';
13
14
  export function Sheet(props: SheetProps) @{
14
15
  const dark = useThemeScheme() === 'dark';
15
16
  const panel = useRef<HTMLElement | null>(null);
17
+ const layer = useRef<HTMLDivElement | null>(null);
16
18
  const dismiss = useRef(props.onDismiss);
17
19
  dismiss.current = props.onDismiss;
20
+ useEffect(() => {
21
+ if (!props.open || props.shadeCover === false || !layer.current || !panel.current) return;
22
+ return isolateModalFocus(layer.current, panel.current, () => dismiss.current?.());
23
+ }, [props.open, props.shadeCover]);
18
24
 
19
25
  const detents = normalizeDetents(props.detents);
20
26
  const maxDetent = detents.length ? detents[detents.length - 1] : 0;
@@ -31,11 +37,13 @@ export function Sheet(props: SheetProps) @{
31
37
 
32
38
  @if (props.open) {
33
39
  <>{createPortal(
34
- <div className={cx('vx-sheet-layer', dark && 'dark')}>
40
+ <div ref={layer} className={cx('vx-sheet-layer', dark && 'dark')}>
35
41
  {(props.shadeCover ?? true) && <div className="vx-sheet-backdrop" onClick={props.onDismiss} />}
36
42
  <div
37
43
  ref={(el: any) => { panel.current = el; }}
38
44
  className={cx('vx-sheet', detents.length && 'vx-sheet-detents', props.className)}
45
+ role={props.shadeCover === false ? undefined : 'dialog'}
46
+ aria-modal={props.shadeCover === false ? undefined : true}
39
47
  style={{
40
48
  ...props.style,
41
49
  ...(detents.length
package/src/Text.tsrx CHANGED
@@ -37,9 +37,11 @@ export function Text(props: TextProps) @{
37
37
  'extra-bold': 800,
38
38
  black: 900,
39
39
  };
40
+
40
41
  const fontWeight = /^\d+$/.test(fontWeightValue)
41
42
  ? Number(fontWeightValue)
42
43
  : namedWeights[fontWeightValue];
44
+
43
45
  const lineHeight = props.style?.lineHeight;
44
46
  // UIFontDescriptor resolves variable fonts to the regular axis default on
45
47
  // iOS. Set wght explicitly so CSS fontWeight selects the matching instance.