@sincpro/mobile-ui 0.7.0 → 0.7.2

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 CHANGED
@@ -1,114 +1,691 @@
1
1
  # @sincpro/mobile-ui
2
2
 
3
- Sincpro Mobile design system — generic, presentational React Native components (Expo SDK 56).
3
+ **Sincpro Mobile Design System** — the UI contract between design and engineering for React Native / Expo SDK 56 applications.
4
4
 
5
- - **Presentational only** — driven by props/callbacks. No domain logic, services, or state stores.
6
- - **Standalone** — does not depend on `@sincpro/mobile` (the core depends on this package).
7
- - **Mobile-first** — built with NativeWind 4 + tailwind-variants, design tokens via CSS variables.
5
+ This package is maintained by Sincpro as part of the mobile framework ecosystem. Anyone can read the source and use the components. What Sincpro provides is the commitment behind it: stable APIs, predictable behavior, long-term support, and a team that ensures this keeps working as platforms evolve. Build on it knowing it will still be there.
8
6
 
9
- > 🤖 **AI agents:** read [`AGENTS.md`](AGENTS.md) (ecosystem & patterns) and [`docs/GOTCHAS.md`](docs/GOTCHAS.md) (known traps) first.
7
+ > **AI agent?** Read [`AGENTS.md`](AGENTS.md) and [`docs/GOTCHAS.md`](docs/GOTCHAS.md) first.
10
8
 
11
- ## Install
9
+ ---
10
+
11
+ ## Quick Start
12
12
 
13
13
  ```bash
14
14
  npx expo install @sincpro/mobile-ui
15
15
  ```
16
16
 
17
- Peer dependencies are provided by the host app: `react`, `react-native`, `nativewind`,
18
- `react-native-reanimated`, `react-native-svg`, `react-native-safe-area-context`,
19
- `react-native-keyboard-controller`, `react-native-worklets`, `react-hook-form`,
20
- `react-native-currency-input`, `@expo/vector-icons`, `@expo-google-fonts/montserrat`, plus the
21
- expo modules listed in `package.json`. A few components need **optional** peers (install only if
22
- you use them): `@react-native-community/slider` (`Form/Slider`), `@react-native-community/datetimepicker`
23
- (`Form/DatePicker`), `@expo/ui` (future native primitives).
24
-
25
- ## Tailwind preset (required)
26
-
27
- Components use design tokens exposed as Tailwind classes (`bg-primary`, `text-text-secondary`, …)
28
- backed by CSS variables. Wire them via the shipped preset and content globs:
17
+ Wire the Tailwind preset, then import by category:
29
18
 
30
19
  ```js
31
20
  // tailwind.config.js
32
21
  module.exports = {
33
22
  presets: [require("@sincpro/mobile-ui/tailwind.preset")],
34
- content: ["./App.tsx", "./src/**/*.{ts,tsx}", "./node_modules/@sincpro/mobile-ui/**/*.js"],
35
- theme: { extend: { colors: { brand: "#FF6600" } } },
23
+ content: [
24
+ "./App.tsx",
25
+ "./src/**/*.{ts,tsx}",
26
+ "./node_modules/@sincpro/mobile-ui/**/*.js",
27
+ ],
36
28
  };
37
29
  ```
38
30
 
39
- Token values are CSS variables; override them (a custom theme) in your global CSS `:root`, or inject
40
- at runtime with `vars()`. The framework's `createAppShell({ theme })` does this automatically.
31
+ ```tsx
32
+ import { Display } from "@sincpro/mobile-ui/Display";
33
+ import { Form } from "@sincpro/mobile-ui/Form";
34
+ import { useToast, ToastProvider } from "@sincpro/mobile-ui/Feedback";
35
+
36
+ export default function InvoiceScreen() {
37
+ const toast = useToast();
38
+ return (
39
+ <Display.Card>
40
+ <Display.KeyValue label="Invoice #" value="INV-0042" copyable />
41
+ <Display.Monetary amount={1500.00} variant="dataLarge" />
42
+ <Form.Button onPress={() => toast.success("Invoice sent")}>
43
+ Send Invoice
44
+ </Form.Button>
45
+ </Display.Card>
46
+ );
47
+ }
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Table of Contents
53
+
54
+ 1. [What is a Design System](#what-is-a-design-system)
55
+ 2. [Philosophy](#philosophy)
56
+ 3. [Key Features](#key-features)
57
+ 4. [Component Catalog](#component-catalog)
58
+ 5. [Theme System](#theme-system)
59
+ 6. [Typography System](#typography-system)
60
+ 7. [Motion Tokens](#motion-tokens)
61
+ 8. [Branding](#branding)
62
+ 9. [Tailwind Setup](#tailwind-setup)
63
+ 10. [Import Patterns](#import-patterns)
64
+ 11. [Storybook](#storybook)
65
+ 12. [Development](#development)
66
+
67
+ ---
68
+
69
+ ## What is a Design System
70
+
71
+ A design system is a shared language between the people who design products and the people who build them. It defines how things look, how they behave, and how they are named — once — so every screen built from it is consistent by default.
72
+
73
+ Without a design system, every developer makes local decisions: which shade of green for success, how many pixels for a button press state, which font weight for a label. Those decisions accumulate. Over time the product looks like it was built by ten different teams, because it was.
74
+
75
+ `@sincpro/mobile-ui` is Sincpro's answer to that problem for mobile. It encodes the design decisions once, in components and tokens that the whole team uses. When the brand evolves, the change propagates everywhere. When a new screen needs a data table, it already exists, already handles edge cases, and already matches everything else.
76
+
77
+ **The design system is not a collection of pretty components. It is the baseline every Sincpro app starts from.**
78
+
79
+ ---
80
+
81
+ ## Philosophy
82
+
83
+ ### Maintained for the long term
84
+
85
+ Enterprise software lives for years. The screens built with this DS will be in production long after the initial project is delivered. Sincpro's commitment is to maintain API stability, patch what breaks on platform upgrades, and evolve the system without forcing rewrites on consumers.
86
+
87
+ Changing a behavior that existing apps depend on is a breaking change. We treat it as one. Additions are additive; deprecations are marked and kept for a full major version cycle before removal.
88
+
89
+ ### Presentational only
90
+
91
+ Components in this package are driven entirely by props and callbacks. There is no routing, no domain state, no API calls. This is intentional: it makes components independently testable in Storybook, reusable across different app architectures, and safe to upgrade without side effects in business logic.
92
+
93
+ ### Token-driven, not hardcoded
94
+
95
+ No color, no font size, no shadow is hardcoded in a component. Every visual property references a design token — a CSS variable injected at the root. Swapping the entire visual language of an app means changing one token map, not hunting down hex codes across a codebase.
96
+
97
+ ### Compound component API
98
+
99
+ Related components are grouped into namespaced objects (`Display`, `Form`, `Navigation`, etc.). One import gives access to an entire category, and the namespace makes the relationship between components visible at the call site.
100
+
101
+ ---
102
+
103
+ ## Key Features
104
+
105
+ ### Controlled Form Components
106
+
107
+ Every interactive input ships a `Controlled*` variant that integrates directly with `react-hook-form`. No wrapper, no boilerplate:
108
+
109
+ ```tsx
110
+ import { Form } from "@sincpro/mobile-ui/Form";
111
+ import { useForm } from "react-hook-form";
112
+
113
+ const { control } = useForm<{ email: string; amount: number }>();
114
+
115
+ <Form.ControlledInput
116
+ control={control}
117
+ name="email"
118
+ label="Email address"
119
+ rules={{ required: "Required" }}
120
+ />
121
+ <Form.ControlledMonetaryInput
122
+ control={control}
123
+ name="amount"
124
+ label="Amount (USD)"
125
+ />
126
+ ```
127
+
128
+ ### Toast Notifications
129
+
130
+ Queued toast system with promise support — covers the full loading → success / error lifecycle:
131
+
132
+ ```tsx
133
+ import { ToastProvider, useToast } from "@sincpro/mobile-ui/Feedback";
134
+
135
+ // 1. Wrap the app once (createAppShell does this automatically)
136
+ <ToastProvider position="top">{children}</ToastProvider>
137
+
138
+ // 2. Call from anywhere in the tree
139
+ const toast = useToast();
140
+ toast.success("Invoice saved");
141
+ toast.promise(sendInvoice(), {
142
+ loading: "Sending…",
143
+ success: "Invoice sent",
144
+ error: "Failed to send",
145
+ });
146
+ ```
147
+
148
+ ### Bottom Sheet and Dialogs
149
+
150
+ ```tsx
151
+ import Sheet from "@sincpro/mobile-ui/Dialog/Dialog.Sheet";
152
+ import { ConfirmationProvider, useConfirmationContext } from "@sincpro/mobile-ui/Dialog";
153
+
154
+ // Controlled sheet — built on RN Modal, no native peer deps
155
+ <Sheet visible={open} onClose={() => setOpen(false)} title="Options">
156
+ <Form.Button onPress={confirm}>Confirm</Form.Button>
157
+ </Sheet>
158
+
159
+ // Imperative confirmation
160
+ const { confirm } = useConfirmationContext();
161
+ const agreed = await confirm({ title: "Delete invoice?", message: "This cannot be undone." });
162
+ ```
163
+
164
+ ### Multi-Step Wizard
41
165
 
42
- ## Usage & import patterns
166
+ ```tsx
167
+ import { Wizard, useWizard } from "@sincpro/mobile-ui/views/Wizard";
168
+
169
+ <Wizard initialStep="customer">
170
+ <Wizard.Step id="customer"><CustomerStep /></Wizard.Step>
171
+ <Wizard.Step id="items"><ItemsStep /></Wizard.Step>
172
+ <Wizard.Step id="review"><ReviewStep /></Wizard.Step>
173
+ </Wizard>
174
+
175
+ // Inside any step
176
+ const { go, back } = useWizard();
177
+ go("items");
178
+ ```
43
179
 
44
- Import by **category subpath** (recommended — better tree-shaking) or from the root:
180
+ ### Data Display
45
181
 
46
182
  ```tsx
