@proulr/sdui-components 0.1.88 → 0.1.91

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 (33) hide show
  1. package/package.json +2 -2
  2. package/src/bdc/compile-bdc-row-template.test.ts +179 -0
  3. package/src/bdc/compile-bdc-to-wire.test.ts +11 -0
  4. package/src/bdc/compile-bdc-to-wire.ts +52 -1
  5. package/src/bdc/components/bdc-components.test.ts +9 -0
  6. package/src/bdc/components/discover.tsx +7 -1
  7. package/src/bdc/components/list.test.ts +36 -0
  8. package/src/bdc/components/list.tsx +62 -0
  9. package/src/bdc/components/media.test.ts +268 -0
  10. package/src/bdc/components/media.tsx +275 -0
  11. package/src/bdc/components/native-blocks.tsx +7 -1
  12. package/src/bdc/components/ui.tsx +33 -0
  13. package/src/bdc/index.ts +5 -0
  14. package/src/catalog/block-types.ts +5 -0
  15. package/src/renderer/SduiTemplateRow.tsx +63 -0
  16. package/src/renderer/blocks/SduiContentBlocks.tsx +299 -0
  17. package/src/renderer/blocks/SduiDiscoverBlocks.tsx +39 -3
  18. package/src/renderer/blocks/SduiMediaBlocks.tsx +348 -0
  19. package/src/renderer/blocks/SduiNativeDomainBlocks.tsx +2 -0
  20. package/src/renderer/blocks/SduiPrimitiveBlocks.tsx +22 -3
  21. package/src/renderer/index.ts +48 -0
  22. package/src/renderer/lib/sdui-media-block-props.ts +282 -0
  23. package/src/renderer/lib/sdui-media-blocks.test.ts +279 -0
  24. package/src/renderer/lib/sdui-media-uri.ts +39 -0
  25. package/src/renderer/lib/sdui-row-template.test.ts +190 -0
  26. package/src/renderer/lib/sdui-row-template.ts +153 -0
  27. package/src/renderer/sdui-component-registry.tsx +16 -0
  28. package/src/renderer/sdui-hybrid-renderer-context.tsx +2 -0
  29. package/src/renderer/sdui-media-resolver-context.tsx +84 -0
  30. package/src/renderer/sdui-video-player-context.tsx +55 -0
  31. package/src/renderer/theme/resolve-style-props.ts +2 -0
  32. package/src/renderer/ui/Chip.tsx +25 -8
  33. package/src/renderer/ui/ChipRow.tsx +14 -1
