@proulr/sdui-components 0.1.81 → 0.1.85

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proulr/sdui-components",
3
- "version": "0.1.81",
3
+ "version": "0.1.85",
4
4
  "type": "module",
5
5
  "main": "./src/renderer/index.ts",
6
6
  "types": "./src/renderer/index.ts",
@@ -177,5 +177,27 @@ describe('compileBdcToWire', () => {
177
177
  expect(block).toEqual({ id: '0', type: 'text', props: { text: 'x' } });
178
178
  expect(viaIndex(screen({ id: 'i' }, [])).screen_id).toBe('i');
179
179
  });
180
+
181
+ it('flattens _fragment children instead of dropping them', () => {
182
+ const wire = compileBdcToWire(
183
+ screen({ id: 'demo.fragment' }, [
184
+ {
185
+ type: 'column',
186
+ props: {},
187
+ children: [
188
+ {
189
+ type: '_fragment',
190
+ props: {},
191
+ children: [
192
+ { type: 'text', props: { text: 'a' }, children: [] },
193
+ { type: 'text', props: { text: 'b' }, children: [] },
194
+ ],
195
+ },
196
+ ],
197
+ },
198
+ ]),
199
+ );
200
+ expect(wire.blocks[0]?.children?.map((c) => c.props?.text)).toEqual(['a', 'b']);
201
+ });
180
202
  });
181
203
 
@@ -61,6 +61,19 @@ export function compileBdcBlock(element: BdcElement, path = '0'): SduiBlockWire
61
61
  return mapBlock(element, path);
62
62
  }
63
63
 