47
- import { Form } from "@sincpro/mobile-ui/Form"; // inputs + actions
48
- import { Display } from "@sincpro/mobile-ui/Display"; // data display
49
- import { Feedback } from "@sincpro/mobile-ui/Feedback"; // app state (Toast, Skeleton…)
50
- import { Navigation } from "@sincpro/mobile-ui/Navigation"; // AppBar, BottomNav, SearchBar…
51
- import { Dialog } from "@sincpro/mobile-ui/Dialog"; // overlays (Sheet, ActionSheet…)
52
- import { theme, useTheme } from "@sincpro/mobile-ui/theme";
53
- import { motion } from "@sincpro/mobile-ui/tokens";
183
+ import { Display } from "@sincpro/mobile-ui/Display";
184
+
185
+ <Display.Stat label="Sales today" value="$12,400" trend="+8%" />
186
+ <Display.PriceTag amount={299.99} currency="USD" />
187
+ <Display.KeyValue label="Order #" value="ORD-8821" copyable />
188
+ <Display.Badge variant="success">Paid</Display.Badge>
189
+ <Display.Monetary amount={1500.00} variant="dataLarge" />
190
+ <Display.DataTable columns={cols} rows={rows} />
54
191
  ```
55
192
 
56
- Full component catalog by category: [`docs/CATALOG.md`](docs/CATALOG.md).
193
+ ---
194
+
195
+ ## Component Catalog
196
+
197
+ ### Actions
198
+
199
+ | Component | Description |
200
+ |-----------|-------------|
201
+ | `Actions.Button` | Primary action — gradient, outline, flat, link variants; optional confirm dialog |
202
+ | `Actions.IconButton` | Icon-only tap target — ghost, primary, secondary, tertiary variants |
203
+ | `Actions.FAB` | Floating Action Button — fixed position, optional label |
204
+ | `Actions.FilterButton` | Chip-style active/inactive toggle |
205
+ | `Actions.FilterButtonGroup` | Horizontal scroll container for filter chips |
206
+
207
+ ### Display
208
+
209
+ | Component | Description |
210
+ |-----------|-------------|
211
+ | `Display.Badge` | Status badge — success, warning, danger, info, neutral |
212
+ | `Display.Card` | Surface container with optional shadow and padding |
213
+ | `Display.KeyValue` | Label + value row, optional clipboard copy |
214
+ | `Display.Stat` | KPI cell — label, value, optional trend indicator |
215
+ | `Display.StatCard` | Card-wrapped KPI |
216
+ | `Display.Monetary` | Formatted monetary amount using mono typography |
217
+ | `Display.PriceTag` | Currency-formatted price with size options |
218
+ | `Display.DataTable` | Scrollable table for structured data |
219
+ | `Display.ListItem` | Row with icon, label, sublabel, optional action |
220
+ | `Display.Chip` | Compact label tag |
221
+ | `Display.Avatar` / `AvatarGroup` | User avatar, stacked group |
222
+ | `Display.Icon` | Vector icon wrapper (`@expo/vector-icons`) |
223
+ | `Display.StatusDot` | Colored presence / state indicator |
224
+ | `Display.Divider` | Horizontal separator |
225
+ | `Display.GradientSurface` | Container with linear gradient background |
226
+ | `Display.Timeline` | Vertical event timeline |
227
+ | `Display.SwipeableRow` | Row with swipe-to-reveal actions |
228
+ | `Display.Tooltip` | Overlay tooltip |
229
+ | `Display.CopyableText` | Text with one-tap clipboard copy |
230
+ | `Display.Logo` | Brand logo (falls back to `setBranding` value) |
231
+ | `Display.Accordion` | Collapsible content section |
232
+ | `Display.Carousel` | Horizontal swipe carousel |
233
+ | `Display.Story` / `StoryTray` / `StoryViewer` | Stories UI pattern |
234
+ | `Display.MediaCard` | Card with image or video embed |
235
+ | `Display.Pattern` | Decorative background pattern |
236
+
237
+ ### Form
238
+
239
+ | Component | Description |
240
+ |-----------|-------------|
241
+ | `Form.TextInput` | Text field with label, error, and helper text |
242
+ | `Form.PasswordInput` | Text input with show/hide toggle |
243
+ | `Form.PinInput` | PIN code entry |
244
+ | `Form.MonetaryInput` | Currency input with formatting |
245
+ | `Form.QuantitySelector` | Integer increment / decrement |
246
+ | `Form.FractionalQuantityInput` | Decimal quantity input |
247
+ | `Form.Select` | Dropdown select (modal-based) |
248
+ | `Form.Dropdown` | Inline dropdown menu |
249
+ | `Form.RadioButton` | Single-select radio option |
250
+ | `Form.Checkbox` | Multi-select checkbox |
251
+ | `Form.Switch` | Toggle switch |
252
+ | `Form.Slider` | Range slider (`@react-native-community/slider` peer) |
253
+ | `Form.DatePicker` | Date picker (`@react-native-community/datetimepicker` peer) |
254
+ | `Form.SearchBar` | Search input with clear button |
255
+ | `Form.ControlledInput` | `TextInput` + react-hook-form |
256
+ | `Form.ControlledRadioButton` | `RadioButton` + react-hook-form |
257
+ | `Form.ControlledCheckbox` | `Checkbox` + react-hook-form |
258
+ | `Form.ControlledDropdown` | `Dropdown` + react-hook-form |
259
+ | `Form.ControlledMonetaryInput` | `MonetaryInput` + react-hook-form |
260
+ | `Form.Button` / `IconButton` / `FAB` | Action components in Form namespace |
261
+
262
+ ### Feedback
263
+
264
+ | Component | Description |
265
+ |-----------|-------------|
266
+ | `Feedback.Spinner` | Animated loading spinner |
267
+ | `Feedback.Loading` | Full-screen loading overlay |
268
+ | `Feedback.Skeleton` | Content placeholder shimmer |
269
+ | `Feedback.Progress` | Linear progress bar |
270
+ | `Feedback.ProgressCircle` | Circular progress indicator |
271
+ | `Feedback.Banner` | Contextual notification banner |
272
+ | `Feedback.Empty` / `EmptyState` | Empty list or page placeholder |
273
+ | `Feedback.Error` | Error state with optional retry |
274
+ | `Feedback.PermissionCard` | Permission request UI |
275
+ | `ToastProvider` / `useToast` | Queued toast notification system |
276
+ | `ErrorBoundary` | React error boundary wrapper |
277
+
278
+ ### Navigation
279
+
280
+ | Component | Description |
281
+ |-----------|-------------|
282
+ | `Navigation.AppBar` | Top navigation bar with action slots |
283
+ | `Navigation.BottomNav` | Bottom tab bar |
284
+ | `Navigation.Header` | Screen header |
285
+ | `Navigation.HeaderToolbar` | Header with left, center, right slots |
286
+ | `Navigation.CollapsingHeader` | Header that collapses on scroll |
287
+ | `Navigation.FloatingBar` | Floating action bar |
288
+ | `Navigation.SearchBar` | Navigation-level search input |
289
+ | `Navigation.SegmentedControl` | Segmented control switcher |
290
+ | `Navigation.Tabs` | Tab navigation container |
291
+ | `Navigation.Steps` | Step indicator / breadcrumb |
292
+ | `Navigation.Menu` / `MenuSection` | Dropdown menu with sections |
293
+
294
+ ### Dialog
57
295
 
58
- ### Dark mode
296
+ | Component | Description |
297
+ |-----------|-------------|
298
+ | `Dialog.BottomSheet` | Bottom drawer (RN Modal, no native peer deps) |
299
+ | `Dialog.ActionSheet` | Action sheet with options list |
300
+ | `Dialog.Confirmation` | Confirm / cancel modal |
301
+ | `Dialog.EditValue` | Inline edit value modal |
302
+ | `Sheet` (direct import) | Controlled sheet via `Dialog/Dialog.Sheet` |
303
+ | `ConfirmationProvider` / `useConfirmationContext` | Imperative confirmation API |
59
304
 
60
- Ships `DEFAULT_THEME` (light) and `DEFAULT_DARK_THEME`. Set the active theme with `setActiveTheme`
61
- (or `createAppShell({ theme })` in the core) and inject the CSS vars with `vars(themeToVars(tokens))`
62
- on the subtree. `useTheme()` is a reactive hook to read the theme in components.
305
+ ### Typography
63
306
 
64
- ### Branding (logo by context)
307
+ | Component | Description |
308
+ |-----------|-------------|
309
+ | `Typography.Text` | Main text component — accepts `variant` prop from the full scale |
310
+ | `Typography.Heading` | Heading shorthand |
311
+
312
+ ### Primitives
313
+
314
+ | Component | Description |
315
+ |-----------|-------------|
316
+ | `Box` | NativeWind layout container |
317
+ | `Row` | Horizontal flex |
318
+ | `Stack` | Vertical flex |
319
+ | `Spacer` | Flexible space filler |
320
+ | `Pressable` | Animated press target |
321
+ | `AspectRatio` | Aspect-ratio container |
322
+
323
+ ### Layouts
324
+
325
+ | Component | Description |
326
+ |-----------|-------------|
327
+ | `Container` | Padded layout container |
328
+ | `ScrollContainer` | Scrollable layout wrapper |
329
+ | `GradientContainer` | Container with gradient background |
330
+ | `PlainLayout` | Unstyled layout for custom screens |
331
+ | `TabNavigatorLayout` | Layout for tab-based screen structure |
332
+
333
+ ### Views
334
+
335
+ | Component | Description |
336
+ |-----------|-------------|
337
+ | `ListViewV2` | Paginated list with filtering and empty states |
338
+ | `FormViewV2` | Dynamic form view with field management |
339
+ | `AuthFormView` | Authentication form layout |
340
+ | `Wizard` / `useWizard` | Multi-step wizard with guards and lifecycle hooks |
341
+
342
+ ### Widgets
343
+
344
+ | Component | Description |
345
+ |-----------|-------------|
346
+ | `ScreenHeader` | Composite screen header widget |
347
+ | `MenuGrid` / `MenuCard` | Grid and card-based navigation menus |
348
+ | `QuickActions` | Quick action icon grid |
349
+ | `PromoBanner` | Swipeable promotional carousel |
350
+ | `PromoModal` | Promotional modal overlay |
351
+ | `HomeHeader` | Home screen hero header |
352
+ | `GeoPermissionCard` | Geolocation permission request card |
353
+ | `TimeZoneSelector` | Timezone picker widget |
354
+ | `JSONViewer` | Collapsible JSON tree (debug / dev tools) |
355
+
356
+ ---
357
+
358
+ ## Theme System
359
+
360
+ All component colors reference CSS variables — never hardcoded values. Swapping the theme rewrites the variables and every component updates without touching any component code.
361
+
362
+ ### Setting the active theme
363
+
364
+ When using `@sincpro/mobile`, `createAppShell({ theme })` handles this for you. For standalone DS usage:
365
+
366
+ ```tsx
367
+ import { setActiveTheme, themeToVars } from "@sincpro/mobile-ui/theme";
368
+ import { vars } from "nativewind";
369
+
370
+ // Set globally
371
+ setActiveTheme(MY_DARK_THEME);
372
+
373
+ // Inject CSS variables into a subtree
374
+ const themeStyle = vars(themeToVars(MY_DARK_THEME));
375
+ <View style={themeStyle}>{children}</View>
376
+ ```
377
+
378
+ ### Reading the theme in components
379
+
380
+ ```tsx
381
+ import { useTheme } from "@sincpro/mobile-ui/theme";
382
+
383
+ function PriceCard() {
384
+ const theme = useTheme();
385
+ return <View style={{ backgroundColor: theme.bg.card, borderColor: theme.border.default }} />;
386
+ }
387
+ ```
65
388
 
66
- Configure the logo once; headers/login take it as a fallback:
389
+ ### Defining a custom theme
390
+
391
+ ```tsx
392
+ import { extendTheme } from "@sincpro/mobile-ui/theme";
393
+
394
+ // Deep-merges on top of the DS base theme
395
+ const MY_THEME = extendTheme({
396
+ accent: "#00C357",
397
+ primary: "#0B0B0B",
398
+ secondary: "#6B6963",
399
+ bg: {
400
+ page: "#FAFAFA",
401
+ card: "#FFFFFF",
402
+ },
403
+ text: {
404
+ accent: "#006E2E", // text on accent-tinted surfaces
405
+ },
406
+ });
407
+ ```
408
+
409
+ ### ThemeTokens reference
410
+
411
+ | Group | Keys |
412
+ |-------|------|
413
+ | `primary / secondary / accent` | Main brand color scales |
414
+ | `bg.*` | `page, card, muted, accent, hover, disabled, popover` |
415
+ | `text.*` | `primary, secondary, tertiary, muted, accent, inverse, disabled, onPrimary, onSecondary, onAccent, onDanger, onSuccess` |
416
+ | `icon.*` | `primary, secondary, tertiary, inverse, disabled` |
417
+ | `border.*` | `default, light, strong, focus` |
418
+ | `success / warning / danger / info` | Semantic state colors |
419
+ | `*Light` | Tinted backgrounds for each state |
420
+ | `ring / input` | Focus ring and input border |
421
+ | `gradient.*` | `primary, accent` — arrays of color stops |
422
+ | `shadow.*` | `sm, md, lg` — React Native shadow objects |
423
+
424
+ ---
425
+
426
+ ## Typography System
427
+
428
+ Typography is built on a **role → family** indirection. Components never hardcode a font family — they resolve `fontFamilies[role]` at render time. Calling `configureFonts` changes the mapping globally with no re-render required.
429
+
430
+ ### Variants
431
+
432
+ 24 predefined text style variants via `Typography.Text`:
433
+
434
+ ```tsx
435
+ import { Typography } from "@sincpro/mobile-ui/Typography";
436
+
437
+ <Typography.Text variant="display">Hero text</Typography.Text>
438
+ <Typography.Text variant="h1">Page title</Typography.Text>
439
+ <Typography.Text variant="body">Body paragraph at 14px.</Typography.Text>
440
+ <Typography.Text variant="data">1,204.50</Typography.Text> {/* mono, medium weight */}
441
+ <Typography.Text variant="overline">SECTION LABEL</Typography.Text> {/* UPPERCASE mono */}
442
+ <Typography.Text variant="caption">Metadata</Typography.Text>
443
+ <Typography.Text variant="button">Action Label</Typography.Text>
444
+ ```
445
+
446
+ | Group | Variants |
447
+ |-------|----------|
448
+ | Display | `display` |
449
+ | Headings | `h1` `h2` `h3` `h4` `h5` `h6` |
450
+ | Body | `bodyLarge` `body` `bodySmall` |
451
+ | Labels | `label` `labelSmall` |
452
+ | Data (mono) | `data` `dataLarge` |
453
+ | Meta (mono) | `overline` `caption` `captionSmall` |
454
+ | Buttons | `button` `buttonSmall` `buttonLarge` |
455
+ | Legacy | `title` `subtitle` |
456
+
457
+ ### FontRole
458
+
459
+ Every configurable font slot in the DS:
460
+
461
+ ```
462
+ light · regular · medium · semiBold · extraBold ← body roles (Inter by default)
463
+ mono · monoMedium ← data roles (Fira Code by default)
464
+ titleRegular · titleMedium · title · titleBlack ← display roles (Inter fallback)
465
+ ```
466
+
467
+ `title*` roles default to Inter. Override them with the app's brand display typeface.
468
+
469
+ ### Configuring brand fonts at runtime
470
+
471
+ ```tsx
472
+ import { configureFonts, resetFonts } from "@sincpro/mobile-ui/theme/typography";
473
+
474
+ configureFonts({
475
+ titleRegular: "Satoshi-Regular",
476
+ titleMedium: "Satoshi-Medium",
477
+ title: "Satoshi-Bold",
478
+ titleBlack: "Satoshi-Black",
479
+ });
480
+
481
+ // Reset to defaults — useful in tests and Storybook stories
482
+ resetFonts();
483
+ ```
484
+
485
+ ### Font size scale
486
+
487
+ | Token | `px` | Token | `px` |
488
+ |-------|------|-------|------|
489
+ | `xs` | 10 | `4xl` | 28 |
490
+ | `sm` | 13 | `5xl` | 32 |
491
+ | `base` | 14 | `6xl` | 36 |
492
+ | `lg` | 16 | `7xl` | 48 |
493
+ | `xl` | 18 | `8xl` | 64 |
494
+ | `2xl` | 20 | | |
495
+ | `3xl` | 24 | | |
496
+
497
+ ### Custom text styles
498
+
499
+ ```tsx
500
+ import { createTextStyle, createCustomTextStyle } from "@sincpro/mobile-ui/theme/typography";
501
+
502
+ // From a named variant with overrides
503
+ const errorLabel = createTextStyle("labelSmall", { color: "#FF4D1F" });
504
+
505
+ // Fully custom
506
+ const amountStyle = createCustomTextStyle("4xl", "monoMedium", {
507
+ lineHeight: "none",
508
+ letterSpacing: "tight",
509
+ });
510
+ ```
511
+
512
+ ---
513
+
514
+ ## Motion Tokens
515
+
516
+ Centralized animation constants so motion feels consistent across every screen — not improvised per component:
517
+
518
+ ```tsx
519
+ import { duration, easing, spring } from "@sincpro/mobile-ui/tokens";
520
+
521
+ // Durations (ms)
522
+ duration.instant // 0 — immediate state swap, no transition
523
+ duration.fast // 120 — micro-interactions (button press, toggle)
524
+ duration.normal // 200 — standard transitions (modal, drawer)
525
+ duration.slow // 320 — large layout shifts
526
+ duration.slower // 480 — onboarding, emphasis animations
527
+
528
+ // Bezier easing curves
529
+ easing.standard // general purpose
530
+ easing.decelerate // elements entering the screen
531
+ easing.accelerate // elements leaving the screen
532
+ easing.emphasized // expressive, attention-drawing transitions
533
+
534
+ // Reanimated spring presets
535
+ spring.default // balanced bounce
536
+ spring.gentle // smooth, no overshoot
537
+ spring.snappy // fast with slight bounce
538
+ ```
539
+
540
+ Usage with Reanimated:
541
+
542
+ ```tsx
543
+ import { withTiming, withSpring } from "react-native-reanimated";
544
+ import { duration, easing, spring } from "@sincpro/mobile-ui/tokens";
545
+
546
+ const opacity = withTiming(1, { duration: duration.normal, easing: easing.decelerate });
547
+ const scale = withSpring(1, spring.snappy);
548
+ ```
549
+
550
+ ---
551
+
552
+ ## Branding
553
+
554
+ Configure the app logo once. DS components (`Display.Logo`, `Navigation.AppBar`, `AuthFormView`) use it as a fallback when no explicit `source` prop is passed:
67
555
 
68
556
  ```tsx
