@proulr/sdui-components 0.1.90 → 0.1.92

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.90",
3
+ "version": "0.1.92",
4
4
  "type": "module",
5
5
  "main": "./src/renderer/index.ts",
6
6
  "types": "./src/renderer/index.ts",
@@ -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: [] }),
@@ -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');
@@ -25,6 +25,8 @@ export const Chip: BdcComponent = (props) => layoutBlock('chip', props as ChipPr
25
25
  type ChipRowProps = LayoutProps & {
26
26
  /** Caminho em `screen.data` para chips dinâmicos: `{ label, glyph?, tone?, actionId?, actionParams? }[]`. */
27
27
  itemsRef?: string;
28
+ /** `wrap` quebra em linhas (todas visíveis); padrão rola na horizontal. */
29
+ layout?: 'scroll' | 'wrap';
28
30
  };
29
31
 
30
32
  export const ChipRow: BdcComponent = (props) => layoutBlock('chip_row', props as ChipRowProps);
@@ -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);
@@ -97,8 +101,12 @@ export const SectionHeader: BdcComponent = (props) =>
97
101
  layoutBlock('section_header', props as SectionHeaderProps);
98
102
 
99
103
  type StatTileRowProps = LayoutProps & {
100
- /** Caminho em `screen.data` para o array de tiles: `{ icon, value, label }[]`. */
104
+ /** Caminho em `screen.data` para o array de tiles: `{ icon, value, label, action_id?, action_params? }[]`. */
101
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';
102
110
  };
103
111
 
104
112
  /** Linha genérica de tiles de métrica (ícone + valor + rótulo). Reusável em qualquer tela. */
@@ -8,6 +8,8 @@ import { SduiLayoutDebugFrame } from './sdui-layout-debug';
8
8
  type Props = {
9
9
  screen: SduiScreen;
10
10
  onAction: (actionId: string, vars?: Record<string, string>) => void;
11
+ /** Host já decidiu a coluna (ou a dispensou: tela self-managed / rail) — sem teto aqui. */
12
+ fillWidth?: boolean;
11
13
  };
12
14
 
13
15
  /** Blocos overlay (FAB etc.) — não competem por flex com o Scroll. */
@@ -38,9 +40,16 @@ const SduiScreenBlock = memo(function SduiScreenBlock({ block, data, onAction }:
38
40
  );
39
41
  });
40
42
 
