@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proulr/sdui-components",
3
- "version": "0.1.88",
3
+ "version": "0.1.91",
4
4
  "type": "module",
5
5
  "main": "./src/renderer/index.ts",
6
6
  "types": "./src/renderer/index.ts",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@proulr/sdui-contract": "^0.1.17",
42
+ "@proulr/sdui-contract": "^0.1.19",
43
43
  "react-native-qrcode-svg": "^6.3.21"
44
44
  },
45
45
  "peerDependencies": {
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileBdcToWire } from './compile-bdc-to-wire';
3
+ import { ListRowTemplate } from './components/list';
4
+ import { Button, Column, Screen, Text } from './components/ui';
5
+ import { SocialPostFeed } from './components/native-blocks';
6
+ import type { BdcElement } from './jsx-runtime';
7
+
8
+ function screen(children: BdcElement[]): BdcElement {
9
+ return Screen({ id: 'social.feed', data: { posts: [{ id: 'p1' }] }, children });
10
+ }
11
+
12
+ describe('compileBdcToWire — row templates', () => {
13
+ it('hoists <ListRowTemplate> subtree into host props.row_template', () => {
14
+ const host = SocialPostFeed({
15
+ dataRef: 'social.posts',
16
+ children: [
17
+ ListRowTemplate({
18
+ itemKey: 'id',
19
+ section: 'posts',
20
+ children: [
21
+ Column({
22
+ children: [
23
+ Text({ text: '{{item.body}}' }),
24
+ Button({ actionId: 'open_post_{{item.id}}', label: 'Abrir' }),
25
+ ],
26
+ }),
27
+ ],
28
+ }),
29
+ ],
30
+ });
31
+
32
+ const wire = compileBdcToWire(screen([host]));
33
+ const feed = wire.blocks[0];
34
+
35
+ expect(feed.type).toBe('social_post_feed');
36
+ expect(feed.props).toMatchObject({
37
+ data_ref: 'social.posts',
38
+ item_key: 'id',
39
+ section: 'posts',
40
+ });
41
+
42
+ // Template element never reaches the wire as a child block.
43
+ expect(feed.children).toEqual([]);
44
+
45
+ const rows = feed.props?.row_template as BdcElement[];
46
+ expect(Array.isArray(rows)).toBe(true);
47
+ expect(rows).toHaveLength(1);
48
+ expect(rows[0]).toMatchObject({ type: 'column', id: '0.row_template.0.0' });
49
+ // Bindings stay verbatim in the wire — substitution happens at materialize time.
50
+ const inner = (rows[0] as unknown as BdcElement).children;
51
+ expect(inner[0].props.text).toBe('{{item.body}}');
52
+ expect(inner[1].props.action_id).toBe('open_post_{{item.id}}');
53
+ });
54
+
55
+ it('defaults item_key to "id" and omits section when not set', () => {
56
+ const wire = compileBdcToWire(
57
+ screen([
58
+ SocialPostFeed({
59
+ children: [ListRowTemplate({ children: [Text({ text: 'x' })] })],
60
+ }),
61
+ ]),
62
+ );
63
+ expect(wire.blocks[0]?.props).toMatchObject({ item_key: 'id' });
64
+ expect('section' in (wire.blocks[0]?.props ?? {})).toBe(false);
65
+ });
66
+
67
+ it('single-template host: wire is byte-identical to before (no row_templates key)', () => {
68
+ const host = SocialPostFeed({
69
+ dataRef: 'social.posts',
70
+ children: [
71
+ Text({ text: 'header' }),
72
+ ListRowTemplate({ itemKey: 'id', children: [Text({ text: 'a' })] }),
73
+ ],
74
+ });
75
+ const wire = compileBdcToWire(screen([host]));
76
+ const feed = wire.blocks[0];
77
+ expect(feed.children?.map((c) => c.props?.text)).toEqual(['header']);
78
+ expect(feed.props?.row_template).toHaveLength(1);
79
+ expect('row_templates' in (feed.props ?? {})).toBe(false);
80
+ expect(feed.props).toMatchObject({ item_key: 'id' });
81
+ });
82
+
83
+ it('multi-template host: row_templates is ordered (own item_key/section each), row_template stays the FIRST', () => {
84
+ const host = SocialPostFeed({
85
+ dataRef: 'social.posts',
86
+ children: [
87
+ ListRowTemplate({ itemKey: 'id', section: 'posts', children: [Text({ text: 'a' })] }),
88
+ ListRowTemplate({ itemKey: 'comment_id', section: 'comments', children: [Text({ text: 'b' })] }),
89
+ ],
90
+ });
91
+ const wire = compileBdcToWire(screen([host]));
92
+ const feed = wire.blocks[0];
93
+
94
+ // Backward compat: single-shape props describe the first template.
95
+ expect(feed.props).toMatchObject({ item_key: 'id', section: 'posts' });
96
+ expect(feed.props?.row_template).toHaveLength(1);
97
+ expect(((feed.props?.row_template as BdcElement[])[0]).props.text).toBe('a');
98
+
99
+ const set = feed.props?.row_templates as Record<string, unknown>[];
100
+ expect(Array.isArray(set)).toBe(true);
101
+ expect(set).toHaveLength(2);
102
+ expect(set[0]).toMatchObject({ item_key: 'id', section: 'posts' });
103
+ expect(set[1]).toMatchObject({ item_key: 'comment_id', section: 'comments' });
104
+ expect(set[1].row_template).toHaveLength(1);
105
+ expect((set[1].row_template as BdcElement[])[0].props.text).toBe('b');
106
+ });
107
+
108
+ it('template ids stay unique via host path + template index', () => {
109
+ const host = SocialPostFeed({
110
+ children: [
111
+ ListRowTemplate({ itemKey: 'id', children: [Text({ text: 'a' })] }),
112
+ ListRowTemplate({ itemKey: 'id', children: [Text({ text: 'b' }), Text({ text: 'c' })] }),
113
+ ],
114
+ });
115
+ const wire = compileBdcToWire(screen([host]));
116
+ const set = wire.blocks[0]?.props?.row_templates as { row_template: { id: string }[] }[];
117
+ const ids = set.flatMap((entry) => entry.row_template.map((block) => block.id));
118
+ expect(ids).toEqual(['0.row_template.0.0', '0.row_template.1.0', '0.row_template.1.1']);
119
+ expect(new Set(ids).size).toBe(ids.length);
120
+ });
121
+
122
+ it('template entry omits section when not set; item_key defaults to "id"', () => {
123
+ const host = SocialPostFeed({
124
+ children: [
125
+ ListRowTemplate({ children: [Text({ text: 'a' })] }),
126
+ ListRowTemplate({ itemKey: 'id', section: 'b', children: [Text({ text: 'b' })] }),
127
+ ],
128
+ });
129
+ const wire = compileBdcToWire(screen([host]));
130
+ const set = wire.blocks[0]?.props?.row_templates as Record<string, unknown>[];
131
+ expect(set[0]).toMatchObject({ item_key: 'id' });
132
+ expect('section' in set[0]).toBe(false);
133
+ expect(set[1]).toMatchObject({ item_key: 'id', section: 'b' });
134
+ // First template has no section → host-level `section` stays absent (compat).
135
+ expect('section' in (wire.blocks[0]?.props ?? {})).toBe(false);
136
+ });
137
+
138
+ it('flattens fragments wrapping template rows', () => {
139
+ const host = SocialPostFeed({
140
+ children: [
141
+ {
142
+ type: 'list_row_template',
143
+ props: { item_key: 'id' },
144
+ children: [
145
+ {
146
+ type: '_fragment',
147
+ props: {},
148
+ children: [{ type: 'text', props: { text: '{{item.a.b}}' }, children: [] }],
149
+ },
150
+ ],
151
+ },
152
+ ],
153
+ });
154
+ const wire = compileBdcToWire(screen([host]));
155
+ const rows = wire.blocks[0]?.props?.row_template;
156
+ expect(rows).toHaveLength(1);
157
+ expect((rows as unknown[])[0]).toMatchObject({ type: 'text', props: { text: '{{item.a.b}}' } });
158
+ });
159
+
160
+ it('supports templates inside any block (generic children handling), e.g. Column host', () => {
161
+ const host = Column({
162
+ children: [ListRowTemplate({ itemKey: 'id', children: [Text({ text: 'r' })] })],
163
+ });
164
+ const wire = compileBdcToWire(screen([host]));
165
+ expect(wire.blocks[0]?.props).toMatchObject({ item_key: 'id' });
166
+ expect(wire.blocks[0]?.props?.row_template).toHaveLength(1);
167
+ expect(wire.blocks[0]?.children).toEqual([]);
168
+ });
169
+
170
+ it('blocks without row_template compile exactly as before (backward compat)', () => {
171
+ const wire = compileBdcToWire(
172
+ screen([Column({ children: [Text({ text: 'plain' })] })]),
173
+ );
174
+ const col = wire.blocks[0];
175
+ expect(col.props).not.toHaveProperty('row_template');
176
+ expect(col.props).not.toHaveProperty('item_key');
177
+ expect(col.children).toHaveLength(1);
178
+ });
179
+ });
@@ -41,6 +41,17 @@ describe('compileBdcToWire', () => {
41
41
  expect(wire.blocks[0].props).toMatchObject({ action_id: 'go', label: 'Go' });
42
42
  });