69
557
  import { setBranding } from "@sincpro/mobile-ui";
70
- setBranding({ logo: require("../assets/logo.png") }); // Display.Logo uses it when no `source` is passed
558
+
559
+ // Call before rendering the tree
560
+ setBranding({
561
+ logo: require("../assets/logo.png"),
562
+ });
71
563
  ```
72
564
 
73
- ### Toast (queued feedback)
565
+ When using `@sincpro/mobile`, pass `branding` to `createAppShell` — it calls `setBranding` internally.
566
+
567
+ ---
568
+
569
+ ## Tailwind Setup
570
+
571
+ The preset ships the full token class set (`bg-primary`, `text-text-secondary`, `border-border-focus`, …) and the NativeWind v4 configuration.
572
+
573
+ ### `tailwind.config.js`
574
+
575
+ ```js
576
+ module.exports = {
577
+ presets: [require("@sincpro/mobile-ui/tailwind.preset")],
578
+ content: [
579
+ "./App.tsx",
580
+ "./src/**/*.{ts,tsx}",
581
+ "./node_modules/@sincpro/mobile-ui/**/*.js",
582
+ ],
583
+ theme: {
584
+ extend: {
585
+ // Override font families to match loaded font names
586
+ fontFamily: {
587
+ light: ["Inter_300Light"],
588
+ regular: ["Inter_400Regular"],
589
+ medium: ["Inter_500Medium"],
590
+ semibold: ["Inter_600SemiBold"],
591
+ extrabold: ["Inter_800ExtraBold"],
592
+ },
593
+ },
594
+ },
595
+ };
596
+ ```
597
+
598
+ ### Global CSS
599
+
600
+ Import the DS global stylesheet at the root of the app:
74
601
 
75
602
  ```tsx
