diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 461e2e0c1..ea798e317 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -74,7 +74,7 @@ prevention; there is no automatic fitting or shrinking pass. ### Recipient identity and inline warning rows Status: implemented in local source for the Select Address and Swap selectors; -native device and Web runtime verification remain pending. These fields travel in snapshot/patch JSON +native device and broader Web runtime verification remain pending. These fields travel in snapshot/patch JSON and do not change the Nitro ABI. An `identity` row may opt into `subtitleLines: 0`, meaning the complete subtitle @@ -88,6 +88,18 @@ height changes with row width, text, style or configured font and may not retain a stale prior width's measurement. The existing 4096 UTF-16-code-unit bound per text field still applies; zero is not an unlimited data-size allowance. +On Web, opt-in auto-height identity rows without an explicit height use the +intrinsic body height in CSS layout pixels, before ancestor transforms. A hidden +body with zero layout height supplies no rendered measurement; zero MUST NOT +replace a valid layout height. Rebinding between automatic and explicit height +restores the corresponding body sizing. Multiline subtitles, including +`subtitleLines: 0`, break unspaced words within the text-column width; single-line +behavior remains unchanged. + +Auto-height recipient rows are production-consumer verified with isolated wallet +fixtures, including modal scaling, width changes and row reuse. This scoped +acceptance does not establish Electron or browser-extension runtime acceptance. + An `identity` row may set `longPressActionKey` to a nonempty action key. A valid touch long press on an enabled row emits one `onRowAction` with its current row key, action key, `row` anchor source and window press point. An accessibility long-click diff --git a/native-views/react-native-native-list/src/__tests__/IdentityRowWebParity.test.ts b/native-views/react-native-native-list/src/__tests__/IdentityRowWebParity.test.ts index a0ac6bb51..70399688e 100644 --- a/native-views/react-native-native-list/src/__tests__/IdentityRowWebParity.test.ts +++ b/native-views/react-native-native-list/src/__tests__/IdentityRowWebParity.test.ts @@ -31,7 +31,10 @@ function renderIdentity(row: IdentityRow) { iconAction: () => document.createElement('span'), dispose: () => undefined, } as unknown as RowPrimitives; - return identityRowRenderer.create(document, row, primitives); + return { + body: identityRowRenderer.create(document, row, primitives), + primitives, + }; } describe('Web identity row visual and expanded text', () => { @@ -43,7 +46,7 @@ describe('Web identity row visual and expanded text', () => { leading: { kind: 'token', image: mainImage, networkImage }, style: { image: { width: 40, height: 40, shape: 'circle' } }, }; - const body = renderIdentity(row); + const { body } = renderIdentity(row); const main = body.querySelector('.ok-native-list-visual-main'); const corner = body.querySelector( '.ok-native-list-visual-corner' @@ -55,24 +58,37 @@ describe('Web identity row visual and expanded text', () => { expect(corner?.style.borderRadius).toBe(''); }); - it('allows an unbounded subtitle and measures its rendered height when opted in', () => { + it('wraps an unspaced subtitle and measures untransformed layout height when opted in', () => { const row: IdentityRow = { type: 'identity', key: 'long-token', title: 'TOKEN', - subtitle: 'A long token name that wraps across multiple lines', + subtitle: '0xc19f5C3b2471D36c7C164088297A674Afec0fD25', subtitleLines: 0, contentHeight: 'auto', leading: { kind: 'token', image: mainImage }, }; - const body = renderIdentity(row); + const { body } = renderIdentity(row); const subtitle = body.querySelector( '[data-nl-slot="subtitle"]' ); expect(subtitle?.style.whiteSpace).toBe('pre-wrap'); + expect(subtitle?.style.overflowWrap).toBe('anywhere'); expect(subtitle?.style.getPropertyValue('-webkit-line-clamp')).toBe(''); - body.getBoundingClientRect = () => ({ height: 128 } as DOMRect); + expect(body.style.height).toBe('auto'); + let layoutHeight = 128; + Object.defineProperty(body, 'offsetHeight', { get: () => layoutHeight }); + const visualBounds = jest + .spyOn(body, 'getBoundingClientRect') + .mockReturnValue({ height: 128 * 0.95 } as DOMRect); expect(identityRowRenderer.measureRendered(body, row)).toBe(128); + visualBounds.mockReturnValue({ height: 128 * 1.004 } as DOMRect); + expect(identityRowRenderer.measureRendered(body, row)).toBe(128); + layoutHeight = 0; + expect(identityRowRenderer.measureRendered(body, row)).toBeUndefined(); + layoutHeight = 128; + expect(identityRowRenderer.measureRendered(body, row)).toBe(128); + expect(visualBounds).not.toHaveBeenCalled(); expect( identityRowRenderer.measureRendered(body, { ...row, @@ -81,6 +97,80 @@ describe('Web identity row visual and expanded text', () => { ).toBeUndefined(); }); + it.each([ + { subtitleLines: undefined, style: undefined, wrap: '', whiteSpace: '' }, + { subtitleLines: 1, style: undefined, wrap: '', whiteSpace: 'nowrap' }, + { + subtitleLines: 0, + style: { subtitle: { lines: 1 } }, + wrap: '', + whiteSpace: 'nowrap', + }, + { + subtitleLines: 2, + style: undefined, + wrap: 'anywhere', + whiteSpace: 'pre-wrap', + }, + ] as const)( + 'preserves subtitle line defaults and precedence: %j', + (testCase) => { + const { body } = renderIdentity({ + type: 'identity', + key: 'subtitle-lines', + title: 'Recipient', + subtitle: '0xc19f5C3b2471D36c7C164088297A674Afec0fD25', + leading: { kind: 'account' }, + subtitleLines: testCase.subtitleLines, + style: testCase.style, + }); + const subtitle = body.querySelector( + '[data-nl-slot="subtitle"]' + ); + expect(subtitle?.style.overflowWrap).toBe(testCase.wrap); + expect(subtitle?.style.whiteSpace).toBe(testCase.whiteSpace); + expect(body.style.height).toBe('100%'); + } + ); + + it('restores body sizing when auto-height rows are rebound with explicit or omitted height', () => { + const row: IdentityRow = { + type: 'identity', + key: 'reused-recipient', + title: 'Recipient', + subtitle: '0xc19f5C3b2471D36c7C164088297A674Afec0fD25', + subtitleLines: 0, + contentHeight: 'auto', + leading: { kind: 'account' }, + }; + const { body, primitives } = renderIdentity(row); + Object.defineProperty(body, 'offsetHeight', { value: 128 }); + expect(body.style.height).toBe('auto'); + for (const fixed of [ + { ...row, height: 90 }, + { ...row, style: { container: { height: 80 } } }, + { ...row, height: 90, style: { container: { height: 80 } } }, + { ...row, contentHeight: undefined }, + ]) { + identityRowRenderer.bind(body, fixed, primitives); + expect(body.style.height).toBe('100%'); + expect(identityRowRenderer.measureRendered(body, fixed)).toBeUndefined(); + identityRowRenderer.bind(body, row, primitives); + expect(body.style.height).toBe('auto'); + expect(identityRowRenderer.measureRendered(body, row)).toBe(128); + } + identityRowRenderer.bind(body, { ...row, subtitleLines: 1 }, primitives); + expect( + body.querySelector('[data-nl-slot="subtitle"]')?.style + .overflowWrap + ).toBe(''); + identityRowRenderer.bind(body, row, primitives); + expect( + body.querySelector('[data-nl-slot="subtitle"]')?.style + .overflowWrap + ).toBe('anywhere'); + }); + it('renders Recent metadata as independently truncated title text, not badges', () => { const row: IdentityRow = { type: 'identity', @@ -92,7 +182,7 @@ describe('Web identity row visual and expanded text', () => { { text: '2 days ago', tone: 'disabled' }, ], }; - const body = renderIdentity(row); + const { body } = renderIdentity(row); const title = body.querySelector('[data-nl-slot="title"]'); const suffixes = body.querySelectorAll( '.ok-native-list-identity-title-suffix' @@ -122,7 +212,7 @@ describe('Web identity row visual and expanded text', () => { titleIcon: { name: 'GasSolid', hoverActionKey: 'gas-info' }, style: { title: { token: '$bodyLg', fontWeight: 'medium', lines: 1 } }, }; - const body = renderIdentity(row); + const { body } = renderIdentity(row); const line = body.querySelector('.ok-native-list-title')!; const text = body.querySelector( '.ok-native-list-identity-title-text' @@ -153,7 +243,7 @@ describe('Web identity row visual and expanded text', () => { badges: [{ key: 'network', text: 'Ethereum' }], style: { title: { lines: 1 } }, }; - const body = renderIdentity(row); + const { body } = renderIdentity(row); const line = body.querySelector('.ok-native-list-title')!; const text = body.querySelector( '.ok-native-list-identity-title-text' diff --git a/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts index 6528ba09b..5370a87e1 100644 --- a/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts @@ -407,13 +407,15 @@ function createIdentityRow( if (lines !== undefined) column .querySelectorAll(`[data-nl-slot="${key}"]`) - .forEach((text) => + .forEach((text) => { applyTextLayout( text, lines === 0 ? {} : { lines }, lines === 0 ? 0 : undefined - ) - ); + ); + if (key === 'subtitle') + text.style.overflowWrap = lines === 1 ? '' : 'anywhere'; + }); } } body.appendChild(column); @@ -695,6 +697,10 @@ function bind(body: HTMLElement, row: IdentityRow, primitives: RowPrimitives) { } applyIdentityStyle(body, row); + body.style.height = + row.contentHeight === 'auto' && !hasExplicitRowHeight(row) + ? 'auto' + : '100%'; } function create( document: Document, @@ -741,7 +747,7 @@ export const identityRowRenderer = { ): number | undefined => { if (row.contentHeight !== 'auto' || hasExplicitRowHeight(row)) return undefined; - const height = body.getBoundingClientRect().height; + const height = body.offsetHeight; return height > 0 ? height : undefined; }, };