43
43
 
44
+ it('keeps grid/wrap layout props of content blocks on the wire', () => {
45
+ const wire = compileBdcToWire(
46
+ screen({ id: 'demo.home' }, [
47
+ { type: 'stat_tile_row', props: { itemsRef: 'content.health', columns: 2, align: 'start' }, children: [] },
48
+ { type: 'chip_row', props: { itemsRef: 'content.tribes', layout: 'wrap' }, children: [] },
49
+ ]),
50
+ );
51
+ expect(wire.blocks[0].props).toMatchObject({ items_ref: 'content.health', columns: 2, align: 'start' });
52
+ expect(wire.blocks[1].props).toMatchObject({ items_ref: 'content.tribes', layout: 'wrap' });
53
+ });
54
+
44
55
  it('rejects non-screen roots', () => {
45
56
  expect(() =>
46
57
  compileBdcToWire({ type: 'stack', props: {}, children: [] }),
@@ -74,6 +74,57 @@ function flattenBlockChildren(children: BdcElement[]): BdcElement[] {
74
74
  return out;
75
75
  }
76
76
 
77
+ /** Tipo BDC de `<ListRowTemplate>` — promoted para `props.row_template` do hospedeiro. */
78
+ const LIST_ROW_TEMPLATE_TYPE = 'list_row_template';
79
+
80
+ /** Um template promovido: blocos da linha + item_key/section próprios. */
81
+ type RowTemplateEntry = {
82
+ row_template: SduiBlockWire[];
83
+ item_key: string;
84
+ section?: string;
85
+ };
86
+
87
+ function hoistRowTemplates(
88
+ blockChildren: BdcElement[],
89
+ props: Record<string, unknown>,
90
+ path: string,
91
+ ): BdcElement[] {
92
+ const templates = blockChildren.filter((child) => child.type === LIST_ROW_TEMPLATE_TYPE);
93
+ if (templates.length === 0) return blockChildren;
94
+
95
+ const entries: RowTemplateEntry[] = templates.map((template, templateIndex) => {
96
+ const rowBlocks: SduiBlockWire[] = [];
97
+ flattenBlockChildren(template.children).forEach((child, childIndex) => {
98
+ rowBlocks.push(mapBlock(child, `${path}.row_template.${templateIndex}.${childIndex}`));
99
+ });
100
+ const itemKey = template.props.item_key ?? template.props.itemKey;
101
+ const entry: RowTemplateEntry = {
102
+ row_template: rowBlocks,
103
+ item_key: typeof itemKey === 'string' && itemKey ? itemKey : 'id',
104
+ };
105
+ if (typeof template.props.section === 'string' && template.props.section) {
106
+ entry.section = template.props.section;
107
+ }
108
+ return entry;
109
+ });
110
+
111
+ // Backward compat: as props singulares descrevem SEMPRE o primeiro template —
112
+ // leitores antigos (`readSduiRowTemplate`) continuam funcionando inalterados.
113
+ props.row_template = entries[0].row_template;
114
+ props.item_key = entries[0].item_key;
115
+ if (entries[0].section !== undefined) {
116
+ props.section = entries[0].section;
117
+ }
118
+ // Host multi-template: lista ordenada, um entry por template (cada um com
119
+ // seu `item_key`/`section`). Hosts de template único mantém o wire idêntico
120
+ // (sem `row_templates`). Leitura: `readSduiRowTemplates` no renderer.
121
+ if (entries.length > 1) {
122
+ props.row_templates = entries;
123
+ }
124
+
125
+ return blockChildren.filter((child) => child.type !== LIST_ROW_TEMPLATE_TYPE);
126
+ }
127
+
77
128
  function mapBlock(element: BdcElement, path: string): SduiBlockWire {
78
129
  if (element.type === '_text_node') {
79
130
  return { id: path, type: 'text', props: { text: String(element.props.value ?? '') } };
@@ -89,7 +140,7 @@ function mapBlock(element: BdcElement, path: string): SduiBlockWire {
89
140
  props.text = textFromChildren(element.children);
90
141
  }
91
142
 
92
- const blockChildren = flattenBlockChildren(element.children);
143
+ const blockChildren = hoistRowTemplates(flattenBlockChildren(element.children), props, path);
93
144
 
94
145
  return {
95
146
  id: typeof element.props.id === 'string' ? element.props.id : path,
@@ -55,6 +55,7 @@ import {
55
55
  Scroll,
56
56
  SectionHeader,
57
57
  Spacer,
58
+ StatTileRow,
58
59
  Text,
59
60
  ToggleRow,
60
61
  screenProps,
@@ -75,6 +76,10 @@ describe('bdc ui components', () => {
75
76
  expect(SectionHeader({ title: 'T' }).type).toBe('section_header');
76
77
  expect(ListItem({ title: 'Item' }).type).toBe('list_item');
77
78
  expect(ToggleRow({ label: 'On' }).type).toBe('toggle_row');
79
+ expect(StatTileRow({ itemsRef: 'content.health', columns: 2, align: 'start' })).toMatchObject({
80
+ type: 'stat_tile_row',
81
+ props: { itemsRef: 'content.health', columns: 2, align: 'start' },
82
+ });
78
83
  expect(screenProps({ id: 'x' })).toEqual({ id: 'x' });
79
84
  });
80
85
  });
@@ -83,6 +88,10 @@ describe('bdc discover/forms/social/inbox/native', () => {
83
88
  it('builds discover and form blocks', () => {
84
89
  expect(Chip({ label: 'All' }).type).toBe('chip');
85
90
  expect(ChipRow({}).type).toBe('chip_row');
91
+ expect(ChipRow({ itemsRef: 'content.tribes', layout: 'wrap' }).props).toMatchObject({
92
+ itemsRef: 'content.tribes',
93
+ layout: 'wrap',
94
+ });
86
95
  expect(DiscoverGrid({ itemsRef: 'items' }).type).toBe('discover_grid');
87
96
  expect(DiscoverProfileCard({ profileRef: 'p' }).type).toBe('discover_profile_card');
88
97
  expect(DiscoverMap({}).type).toBe('discover_map');
@@ -14,6 +14,7 @@ function layoutBlock(type: string, props: LayoutProps): BdcElement {
14
14
  type ChipProps = LayoutProps & {
15
15
  label: string;
16
16
  glyph?: string;
17
+ tone?: 'primary' | 'secondary';
17
18
  actionId?: string;
18
19
  actionParams?: Record<string, string>;
19
20
  active?: boolean;
@@ -21,7 +22,12 @@ type ChipProps = LayoutProps & {
21
22
 
22
23
  export const Chip: BdcComponent = (props) => layoutBlock('chip', props as ChipProps);
23
24
 
24
- type ChipRowProps = LayoutProps;
25
+ type ChipRowProps = LayoutProps & {
26
+ /** Caminho em `screen.data` para chips dinâmicos: `{ label, glyph?, tone?, actionId?, actionParams? }[]`. */
27
+ itemsRef?: string;
28
+ /** `wrap` quebra em linhas (todas visíveis); padrão rola na horizontal. */
29
+ layout?: 'scroll' | 'wrap';
30
+ };
25
31
 
26
32
  export const ChipRow: BdcComponent = (props) => layoutBlock('chip_row', props as ChipRowProps);
27
33
 
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ListRowTemplate } from './list';
3
+ import { Text } from './ui';
4
+ import type { BdcElement } from '../jsx-runtime';
5
+
6
+ function el(type: string, props: Record<string, unknown>, children: BdcElement[] = []): BdcElement {
7
+ return { type, props, children };
8
+ }
9
+
10
+ describe('ListRowTemplate (BDC)', () => {
11
+ it('normalizes itemKey to wire item_key with default id', () => {
12
+ const node = ListRowTemplate({ itemKey: 'post_id', children: [el('text', { text: 'x' })] });
13
+ expect(node.type).toBe('list_row_template');
14
+ expect(node.props.item_key).toBe('post_id');
15
+ expect(node.children).toHaveLength(1);
16
+
17
+ const defaulted = ListRowTemplate({});
18
+ expect(defaulted.props.item_key).toBe('id');
19
+ expect(defaulted.children).toEqual([]);
20
+ });
21
+
22
+ it('accepts snake_case item_key and hoists section only when set', () => {
23
+ const withSection = ListRowTemplate({ item_key: 'id', section: 'comments' });
24
+ expect(withSection.props.item_key).toBe('id');
25
+ expect(withSection.props.section).toBe('comments');
26
+
27
+ const withoutSection = ListRowTemplate({ item_key: 'id', section: '' });
28
+ expect('section' in withoutSection.props).toBe(false);
29
+ });
30
+
31
+ it('composes with BDC children', () => {
32
+ const row = Text({ text: '{{item.body}}' });
33
+ const node = ListRowTemplate({ itemKey: 'id', children: [row] });
34
+ expect(node.children[0]?.props.text).toBe('{{item.body}}');
35
+ });
36
+ });
@@ -0,0 +1,62 @@
1
+ import type { BdcComponent, BdcElement } from '../jsx-runtime';
2
+
3
+ /**
4
+ * `<ListRowTemplate>` — marca de autoria (BFF) que transforma o subtree de
5
+ * filhos num **row template** do bloco hospedeiro (híbrido nativo: `SocialPostFeed`,
6
+ * `ThoughtFeed`, `PostComments`, `SocialProfile`, `ProfileView`, `ProfileSelf`,
7
+ * `StoryViewersPanel`, `RightNowFeed`, `AlbumGrid`, …).
8
+ *
9
+ * `compileBdcToWire` remove o elemento dos `children` do hospedeiro e promove
10
+ * no `props` dele (ver `SduiRowTemplateWireProps` em `@proulr/sdui-contract`):
11
+ *
12
+ * ```
13
+ * row_template: SduiBlockWire[] // subtree compilado (wire normal, snake_case)
14
+ * item_key: string // campo identificador de cada item (default 'id'; dot path ok)
15
+ * section?: string // marcador opcional de seção (só quando definido)
16
+ * ```
17
+ *
18
+ * Bindings: qualquer string em props do subtree (texto, imagem, `actionId`,
19
+ * payloads de action aninhados) pode usar `{{item.campo}}` / `{{item.a.b}}`;
20
+ * o app resolve em `materializeRowTemplate` (renderer) por item vivo do
21
+ * `screen.data`. Path não resolvido → string vazia.
22
+ *
23
+ * **Multi-template:** cada `<ListRowTemplate>` carrega o **próprio**
24
+ * `item_key`/`section`. Com dois ou mais templates no mesmo hospedeiro, o
25
+ * `compileBdcToWire` emite `props.row_templates` (lista ordenada, um entry por
26
+ * template) e mantém `row_template`/`item_key`/`section` = primeiro template
27
+ * (backward compat; leitura via `readSduiRowTemplates` no renderer).
28
+ *
29
+ * Restrições documentadas:
30
+ * - deve ser **filho direto** do bloco hospedeiro;
31
+ * - `item_key`/`section` são **por template** (default `item_key` = `'id'`);
32
+ * - nunca chega ao wire como bloco renderizável (não registrar no renderer).
33
+ *
34
+ * @example
35
+ * <SocialPostFeed dataRef="social.posts">
36
+ * <ListRowTemplate itemKey="id">
37
+ * <Column>
38
+ * <Text text="{{item.author.name}}" />
39
+ * <Button label="Abrir" actionId="open_post_{{item.id}}" />
40
+ * </Column>
41
+ * </ListRowTemplate>
42
+ * </SocialPostFeed>
43
+ */
44
+ type ListRowTemplateProps = {
45
+ id?: string;
46
+ /** Campo identificador de cada item vivo (ex.: `'id'`, `'post.id'`). Default `'id'`. */
47
+ itemKey?: string;
48
+ /** Aceita também a forma snake_case do wire. */
49
+ item_key?: string;
50
+ /** Marcador opcional de seção para feeds com múltiplas seções. */
51
+ section?: string;
52
+ children?: BdcElement[];
53
+ };
54
+
55
+ export const ListRowTemplate: BdcComponent = (props) => {
56
+ const { id, itemKey, item_key: itemKeySnake, section, children } = props as ListRowTemplateProps;
57
+ const itemKeyWire = itemKey ?? itemKeySnake ?? 'id';
58
+ const elementProps: Record<string, unknown> = { item_key: itemKeyWire };
59
+ if (typeof id === 'string') elementProps.id = id;
60
+ if (typeof section === 'string' && section) elementProps.section = section;
61
+ return { type: 'list_row_template', props: elementProps, children: children ?? [] };
62
+ };