76
- import { ToastProvider, useToast } from "@sincpro/mobile-ui/Feedback";
77
- // wrap the app in <ToastProvider>; then:
78
- const toast = useToast();
79
- toast.promise(save(), { loading: "Saving…", success: "Done", error: "Failed" });
603
+ import "@sincpro/mobile-ui/theme/globals.css";
80
604
  ```
81
605
 
82
- ### Bottom sheet
606
+ ---
607
+
608
+ ## Import Patterns
609
+
610
+ ### By category subpath — recommended
611
+
612
+ Best tree-shaking; makes the component's category visible at the call site:
613
+
614
+ ```tsx
615
+ import { Display } from "@sincpro/mobile-ui/Display";
616
+ import { Form } from "@sincpro/mobile-ui/Form";
617
+ import { Feedback } from "@sincpro/mobile-ui/Feedback";
618
+ import { Navigation } from "@sincpro/mobile-ui/Navigation";
619
+ import { Dialog } from "@sincpro/mobile-ui/Dialog";
620
+ import { Typography } from "@sincpro/mobile-ui/Typography";
621
+ import { Actions } from "@sincpro/mobile-ui/Actions";
622
+ ```
623
+
624
+ ### Theme and tokens
625
+
626
+ ```tsx
627
+ import { useTheme, setActiveTheme, extendTheme, themeToVars } from "@sincpro/mobile-ui/theme";
628
+ import { duration, easing, spring, motion } from "@sincpro/mobile-ui/tokens";
629
+ ```
630
+
631
+ ### Direct component imports
632
+
633
+ For components outside a namespace:
83
634
 
84
635
  ```tsx
85
636
  import Sheet from "@sincpro/mobile-ui/Dialog/Dialog.Sheet";
