@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.
- package/package.json +2 -2
- package/src/bdc/compile-bdc-row-template.test.ts +179 -0
- package/src/bdc/compile-bdc-to-wire.test.ts +11 -0
- package/src/bdc/compile-bdc-to-wire.ts +52 -1
- package/src/bdc/components/bdc-components.test.ts +9 -0
- package/src/bdc/components/discover.tsx +7 -1
- package/src/bdc/components/list.test.ts +36 -0
- package/src/bdc/components/list.tsx +62 -0
- package/src/bdc/components/media.test.ts +268 -0
- package/src/bdc/components/media.tsx +275 -0
- package/src/bdc/components/native-blocks.tsx +7 -1
- package/src/bdc/components/ui.tsx +33 -0
- package/src/bdc/index.ts +5 -0
- package/src/catalog/block-types.ts +5 -0
- package/src/renderer/SduiTemplateRow.tsx +63 -0
- package/src/renderer/blocks/SduiContentBlocks.tsx +299 -0
- package/src/renderer/blocks/SduiDiscoverBlocks.tsx +39 -3
- package/src/renderer/blocks/SduiMediaBlocks.tsx +348 -0
- package/src/renderer/blocks/SduiNativeDomainBlocks.tsx +2 -0
- package/src/renderer/blocks/SduiPrimitiveBlocks.tsx +22 -3
- package/src/renderer/index.ts +48 -0
- package/src/renderer/lib/sdui-media-block-props.ts +282 -0
- package/src/renderer/lib/sdui-media-blocks.test.ts +279 -0
- package/src/renderer/lib/sdui-media-uri.ts +39 -0
- package/src/renderer/lib/sdui-row-template.test.ts +190 -0
- package/src/renderer/lib/sdui-row-template.ts +153 -0
- package/src/renderer/sdui-component-registry.tsx +16 -0
- package/src/renderer/sdui-hybrid-renderer-context.tsx +2 -0
- package/src/renderer/sdui-media-resolver-context.tsx +84 -0
- package/src/renderer/sdui-video-player-context.tsx +55 -0
- package/src/renderer/theme/resolve-style-props.ts +2 -0
- package/src/renderer/ui/Chip.tsx +25 -8
- 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
|
-
|
|
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,
|