@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,348 @@
1
+ import React, { useState } from 'react';
2
+ import { Image, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import {
4
+ computeSduiMediaFrame,
5
+ deriveSduiInitials,
6
+ parseSduiAvatarProps,
7
+ parseSduiContentImageProps,
8
+ parseSduiIconButtonProps,
9
+ parseSduiRowOverflowMenuProps,
10
+ parseSduiVideoProps,
11
+ } from '../lib/sdui-media-block-props';
12
+ import { useSduiMediaResolver, useSduiMediaUri } from '../sdui-media-resolver-context';
13
+ import { useSduiVideoPlayer } from '../sdui-video-player-context';
14
+ import { AppIcon } from '../ui/app-icon';
15
+ import { APP_ICON_SIZE } from '../ui/app-icon-size-catalog';
16
+ import { colors, fonts, m3Colors, m3Elevation, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
17
+ import { resolveViewStyleProps } from '../theme/resolve-style-props';
18
+ import type { SduiBlockRenderProps } from './SduiPrimitiveBlocks';
19
+
20
+ /**
21
+ * Blocos genéricos de mídia/estado para row templates e telas BFF-authored:
22
+ * `content_image`, `video`, `avatar`, `icon_button`, `row_overflow_menu`.
23
+ * Layout 100% no wire; path de mídia
24
+ * 100% em dados (`{{item.*}}`); resolução path → URL é capability do app via
25
+ * `SduiMediaResolver`. Zero hex aqui — só tokens do tema.
26
+ */
27
+
28
+ function cornerPx(token: keyof typeof m3Shape | undefined): number | undefined {
29
+ return token ? m3Shape[token] : undefined;
30
+ }
31
+
32
+ function m3Color(token: string | undefined, fallback: string): string {
33
+ const value = token ? m3Colors[token as keyof typeof m3Colors] : undefined;
34
+ return value ?? fallback;
35
+ }
36
+
37
+ export function SduiContentImageBlock({ block }: SduiBlockRenderProps) {
38
+ const parsed = parseSduiContentImageProps(block.props);
39
+ const resolver = useSduiMediaResolver();
40
+ const { uri, settled } = useSduiMediaUri(parsed.path);
41
+
42
+ // Capability ausente ou path vazio (binding não resolvido) → renderizar nada.
43
+ // Nunca cair em path/URL de produto hardcoded.
44
+ if (!resolver || !parsed.path) return null;
45
+ if (settled && !uri) return null;
46
+
47
+ const frame = computeSduiMediaFrame(parsed.width, parsed.height, parsed.aspectRatio);
48
+ const containerStyle = [
49
+ styles.mediaShell,
50
+ {
51
+ width: frame.width,
52
+ height: frame.height,
53
+ aspectRatio: frame.aspectRatio,
54
+ ...(frame.stretch ? { alignSelf: 'stretch' as const } : null),
55
+ borderRadius: cornerPx(parsed.corner ?? 'cornerMedium'),
56
+ },
57
+ resolveViewStyleProps(block.props.style as Record<string, unknown>),
58
+ ];
59
+
60
+ if (!uri) {
61
+ // Placeholder de tamanho estável (mesmo frame do futuro image) — anti-flicker.
62
+ return <View style={containerStyle} testID={parsed.testId} />;
63
+ }
64
+
65
+ const fillBehind = parsed.blurFill && parsed.fit === 'contain';
66
+ return (
67
+ <View style={containerStyle} testID={parsed.testId}>
68
+ {fillBehind ? (
69
+ <>
70
+ <Image source={{ uri }} resizeMode="cover" style={StyleSheet.absoluteFill} />
71
+ <View style={[StyleSheet.absoluteFill, styles.blurVeil]} pointerEvents="none" />
72
+ </>
73
+ ) : null}
74
+ <Image
75
+ source={{ uri }}
76
+ resizeMode={parsed.fit === 'contain' ? 'contain' : 'cover'}
77
+ style={StyleSheet.absoluteFill}
78
+ />
79
+ </View>
80
+ );
81
+ }
82
+
83
+ export function SduiVideoBlock({ block, data, onAction }: SduiBlockRenderProps) {
84
+ const parsed = parseSduiVideoProps(block.props);
85
+ const resolver = useSduiMediaResolver();
86
+ const player = useSduiVideoPlayer();
87
+ const { uri, pending, settled } = useSduiMediaUri(parsed.path);
88
+ const { uri: posterUri } = useSduiMediaUri(parsed.posterPath ?? '');
89
+
90
+ // Capability ausente ou path vazio (binding não resolvido) → renderizar nada.
91
+ if (!resolver || !parsed.path) return null;
92
+
93
+ // Slot do app (expo-video etc.): a lib só entrega URI resolvida + frame do wire.
94
+ if (player) {
95
+ return <>{player({ block, data, onAction, parsed, uri, pending, settled, posterUri })}</>;
96
+ }
97
+
98
+ // Default sem player: FRAME com poster/still (ou placeholder estável com
99
+ // glyph `play`). Anti-flicker: mesmo frame do wire em todos os estados.
100
+ const frame = computeSduiMediaFrame(parsed.width, parsed.height, parsed.aspectRatio);
101
+ const containerStyle = [
102
+ styles.mediaShell,
103
+ {
104
+ width: frame.width,
105
+ height: frame.height,
106
+ aspectRatio: frame.aspectRatio,
107
+ ...(frame.stretch ? { alignSelf: 'stretch' as const } : null),
108
+ borderRadius: cornerPx(parsed.corner ?? 'cornerMedium'),
109
+ },
110
+ resolveViewStyleProps(block.props.style as Record<string, unknown>),
111
+ ];
112
+
113
+ if (settled && !uri && !posterUri) return null;
114
+
115
+ return (
116
+ <View style={containerStyle} testID={parsed.testId}>
117
+ {posterUri ? (
118
+ <Image
119
+ source={{ uri: posterUri }}
120
+ resizeMode={parsed.fit === 'contain' ? 'contain' : 'cover'}
121
+ style={StyleSheet.absoluteFill}
122
+ />
123
+ ) : null}
124
+ <View style={[StyleSheet.absoluteFill, styles.videoStillCenter]} pointerEvents="none">
125
+ <AppIcon name="play" size={APP_ICON_SIZE.fab} color={m3Colors.onSurface} />
126
+ </View>
127
+ </View>
128
+ );
129
+ }
130
+
131
+ export function SduiRowOverflowMenuBlock({ block, onAction }: SduiBlockRenderProps) {
132
+ const parsed = parseSduiRowOverflowMenuProps(block.props);
133
+ const [open, setOpen] = useState(false);
134
+
135
+ // Sem entradas válidas → o trigger não abriria nada; não renderiza.
136
+ if (parsed.actions.length === 0) return null;
137
+
138
+ const pressAction = (actionId: string) => {
139
+ setOpen(false);
140
+ onAction(actionId);
141
+ };
142
+
143
+ return (
144
+ <>
145
+ <Pressable
146
+ onPress={() => setOpen(true)}
147
+ accessibilityRole="button"
148
+ testID={parsed.testId}
149
+ style={({ pressed }) => [
150
+ styles.iconButton,
151
+ pressed && styles.iconButtonPressed,
152
+ resolveViewStyleProps(block.props.style as Record<string, unknown>),
153
+ ]}
154
+ >
155
+ <AppIcon
156
+ name={parsed.triggerIcon}
157
+ size={APP_ICON_SIZE.feedAction}
158
+ color={m3Colors.onSurfaceVariant}
159
+ />
160
+ </Pressable>
161
+
162
+ <Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
163
+ <View style={styles.overflowOverlay}>
164
+ <Pressable style={StyleSheet.absoluteFill} onPress={() => setOpen(false)} />
165
+ <View style={styles.overflowMenu}>
166
+ {parsed.actions.map((action) => (
167
+ <Pressable
168
+ key={action.actionId}
169
+ onPress={() => pressAction(action.actionId)}
170
+ accessibilityRole="button"
171
+ accessibilityLabel={action.label}
172
+ style={({ pressed }) => [
173
+ styles.overflowMenuItem,
174
+ pressed && styles.overflowMenuItemPressed,
175
+ ]}
176
+ >
177
+ {action.icon ? (
178
+ <AppIcon
179
+ name={action.icon}
180
+ size={APP_ICON_SIZE.feedAction}
181
+ color={action.destructive ? m3Colors.error : m3Colors.onSurfaceVariant}
182
+ />
183
+ ) : null}
184
+ <Text
185
+ style={[
186
+ styles.overflowMenuLabel,
187
+ action.destructive && { color: m3Colors.error },
188
+ ]}
189
+ >
190
+ {action.label}
191
+ </Text>
192
+ </Pressable>
193
+ ))}
194
+ </View>
195
+ </View>
196
+ </Modal>
197
+ </>
198
+ );
199
+ }
200
+
201
+ export function SduiAvatarBlock({ block }: SduiBlockRenderProps) {
202
+ const parsed = parseSduiAvatarProps(block.props);
203
+ const { uri } = useSduiMediaUri(parsed.path);
204
+ const initials = (parsed.initials?.trim() || deriveSduiInitials(parsed.name)).slice(0, 2);
205
+ const sizeStyle = {
206
+ width: parsed.sizePx,
207
+ height: parsed.sizePx,
208
+ borderRadius: cornerPx(parsed.corner),
209
+ };
210
+
211
+ if (parsed.path && uri) {
212
+ return (
213
+ <View style={[styles.avatarShell, sizeStyle]} testID={parsed.testId}>
214
+ <Image source={{ uri }} style={StyleSheet.absoluteFill} resizeMode="cover" />
215
+ </View>
216
+ );
217
+ }
218
+
219
+ // Sem foto resolvível (path vazio, sem resolver, pendente sem iniciais?) →
220
+ // placeholder genérico: iniciais derivadas de dados ou glyph do catalog.
221
+ // Nunca um path de produto fallback.
222
+ if (initials) {
223
+ return (
224
+ <View
225
+ style={[styles.avatarShell, styles.avatarInitials, sizeStyle]}
226
+ testID={parsed.testId}
227
+ accessibilityLabel={parsed.name}
228
+ >
229
+ <Text
230
+ style={[
231
+ styles.avatarInitialsText,
232
+ { fontSize: Math.max(10, Math.round(parsed.sizePx * 0.36)) },
233
+ ]}
234
+ >
235
+ {initials}
236
+ </Text>
237
+ </View>
238
+ );
239
+ }
240
+
241
+ return (
242
+ <View style={[styles.avatarShell, styles.avatarFallback, sizeStyle]} testID={parsed.testId}>
243
+ <AppIcon name="profile" size={Math.round(parsed.sizePx * 0.55)} color={m3Colors.onSurfaceVariant} />
244
+ </View>
245
+ );
246
+ }
247
+
248
+ export function SduiIconButtonBlock({ block, onAction }: SduiBlockRenderProps) {
249
+ const parsed = parseSduiIconButtonProps(block.props);
250
+ if (!parsed.icon) return null;
251
+
252
+ const glyphSize = parsed.size ?? APP_ICON_SIZE.feedAction;
253
+ const name = parsed.selected && parsed.selectedIcon ? parsed.selectedIcon : parsed.icon;
254
+ const tint = parsed.selected
255
+ ? m3Color(parsed.selectedColor, m3Colors.primary)
256
+ : m3Color(parsed.color, m3Colors.onSurfaceVariant);
257
+ const pressableSize = glyphSize + spacing.xs * 2;
258
+
259
+ return (
260
+ <Pressable
261
+ onPress={() => parsed.actionId && onAction(parsed.actionId)}
262
+ disabled={parsed.disabled || !parsed.actionId}
263
+ accessibilityRole="button"
264
+ accessibilityLabel={parsed.label}
265
+ accessibilityState={{ disabled: parsed.disabled, selected: parsed.selected }}
266
+ testID={parsed.testId}
267
+ style={({ pressed }) => [
268
+ styles.iconButton,
269
+ { width: pressableSize, height: pressableSize },
270
+ pressed && styles.iconButtonPressed,
271
+ parsed.disabled && styles.iconButtonDisabled,
272
+ resolveViewStyleProps(block.props.style as Record<string, unknown>),
273
+ ]}
274
+ >
275
+ <AppIcon name={name} size={glyphSize} color={tint} />
276
+ </Pressable>
277
+ );
278
+ }
279
+
280
+ const styles = StyleSheet.create({
281
+ mediaShell: {
282
+ overflow: 'hidden',
283
+ backgroundColor: m3Colors.surfaceContainerHigh,
284
+ },
285
+ /** Véu OLED sobre o cover de trás no blur-fill (o app pode trocar pelo
286
+ * MediaBlurFillImage canônico com blur real; a estrutura é a mesma). */
287
+ blurVeil: {
288
+ backgroundColor: colors.blackAlpha62,
289
+ },
290
+ avatarShell: {
291
+ overflow: 'hidden',
292
+ alignItems: 'center',
293
+ justifyContent: 'center',
294
+ backgroundColor: m3Colors.surfaceContainerHigh,
295
+ },
296
+ avatarInitials: {
297
+ backgroundColor: m3Colors.secondaryContainer,
298
+ },
299
+ avatarInitialsText: {
300
+ ...m3Type.labelSmall,
301
+ fontFamily: fonts.bold,
302
+ color: m3Colors.onSecondaryContainer,
303
+ },
304
+ avatarFallback: {
305
+ backgroundColor: m3Colors.surfaceContainerHighest,
306
+ },
307
+ iconButton: {
308
+ alignItems: 'center',
309
+ justifyContent: 'center',
310
+ },
311
+ iconButtonPressed: {
312
+ backgroundColor: colors.whiteAlpha12,
313
+ borderRadius: m3Shape.cornerFull,
314
+ },
315
+ iconButtonDisabled: {
316
+ opacity: 0.4,
317
+ },
318
+ videoStillCenter: {
319
+ alignItems: 'center',
320
+ justifyContent: 'center',
321
+ },
322
+ overflowOverlay: {
323
+ flex: 1,
324
+ backgroundColor: colors.blackAlpha62,
325
+ justifyContent: 'flex-end',
326
+ },
327
+ overflowMenu: {
328
+ margin: spacing.md,
329
+ paddingVertical: spacing.xs,
330
+ backgroundColor: m3Colors.surfaceContainerHigh,
331
+ borderRadius: m3Shape.cornerLarge,
332
+ ...m3Elevation.level3,
333
+ },
334
+ overflowMenuItem: {
335
+ flexDirection: 'row',
336
+ alignItems: 'center',
337
+ gap: spacing.sm,
338
+ paddingHorizontal: spacing.md,
339
+ paddingVertical: spacing.sm,
340
+ },
341
+ overflowMenuItemPressed: {
342
+ backgroundColor: colors.whiteAlpha12,
343
+ },
344
+ overflowMenuLabel: {
345
+ ...m3Type.body,
346
+ color: m3Colors.onSurface,
347
+ },
348
+ });
@@ -14,6 +14,7 @@ function NativeBlockShell({
14
14
  block,
15
15
  data,
16
16
  onAction,
17
+ children,
17
18
  rendererKey,
18
19
  fallbackLabel,
19
20
  }: SduiBlockRenderProps & {
@@ -53,6 +54,7 @@ function NativeBlockShell({
53
54
  block,
54
55
  data,
55
56
  onAction,
57
+ children,
56
58
  dataRef: readStringProp(block, 'data_ref'),
57
59
  conversationIdRef: readStringProp(block, 'conversation_id_ref'),
58
60
  peerIdRef: readStringProp(block, 'peer_id_ref'),
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
2
+ import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
3
3
  import type { SduiBlock } from '@proulr/sdui-contract';
4
4
  import { AppButton } from '../ui/AppButton';
5
5
  import { AppIcon, isAppIconName } from '../ui/app-icon';
@@ -22,16 +22,18 @@ export type SduiBlockRenderProps = {
22
22
 
23
23
  const textVariants = ['headline', 'title', 'body', 'label'] as const;
24
24
 
25
- export function SduiTextBlock({ block }: SduiBlockRenderProps) {
25
+ export function SduiTextBlock({ block, onAction }: SduiBlockRenderProps) {
26
26
  const text = typeof block.props.text === 'string' ? block.props.text : '';
27
27
  const variant =
28
28
  typeof block.props.variant === 'string' &&
29
29
  (textVariants as readonly string[]).includes(block.props.variant)
30
30
  ? block.props.variant
31
31
  : 'body';
32
+ const actionId = typeof block.props.action_id === 'string' ? block.props.action_id : '';
32
33
 
33
- return (
34
+ const label = (
34
35
  <Text
36
+ numberOfLines={actionId ? 1 : undefined}
35
37
  style={[
36
38
  { color: colors.onSurface },
37
39
  resolveTextStyleProps(block.props.style as Record<string, unknown>, variant),
@@ -40,6 +42,19 @@ export function SduiTextBlock({ block }: SduiBlockRenderProps) {
40
42
  {text}
41
43
  </Text>
42
44
  );
45
+ if (!actionId) return label;
46
+
47
+ return (
48
+ <Pressable
49
+ accessibilityRole="link"
50
+ accessibilityLabel={text}
51
+ hitSlop={spacing.xs}
52
+ onPress={() => onAction(actionId)}
53
+ style={styles.textLink}
54
+ >
55
+ {label}
56
+ </Pressable>
57
+ );
43
58
  }
44
59
 
45
60
  export function SduiColumnBlock({ block, children }: SduiBlockRenderProps) {
@@ -164,6 +179,9 @@ export function SduiIconBlock({ block }: SduiBlockRenderProps) {
164
179
  }
165
180
 
166
181
  export function SduiSpacerBlock({ block }: SduiBlockRenderProps) {
182
+ if (typeof block.props.flex === 'number') {
183
+ return <View style={{ flex: block.props.flex, minWidth: 0 }} />;
184
+ }
167
185
  const height = typeof block.props.height === 'number' ? block.props.height : spacing.md;
168
186
  return <View style={{ height }} />;
169
187
  }
@@ -184,6 +202,7 @@ export function SduiUnknownBlock({ block }: SduiBlockRenderProps) {
184
202
 
185
203
  const styles = StyleSheet.create({
186
204
  scroll: { flex: 1, minHeight: 0 },
205
+ textLink: { flexShrink: 1, minWidth: 0 },
187
206
  banner: {
188
207
  padding: spacing.md,
189
208
  backgroundColor: colors.surface,
@@ -1,4 +1,12 @@
1
1
  export { SduiScreenRenderer } from './SduiScreenRenderer';
2
+ export { SduiTemplateRow } from './SduiTemplateRow';
3
+ export {
4
+ materializeRowTemplate,
5
+ readSduiRowTemplate,
6
+ readSduiRowTemplates,
7
+ resolveRowTemplateBindings,
8
+ } from './lib/sdui-row-template';
9
+ export type { SduiRowTemplateSource } from './lib/sdui-row-template';
2
10
  export {
3
11
  resolveSduiBlockComponent,
4
12
  registerSduiBlockComponent,
@@ -9,6 +17,46 @@ export {
9
17
  SduiHybridRendererProvider,
10
18
  useSduiHybridRenderer,
11
19
  } from './sdui-hybrid-renderer-context';
20
+ export {
21
+ SduiMediaResolverProvider,
22
+ useSduiMediaResolver,
23
+ useSduiMediaUri,
24
+ } from './sdui-media-resolver-context';
25
+ export { invokeSduiMediaResolver } from './lib/sdui-media-uri';
26
+ export type { SduiMediaResolver } from './lib/sdui-media-uri';
27
+ export {
28
+ SDUI_MEDIA_ASPECT_RATIOS,
29
+ SDUI_AVATAR_SIZES,
30
+ SDUI_ROW_OVERFLOW_DEFAULT_ICON,
31
+ computeSduiMediaFrame,
32
+ deriveSduiInitials,
33
+ parseSduiAvatarProps,
34
+ parseSduiContentImageProps,
35
+ parseSduiIconButtonProps,
36
+ parseSduiRowOverflowMenuProps,
37
+ parseSduiVideoProps,
38
+ } from './lib/sdui-media-block-props';
39
+ export type {
40
+ SduiAvatarParsed,
41
+ SduiContentImageParsed,
42
+ SduiIconButtonParsed,
43
+ SduiMediaFrame,
44
+ SduiRowOverflowMenuActionParsed,
45
+ SduiRowOverflowMenuParsed,
46
+ SduiVideoParsed,
47
+ } from './lib/sdui-media-block-props';
48
+ export {
49
+ SduiAvatarBlock,
50
+ SduiContentImageBlock,
51
+ SduiIconButtonBlock,
52
+ SduiRowOverflowMenuBlock,
53
+ SduiVideoBlock,
54
+ } from './blocks/SduiMediaBlocks';
55
+ export {
56
+ SduiVideoPlayerProvider,
57
+ useSduiVideoPlayer,
58
+ } from './sdui-video-player-context';
59
+ export type { SduiVideoPlayer, SduiVideoPlayerProps } from './sdui-video-player-context';
12
60
  export {
13
61
  SduiChromeScrollProvider,
14
62
  useSduiChromeScroll,