86
- // controlled by visible/onClose; closes via the top handle (swipe-down) or your own button.
87
- // Built on RN Modal — no provider or native deps required.
637
+ import { ConfirmationProvider } from "@sincpro/mobile-ui/Dialog";
638
+ import { ToastProvider, useToast } from "@sincpro/mobile-ui/Feedback";
639
+ import { Wizard, useWizard } from "@sincpro/mobile-ui/views/Wizard";
88
640
  ```
89
641
 
90
- ## Conventions
642
+ ### Layout primitives
91
643
 
92
- - **Language:** code, comments, and JSDoc are in **English**. Docs in `docs/` are English.
93
- - **Styling:** NativeWind-first (`className` + tokens). Native/JS escape hatch only where `className`
94
- can't reach (gradients, native props, Modal surfaces). See [`docs/PHILOSOPHY.md`](docs/PHILOSOPHY.md).
95
- - **Backwards compatible:** changes are additive by default; superseded components are kept and marked
96
- `@deprecated` (see `Legacy/*` in Storybook). See [`docs/STRUCTURE.md`](docs/STRUCTURE.md).
644
+ ```tsx
645
+ import { Box, Row, Stack, Spacer } from "@sincpro/mobile-ui/primitives";
646
+ import Container from "@sincpro/mobile-ui/layouts/Container";
647
+ import PlainLayout from "@sincpro/mobile-ui/layouts/PlainLayout";
648
+ import TabNavigatorLayout from "@sincpro/mobile-ui/layouts/TabNavigatorLayout";
649
+ ```
650
+
651
+ ---
97
652
 
98
- ## Storybook (on-device, Expo)
653
+ ## Storybook
99
654
 
100
- Preview components on simulator/device with Storybook React Native (dev-only, not published):
655
+ Every component in the DS has a Storybook story for on-device preview and manual QA. Run it on simulator or physical device:
101
656
 
102
657
  ```bash
103
- make storybook # expo start (Expo Go / dev build)
658
+ make storybook # expo start — Expo Go or dev build
104
659
  make storybook-android # expo run:android
105
660
  ```
106
661
 
107
- Stories live in `.rnstorybook/stories/**/*.stories.tsx`, grouped as **Foundations / Components /
108
- Patterns / Legacy**. The harness (`App.tsx`, `.rnstorybook/`) is excluded from the published package
109
- (`files: ["dist", …]`).
662
+ Stories are organized into four groups:
663
+
664
+ | Group | Contents |
665
+ |-------|----------|
666
+ | **Foundations** | Tokens, typography scale, color palette, motion |
667
+ | **Components** | All compound component namespaces |
668
+ | **Patterns** | Composed examples (list screen, form screen, auth flow) |
669
+ | **Legacy** | Deprecated components kept for backwards compatibility |
670
+
671
+ Stories live in `.rnstorybook/stories/**/*.stories.tsx`. The Storybook harness is excluded from the published package (`files: ["dist", ...]`) and never lands in consumer bundles.
672
+
673
+ ---
110
674
 
111
- ## Build & release
675
+ ## Development
676
+
677
+ | Command | What it does |
678
+ |---------|-------------|
679
+ | `make build` | `tsc` + `tsc-alias` → `dist/` (JS + type declarations) |
680
+ | `make format` | Auto-fix: ESLint + Prettier + type-check |
681
+ | `make verify-format` | CI gate — runs `format`, fails if any file changed |
682
+ | `make storybook` | Start Expo dev server for Storybook |
683
+ | `make publish` | Bump version + `npm publish` |
684
+
685
+ After building, copy the dist to consumers in the local monorepo (no symlinks setup):
686
+
687
+ ```bash
688
+ cp -r dist/* ../sincpro_mobile_tickets/node_modules/@sincpro/mobile-ui/dist/
689
+ ```
112
690
 
113
- `make build` compiles `tsc + tsc-alias → dist/` (JS + types). The `exports` map publishes category
114
- subpaths (`./Display`, `./Form`, …) plus a catch-all `./*`. See [`CHANGELOG.md`](CHANGELOG.md).
691
+ Architectural decisions, naming conventions, and guardrails: [`AGENTS.md`](AGENTS.md).
@@ -4,13 +4,29 @@ import Pressable from "../primitives/Pressable";
4
4
  import { theme } from "../theme";
5
5
  import { cn } from "../theme/tw";
6
6
  import { Typography } from "../Typography";
7
- import { Modal, Pressable as RNPressable, View } from "react-native";
7
+ import { useEffect, useRef } from "react";
8
+ import { Animated, Modal, Pressable as RNPressable, View } from "react-native";
8
9
  import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10
  function ActionSheet({ visible, onClose, actions, title, message, cancelLabel = "Cancelar", }) {
10
11
  const insets = useSafeAreaInsets();
11
- return (_jsx(Modal, { animationType: "slide", onRequestClose: onClose, transparent: true, visible: visible, children: _jsx(RNPressable, { className: "flex-1 justify-end bg-black/50", onPress: onClose, children: _jsxs(RNPressable, { style: { paddingHorizontal: 12, paddingBottom: insets.bottom + 12 }, children: [title || message ? (_jsxs(View, { className: "rounded-xl mb-2 px-4 py-3 items-center", style: { backgroundColor: theme.bg.card }, children: [title ? (_jsx(Typography.Text, { className: "text-text-primary", semibold: true, children: title })) : null, message ? (_jsx(Typography.Text, { className: "text-text-secondary text-sm", variant: "bodySmall", children: message })) : null] })) : null, _jsx(View, { className: "rounded-xl overflow-hidden mb-2", style: { backgroundColor: theme.bg.card }, children: actions.map((action, index) => (_jsxs(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: action.disabled }, className: cn("flex-row items-center justify-center gap-2 px-4 py-4", index < actions.length - 1 && "border-b border-border-light"), disabled: action.disabled, onPress: () => {
12
- onClose();
13
- action.onPress();
14
- }, children: [action.icon ? (_jsx(Icon, { color: action.destructive ? theme.danger : theme.icon.primary, name: action.icon, size: 20 })) : null, _jsx(Typography.Text, { className: cn(action.destructive ? "text-danger" : "text-text-primary"), semibold: true, children: action.label })] }, action.label))) }), _jsx(Pressable, { accessibilityRole: "button", className: "rounded-xl px-4 py-4 items-center", onPress: onClose, style: { backgroundColor: theme.bg.card }, children: _jsx(Typography.Text, { className: "text-primary", semibold: true, children: cancelLabel }) })] }) }) }));
12
+ // eslint-disable-next-line react-hooks/refs
13
+ const translateY = useRef(new Animated.Value(800)).current;
14
+ useEffect(() => {
15
+ if (visible) {
16
+ Animated.spring(translateY, {
17
+ toValue: 0,
18
+ useNativeDriver: true,
19
+ damping: 20,
20
+ stiffness: 300,
21
+ }).start();
22
+ }
23
+ else {
24
+ translateY.setValue(800);
25
+ }
26
+ }, [visible, translateY]);
27
+ return (_jsx(Modal, { animationType: "none", onRequestClose: onClose, presentationStyle: "overFullScreen", transparent: true, visible: visible, children: _jsx(RNPressable, { className: "flex-1 justify-end bg-black/50", onPress: onClose, children: _jsx(Animated.View, { style: { transform: [{ translateY }] }, children: _jsxs(RNPressable, { style: { paddingHorizontal: 12, paddingBottom: insets.bottom + 12 }, children: [title || message ? (_jsxs(View, { className: "rounded-xl mb-2 px-4 py-3 items-center", style: { backgroundColor: theme.bg.card }, children: [title ? (_jsx(Typography.Text, { className: "text-text-primary", semibold: true, children: title })) : null, message ? (_jsx(Typography.Text, { className: "text-text-secondary text-sm", variant: "bodySmall", children: message })) : null] })) : null, _jsx(View, { className: "rounded-xl overflow-hidden mb-2", style: { backgroundColor: theme.bg.card }, children: actions.map((action, index) => (_jsxs(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: action.disabled }, className: cn("flex-row items-center justify-center gap-2 px-4 py-4", index < actions.length - 1 && "border-b border-border-light"), disabled: action.disabled, onPress: () => {
28
+ onClose();
29
+ action.onPress();
30
+ }, children: [action.icon ? (_jsx(Icon, { color: action.destructive ? theme.danger : theme.icon.primary, name: action.icon, size: 20 })) : null, _jsx(Typography.Text, { className: cn(action.destructive ? "text-danger" : "text-text-primary"), semibold: true, children: action.label })] }, action.label))) }), _jsx(Pressable, { accessibilityRole: "button", className: "rounded-xl px-4 py-4 items-center", onPress: onClose, style: { backgroundColor: theme.bg.card }, children: _jsx(Typography.Text, { className: "text-primary", semibold: true, children: cancelLabel }) })] }) }) }) }));
15
31
  }
16
32
  export default ActionSheet;
@@ -4,7 +4,7 @@ import { theme } from "../theme";
4
4
  import Typography from "../Typography/Typography.Text";
5
5
  import { Modal, TouchableWithoutFeedback, View } from "react-native";
6
6
  const ConfirmationDialog = ({ visible, onCancel, onConfirm, auxTitle = "cambios", subtitle = "¿Estás seguro de que quieres descartar los cambios realizados?", confirmButtonText = "Sí", cancelButtonText = "No, continuar aquí", }) => {
7
- return (_jsx(Modal, { animationType: "fade", onRequestClose: onCancel, transparent: true, visible: visible, children: _jsx(TouchableWithoutFeedback, { onPress: onCancel, children: _jsx(View, { className: "flex-1 justify-center items-center p-6 bg-black/50", children: _jsx(TouchableWithoutFeedback, { children: _jsxs(View, { className: "p-3 w-full max-w-xs mx-auto", style: {
7
+ return (_jsx(Modal, { animationType: "fade", onRequestClose: onCancel, presentationStyle: "overFullScreen", transparent: true, visible: visible, children: _jsx(TouchableWithoutFeedback, { onPress: onCancel, children: _jsx(View, { className: "flex-1 justify-center items-center p-6 bg-black/50", children: _jsx(TouchableWithoutFeedback, { children: _jsxs(View, { className: "p-3 w-full max-w-xs mx-auto", style: {
8
8
  borderRadius: 22,
9
9
  backgroundColor: theme.bg.popover,
10
10
  ...theme.shadow.md,
@@ -11,6 +11,6 @@ function EditableValueModal({ visible, title, value, onChangeValue, onConfirm, o
11
11
  return () => clearTimeout(t);
12
12
  }
13
13
  }, [visible, autoFocus]);
14
- return (_jsx(Modal, { animationType: "fade", onRequestClose: onCancel, transparent: true, visible: visible, children: _jsx(View, { className: "flex-1 justify-center p-6 bg-black/40", children: _jsxs(View, { className: "p-4", style: { borderRadius: 22, backgroundColor: theme.bg.popover, ...theme.shadow.md }, testID: testID, children: [_jsx(Typography.Text, { className: "mb-3", semibold: true, variant: "h6", children: title }), _jsxs(View, { className: "flex-row items-center", children: [_jsx(TextInput, { className: "flex-1 border border-input rounded-lg p-2.5 text-lg text-text-primary", keyboardType: keyboardType, onChangeText: onChangeValue, onSubmitEditing: onConfirm, placeholder: placeholder, ref: inputRef, returnKeyType: "done", value: value }), rightAdornment && _jsx(View, { className: "ml-2", children: rightAdornment })] }), helperText ? (_jsx(View, { className: "mt-2", children: typeof helperText === "string" ? (_jsx(Typography.Text, { className: "opacity-70", variant: "bodySmall", children: helperText })) : (helperText) })) : null, chips && chips.length > 0 && (_jsx(FlatList, { contentContainerClassName: "gap-2 py-3", data: chips, horizontal: true, keyExtractor: (c, i) => c.key || `${c.label}-${i}`, renderItem: ({ item }) => (_jsx(TouchableOpacity, { className: "px-3 py-1.5 bg-accent/10 rounded-2xl border border-accent/30", onPress: item.onPress, children: _jsx(Typography.Text, { semibold: true, variant: "bodySmall", children: item.label }) })) })), _jsxs(View, { className: "flex-row justify-end gap-3", children: [_jsx(TouchableOpacity, { className: "px-4 py-2.5 rounded-lg bg-bg-hover", onPress: onCancel, children: _jsx(Typography.Text, { semibold: true, variant: "body", children: cancelLabel }) }), _jsx(TouchableOpacity, { className: "px-4 py-2.5 rounded-lg bg-accent", onPress: onConfirm, children: _jsx(Typography.Text, { className: "text-text-inverse", semibold: true, variant: "body", children: confirmLabel }) })] })] }) }) }));
14
+ return (_jsx(Modal, { animationType: "fade", onRequestClose: onCancel, presentationStyle: "overFullScreen", transparent: true, visible: visible, children: _jsx(View, { className: "flex-1 justify-center p-6 bg-black/40", children: _jsxs(View, { className: "p-4", style: { borderRadius: 22, backgroundColor: theme.bg.popover, ...theme.shadow.md }, testID: testID, children: [_jsx(Typography.Text, { className: "mb-3", semibold: true, variant: "h6", children: title }), _jsxs(View, { className: "flex-row items-center", children: [_jsx(TextInput, { className: "flex-1 border border-input rounded-lg p-2.5 text-lg text-text-primary", keyboardType: keyboardType, onChangeText: onChangeValue, onSubmitEditing: onConfirm, placeholder: placeholder, ref: inputRef, returnKeyType: "done", value: value }), rightAdornment && _jsx(View, { className: "ml-2", children: rightAdornment })] }), helperText ? (_jsx(View, { className: "mt-2", children: typeof helperText === "string" ? (_jsx(Typography.Text, { className: "opacity-70", variant: "bodySmall", children: helperText })) : (helperText) })) : null, chips && chips.length > 0 && (_jsx(FlatList, { contentContainerClassName: "gap-2 py-3", data: chips, horizontal: true, keyExtractor: (c, i) => c.key || `${c.label}-${i}`, renderItem: ({ item }) => (_jsx(TouchableOpacity, { className: "px-3 py-1.5 bg-accent/10 rounded-2xl border border-accent/30", onPress: item.onPress, children: _jsx(Typography.Text, { semibold: true, variant: "bodySmall", children: item.label }) })) })), _jsxs(View, { className: "flex-row justify-end gap-3", children: [_jsx(TouchableOpacity, { className: "px-4 py-2.5 rounded-lg bg-bg-hover", onPress: onCancel, children: _jsx(Typography.Text, { semibold: true, variant: "body", children: cancelLabel }) }), _jsx(TouchableOpacity, { className: "px-4 py-2.5 rounded-lg bg-accent", onPress: onConfirm, children: _jsx(Typography.Text, { className: "text-text-inverse", semibold: true, variant: "body", children: confirmLabel }) })] })] }) }) }));
15
15
  }
16
16
  export default EditableValueModal;
@@ -18,13 +18,22 @@ export function SheetProvider({ children }) {
18
18
  */
