@proulr/sdui-components 0.1.87 → 0.1.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +2 -2
- package/src/bdc/compile-bdc-row-template.test.ts +179 -0
- package/src/bdc/compile-bdc-to-wire.ts +52 -1
- package/src/bdc/components/discover.tsx +5 -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 +25 -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 +198 -0
- package/src/renderer/blocks/SduiDiscoverBlocks.tsx +37 -2
- package/src/renderer/blocks/SduiMediaBlocks.tsx +348 -0
- package/src/renderer/blocks/SduiNativeDomainBlocks.tsx +2 -0
- 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/ui/Chip.tsx +15 -2
- package/src/renderer/ui/Skeleton.tsx +13 -4
- package/src/renderer/ui/index.ts +1 -0
- package/src/renderer/ui/use-reduce-motion.ts +31 -0
|
@@ -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
|
+
}
|
package/src/renderer/ui/Chip.tsx
CHANGED
|
@@ -18,6 +18,7 @@ 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,
|
|
@@ -25,16 +26,28 @@ export function Chip({
|
|
|
25
26
|
}: {
|
|
26
27
|
label: string;
|
|
27
28
|
active?: boolean;
|
|
29
|
+
/** Cor de destaque da pílula de conteúdo: 'primary' (realce) ou 'secondary' (relacionado). */
|
|
30
|
+
tone?: 'primary' | 'secondary';
|
|
28
31
|
onPress?: () => void;
|
|
29
32
|
disabled?: boolean;
|
|
30
33
|
icon?: string;
|
|
31
34
|
testID?: string;
|
|
32
35
|
}) {
|
|
36
|
+
const toneColor = tone === 'secondary' ? m3Colors.secondary : m3Colors.primary;
|
|
37
|
+
const toneBorder = tone === 'secondary' ? m3Colors.secondaryContainer : m3Colors.primaryContainer;
|
|
33
38
|
const containerStyle = active
|
|
34
39
|
? { backgroundColor: m3Colors.inverseSurface, borderColor: m3Colors.inverseSurface }
|
|
35
|
-
:
|
|
40
|
+
: tone
|
|
41
|
+
? { backgroundColor: toneBorder, borderColor: toneColor }
|
|
42
|
+
: { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder };
|
|
36
43
|
|
|
37
|
-
const textColor = disabled
|
|
44
|
+
const textColor = disabled
|
|
45
|
+
? m3Colors.onDisabled
|
|
46
|
+
: active
|
|
47
|
+
? colors.headerStart
|
|
48
|
+
: tone
|
|
49
|
+
? toneColor
|
|
50
|
+
: colors.textOnAccent;
|
|
38
51
|
const iconName = icon && isAppIconName(icon) ? icon : null;
|
|
39
52
|
const focus = useFocusRing();
|
|
40
53
|
|
|
@@ -9,19 +9,28 @@ import Animated, {
|
|
|
9
9
|
withTiming,
|
|
10
10
|
} from 'react-native-reanimated';
|
|
11
11
|
import { m3Colors, m3Shape, spacing } from '../theme/proulr-theme';
|
|
12
|
+
import { useReduceMotion } from './use-reduce-motion';
|
|
12
13
|
|
|
13
14
|
const PULSE_DURATION_MS = 900;
|
|
14
15
|
const PULSE_MIN_OPACITY = 0.45;
|
|
16
|
+
const PULSE_STATIC_OPACITY = 0.7;
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Pulso de opacidade compartilhado por cada bloco de skeleton. Respeita o
|
|
18
|
-
* "reduzir movimento" do sistema
|
|
19
|
-
*
|
|
20
|
+
* "reduzir movimento" do sistema: o loop infinito é desligado por completo
|
|
21
|
+
* (com `ReduceMotion.System` puro, o `withRepeat` alternaria 0↠1 a cada
|
|
22
|
+
* frame, virando strobe). Como `react-native-reanimated` ja e usado nos
|
|
23
|
+
* primitivos do pacote (ex.: RangeSlider), importamos direto.
|
|
20
24
|
*/
|
|
21
25
|
function usePulseStyle() {
|
|
22
|
-
const
|
|
26
|
+
const reducedMotion = useReduceMotion();
|
|
27
|
+
const progress = useSharedValue(reducedMotion ? PULSE_STATIC_OPACITY : PULSE_MIN_OPACITY);
|
|
23
28
|
|
|
24
29
|
useEffect(() => {
|
|
30
|
+
if (reducedMotion) {
|
|
31
|
+
progress.value = PULSE_STATIC_OPACITY;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
25
34
|
progress.value = withRepeat(
|
|
26
35
|
withTiming(1, {
|
|
27
36
|
duration: PULSE_DURATION_MS,
|
|
@@ -31,7 +40,7 @@ function usePulseStyle() {
|
|
|
31
40
|
-1,
|
|
32
41
|
true,
|
|
33
42
|
);
|
|
34
|
-
}, [progress]);
|
|
43
|
+
}, [progress, reducedMotion]);
|
|
35
44
|
|
|
36
45
|
return useAnimatedStyle(() => ({ opacity: progress.value }));
|
|
37
46
|
}
|
package/src/renderer/ui/index.ts
CHANGED
|
@@ -89,3 +89,4 @@ export { StoryComposeHeader } from './story-compose-header';
|
|
|
89
89
|
export { StoryCreateShortcutRow, type StoryCreateShortcutItem } from './story-create-shortcut-row';
|
|
90
90
|
export { StoryEditorChrome, type StoryEditorTool } from './story-editor-chrome';
|
|
91
91
|
export { StorySurfacePicker, type StorySurfaceOption } from './story-surface-picker';
|
|
92
|
+
export { useReduceMotion } from './use-reduce-motion';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { AccessibilityInfo, Platform } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/** Fonte única do "Reduzir movimento" — web lê `prefers-reduced-motion` via matchMedia, nativo via AccessibilityInfo. */
|
|
5
|
+
export function useReduceMotion(): boolean {
|
|
6
|
+
const [reduced, setReduced] = useState(false);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (Platform.OS === 'web') {
|
|
10
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
|
11
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
12
|
+
setReduced(query.matches);
|
|
13
|
+
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
|
|
14
|
+
query.addEventListener('change', onChange);
|
|
15
|
+
return () => query.removeEventListener('change', onChange);
|
|
16
|
+
}
|
|
17
|
+
let active = true;
|
|
18
|
+
AccessibilityInfo.isReduceMotionEnabled().then((value) => {
|
|
19
|
+
if (active) setReduced(value);
|
|
20
|
+
});
|
|
21
|
+
const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (value) => {
|
|
22
|
+
if (active) setReduced(value);
|
|
23
|
+
});
|
|
24
|
+
return () => {
|
|
25
|
+
active = false;
|
|
26
|
+
subscription?.remove?.();
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
return reduced;
|
|
31
|
+
}
|