@proulr/sdui-components 0.1.87 → 0.1.90

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 (32) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -2
  3. package/src/bdc/compile-bdc-row-template.test.ts +179 -0
  4. package/src/bdc/compile-bdc-to-wire.ts +52 -1
  5. package/src/bdc/components/discover.tsx +5 -1
  6. package/src/bdc/components/list.test.ts +36 -0
  7. package/src/bdc/components/list.tsx +62 -0
  8. package/src/bdc/components/media.test.ts +268 -0
  9. package/src/bdc/components/media.tsx +275 -0
  10. package/src/bdc/components/native-blocks.tsx +7 -1
  11. package/src/bdc/components/ui.tsx +25 -0
  12. package/src/bdc/index.ts +5 -0
  13. package/src/catalog/block-types.ts +5 -0
  14. package/src/renderer/SduiTemplateRow.tsx +63 -0
  15. package/src/renderer/blocks/SduiContentBlocks.tsx +198 -0
  16. package/src/renderer/blocks/SduiDiscoverBlocks.tsx +37 -2
  17. package/src/renderer/blocks/SduiMediaBlocks.tsx +348 -0
  18. package/src/renderer/blocks/SduiNativeDomainBlocks.tsx +2 -0
  19. package/src/renderer/index.ts +48 -0
  20. package/src/renderer/lib/sdui-media-block-props.ts +282 -0
  21. package/src/renderer/lib/sdui-media-blocks.test.ts +279 -0
  22. package/src/renderer/lib/sdui-media-uri.ts +39 -0
  23. package/src/renderer/lib/sdui-row-template.test.ts +190 -0
  24. package/src/renderer/lib/sdui-row-template.ts +153 -0
  25. package/src/renderer/sdui-component-registry.tsx +16 -0
  26. package/src/renderer/sdui-hybrid-renderer-context.tsx +2 -0
  27. package/src/renderer/sdui-media-resolver-context.tsx +84 -0
  28. package/src/renderer/sdui-video-player-context.tsx +55 -0
  29. package/src/renderer/ui/Chip.tsx +15 -2
  30. package/src/renderer/ui/Skeleton.tsx +13 -4
  31. package/src/renderer/ui/index.ts +1 -0
  32. package/src/renderer/ui/use-reduce-motion.ts +31 -0
@@ -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,7 +297,40 @@ export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
295
297
  );
296
298
  }
297
299
 
298
- export function SduiChipRowBlock({ children }: SduiBlockRenderProps) {
300
+ export function SduiChipRowBlock({ block, data, children, onAction }: SduiBlockRenderProps) {
301
+ const itemsRef = readStringProp(block, 'items_ref');
302
+ if (itemsRef) {
303
+ const raw = resolveDataRef(data, itemsRef);
304
+ const items = Array.isArray(raw) ? raw : [];
305
+ return (
306
+ <ChipRow>
307
+ {items.map((item, index) => {
308
+ if (!item || typeof item !== 'object') return null;
309
+ const row = item as Record<string, unknown>;
310
+ const label = typeof row.label === 'string' ? row.label : '';
311
+ if (!label) return null;
312
+ const glyph = typeof row.glyph === 'string' && isAppIconName(row.glyph) ? row.glyph : undefined;
313
+ const toneRaw = row.tone;
314
+ const tone = toneRaw === 'secondary' || toneRaw === 'primary' ? toneRaw : undefined;
315
+ const actionId = typeof row.action_id === 'string' ? row.action_id : undefined;
316
+ const actionParams =
317
+ row.action_params && typeof row.action_params === 'object'
318
+ ? (row.action_params as Record<string, string>)
319
+ : undefined;
320
+ return (
321
+ <Chip
322
+ key={`${label}-${index}`}
323
+ label={label}
324
+ icon={glyph}
325
+ tone={tone}
326
+ disabled={!actionId}
327
+ onPress={() => actionId && onAction(actionId, actionParams)}
328
+ />
329
+ );
330
+ })}
331
+ </ChipRow>
332
+ );
333
+ }
299
334
  return <ChipRow>{children}</ChipRow>;
300
335
  }
301
336
 
@@ -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,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,