19
19
  function Sheet({ visible, onClose, children, title, maxHeightRatio = 0.9, dismissOnBackdrop = false, testID, }) {
20
20
  const insets = useSafeAreaInsets();
21
- const [translateY] = useState(() => new Animated.Value(0));
21
+ const translateY = useRef(new Animated.Value(800)).current;
22
22
  const onCloseRef = useRef(onClose);
23
23
  // eslint-disable-next-line react-hooks/refs
24
24
  onCloseRef.current = onClose;
25
25
  useEffect(() => {
26
- if (visible)
27
- translateY.setValue(0);
26
+ if (visible) {
27
+ Animated.spring(translateY, {
28
+ toValue: 0,
29
+ useNativeDriver: true,
30
+ damping: 20,
31
+ stiffness: 300,
32
+ }).start();
33
+ }
34
+ else {
35
+ translateY.setValue(800);
36
+ }
28
37
  }, [visible, translateY]);
29
38
  // eslint-disable-next-line react-hooks/refs
30
39
  const [pan] = useState(() => PanResponder.create({
@@ -47,7 +56,7 @@ function Sheet({ visible, onClose, children, title, maxHeightRatio = 0.9, dismis
47
56
  }
48
57
  },
49
58
  }));
50
- return (_jsx(Modal, { animationType: "slide", onRequestClose: onClose, transparent: true, visible: visible, children: _jsxs(View, { className: "flex-1 justify-end", testID: testID, children: [_jsx(View, { className: "absolute inset-0 bg-black/50", onResponderRelease: dismissOnBackdrop ? onClose : undefined, onStartShouldSetResponder: () => dismissOnBackdrop }), _jsxs(Animated.View, { className: "rounded-t-3xl", style: {
59
+ return (_jsx(Modal, { animationType: "none", onRequestClose: onClose, presentationStyle: "overFullScreen", transparent: true, visible: visible, children: _jsxs(View, { className: "flex-1 justify-end", testID: testID, children: [_jsx(View, { className: "absolute inset-0 bg-black/50", onResponderRelease: dismissOnBackdrop ? onClose : undefined, onStartShouldSetResponder: () => dismissOnBackdrop }), _jsxs(Animated.View, { className: "rounded-t-3xl", style: {
51
60
  backgroundColor: theme.bg.card,
52
61
  paddingBottom: insets.bottom + 12,
53
62
  maxHeight: `${Math.round(maxHeightRatio * 100)}%`,
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx } from "nativewind/jsx-runtime";
2
2
  import { cn } from "../theme/tw";
3
3
  import { motion } from "../tokens/motion";
4
- import { useEffect, useState } from "react";
4
+ import { useEffect, useRef } from "react";
5
5
  import { Animated, Easing, View } from "react-native";
6
6
  function clamp(n) {
7
7
  return Math.max(0, Math.min(1, n));
8
8
  }
9
9
  function Progress({ value = 0, indeterminate = false, height = 6, className, testID, }) {
10
- const [translate] = useState(() => new Animated.Value(0));
10
+ // eslint-disable-next-line react-hooks/refs
11
+ const translate = useRef(new Animated.Value(0)).current;
11
12
  useEffect(() => {
12
13
  if (!indeterminate)
13
14
  return;
@@ -24,6 +25,7 @@ function Progress({ value = 0, indeterminate = false, height = 6, className, tes
24
25
  width: "40%",
25
26
  transform: [
26
27
  {
28
+ // eslint-disable-next-line react-hooks/refs
27
29
  translateX: translate.interpolate({
28
30
  inputRange: [0, 1],
29
31
  outputRange: ["-40%", "250%"],
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx } from "nativewind/jsx-runtime";
2
2
  import { cn } from "../theme/tw";
3
3
  import { motion } from "../tokens/motion";
4
- import { useEffect, useState } from "react";
4
+ import { useEffect, useRef } from "react";
5
5
  import { Animated, Easing } from "react-native";
6
6
  function Skeleton({ width = "100%", height = 16, radius = 8, circle = false, className, style, }) {
7
- const [opacity] = useState(() => new Animated.Value(0.5));
7
+ // eslint-disable-next-line react-hooks/refs
8
+ const opacity = useRef(new Animated.Value(0.5)).current;
8
9
  useEffect(() => {
9
10
  const pulse = Animated.loop(Animated.sequence([
10
11
  Animated.timing(opacity, {
@@ -77,7 +77,7 @@ function DropdownButton({ title, options, variant = "secondary", size = "small",
77
77
  xs: "bodySmall",
78
78
  small: "bodySmall",
79
79
  }[size];
80
- return (_jsxs(_Fragment, { children: [_jsxs(TouchableOpacity, { activeOpacity: 0.7, className: styles.button(), onPress: () => setIsOpen(true), children: [_jsx(Typography.Text, { className: textColorClass, variant: textVariant, children: title }), _jsx(Display.Icon, { color: iconColor, name: "chevron-down", size: 16, type: "feather" })] }), _jsx(Modal, { animationType: "fade", transparent: true, visible: isOpen, children: _jsx(TouchableWithoutFeedback, { onPress: () => setIsOpen(false), children: _jsx(View, { className: styles.overlay(), children: _jsx(TouchableWithoutFeedback, { children: _jsxs(View, { className: styles.dropdownContainer(), style: {
80
+ return (_jsxs(_Fragment, { children: [_jsxs(TouchableOpacity, { activeOpacity: 0.7, className: styles.button(), onPress: () => setIsOpen(true), children: [_jsx(Typography.Text, { className: textColorClass, variant: textVariant, children: title }), _jsx(Display.Icon, { color: iconColor, name: "chevron-down", size: 16, type: "feather" })] }), _jsx(Modal, { animationType: "fade", presentationStyle: "overFullScreen", transparent: true, visible: isOpen, children: _jsx(TouchableWithoutFeedback, { onPress: () => setIsOpen(false), children: _jsx(View, { className: styles.overlay(), children: _jsx(TouchableWithoutFeedback, { children: _jsxs(View, { className: styles.dropdownContainer(), style: {
81
81
  backgroundColor: theme.bg.popover,
82
82
  ...theme.shadow.lg,
83
83
  }, children: [_jsxs(View, { className: styles.header(), children: [_jsx(Typography.Text, { className: "text-text-primary", semibold: true, variant: "bodyLarge", children: title }), _jsx(TouchableOpacity, { onPress: () => setIsOpen(false), children: _jsx(Display.Icon, { name: "x", size: 20, type: "feather" }) })] }), _jsx(View, { className: styles.optionsList(), children: options.map((option, index) => (_jsxs(View, { children: [_jsx(TouchableOpacity, { activeOpacity: 0.7, className: dropdown({ disabled: option.disabled }).option(), disabled: option.disabled, onPress: () => handleOptionPress(option), children: _jsx(Typography.Text, { className: option.disabled ? "text-text-disabled" : "text-text-primary", variant: "body", children: option.title }) }), index < options.length - 1 && _jsx(View, { className: styles.separator() })] }, option.id))) })] }) }) }) }) })] }));
@@ -4,23 +4,38 @@ import Pressable from "../primitives/Pressable";
4
4
  import { theme } from "../theme";
5
5
  import { cn } from "../theme/tw";
6
6
  import { Typography } from "../Typography";
7
- import { useState } from "react";
8
- import { Modal, Pressable as RNPressable, ScrollView, View } from "react-native";
7
+ import { useEffect, useRef, useState } from "react";
8
+ import { Animated, Modal, Pressable as RNPressable, ScrollView, View } from "react-native";
9
9
  import { useSafeAreaInsets } from "react-native-safe-area-context";
10
10
  function Select({ options, value, onChange, label, placeholder = "Seleccionar…", title, disabled = false, className, testID, }) {
11
11
  const insets = useSafeAreaInsets();
12
12
  const [open, setOpen] = useState(false);
13
13
  const selected = options.find((o) => o.value === value) ?? null;
14
- return (_jsxs(View, { className: cn("gap-1", className), testID: testID, children: [label ? (_jsx(Typography.Text, { className: "font-medium text-text-primary", variant: "bodySmall", children: label })) : null, _jsxs(Pressable, { accessibilityHint: "Abre la lista de opciones", accessibilityLabel: label ?? placeholder, accessibilityRole: "button", accessibilityState: { disabled }, className: "flex-row items-center justify-between rounded-lg border border-input bg-bg-card px-3 min-h-[44px]", disabled: disabled, onPress: () => setOpen(true), children: [_jsx(Typography.Text, { className: cn("flex-1 text-sm", selected ? "text-text-primary" : "text-text-tertiary"), numberOfLines: 1, children: selected ? selected.label : placeholder }), _jsx(Icon, { color: theme.icon.secondary, name: "chevron-down", size: 18 })] }), _jsx(Modal, { animationType: "slide", onRequestClose: () => setOpen(false), transparent: true, visible: open, children: _jsx(RNPressable, { className: "flex-1 justify-end bg-black/50", onPress: () => setOpen(false), children: _jsxs(RNPressable, { className: "rounded-t-3xl", style: {
15
- backgroundColor: theme.bg.card,
16
- paddingBottom: insets.bottom + 8,
17
- maxHeight: "70%",
18
- }, children: [_jsx(View, { className: "items-center pt-3 pb-1", children: _jsx(View, { className: "w-10 h-1 rounded-full bg-border-strong" }) }), title ? (_jsx(Typography.Text, { className: "text-text-primary px-4 py-2", semibold: true, variant: "body", children: title })) : null, _jsx(ScrollView, { children: options.map((option) => {
19
- const isSelected = option.value === value;
20
- return (_jsxs(Pressable, { accessibilityRole: "menuitem", accessibilityState: { selected: isSelected }, className: "flex-row items-center justify-between px-4 py-3 border-b border-border-light", onPress: () => {
21
- onChange(option.value);
22
- setOpen(false);
23
- }, children: [_jsx(Typography.Text, { className: cn("flex-1 text-sm", isSelected ? "text-primary font-semibold" : "text-text-primary"), children: option.label }), isSelected ? (_jsx(Icon, { color: theme.primary, name: "checkmark", size: 18 })) : null] }, String(option.value)));
24
- }) })] }) }) })] }));
14
+ // eslint-disable-next-line react-hooks/refs
15
+ const translateY = useRef(new Animated.Value(800)).current;
16
+ useEffect(() => {
17
+ if (open) {
18
+ Animated.spring(translateY, {
19
+ toValue: 0,
20
+ useNativeDriver: true,
21
+ damping: 20,
22
+ stiffness: 300,
23
+ }).start();
24
+ }
25
+ else {
26
+ translateY.setValue(800);
27
+ }
28
+ }, [open, translateY]);
29
+ return (_jsxs(View, { className: cn("gap-1", className), testID: testID, children: [label ? (_jsx(Typography.Text, { className: "font-medium text-text-primary", variant: "bodySmall", children: label })) : null, _jsxs(Pressable, { accessibilityHint: "Abre la lista de opciones", accessibilityLabel: label ?? placeholder, accessibilityRole: "button", accessibilityState: { disabled }, className: "flex-row items-center justify-between rounded-lg border border-input bg-bg-card px-3 min-h-[44px]", disabled: disabled, onPress: () => setOpen(true), children: [_jsx(Typography.Text, { className: cn("flex-1 text-sm", selected ? "text-text-primary" : "text-text-tertiary"), numberOfLines: 1, children: selected ? selected.label : placeholder }), _jsx(Icon, { color: theme.icon.secondary, name: "chevron-down", size: 18 })] }), _jsx(Modal, { animationType: "none", onRequestClose: () => setOpen(false), presentationStyle: "overFullScreen", transparent: true, visible: open, children: _jsx(RNPressable, { className: "flex-1 justify-end bg-black/50", onPress: () => setOpen(false), children: _jsx(Animated.View, { style: { transform: [{ translateY }] }, children: _jsxs(RNPressable, { className: "rounded-t-3xl", style: {
30
+ backgroundColor: theme.bg.card,
31
+ paddingBottom: insets.bottom + 8,
32
+ maxHeight: "70%",
33
+ }, children: [_jsx(View, { className: "items-center pt-3 pb-1", children: _jsx(View, { className: "w-10 h-1 rounded-full bg-border-strong" }) }), title ? (_jsx(Typography.Text, { className: "text-text-primary px-4 py-2", semibold: true, variant: "body", children: title })) : null, _jsx(ScrollView, { children: options.map((option) => {
34
+ const isSelected = option.value === value;
35
+ return (_jsxs(Pressable, { accessibilityRole: "menuitem", accessibilityState: { selected: isSelected }, className: "flex-row items-center justify-between px-4 py-3 border-b border-border-light", onPress: () => {
36
+ onChange(option.value);
37
+ setOpen(false);
38
+ }, children: [_jsx(Typography.Text, { className: cn("flex-1 text-sm", isSelected ? "text-primary font-semibold" : "text-text-primary"), children: option.label }), isSelected ? (_jsx(Icon, { color: theme.primary, name: "checkmark", size: 18 })) : null] }, String(option.value)));
39
+ }) })] }) }) }) })] }));
25
40
  }
26
41
  export default Select;
@@ -21,7 +21,7 @@ function Menu({ items, trigger, menuWidth = 220, testID }) {
21
21
  const top = anchor ? anchor.y + anchor.height + 4 : 0;
22
22
  return (_jsxs(View, { collapsable: false, ref: triggerRef, testID: testID, children: [trigger ? (
23
23
  // eslint-disable-next-line react-hooks/refs
24
- cloneElement(trigger, { onPress: open })) : (_jsx(Pressable, { accessibilityLabel: "M\u00E1s opciones", accessibilityRole: "button", onPress: open, children: _jsx(Icon, { color: theme.icon.secondary, name: "ellipsis-vertical", size: 22 }) })), _jsx(Modal, { animationType: "fade", onRequestClose: close, transparent: true, visible: anchor !== null, children: _jsx(RNPressable, { onPress: close, style: { flex: 1 }, children: _jsx(View, { className: "rounded-lg py-1", style: {
24
+ cloneElement(trigger, { onPress: open })) : (_jsx(Pressable, { accessibilityLabel: "M\u00E1s opciones", accessibilityRole: "button", onPress: open, children: _jsx(Icon, { color: theme.icon.secondary, name: "ellipsis-vertical", size: 22 }) })), _jsx(Modal, { animationType: "fade", onRequestClose: close, presentationStyle: "overFullScreen", transparent: true, visible: anchor !== null, children: _jsx(RNPressable, { onPress: close, style: { flex: 1 }, children: _jsx(View, { className: "rounded-lg py-1", style: {
25
25
  position: "absolute",
26
26
  top,
27
27
  right,
@@ -1,13 +1,14 @@
1
+ import { useFonts } from "@expo-google-fonts/inter";
1
2
  import type { TextStyle } from "react-native";
2
3
  export type FontRole = "light" | "regular" | "medium" | "semiBold" | "extraBold" | "mono" | "monoMedium" | "titleRegular" | "titleMedium" | "title" | "titleBlack";
3
4
  export declare const fontFamilies: Record<FontRole, string>;
4
5
  /**
5
- * Configura las familias de fuente del DS (rol → nombre de familia ya cargada por la app).
6
- * Mutación in-place del singleton, así los componentes la leen en vivo. Ejemplo:
7
- * `configureFonts({ title: "Satoshi-Bold", titleBlack: "Satoshi-Black" })`.
6
+ * Override DS font family names (role → already-loaded family name).
7
+ * Mutates the singleton in-place so components pick up the change at render time.
8
+ * Example: `configureFonts({ title: "Satoshi-Bold", titleBlack: "Satoshi-Black" })`.
8
9
  */
9
10
  export declare function configureFonts(overrides: Partial<Record<FontRole, string>>): void;
10
- /** Restablece las familias a los defaults (Inter + Fira Code). Útil en tests/stories. */
11
+ /** Reset all font roles to the defaults (Inter + Fira Code). Useful in tests and stories. */
11
12
  export declare function resetFonts(): void;
12
13
  export declare const fontWeights: {
13
14
  readonly light: "300";
@@ -216,6 +217,7 @@ export declare const createCustomTextStyle: (fontSize: keyof typeof fontSizes, f
216
217
  letterSpacing?: keyof typeof letterSpacing;
217
218
  color?: string;
218
219
  }) => TextStyle;
220
+ export { useFonts };
219
221
  export declare const useAppFonts: () => boolean;
220
222
  export declare const Typography: {
221
223
  readonly fontFamilies: Record<FontRole, string>;
@@ -8,7 +8,7 @@ const DEFAULT_FONT_FAMILIES = {
8
8
  extraBold: "Inter_800ExtraBold",
9
9
  mono: "FiraCode_400Regular",
10
10
  monoMedium: "FiraCode_500Medium",
11
- // Título: default Inter (seguro). Storybook/app lo apunta a Satoshi vía configureFonts.
11
+ // Title roles default to Inter until the app wires its brand typeface via configureFonts.
12
12
  titleRegular: "Inter_400Regular",
13
13
  titleMedium: "Inter_500Medium",
14
14
  title: "Inter_600SemiBold",
@@ -16,14 +16,14 @@ const DEFAULT_FONT_FAMILIES = {
16
16
  };
17
17
  export const fontFamilies = { ...DEFAULT_FONT_FAMILIES };
18
18
  /**
19
- * Configura las familias de fuente del DS (rol → nombre de familia ya cargada por la app).
20
- * Mutación in-place del singleton, así los componentes la leen en vivo. Ejemplo:
21
- * `configureFonts({ title: "Satoshi-Bold", titleBlack: "Satoshi-Black" })`.
19
+ * Override DS font family names (role → already-loaded family name).
20
+ * Mutates the singleton in-place so components pick up the change at render time.
21
+ * Example: `configureFonts({ title: "Satoshi-Bold", titleBlack: "Satoshi-Black" })`.
22
22
  */
23
23
  export function configureFonts(overrides) {
24
24
  Object.assign(fontFamilies, overrides);
25
25
  }
26
- /** Restablece las familias a los defaults (Inter + Fira Code). Útil en tests/stories. */
26
+ /** Reset all font roles to the defaults (Inter + Fira Code). Useful in tests and stories. */
27
27
  export function resetFonts() {
28
28
  Object.assign(fontFamilies, DEFAULT_FONT_FAMILIES);
29
29
  }
@@ -75,7 +75,7 @@ export const letterSpacing = {
75
75
  };
76
76
  /* -------------------- Typography Variants -------------------- */
77
77
  export const typographyVariants = {
78
- // Display (Satoshi Black — carácter de marca)
78
+ // Display (Satoshi Black — brand character)
79
79
  display: {
80
80
  fontSize: fontSizes["7xl"],
81
81
  fontFamily: fontFamilies.titleBlack,
@@ -163,7 +163,7 @@ export const typographyVariants = {
163
163
  lineHeight: fontSizes.sm * lineHeights.normal,
164
164
  letterSpacing: letterSpacing.wide,
165
165
  },
166
- // Data — cifras, montos, SKU, % (mono Fira Code, peso medio). DS spec rol "data".
166
+ // Data — amounts, SKU, % (Fira Code mono, medium weight). DS role "data".
167
167
  data: {
168
168
  fontSize: fontSizes.lg,
169
169
  fontFamily: fontFamilies.monoMedium,
@@ -178,7 +178,7 @@ export const typographyVariants = {
178
178
  lineHeight: fontSizes["4xl"] * lineHeights.none,
179
179
  letterSpacing: letterSpacing.tight,
180
180
  },
181
- // Overline — labels/metadata en mono MAYÚSCULAS con tracking. DS spec rol "caption".
181
+ // Overline — UPPERCASE labels / metadata in mono with tracking. DS role "caption".
182
182
  overline: {
183
183
  fontSize: 11,
184
184
  fontFamily: fontFamilies.mono,
@@ -187,7 +187,7 @@ export const typographyVariants = {
187
187
  letterSpacing: 0.6,
188
188
  textTransform: "uppercase",
189
189
  },
190
- // Caption — Fira Code mono (secundario, metadata, labels de datos)
190
+ // Caption — Fira Code mono (secondary text, metadata, data labels)
191
191
  caption: {
192
192
  fontSize: fontSizes.sm,
193
193
  fontFamily: fontFamilies.mono,
@@ -241,9 +241,9 @@ export const typographyVariants = {
241
241
  },
242
242
  };
243
243
  /* -------------------- Variant → Font Role --------------------
244
- * Cada variante apunta a un ROL de fuente (no a una familia fija), para que
245
- * `configureFonts` la cambie en vivo. `Typography.Text` resuelve `fontFamilies[rol]`
246
- * en render. La familia "baked" de cada variante arriba queda solo como fallback. */
244
+ * Each variant points to a ROLE (not a fixed family) so `configureFonts` can
245
+ * update it at runtime. `Typography.Text` resolves `fontFamilies[role]` on
246
+ * render. The baked-in family in each variant above is a static fallback only. */
247
247
  export const variantFontRole = {
248
248
  display: "titleBlack",
249
249
  h1: "titleBlack",
@@ -286,10 +286,13 @@ export const createCustomTextStyle = (fontSize, fontFamily, options) => {
286
286
  ...(options?.color && { color: options.color }),
287
287
  };
288
288
  };
289
- /* -------------------- Font Loader (opcional, conveniencia) --------------------
290
- * Carga las fuentes recomendadas (Inter + Fira Code), que la app instala como peer deps.
291
- * El rol "title" (Satoshi u otra) lo carga y enchufa la app/Storybook por su cuenta vía
292
- * `configureFonts` — la librería NO bundlea archivos de fuente. */
289
+ /* Re-export useFonts so consumers (e.g. the framework) can load fonts without
290
+ * depending directly on expo-font — it's already a transitive dep via this package. */
291
+ export { useFonts };
292
+ /* -------------------- Font Loader (optional, convenience) ------------------
293
+ * Loads the recommended base fonts (Inter + Fira Code) declared as peer deps.
294
+ * The "title" role (e.g. Satoshi) is loaded and wired by the app/Storybook
295
+ * via `configureFonts` — the DS never bundles font files. */
293
296
  export const useAppFonts = () => {
294
297
  const [loaded] = useFonts({
295
298
  FiraCode_400Regular,
@@ -25,7 +25,7 @@ export function PromoModal({ visible, onDismiss, title, body, icon: Icon, header
25
25
  const theme = useTheme();
26
26
  const insets = useSafeAreaInsets();
27
27
  const accent = accentColor ?? theme.accent;
28
- return (_jsxs(Modal, { animationType: "fade", onRequestClose: onDismiss, statusBarTranslucent: true, transparent: true, visible: visible, children: [_jsx(TouchableWithoutFeedback, { onPress: onDismiss, children: _jsx(View, { style: {
28
+ return (_jsxs(Modal, { animationType: "fade", onRequestClose: onDismiss, presentationStyle: "overFullScreen", statusBarTranslucent: true, transparent: true, visible: visible, children: [_jsx(TouchableWithoutFeedback, { onPress: onDismiss, children: _jsx(View, { style: {
29
29
  flex: 1,
30
30
  backgroundColor: "rgba(0,0,0,0.55)",
31
31
  alignItems: "center",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sincpro/mobile-ui",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Sincpro Mobile design system: generic, presentational React Native components (no domain logic)",
5
5
  "main": "expo/AppEntry.js",
6
6
  "types": "./dist/index.d.ts",
@@ -50,7 +50,6 @@
50
50
  "peerDependencies": {
51
51
  "@expo-google-fonts/fira-code": "*",
52
52
  "@expo-google-fonts/inter": "*",
53
- "@expo-google-fonts/montserrat": "*",
54
53
  "@expo/ui": "*",
55
54
  "@expo/vector-icons": "*",
56
55
  "@react-native-community/datetimepicker": "*",
@@ -85,7 +84,6 @@
85
84
  "devDependencies": {
86
85
  "@expo-google-fonts/fira-code": "^0.4.1",
87
86
  "@expo-google-fonts/inter": "^0.4.2",
88
- "@expo-google-fonts/montserrat": "^0.4.2",
89
87
  "@expo/ui": "~56.0.18",
90
88
  "@expo/vector-icons": "^15.0.3",
91
89
  "@gorhom/bottom-sheet": "^5",