41
- export const SduiScreenRenderer = memo(function SduiScreenRenderer({ screen, onAction }: Props) {
43
+ export const SduiScreenRenderer = memo(function SduiScreenRenderer({
44
+ screen,
45
+ onAction,
46
+ fillWidth = false,
47
+ }: Props) {
42
48
  return (
43
- <SduiLayoutDebugFrame layer="renderer" style={styles.screen}>
49
+ <SduiLayoutDebugFrame
50
+ layer="renderer"
51
+ style={[styles.screen, fillWidth ? styles.screenFillWidth : null]}
52
+ >
44
53
  {screen.blocks.map((block) => (
45
54
  <SduiScreenBlock key={block.id} block={block} data={screen.data} onAction={onAction} />
46
55
  ))}
@@ -62,6 +71,10 @@ const styles = StyleSheet.create({
62
71
  alignSelf: 'center',
63
72
  backgroundColor: colors.bg,
64
73
  },
74
+ screenFillWidth: {
75
+ maxWidth: '100%',
76
+ alignSelf: 'stretch',
77
+ },
65
78
  block: { flex: 1, minHeight: 0 },
66
79
  overlayBlock: {
67
80
  ...StyleSheet.absoluteFill,
@@ -1,4 +1,4 @@
1
- import { StyleSheet, Text, View } from 'react-native';
1
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
2
2
  import { fonts, m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
3
3
  import { AppIcon } from '../ui/app-icon';
4
4
  import { isAppIconName, type AppIconName } from '../../catalog/app-icon-names';
@@ -33,24 +33,100 @@ function toIcon(value: unknown): AppIconName | undefined {
33
33
 
34
34
  /* ── stat_tile_row ─────────────────────────────────────────────────────────── */
35
35
 
36
- type StatTile = { icon?: AppIconName; value: string; label: string };
36
+ type StatTile = {
37
+ icon?: AppIconName;
38
+ value: string;
39
+ label: string;
40
+ actionId?: string;
41
+ actionParams?: Record<string, string>;
42
+ };
43
+
44
+ function chunk<T>(items: T[], size: number): T[][] {
45
+ const rows: T[][] = [];
46
+ for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
47
+ return rows;
48
+ }
49
+
50
+ function StatTileView({
51
+ tile,
52
+ card,
53
+ onAction,
54
+ }: {
55
+ tile: StatTile;
56
+ card: boolean;
57
+ onAction: SduiBlockRenderProps['onAction'];
58
+ }) {
59
+ const content = card ? (
60
+ <>
61
+ <View style={styles.cardHead}>
62
+ {tile.icon ? (
63
+ <View style={styles.cardIcon}>
64
+ <AppIcon name={tile.icon} size={16} color={m3Colors.primary} />
65
+ </View>
66
+ ) : null}
67
+ {tile.actionId ? <AppIcon name="arrow-up-right" size={16} color={m3Colors.onSurfaceVariant} /> : null}
68
+ </View>
69
+ <Text style={styles.cardValue} numberOfLines={2}>{tile.value}</Text>
70
+ <Text style={styles.label} numberOfLines={1}>{tile.label}</Text>
71
+ </>
72
+ ) : (
73
+ <>
74
+ {tile.icon ? <AppIcon name={tile.icon} size={17} color={m3Colors.onSurfaceVariant} /> : null}
75
+ <Text style={styles.value} numberOfLines={1}>{tile.value}</Text>
76
+ <Text style={styles.label} numberOfLines={1}>{tile.label}</Text>
77
+ </>
78
+ );
79
+ const tileStyle = card ? styles.card : styles.tile;
80
+ const actionId = tile.actionId;
81
+ if (!actionId) return <View style={tileStyle}>{content}</View>;
82
+ return (
83
+ <Pressable
84
+ accessibilityRole="link"
85
+ accessibilityLabel={`${tile.label} ${tile.value}`.trim()}
86
+ onPress={() => onAction(actionId, tile.actionParams)}
87
+ style={({ pressed }) => [tileStyle, card && styles.cardLink, pressed && styles.pressed]}
88
+ >
89
+ {content}
90
+ </Pressable>
91
+ );
92
+ }
37
93
 
38
94
  /** Tiles genéricos de métrica (ícone + valor + rótulo) — layout vem do wire, regra do BFF. */
39
- export function SduiStatTileRowBlock({ block, data }: SduiBlockRenderProps) {
95
+ export function SduiStatTileRowBlock({ block, data, onAction }: SduiBlockRenderProps) {
40
96
  const tiles: StatTile[] = itemsFrom(block, data, 'stats')
41
97
  .map(asRecord)
42
98
  .filter((row): row is Record<string, unknown> => row !== null)
43
- .map((row) => ({ icon: toIcon(row.icon), value: toText(row.value), label: toText(row.label) }))
99
+ .map((row) => ({
100
+ icon: toIcon(row.icon),
101
+ value: toText(row.value),
102
+ label: toText(row.label),
103
+ actionId: typeof row.action_id === 'string' && row.action_id ? row.action_id : undefined,
104
+ actionParams:
105
+ row.action_params && typeof row.action_params === 'object'
106
+ ? (row.action_params as Record<string, string>)
107
+ : undefined,
108
+ }))
44
109
  .filter((tile) => tile.value || tile.label);
45
110
  if (tiles.length === 0) return null;
46
111
 
112
+ const columnsProp = block.props.columns;
113
+ const columns =
114
+ typeof columnsProp === 'number' && Number.isInteger(columnsProp) && columnsProp > 0
115
+ ? columnsProp
116
+ : tiles.length;
117
+ const card = block.props.align === 'start';
118
+ const rows = chunk(tiles, columns);
119
+
47
120
  return (
48
- <View style={styles.row} accessibilityRole="list">
49
- {tiles.map((tile, index) => (
50
- <View key={`${tile.label}-${index}`} style={styles.tile}>
51
- {tile.icon ? <AppIcon name={tile.icon} size={17} color={m3Colors.onSurfaceVariant} /> : null}
52
- <Text style={styles.value} numberOfLines={1}>{tile.value}</Text>
53
- <Text style={styles.label} numberOfLines={1}>{tile.label}</Text>
121
+ <View style={styles.grid} accessibilityRole="list">
122
+ {rows.map((row, rIndex) => (
123
+ <View key={`row-${rIndex}`} style={styles.row}>
124
+ {row.map((tile, index) => (
125
+ <StatTileView key={`${tile.label}-${index}`} tile={tile} card={card} onAction={onAction} />
126
+ ))}
127
+ {Array.from({ length: columns - row.length }, (_, pad) => (
128
+ <View key={`pad-${pad}`} style={styles.pad} />
129
+ ))}
54
130
  </View>
55
131
  ))}
56
132
  </View>
@@ -136,7 +212,9 @@ export function SduiVouchListBlock({ block, data }: SduiBlockRenderProps) {
136
212
  }
137
213
 
138
214
  const styles = StyleSheet.create({
215
+ grid: { gap: spacing.sm },
139
216
  row: { flexDirection: 'row', gap: spacing.sm },
217
+ pad: { flex: 1 },
140
218
  tile: {
141
219
  flex: 1,
142
220
  minHeight: 64,
@@ -152,6 +230,29 @@ const styles = StyleSheet.create({
152
230
  },
153
231
  value: { ...m3Type.title, color: m3Colors.onSurface, fontFamily: fonts.extrabold },
154
232
  label: { ...m3Type.labelSmall, color: m3Colors.onSurfaceVariant },
233
+ card: {
234
+ flex: 1,
235
+ minWidth: 0,
236
+ minHeight: 92,
237
+ borderRadius: m3Shape.cornerMedium,
238
+ backgroundColor: m3Colors.surfaceContainerHigh,
239
+ borderWidth: 1,
240
+ borderColor: m3Colors.outlineVariant,
241
+ gap: spacing.xs,
242
+ padding: spacing.sm + 4,
243
+ },
244
+ cardLink: { borderColor: m3Colors.outlineInteractive },
245
+ cardHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
246
+ cardIcon: {
247
+ width: 28,
248
+ height: 28,
249
+ borderRadius: m3Shape.cornerSmall,
250
+ backgroundColor: m3Colors.surfaceContainerHighest,
251
+ alignItems: 'center',
252
+ justifyContent: 'center',
253
+ },
254
+ cardValue: { ...m3Type.body, color: m3Colors.onSurface, fontFamily: fonts.bold },
255
+ pressed: { opacity: 0.85 },
155
256
 
156
257
  groups: { gap: spacing.md },
157
258
  group: {
@@ -299,11 +299,12 @@ export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
299
299
 
300
300
  export function SduiChipRowBlock({ block, data, children, onAction }: SduiBlockRenderProps) {
301
301
  const itemsRef = readStringProp(block, 'items_ref');
302
+ const wrap = readStringProp(block, 'layout') === 'wrap';
302
303
  if (itemsRef) {
303
304
  const raw = resolveDataRef(data, itemsRef);
304
305
  const items = Array.isArray(raw) ? raw : [];
305
306
  return (
306
- <ChipRow>
307
+ <ChipRow wrap={wrap}>
307
308
  {items.map((item, index) => {
308
309
  if (!item || typeof item !== 'object') return null;
309
310
  const row = item as Record<string, unknown>;
@@ -323,7 +324,7 @@ export function SduiChipRowBlock({ block, data, children, onAction }: SduiBlockR
323
324
  label={label}
324
325
  icon={glyph}
325
326
  tone={tone}
326
- disabled={!actionId}
327
+ readOnly={!actionId}
327
328
  onPress={() => actionId && onAction(actionId, actionParams)}
328
329
  />
329
330
  );
@@ -331,7 +332,7 @@ export function SduiChipRowBlock({ block, data, children, onAction }: SduiBlockR
331
332
  </ChipRow>
332
333
  );
333
334
  }
334
- return <ChipRow>{children}</ChipRow>;
335
+ return <ChipRow wrap={wrap}>{children}</ChipRow>;
335
336
  }
336
337
 
337
338
  export function SduiTextFieldBlock({ block, data, onAction }: SduiBlockRenderProps) {
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
2
+ import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
3
3
  import type { SduiBlock } from '@proulr/sdui-contract';
4
4
  import { AppButton } from '../ui/AppButton';
5
5
  import { AppIcon, isAppIconName } from '../ui/app-icon';
@@ -22,16 +22,18 @@ export type SduiBlockRenderProps = {
22
22
 
23
23
  const textVariants = ['headline', 'title', 'body', 'label'] as const;
24
24
 
25
- export function SduiTextBlock({ block }: SduiBlockRenderProps) {
25
+ export function SduiTextBlock({ block, onAction }: SduiBlockRenderProps) {
26
26
  const text = typeof block.props.text === 'string' ? block.props.text : '';
27
27
  const variant =
28
28
  typeof block.props.variant === 'string' &&
29
29
  (textVariants as readonly string[]).includes(block.props.variant)
30
30
  ? block.props.variant
31
31
  : 'body';
32
+ const actionId = typeof block.props.action_id === 'string' ? block.props.action_id : '';
32
33
 
33
- return (
34
+ const label = (
34
35
  <Text
36
+ numberOfLines={actionId ? 1 : undefined}
35
37
  style={[
36
38
  { color: colors.onSurface },
37
39
  resolveTextStyleProps(block.props.style as Record<string, unknown>, variant),
@@ -40,6 +42,19 @@ export function SduiTextBlock({ block }: SduiBlockRenderProps) {
40
42
  {text}
41
43
  </Text>
42
44
  );
45
+ if (!actionId) return label;
46
+
47
+ return (
48
+ <Pressable
49
+ accessibilityRole="link"
50
+ accessibilityLabel={text}
51
+ hitSlop={spacing.xs}
52
+ onPress={() => onAction(actionId)}
53
+ style={styles.textLink}
54
+ >
55
+ {label}
56
+ </Pressable>
57
+ );
43
58
  }
44
59
 
45
60
  export function SduiColumnBlock({ block, children }: SduiBlockRenderProps) {
@@ -164,6 +179,9 @@ export function SduiIconBlock({ block }: SduiBlockRenderProps) {
164
179
  }
165
180
 
166
181
  export function SduiSpacerBlock({ block }: SduiBlockRenderProps) {
182
+ if (typeof block.props.flex === 'number') {
183
+ return <View style={{ flex: block.props.flex, minWidth: 0 }} />;
184
+ }
167
185
  const height = typeof block.props.height === 'number' ? block.props.height : spacing.md;
168
186
  return <View style={{ height }} />;
169
187
  }
@@ -184,6 +202,7 @@ export function SduiUnknownBlock({ block }: SduiBlockRenderProps) {
184
202
 
185
203
  const styles = StyleSheet.create({
186
204
  scroll: { flex: 1, minHeight: 0 },
205
+ textLink: { flexShrink: 1, minWidth: 0 },
187
206
  banner: {
188
207
  padding: spacing.md,
189
208
  backgroundColor: colors.surface,
@@ -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
  }
@@ -23,6 +23,7 @@ export function Chip({
23
23
  disabled,
24
24
  icon,
25
25
  testID,
26
+ readOnly,
26
27
  }: {
27
28
  label: string;
28
29
  active?: boolean;
@@ -32,6 +33,8 @@ export function Chip({
32
33
  disabled?: boolean;
33
34
  icon?: string;
34
35
  testID?: string;
36
+ /** Tag de conteúdo sem ação: não é botão nem aparece esmaecida como desabilitada. */
37
+ readOnly?: boolean;
35
38
  }) {
36
39
  const toneColor = tone === 'secondary' ? m3Colors.secondary : m3Colors.primary;
37
40
  const toneBorder = tone === 'secondary' ? m3Colors.secondaryContainer : m3Colors.primaryContainer;
@@ -41,7 +44,7 @@ export function Chip({
41
44
  ? { backgroundColor: toneBorder, borderColor: toneColor }
42
45
  : { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder };
43
46
 
44
- const textColor = disabled
47
+ const textColor = disabled && !readOnly
45
48
  ? m3Colors.onDisabled
46
49
  : active
47
50
  ? colors.headerStart
@@ -54,13 +57,14 @@ export function Chip({
54
57
  return (
55
58
  <Pressable
56
59
  testID={testID}
57
- accessibilityRole="button"
60
+ accessibilityRole={readOnly ? 'text' : 'button'}
58
61
  accessibilityLabel={label}
59
- accessibilityState={{ selected: Boolean(active), disabled: Boolean(disabled) }}
60
- onPress={onPress}
61
- onFocus={focus.onFocus}
62
- onBlur={focus.onBlur}
63
- 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}
64
68
  hitSlop={CHIP_HIT_SLOP}
65
69
  style={({ pressed }) => [
66
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
  });