64
+ function flattenBlockChildren(children: BdcElement[]): BdcElement[] {
65
+ const out: BdcElement[] = [];
66
+ for (const child of children) {
67
+ if (child.type === '_text_node') continue;
68
+ if (child.type === '_fragment') {
69
+ out.push(...flattenBlockChildren(child.children));
70
+ continue;
71
+ }
72
+ out.push(child);
73
+ }
74
+ return out;
75
+ }
76
+
64
77
  function mapBlock(element: BdcElement, path: string): SduiBlockWire {
65
78
  if (element.type === '_text_node') {
66
79
  return { id: path, type: 'text', props: { text: String(element.props.value ?? '') } };
@@ -76,9 +89,7 @@ function mapBlock(element: BdcElement, path: string): SduiBlockWire {
76
89
  props.text = textFromChildren(element.children);
77
90
  }
78
91
 
79
- const blockChildren = element.children.filter(
80
- (child) => child.type !== '_text_node' && child.type !== '_fragment',
81
- );
92
+ const blockChildren = flattenBlockChildren(element.children);
82
93
 
83
94
  return {
84
95
  id: typeof element.props.id === 'string' ? element.props.id : path,
@@ -114,9 +125,9 @@ export function compileBdcToWire(root: BdcElement): SduiScreenWire {
114
125
 
115
126
  void rest;
116
127
 
117
- const blocks = root.children
118
- .filter((child) => child.type !== '_fragment')
119
- .map((child, index) => mapBlock(child, String(index)));
128
+ const blocks = flattenBlockChildren(root.children).map((child, index) =>
129
+ mapBlock(child, String(index)),
130
+ );
120
131
 
121
132
  const compiledHeader = header ? mapHeaderToWire(header as Record<string, unknown>) : undefined;
122
133
  if (compiledHeader && headerSlot) {
@@ -1,6 +1,6 @@
1
- import React from 'react';
1
+ import React, { memo } from 'react';
2
2
  import { StyleSheet } from 'react-native';
3
- import type { SduiScreen } from '@proulr/sdui-contract';
3
+ import type { SduiScreen, SduiBlock } from '@proulr/sdui-contract';
4
4
  import { colors } from './theme/proulr-theme';
5
5
  import { renderBlockTree } from './sdui-component-registry';
6
6
  import { SduiLayoutDebugFrame } from './sdui-layout-debug';
@@ -13,23 +13,40 @@ type Props = {
13
13
  /** Blocos overlay (FAB etc.) — não competem por flex com o Scroll. */
14
14
  const OVERLAY_BLOCK_TYPES = new Set(['floating_create_menu', 'inbox_hub_chrome']);
15
15
 
16
- export function SduiScreenRenderer({ screen, onAction }: Props) {
16
+ type ScreenBlockProps = {
17
+ block: SduiBlock;
18
+ data: SduiScreen['data'];
19
+ onAction: Props['onAction'];
20
+ };
21
+
22
+ /**
23
+ * Bloco memoizado: re-renderiza só quando `block`/`data`/`onAction` mudam de
24
+ * identidade (novo wire). Re-renders do shell (header, insets, sheets) não
25
+ * reconstroem a árvore de todos os blocos — custo direto de INP no web.
26
+ */
27
+ const SduiScreenBlock = memo(function SduiScreenBlock({ block, data, onAction }: ScreenBlockProps) {
28
+ const overlay = OVERLAY_BLOCK_TYPES.has(block.type);
29
+ return (
30
+ <SduiLayoutDebugFrame
31
+ layer="block"
32
+ extra={block.type}
33
+ style={overlay ? styles.overlayBlock : styles.block}
34
+ pointerEvents={overlay ? 'box-none' : undefined}
35
+ >
36
+ {renderBlockTree(block, data, onAction)}
37
+ </SduiLayoutDebugFrame>
38
+ );
39
+ });
40
+
41
+ export const SduiScreenRenderer = memo(function SduiScreenRenderer({ screen, onAction }: Props) {
17
42
  return (
18
43
  <SduiLayoutDebugFrame layer="renderer" style={styles.screen}>
19
44
  {screen.blocks.map((block) => (
20
- <SduiLayoutDebugFrame
21
- key={block.id}
22
- layer="block"
23
- extra={block.type}
24
- style={OVERLAY_BLOCK_TYPES.has(block.type) ? styles.overlayBlock : styles.block}
25
- pointerEvents={OVERLAY_BLOCK_TYPES.has(block.type) ? 'box-none' : undefined}
26
- >
27
- {renderBlockTree(block, screen.data, onAction)}
28
- </SduiLayoutDebugFrame>
45
+ <SduiScreenBlock key={block.id} block={block} data={screen.data} onAction={onAction} />
29
46
  ))}
30
47
  </SduiLayoutDebugFrame>
31
48
  );
32
- }
49
+ });
33
50
 
34
51
  const styles = StyleSheet.create({
35
52
  screen: { flex: 1, minHeight: 0, backgroundColor: colors.bg },
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { resolveTextStyleProps, resolveViewStyleProps } from './resolve-style-props';
4
+ import { m3Colors, m3Shape, spacing } from './proulr-theme';
5
+
6
+ vi.mock('react-native', () => ({
7
+ Platform: {
8
+ OS: 'web',
9
+ select: (spec: Record<string, unknown>) => spec.default ?? spec.web ?? spec.ios ?? {},
10
+ },
11
+ }));
12
+
13
+ describe('resolveViewStyleProps', () => {
14
+ it('resolves snake_case StyleWire tokens', () => {
15
+ const style = resolveViewStyleProps({
16
+ background_color: 'surfaceContainerHigh',
17
+ padding_horizontal: 'lg',
18
+ padding_vertical: 'md',
19
+ margin_top: 'sm',
20
+ gap: 'md',
21
+ border_radius: 'cornerExtraLarge',
22
+ border_width: 1,
23
+ border_color: 'outlineVariant',
24
+ align_items: 'center',
25
+ flex: 1,
26
+ }) as Record<string, unknown>;
27
+
28
+ expect(style.backgroundColor).toBe(m3Colors.surfaceContainerHigh);
29
+ expect(style.paddingHorizontal).toBe(spacing.lg);
30
+ expect(style.paddingVertical).toBe(spacing.md);
31
+ expect(style.marginTop).toBe(spacing.sm);
32
+ expect(style.gap).toBe(spacing.md);
33
+ expect(style.borderRadius).toBe(m3Shape.cornerExtraLarge);
34
+ expect(style.borderWidth).toBe(1);
35
+ expect(style.borderColor).toBe(m3Colors.outlineVariant);
36
+ expect(style.alignItems).toBe('center');
37
+ expect(style.flex).toBe(1);
38
+ });
39
+
40
+ it('accepts camelCase aliases without dropping values', () => {
41
+ const style = resolveViewStyleProps({
42
+ backgroundColor: 'primaryContainer',
43
+ paddingHorizontal: 'sm',
44
+ alignItems: 'flex-end',
45
+ } as Record<string, unknown>) as Record<string, unknown>;
46
+
47
+ expect(style.backgroundColor).toBe(m3Colors.primaryContainer);
48
+ expect(style.paddingHorizontal).toBe(spacing.sm);
49
+ expect(style.alignItems).toBe('flex-end');
50
+ });
51
+ });
52
+
53
+ describe('resolveTextStyleProps', () => {
54
+ it('resolves color and text_align from snake_case wire', () => {
55
+ const style = resolveTextStyleProps(
56
+ { color: 'primary', text_align: 'center', typography: 'titleSmall' },
57
+ 'body',
58
+ ) as Record<string, unknown>;
59
+
60
+ expect(style.color).toBe(m3Colors.primary);
61
+ expect(style.textAlign).toBe('center');
62
+ });
63
+ });
@@ -24,18 +24,23 @@ function resolveTypography(token: TypeToken | undefined): TextStyle | undefined
24
24
  return m3Type[token];
25
25
  }
26
26
 
27
- function snakeToCamel(key: string): string {
28
- return key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
27
+ function camelToSnake(key: string): string {
28
+ return key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
29
29
  }
30
30
 
31
+ /**
32
+ * Canonical StyleWire is snake_case. Accept camelCase inputs from older wires
33
+ * without dropping underscore keys (previous normalize converted to camel and
34
+ * broke every `*_` token read).
35
+ */
31
36
  function normalizeStyleWire(style: StyleWire | Record<string, unknown> | undefined): StyleWire {
32
37
  if (!style) return {};
33
- const normalized: StyleWire = {};
38
+ const normalized: Record<string, unknown> = {};
34
39
  for (const [key, value] of Object.entries(style)) {
35
- const camelKey = snakeToCamel(key) as keyof StyleWire;
36
- (normalized as Record<string, unknown>)[camelKey] = value;
40
+ const snakeKey = key.includes('_') ? key : camelToSnake(key);
41
+ normalized[snakeKey] = value;
37
42
  }
38
- return normalized;
43
+ return normalized as StyleWire;
39
44
  }
40
45
 
41
46
  export function resolveViewStyleProps(
@@ -91,12 +91,24 @@ type ChromeGlassLayerProps = {
91
91
  /**
92
92
  * Camada de frost única do sistema (igual AppSideDrawer topo/base).
93
93
  * BlurView + `colors.chromeGlassOverlay` — sem cast cinza.
94
+ *
95
+ * Web: BlurView vira `backdrop-filter` em camada fixa sobre conteúdo que rola —
96
+ * repaint contínuo no scroll (custo direto de INP). Fallback: pílula OLED opaca
97
+ * (`colors.chromePillBackground`), mesmo visual do chrome sem glass.
94
98
  */
95
99
  export function ChromeGlassLayer({
96
100
  style,
97
101
  pointerEvents = 'none',
98
102
  }: ChromeGlassLayerProps) {
99
103
  const androidBlurProps = useAndroidBlurProps();
104
+ if (Platform.OS === 'web') {
105
+ return (
106
+ <View
107
+ style={[absoluteFill, styles.webGlassFallback, style]}
108
+ pointerEvents={pointerEvents}
109
+ />
110
+ );
111
+ }
100
112
  return (
101
113
  <View style={[absoluteFill, style]} pointerEvents={pointerEvents}>
102
114
  <BlurView
@@ -132,6 +144,9 @@ const styles = StyleSheet.create({
132
144
  tint: {
133
145
  backgroundColor: colors.chromeGlassOverlay,
134
146
  },
147
+ webGlassFallback: {
148
+ backgroundColor: colors.chromePillBackground,
149
+ },
135
150
  shellTransparent: {
136
151
  backgroundColor: 'transparent',
137
152
  },