@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,268 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Avatar, ContentImage, IconButton, RowOverflowMenu, Video } from './media';
3
+ import { compileBdcBlock, compileBdcToWire } from '../compile-bdc-to-wire';
4
+ import { ListRowTemplate } from './list';
5
+ import { SocialPostFeed } from './native-blocks';
6
+ import { Column, Screen } from './ui';
7
+ import type { BdcElement } from '../jsx-runtime';
8
+
9
+ describe('Video (BDC)', () => {
10
+ it('emits video with binding path/poster and tokens; aliases normalized', () => {
11
+ const wire = compileBdcBlock(
12
+ Video({
13
+ pathRef: '{{item.video_path}}',
14
+ posterPath: '{{item.poster_path}}',
15
+ width: 480,
16
+ aspect: 'video',
17
+ corner: 'cornerMedium',
18
+ fit: 'contain',
19
+ testId: 'clip-video',
20
+ }),
21
+ );
22
+ expect(wire.type).toBe('video');
23
+ expect(wire.props).toEqual({
24
+ path: '{{item.video_path}}',
25
+ poster_path: '{{item.poster_path}}',
26
+ width: 480,
27
+ aspect: 'video',
28
+ corner: 'cornerMedium',
29
+ fit: 'contain',
30
+ test_id: 'clip-video',
31
+ });
32
+ });
33
+
34
+ it('drops undefined props and keeps poster absent when not authored', () => {
35
+ const node = Video({ path: 'clips/a.mp4' });
36
+ expect(node.type).toBe('video');
37
+ expect('poster_path' in node.props).toBe(false);
38
+ expect('width' in node.props).toBe(false);
39
+ });
40
+
41
+ it('composes into a row template with bindings verbatim', () => {
42
+ const host = SocialPostFeed({
43
+ children: [
44
+ ListRowTemplate({
45
+ itemKey: 'id',
46
+ children: [Video({ pathRef: '{{item.clip_path}}', aspect: 'story' })],
47
+ }),
48
+ ],
49
+ });
50
+ const wire = compileBdcToWire(Screen({ id: 'social.feed', children: [host] }));
51
+ const rows = wire.blocks[0]?.props?.row_template as BdcElement[];
52
+ expect(rows[0].type).toBe('video');
53
+ expect(rows[0].props.path).toBe('{{item.clip_path}}');
54
+ });
55
+ });
56
+
57
+ describe('RowOverflowMenu (BDC)', () => {
58
+ it('normalizes nested actions to snake_case action_id (arrays skip camelToSnake)', () => {
59
+ const wire = compileBdcBlock(
60
+ RowOverflowMenu({
61
+ actions: [
62
+ { actionId: 'delete_post_{{item.id}}', label: 'Excluir', icon: 'trash', destructive: true },
63
+ { actionId: 'bookmark_{{item.id}}', label: 'Salvar' },
64
+ ],
65
+ icon: 'overflow',
66
+ testId: 'post-menu',
67
+ }),
68
+ );
69
+ expect(wire.type).toBe('row_overflow_menu');
70
+ expect(wire.props).toEqual({
71
+ actions: [
72
+ { action_id: 'delete_post_{{item.id}}', label: 'Excluir', icon: 'trash', destructive: true },
73
+ { action_id: 'bookmark_{{item.id}}', label: 'Salvar' },
74
+ ],
75
+ icon: 'overflow',
76
+ test_id: 'post-menu',
77
+ });
78
+ });
79
+
80
+ it('accepts snake_case authoring and omits undefined keys per action', () => {
81
+ const node = RowOverflowMenu({ actions: [{ action_id: 'a', label: 'A' }] });
82
+ expect(node.props.actions).toEqual([{ action_id: 'a', label: 'A' }]);
83
+ expect('icon' in node.props).toBe(false);
84
+ });
85
+
86
+ it('keeps item bindings untouched inside label/action_id (template-safe)', () => {
87
+ const host = SocialPostFeed({
88
+ children: [
89
+ ListRowTemplate({
90
+ itemKey: 'id',
91
+ children: [
92
+ RowOverflowMenu({ actions: [{ actionId: 'report_{{item.id}}', label: '{{item.author.name}}' }], icon: 'overflow' }),
93
+ ],
94
+ }),
95
+ ],
96
+ });
97
+ const wire = compileBdcToWire(Screen({ id: 'social.feed', children: [host] }));
98
+ const rows = wire.blocks[0]?.props?.row_template as BdcElement[];
99
+ const actions = rows[0].props.actions as Record<string, unknown>[];
100
+ expect(actions[0].action_id).toBe('report_{{item.id}}');
101
+ expect(actions[0].label).toBe('{{item.author.name}}');
102
+ });
103
+ });
104
+
105
+ describe('registry wiring for new block types (static)', () => {
106
+ it('bdc media factories map to catalog block types video / row_overflow_menu', () => {
107
+ expect(Video({ path: 'a.mp4' }).type).toBe('video');
108
+ expect(RowOverflowMenu({ actions: [] }).type).toBe('row_overflow_menu');
109
+ });
110
+ });
111
+
112
+ describe('ContentImage (BDC)', () => {
113
+ it('emits content_image with path and tokens preserved verbatim', () => {
114
+ const node = ContentImage({
115
+ path: '{{item.media_path}}',
116
+ width: 96,
117
+ aspect: 'square',
118
+ corner: 'cornerMedium',
119
+ fit: 'cover',
120
+ blurFill: true,
121
+ testId: 'feed-image',
122
+ });
123
+ expect(node.type).toBe('content_image');
124
+ expect(node.props?.path).toBe('{{item.media_path}}');
125
+ expect(node.props.aspect).toBe('square');
126
+ expect(node.props.corner).toBe('cornerMedium');
127
+ expect(node.children).toEqual([]);
128
+ });
129
+
130
+ it('normalizes pathRef/path_ref aliases onto wire path', () => {
131
+ expect(ContentImage({ pathRef: 'posts/a.jpg' }).props?.path).toBe('posts/a.jpg');
132
+ expect(ContentImage({ path_ref: 'posts/b.jpg' }).props?.path).toBe('posts/b.jpg');
133
+ expect('pathRef' in ContentImage({ path: 'x' }).props).toBe(false);
134
+ });
135
+
136
+ it('compiles camel props to snake_case wire, keeping bindings untouched', () => {
137
+ const wire = compileBdcBlock(
138
+ ContentImage({
139
+ id: 'img',
140
+ path: '{{item.media_path}}',
141
+ blurFill: true,
142
+ testId: 'row-image',
143
+ fit: 'contain',
144
+ }),
145
+ );
146
+ expect(wire.type).toBe('content_image');
147
+ expect(wire.props).toEqual({
148
+ id: 'img',
149
+ path: '{{item.media_path}}',
150
+ blur_fill: true,
151
+ test_id: 'row-image',
152
+ fit: 'contain',
153
+ });
154
+ });
155
+
156
+ it('drops undefined props (no noise on the wire)', () => {
157
+ const node = ContentImage({ path: 'a.jpg', width: undefined });
158
+ expect('width' in node.props).toBe(false);
159
+ expect('blur_fill' in node.props).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe('Avatar (BDC)', () => {
164
+ it('emits avatar with path alias, name, initials and size token', () => {
165
+ const node = Avatar({
166
+ pathRef: '{{item.author.avatar_path}}',
167
+ name: '{{item.author.name}}',
168
+ size: 'sm',
169
+ corner: 'cornerFull',
170
+ });
171
+ expect(node.type).toBe('avatar');
172
+ expect(node.props?.path).toBe('{{item.author.avatar_path}}');
173
+ expect(node.props.name).toBe('{{item.author.name}}');
174
+ expect(node.props.size).toBe('sm');
175
+
176
+ const wire = compileBdcBlock(node);
177
+ expect(wire.props?.path).toBe('{{item.author.avatar_path}}');
178
+ expect(wire.props?.corner).toBe('cornerFull');
179
+ });
180
+
181
+ it('supports initials-only authoring', () => {
182
+ const wire = compileBdcBlock(Avatar({ initials: 'AB', size: 'xs' }));
183
+ expect(wire.props).toEqual({ initials: 'AB', size: 'xs' });
184
+ });
185
+ });
186
+
187
+ describe('IconButton (BDC)', () => {
188
+ it('emits icon_button with binding in selected kept verbatim (stringly-typed rule)', () => {
189
+ const wire = compileBdcBlock(
190
+ IconButton({
191
+ icon: 'heart',
192
+ selectedIcon: 'heart-filled',
193
+ actionId: 'like_post_{{item.id}}',
194
+ selectedRef: '{{item.liked_by_me}}',
195
+ selectedColor: 'primary',
196
+ testId: 'like-btn',
197
+ }),
198
+ );
199
+ expect(wire.type).toBe('icon_button');
200
+ expect(wire.props).toEqual({
201
+ icon: 'heart',
202
+ selected_icon: 'heart-filled',
203
+ action_id: 'like_post_{{item.id}}',
204
+ selected: '{{item.liked_by_me}}',
205
+ selected_color: 'primary',
206
+ test_id: 'like-btn',
207
+ });
208
+ });
209
+
210
+ it('passes literal booleans through for selected', () => {
211
+ expect(compileBdcBlock(IconButton({ icon: 'heart', selected: true })).props?.selected).toBe(true);
212
+ expect(compileBdcBlock(IconButton({ icon: 'heart', selected: false })).props?.selected).toBe(false);
213
+ });
214
+
215
+ it('accepts snake_case authoring too', () => {
216
+ const wire = compileBdcBlock(
217
+ IconButton({ icon: 'heart', action_id: 'like', selected: 'false', test_id: 'x' }),
218
+ );
219
+ expect(wire.props?.action_id).toBe('like');
220
+ expect(wire.props?.selected).toBe('false');
221
+ expect(wire.props?.test_id).toBe('x');
222
+ });
223
+
224
+ it('composes into a social feed row template (bindings verbatim in the wire)', () => {
225
+ const host = SocialPostFeed({
226
+ dataRef: 'social.posts',
227
+ children: [
228
+ ListRowTemplate({
229
+ itemKey: 'id',
230
+ children: [
231
+ Column({
232
+ children: [
233
+ Avatar({
234
+ pathRef: '{{item.author.avatar_path}}',
235
+ name: '{{item.author.name}}',
236
+ size: 'sm',
237
+ }),
238
+ ContentImage({ pathRef: '{{item.media_path}}', width: 96, aspect: 'square' }),
239
+ IconButton({
240
+ icon: 'heart',
241
+ selectedIcon: 'heart-filled',
242
+ actionId: 'like_post_{{item.id}}',
243
+ selectedRef: '{{item.liked_by_me}}',
244
+ }),
245
+ ],
246
+ }),
247
+ ],
248
+ }),
249
+ ],
250
+ });
251
+
252
+ const wire = compileBdcToWire(
253
+ Screen({ id: 'social.feed', data: { posts: [] }, children: [host] }),
254
+ );
255
+ const feed = wire.blocks[0];
256
+ expect(feed.children).toEqual([]);
257
+
258
+ const rows = feed.props?.row_template as unknown as BdcElement[];
259
+ expect(rows).toHaveLength(1);
260
+ const [avatar, image, like] = rows[0].children;
261
+ expect(avatar.type).toBe('avatar');
262
+ expect(avatar.props.path).toBe('{{item.author.avatar_path}}');
263
+ expect(image.props.path).toBe('{{item.media_path}}');
264
+ expect(like.props.selected).toBe('{{item.liked_by_me}}');
265
+ expect(like.props.action_id).toBe('like_post_{{item.id}}');
266
+ expect(like.props.selected_icon).toBe('heart-filled');
267
+ });
268
+ });
@@ -0,0 +1,275 @@
1
+ import type {
2
+ ColorToken,
3
+ ShapeToken,
4
+ SduiAvatarSizeToken,
5
+ SduiMediaAspectToken,
6
+ SduiMediaFitToken,
7
+ SduiSelectedFlag,
8
+ } from '@proulr/sdui-contract';
9
+ import type { BdcComponent } from '../jsx-runtime';
10
+
11
+ /**
12
+ * Blocos genéricos de mídia/estado para row templates e layouts BFF.
13
+ *
14
+ * Nenhum deles conhece produto: o **path de mídia** vem de dados (bindings
15
+ * `{{item.*}}` em row templates são resolvidos pelo app em materialize time)
16
+ * e a transformação path → URL (download autenticado) é capability genérica
17
+ * fornecida pelo app via `SduiMediaResolver` (`@proulr/sdui-components/renderer`).
18
+ * A lib nunca cai em path/URL de produto: sem resolver ou com path vazio,
19
+ * `content_image` renderiza `null`; `avatar` cai em iniciais/glyph genérico.
20
+ */
21
+
22
+ function definedProps(props: Record<string, unknown>): Record<string, unknown> {
23
+ const out: Record<string, unknown> = {};
24
+ for (const [key, value] of Object.entries(props)) {
25
+ if (key === 'children' || value === undefined) continue;
26
+ out[key] = value;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /** Lê string aceitando a forma camel ou snake do mesmo prop (autoria BFF tolerante). */
32
+ function readStringAlias(props: Record<string, unknown>, ...keys: string[]): string | undefined {
33
+ for (const key of keys) {
34
+ const value = props[key];
35
+ if (typeof value === 'string') return value;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ type ContentImageProps = {
41
+ id?: string;
42
+ /** Storage path (ex.: `posts/2026/a.jpg`) ou binding `{{item.media_path}}` no row template. */
43
+ path?: string;
44
+ /** Alias de autoria: mesmo valor de `path` (compacto em rows). */
45
+ pathRef?: string;
46
+ path_ref?: string;
47
+ width?: number;
48
+ height?: number;
49
+ /** Token de proporção — `square` | `portrait` | `story` | `video` | `wide`. */
50
+ aspect?: SduiMediaAspectToken;
51
+ /** Token de raio do tema (sem px/hex) — default `cornerMedium`. */
52
+ corner?: ShapeToken;
53
+ /** `cover` (default) | `contain`. */
54
+ fit?: SduiMediaFitToken;
55
+ /** Preenche o frame atrás de mídia `contain` (blur-fill genérico; app pode refinar). */
56
+ blurFill?: boolean;
57
+ testId?: string;
58
+ };
59
+
60
+ /**
61
+ * `<ContentImage path="{{item.media_path}}" aspect="square" corner="cornerMedium" fit="cover" />`
62
+ * → wire `content_image`:
63
+ * `{ path, width?, height?, aspect?, corner?, fit?, blur_fill?, test_id? }`
64
+ */
65
+ export const ContentImage: BdcComponent = (props) => {
66
+ const raw = props as ContentImageProps & Record<string, unknown>;
67
+ const path = readStringAlias(raw, 'path', 'pathRef', 'path_ref');
68
+ const elementProps = definedProps({
69
+ id: raw.id,
70
+ width: raw.width,
71
+ height: raw.height,
72
+ aspect: raw.aspect,
73
+ corner: raw.corner,
74
+ fit: raw.fit,
75
+ blurFill: raw.blurFill ?? raw.blur_fill,
76
+ testId: raw.testId ?? raw.test_id,
77
+ });
78
+ if (path !== undefined) elementProps.path = path;
79
+ return { type: 'content_image', props: elementProps, children: [] };
80
+ };
81
+
82
+ type VideoProps = {
83
+ id?: string;
84
+ /** Storage path do vídeo (ex.: `clips/2026/a.mp4`) ou binding `{{item.video_path}}`. */
85
+ path?: string;
86
+ pathRef?: string;
87
+ path_ref?: string;
88
+ /** Storage path do poster/still (ou binding); exibido quando o app não pluga player. */
89
+ posterPath?: string;
90
+ poster_path?: string;
91
+ posterRef?: string;
92
+ poster_ref?: string;
93
+ width?: number;
94
+ height?: number;
95
+ /** Token de proporção — `square` | `portrait` | `story` | `video` | `wide`. */
96
+ aspect?: SduiMediaAspectToken;
97
+ /** Token de raio do tema (sem px/hex) — default `cornerMedium`. */
98
+ corner?: ShapeToken;
99
+ /** `cover` (default) | `contain`. */
100
+ fit?: SduiMediaFitToken;
101
+ testId?: string;
102
+ };
103
+
104
+ /**
105
+ * `<Video path="{{item.video_path}}" posterPath="{{item.poster_path}}" aspect="video" />`
106
+ * → wire `video`:
107
+ * `{ path, width?, height?, aspect?, corner?, fit?, poster_path?, test_id? }`
108
+ *
109
+ * O player em si é do **app** (expo-video): o renderer resolve `path`/
110
+ * `poster_path` via o mesmo `SduiMediaResolverContext` e delega a playback a
111
+ * um slot injetável (`SduiVideoPlayerProvider`). Sem slot, o bloco renderiza
112
+ * um **frame** com o poster (still) — nunca URL de produto hardcoded.
113
+ */
114
+ export const Video: BdcComponent = (props) => {
115
+ const raw = props as VideoProps & Record<string, unknown>;
116
+ const path = readStringAlias(raw, 'path', 'pathRef', 'path_ref');
117
+ const posterPath = readStringAlias(raw, 'posterPath', 'poster_path', 'posterRef', 'poster_ref');
118
+ const elementProps = definedProps({
119
+ id: raw.id,
120
+ width: raw.width,
121
+ height: raw.height,
122
+ aspect: raw.aspect,
123
+ corner: raw.corner,
124
+ fit: raw.fit,
125
+ testId: raw.testId ?? raw.test_id,
126
+ });
127
+ if (path !== undefined) elementProps.path = path;
128
+ if (posterPath !== undefined) elementProps.poster_path = posterPath;
129
+ return { type: 'video', props: elementProps, children: [] };
130
+ };
131
+
132
+ type RowOverflowMenuActionProps = {
133
+ /** Action id do wire; binding `{{item.*}}` ok (template-safe). */
134
+ actionId?: string;
135
+ action_id?: string;
136
+ /** Rótulo da entrada (copy via Rosetta no BFF; binding ok). */
137
+ label?: string;
138
+ /** AppIconName kebab-case (catalog) — ex.: `trash`, `bookmark`. */
139
+ icon?: string;
140
+ /** Tint `error` do tema nesta entrada. */
141
+ destructive?: boolean;
142
+ };
143
+
144
+ type RowOverflowMenuProps = {
145
+ id?: string;
146
+ /** Entradas do menu, em ordem. */
147
+ actions?: RowOverflowMenuActionProps[];
148
+ /** AppIconName do trigger (default no renderer: `overflow`). */
149
+ icon?: string;
150
+ testId?: string;
151
+ };
152
+
153
+ /**
154
+ * `<RowOverflowMenu actions={[{ actionId: 'delete_post_{{item.id}}', label: 'Excluir', icon: 'trash', destructive: true }]} />`
155
+ * → wire `row_overflow_menu`:
156
+ * `{ actions: [{ action_id, label, icon?, destructive? }], icon?, test_id? }`
157
+ *
158
+ * Overflow genérico por linha: aninhados **não** passam por camelToSnake no
159
+ * compile — o factory já normaliza `action_id`. Toda copy vem do chamador
160
+ * (BFF); a lib não inventa strings.
161
+ */
162
+ export const RowOverflowMenu: BdcComponent = (props) => {
163
+ const raw = props as RowOverflowMenuProps & Record<string, unknown>;
164
+ const actions = (raw.actions ?? []).map((action) => {
165
+ const entry = action as RowOverflowMenuActionProps;
166
+ return definedProps({
167
+ action_id: entry.actionId ?? entry.action_id,
168
+ label: entry.label,
169
+ icon: entry.icon,
170
+ destructive: entry.destructive,
171
+ });
172
+ });
173
+ const elementProps = definedProps({
174
+ id: raw.id,
175
+ icon: raw.icon,
176
+ testId: raw.testId ?? raw.test_id,
177
+ });
178
+ elementProps.actions = actions;
179
+ return { type: 'row_overflow_menu', props: elementProps, children: [] };
180
+ };
181
+
182
+ type AvatarProps = {
183
+ id?: string;
184
+ /** Storage path da foto (ou binding `{{item.author.avatar_path}}`). */
185
+ path?: string;
186
+ pathRef?: string;
187
+ path_ref?: string;
188
+ /** Nome de exibição — iniciais derivadas quando não há foto resolvível. */
189
+ name?: string;
190
+ /** Iniciais explícitas (override da derivação de `name`). */
191
+ initials?: string;
192
+ /** Token de tamanho — `xs` | `sm` | `md` | `lg` | `xl` (default `md`). */
193
+ size?: SduiAvatarSizeToken;
194
+ /** Token de raio — default `cornerFull` (círculo). */
195
+ corner?: ShapeToken;
196
+ testId?: string;
197
+ };
198
+
199
+ /**
200
+ * `<Avatar pathRef="{{item.author.avatar_path}}" name="{{item.author.name}}" size="sm" />`
201
+ * → wire `avatar`: `{ path?, name?, initials?, size?, corner?, test_id? }`
202
+ */
203
+ export const Avatar: BdcComponent = (props) => {
204
+ const raw = props as AvatarProps & Record<string, unknown>;
205
+ const path = readStringAlias(raw, 'path', 'pathRef', 'path_ref');
206
+ const elementProps = definedProps({
207
+ id: raw.id,
208
+ name: raw.name,
209
+ initials: raw.initials,
210
+ size: raw.size,
211
+ corner: raw.corner,
212
+ testId: raw.testId ?? raw.test_id,
213
+ });
214
+ if (path !== undefined) elementProps.path = path;
215
+ return { type: 'avatar', props: elementProps, children: [] };
216
+ };
217
+
218
+ type IconButtonProps = {
219
+ id?: string;
220
+ /** AppIconName kebab-case (catalog) — ex.: `heart`. */
221
+ icon: string;
222
+ /** AppIconName do estado selecionado (ex.: `heart-filled`); sem ele, só tint. */
223
+ selectedIcon?: string;
224
+ selected_icon?: string;
225
+ actionId?: string;
226
+ /**
227
+ * Estado selecionado: `true`/`false` literal ou binding `{{item.liked_by_me}}`.
228
+ * Em row templates, materialize resolve booleanos para as **strings**
229
+ * `'true'`/`'false'` — o renderer trata `'true'`/`true` como selecionado
230
+ * (regra stringly-typed documentada em `docs/components.md`).
231
+ */
232
+ selected?: SduiSelectedFlag;
233
+ /** Alias de autoria: mesmo destino de `selected`. */
234
+ selectedRef?: string;
235
+ selected_ref?: string;
236
+ /** Px do glyph (default `feedAction`). */
237
+ size?: number;
238
+ /** ColorToken do estado default (ex.: `onSurfaceVariant`). */
239
+ color?: ColorToken;
240
+ /** ColorToken do estado selecionado (ex.: `primary`). */
241
+ selectedColor?: ColorToken;
242
+ selected_color?: ColorToken;
243
+ /** Rótulo de acessibilidade (copy via Rosetta no BFF). */
244
+ label?: string;
245
+ disabled?: boolean;
246
+ testId?: string;
247
+ };
248
+
249
+ /**
250
+ * `<IconButton icon="heart" selectedIcon="heart-filled" actionId="like_post_{{item.id}}" selectedRef="{{item.liked_by_me}}" />`
251
+ * → wire `icon_button`:
252
+ * `{ icon, selected_icon?, action_id?, selected?, size?, color?, selected_color?, label?, disabled?, test_id? }`
253
+ */
254
+ export const IconButton: BdcComponent = (props) => {
255
+ const raw = props as IconButtonProps & Record<string, unknown>;
256
+ const selected = readStringAlias(raw, 'selected', 'selectedRef', 'selected_ref');
257
+ const elementProps = definedProps({
258
+ id: raw.id,
259
+ icon: raw.icon,
260
+ selectedIcon: readStringAlias(raw, 'selectedIcon', 'selected_icon'),
261
+ actionId: raw.actionId ?? raw.action_id,
262
+ size: raw.size,
263
+ color: raw.color,
264
+ selectedColor: readStringAlias(raw, 'selectedColor', 'selected_color'),
265
+ label: raw.label,
266
+ disabled: raw.disabled,
267
+ testId: raw.testId ?? raw.test_id,
268
+ });
269
+ if (selected !== undefined) {
270
+ elementProps.selected = selected;
271
+ } else if (typeof raw.selected === 'boolean') {
272
+ elementProps.selected = raw.selected;
273
+ }
274
+ return { type: 'icon_button', props: elementProps, children: [] };
275
+ };
@@ -12,15 +12,21 @@ type NativeBlockProps = {
12
12
  profileIdRef?: string;
13
13
  /** Wire `settings_page` — id da página filha (about, privacy, …). */
14
14
  page?: string;
15
+ children?: BdcElement[];
15
16
  };
16
17
 
17
18
  function nativeBlock(type: string, props: NativeBlockProps): BdcElement {
18
- return { type, props: { ...props }, children: [] };
19
+ const { children, ...rest } = props;
20
+ return { type, props: { ...rest }, children: children ?? [] };
19
21
  }
20
22
 
21
23
  export const ChatThread: BdcComponent = (props) => nativeBlock('chat_thread', props);
22
24
  export const SocialPostFeed: BdcComponent = (props) => nativeBlock('social_post_feed', props);
23
25
  export const ThoughtFeed: BdcComponent = (props) => nativeBlock('thought_feed', props);
26
+ /**
27
+ * Hero nativo genérico do perfil (fotos + vidro + fade). Aceita blocos BDC como
28
+ * filhos, que o híbrido monta na sua folha rolável sobre a foto fixa.
29
+ */
24
30
  export const ProfileView: BdcComponent = (props) => nativeBlock('profile_view', props);
25
31
  export const ProfileSelf: BdcComponent = (props) => nativeBlock('profile_self', props);
26
32
  export const ProfileSetup: BdcComponent = (props) => nativeBlock('profile_setup', props);
@@ -11,6 +11,8 @@ type LayoutProps = {
11
11
  gap?: number | string;
12
12
  padding?: number | string;
13
13
  height?: number;
14
+ /** Spacer: ocupa o espaço livre do Row/Column pai (em vez de `height`). */
15
+ flex?: number;
14
16
  variant?: string;
15
17
  style?: StyleWire;
16
18
  children?: BdcElement[];
@@ -37,6 +39,8 @@ export const Spacer: BdcComponent = (props) => layoutBlock('spacer', props as La
37
39
  type TextProps = LayoutProps & {
38
40
  text?: string;
39
41
  variant?: 'headline' | 'title' | 'body' | 'label';
42
+ /** Torna o texto tocável (link) sem o min-height de um Button. */
43
+ actionId?: string;
40
44
  };
41
45
 
42
46
  export const Text: BdcComponent = (props) => layoutBlock('text', props as TextProps);
@@ -96,6 +100,35 @@ type SectionHeaderProps = LayoutProps & {
96
100
  export const SectionHeader: BdcComponent = (props) =>
97
101
  layoutBlock('section_header', props as SectionHeaderProps);
98
102
 
103
+ type StatTileRowProps = LayoutProps & {
104
+ /** Caminho em `screen.data` para o array de tiles: `{ icon, value, label, action_id?, action_params? }[]`. */
105
+ itemsRef?: string;
106
+ /** Tiles por linha (grade); padrão = todos numa linha só. */
107
+ columns?: number;
108
+ /** `start` = card alinhado à esquerda (valor em até 2 linhas); padrão centralizado. */
109
+ align?: 'center' | 'start';
110
+ };
111
+
112
+ /** Linha genérica de tiles de métrica (ícone + valor + rótulo). Reusável em qualquer tela. */
113
+ export const StatTileRow: BdcComponent = (props) => layoutBlock('stat_tile_row', props as StatTileRowProps);
114
+
115
+ type DataGroupListProps = LayoutProps & {
116
+ /** Caminho em `screen.data`: `{ title, icon, rows: { label, value }[] }[]`. */
117
+ itemsRef?: string;
118
+ };
119
+
120
+ /** Grupos de detalhes (cabeçalho com ícone + linhas rótulo→valor). Reusável. */
121
+ export const DataGroupList: BdcComponent = (props) =>
122
+ layoutBlock('data_group_list', props as DataGroupListProps);
123
+
124
+ type VouchListProps = LayoutProps & {
125
+ /** Caminho em `screen.data`: `{ header, items: { quote, author_name, meta }[] }`. */
126
+ itemsRef?: string;
127
+ };
128
+
129
+ /** Cartões de citação/recomendação (Vouches/depoimentos). Reusável. */
130
+ export const VouchList: BdcComponent = (props) => layoutBlock('vouch_list', props as VouchListProps);
131
+
99
132
  type ListItemProps = LayoutProps & {
100
133
  title?: string;
101
134
  subtitle?: string;
package/src/bdc/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  Button,
4
4
  Card,
5
5
  Column,
6
+ DataGroupList,
6
7
  Icon,
7
8
  InfoBanner,
8
9
  ListItem,
@@ -13,11 +14,15 @@ export {
13
14
  Scroll,
14
15
  SectionHeader,
15
16
  Spacer,
17
+ StatTileRow,
16
18
  Text,
17
19
  ToggleRow,
20
+ VouchList,
18
21
  screenProps,
19
22
  } from './components/ui';
20
23
  export { Chip, ChipRow, DiscoverGrid, DiscoverMap, DiscoverProfileCard } from './components/discover';
24
+ export { Avatar, ContentImage, IconButton, RowOverflowMenu, Video } from './components/media';
25
+ export { ListRowTemplate } from './components/list';
21
26
  export {
22
27
  ChatInboxCapabilities,
23
28
  EmptyState,
@@ -9,6 +9,11 @@ export const SDUI_BLOCK_TYPES = [
9
9
  'spacer',
10
10
  'info_banner',
11
11
  'native_ad',
12
+ 'content_image',
13
+ 'video',
14
+ 'avatar',
15
+ 'icon_button',
16
+ 'row_overflow_menu',
12
17
  'inbox_hub_chrome',
13
18
  'select_row',
14
19
  ] as const;