@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,190 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { SduiBlockWire } from '@proulr/sdui-contract';
3
+ import {
4
+ materializeRowTemplate,
5
+ readSduiRowTemplate,
6
+ readSduiRowTemplates,
7
+ resolveRowTemplateBindings,
8
+ } from './sdui-row-template';
9
+
10
+ const template: SduiBlockWire[] = [
11
+ {
12
+ id: '0.row_template.0.0',
13
+ type: 'column',
14
+ props: {
15
+ action_id: 'open_post_{{item.id}}',
16
+ tap_action: {
17
+ type: 'api_call',
18
+ path: '/v1/posts/{{item.id}}/like',
19
+ body: { comment: '{{item.author.name}} said {{item.body}}', tags: ['{{item.topic}}', 'static'] },
20
+ },
21
+ },
22
+ children: [
23
+ {
24
+ id: '0.row_template.0.0.0',
25
+ type: 'text',
26
+ props: { text: '{{item.author.name}}', fallback: '{{item.missing.deep}}' },
27
+ children: [],
28
+ },
29
+ {
30
+ id: '0.row_template.0.0.1',
31
+ type: 'icon',
32
+ props: { name: 'share', size: '{{item.likes}}' },
33
+ children: [],
34
+ },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ const item = {
40
+ id: 'p-7',
41
+ body: 'hello',
42
+ likes: 12,
43
+ topic: 'music',
44
+ author: { name: 'Ana' },
45
+ };
46
+
47
+ describe('materializeRowTemplate', () => {
48
+ it('resolves simple, nested, interpolated and multi bindings across all props', () => {
49
+ const rows = materializeRowTemplate({ row_template: template, item_key: 'id' }, item, 0);
50
+ const row = rows[0];
51
+ expect(row.props?.action_id).toBe('open_post_p-7');
52
+ const child = row.children?.[0];
53
+ expect(child?.props?.text).toBe('Ana');
54
+ expect(child?.props?.fallback).toBe('');
55
+ expect(row.children?.[1]?.props?.size).toBe('12');
56
+ expect(row.children?.[1]?.props?.name).toBe('share');
57
+ });
58
+
59
+ it('resolves bindings inside nested action payloads (objects and arrays)', () => {
60
+ const rows = materializeRowTemplate({ row_template: template, item_key: 'id' }, item, 3);
61
+ const tap = rows[0].props?.tap_action as {
62
+ path: string;
63
+ body: { comment: string; tags: string[] };
64
+ };
65
+ expect(tap.path).toBe('/v1/posts/p-7/like');
66
+ expect(tap.body.comment).toBe('Ana said hello');
67
+ expect(tap.body.tags).toEqual(['music', 'static']);
68
+ });
69
+
70
+ it('missing fields resolve to empty string; whole-placeholder and partial both work', () => {
71
+ const rows = materializeRowTemplate({ row_template: template, item_key: 'id' }, { id: 'x' }, 0);
72
+ expect(rows[0].children?.[0]?.props?.text).toBe('');
73
+ expect(rows[0].props?.action_id).toBe('open_post_x');
74
+ });
75
+
76
+ it('gives stable ids (template id + item_key value) recursively; falls back to index', () => {
77
+ const source = { row_template: template, item_key: 'id' };
78
+ const a = materializeRowTemplate(source, item, 0);
79
+ const b = materializeRowTemplate(source, item, 5);
80
+ expect(a[0].id).toBe('0.row_template.0.0@p-7');
81
+ expect(a[0].children?.[0].id).toBe('0.row_template.0.0.0@p-7');
82
+ expect(a[0].id).toBe(b[0].id);
83
+
84
+ const fallback = materializeRowTemplate(source, { body: 'no id' }, 7);
85
+ expect(fallback[0].id).toBe('0.row_template.0.0@7');
86
+
87
+ const dotKey = materializeRowTemplate({ row_template: template, item_key: 'author.name' }, item, 0);
88
+ expect(dotKey[0].id).toBe('0.row_template.0.0@Ana');
89
+ });
90
+
91
+ it('accepts a bare block array and deep-clones without mutating the template', () => {
92
+ const frozenProps = template[0].props;
93
+ const rows = materializeRowTemplate(template, item, 1);
94
+ expect(rows[0].props).not.toBe(frozenProps);
95
+ expect(template[0].props?.action_id).toBe('open_post_{{item.id}}');
96
+ expect(rows[0].props).not.toBe(template[0].props);
97
+ expect(rows[0].children?.[0]).not.toBe(template[0].children?.[0]);
98
+ // Bare array has no item_key → index fallback.
99
+ expect(rows[0].id).toBe('0.row_template.0.0@1');
100
+ });
101
+
102
+ it('tolerates empty/missing template parts', () => {
103
+ expect(materializeRowTemplate([], item, 0)).toEqual([]);
104
+ expect(materializeRowTemplate({ row_template: [], item_key: 'id' }, item, 0)).toEqual([]);
105
+ expect(
106
+ materializeRowTemplate({ row_template: [{ id: 'x', type: 'text' }], item_key: 'id' }, item, 0)[0],
107
+ ).toMatchObject({ id: 'x@p-7', props: {}, children: [] });
108
+ });
109
+ });
110
+
111
+ describe('resolveRowTemplateBindings', () => {
112
+ it('resolves strings, arrays, objects and leaves non-strings untouched', () => {
113
+ expect(resolveRowTemplateBindings('{{item.likes}} x {{item.nope}}', item)).toBe('12 x ');
114
+ expect(resolveRowTemplateBindings(42, item)).toBe(42);
115
+ expect(resolveRowTemplateBindings(null, item)).toBe(null);
116
+ expect(resolveRowTemplateBindings(['{{item.id}}'], item)).toEqual(['p-7']);
117
+ expect(resolveRowTemplateBindings({ k: { j: '{{item.id}}' }, n: 1 }, item)).toEqual({
118
+ k: { j: 'p-7' },
119
+ n: 1,
120
+ });
121
+ });
122
+
123
+ it('supports array index dot paths and whole-value objects via JSON', () => {
124
+ expect(resolveRowTemplateBindings('{{item.tags.1}}', { tags: ['a', 'b'] })).toBe('b');
125
+ expect(resolveRowTemplateBindings('{{item.a.b.c}}', {})).toBe('');
126
+ });
127
+ });
128
+
129
+ describe('readSduiRowTemplate', () => {
130
+ it('reads host block props and rejects non-template props', () => {
131
+ expect(
132
+ readSduiRowTemplate({ row_template: [], item_key: 'id', data_ref: 'x' })?.item_key,
133
+ ).toBe('id');
134
+ expect(readSduiRowTemplate({ data_ref: 'x' })).toBeUndefined();
135
+ expect(readSduiRowTemplate(undefined)).toBeUndefined();
136
+ });
137
+ });
138
+
139
+ describe('readSduiRowTemplates', () => {
140
+ it('falls back to the single template as a length-1 array (old hosts unchanged)', () => {
141
+ expect(
142
+ readSduiRowTemplates({ row_template: template, item_key: 'id', data_ref: 'social.posts' }),
143
+ ).toEqual([{ row_template: template, item_key: 'id' }]);
144
+ expect(
145
+ readSduiRowTemplates({ row_template: template, item_key: 'id', section: 'posts' }),
146
+ ).toEqual([{ row_template: template, item_key: 'id', section: 'posts' }]);
147
+ });
148
+
149
+ it('returns the ordered row_templates when a multi-template host carries them', () => {
150
+ const entries = [
151
+ { row_template: template, item_key: 'id', section: 'posts' },
152
+ { row_template: template, item_key: 'comment_id' },
153
+ ];
154
+ expect(
155
+ readSduiRowTemplates({
156
+ row_template: template,
157
+ item_key: 'id',
158
+ section: 'posts',
159
+ row_templates: entries,
160
+ }),
161
+ ).toEqual(entries);
162
+ });
163
+
164
+ it('rejects non-template props and ignores malformed row_templates (fallback to single)', () => {
165
+ expect(readSduiRowTemplates({ data_ref: 'x' })).toBeUndefined();
166
+ expect(readSduiRowTemplates(undefined)).toBeUndefined();
167
+ expect(
168
+ readSduiRowTemplates({
169
+ row_template: template,
170
+ item_key: 'id',
171
+ row_templates: [{ row_template: 'nope', item_key: 'id' }],
172
+ }),
173
+ ).toEqual([{ row_template: template, item_key: 'id' }]);
174
+ });
175
+
176
+ it('entries feed materializeRowTemplate directly (same source shape)', () => {
177
+ const entries = readSduiRowTemplates({
178
+ row_template: template,
179
+ item_key: 'id',
180
+ row_templates: [
181
+ { row_template: [{ id: 't0', type: 'text', props: { text: '{{item.body}}' } }], item_key: 'id' },
182
+ { row_template: [{ id: 't1', type: 'text', props: { text: '{{item.author.name}}' } }], item_key: 'id' },
183
+ ],
184
+ });
185
+ expect(entries).toHaveLength(2);
186
+ expect(materializeRowTemplate(entries?.[1] ?? [], item, 3)).toMatchObject([
187
+ { id: 't1@p-7', props: { text: 'Ana' } },
188
+ ]);
189
+ });
190
+ });
@@ -0,0 +1,153 @@
1
+ import type {
2
+ SduiBlockWire,
3
+ SduiRowTemplateEntryWireProps,
4
+ SduiRowTemplateSetWireProps,
5
+ SduiRowTemplateWireProps,
6
+ } from '@proulr/sdui-contract';
7
+ import { isSduiRowTemplateEntryWireProps, isSduiRowTemplateWireProps } from '@proulr/sdui-contract';
8
+
9
+ /**
10
+ * Materialização de row templates (SDUI 100%): o BFF autorretrata o layout da
11
+ * linha no wire (`props.row_template` + `props.item_key`, via `<ListRowTemplate>`
12
+ * do BDC); o híbrido do app clona o subtree por item vivo de `screen.data` e
13
+ * resolve os bindings `{{item.campo}}` antes de passar ao renderer de blocos.
14
+ *
15
+ * Binding syntax: `{{item.field}}`, `{{item.a.b}}` (dot path; segmentos com
16
+ * `$`/`_`/letras/dígitos/`-`). Substituição em **qualquer** string de prop —
17
+ * incluindo `action_id` e payloads de action aninhados (objetos/arrays).
18
+ * Só `item.*` é suportado (não há `{{index}}`). Path não resolvido → string
19
+ * vazia.
20
+ *
21
+ * Ids estáveis: cada bloco clonado ganha sufixo `@<rowKey>`, onde `rowKey` é o
22
+ * valor de `item_key` do item (dot path; fallback = `index`). Duas renderizações
23
+ * do mesmo item produzem os mesmos ids (React keys / diffs estáveis).
24
+ */
25
+
26
+ /** Fonte do template: ou o objeto de props do hospedeiro, ou só o array de blocos. */
27
+ export type SduiRowTemplateSource =
28
+ | SduiBlockWire[]
29
+ | Pick<SduiRowTemplateWireProps, 'row_template' | 'item_key' | 'section'>;
30
+
31
+ const BINDING_RE = /\{\{\s*item((?:\.[A-Za-z0-9_$-]+)*)\s*\}\}/g;
32
+
33
+ function lookupDotPath(item: unknown, path: string): unknown {
34
+ if (path === '') return undefined;
35
+ let current: unknown = item;
36
+ for (const segment of path.split('.')) {
37
+ if (current == null) return undefined;
38
+ if (Array.isArray(current) && /^\d+$/.test(segment)) {
39
+ current = current[Number(segment)];
40
+ continue;
41
+ }
42
+ if (typeof current !== 'object') return undefined;
43
+ current = (current as Record<string, unknown>)[segment];
44
+ }
45
+ return current;
46
+ }
47
+
48
+ function stringifyBinding(value: unknown): string {
49
+ if (value == null) return '';
50
+ if (typeof value === 'string') return value;
51
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
52
+ return String(value);
53
+ }
54
+ return JSON.stringify(value) ?? '';
55
+ }
56
+
57
+ function resolveString(value: string, item: unknown): string {
58
+ return value.replace(BINDING_RE, (_match, path: string) =>
59
+ stringifyBinding(lookupDotPath(item, path.slice(1))),
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Resolve bindings `{{item.*}}` em qualquer string de um valor profundo
65
+ * (strings, arrays, objetos; chaves não são tocadas). Retorna cópia — nunca
66
+ * muta a entrada.
67
+ */
68
+ export function resolveRowTemplateBindings(value: unknown, item: unknown): unknown {
69
+ if (typeof value === 'string') return resolveString(value, item);
70
+ if (Array.isArray(value)) return value.map((entry) => resolveRowTemplateBindings(entry, item));
71
+ if (value && typeof value === 'object') {
72
+ const out: Record<string, unknown> = {};
73
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
74
+ out[key] = resolveRowTemplateBindings(entry, item);
75
+ }
76
+ return out;
77
+ }
78
+ return value;
79
+ }
80
+
81
+ function materializeBlock(
82
+ block: SduiBlockWire,
83
+ item: unknown,
84
+ rowKey: string,
85
+ ): SduiBlockWire {
86
+ return {
87
+ id: `${block.id}@${rowKey}`,
88
+ type: block.type,
89
+ props: resolveRowTemplateBindings(block.props ?? {}, item) as Record<string, unknown>,
90
+ children: (block.children ?? []).map((child) => materializeBlock(child, item, rowKey)),
91
+ };
92
+ }
93
+
94
+ /** Lê o template promovido no `props` de um bloco hospedeiro (wire ou domain). */
95
+ export function readSduiRowTemplate(
96
+ props: Record<string, unknown> | undefined,
97
+ ): SduiRowTemplateWireProps | undefined {
98
+ return isSduiRowTemplateWireProps(props) ? props : undefined;
99
+ }
100
+
101
+ /**
102
+ * Lê a lista **ordenada** de templates de um bloco hospedeiro multi-template:
103
+ * - `props.row_templates` presente e válido → esse array (um entry por
104
+ * `<ListRowTemplate>`, cada um com seu `item_key`/`section`);
105
+ * - senão, fallback: o template único (`row_template` + `item_key` +
106
+ * `section?`) como array de comprimento 1 — hosts antigos funcionam
107
+ * inalterados;
108
+ * - props sem template → `undefined`.
109
+ */
110
+ export function readSduiRowTemplates(
111
+ props: Record<string, unknown> | undefined,
112
+ ): SduiRowTemplateEntryWireProps[] | undefined {
113
+ if (!isSduiRowTemplateWireProps(props)) return undefined;
114
+ const set = props as SduiRowTemplateSetWireProps;
115
+ const templates = set.row_templates;
116
+ if (
117
+ Array.isArray(templates) &&
118
+ templates.length > 0 &&
119
+ templates.every(isSduiRowTemplateEntryWireProps)
120
+ ) {
121
+ return templates;
122
+ }
123
+ const single: SduiRowTemplateEntryWireProps = {
124
+ row_template: set.row_template,
125
+ item_key: set.item_key,
126
+ };
127
+ if (typeof set.section === 'string' && set.section) single.section = set.section;
128
+ return [single];
129
+ }
130
+
131
+ /**
132
+ * Clona profundamente os blocos do template e resolve `{{item.*}}` em todas as
133
+ * props (incl. action ids e payloads). `template` aceita o objeto
134
+ * `{ row_template, item_key, section }` (props do hospedeiro) ou o array nu de
135
+ * blocos. `index` é o fallback de `rowKey` quando `item_key` não resolve.
136
+ */
137
+ export function materializeRowTemplate(
138
+ template: SduiRowTemplateSource,
139
+ item: unknown,
140
+ index = 0,
141
+ ): SduiBlockWire[] {
142
+ const blocks = Array.isArray(template) ? template : template.row_template ?? [];
143
+ const itemKey = Array.isArray(template) ? undefined : template.item_key;
144
+
145
+ let rowKey = '';
146
+ if (itemKey) {
147
+ const raw = lookupDotPath(item, itemKey);
148
+ if (raw != null && raw !== '') rowKey = stringifyBinding(raw);
149
+ }
150
+ if (!rowKey) rowKey = String(index);
151
+
152
+ return blocks.map((block) => materializeBlock(block, item, rowKey));
153
+ }
@@ -31,6 +31,13 @@ import {
31
31
  SduiProfileCompletionBannerBlock,
32
32
  SduiStoryRailBlock,
33
33
  } from './blocks/SduiSocialBlocks';
34
+ import {
35
+ SduiAvatarBlock,
36
+ SduiContentImageBlock,
37
+ SduiIconButtonBlock,
38
+ SduiRowOverflowMenuBlock,
39
+ SduiVideoBlock,
40
+ } from './blocks/SduiMediaBlocks';
34
41
  import {
35
42
  SduiChatInboxCapabilitiesBlock,
36
43
  SduiEmptyStateBlock,
@@ -44,6 +51,7 @@ import {
44
51
  } from './blocks/SduiInboxBlocks';
45
52
  import { SduiNativeAdBlock } from './blocks/SduiNativeAdBlock';
46
53
  import { SduiQrCodeBlock } from './blocks/SduiQrCodeBlock';
54
+ import { SduiStatTileRowBlock, SduiDataGroupListBlock, SduiVouchListBlock } from './blocks/SduiContentBlocks';
47
55
  import {
48
56
  SduiAlbumDetailBlock,
49
57
  SduiAlbumGridBlock,
@@ -88,11 +96,19 @@ const REGISTRY: Record<string, SduiBlockComponent> = {
88
96
  native_ad: SduiNativeAdBlock,
89
97
  card: SduiCardBlock,
90
98
  section_header: SduiSectionHeaderBlock,
99
+ stat_tile_row: SduiStatTileRowBlock,
100
+ data_group_list: SduiDataGroupListBlock,
101
+ vouch_list: SduiVouchListBlock,
91
102
  list_item: SduiListItemBlock,
92
103
  toggle_row: SduiToggleRowBlock,
93
104
  select_row: SduiSelectRowBlock,
94
105
  chip: SduiChipBlock,
95
106
  chip_row: SduiChipRowBlock,
107
+ content_image: SduiContentImageBlock,
108
+ video: SduiVideoBlock,
109
+ avatar: SduiAvatarBlock,
110
+ icon_button: SduiIconButtonBlock,
111
+ row_overflow_menu: SduiRowOverflowMenuBlock,
96
112
  segmented_tabs: SduiSegmentedTabsBlock,
97
113
  empty_state: SduiEmptyStateBlock,
98
114
  inbox_person_row: SduiInboxPersonRowBlock,
@@ -46,6 +46,8 @@ export type SduiNativeBlockRenderProps = {
46
46
  block: SduiBlock;
47
47
  data: Record<string, unknown>;
48
48
  onAction: (actionId: string, vars?: Record<string, string>) => void;
49
+ /** Nós SDUI já compilados dos blocos filhos do bloco nativo (ex.: corpo do hero). */
50
+ children?: ReactNode;
49
51
  dataRef?: string;
50
52
  conversationIdRef?: string;
51
53
  peerIdRef?: string;
@@ -0,0 +1,84 @@
1
+ import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
2
+ import { invokeSduiMediaResolver, type SduiMediaResolver } from './lib/sdui-media-uri';
3
+
4
+ export type { SduiMediaResolver };
5
+
6
+ /**
7
+ * Contexto genérico de resolução de mídia (capability do app, não do BFF):
8
+ * o app provê `(path) => URL` com **download autenticado** (+ signed só se GET
9
+ * ok). Blocos de mídia do renderer (`content_image`, `avatar`) só consomem
10
+ * este contexto — a lib nunca conhece buckets, tokens nem paths de produto.
11
+ *
12
+ * ```tsx
13
+ * // proulr-app (shell genérico, fora do RootNavigator):
14
+ * <SduiMediaResolverProvider resolveMediaUrl={(path) => authenticatedDownloadUrl(path)}>
15
+ * <SduiScreenRenderer ... />
16
+ * </SduiMediaResolverProvider>
17
+ * ```
18
+ */
19
+ const SduiMediaResolverContext = createContext<SduiMediaResolver | null>(null);
20
+
21
+ export function SduiMediaResolverProvider({
22
+ resolveMediaUrl,
23
+ children,
24
+ }: {
25
+ resolveMediaUrl: SduiMediaResolver | null;
26
+ children: ReactNode;
27
+ }) {
28
+ return (
29
+ <SduiMediaResolverContext.Provider value={resolveMediaUrl}>
30
+ {children}
31
+ </SduiMediaResolverContext.Provider>
32
+ );
33
+ }
34
+
35
+ /** Resolver fornecido pelo app, ou `null` quando a capability está desligada. */
36
+ export function useSduiMediaResolver(): SduiMediaResolver | null {
37
+ return useContext(SduiMediaResolverContext);
38
+ }
39
+
40
+ /**
41
+ * Uri resolvida para um path de mídia. Estados:
42
+ * - `uri`: URL pronta para o componente de imagem (ou `null`);
43
+ * - `pending`: resolver assíncrono ainda em voo — o bloco mantém placeholder
44
+ * de tamanho estável (anti-flicker: sem animação de opacidade no mount);
45
+ * - `settled`: a resolução terminou (sucesso, vazio ou erro). Antes do primeiro
46
+ * settle, `settled=false` — o bloco mantém placeholder em vez de colapsar
47
+ * (o `useEffect` roda pós-mount; sem este flag renderizaria `null` por um
48
+ * frame e causaria flicker de layout).
49
+ * Sem resolver / path vazio / resolução vazia / erro → `uri: null, settled: true`.
50
+ */
51
+ export function useSduiMediaUri(path: string): {
52
+ uri: string | null;
53
+ pending: boolean;
54
+ settled: boolean;
55
+ } {
56
+ const resolver = useSduiMediaResolver();
57
+ const [state, setState] = useState<{ uri: string | null; pending: boolean; settled: boolean }>({
58
+ uri: null,
59
+ pending: false,
60
+ settled: false,
61
+ });
62
+
63
+ useEffect(() => {
64
+ let cancelled = false;
65
+ const result = invokeSduiMediaResolver(resolver, path);
66
+ if (result === null) {
67
+ setState({ uri: null, pending: false, settled: true });
68
+ return;
69
+ }
70
+ if (typeof result === 'string') {
71
+ setState({ uri: result, pending: false, settled: true });
72
+ return;
73
+ }
74
+ setState({ uri: null, pending: true, settled: false });
75
+ result.then((uri) => {
76
+ if (!cancelled) setState({ uri, pending: false, settled: true });
77
+ });
78
+ return () => {
79
+ cancelled = true;
80
+ };
81
+ }, [resolver, path]);
82
+
83
+ return state;
84
+ }
@@ -0,0 +1,55 @@
1
+ import { createContext, useContext, type ReactNode } from 'react';
2
+ import type { SduiBlock } from '@proulr/sdui-contract';
3
+ import type { SduiVideoParsed } from './lib/sdui-media-block-props';
4
+
5
+ /**
6
+ * Slot injetável de playback de vídeo (capability do **app**, não da lib):
7
+ * o bloco `video` do renderer resolve `path`/`poster_path` via o mesmo
8
+ * `SduiMediaResolverContext` (download autenticado continua no app) e delega
9
+ * a renderização do player a este contexto — o app pluga `expo-video` quando
10
+ * quiser. **Sem provider**, o bloco renderiza um frame estático com o poster
11
+ * (still via resolver) — nunca URL/path de produto hardcoded.
12
+ *
13
+ * ```tsx
14
+ * // proulr-app (shell genérico):
15
+ * <SduiVideoPlayerProvider renderVideoPlayer={ExpoVideoPlayerSlot}>
16
+ * <SduiScreenRenderer ... />
17
+ * </SduiVideoPlayerProvider>
18
+ * ```
19
+ */
20
+ export type SduiVideoPlayerProps = {
21
+ block: SduiBlock;
22
+ data: Record<string, unknown>;
23
+ onAction: (actionId: string, vars?: Record<string, string>) => void;
24
+ /** Frame já dimensionado pela lib (width/height/aspect/corner/testId do wire). */
25
+ parsed: SduiVideoParsed;
26
+ /** URI do vídeo resolvida pelo `SduiMediaResolver` (null enquanto pendente/sem resolver). */
27
+ uri: string | null;
28
+ pending: boolean;
29
+ settled: boolean;
30
+ /** URI do poster resolvida (null quando não há `poster_path` ou não resolve). */
31
+ posterUri: string | null;
32
+ };
33
+
34
+ export type SduiVideoPlayer = (props: SduiVideoPlayerProps) => ReactNode;
35
+
36
+ const SduiVideoPlayerContext = createContext<SduiVideoPlayer | null>(null);
37
+
38
+ export function SduiVideoPlayerProvider({
39
+ renderVideoPlayer,
40
+ children,
41
+ }: {
42
+ renderVideoPlayer: SduiVideoPlayer | null;
43
+ children: ReactNode;
44
+ }) {
45
+ return (
46
+ <SduiVideoPlayerContext.Provider value={renderVideoPlayer}>
47
+ {children}
48
+ </SduiVideoPlayerContext.Provider>
49
+ );
50
+ }
51
+
52
+ /** Player fornecido pelo app, ou `null` → o bloco usa o frame de poster default. */
53
+ export function useSduiVideoPlayer(): SduiVideoPlayer | null {
54
+ return useContext(SduiVideoPlayerContext);
55
+ }
@@ -59,6 +59,8 @@ export function resolveViewStyleProps(
59
59
  borderColor: resolveColor(style.border_color),
60
60
  opacity: style.opacity,
61
61
  flex: style.flex,
62
+ // Sem isso um filho `flex` de Row não encolhe abaixo do conteúdo e estoura a linha.
63
+ minWidth: style.flex != null ? 0 : undefined,
62
64
  alignItems: style.align_items,
63
65
  };
64
66
  }
@@ -18,36 +18,53 @@ const CHIP_HIT_SLOP = { top: 4, bottom: 4, left: 2, right: 2 };
18
18
  export function Chip({
19
19
  label,
20
20
  active,
21
+ tone,
21
22
  onPress,
22
23
  disabled,
23
24
  icon,
24
25
  testID,
26
+ readOnly,
25
27
  }: {
26
28
  label: string;
27
29
  active?: boolean;
30
+ /** Cor de destaque da pílula de conteúdo: 'primary' (realce) ou 'secondary' (relacionado). */
31
+ tone?: 'primary' | 'secondary';
28
32
  onPress?: () => void;
29
33
  disabled?: boolean;
30
34
  icon?: string;
31
35
  testID?: string;
36
+ /** Tag de conteúdo sem ação: não é botão nem aparece esmaecida como desabilitada. */
37
+ readOnly?: boolean;
32
38
  }) {
39
+ const toneColor = tone === 'secondary' ? m3Colors.secondary : m3Colors.primary;
40
+ const toneBorder = tone === 'secondary' ? m3Colors.secondaryContainer : m3Colors.primaryContainer;
33
41
  const containerStyle = active
34
42
  ? { backgroundColor: m3Colors.inverseSurface, borderColor: m3Colors.inverseSurface }
35
- : { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder };
43
+ : tone
44
+ ? { backgroundColor: toneBorder, borderColor: toneColor }
45
+ : { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder };
36
46
 
37
- const textColor = disabled ? m3Colors.onDisabled : active ? colors.headerStart : colors.textOnAccent;
47
+ const textColor = disabled && !readOnly
48
+ ? m3Colors.onDisabled
49
+ : active
50
+ ? colors.headerStart
51
+ : tone
52
+ ? toneColor
53
+ : colors.textOnAccent;
38
54
  const iconName = icon && isAppIconName(icon) ? icon : null;
39
55
  const focus = useFocusRing();
40
56
 
41
57
  return (
42
58
  <Pressable
43
59
  testID={testID}
44
- accessibilityRole="button"
60
+ accessibilityRole={readOnly ? 'text' : 'button'}
45
61
  accessibilityLabel={label}
46
- accessibilityState={{ selected: Boolean(active), disabled: Boolean(disabled) }}
47
- onPress={onPress}
48
- onFocus={focus.onFocus}
49
- onBlur={focus.onBlur}
50
- disabled={disabled}
62
+ accessibilityState={readOnly ? undefined : { selected: Boolean(active), disabled: Boolean(disabled) }}
63
+ onPress={readOnly ? undefined : onPress}
64
+ onFocus={readOnly ? undefined : focus.onFocus}
65
+ onBlur={readOnly ? undefined : focus.onBlur}
66
+ focusable={!readOnly}
67
+ disabled={disabled || readOnly}
51
68
  hitSlop={CHIP_HIT_SLOP}
52
69
  style={({ pressed }) => [
53
70
  styles.chip,
@@ -4,17 +4,23 @@ import { spacing } from '../theme/proulr-theme';
4
4
 
5
5
  type ChipRowProps = {
6
6
  children: React.ReactNode;
7
+ /** Quebra em várias linhas (todas visíveis) em vez de rolar para o lado. */
8
+ wrap?: boolean;
7
9
  };
8
10
 
9
11
  /**
10
12
  * Linha horizontal de pílulas — a mesma fileira do Caçar, do hub do chat e de
11
13
  * qualquer seletor de opções. O encaixe horizontal é do invólucro que chama.
12
14
  */
13
- export function ChipRow({ children }: ChipRowProps) {
15
+ export function ChipRow({ children, wrap = false }: ChipRowProps) {
14
16
  if (!children) {
15
17
  return <View style={styles.content} />;
16
18
  }
17
19
 
20
+ if (wrap) {
21
+ return <View style={styles.wrap}>{children}</View>;
22
+ }
23
+
18
24
  return (
19
25
  <ScrollView
20
26
  horizontal
@@ -41,4 +47,11 @@ const styles = StyleSheet.create({
41
47
  gap: spacing.xs,
42
48
  paddingHorizontal: spacing.sm,
43
49
  },
50
+ wrap: {
51
+ flexDirection: 'row',
52
+ flexWrap: 'wrap',
53
+ alignItems: 'center',
54
+ alignSelf: 'stretch',
55
+ gap: spacing.xs,
56
+ },
44
57
  });