@@ -0,0 +1,63 @@
1
+ import React, { useMemo } from 'react';
2
+ import type { SduiBlock, SduiBlockWire } from '@proulr/sdui-contract';
3
+ import { renderBlockTree } from './sdui-component-registry';
4
+ import { materializeRowTemplate, type SduiRowTemplateSource } from './lib/sdui-row-template';
5
+
6
+ type Props = {
7
+ /** Props do bloco hospedeiro (`{ row_template, item_key, section }`) ou array nu de blocos do template. */
8
+ template: SduiRowTemplateSource | null | undefined;
9
+ /** Item vivo do feed (`screen.data[...]`). */
10
+ item: unknown;
11
+ /** Índice do item — fallback do sufixo de id quando `item_key` não resolve. */
12
+ index?: number;
13
+ /** `screen.data` repassado ao renderer de blocos. */
14
+ data?: Record<string, unknown>;
15
+ onAction: (actionId: string, vars?: Record<string, string>) => void;
16
+ };
17
+
18
+ function toDomainBlock(block: SduiBlockWire): SduiBlock {
19
+ return {
20
+ id: block.id,
21
+ type: block.type,
22
+ props: block.props ?? {},
23
+ children: (block.children ?? []).map(toDomainBlock),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Renderiza **uma** linha de um row template wire-driven: materializa o subtree
29
+ * do bloco template para o item (bindings `{{item.*}}` + ids estáveis) e monta
30
+ * via registry de blocos do SDUI (`renderBlockTree`) — componentes genéricos,
31
+ * sem layout de linha hardcoded no híbrido.
32
+ *
33
+ * ```tsx
34
+ * {items.map((post, i) => (
35
+ * <SduiTemplateRow
36
+ * key={post.id}
37
+ * template={readSduiRowTemplate(block.props)}
38
+ * item={post}
39
+ * index={i}
40
+ * data={data}
41
+ * onAction={onAction}
42
+ * />
43
+ * ))}
44
+ * ```
45
+ */
46
+ export function SduiTemplateRow({ template, item, index = 0, data = {}, onAction }: Props) {
47
+ const blocks = useMemo(
48
+ () => (template ? materializeRowTemplate(template, item, index) : []),
49
+ [template, item, index],
50
+ );
51
+ return (
52
+ <>
53
+ {blocks.map((block) => {
54
+ const domainBlock = toDomainBlock(block);
55
+ return (
56
+ <React.Fragment key={domainBlock.id}>
57
+ {renderBlockTree(domainBlock, data, onAction)}
58
+ </React.Fragment>
59
+ );
60
+ })}
61
+ </>
62
+ );
63
+ }
@@ -0,0 +1,299 @@
1
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
2
+ import { fonts, m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
3
+ import { AppIcon } from '../ui/app-icon';
4
+ import { isAppIconName, type AppIconName } from '../../catalog/app-icon-names';
5
+ import { resolveDataRef } from '../lib/resolve-data-ref';
6
+ import type { SduiBlockRenderProps } from './SduiPrimitiveBlocks';
7
+
8
+ function itemsFrom(
9
+ block: SduiBlockRenderProps['block'],
10
+ data: Record<string, unknown>,
11
+ fallback = '',
12
+ ): unknown[] {
13
+ const refProp = block.props.items_ref;
14
+ const ref = typeof refProp === 'string' && refProp ? refProp : fallback;
15
+ if (!ref) return [];
16
+ const raw = resolveDataRef(data, ref);
17
+ return Array.isArray(raw) ? raw : [];
18
+ }
19
+
20
+ function asRecord(item: unknown): Record<string, unknown> | null {
21
+ return item && typeof item === 'object' ? (item as Record<string, unknown>) : null;
22
+ }
23
+
24
+ function toText(value: unknown): string {
25
+ if (typeof value === 'string') return value;
26
+ if (value != null) return String(value);
27
+ return '';
28
+ }
29
+
30
+ function toIcon(value: unknown): AppIconName | undefined {
31
+ return typeof value === 'string' && isAppIconName(value) ? value : undefined;
32
+ }
33
+
34
+ /* ── stat_tile_row ─────────────────────────────────────────────────────────── */
35
+
36
+ type StatTile = {
37
+ icon?: AppIconName;
38
+ value: string;
39
+ label: string;
40
+ actionId?: string;
41
+ actionParams?: Record<string, string>;
42
+ };
43
+
44
+ function chunk<T>(items: T[], size: number): T[][] {
45
+ const rows: T[][] = [];
46
+ for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
47
+ return rows;
48
+ }
49
+
50
+ function StatTileView({
51
+ tile,
52
+ card,
53
+ onAction,
54
+ }: {
55
+ tile: StatTile;
56
+ card: boolean;
57
+ onAction: SduiBlockRenderProps['onAction'];
58
+ }) {
59
+ const content = card ? (
60
+ <>
61
+ <View style={styles.cardHead}>
62
+ {tile.icon ? (
63
+ <View style={styles.cardIcon}>
64
+ <AppIcon name={tile.icon} size={16} color={m3Colors.primary} />
65
+ </View>
66
+ ) : null}
67
+ {tile.actionId ? <AppIcon name="arrow-up-right" size={16} color={m3Colors.onSurfaceVariant} /> : null}
68
+ </View>
69
+ <Text style={styles.cardValue} numberOfLines={2}>{tile.value}</Text>
70
+ <Text style={styles.label} numberOfLines={1}>{tile.label}</Text>
71
+ </>
72
+ ) : (
73
+ <>
74
+ {tile.icon ? <AppIcon name={tile.icon} size={17} color={m3Colors.onSurfaceVariant} /> : null}
75
+ <Text style={styles.value} numberOfLines={1}>{tile.value}</Text>
76
+ <Text style={styles.label} numberOfLines={1}>{tile.label}</Text>
77
+ </>
78
+ );
79
+ const tileStyle = card ? styles.card : styles.tile;
80
+ const actionId = tile.actionId;
81
+ if (!actionId) return <View style={tileStyle}>{content}</View>;
82
+ return (
83
+ <Pressable
84
+ accessibilityRole="link"
85
+ accessibilityLabel={`${tile.label} ${tile.value}`.trim()}
86
+ onPress={() => onAction(actionId, tile.actionParams)}
87
+ style={({ pressed }) => [tileStyle, card && styles.cardLink, pressed && styles.pressed]}
88
+ >
89
+ {content}
90
+ </Pressable>
91
+ );
92
+ }
93
+
94
+ /** Tiles genéricos de métrica (ícone + valor + rótulo) — layout vem do wire, regra do BFF. */
95
+ export function SduiStatTileRowBlock({ block, data, onAction }: SduiBlockRenderProps) {
96
+ const tiles: StatTile[] = itemsFrom(block, data, 'stats')
97
+ .map(asRecord)
98
+ .filter((row): row is Record<string, unknown> => row !== null)
99
+ .map((row) => ({
100
+ icon: toIcon(row.icon),
101
+ value: toText(row.value),
102
+ label: toText(row.label),
103
+ actionId: typeof row.action_id === 'string' && row.action_id ? row.action_id : undefined,
104
+ actionParams:
105
+ row.action_params && typeof row.action_params === 'object'
106
+ ? (row.action_params as Record<string, string>)
107
+ : undefined,
108
+ }))
109
+ .filter((tile) => tile.value || tile.label);
110
+ if (tiles.length === 0) return null;
111
+
112
+ const columnsProp = block.props.columns;
113
+ const columns =
114
+ typeof columnsProp === 'number' && Number.isInteger(columnsProp) && columnsProp > 0
115
+ ? columnsProp
116
+ : tiles.length;
117
+ const card = block.props.align === 'start';
118
+ const rows = chunk(tiles, columns);
119
+
120
+ return (
121
+ <View style={styles.grid} accessibilityRole="list">
122
+ {rows.map((row, rIndex) => (
123
+ <View key={`row-${rIndex}`} style={styles.row}>
124
+ {row.map((tile, index) => (
125
+ <StatTileView key={`${tile.label}-${index}`} tile={tile} card={card} onAction={onAction} />
126
+ ))}
127
+ {Array.from({ length: columns - row.length }, (_, pad) => (
128
+ <View key={`pad-${pad}`} style={styles.pad} />
129
+ ))}
130
+ </View>
131
+ ))}
132
+ </View>
133
+ );
134
+ }
135
+
136
+ /* ── data_group_list ──────────────────────────────────────────────────────── */
137
+
138
+ /** Grupos de detalhes (cabeçalho com ícone + linhas rótulo→valor). Reutilizável em qualquer tela. */
139
+ export function SduiDataGroupListBlock({ block, data }: SduiBlockRenderProps) {
140
+ const groups = itemsFrom(block, data)
141
+ .map(asRecord)
142
+ .filter((g): g is Record<string, unknown> => g !== null)
143
+ .map((group) => {
144
+ const rowsRaw = Array.isArray(group.rows) ? group.rows : [];
145
+ const rows = rowsRaw
146
+ .map(asRecord)
147
+ .filter((r): r is Record<string, unknown> => r !== null)
148
+ .map((row) => ({ label: toText(row.label), value: toText(row.value) }))
149
+ .filter((row) => row.label || row.value);
150
+ return {
151
+ icon: toIcon(group.icon),
152
+ title: toText(group.title),
153
+ rows,
154
+ };
155
+ })
156
+ .filter((group) => group.rows.length > 0);
157
+ if (groups.length === 0) return null;
158
+
159
+ return (
160
+ <View style={styles.groups}>
161
+ {groups.map((group, gIndex) => (
162
+ <View key={`${group.title}-${gIndex}`} style={styles.group}>
163
+ <View style={styles.groupHead} accessibilityRole="header">
164
+ {group.icon ? <AppIcon name={group.icon} size={16} color={m3Colors.onSurface} /> : null}
165
+ <Text style={styles.groupTitle}>{group.title}</Text>
166
+ </View>
167
+ {group.rows.map((row, rIndex) => (
168
+ <View key={`${row.label}-${rIndex}`} style={styles.dataRow}>
169
+ <Text style={styles.dataLabel} numberOfLines={1}>{row.label}</Text>
170
+ <Text style={styles.dataValue}>{row.value}</Text>
171
+ </View>
172
+ ))}
173
+ </View>
174
+ ))}
175
+ </View>
176
+ );
177
+ }
178
+
179
+ /* ── vouch_list ───────────────────────────────────────────────────────────── */
180
+
181
+ /** Cartões de citação/recomendação (cabeçalho + citações com autor). Reutilizável (depoimentos, reviews). */
182
+ export function SduiVouchListBlock({ block, data }: SduiBlockRenderProps) {
183
+ const itemsRefProp = block.props.items_ref;
184
+ const itemsRef = typeof itemsRefProp === 'string' && itemsRefProp ? itemsRefProp : 'vouches';
185
+ const container = asRecord(resolveDataRef(data, itemsRef));
186
+ const header = container ? toText(container.header) : '';
187
+ const items = (container && Array.isArray(container.items) ? container.items : [])
188
+ .map(asRecord)
189
+ .filter((i): i is Record<string, unknown> => i !== null)
190
+ .map((item) => ({
191
+ quote: toText(item.quote),
192
+ author: toText(item.author_name),
193
+ meta: toText(item.meta),
194
+ }))
195
+ .filter((item) => item.quote);
196
+ if (!header && items.length === 0) return null;
197
+
198
+ return (
199
+ <View style={styles.vouchWrap}>
200
+ {header ? <Text style={styles.vouchHeader}>{header}</Text> : null}
201
+ {items.map((item, index) => (
202
+ <View key={`vouch-${index}`} style={styles.vouchCard}>
203
+ <Text style={styles.vouchQuote}>“{item.quote}”</Text>
204
+ <View style={styles.vouchFooter}>
205
+ <Text style={styles.vouchAuthor}>{item.author}</Text>
206
+ {item.meta ? <Text style={styles.vouchMeta}>{item.meta}</Text> : null}
207
+ </View>
208
+ </View>
209
+ ))}
210
+ </View>
211
+ );
212
+ }
213
+
214
+ const styles = StyleSheet.create({
215
+ grid: { gap: spacing.sm },
216
+ row: { flexDirection: 'row', gap: spacing.sm },
217
+ pad: { flex: 1 },
218
+ tile: {
219
+ flex: 1,
220
+ minHeight: 64,
221
+ borderRadius: m3Shape.cornerMedium,
222
+ backgroundColor: m3Colors.surfaceContainerHigh,
223
+ borderWidth: 1,
224
+ borderColor: m3Colors.outlineVariant,
225
+ alignItems: 'center',
226
+ justifyContent: 'center',
227
+ gap: 1,
228
+ paddingVertical: spacing.sm,
229
+ paddingHorizontal: spacing.xs,
230
+ },
231
+ value: { ...m3Type.title, color: m3Colors.onSurface, fontFamily: fonts.extrabold },
232
+ label: { ...m3Type.labelSmall, color: m3Colors.onSurfaceVariant },
233
+ card: {
234
+ flex: 1,
235
+ minWidth: 0,
236
+ minHeight: 92,
237
+ borderRadius: m3Shape.cornerMedium,
238
+ backgroundColor: m3Colors.surfaceContainerHigh,
239
+ borderWidth: 1,
240
+ borderColor: m3Colors.outlineVariant,
241
+ gap: spacing.xs,
242
+ padding: spacing.sm + 4,
243
+ },
244
+ cardLink: { borderColor: m3Colors.outlineInteractive },
245
+ cardHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
246
+ cardIcon: {
247
+ width: 28,
248
+ height: 28,
249
+ borderRadius: m3Shape.cornerSmall,
250
+ backgroundColor: m3Colors.surfaceContainerHighest,
251
+ alignItems: 'center',
252
+ justifyContent: 'center',
253
+ },
254
+ cardValue: { ...m3Type.body, color: m3Colors.onSurface, fontFamily: fonts.bold },
255
+ pressed: { opacity: 0.85 },
256
+
257
+ groups: { gap: spacing.md },
258
+ group: {
259
+ borderRadius: m3Shape.cornerMedium,
260
+ backgroundColor: m3Colors.surfaceContainerHigh,
261
+ borderWidth: 1,
262
+ borderColor: m3Colors.outlineVariant,
263
+ overflow: 'hidden',
264
+ },
265
+ groupHead: {
266
+ flexDirection: 'row',
267
+ alignItems: 'center',
268
+ gap: spacing.sm,
269
+ backgroundColor: 'rgba(255,255,255,0.08)',
270
+ paddingHorizontal: spacing.md,
271
+ paddingVertical: spacing.sm,
272
+ },
273
+ groupTitle: { ...m3Type.label, color: m3Colors.onSurface, fontFamily: fonts.bold },
274
+ dataRow: {
275
+ flexDirection: 'row',
276
+ justifyContent: 'space-between',
277
+ alignItems: 'flex-start',
278
+ gap: spacing.md,
279
+ paddingHorizontal: spacing.md,
280
+ paddingVertical: spacing.sm,
281
+ },
282
+ dataLabel: { ...m3Type.bodySmall, color: m3Colors.onSurfaceVariant, flexShrink: 0 },
283
+ dataValue: { ...m3Type.bodySmall, color: m3Colors.onSurface, fontFamily: fonts.semibold, flex: 1, textAlign: 'right' },
284
+
285
+ vouchWrap: { gap: spacing.sm },
286
+ vouchHeader: { ...m3Type.bodySmall, color: m3Colors.onSurfaceVariant },
287
+ vouchCard: {
288
+ borderRadius: m3Shape.cornerMedium,
289
+ backgroundColor: m3Colors.surfaceContainerHigh,
290
+ borderWidth: 1,
291
+ borderColor: m3Colors.outlineVariant,
292
+ padding: spacing.md,
293
+ gap: spacing.sm,
294
+ },
295
+ vouchQuote: { ...m3Type.body, color: m3Colors.onSurface },
296
+ vouchFooter: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm },
297
+ vouchAuthor: { ...m3Type.labelSmall, color: m3Colors.secondary, fontFamily: fonts.bold },
298
+ vouchMeta: { ...m3Type.labelSmall, color: m3Colors.onSurfaceVariant },
299
+ });
@@ -13,7 +13,7 @@ import type { SduiBlock } from '@proulr/sdui-contract';
13
13
  import { TextField as UiTextField } from '../ui/TextField';
14
14
  import { Chip } from '../ui/Chip';
15
15
  import { ChipRow } from '../ui/ChipRow';
16
- import { AppIcon } from '../ui/app-icon';
16
+ import { AppIcon, isAppIconName } from '../ui/app-icon';
17
17
  import { colors, fonts, gradients, m3Colors, m3Elevation, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
18
18
  import { resolveDataRef, resolveDataRefString } from '../lib/resolve-data-ref';
19
19
  import { useSduiHybridRenderer } from '../sdui-hybrid-renderer-context';
@@ -280,6 +280,7 @@ export function SduiDiscoverGridBlock({ block, data, onAction }: SduiBlockRender
280
280
  export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
281
281
  const label = readStringProp(block, 'label') ?? '';
282
282
  const glyph = readStringProp(block, 'glyph');
283
+ const tone = readStringProp(block, 'tone');
283
284
  const actionId = readStringProp(block, 'action_id');
284
285
  const actionParams = readStringRecordProp(block, 'action_params');
285
286
  const active = readBooleanProp(block, 'active');
@@ -288,6 +289,7 @@ export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
288
289
  <Chip
289
290
  label={label}
290
291
  icon={glyph}
292
+ tone={tone === 'secondary' || tone === 'primary' ? tone : undefined}
291
293
  active={active}
292
294
  disabled={!actionId}
293
295
  onPress={() => actionId && onAction(actionId, actionParams)}
@@ -295,8 +297,42 @@ export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
295
297
  );
296
298
  }
297
299
 
298
- export function SduiChipRowBlock({ children }: SduiBlockRenderProps) {
299
- return <ChipRow>{children}</ChipRow>;
300
+ export function SduiChipRowBlock({ block, data, children, onAction }: SduiBlockRenderProps) {
301
+ const itemsRef = readStringProp(block, 'items_ref');
302
+ const wrap = readStringProp(block, 'layout') === 'wrap';
303
+ if (itemsRef) {
304
+ const raw = resolveDataRef(data, itemsRef);
305
+ const items = Array.isArray(raw) ? raw : [];
306
+ return (
307
+ <ChipRow wrap={wrap}>
308
+ {items.map((item, index) => {
309
+ if (!item || typeof item !== 'object') return null;
310
+ const row = item as Record<string, unknown>;
311
+ const label = typeof row.label === 'string' ? row.label : '';
312
+ if (!label) return null;
313
+ const glyph = typeof row.glyph === 'string' && isAppIconName(row.glyph) ? row.glyph : undefined;
314
+ const toneRaw = row.tone;
315
+ const tone = toneRaw === 'secondary' || toneRaw === 'primary' ? toneRaw : undefined;
316
+ const actionId = typeof row.action_id === 'string' ? row.action_id : undefined;
317
+ const actionParams =
318
+ row.action_params && typeof row.action_params === 'object'
319
+ ? (row.action_params as Record<string, string>)
320
+ : undefined;
321
+ return (
322
+ <Chip
323
+ key={`${label}-${index}`}
324
+ label={label}
325
+ icon={glyph}
326
+ tone={tone}
327
+ readOnly={!actionId}
328
+ onPress={() => actionId && onAction(actionId, actionParams)}
329
+ />
330
+ );
331
+ })}
332
+ </ChipRow>
333
+ );
334
+ }
335
+ return <ChipRow wrap={wrap}>{children}</ChipRow>;
300
336
  }
301
337
 
302
338
  export function SduiTextFieldBlock({ block, data, onAction }: SduiBlockRenderProps) {