@octane-xplat/ui 0.10.0 → 0.11.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 (219) hide show
  1. package/dist/native/Image.tsrx.js +151 -16
  2. package/dist/native/hash-image.js +285 -0
  3. package/dist/native/image-content-binding.js +158 -0
  4. package/dist/native/image-content.js +206 -0
  5. package/dist/native/image-sizing.js +102 -0
  6. package/dist/native/octane-client-build.json +1 -1
  7. package/dist/native/vendor/ui-svg/index.android.js +1 -1
  8. package/dist/web/Image.web.tsrx.js +112 -19
  9. package/dist/web/hash-image.js +285 -0
  10. package/dist/web/image-content.js +133 -0
  11. package/dist/web/octane-client-build.json +1 -1
  12. package/package.json +3 -2
  13. package/src/Image.macos.tsrx +11 -2
  14. package/src/Image.tsrx +203 -17
  15. package/src/Image.web.tsrx +91 -5
  16. package/src/Tabs.macos.tsrx +2 -0
  17. package/src/Timestamp.macos.tsrx +1 -1
  18. package/src/hash-image.test.ts +72 -0
  19. package/src/hash-image.ts +310 -0
  20. package/src/hosted-unsupported.macos.tsrx +1 -2
  21. package/src/image-content-binding.mobile.test.ts +174 -0
  22. package/src/image-content-binding.ts +231 -0
  23. package/src/image-content.mobile.test.tsrx +84 -0
  24. package/src/image-content.test.ts +219 -0
  25. package/src/image-content.ts +309 -0
  26. package/src/image-decoding.web.test.tsrx +26 -0
  27. package/src/image-placeholder.mobile.test.tsrx +113 -0
  28. package/src/image-placeholder.web.test.tsrx +48 -0
  29. package/src/image-recycling.mobile.test.ts +61 -0
  30. package/src/image-sizing.mobile.test.ts +158 -0
  31. package/src/image-sizing.ts +117 -0
  32. package/src/image.mobile.test.tsrx +81 -0
  33. package/src/index.macos.ts +1 -0
  34. package/src/index.shared.ts +7 -0
  35. package/src/index.shared.web.ts +7 -0
  36. package/src/list-viewability-observer.ios.ts +1 -0
  37. package/src/list-viewability.mobile.test.ts +1 -0
  38. package/src/props.ts +100 -1
  39. package/src/safeAreaInsets.macos.tsrx +6 -1
  40. package/src/styled.macos.tsrx +1 -1
  41. package/src/vendor/ui-svg/src/index.android.ts +5 -1
  42. package/types/generated/.tsrx-typegen-manifest.json +170 -0
  43. package/types/generated/Accordion.macos.d.ts +5 -0
  44. package/types/generated/Alert.macos.d.ts +3 -0
  45. package/types/generated/AlertDialog.macos.d.ts +4 -0
  46. package/types/generated/AppShell.macos.d.ts +7 -0
  47. package/types/generated/AspectRatio.macos.d.ts +5 -0
  48. package/types/generated/Avatar.macos.d.ts +6 -0
  49. package/types/generated/AvatarGroup.macos.d.ts +6 -0
  50. package/types/generated/Badge.macos.d.ts +5 -0
  51. package/types/generated/Banner.macos.d.ts +4 -0
  52. package/types/generated/BaseTypeahead.macos.d.ts +7 -0
  53. package/types/generated/Blockquote.macos.d.ts +6 -0
  54. package/types/generated/BottomSheet.macos.d.ts +3 -0
  55. package/types/generated/Breadcrumb.macos.d.ts +4 -0
  56. package/types/generated/ButtonGroup.macos.d.ts +3 -0
  57. package/types/generated/Calendar.macos.d.ts +5 -0
  58. package/types/generated/Card.macos.d.ts +4 -0
  59. package/types/generated/Carousel.macos.d.ts +6 -0
  60. package/types/generated/Center.macos.d.ts +4 -0
  61. package/types/generated/CheckIndicator.macos.d.ts +4 -0
  62. package/types/generated/CheckboxGroup.macos.d.ts +3 -0
  63. package/types/generated/CheckboxIndicator.macos.d.ts +4 -0
  64. package/types/generated/Chip.macos.d.ts +5 -0
  65. package/types/generated/Citation.macos.d.ts +4 -0
  66. package/types/generated/ClickableCard.macos.d.ts +3 -0
  67. package/types/generated/Code.macos.d.ts +5 -0
  68. package/types/generated/CodeBlock.macos.d.ts +6 -0
  69. package/types/generated/Collapsible.macos.d.ts +5 -0
  70. package/types/generated/CommandPalette.macos.d.ts +3 -0
  71. package/types/generated/ComplexSelector.macos.d.ts +3 -0
  72. package/types/generated/ContextMenu.macos.d.ts +5 -0
  73. package/types/generated/DateInput.macos.d.ts +6 -0
  74. package/types/generated/DateRangeInput.macos.d.ts +5 -0
  75. package/types/generated/DateTimeInput.macos.d.ts +5 -0
  76. package/types/generated/Dialog.macos.d.ts +3 -0
  77. package/types/generated/Drawer.macos.d.ts +7 -0
  78. package/types/generated/DropdownMenu.macos.d.ts +5 -0
  79. package/types/generated/Empty.macos.d.ts +4 -0
  80. package/types/generated/FieldGroup.macos.d.ts +4 -0
  81. package/types/generated/FormField.macos.d.ts +4 -0
  82. package/types/generated/FormLayout.macos.d.ts +6 -0
  83. package/types/generated/HStack.macos.d.ts +4 -0
  84. package/types/generated/Heading.macos.d.ts +3 -0
  85. package/types/generated/Icon.macos.d.ts +3 -0
  86. package/types/generated/IconButton.macos.d.ts +3 -0
  87. package/types/generated/Image.d.ts +0 -5
  88. package/types/generated/Image.macos.d.ts +2 -0
  89. package/types/generated/Image.web.d.ts +12 -1
  90. package/types/generated/InputGroup.macos.d.ts +5 -0
  91. package/types/generated/InputNumber.macos.d.ts +5 -0
  92. package/types/generated/InputRating.macos.d.ts +4 -0
  93. package/types/generated/Item.macos.d.ts +3 -0
  94. package/types/generated/Kbd.macos.d.ts +4 -0
  95. package/types/generated/KeyboardAvoiding.macos.d.ts +3 -0
  96. package/types/generated/Lightbox.macos.d.ts +2 -0
  97. package/types/generated/Link.macos.d.ts +3 -0
  98. package/types/generated/List.macos.d.ts +4 -0
  99. package/types/generated/ListItem.macos.d.ts +5 -0
  100. package/types/generated/MenuParts.macos.d.ts +9 -0
  101. package/types/generated/MenuSurface.macos.d.ts +4 -0
  102. package/types/generated/MetadataList.macos.d.ts +4 -0
  103. package/types/generated/MetadataListItem.macos.d.ts +3 -0
  104. package/types/generated/Meter.macos.d.ts +3 -0
  105. package/types/generated/MobileNav.macos.d.ts +15 -0
  106. package/types/generated/MoreMenu.macos.d.ts +2 -0
  107. package/types/generated/NavIcon.macos.d.ts +4 -0
  108. package/types/generated/NavLink.macos.d.ts +3 -0
  109. package/types/generated/NavMenu.macos.d.ts +11 -0
  110. package/types/generated/NavigationMenu.macos.d.ts +4 -0
  111. package/types/generated/Outline.macos.d.ts +5 -0
  112. package/types/generated/OverflowList.macos.d.ts +6 -0
  113. package/types/generated/Overlay.macos.d.ts +3 -0
  114. package/types/generated/Pagination.macos.d.ts +4 -0
  115. package/types/generated/PinInput.macos.d.ts +4 -0
  116. package/types/generated/Popover.macos.d.ts +3 -0
  117. package/types/generated/PowerSearch.macos.d.ts +7 -0
  118. package/types/generated/PowerSearchFilterEditor.macos.d.ts +12 -0
  119. package/types/generated/PowerSearchToken.macos.d.ts +7 -0
  120. package/types/generated/Pressable.macos.d.ts +2 -0
  121. package/types/generated/ProgressBar.macos.d.ts +6 -0
  122. package/types/generated/ProgressGroup.macos.d.ts +3 -0
  123. package/types/generated/RadioGroup.macos.d.ts +5 -0
  124. package/types/generated/RadioIndicator.macos.d.ts +3 -0
  125. package/types/generated/ResizeHandle.macos.d.ts +7 -0
  126. package/types/generated/RouteBoundary.macos.d.ts +8 -0
  127. package/types/generated/RouteHost.macos.d.ts +13 -0
  128. package/types/generated/Row.macos.d.ts +2 -0
  129. package/types/generated/SafeArea.macos.d.ts +3 -0
  130. package/types/generated/Screen.macos.d.ts +3 -0
  131. package/types/generated/ScrollableArea.macos.d.ts +7 -0
  132. package/types/generated/Section.macos.d.ts +5 -0
  133. package/types/generated/Select.macos.d.ts +3 -0
  134. package/types/generated/SelectableCard.macos.d.ts +3 -0
  135. package/types/generated/Separator.macos.d.ts +5 -0
  136. package/types/generated/SideNav.macos.d.ts +19 -0
  137. package/types/generated/Skeleton.macos.d.ts +5 -0
  138. package/types/generated/StatusDot.macos.d.ts +5 -0
  139. package/types/generated/Stepper.macos.d.ts +4 -0
  140. package/types/generated/TabList.macos.d.ts +18 -0
  141. package/types/generated/Table.macos.d.ts +4 -0
  142. package/types/generated/Tabs.macos.d.ts +3 -0
  143. package/types/generated/Text.macos.d.ts +5 -0
  144. package/types/generated/Thumbnail.macos.d.ts +7 -0
  145. package/types/generated/TimeInput.macos.d.ts +6 -0
  146. package/types/generated/TimeWheelPanel.macos.d.ts +16 -0
  147. package/types/generated/Timeline.macos.d.ts +3 -0
  148. package/types/generated/Timer.macos.d.ts +3 -0
  149. package/types/generated/Timestamp.macos.d.ts +5 -0
  150. package/types/generated/Toast.macos.d.ts +3 -0
  151. package/types/generated/ToggleButton.macos.d.ts +3 -0
  152. package/types/generated/Token.macos.d.ts +5 -0
  153. package/types/generated/Tokenizer.macos.d.ts +6 -0
  154. package/types/generated/Toolbar.macos.d.ts +6 -0
  155. package/types/generated/TopNav.macos.d.ts +25 -0
  156. package/types/generated/Tree.macos.d.ts +4 -0
  157. package/types/generated/Typeahead.macos.d.ts +8 -0
  158. package/types/generated/TypeaheadItem.macos.d.ts +5 -0
  159. package/types/generated/User.macos.d.ts +5 -0
  160. package/types/generated/VStack.macos.d.ts +4 -0
  161. package/types/generated/View.macos.d.ts +3 -0
  162. package/types/generated/VirtualList.macos.d.ts +6 -0
  163. package/types/generated/VisuallyHidden.macos.d.ts +5 -0
  164. package/types/generated/WebView.macos.d.ts +2 -0
  165. package/types/generated/aliases.macos.d.ts +3 -0
  166. package/types/generated/anim.macos.d.ts +3 -0
  167. package/types/generated/animation-host.macos.d.ts +3 -0
  168. package/types/generated/bottom-sheet-panel.macos.d.ts +7 -0
  169. package/types/generated/bottom-sheet-service.macos.d.ts +5 -0
  170. package/types/generated/clipboard-write.macos.d.ts +3 -0
  171. package/types/generated/colorScheme.macos.d.ts +4 -0
  172. package/types/generated/command-palette-keys.macos.d.ts +2 -0
  173. package/types/generated/command-palette-scroll.macos.d.ts +2 -0
  174. package/types/generated/controls.macos.d.ts +8 -0
  175. package/types/generated/date-composition.macos.d.ts +5 -0
  176. package/types/generated/escape-props.macos.d.ts +5 -0
  177. package/types/generated/hash-image.d.ts +13 -0
  178. package/types/generated/hosted-unsupported.macos.d.ts +1 -0
  179. package/types/generated/image-content-binding.d.ts +64 -0
  180. package/types/generated/image-content.d.ts +56 -0
  181. package/types/generated/image-sizing.d.ts +37 -0
  182. package/types/generated/index.macos.d.ts +196 -0
  183. package/types/generated/index.shared.d.ts +1 -1
  184. package/types/generated/index.shared.web.d.ts +1 -1
  185. package/types/generated/layout-shells.macos.d.ts +3 -0
  186. package/types/generated/layout.macos.d.ts +9 -0
  187. package/types/generated/macos-extras.macos.d.ts +4 -0
  188. package/types/generated/measure-bounds.macos.d.ts +3 -0
  189. package/types/generated/nav-href.macos.d.ts +4 -0
  190. package/types/generated/outline-dom.macos.d.ts +2 -0
  191. package/types/generated/pan.macos.d.ts +3 -0
  192. package/types/generated/platform.macos.d.ts +1 -0
  193. package/types/generated/presentation.macos.d.ts +15 -0
  194. package/types/generated/props.d.ts +76 -1
  195. package/types/generated/query-storage.macos.d.ts +8 -0
  196. package/types/generated/resize-basis.macos.d.ts +4 -0
  197. package/types/generated/resize-persist.macos.d.ts +8 -0
  198. package/types/generated/route.macos.d.ts +28 -0
  199. package/types/generated/safeAreaInsets.macos.d.ts +10 -0
  200. package/types/generated/stacks.macos.d.ts +3 -0
  201. package/types/generated/styled.macos.d.ts +9 -0
  202. package/types/generated/surfaces.macos.d.ts +4 -0
  203. package/types/generated/svg-image.macos.d.ts +3 -0
  204. package/types/generated/text-controls.macos.d.ts +4 -0
  205. package/types/generated/theme/theme-scheme.macos.d.ts +8 -0
  206. package/types/generated/toast-service.macos.d.ts +3 -0
  207. package/types/generated/toast-viewport.macos.d.ts +5 -0
  208. package/types/generated/translate.macos.d.ts +1 -0
  209. package/types/generated/unsupported.macos.d.ts +1 -0
  210. package/types/generated/use-imperative-alert-dialog.macos.d.ts +2 -0
  211. package/types/generated/use-imperative-dialog.macos.d.ts +2 -0
  212. package/types/generated/use-lightbox.macos.d.ts +2 -0
  213. package/types/generated/use-scrollable-area.macos.d.ts +6 -0
  214. package/types/generated/useMeasure.macos.d.ts +3 -0
  215. package/types/generated/useResizable.macos.d.ts +8 -0
  216. package/types/generated/useStore.macos.d.ts +2 -0
  217. package/types/generated/windows.macos.d.ts +2 -0
  218. package/src/CameraView.macos.tsrx +0 -6
  219. package/types/index.macos.d.ts +0 -448
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createSizedImageBinding } from './image-sizing'
3
+
4
+ function mockView(width = 0, height = 0) {
5
+ const handlers = new Map<string, Set<() => void>>()
6
+ const srcWrites: any[] = []
7
+ const view: any = {
8
+ measured: { w: width, h: height },
9
+ stretch: 'aspectFit',
10
+ decodeWidth: undefined as any,
11
+ decodeHeight: undefined as any,
12
+ srcWrites,
13
+ on: (event: string, callback: () => void) => {
14
+ const set = handlers.get(event) ?? new Set()
15
+ set.add(callback)
16
+ handlers.set(event, set)
17
+ },
18
+ off: (event: string, callback: () => void) => handlers.get(event)?.delete(callback),
19
+ getMeasuredWidth() {
20
+ return view.measured.w
21
+ },
22
+ getMeasuredHeight() {
23
+ return view.measured.h
24
+ },
25
+ emit(event: string) {
26
+ for (const callback of handlers.get(event) ?? []) callback()
27
+ },
28
+ handlerCount(event: string) {
29
+ return handlers.get(event)?.size ?? 0
30
+ },
31
+ }
32
+ let src: any
33
+ Object.defineProperty(view, 'src', {
34
+ get: () => src,
35
+ set: (value) => {
36
+ src = value
37
+ srcWrites.push(value)
38
+ },
39
+ configurable: true,
40
+ })
41
+ return view
42
+ }
43
+
44
+ describe('sized image decode binding', () => {
45
+ it('holds src until the first real layout, then issues decode dims in device px', () => {
46
+ const view = mockView(0, 0)
47
+ const binding = createSizedImageBinding()
48
+
49
+ binding.update(view, 'res://feed/photo.jpg')
50
+ expect(view.src).toBeUndefined()
51
+ expect(view.decodeWidth).toBeUndefined()
52
+ expect(view.handlerCount('layoutChanged')).toBe(1)
53
+
54
+ view.measured = { w: 300, h: 200 }
55
+ view.emit('layoutChanged')
56
+ expect(view.src).toBe('res://feed/photo.jpg')
57
+ expect(view.decodeWidth).toEqual({ value: 300, unit: 'px' })
58
+ expect(view.decodeHeight).toEqual({ value: 200, unit: 'px' })
59
+ expect(view.srcWrites).toHaveLength(1)
60
+ })
61
+
62
+ it('issues immediately when the view is already measured, and repeats are no-ops', () => {
63
+ const view = mockView(300, 200)
64
+ const binding = createSizedImageBinding()
65
+
66
+ binding.update(view, 'https://img.example/x.png')
67
+ expect(view.src).toBe('https://img.example/x.png')
68
+ expect(view.srcWrites).toHaveLength(1)
69
+
70
+ binding.update(view, 'https://img.example/x.png')
71
+ view.emit('layoutChanged')
72
+ expect(view.srcWrites).toHaveLength(1)
73
+ })
74
+
75
+ it('re-issues on a src change even when the size is unchanged', () => {
76
+ const view = mockView(300, 200)
77
+ const binding = createSizedImageBinding()
78
+
79
+ binding.update(view, 'res://a.jpg')
80
+ binding.update(view, 'res://b.jpg')
81
+ expect(view.src).toBe('res://b.jpg')
82
+ expect(view.srcWrites).toEqual(['res://a.jpg', 'res://b.jpg'])
83
+ expect(view.decodeWidth).toEqual({ value: 300, unit: 'px' })
84
+ })
85
+
86
+ it('re-issues at the new size on resize for aspect-fit stretches', () => {
87
+ const view = mockView(300, 200)
88
+ const binding = createSizedImageBinding()
89
+
90
+ binding.update(view, 'res://a.jpg')
91
+ view.measured = { w: 600, h: 400 }
92
+ view.emit('layoutChanged')
93
+ expect(view.srcWrites).toHaveLength(2)
94
+ expect(view.decodeWidth).toEqual({ value: 600, unit: 'px' })
95
+ expect(view.decodeHeight).toEqual({ value: 400, unit: 'px' })
96
+ })
97
+
98
+ it('does not reload on resize for fill or none — the expo-image gate', () => {
99
+ for (const stretch of ['fill', 'none']) {
100
+ const view = mockView(300, 200)
101
+ view.stretch = stretch
102
+ const binding = createSizedImageBinding()
103
+
104
+ binding.update(view, 'res://a.jpg')
105
+ view.measured = { w: 600, h: 400 }
106
+ view.emit('layoutChanged')
107
+ expect(view.srcWrites).toHaveLength(1)
108
+ expect(view.decodeWidth).toEqual({
109
+ value: stretch === 'fill' ? 300 : 0,
110
+ unit: 'px',
111
+ })
112
+ }
113
+ })
114
+
115
+ it('never downsamples for stretch none — source pixels are the contract', () => {
116
+ const view = mockView(300, 200)
117
+ view.stretch = 'none'
118
+ const binding = createSizedImageBinding()
119
+
120
+ binding.update(view, 'res://a.jpg')
121
+ expect(view.src).toBe('res://a.jpg')
122
+ expect(view.decodeWidth).toEqual({ value: 0, unit: 'px' })
123
+ expect(view.decodeHeight).toEqual({ value: 0, unit: 'px' })
124
+ })
125
+
126
+ it('moves the listener to a recreated view and re-issues at its size', () => {
127
+ const first = mockView(300, 200)
128
+ const second = mockView(0, 0)
129
+ const binding = createSizedImageBinding()
130
+
131
+ binding.update(first, 'res://a.jpg')
132
+ expect(first.src).toBe('res://a.jpg')
133
+
134
+ binding.update(second, 'res://a.jpg')
135
+ expect(first.handlerCount('layoutChanged')).toBe(0)
136
+ expect(second.handlerCount('layoutChanged')).toBe(1)
137
+ expect(second.src).toBeUndefined()
138
+
139
+ second.measured = { w: 100, h: 50 }
140
+ second.emit('layoutChanged')
141
+ expect(second.src).toBe('res://a.jpg')
142
+ expect(second.decodeWidth).toEqual({ value: 100, unit: 'px' })
143
+ })
144
+
145
+ it('keeps a held src pending until layout even across re-renders', () => {
146
+ const view = mockView(0, 0)
147
+ const binding = createSizedImageBinding()
148
+
149
+ binding.update(view, 'res://a.jpg')
150
+ binding.update(view, 'res://b.jpg')
151
+ expect(view.src).toBeUndefined()
152
+
153
+ view.measured = { w: 50, h: 50 }
154
+ view.emit('layoutChanged')
155
+ expect(view.src).toBe('res://b.jpg')
156
+ expect(view.srcWrites).toEqual(['res://b.jpg'])
157
+ })
158
+ })
@@ -0,0 +1,117 @@
1
+ /** View-sized decode for the native <image> leaf.
2
+ *
3
+ * NS Android reads decodeWidth/decodeHeight only when `src` is written —
4
+ * `srcProperty.setNative` → `_createImageSourceFromSrc` →
5
+ * `imageView.setUri(value, decodeWidth, decodeHeight, …)` — and the
6
+ * properties default to 0 → `Fetcher.calculateInSampleSize` maps
7
+ * reqWidth ≤ 0 to the source dims → inSampleSize 1 → a full-resolution
8
+ * decode (~48 MB ARGB_8888 per 12 MP photo; docs/notes/expo-image-study.md
9
+ * L1). So the leaf holds `src` until the view's first real layout, writes
10
+ * the decode dims in device pixels, then assigns src. A later resize
11
+ * re-issues at the new size — the same reload expo-image runs from
12
+ * onSizeChanged — under the same gate: 'fill'/'none' stretches keep the
13
+ * existing decode because a resize can't change the ideal sample there
14
+ * ('none' additionally never downsamples — its contract is source
15
+ * pixels). iOS ignores the decode dims entirely; the deferred assignment
16
+ * is behavior-identical there, so one path serves both platforms.
17
+ *
18
+ * The ref callback re-fires on every render (identity change), so
19
+ * `update` must be idempotent: same view + same src + same size is a
20
+ * no-op. Null ref calls on detach are ignored by the leaf — re-attaching
21
+ * the same view must not lose `applied`, or each render would re-issue
22
+ * setUri. The dead-view listener left behind on real unmount is a
23
+ * self-contained cycle the GC collects with the component record.
24
+ *
25
+ * `afterIssue` runs right after every `src` write — including a same-src
26
+ * resize re-issue mid-load, which fires no `isLoadingChange` (isLoading is
27
+ * already true). The Image placeholder hooks this to re-apply its bitmap
28
+ * after the write has nulled `imageSource` (L5, decision #103). */
29
+ export function createSizedImageBinding(options: { afterIssue?: (view: any) => void } = {}) {
30
+ let view: any = null;
31
+ let listening = false;
32
+ let pendingSrc: any;
33
+ let pendingStretch: string | undefined;
34
+ let appliedSrc: any;
35
+ let applied = false;
36
+ let width = 0;
37
+ let height = 0;
38
+
39
+ const issue = (next: any) => {
40
+ // `pendingStretch` overrides view.stretch for the decode gates when the
41
+ // content-rect path draws stretch='fill' under a different semantic fit
42
+ // ('none' needs the full-resolution source to find the natural size).
43
+ const downscale = (pendingStretch ?? view.stretch) !== 'none';
44
+ view.decodeWidth = { value: downscale ? width : 0, unit: 'px' };
45
+ view.decodeHeight = { value: downscale ? height : 0, unit: 'px' };
46
+ view.src = next;
47
+ appliedSrc = next;
48
+ applied = true;
49
+ options.afterIssue?.(view);
50
+ };
51
+
52
+ const evaluate = () => {
53
+ if (!view) {
54
+ return;
55
+ }
56
+
57
+ const measuredWidth = view.getMeasuredWidth?.() ?? 0;
58
+ const measuredHeight = view.getMeasuredHeight?.() ?? 0;
59
+ // Hold the request until a real layout — writing src sooner decodes
60
+ // at the default 0 dims (full source resolution).
61
+ if (measuredWidth <= 0 || measuredHeight <= 0) {
62
+ return;
63
+ }
64
+
65
+ if (!applied || pendingSrc !== appliedSrc) {
66
+ width = measuredWidth;
67
+ height = measuredHeight;
68
+ issue(pendingSrc);
69
+ return;
70
+ }
71
+
72
+ if (measuredWidth === width && measuredHeight === height) {
73
+ return;
74
+ }
75
+
76
+ const stretch = pendingStretch ?? view.stretch;
77
+ if (stretch === 'fill' || stretch === 'none') {
78
+ return;
79
+ }
80
+
81
+ width = measuredWidth;
82
+ height = measuredHeight;
83
+ issue(appliedSrc);
84
+ };
85
+
86
+ const onLayout = () => evaluate();
87
+
88
+ return {
89
+ /** (Re)bind source → view; issues once the view reports a real layout.
90
+ * `stretchForDecode` overrides view.stretch for the decode/reload
91
+ * gates — see `issue`. */
92
+ update(next: any, source: any, stretchForDecode?: string): void {
93
+ if (next !== view) {
94
+ view?.off?.('layoutChanged', onLayout);
95
+ view = next;
96
+ listening = false;
97
+ applied = false;
98
+ appliedSrc = undefined;
99
+ }
100
+
101
+ pendingSrc = source;
102
+ pendingStretch = stretchForDecode;
103
+ if (view && !listening && typeof view.on === 'function') {
104
+ view.on('layoutChanged', onLayout);
105
+ listening = true;
106
+ }
107
+
108
+ evaluate();
109
+ },
110
+ dispose(): void {
111
+ view?.off?.('layoutChanged', onLayout);
112
+ view = null;
113
+ listening = false;
114
+ applied = false;
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,81 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { createObjectContainer, createObjectDriver, createUniversalRoot } from 'octane/universal/native'
4
+
5
+ vi.mock('@nativescript/core', () => ({
6
+ isIOS: false,
7
+ isAndroid: true,
8
+ ScrollView: class {},
9
+ ImageSource: class {
10
+ static fromBase64Sync() {
11
+ return { mocked: true }
12
+ }
13
+ },
14
+ }))
15
+
16
+ // The raster path is under test; ./svg resolves to svg.mobile.ts, which
17
+ // registers SVGView from the vendored submodule (needs real NS core).
18
+ vi.mock('./svg', () => ({
19
+ isSvgSrc: () => false,
20
+ svgSource: (s: string) => s,
21
+ }))
22
+
23
+ import { Image } from './Image.tsrx'
24
+
25
+ describe('native Image sized decode', () => {
26
+ it('defers src until first layout, issues decode dims in device px, and re-issues per the stretch gate', () => {
27
+ const container = createObjectContainer('nativescript')
28
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
29
+ root.render(Image as any, { src: 'res://feed/photo.jpg' })
30
+ const host = container.children[0] as any
31
+ expect(host.type).toBe('image')
32
+ // The leaf owns src imperatively — the driver never saw it.
33
+ expect(host.props.src).toBeUndefined()
34
+ expect(host.src).toBeUndefined()
35
+
36
+ let layoutCb: (() => void) | null = null
37
+ host.on = (event: string, cb: () => void) => {
38
+ if (event === 'layoutChanged') layoutCb = cb
39
+ }
40
+
41
+ host.off = () => {}
42
+ host.getMeasuredWidth = () => 300
43
+ host.getMeasuredHeight = () => 200
44
+ host.stretch = 'aspectFit'
45
+
46
+ root.render(Image as any, { src: 'res://feed/photo.jpg', id: 'rebind' })
47
+ expect(host.src).toBe('res://feed/photo.jpg')
48
+ expect(host.decodeWidth).toEqual({ value: 300, unit: 'px' })
49
+ expect(host.decodeHeight).toEqual({ value: 200, unit: 'px' })
50
+
51
+ host.getMeasuredWidth = () => 600
52
+ layoutCb!()
53
+ expect(host.decodeWidth).toEqual({ value: 600, unit: 'px' })
54
+ expect(host.src).toBe('res://feed/photo.jpg')
55
+
56
+ host.stretch = 'fill'
57
+ host.getMeasuredWidth = () => 900
58
+ layoutCb!()
59
+ expect(host.decodeWidth).toEqual({ value: 600, unit: 'px' })
60
+
61
+ root.render(Image as any, { src: 'res://feed/other.jpg' })
62
+ expect(host.src).toBe('res://feed/other.jpg')
63
+ expect(host.decodeWidth).toEqual({ value: 900, unit: 'px' })
64
+ root.unmount()
65
+ })
66
+
67
+ it('maps decoding to NS loadMode — async by default, sync when requested', () => {
68
+ const container = createObjectContainer('nativescript')
69
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
70
+
71
+ root.render(Image as any, { src: 'res://feed/photo.jpg' })
72
+ expect((container.children[0] as any).props.loadMode).toBe('async')
73
+
74
+ root.render(Image as any, { src: 'res://feed/photo.jpg', decoding: 'sync' })
75
+ expect((container.children[0] as any).props.loadMode).toBe('sync')
76
+
77
+ root.render(Image as any, { src: 'res://feed/photo.jpg', decoding: 'async' })
78
+ expect((container.children[0] as any).props.loadMode).toBe('async')
79
+ root.unmount()
80
+ })
81
+ })
@@ -272,6 +272,7 @@ export { SafeArea } from './layout-shells.macos.tsrx'
272
272
  export { KeyboardAvoiding } from './KeyboardAvoiding.macos.tsrx'
273
273
  export type { KeyboardAvoidingProps } from './props'
274
274
  export { useSafeAreaInsets } from './safeAreaInsets.macos.tsrx'
275
+ export type { SafeAreaInsets } from './safeAreaInsets.macos.tsrx'
275
276
  export { useMeasure } from './useMeasure.macos.tsrx'
276
277
  export { Meter, Heading } from './macos-extras.macos.tsrx'
277
278
  export { Icon } from './Icon.macos.tsrx'
@@ -837,6 +837,13 @@ export type {
837
837
  DrawerProps,
838
838
  GridProps,
839
839
  ImageProps,
840
+ ImageContentFit,
841
+ ImageContentPosition,
842
+ ImageContentPositionObject,
843
+ ImageContentPositionString,
844
+ ImageContentPositionValue,
845
+ ImageDecoding,
846
+ ImageSourceLike,
840
847
  KeyboardAvoidingProps,
841
848
  LayoutChildProps,
842
849
  PressableProps,
@@ -838,6 +838,13 @@ export type {
838
838
  DrawerProps,
839
839
  GridProps,
840
840
  ImageProps,
841
+ ImageContentFit,
842
+ ImageContentPosition,
843
+ ImageContentPositionObject,
844
+ ImageContentPositionString,
845
+ ImageContentPositionValue,
846
+ ImageDecoding,
847
+ ImageSourceLike,
841
848
  KeyboardAvoidingProps,
842
849
  LayoutChildProps,
843
850
  PressableProps,
@@ -42,6 +42,7 @@ export function observeListViewability(
42
42
  const top = offset + Number(insets?.top ?? 0)
43
43
  const bottom =
44
44
  offset + Number(table.bounds?.size?.height ?? 0) - Number(insets?.bottom ?? 0)
45
+
45
46
  const rows: VisibleListRow[] = []
46
47
  const paths = Array.from(table.indexPathsForVisibleRows ?? []) as NSIndexPath[]
47
48
  for (const path of paths) {
@@ -101,6 +101,7 @@ describe('platform list viewability', () => {
101
101
  { minimumViewTime: 0 },
102
102
  onChange,
103
103
  )
104
+
104
105
  tracker.update([{ index: 1, visiblePercent: 100 }], ['active', 'pending'], undefined, onChange)
105
106
  tracker.reset()
106
107
  vi.advanceTimersByTime(250)
package/src/props.ts CHANGED
@@ -390,6 +390,8 @@ export interface RichTextProps extends LayoutChildProps {
390
390
  style?: any
391
391
  children?: any
392
392
  id?: string
393
+ /** Optional explicit accessible name override for the text block. */
394
+ accessibilityLabel?: string
393
395
  /** Platform-specific properties are applied after shared props. */
394
396
  ios?: any
395
397
  android?: any
@@ -1196,12 +1198,105 @@ export interface WebViewProps extends LayoutChildProps, AccessibilityProps {
1196
1198
  web?: any
1197
1199
  }
1198
1200
 
1201
+ /** A density/size-qualified entry in a multi-resolution `Image.src` array —
1202
+ * the native `srcset` analog. `uri` takes the same grammar as a string
1203
+ * `src`. `width`/`height` declare the source's display size (dips) and
1204
+ * `scale` its density multiplier, so file pixels are `width·scale`: the
1205
+ * selector compares `width·height·scale²` against the view's pixel count
1206
+ * and loads the closest match once a real layout exists. */
1207
+ export interface ImageSourceLike {
1208
+ uri: string
1209
+ width?: number
1210
+ height?: number
1211
+ scale?: number
1212
+ }
1213
+
1214
+ /** How the bitmap is sized inside the element's box — the CSS `object-fit`
1215
+ * vocabulary (same contract as expo-image `contentFit`). Default 'cover'. */
1216
+ export type ImageContentFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
1217
+
1218
+ /** One edge value in a `contentPosition`: dips from the named edge, a
1219
+ * percentage of the free space along that axis, or 'center' (= '50%'). */
1220
+ export type ImageContentPositionValue = number | `${number}%` | 'center'
1221
+
1222
+ export interface ImageContentPositionObject {
1223
+ top?: ImageContentPositionValue
1224
+ right?: ImageContentPositionValue
1225
+ bottom?: ImageContentPositionValue
1226
+ left?: ImageContentPositionValue
1227
+ }
1228
+
1229
+ export type ImageContentPositionString =
1230
+ | 'center'
1231
+ | 'top'
1232
+ | 'right'
1233
+ | 'bottom'
1234
+ | 'left'
1235
+ | 'top center'
1236
+ | 'top right'
1237
+ | 'top left'
1238
+ | 'right center'
1239
+ | 'right top'
1240
+ | 'right bottom'
1241
+ | 'bottom center'
1242
+ | 'bottom right'
1243
+ | 'bottom left'
1244
+ | 'left center'
1245
+ | 'left top'
1246
+ | 'left bottom'
1247
+
1248
+ /** Where the drawn bitmap sits inside the element's box — CSS
1249
+ * `object-position`. A keyword string, or an object naming one horizontal
1250
+ * and one vertical edge. Default centered on every platform. */
1251
+ export type ImageContentPosition = ImageContentPositionObject | ImageContentPositionString
1252
+
1253
+ /** When the bitmap is decoded — the HTML `decoding` attribute's vocabulary.
1254
+ * 'async' decodes off the UI thread, 'sync' decodes during the `src`
1255
+ * assignment. */
1256
+ export type ImageDecoding = 'async' | 'sync'
1257
+
1199
1258
  export interface ImageProps extends LayoutChildProps {
1200
1259
  className?: any
1201
1260
  style?: any
1202
1261
  id?: string
1203
- src: string
1262
+ /** A single source — `res://name`, `~/bundle-path`, file path, remote
1263
+ * URL, or data:/inline-SVG URI — or a resolution-qualified array
1264
+ * (closest pixel count wins; the `srcset` model). */
1265
+ src: string | ImageSourceLike[]
1204
1266
  alt?: string
1267
+ /** Placeholder shown while `src` loads — painted inside the same image
1268
+ * view so the swap can't blank or mismatch fit (expo-image study L5).
1269
+ * Accepts `blurhash:<hash>` / `thumbhash:<base64>` (decoded to a small
1270
+ * PNG in JS) plus any synchronously resolvable `src` grammar (`data:`
1271
+ * base64, `res://name`, `~/`, file path). Remote URLs are ignored on
1272
+ * native — a placeholder that must fetch first is pointless. On web any
1273
+ * `img.src` URL works (e.g. a low-res image). No-op on macOS, where
1274
+ * image loads are synchronous. Ignored for SVG srcs. */
1275
+ placeholder?: string
1276
+ /** Item identity for recycled platform-list cells (RecyclerView/UITableView
1277
+ * rebind the same view). When the key changes between binds, the previous
1278
+ * bitmap is cleared before the new `src` load starts, so the cell never
1279
+ * flashes the prior item's image (expo-image's `recyclingKey`). Web and
1280
+ * macOS leaves ignore it: their lists remount keyed rows rather than
1281
+ * rebinding a reused host. */
1282
+ recyclingKey?: string
1283
+ /** Bitmap sizing inside the box — CSS `object-fit`. Default 'cover' on
1284
+ * every platform (the platform-native defaults disagree: web `img` is
1285
+ * `fill`, NS stretch is `aspectFit`). */
1286
+ contentFit?: ImageContentFit
1287
+ /** Bitmap alignment inside the box — CSS `object-position`. Native
1288
+ * honors it by laying out the image itself (measured path), so
1289
+ * arbitrary positions match web pixel-for-pixel. */
1290
+ contentPosition?: ImageContentPosition
1291
+ /** Decode scheduling hint — the HTML `decoding` attribute. 'async'
1292
+ * (default): the bitmap decodes off the UI thread and may paint a frame
1293
+ * late. 'sync': decoded during the `src` assignment, blocking the UI
1294
+ * thread — for small bitmaps that must appear without a flicker.
1295
+ * Native maps to NS `loadMode` for file/resource/data srcs; remote URLs
1296
+ * always decode async and font-icon srcs are always sync regardless.
1297
+ * Web writes the `decoding` attribute. macOS ignores it — AppKit image
1298
+ * loads are synchronous. */
1299
+ decoding?: ImageDecoding
1205
1300
  /** Platform-specific properties are applied after shared props. */
1206
1301
  ios?: any
1207
1302
  android?: any
@@ -1262,6 +1357,8 @@ export interface SwitchProps extends FieldControlProps {
1262
1357
  className?: any
1263
1358
  style?: any
1264
1359
  id?: string
1360
+ /** Optional explicit accessible name override for the switch. */
1361
+ accessibilityLabel?: string
1265
1362
  checked?: boolean
1266
1363
  onCheckedChange?: (checked: boolean) => void
1267
1364
  /** Platform-specific properties are applied after shared props. */
@@ -2289,6 +2386,7 @@ export interface LinkProps {
2289
2386
  href: string
2290
2387
  target?: string
2291
2388
  className?: any
2389
+ style?: any
2292
2390
  children?: any
2293
2391
  }
2294
2392
 
@@ -2296,6 +2394,7 @@ export interface NavLinkProps {
2296
2394
  route: Route
2297
2395
  activeClassName?: string
2298
2396
  className?: any
2397
+ style?: any
2299
2398
  children?: any
2300
2399
  }
2301
2400
 
@@ -1,4 +1,9 @@
1
- import type { SafeAreaInsets } from './props'
1
+ export interface SafeAreaInsets {
2
+ top: number
3
+ right: number
4
+ bottom: number
5
+ left: number
6
+ }
2
7
 
3
8
  /** The AppKit root view begins below the title bar; there are no additional
4
9
  * safe-area insets in this window-hosted renderer. */
@@ -5,7 +5,7 @@ import { cx } from './cx'
5
5
  export function styled<P extends { className?: any }, V extends Record<string, any>>(
6
6
  Base: (props: P) => any,
7
7
  def: { base?: any; variants?: V },
8
- ) {
8
+ ): (props: P & { [K in keyof V]?: boolean }, context: any) => any {
9
9
  function Styled(props: P & { [K in keyof V]?: boolean }) {
10
10
  const classes: any[] = [def.base]
11
11
  const rest: any = { ...props }
@@ -55,7 +55,11 @@ class SVGExternalFileResolver extends com.caverock.androidsvg.SVGExternalFileRes
55
55
  if (fontFamily) {
56
56
  fontFamily = fontFamily.replace(/\\\//, '/')
57
57
  }
58
- return new Font(fontFamily, undefined as any, fontStyle.toLowerCase() as any, (fontWeight + '') as any).getAndroidTypeface()
58
+ return Font.default
59
+ .withFontFamily(fontFamily)
60
+ .withFontStyle(fontStyle.toLowerCase() as any)
61
+ .withFontWeight((fontWeight + '') as any)
62
+ .getAndroidTypeface()
59
63
  }
60
64
 
61
65
  override resolveImage(filename: string): globalAndroid.graphics.Bitmap {