diff --git a/CHANGELOG.md b/CHANGELOG.md index 32779e44..ef097f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Features +- **native-list**: Add `snapshot.listStyle` for list chrome: `separator.inset`, `separator.color`, and `groupCornerRadius`, applied on all three platforms. The surface carries only what every platform can honour — pull to refresh is a system control on both native platforms, the section index belongs to `capabilities.sectionIndex`, and the reorder preview is drawn with platform-specific primitives — and `validateSnapshot` rejects any other key rather than accepting one that some platform would ignore. Every value is absent by default and each platform keeps its own number as the fallback, so an untouched list renders exactly as before; on Web the inset separator keeps a transparent border for layout and paints the line with a logical-inset overlay, so row heights do not move. - **native-list (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect. - **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. diff --git a/example/react-native/pages/NativeListRowStylePage.tsx b/example/react-native/pages/NativeListRowStylePage.tsx index a21966fa..fecf229b 100644 --- a/example/react-native/pages/NativeListRowStylePage.tsx +++ b/example/react-native/pages/NativeListRowStylePage.tsx @@ -161,6 +161,37 @@ function buildRows(styled: boolean): RowModel[] { : {}), }); + // listStyle is chrome, not a row: the separator inset and the group card + // radius below come from the snapshot, not from these rows. + rows.push(header('chrome', 'listStyle', 'separator / group')); + rows.push({ + type: 'identity', + key: 'chrome-separator', + sectionKey: 'chrome', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Separator inset', + subtitle: 'Inset comes from listStyle.separator', + separator: true, + }); + rows.push({ + type: 'identity', + key: 'chrome-group-first', + sectionKey: 'chrome', + groupId: 'chrome-card', + groupPosition: 'first', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Grouped card, first', + }); + rows.push({ + type: 'identity', + key: 'chrome-group-last', + sectionKey: 'chrome', + groupId: 'chrome-card', + groupPosition: 'last', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Grouped card, last', + }); + // Growing text needs an explicit height: row heights are not derived from the // style. See docs/STYLE_SPEC.md section 6.1. rows.push(header('height', 'explicit height', 'larger text')); @@ -194,6 +225,11 @@ export function NativeListRowStylePage() { generation: styled ? 2 : 1, layout: { kind: 'sectioned', stickyHeaders: true, contentPadding: 8 }, theme: THEME, + // Absent by default, so the chrome falls back to each platform's own + // numbers - which are not the same everywhere, see STYLE_SPEC section 6.2. + listStyle: styled + ? { separator: { inset: 20 }, groupCornerRadius: 16 } + : undefined, rows: buildRows(styled), }), [styled], diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt index 41a5b534..aa80dbbe 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt @@ -52,6 +52,13 @@ internal class NativeListAdapter( if (field?.toString() != value?.toString()) needsThemeRebind = true field = value } + // Reuses the theme rebind path: chrome lives outside the row payload, so a + // changed list style would otherwise not reach rows DiffUtil considers equal. + var listStyle: JSONObject? = null + set(value) { + if (field?.toString() != value?.toString()) needsThemeRebind = true + field = value + } var layout: String = "linear" var orientation: String = "vertical" var selectedKeys: Set = emptySet() @@ -79,6 +86,7 @@ internal class NativeListAdapter( override fun onBindViewHolder(holder: NativeListViewHolder, position: Int) { val item = itemAt(position) ?: return + holder.rowView.listStyle = listStyle holder.rowView.bind( item, theme, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 52ae588f..99fe2899 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -212,6 +212,7 @@ internal data class NativeListConfig( val sectionIndexHapticsEnabled: Boolean, val sectionIndexCenteredInWindow: Boolean, val theme: JSONObject?, + val listStyle: JSONObject?, val fixedFooter: NativeListItem?, val items: List, ) { @@ -261,6 +262,7 @@ internal data class NativeListConfig( sectionIndexHapticsEnabled = sectionIndex?.optBoolean("hapticsEnabled", true) ?: true, sectionIndexCenteredInWindow = sectionIndex?.optBoolean("centeredInWindow", false) ?: false, theme = root.optJSONObject("theme"), + listStyle = root.optJSONObject("listStyle"), fixedFooter = fixedFooter, items = items, ) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 72c039ed..6ef0a5ef 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -542,6 +542,12 @@ internal class NativeListRowView( private val separatorPaint = Paint(Paint.ANTI_ALIAS_FLAG) private var showsSeparator = false + /** + * docs/STYLE_SPEC.md section 5. Set by the adapter before bind, so the binder and + * groupedBackground can read it without another parameter. + */ + var listStyle: JSONObject? = null + var onRowPress: ((NativeListItem, NativeListActionOrigin) -> Unit)? = null var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Unit)? = null var onBindingInvalidated: ((NativeListRowView, Long) -> Unit)? = null @@ -722,7 +728,14 @@ internal class NativeListRowView( override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) if (showsSeparator) { - val start = if ((tag as? NativeListItem)?.type == "identity") dp(60).toFloat() else dp(12).toFloat() + val separatorStyle = listStyle?.optJSONObject("separator") + val start = if (separatorStyle?.has("inset") == true) { + dp(separatorStyle.optDouble("inset").roundToInt()).toFloat() + } else if ((tag as? NativeListItem)?.type == "identity") { + dp(60).toFloat() + } else { + dp(12).toFloat() + } canvas.drawLine(start, height - 1f, width.toFloat(), height - 1f, separatorPaint) } if (reorderActive && (tag as? NativeListItem)?.type == "walletGroup" && walletGroupDragChildCount > 0) { @@ -979,7 +992,10 @@ internal class NativeListRowView( status.setTextColor(secondary) metricSubtitle.setTextColor(secondary) badgeLine.setTextColor(accent) - separatorPaint.color = color(theme, "separator", "#0000001F") + separatorPaint.color = safeColor( + listStyle?.optJSONObject("separator")?.optString("color"), + color(theme, "separator", "#0000001F"), + ) separatorPaint.strokeWidth = 1f showsSeparator = item.json.optBoolean("separator", false) && !item.key.startsWith("token-") && @@ -1724,6 +1740,7 @@ internal class NativeListRowView( memberRow.onBindingInvalidated = { row, epoch -> onBindingInvalidated?.invoke(row, epoch) } + memberRow.listStyle = listStyle memberRow.bind( member, theme, @@ -4420,7 +4437,9 @@ internal class NativeListRowView( private fun groupedBackground(position: String, color: Int) = GradientDrawable().apply { setColor(color) - val radius = scaledDp(12f) + val radius = listStyle?.takeIf { it.has("groupCornerRadius") } + ?.let { scaledDp(it.optDouble("groupCornerRadius").toFloat()) } + ?: scaledDp(12f) cornerRadii = when (position) { "first" -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) "last" -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index c4299d5c..3e2f6954 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -500,6 +500,7 @@ class NativeListView( usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } adapter.usesSelectorSourceScale = usesSelectorSourceScale adapter.theme = next.theme + adapter.listStyle = next.listStyle adapter.layout = next.layout adapter.orientation = next.orientation adapter.selectedKeys = next.selectedKeys @@ -1381,6 +1382,7 @@ class NativeListView( footerView.recycle() } else { footerView.visibility = VISIBLE + footerView.listStyle = next.listStyle footerView.bind( footer, next.theme, diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 476f5d38..b05d929c 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -55,6 +55,7 @@ object identity. | Contract, validation, token resolution, patch support | Implemented | | Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented on Web, iOS and Android | | `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides | +| `listStyle.separator` and `listStyle.groupCornerRadius` | Implemented on Web, iOS and Android | | `dataRow` columns in the `table` layout | Not applied. The text lives inside `NativeListTableColumnView` / `tableDataColumns`, which own their own labels; the `linear` layout is covered | | Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | | Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification | @@ -330,8 +331,8 @@ not yet implemented). | --- | --- | --- | --- | --- | | Content padding | yes (`layout.contentPadding*`) | `contentInset` | `setPadding` | `paddingValues` | | Item spacing | yes (`layout.itemSpacing`) | flow layout spacing | `ItemSpacingDecoration` | layout gap | -| Separator | partial (`row.separator`) | 1/scale, inset 60/12 | 1px, inset dp(60)/dp(12) | `1px`, class rule | -| Group card radius | no | 20 / 12 | dp(12) | 12px | +| Separator | **yes** — `listStyle.separator.{inset,color}` | 1/scale, inset 60/12 | 1px, inset dp(60)/dp(12) | `1px`, class rule, **no inset** | +| Group card radius | **yes** — `listStyle.groupCornerRadius` | 20 / 12 | dp(12) | 12px | | Section index rail | partial (`capabilities.sectionIndex`) | static constants, label 10 | `SECTION_INDEX_*_DP`, raw density | `SECTION_INDEX_*` + CSS | | Section index preview | no | 48×48 r14, 22 semibold | preview w/h/margin constants | 48px r14, 22px | | Pull to refresh | partial (`capabilities.pullToRefresh`) | `UIRefreshControl` | `SwipeRefreshLayout` | custom pill, 12px | @@ -341,6 +342,25 @@ not yet implemented). The section index rail is three separate constant sets for one control; Android's `sectionIndexDp()` deliberately never follows the source-scale switch. +`listStyle` carries only what all three platforms can honour. The rest of this table +is deliberately excluded rather than declared and half-implemented: + +- **Pull to refresh** is a system control on both native platforms (`UIRefreshControl`, + `SwipeRefreshLayout`); only Web draws its own indicator. +- **Section index rail and preview** belong to `capabilities.sectionIndex`, which is + where their geometry should go if it is ever exposed, and are three independent + constant sets today. +- **Reorder preview and count badge** are drawn with platform-specific primitives — + Android paints the badge on `Canvas` inside `dispatchDraw`, Web uses a CSS overlay, + and iOS has neither. +- **Content padding and item spacing** are already `layout.contentPadding*` and + `layout.itemSpacing`; duplicating them here would give one value two homes. + +Every `listStyle` value is absent by default, and each platform keeps its own number +as the fallback, so an untouched list renders exactly as before. On Web that required +the inset separator to keep the transparent `border-bottom` for layout and paint the +visible line with a logical-inset overlay, rather than moving the row by a pixel. + ## 6. T4 — Known divergences Registered, **not** fixed by this document. Changing any of these requires a PR @@ -382,6 +402,12 @@ here and left alone. | `metricCard.value` | 18 semibold | sp(18) semibold | **22 / 28 / 700** | | visual `rounded` radius | `min(10, h/4)` | `min(10, size/4)`; 8 for accountSelector | **10px**; 8 `!important` for account action; 8 for market | | `walletSidebar` row radius | **20** | 12 | 12 | +| separator default inset | 60 identity / 12 | dp(60) identity / dp(12) | **0** | +| separator thickness | `1 / scale` (hairline) | `1f` raw px | `1px` | + +Separator inset and colour are now settable through `listStyle.separator` (§5); the +defaults above are what a list gets when it does not set them. Thickness is not +exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. ### 6.3 Behavioral diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index d88e2a60..2b4d7b85 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -556,6 +556,10 @@ final class NativeListCell: UICollectionViewCell { private var selectorImageRetries: [ObjectIdentifier: DispatchWorkItem] = [:] private(set) var bindingEpoch = 0 + /// docs/STYLE_SPEC.md §5. Set by the list before `bind`, so the row binder and + /// `applyGroupPosition` can read it without another parameter. + var listStyle: [String: Any]? + var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? var onBindingInvalidated: ((NativeListCell, Int) -> Void)? @@ -950,8 +954,14 @@ final class NativeListCell: UICollectionViewCell { mediaBadgeLabel.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020") mediaBadgeLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") mediaBadgeLabel.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor - separatorView.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0") - separatorLeadingConstraint.constant = item.type == "identity" ? 60 : 12 + let separatorStyle = listStyle?.dictionary("separator") + let separatorColor = nativeListColor(theme, "separator", "#E0E0E0") + separatorView.backgroundColor = (separatorStyle?["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: separatorColor) } + ?? separatorColor + separatorLeadingConstraint.constant = separatorStyle?["inset"] == nil + ? (item.type == "identity" ? 60 : 12) + : CGFloat(separatorStyle?.double("inset") ?? 0) separatorView.isHidden = !item.data.bool("separator") restingBackgroundColor = selectionBackgroundColor( item: item, @@ -1545,6 +1555,7 @@ final class NativeListCell: UICollectionViewCell { memberCell.onBindingInvalidated = { [weak self] cell, epoch in self?.onBindingInvalidated?(cell, epoch) } + memberCell.listStyle = listStyle memberCell.bind( item: member, theme: theme, @@ -1556,6 +1567,7 @@ final class NativeListCell: UICollectionViewCell { rootStack.addArrangedSubview(memberCell) } if let parent = walletGroupMembers.first { + walletGroupCompactCell?.listStyle = listStyle walletGroupCompactCell?.bind( item: parent, theme: theme, @@ -4414,7 +4426,9 @@ final class NativeListCell: UICollectionViewCell { } let isWalletSidebar = currentItem?.type == "identity" && currentItem?.data.string("presentation") == "walletSidebar" - layer.cornerRadius = isWalletSidebar ? 20 : 12 + layer.cornerRadius = listStyle?["groupCornerRadius"] == nil + ? (isWalletSidebar ? 20 : 12) + : CGFloat(listStyle?.double("groupCornerRadius") ?? 12) layer.cornerCurve = isWalletSidebar ? .continuous : .circular layer.masksToBounds = true } diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 195713a1..9b37268e 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -70,6 +70,7 @@ struct NativeListConfig { let sectionIndexHapticsEnabled: Bool let sectionIndexCenteredInWindow: Bool let theme: [String: Any]? + let listStyle: [String: Any]? let fixedFooter: NativeListItem? var items: [NativeListItem] @@ -132,6 +133,7 @@ struct NativeListConfig { sectionIndexHapticsEnabled: sectionIndex?["hapticsEnabled"] as? Bool ?? true, sectionIndexCenteredInWindow: sectionIndex?["centeredInWindow"] as? Bool ?? false, theme: root["theme"] as? [String: Any], + listStyle: root["listStyle"] as? [String: Any], fixedFooter: footer, items: items ) diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 59064de8..2454b4be 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -351,7 +351,10 @@ final class NativeListView: UIView { cancelInteractiveReorderForStructuralUpdate() } let oldItems = itemsByKey + // Chrome lives outside the row payload, so a changed list style must rebind + // rows whose own content is unchanged. let themeChanged = !dictionariesEqual(config?.theme, next.theme) + || !dictionariesEqual(config?.listStyle, next.listStyle) if config?.generation != next.generation { endReachedGeneration = nil } config = next itemsByKey = Dictionary(uniqueKeysWithValues: next.items.map { ($0.key, $0) }) @@ -1317,6 +1320,7 @@ final class NativeListView: UIView { private func bind(cell: NativeListCell, item: NativeListItem, itemIndex: Int? = nil) { guard let config else { return } + cell.listStyle = config.listStyle cell.bind( item: item, theme: config.theme, @@ -1469,6 +1473,7 @@ final class NativeListView: UIView { current.layout == next.layout, current.orientation == next.orientation, current.gridColumns == next.gridColumns, + dictionariesEqual(current.listStyle, next.listStyle), current.stickyHeaders == next.stickyHeaders, current.contentPadding == next.contentPadding, current.contentPaddingHorizontal == next.contentPaddingHorizontal, @@ -1516,6 +1521,7 @@ final class NativeListView: UIView { guard current.layout == next.layout, current.orientation == next.orientation, current.gridColumns == next.gridColumns, + dictionariesEqual(current.listStyle, next.listStyle), current.stickyHeaders == next.stickyHeaders, current.contentPadding == next.contentPadding, current.contentPaddingHorizontal == next.contentPaddingHorizontal, diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 2bbe119e..5456e974 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -539,6 +539,20 @@ describe('web row style', () => { expect(value?.style.fontWeight).toBe('700'); }); + it("keeps today's numbers as the chrome fallbacks", () => { + // An untouched list must render exactly as before, so every chrome variable + // carries the current value as its CSS fallback. + expect(WEB_LIST_CSS).toContain( + 'border-bottom:1px solid var(--nl-separator-color,var(--nl-separator))' + ); + expect(WEB_LIST_CSS).toContain('border-radius:var(--nl-group-radius,12px)'); + // The inset variant keeps the transparent border so row height is unchanged. + expect(WEB_LIST_CSS).toContain('border-bottom-color:transparent'); + expect(WEB_LIST_CSS).toContain( + 'inset-inline-start:var(--nl-separator-inset,0)' + ); + }); + it('applies box padding only when the row asks for it', () => { const base: RowModel = { type: 'identity', diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 8aa3974a..31b9de0d 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1302,6 +1302,35 @@ describe('NativeList style contract', () => { ).toThrow('is not a style key of the "identity" template'); }); + it('accepts list chrome and rejects anything outside it', () => { + const withChrome = (listStyle: unknown): NativeListSnapshot => + ({ ...snapshot(), listStyle } as NativeListSnapshot); + expect( + validateSnapshot( + withChrome({ + separator: { inset: 16, color: '#E0E0E0' }, + groupCornerRadius: 8, + }) + ).listStyle + ).toEqual({ + separator: { inset: 16, color: '#E0E0E0' }, + groupCornerRadius: 8, + }); + expect(() => + validateSnapshot(withChrome({ separator: { inset: 65 } })) + ).toThrow('separator.inset'); + expect(() => + validateSnapshot(withChrome({ groupCornerRadius: 41 })) + ).toThrow('groupCornerRadius'); + // Chrome the platforms cannot all honour is rejected rather than ignored. + expect(() => validateSnapshot(withChrome({ pullToRefresh: {} }))).toThrow( + 'is not a list style key' + ); + expect(() => + validateSnapshot(withChrome({ separator: { thickness: 2 } })) + ).toThrow('is not a separator style key'); + }); + it('keeps the Market style surface intact', () => { const [first] = validateSnapshot( snapshot([ diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index bfa396db..10de9daa 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -654,6 +654,28 @@ export type SectionIndexConfig = Readonly<{ centeredInWindow?: boolean; }>; +export type NativeListSeparatorStyle = Readonly<{ + /** + * Leading inset in logical pixels. Omitted keeps each platform's own default, + * which is not the same everywhere — see docs/STYLE_SPEC.md §6.2. + */ + inset?: number; + /** Overrides the `separator` theme token for this list only. */ + color?: string; +}>; + +/** + * List chrome that every platform can honour. Pull to refresh, the section index + * rail and the reorder preview are deliberately absent: the first two are system + * controls or three independent constant sets, and the third is drawn with + * platform-specific primitives. See docs/STYLE_SPEC.md §5. + */ +export type NativeListListStyle = Readonly<{ + separator?: NativeListSeparatorStyle; + /** Corner radius of a `groupId` card. Does not affect rail or media tiles. */ + groupCornerRadius?: number; +}>; + export type NativeListSnapshot = Readonly<{ schemaVersion: 1; generation: number; @@ -685,6 +707,7 @@ export type NativeListSnapshot = Readonly<{ emptyState?: ActionRow | SystemRow; fixedFooter?: ActionRow | SystemRow; theme?: NativeListTheme; + listStyle?: NativeListListStyle; }>; // OneKey patch: include selector-only mutable presentation fields. diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index 214ae76c..32d09446 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -5,6 +5,7 @@ import type { LeadingVisual, MarketRow, MarketRowStyle, + NativeListListStyle, NativeListSnapshot, NativeListTextStyle, NativeListTypographyToken, @@ -347,6 +348,62 @@ function assertRowStyle(row: RowModel, path: string): void { ); } +const LIST_STYLE_KEYS: readonly string[] = ['separator', 'groupCornerRadius']; +const SEPARATOR_STYLE_KEYS: readonly string[] = ['inset', 'color']; + +function assertUnknownKeys( + value: Record, + allowed: readonly string[], + path: string, + subject: string +): void { + Object.keys(value).forEach((key) => { + if (!allowed.includes(key)) fail(`${path}.${key}`, `is not a ${subject}`); + }); +} + +function assertListStyle( + listStyle: NativeListListStyle | undefined, + path: string +): void { + if (listStyle === undefined) return; + if ( + typeof listStyle !== 'object' || + listStyle === null || + Array.isArray(listStyle) + ) { + fail(path, 'must be an object'); + } + assertUnknownKeys( + listStyle as Record, + LIST_STYLE_KEYS, + path, + 'list style key' + ); + assertBoundedStyleNumber( + listStyle.groupCornerRadius, + `${path}.groupCornerRadius`, + 0, + 40 + ); + const separator = listStyle.separator; + if (separator === undefined) return; + if ( + typeof separator !== 'object' || + separator === null || + Array.isArray(separator) + ) { + fail(`${path}.separator`, 'must be an object'); + } + assertUnknownKeys( + separator as Record, + SEPARATOR_STYLE_KEYS, + `${path}.separator`, + 'separator style key' + ); + assertBoundedStyleNumber(separator.inset, `${path}.separator.inset`, 0, 64); +} + /** Token resolution is idempotent: a resolved style carries no token. */ function resolveTextStyle( style: NativeListTextStyle | undefined @@ -1162,6 +1219,8 @@ export function validateSnapshot( } } + assertListStyle(snapshot.listStyle, 'snapshot.listStyle'); + const rowKeys = new Set(); const sectionIndexTitles = new Set(); snapshot.rows.forEach((row, index) => { diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 5e89b68f..a90cf9a5 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -913,10 +913,13 @@ export const WEB_LIST_CSS = ` .ok-native-list-reorder-preview>.ok-native-list-row{background:var(--nl-pressed);cursor:grabbing} .ok-native-list-reorder-preview[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)} .ok-native-list-reorder-count{position:absolute;right:4px;bottom:4px;display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:24px;height:24px;padding:0 6px;border:1px solid var(--nl-row);border-radius:12px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;line-height:22px;font-weight:600} -.ok-native-list-item[data-separator="true"]>.ok-native-list-row{border-bottom:1px solid var(--nl-separator)} -.ok-native-list-item[data-group-position="first"]>.ok-native-list-row{border-radius:12px 12px 0 0} -.ok-native-list-item[data-group-position="last"]>.ok-native-list-row{border-radius:0 0 12px 12px} -.ok-native-list-item[data-group-position="single"]>.ok-native-list-row{border-radius:12px} +.ok-native-list-item[data-separator="true"]>.ok-native-list-row{border-bottom:1px solid var(--nl-separator-color,var(--nl-separator))} +/* An inset separator keeps the transparent border so the row's height is unchanged, and paints the visible line with a logical-inset overlay so it follows RTL. */ +.ok-native-list-root[data-separator-inset="true"] .ok-native-list-item[data-separator="true"]>.ok-native-list-row{position:relative;border-bottom-color:transparent} +.ok-native-list-root[data-separator-inset="true"] .ok-native-list-item[data-separator="true"]>.ok-native-list-row::after{content:"";position:absolute;inset-inline-start:var(--nl-separator-inset,0);inset-inline-end:0;bottom:-1px;height:1px;background:var(--nl-separator-color,var(--nl-separator))} +.ok-native-list-item[data-group-position="first"]>.ok-native-list-row{border-radius:var(--nl-group-radius,12px) var(--nl-group-radius,12px) 0 0} +.ok-native-list-item[data-group-position="last"]>.ok-native-list-row{border-radius:0 0 var(--nl-group-radius,12px) var(--nl-group-radius,12px)} +.ok-native-list-item[data-group-position="single"]>.ok-native-list-row{border-radius:var(--nl-group-radius,12px)} .ok-native-list-standard{padding:8px 12px}.ok-native-list-network-row{padding:0 12px}.ok-native-list-wallet-row{padding:4px 8px;flex-direction:column;justify-content:center;gap:4px}.ok-native-list-account-row{padding:4px 12px;gap:8px} .ok-native-list-account-row .ok-native-list-visual,.ok-native-list-account-action-row .ok-native-list-visual{width:32px;height:32px;flex-basis:32px}.ok-native-list-account-row .ok-native-list-visual>img,.ok-native-list-account-row .ok-native-list-visual-main,.ok-native-list-account-action-row .ok-native-list-visual>img,.ok-native-list-account-action-row .ok-native-list-visual-main{width:32px;height:32px}.ok-native-list-account-row .ok-native-list-visual>.ok-native-list-visual-corner{width:20px;height:20px;padding:2px}.ok-native-list-account-action-row .ok-native-list-visual{border-radius:8px!important}.ok-native-list-wallet-row .ok-native-list-title{color:var(--nl-secondary);font-size:12px;line-height:16px;font-weight:400}.ok-native-list-item[data-native-list-selected="true"]>.ok-native-list-wallet-row .ok-native-list-title{color:var(--nl-primary)}.ok-native-list-account-row .ok-native-list-title{font-size:16px;line-height:20px;font-weight:400}.ok-native-list-account-row .ok-native-list-secondary{font-size:14px;line-height:20px;font-weight:400} .ok-native-list-flex{display:flex;flex:1;min-width:0;flex-direction:column;justify-content:center}.ok-native-list-title{font-size:15px;line-height:20px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-secondary{font-size:13px;line-height:18px;color:var(--nl-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-tertiary{color:var(--nl-secondary)}.ok-native-list-info{color:var(--nl-info)} @@ -4134,6 +4137,31 @@ export class NativeListWebEngine { this.indexRail.style.setProperty(name, value); this.reorderPreview.style.setProperty(name, value); }); + this.applyListStyle(); + } + + /** + * List chrome from `snapshot.listStyle`. Every value is absent by default and + * the stylesheet carries today's number as the fallback, so an untouched list + * renders exactly as before. See docs/STYLE_SPEC.md §5. + */ + private applyListStyle() { + const listStyle = this.snapshot.listStyle; + const inset = listStyle?.separator?.inset; + setData(this.root, 'separatorInset', inset !== undefined && inset > 0); + const chrome: Readonly> = { + '--nl-separator-inset': + inset === undefined ? undefined : String(inset) + 'px', + '--nl-separator-color': listStyle?.separator?.color, + '--nl-group-radius': + listStyle?.groupCornerRadius === undefined + ? undefined + : String(listStyle.groupCornerRadius) + 'px', + }; + Object.entries(chrome).forEach(([name, value]) => { + if (value === undefined) this.root.style.removeProperty(name); + else this.root.style.setProperty(name, value); + }); } private recomputeLayout = () => {