@proulr/sdui-components 0.1.86 → 0.1.88
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/components/discover.tsx +1 -4
- package/src/bdc/components/forms.tsx +4 -0
- package/src/bdc/components/inbox.tsx +4 -0
- package/src/renderer/SduiScreenRenderer.tsx +14 -2
- package/src/renderer/blocks/SduiDiscoverBlocks.tsx +50 -34
- package/src/renderer/blocks/SduiInboxBlocks.tsx +26 -11
- package/src/renderer/blocks/SduiLayoutBlocks.tsx +39 -46
- package/src/renderer/blocks/SduiNativeAdBlock.tsx +7 -8
- package/src/renderer/blocks/SduiSocialBlocks.tsx +4 -27
- package/src/renderer/lib/discover-profile-formatters.test.ts +7 -0
- package/src/renderer/lib/discover-profile-formatters.ts +6 -3
- package/src/renderer/sdui-hybrid-renderer-context.tsx +6 -1
- package/src/renderer/theme/proulr-theme.test.ts +107 -0
- package/src/renderer/theme/proulr-theme.ts +51 -9
- package/src/renderer/ui/AppButton.tsx +54 -28
- package/src/renderer/ui/AppChromeIconButton.tsx +19 -7
- package/src/renderer/ui/AuthStepShell.tsx +1 -2
- package/src/renderer/ui/Chip.tsx +41 -34
- package/src/renderer/ui/ChipRow.tsx +7 -13
- package/src/renderer/ui/HintText.tsx +2 -4
- package/src/renderer/ui/InfoBanner.tsx +20 -3
- package/src/renderer/ui/Label.tsx +2 -3
- package/src/renderer/ui/ListItem.tsx +28 -4
- package/src/renderer/ui/RangeSlider.tsx +1 -2
- package/src/renderer/ui/SectionHeader.tsx +3 -4
- package/src/renderer/ui/SelectRow.tsx +113 -22
- package/src/renderer/ui/Skeleton.tsx +13 -4
- package/src/renderer/ui/StatRow.tsx +3 -3
- package/src/renderer/ui/TextField.tsx +15 -11
- package/src/renderer/ui/ToggleRow.tsx +38 -11
- package/src/renderer/ui/compose-audience-pill.tsx +2 -3
- package/src/renderer/ui/compose-media-carousel.tsx +20 -7
- package/src/renderer/ui/compose-media-shortcut.tsx +2 -3
- package/src/renderer/ui/compose-pill-input.tsx +28 -9
- package/src/renderer/ui/compose-recent-media-grid.tsx +5 -8
- package/src/renderer/ui/compose-reply-audience-sheet.tsx +19 -8
- package/src/renderer/ui/compose-screen-header.tsx +15 -10
- package/src/renderer/ui/compose-text-area.tsx +14 -8
- package/src/renderer/ui/compose-toolbar-icon.tsx +9 -5
- package/src/renderer/ui/compose-toolbar.tsx +0 -7
- package/src/renderer/ui/compose-visibility-row.tsx +2 -3
- package/src/renderer/ui/floating-create-menu.tsx +2 -3
- package/src/renderer/ui/focus-ring.ts +26 -0
- package/src/renderer/ui/index.ts +3 -1
- package/src/renderer/ui/story-compose-header.tsx +14 -9
- package/src/renderer/ui/story-create-shortcut-row.tsx +1 -1
- package/src/renderer/ui/story-editor-chrome.tsx +30 -16
- package/src/renderer/ui/story-surface-picker.tsx +1 -2
- package/src/renderer/ui/use-reduce-motion.ts +31 -0
|
@@ -2,7 +2,8 @@ export type DiscoverNearbyProfileWire = {
|
|
|
2
2
|
id: string;
|
|
3
3
|
display_name: string | null;
|
|
4
4
|
birth_date: string | null;
|
|
5
|
-
|
|
5
|
+
/** `null` = visitante ativou "ocultar distância" — o rótulo some, o resto do card fica. */
|
|
6
|
+
distance_m: number | null;
|
|
6
7
|
primary_photo_path: string | null;
|
|
7
8
|
photo_url?: string | null;
|
|
8
9
|
is_online: boolean | null;
|
|
@@ -42,9 +43,11 @@ export function profileAgeFromBirthDate(birthDate: string | null | undefined): n
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function formatDistanceLabel(
|
|
45
|
-
distanceM: number,
|
|
46
|
+
distanceM: number | null | undefined,
|
|
46
47
|
unit: 'metric' | 'imperial' = 'metric',
|
|
47
|
-
): string {
|
|
48
|
+
): string | null {
|
|
49
|
+
if (distanceM === null || distanceM === undefined || !Number.isFinite(distanceM)) return null;
|
|
50
|
+
|
|
48
51
|
if (unit === 'imperial') {
|
|
49
52
|
const feet = distanceM * 3.28084;
|
|
50
53
|
if (feet < 528) return `${Math.round(feet)} ft`;
|
|
@@ -102,7 +102,12 @@ export type SduiHybridRenderers = {
|
|
|
102
102
|
activeRef?: string;
|
|
103
103
|
actionPrefix?: string;
|
|
104
104
|
}) => ReactNode;
|
|
105
|
-
renderEmptyState?: (props: {
|
|
105
|
+
renderEmptyState?: (props: {
|
|
106
|
+
title: string;
|
|
107
|
+
hint?: string;
|
|
108
|
+
actionLabel?: string;
|
|
109
|
+
onAction?: () => void;
|
|
110
|
+
}) => ReactNode;
|
|
106
111
|
renderInboxPersonRow?: (props: {
|
|
107
112
|
block: SduiBlock;
|
|
108
113
|
data: Record<string, unknown>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { colors, m3Colors } from './proulr-theme';
|
|
4
|
+
|
|
5
|
+
vi.mock('react-native', () => ({
|
|
6
|
+
Platform: {
|
|
7
|
+
OS: 'web',
|
|
8
|
+
select: (spec: Record<string, unknown>) => spec.default ?? spec.web ?? spec.ios ?? {},
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
type Rgb = { r: number; g: number; b: number; a: number };
|
|
13
|
+
|
|
14
|
+
function parseColor(input: string): Rgb {
|
|
15
|
+
const value = input.trim();
|
|
16
|
+
const rgba = /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/.exec(value);
|
|
17
|
+
if (rgba) {
|
|
18
|
+
return { r: +rgba[1], g: +rgba[2], b: +rgba[3], a: +rgba[4] };
|
|
19
|
+
}
|
|
20
|
+
const hex = value.replace('#', '');
|
|
21
|
+
const full =
|
|
22
|
+
hex.length === 3
|
|
23
|
+
? hex.split('').map((c) => c + c).join('')
|
|
24
|
+
: hex.slice(0, 6);
|
|
25
|
+
return {
|
|
26
|
+
r: parseInt(full.slice(0, 2), 16),
|
|
27
|
+
g: parseInt(full.slice(2, 4), 16),
|
|
28
|
+
b: parseInt(full.slice(4, 6), 16),
|
|
29
|
+
a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Tema é OLED puro: qualquer rgba translúcido compõe sobre #000000. */
|
|
34
|
+
function compositeOnBlack(input: string): Rgb {
|
|
35
|
+
const { r, g, b, a } = parseColor(input);
|
|
36
|
+
if (a >= 1) return { r, g, b, a: 1 };
|
|
37
|
+
return { r: r * a, g: g * a, b: b * a, a: 1 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function relativeLuminance(color: Rgb): number {
|
|
41
|
+
const channel = (value: number) => {
|
|
42
|
+
const s = value / 255;
|
|
43
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
44
|
+
};
|
|
45
|
+
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function contrastRatio(foreground: string, background: string): number {
|
|
49
|
+
const fg = relativeLuminance(compositeOnBlack(foreground));
|
|
50
|
+
const bg = relativeLuminance(compositeOnBlack(background));
|
|
51
|
+
const [high, low] = fg >= bg ? [fg, bg] : [bg, fg];
|
|
52
|
+
return (high + 0.05) / (low + 0.05);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TEXT_PAIRS: [string, string, string][] = [
|
|
56
|
+
['onPrimary / primaryFill', m3Colors.onPrimary, m3Colors.primaryFill],
|
|
57
|
+
['onError / errorFill', m3Colors.onError, m3Colors.errorFill],
|
|
58
|
+
['onSuccess / success', m3Colors.onSuccess, m3Colors.success],
|
|
59
|
+
['onSurface / surface', m3Colors.onSurface, m3Colors.surface],
|
|
60
|
+
['onSurface / surfaceContainer', m3Colors.onSurface, m3Colors.surfaceContainer],
|
|
61
|
+
['onSurface / surfaceContainerHigh', m3Colors.onSurface, m3Colors.surfaceContainerHigh],
|
|
62
|
+
['onSurface / surfaceContainerHighest', m3Colors.onSurface, m3Colors.surfaceContainerHighest],
|
|
63
|
+
['onSurfaceVariant / surfaceContainerHigh', m3Colors.onSurfaceVariant, m3Colors.surfaceContainerHigh],
|
|
64
|
+
['onPrimaryContainer / primaryContainer', m3Colors.onPrimaryContainer, m3Colors.primaryContainer],
|
|
65
|
+
['onSecondaryContainer / secondaryContainer', m3Colors.onSecondaryContainer, m3Colors.secondaryContainer],
|
|
66
|
+
['onTertiaryContainer / tertiaryContainer', m3Colors.onTertiaryContainer, m3Colors.tertiaryContainer],
|
|
67
|
+
['onErrorContainer / errorContainer', m3Colors.onErrorContainer, m3Colors.errorContainer],
|
|
68
|
+
['textPrimary / bg', colors.textPrimary, colors.bg],
|
|
69
|
+
['textSecondary / bg', colors.textSecondary, colors.bg],
|
|
70
|
+
['textMuted / bg', colors.textMuted, colors.bg],
|
|
71
|
+
['textMuted / surface2', colors.textMuted, colors.surface2],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** WCAG 1.4.11: bordas, foco e indicadores que carregam significado. */
|
|
75
|
+
const GRAPHIC_PAIRS: [string, string, string][] = [
|
|
76
|
+
['outlineInteractive / bg', m3Colors.outlineInteractive, colors.bg],
|
|
77
|
+
['focus / bg', m3Colors.focus, colors.bg],
|
|
78
|
+
['primary (foreground) / bg', m3Colors.primary, colors.bg],
|
|
79
|
+
['secondary (foreground) / bg', m3Colors.secondary, colors.bg],
|
|
80
|
+
['error (foreground) / bg', m3Colors.error, colors.bg],
|
|
81
|
+
['online / bg', m3Colors.online, colors.bg],
|
|
82
|
+
['presenceRecent / bg', m3Colors.presenceRecent, colors.bg],
|
|
83
|
+
['onPrimary / primary (glyph) ', m3Colors.onPrimary, m3Colors.primary],
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
describe('proulr-theme contraste WCAG', () => {
|
|
87
|
+
it.each(TEXT_PAIRS)('%s ≥ 4.5:1', (_label, foreground, background) => {
|
|
88
|
+
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.each(GRAPHIC_PAIRS)('%s ≥ 3:1', (_label, foreground, background) => {
|
|
92
|
+
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('expõe tokens de estado usados pelos primitivos', () => {
|
|
96
|
+
expect(m3Colors.disabledContainer).toBeTruthy();
|
|
97
|
+
expect(m3Colors.onDisabled).toBeTruthy();
|
|
98
|
+
expect(m3Colors.divider).toBeTruthy();
|
|
99
|
+
expect(m3Colors.focus).toBeTruthy();
|
|
100
|
+
expect(m3Colors.primaryFill).toBeTruthy();
|
|
101
|
+
expect(m3Colors.errorFill).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rótulo desabilitado continua legível sobre o fundo desabilitado', () => {
|
|
105
|
+
expect(contrastRatio(m3Colors.onDisabled, m3Colors.disabledContainer)).toBeGreaterThanOrEqual(3);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -7,6 +7,9 @@ export const m3Colors = {
|
|
|
7
7
|
onPrimary: '#FFFFFF',
|
|
8
8
|
primaryContainer: 'rgba(217,70,239,0.22)',
|
|
9
9
|
onPrimaryContainer: '#F0ABFC',
|
|
10
|
+
/** Fundo de CTA/bolha com rótulo claro. Branco sobre #D946EF = 3.46:1 (reprova
|
|
11
|
+
* AA em texto); #A622D6 mantém o matiz da marca e chega a 5.5:1. */
|
|
12
|
+
primaryFill: '#A622D6',
|
|
10
13
|
secondary: '#00E5FF',
|
|
11
14
|
onSecondary: '#000000',
|
|
12
15
|
secondaryContainer: 'rgba(0,229,255,0.18)',
|
|
@@ -23,8 +26,19 @@ export const m3Colors = {
|
|
|
23
26
|
surfaceContainerHighest: '#222222',
|
|
24
27
|
outline: '#1A1A1A',
|
|
25
28
|
outlineVariant: '#2A2A2A',
|
|
29
|
+
/** Fronteira de componente interativo (botão outline, campo, chip, slider) —
|
|
30
|
+
* #1A1A1A dá 1.21:1 e some no OLED; 3:1 é o piso de WCAG 1.4.11. */
|
|
31
|
+
outlineInteractive: '#5C5C5C',
|
|
32
|
+
/** Divisória de lista/seção: decorativa, então fora do piso de 1.4.11. */
|
|
33
|
+
divider: '#242424',
|
|
34
|
+
/** Anel de foco (teclado / web). Ciano de marca = 13.6:1 sobre preto. */
|
|
35
|
+
focus: '#00E5FF',
|
|
36
|
+
disabledContainer: '#1F1F1F',
|
|
37
|
+
onDisabled: '#7A7A7A',
|
|
26
38
|
error: '#FF4444',
|
|
27
39
|
onError: '#FFFFFF',
|
|
40
|
+
/** Fundo sólido de ação destrutiva: branco sobre #FF4444 = 3.41:1; #C81E1E = 5.7:1. */
|
|
41
|
+
errorFill: '#C81E1E',
|
|
28
42
|
errorContainer: 'rgba(255,68,68,0.22)',
|
|
29
43
|
onErrorContainer: '#FF8A80',
|
|
30
44
|
inverseSurface: '#FFFFFF',
|
|
@@ -32,8 +46,14 @@ export const m3Colors = {
|
|
|
32
46
|
scrim: 'rgba(0,0,0,0.78)',
|
|
33
47
|
storyInteractionScrim: 'rgba(0,0,0,0.48)',
|
|
34
48
|
online: '#34FF88',
|
|
49
|
+
/** Verde de presença reaproveitado como feedback positivo (salvo / concluído). */
|
|
50
|
+
success: '#34FF88',
|
|
51
|
+
onSuccess: '#00382E',
|
|
35
52
|
/** Saiu recentemente (≤24h) — laranja, distinto do online verde e do ciano de marca. */
|
|
36
53
|
presenceRecent: '#FF9F0A',
|
|
54
|
+
/** States de interação sobre fundo sólido — em vez de opacity no botão inteiro. */
|
|
55
|
+
stateHovered: 'rgba(255,255,255,0.08)',
|
|
56
|
+
statePressed: 'rgba(255,255,255,0.14)',
|
|
37
57
|
} as const;
|
|
38
58
|
|
|
39
59
|
/** Accent/soft por reação Flash (conteúdo) — fonte única, sem hex solto nos apps. */
|
|
@@ -67,15 +87,17 @@ export const m3Shape = {
|
|
|
67
87
|
cornerFull: 999,
|
|
68
88
|
} as const;
|
|
69
89
|
|
|
90
|
+
/** Tracking por estilo: Montserrat é geométrica — fecha nos tamanhos de display,
|
|
91
|
+
* abre nas labels pequenas para continuar legível em tela de baixa DPI. */
|
|
70
92
|
export const m3Type = {
|
|
71
|
-
display: { fontFamily: 'Montserrat_800ExtraBold', fontSize: 57, lineHeight: 64 },
|
|
72
|
-
headline: { fontFamily: 'Montserrat_700Bold', fontSize: 28, lineHeight: 36 },
|
|
73
|
-
title: { fontFamily: 'Montserrat_600SemiBold', fontSize: 22, lineHeight: 28 },
|
|
74
|
-
titleSmall: { fontFamily: 'Montserrat_600SemiBold', fontSize: 16, lineHeight: 24 },
|
|
75
|
-
body: { fontFamily: 'Montserrat_400Regular', fontSize: 16, lineHeight: 24 },
|
|
76
|
-
bodySmall: { fontFamily: 'Montserrat_400Regular', fontSize: 14, lineHeight: 20 },
|
|
77
|
-
label: { fontFamily: 'Montserrat_500Medium', fontSize: 14, lineHeight: 20 },
|
|
78
|
-
labelSmall: { fontFamily: 'Montserrat_500Medium', fontSize: 12, lineHeight: 16 },
|
|
93
|
+
display: { fontFamily: 'Montserrat_800ExtraBold', fontSize: 57, lineHeight: 64, letterSpacing: -1 },
|
|
94
|
+
headline: { fontFamily: 'Montserrat_700Bold', fontSize: 28, lineHeight: 36, letterSpacing: -0.5 },
|
|
95
|
+
title: { fontFamily: 'Montserrat_600SemiBold', fontSize: 22, lineHeight: 28, letterSpacing: -0.3 },
|
|
96
|
+
titleSmall: { fontFamily: 'Montserrat_600SemiBold', fontSize: 16, lineHeight: 24, letterSpacing: 0 },
|
|
97
|
+
body: { fontFamily: 'Montserrat_400Regular', fontSize: 16, lineHeight: 24, letterSpacing: 0.15 },
|
|
98
|
+
bodySmall: { fontFamily: 'Montserrat_400Regular', fontSize: 14, lineHeight: 20, letterSpacing: 0.2 },
|
|
99
|
+
label: { fontFamily: 'Montserrat_500Medium', fontSize: 14, lineHeight: 20, letterSpacing: 0.4 },
|
|
100
|
+
labelSmall: { fontFamily: 'Montserrat_500Medium', fontSize: 12, lineHeight: 16, letterSpacing: 0.5 },
|
|
79
101
|
} as const;
|
|
80
102
|
|
|
81
103
|
export const colors = {
|
|
@@ -84,9 +106,14 @@ export const colors = {
|
|
|
84
106
|
surface2: '#141414',
|
|
85
107
|
border: '#1A1A1A',
|
|
86
108
|
borderStrong: '#2A2A2A',
|
|
109
|
+
/** Borda de componente interativo — ≥3:1 sobre preto (WCAG 1.4.11). */
|
|
110
|
+
borderInteractive: '#5C5C5C',
|
|
111
|
+
divider: '#242424',
|
|
87
112
|
textPrimary: '#FFFFFF',
|
|
88
113
|
textSecondary: '#A3A3A3',
|
|
89
|
-
|
|
114
|
+
/** #666666 dava 3.66:1 e reprova AA em texto corrido; #828282 = 5.4:1 no preto,
|
|
115
|
+
* 4.8:1 sobre surface2, onde hints costumam sentar. */
|
|
116
|
+
textMuted: '#828282',
|
|
90
117
|
white: '#FFFFFF',
|
|
91
118
|
textOnAccent: '#FFFFFF',
|
|
92
119
|
accent: '#D946EF',
|
|
@@ -102,8 +129,15 @@ export const colors = {
|
|
|
102
129
|
presenceRecent: '#FF9F0A',
|
|
103
130
|
presenceRecentSoft: 'rgba(255,159,10,0.22)',
|
|
104
131
|
danger: '#FF4444',
|
|
132
|
+
/** Fundo sólido destrutivo — branco sobre #FF4444 = 3.41:1; aqui = 5.7:1. */
|
|
133
|
+
dangerFill: '#C81E1E',
|
|
105
134
|
dangerSoft: 'rgba(255,68,68,0.22)',
|
|
106
135
|
dangerSoft18: 'rgba(239,68,68,0.18)',
|
|
136
|
+
success: '#34FF88',
|
|
137
|
+
onSuccess: '#00382E',
|
|
138
|
+
focus: '#00E5FF',
|
|
139
|
+
disabled: '#1F1F1F',
|
|
140
|
+
onDisabled: '#7A7A7A',
|
|
107
141
|
overlay: 'rgba(0,0,0,0.78)',
|
|
108
142
|
surfaceGlass: 'rgba(255,255,255,0.06)',
|
|
109
143
|
/** Pílula / chrome sem BlurView — preto OLED. */
|
|
@@ -120,6 +154,7 @@ export const colors = {
|
|
|
120
154
|
scrollbarThumb: 'rgba(255, 255, 255, 0.28)',
|
|
121
155
|
scrollbarThumbHover: 'rgba(255, 255, 255, 0.45)',
|
|
122
156
|
primary: '#D946EF',
|
|
157
|
+
primaryFill: '#A622D6',
|
|
123
158
|
primarySoft18: 'rgba(217,70,239,0.18)',
|
|
124
159
|
primarySoft35: 'rgba(217,70,239,0.35)',
|
|
125
160
|
onSurface: '#FFFFFF',
|
|
@@ -171,6 +206,13 @@ export const colors = {
|
|
|
171
206
|
surfaceElevated95: 'rgba(28,28,30,0.95)',
|
|
172
207
|
} as const;
|
|
173
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Coluna de leitura do corpo em viewport largo. Acima disso a linha de texto
|
|
211
|
+
* passa de ~100 caracteres e o grid perde ritmo — é teto de leitura, não de
|
|
212
|
+
* inset (quem se autogereia, ex. rail do Vibes, tem a própria coluna).
|
|
213
|
+
*/
|
|
214
|
+
export const SCREEN_CONTENT_MAX_WIDTH = 880;
|
|
215
|
+
|
|
174
216
|
export const spacing = {
|
|
175
217
|
xs: 4,
|
|
176
218
|
sm: 8,
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { Pressable, StyleSheet, Text } from 'react-native';
|
|
2
|
-
import {
|
|
2
|
+
import { m3Colors, m3Elevation, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
|
|
3
3
|
import { AppLoadingSpinner } from './AppLoadingSpinner';
|
|
4
|
+
import { FOCUS_RING_STYLE, useFocusRing } from './focus-ring';
|
|
5
|
+
|
|
6
|
+
type AppButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger';
|
|
7
|
+
|
|
8
|
+
const FILL_BY_VARIANT: Partial<Record<AppButtonVariant, string>> = {
|
|
9
|
+
primary: m3Colors.primaryFill,
|
|
10
|
+
secondary: m3Colors.surfaceContainerHigh,
|
|
11
|
+
danger: m3Colors.errorFill,
|
|
12
|
+
};
|
|
4
13
|
|
|
5
14
|
export function AppButton({
|
|
6
15
|
label,
|
|
@@ -9,54 +18,66 @@ export function AppButton({
|
|
|
9
18
|
disabled,
|
|
10
19
|
loading,
|
|
11
20
|
compact,
|
|
21
|
+
fullWidth,
|
|
12
22
|
testID,
|
|
23
|
+
accessibilityLabel,
|
|
13
24
|
}: {
|
|
14
25
|
label: string;
|
|
15
26
|
onPress: () => void;
|
|
16
|
-
variant?:
|
|
27
|
+
variant?: AppButtonVariant;
|
|
17
28
|
disabled?: boolean;
|
|
18
29
|
loading?: boolean;
|
|
19
30
|
compact?: boolean;
|
|
31
|
+
fullWidth?: boolean;
|
|
20
32
|
testID?: string;
|
|
33
|
+
accessibilityLabel?: string;
|
|
21
34
|
}) {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
?
|
|
29
|
-
:
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const focus = useFocusRing();
|
|
36
|
+
const inactive = Boolean(disabled) || Boolean(loading);
|
|
37
|
+
const isTextonlyVariant = variant === 'outline' || variant === 'ghost';
|
|
38
|
+
|
|
39
|
+
const backgroundColor = inactive
|
|
40
|
+
? isTextonlyVariant
|
|
41
|
+
? 'transparent'
|
|
42
|
+
: m3Colors.disabledContainer
|
|
43
|
+
: (FILL_BY_VARIANT[variant] ?? 'transparent');
|
|
44
|
+
|
|
45
|
+
// Rótulo por estado, nunca por opacity composta no botão inteiro.
|
|
46
|
+
const labelColor = inactive
|
|
47
|
+
? m3Colors.onDisabled
|
|
48
|
+
: isTextonlyVariant
|
|
49
|
+
? m3Colors.primary
|
|
50
|
+
: m3Colors.onPrimary;
|
|
32
51
|
|
|
33
52
|
return (
|
|
34
53
|
<Pressable
|
|
35
54
|
testID={testID}
|
|
36
55
|
accessibilityRole="button"
|
|
56
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
57
|
+
accessibilityState={{ disabled: inactive, busy: Boolean(loading) }}
|
|
37
58
|
onPress={onPress}
|
|
38
|
-
|
|
59
|
+
onFocus={focus.onFocus}
|
|
60
|
+
onBlur={focus.onBlur}
|
|
61
|
+
disabled={inactive}
|
|
39
62
|
style={({ pressed }) => [
|
|
40
63
|
styles.btn,
|
|
41
64
|
compact && styles.btnCompact,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
backgroundColor: 'transparent',
|
|
47
|
-
borderWidth: 1,
|
|
48
|
-
borderColor: m3Colors.outline,
|
|
65
|
+
fullWidth && styles.btnFullWidth,
|
|
66
|
+
{ backgroundColor },
|
|
67
|
+
variant === 'outline' && {
|
|
68
|
+
borderColor: inactive ? m3Colors.outlineVariant : m3Colors.outlineInteractive,
|
|
49
69
|
},
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
(isPrimary || isSecondary) && m3Elevation.level1,
|
|
70
|
+
focus.focused && !inactive && FOCUS_RING_STYLE,
|
|
71
|
+
pressed && !inactive && styles.pressed,
|
|
72
|
+
!inactive && (variant === 'primary' || variant === 'secondary') && m3Elevation.level1,
|
|
54
73
|
]}
|
|
55
74
|
>
|
|
56
75
|
{loading ? (
|
|
57
76
|
<AppLoadingSpinner color={labelColor} />
|
|
58
77
|
) : (
|
|
59
|
-
<Text style={[styles.btnText, { color: labelColor }]}
|
|
78
|
+
<Text style={[styles.btnText, { color: labelColor }]} numberOfLines={1}>
|
|
79
|
+
{label}
|
|
80
|
+
</Text>
|
|
60
81
|
)}
|
|
61
82
|
</Pressable>
|
|
62
83
|
);
|
|
@@ -64,12 +85,17 @@ export function AppButton({
|
|
|
64
85
|
|
|
65
86
|
const styles = StyleSheet.create({
|
|
66
87
|
btn: {
|
|
67
|
-
|
|
88
|
+
minHeight: 48,
|
|
89
|
+
minWidth: 64,
|
|
68
90
|
borderRadius: m3Shape.cornerFull,
|
|
69
91
|
alignItems: 'center',
|
|
70
92
|
justifyContent: 'center',
|
|
71
93
|
paddingHorizontal: spacing.xl,
|
|
94
|
+
borderWidth: 2,
|
|
95
|
+
borderColor: 'transparent',
|
|
72
96
|
},
|
|
73
|
-
btnCompact: {
|
|
74
|
-
|
|
97
|
+
btnCompact: { minHeight: 44, paddingHorizontal: spacing.lg },
|
|
98
|
+
btnFullWidth: { alignSelf: 'stretch' },
|
|
99
|
+
pressed: { opacity: 0.85 },
|
|
100
|
+
btnText: { ...m3Type.label },
|
|
75
101
|
});
|
|
@@ -11,6 +11,9 @@ type AppChromeIconButtonProps = {
|
|
|
11
11
|
size?: number;
|
|
12
12
|
/** Header chrome pill — circular bordered target. */
|
|
13
13
|
variant?: 'header' | 'plain';
|
|
14
|
+
/** Optional background for dense surfaces (banner close, sheet dismiss). */
|
|
15
|
+
backgroundColor?: string;
|
|
16
|
+
testID?: string;
|
|
14
17
|
};
|
|
15
18
|
|
|
16
19
|
export function AppChromeIconButton({
|
|
@@ -21,17 +24,22 @@ export function AppChromeIconButton({
|
|
|
21
24
|
color = m3Colors.primary,
|
|
22
25
|
size = APP_ICON_SIZE.chromeHeader,
|
|
23
26
|
variant = 'header',
|
|
27
|
+
backgroundColor,
|
|
28
|
+
testID,
|
|
24
29
|
}: AppChromeIconButtonProps) {
|
|
25
30
|
return (
|
|
26
31
|
<Pressable
|
|
27
32
|
onPress={onPress}
|
|
28
33
|
disabled={disabled || !onPress}
|
|
29
|
-
hitSlop={8}
|
|
30
34
|
accessibilityRole="button"
|
|
31
35
|
accessibilityLabel={label}
|
|
32
|
-
|
|
36
|
+
accessibilityState={{ disabled: Boolean(disabled) }}
|
|
37
|
+
testID={testID}
|
|
38
|
+
style={({ pressed }) => [
|
|
33
39
|
variant === 'header' ? styles.headerBtn : styles.plainBtn,
|
|
40
|
+
pressed && styles.pressed,
|
|
34
41
|
disabled && styles.disabled,
|
|
42
|
+
backgroundColor ? { backgroundColor } : null,
|
|
35
43
|
]}
|
|
36
44
|
>
|
|
37
45
|
<AppIcon name={icon} color={color} size={size} />
|
|
@@ -41,21 +49,25 @@ export function AppChromeIconButton({
|
|
|
41
49
|
|
|
42
50
|
const styles = StyleSheet.create({
|
|
43
51
|
headerBtn: {
|
|
44
|
-
width:
|
|
45
|
-
height:
|
|
46
|
-
borderRadius: 22
|
|
52
|
+
width: 44,
|
|
53
|
+
height: 44,
|
|
54
|
+
borderRadius: 22,
|
|
47
55
|
borderWidth: 2,
|
|
48
56
|
borderColor: 'rgba(255,255,255,0.22)',
|
|
49
57
|
margin: spacing.xs,
|
|
50
58
|
alignItems: 'center',
|
|
51
59
|
justifyContent: 'center',
|
|
52
60
|
},
|
|
61
|
+
/** 44×44: `plain` também é usado como botão de fechar em banner e sheet. */
|
|
53
62
|
plainBtn: {
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
minWidth: 44,
|
|
64
|
+
minHeight: 44,
|
|
56
65
|
alignItems: 'center',
|
|
57
66
|
justifyContent: 'center',
|
|
58
67
|
},
|
|
68
|
+
pressed: {
|
|
69
|
+
opacity: 0.75,
|
|
70
|
+
},
|
|
59
71
|
disabled: {
|
|
60
72
|
opacity: 0.4,
|
|
61
73
|
},
|
package/src/renderer/ui/Chip.tsx
CHANGED
|
@@ -1,55 +1,65 @@
|
|
|
1
1
|
import { Pressable, StyleSheet, Text } from 'react-native';
|
|
2
|
-
import { colors, fonts, m3Colors, m3Shape, spacing } from '../theme/proulr-theme';
|
|
2
|
+
import { colors, fonts, m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
|
|
3
3
|
import { AppIcon, isAppIconName } from './app-icon';
|
|
4
|
+
import { FOCUS_RING_STYLE, useFocusRing } from './focus-ring';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* (44 − 36) / 2 — o alvo de toque mínimo vem do slop, não da altura visual: a
|
|
8
|
+
* pílula continua compacta na linha do chrome e continua alcançando 44 pt.
|
|
9
|
+
*/
|
|
10
|
+
const CHIP_HIT_SLOP = { top: 4, bottom: 4, left: 2, right: 2 };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pílula do sistema (padrão nascido no chip de filtros do Caçar) — uma só
|
|
14
|
+
* superfície para filtro, seletor e tag em qualquer breakpoint. Antes havia um
|
|
15
|
+
* `variant: 'default'` retangular para "conteúdo" e outro para "header", o que
|
|
16
|
+
* fazia a mesma linha de filtros trocar de aparência entre tela pequena e grande.
|
|
17
|
+
*/
|
|
5
18
|
export function Chip({
|
|
6
19
|
label,
|
|
7
20
|
active,
|
|
8
21
|
onPress,
|
|
9
|
-
variant = 'default',
|
|
10
22
|
disabled,
|
|
11
23
|
icon,
|
|
24
|
+
testID,
|
|
12
25
|
}: {
|
|
13
26
|
label: string;
|
|
14
27
|
active?: boolean;
|
|
15
28
|
onPress?: () => void;
|
|
16
|
-
variant?: 'default' | 'onHeader';
|
|
17
29
|
disabled?: boolean;
|
|
18
30
|
icon?: string;
|
|
31
|
+
testID?: string;
|
|
19
32
|
}) {
|
|
20
|
-
const isHeader = variant === 'onHeader';
|
|
21
|
-
|
|
22
33
|
const containerStyle = active
|
|
23
|
-
?
|
|
24
|
-
|
|
25
|
-
: { backgroundColor: m3Colors.secondaryContainer, borderColor: m3Colors.outline }
|
|
26
|
-
: isHeader
|
|
27
|
-
? { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder }
|
|
28
|
-
: { backgroundColor: m3Colors.surfaceContainerHigh, borderColor: m3Colors.outline };
|
|
29
|
-
|
|
30
|
-
const textColor = active
|
|
31
|
-
? isHeader
|
|
32
|
-
? colors.headerStart
|
|
33
|
-
: m3Colors.onSecondaryContainer
|
|
34
|
-
: isHeader
|
|
35
|
-
? colors.textOnAccent
|
|
36
|
-
: m3Colors.onSurfaceVariant;
|
|
34
|
+
? { backgroundColor: m3Colors.inverseSurface, borderColor: m3Colors.inverseSurface }
|
|
35
|
+
: { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder };
|
|
37
36
|
|
|
37
|
+
const textColor = disabled ? m3Colors.onDisabled : active ? colors.headerStart : colors.textOnAccent;
|
|
38
38
|
const iconName = icon && isAppIconName(icon) ? icon : null;
|
|
39
|
+
const focus = useFocusRing();
|
|
39
40
|
|
|
40
41
|
return (
|
|
41
42
|
<Pressable
|
|
43
|
+
testID={testID}
|
|
44
|
+
accessibilityRole="button"
|
|
45
|
+
accessibilityLabel={label}
|
|
46
|
+
accessibilityState={{ selected: Boolean(active), disabled: Boolean(disabled) }}
|
|
42
47
|
onPress={onPress}
|
|
48
|
+
onFocus={focus.onFocus}
|
|
49
|
+
onBlur={focus.onBlur}
|
|
43
50
|
disabled={disabled}
|
|
44
|
-
|
|
51
|
+
hitSlop={CHIP_HIT_SLOP}
|
|
52
|
+
style={({ pressed }) => [
|
|
53
|
+
styles.chip,
|
|
54
|
+
containerStyle,
|
|
55
|
+
pressed && !disabled && styles.chipPressed,
|
|
56
|
+
focus.focused && !disabled && FOCUS_RING_STYLE,
|
|
57
|
+
]}
|
|
45
58
|
>
|
|
46
|
-
{iconName ? <AppIcon name={iconName} size={
|
|
59
|
+
{iconName ? <AppIcon name={iconName} size={14} color={textColor} /> : null}
|
|
47
60
|
<Text
|
|
48
|
-
style={{
|
|
49
|
-
|
|
50
|
-
fontSize: isHeader ? 12 : 13,
|
|
51
|
-
fontFamily: fonts.semibold,
|
|
52
|
-
}}
|
|
61
|
+
style={{ ...m3Type.labelSmall, color: textColor, fontFamily: fonts.semibold }}
|
|
62
|
+
numberOfLines={1}
|
|
53
63
|
>
|
|
54
64
|
{label}
|
|
55
65
|
</Text>
|
|
@@ -61,15 +71,12 @@ const styles = StyleSheet.create({
|
|
|
61
71
|
chip: {
|
|
62
72
|
flexDirection: 'row',
|
|
63
73
|
alignItems: 'center',
|
|
74
|
+
justifyContent: 'center',
|
|
64
75
|
gap: spacing.xs,
|
|
65
|
-
|
|
66
|
-
paddingVertical: spacing.sm,
|
|
67
|
-
borderRadius: m3Shape.cornerSmall,
|
|
68
|
-
borderWidth: 1,
|
|
69
|
-
},
|
|
70
|
-
chipOnHeader: {
|
|
71
|
-
borderRadius: m3Shape.cornerFull,
|
|
76
|
+
minHeight: 36,
|
|
72
77
|
paddingHorizontal: spacing.sm + 2,
|
|
73
|
-
|
|
78
|
+
borderRadius: m3Shape.cornerFull,
|
|
79
|
+
borderWidth: 1,
|
|
74
80
|
},
|
|
81
|
+
chipPressed: { opacity: 0.85 },
|
|
75
82
|
});
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { ScrollView, StyleSheet, View } from 'react-native';
|
|
3
|
-
import { CHROME_HORIZONTAL_INSET } from '../theme/chrome-layout';
|
|
4
3
|
import { spacing } from '../theme/proulr-theme';
|
|
5
4
|
|
|
6
5
|
type ChipRowProps = {
|
|
7
6
|
children: React.ReactNode;
|
|
8
|
-
variant?: 'default' | 'onHeader';
|
|
9
7
|
};
|
|
10
8
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Linha horizontal de pílulas — a mesma fileira do Caçar, do hub do chat e de
|
|
11
|
+
* qualquer seletor de opções. O encaixe horizontal é do invólucro que chama.
|
|
12
|
+
*/
|
|
13
|
+
export function ChipRow({ children }: ChipRowProps) {
|
|
15
14
|
if (!children) {
|
|
16
|
-
return <View style={
|
|
15
|
+
return <View style={styles.content} />;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
return (
|
|
@@ -21,7 +20,7 @@ export function ChipRow({ children, variant = 'default' }: ChipRowProps) {
|
|
|
21
20
|
horizontal
|
|
22
21
|
showsHorizontalScrollIndicator={false}
|
|
23
22
|
style={styles.scroll}
|
|
24
|
-
contentContainerStyle={
|
|
23
|
+
contentContainerStyle={styles.content}
|
|
25
24
|
keyboardShouldPersistTaps="handled"
|
|
26
25
|
nestedScrollEnabled
|
|
27
26
|
>
|
|
@@ -41,10 +40,5 @@ const styles = StyleSheet.create({
|
|
|
41
40
|
alignItems: 'center',
|
|
42
41
|
gap: spacing.xs,
|
|
43
42
|
paddingHorizontal: spacing.sm,
|
|
44
|
-
paddingVertical: 0,
|
|
45
|
-
},
|
|
46
|
-
contentOnHeader: {
|
|
47
|
-
paddingLeft: CHROME_HORIZONTAL_INSET,
|
|
48
|
-
paddingRight: CHROME_HORIZONTAL_INSET,
|
|
49
43
|
},
|
|
50
44
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StyleSheet, Text, type TextProps } from 'react-native';
|
|
2
|
-
import {
|
|
2
|
+
import { m3Colors, m3Type, spacing } from '../theme/proulr-theme';
|
|
3
3
|
|
|
4
4
|
type HintTextProps = TextProps & {
|
|
5
5
|
spaced?: boolean;
|
|
@@ -11,10 +11,8 @@ export function HintText({ spaced, style, ...props }: HintTextProps) {
|
|
|
11
11
|
|
|
12
12
|
const styles = StyleSheet.create({
|
|
13
13
|
hint: {
|
|
14
|
+
...m3Type.bodySmall,
|
|
14
15
|
color: m3Colors.onSurfaceVariant,
|
|
15
|
-
fontSize: 12,
|
|
16
|
-
lineHeight: 17,
|
|
17
|
-
fontFamily: fonts.regular,
|
|
18
16
|
},
|
|
19
17
|
spaced: {
|
|
20
18
|
marginTop: spacing.sm,
|