orn-ui 0.2.5 → 0.2.7
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/AGENTS.md +354 -0
- package/README.md +43 -13
- package/lib/commonjs/organisms/DateField.js +1 -1
- package/lib/commonjs/organisms/DateField.js.map +1 -1
- package/lib/module/organisms/DateField.js +1 -1
- package/lib/module/organisms/DateField.js.map +1 -1
- package/lib/typescript/atoms/Badge.d.ts +1 -1
- package/lib/typescript/atoms/Badge.d.ts.map +1 -1
- package/lib/typescript/atoms/Image.d.ts +6 -6
- package/lib/typescript/atoms/Image.d.ts.map +1 -1
- package/lib/typescript/atoms/Input.d.ts +1 -1
- package/lib/typescript/atoms/Input.d.ts.map +1 -1
- package/lib/typescript/atoms/PressableScale.d.ts +1 -1
- package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
- package/lib/typescript/atoms/Skeleton.d.ts +5 -5
- package/lib/typescript/atoms/Skeleton.d.ts.map +1 -1
- package/lib/typescript/atoms/Spinner.d.ts +4 -4
- package/lib/typescript/atoms/Spinner.d.ts.map +1 -1
- package/lib/typescript/atoms/Transition.d.ts +10 -10
- package/lib/typescript/atoms/Transition.d.ts.map +1 -1
- package/lib/typescript/molecules/AvatarHeader.d.ts +2 -2
- package/lib/typescript/molecules/AvatarHeader.d.ts.map +1 -1
- package/lib/typescript/molecules/FormActions.d.ts +1 -1
- package/lib/typescript/molecules/FormActions.d.ts.map +1 -1
- package/lib/typescript/molecules/OptionCard.d.ts +2 -2
- package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
- package/lib/typescript/molecules/Stepper.d.ts +8 -8
- package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
- package/lib/typescript/molecules/Steps.d.ts +3 -3
- package/lib/typescript/molecules/Steps.d.ts.map +1 -1
- package/lib/typescript/molecules/SymmetricGrid.d.ts +4 -4
- package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -1
- package/lib/typescript/organisms/BottomSheet.d.ts +8 -8
- package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/organisms/DateField.d.ts +10 -10
- package/lib/typescript/organisms/DateField.d.ts.map +1 -1
- package/lib/typescript/organisms/DatePicker.d.ts +10 -10
- package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
- package/lib/typescript/organisms/List.d.ts +6 -6
- package/lib/typescript/organisms/List.d.ts.map +1 -1
- package/lib/typescript/organisms/NavigationBar.d.ts +2 -2
- package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
- package/lib/typescript/organisms/ReorderableList.d.ts +7 -7
- package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -1
- package/lib/typescript/organisms/Screen.d.ts +6 -6
- package/lib/typescript/organisms/SearchList.d.ts +8 -8
- package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
- package/lib/typescript/organisms/Select.d.ts +2 -2
- package/lib/typescript/organisms/Select.d.ts.map +1 -1
- package/lib/typescript/organisms/Toast.d.ts +2 -2
- package/lib/typescript/organisms/Toast.d.ts.map +1 -1
- package/lib/typescript/organisms/ToastProvider.d.ts +2 -2
- package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/Wizard.d.ts +7 -7
- package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
- package/lib/typescript/theme/UIProvider.d.ts +9 -9
- package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
- package/llms.txt +33 -0
- package/package.json +24 -6
- package/registry/avatar-header.json +1 -1
- package/registry/badge.json +1 -1
- package/registry/bottom-sheet.json +1 -1
- package/registry/core.json +1 -1
- package/registry/date-field.json +1 -1
- package/registry/date-picker.json +1 -1
- package/registry/form-actions.json +1 -1
- package/registry/image.json +1 -1
- package/registry/index.json +26 -26
- package/registry/input.json +1 -1
- package/registry/list.json +1 -1
- package/registry/navigation-bar.json +1 -1
- package/registry/option-card.json +1 -1
- package/registry/pressable-scale.json +1 -1
- package/registry/reorderable-list.json +1 -1
- package/registry/screen.json +1 -1
- package/registry/search-list.json +1 -1
- package/registry/select.json +1 -1
- package/registry/skeleton.json +1 -1
- package/registry/spinner.json +1 -1
- package/registry/stepper.json +1 -1
- package/registry/steps.json +1 -1
- package/registry/symmetric-grid.json +1 -1
- package/registry/toast-provider.json +1 -1
- package/registry/toast.json +1 -1
- package/registry/transition.json +1 -1
- package/registry/wizard.json +1 -1
- package/src/atoms/Badge.tsx +1 -1
- package/src/atoms/Image.tsx +6 -6
- package/src/atoms/Input.tsx +1 -1
- package/src/atoms/PressableScale.tsx +1 -1
- package/src/atoms/Skeleton.tsx +5 -5
- package/src/atoms/Spinner.tsx +4 -4
- package/src/atoms/Transition.tsx +10 -10
- package/src/molecules/AvatarHeader.tsx +2 -2
- package/src/molecules/FormActions.tsx +1 -1
- package/src/molecules/OptionCard.tsx +2 -2
- package/src/molecules/Stepper.tsx +8 -8
- package/src/molecules/Steps.tsx +3 -3
- package/src/molecules/SymmetricGrid.tsx +4 -4
- package/src/organisms/BottomSheet.tsx +8 -8
- package/src/organisms/DateField.tsx +11 -11
- package/src/organisms/DatePicker.tsx +10 -10
- package/src/organisms/List.tsx +6 -6
- package/src/organisms/NavigationBar.tsx +2 -2
- package/src/organisms/ReorderableList.tsx +7 -7
- package/src/organisms/Screen.tsx +6 -6
- package/src/organisms/SearchList.tsx +8 -8
- package/src/organisms/Select.tsx +2 -2
- package/src/organisms/Toast.tsx +2 -2
- package/src/organisms/ToastProvider.tsx +2 -2
- package/src/organisms/Wizard.tsx +7 -7
- package/src/theme/UIProvider.tsx +9 -9
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
{
|
|
14
14
|
"path": "organisms/DatePicker.tsx",
|
|
15
|
-
"content": "import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useLabels } from '../theme/UIProvider';\nimport { IconButton } from '../atoms/IconButton';\n\nexport const DEFAULT_MONTH_NAMES = [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n];\n\n/** Índice 0 = domingo, para alinear con Date.getDay(). */\nexport const DEFAULT_WEEKDAY_NAMES = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];\n\nexport interface DateRange {\n start: Date;\n /** Falta mientras el rango está a medio elegir (sólo se tocó el inicio). */\n end?: Date;\n}\n\nexport interface DatePickerProps {\n /**\n * 'single' elige una fecha; 'range' elige un intervalo con dos toques.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Fecha elegida en mode=\"single\". */\n value?: Date;\n /** Se llama en mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Rango elegido en mode=\"range\". */\n range?: DateRange;\n /** Se llama en mode=\"range\", tanto al fijar el inicio como al cerrar el rango. */\n onRangeChange?: (range: DateRange) => void;\n minDate?: Date;\n maxDate?: Date;\n /** Mes mostrado al abrir si no hay `value`. @default hoy */\n defaultMonth?: Date;\n /** Nombres de los meses. @default los de `useLabels()` */\n monthNames?: string[];\n /** Iniciales de los días, índice 0 = domingo. @default las de `useLabels()` */\n weekdayNames?: string[];\n /** 0 = domingo, 1 = lunes. @default 0 */\n firstDayOfWeek?: 0 | 1;\n /** Avisa qué mes quedó a la vista al navegar con las flechas. */\n onVisibleMonthChange?: (month: Date) => void;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nfunction startOfDay(d: Date): Date {\n return new Date(d.getFullYear(), d.getMonth(), d.getDate());\n}\n\n/** Timestamp del día, para comparar sin volver a crear Date en cada celda. */\nfunction dayTime(d: Date): number {\n return startOfDay(d).getTime();\n}\n\nfunction dayTimeOf(d: Date | undefined): number | undefined {\n return d ? dayTime(d) : undefined;\n}\n\n/** Milisegundos que faltan para la próxima medianoche local. */\nfunction msUntilNextMidnight(from: Date): number {\n const next = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 1);\n return next.getTime() - from.getTime();\n}\n\n/** Número de mes absoluto (año * 12 + mes), para comparar meses con un `<`. */\nfunction monthIndex(d: Date): number {\n return d.getFullYear() * 12 + d.getMonth();\n}\n\n/**\n * Devuelve la grilla del mes: 6 filas de 7 celdas. Las celdas fuera del mes\n * son null, así el layout no salta de alto entre meses (un mes puede\n * necesitar 4, 5 o 6 filas según en qué día caiga el 1).\n */\nexport function buildMonthGrid(year: number, month: number, firstDayOfWeek: 0 | 1): (Date | null)[][] {\n const first = new Date(year, month, 1);\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n const leading = (first.getDay() - firstDayOfWeek + 7) % 7;\n\n const cells: (Date | null)[] = [];\n for (let i = 0; i < leading; i++) cells.push(null);\n for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));\n while (cells.length < 42) cells.push(null);\n\n const rows: (Date | null)[][] = [];\n for (let r = 0; r < 6; r++) rows.push(cells.slice(r * 7, r * 7 + 7));\n return rows;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: {\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n padding: theme.tokens.spacing.md,\n },\n header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: theme.tokens.spacing.sm },\n monthLabel: { fontSize: theme.tokens.fontSize.lg, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n weekRow: { flexDirection: 'row' },\n weekday: {\n flex: 1,\n textAlign: 'center',\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.textLight,\n paddingVertical: theme.tokens.spacing.sm,\n },\n row: { flexDirection: 'row' },\n cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },\n dayButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },\n dayText: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n daySelected: { backgroundColor: theme.colors.primary },\n dayTextSelected: { color: theme.colors.onPrimary, fontWeight: theme.tokens.fontWeight.bold },\n // El resaltado del tramo intermedio va en la celda (cuadrada, sin gaps) y\n // no en el botón redondo, así la barra del rango se ve continua.\n cellInRange: { backgroundColor: theme.colors.primarySoft },\n cellRangeStart: { borderTopLeftRadius: 18, borderBottomLeftRadius: 18 },\n cellRangeEnd: { borderTopRightRadius: 18, borderBottomRightRadius: 18 },\n dayTextInRange: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n dayToday: { borderWidth: 1, borderColor: theme.colors.primary },\n dayTextDisabled: { color: theme.colors.textLight, opacity: 0.4 },\n}));\n\ntype Styles = ReturnType<typeof useStyles>;\n\ninterface DayCellProps {\n day: Date;\n label: string;\n selected: boolean;\n inRange: boolean;\n isRangeStart: boolean;\n isRangeEnd: boolean;\n isToday: boolean;\n disabled: boolean;\n allowFontScaling: boolean;\n styles: Styles;\n onPress: (day: Date) => void;\n}\n\n/**\n * Una celda del calendario. Va memoizada aparte porque el mes entero son 42\n * celdas: sin esto, cualquier render del padre reconcilia las 42.\n */\nconst DayCell = memo(\n ({\n day,\n label,\n selected,\n inRange,\n isRangeStart,\n isRangeEnd,\n isToday,\n disabled,\n allowFontScaling,\n styles,\n onPress,\n }: DayCellProps) => (\n <View\n style={[\n styles.cell,\n (inRange || isRangeStart || isRangeEnd) && styles.cellInRange,\n isRangeStart && styles.cellRangeStart,\n isRangeEnd && styles.cellRangeEnd,\n ]}\n >\n <TouchableOpacity\n style={[styles.dayButton, selected && styles.daySelected, !selected && isToday && styles.dayToday]}\n onPress={() => onPress(day)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={label}\n accessibilityState={{ selected, disabled }}\n >\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.dayText,\n inRange && styles.dayTextInRange,\n selected && styles.dayTextSelected,\n disabled && styles.dayTextDisabled,\n ]}\n >\n {day.getDate()}\n </Text>\n </TouchableOpacity>\n </View>\n )\n);\nDayCell.displayName = 'DayCell';\n\n/**\n * DatePicker\n * Calendario mensual dibujado con View/Text puros — sin\n * @react-native-community/datetimepicker ni ningún picker nativo, para\n * mantener la librería en cero dependencias y verse igual en iOS y Android.\n */\nexport const DatePicker = memo(\n ({\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n minDate,\n maxDate,\n defaultMonth,\n monthNames,\n weekdayNames,\n firstDayOfWeek = 0,\n onVisibleMonthChange,\n style,\n testID,\n }: DatePickerProps) => {\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const isRange = mode === 'range';\n\n const months = monthNames ?? labels.months;\n const weekdays = weekdayNames ?? labels.weekdaysShort;\n\n const handlePress = useCallback(\n (day: Date) => {\n if (!isRange) {\n onChange?.(day);\n return;\n }\n // Primer toque (o rango ya cerrado, o toque antes del inicio): arranca\n // un rango nuevo. Segundo toque en adelante: lo cierra.\n if (!range?.start || range.end || dayTime(day) < dayTime(range.start)) {\n onRangeChange?.({ start: day });\n } else {\n onRangeChange?.({ start: range.start, end: day });\n }\n },\n [isRange, onChange, onRangeChange, range?.start, range?.end]\n );\n\n const anchor = value ?? range?.start ?? defaultMonth;\n const [visibleMonth, setVisibleMonth] = useState(\n () => new Date((anchor ?? new Date()).getFullYear(), (anchor ?? new Date()).getMonth(), 1)\n );\n\n // La fecha elegida puede cambiar desde afuera (uso controlado): sin esto,\n // el calendario se quedaba clavado en el mes con el que montó.\n const anchorMonth = anchor ? monthIndex(anchor) : undefined;\n const lastAnchorMonth = useRef(anchorMonth);\n useEffect(() => {\n if (anchorMonth === undefined || anchorMonth === lastAnchorMonth.current) return;\n lastAnchorMonth.current = anchorMonth;\n setVisibleMonth(new Date(Math.floor(anchorMonth / 12), anchorMonth % 12, 1));\n }, [anchorMonth]);\n\n // \"Hoy\" se recalcula en la próxima medianoche: una app que queda abierta\n // toda la noche marcaba el día anterior.\n const [todayTs, setTodayTs] = useState(() => dayTime(new Date()));\n useEffect(() => {\n const timer = setTimeout(() => setTodayTs(dayTime(new Date())), msUntilNextMidnight(new Date()));\n return () => clearTimeout(timer);\n }, [todayTs]);\n\n const grid = useMemo(\n () => buildMonthGrid(visibleMonth.getFullYear(), visibleMonth.getMonth(), firstDayOfWeek),\n [visibleMonth, firstDayOfWeek]\n );\n\n const orderedWeekdays = useMemo(\n () => (firstDayOfWeek === 0 ? weekdays : [...weekdays.slice(1), weekdays[0]!]),\n [weekdays, firstDayOfWeek]\n );\n\n // Los límites se resuelven una vez y no 42 veces por render: comparar\n // timestamps sale gratis, crear un Date por celda no.\n const bounds = useMemo(\n () => ({\n min: dayTimeOf(minDate),\n max: dayTimeOf(maxDate),\n start: dayTimeOf(range?.start),\n end: dayTimeOf(range?.end),\n selected: dayTimeOf(value),\n }),\n [minDate, maxDate, range?.start, range?.end, value]\n );\n\n const shiftMonth = (delta: number) =>\n setVisibleMonth((m) => {\n const next = new Date(m.getFullYear(), m.getMonth() + delta, 1);\n onVisibleMonthChange?.(next);\n return next;\n });\n\n // Un mes entero fuera de los límites no tiene nada que elegir: la flecha\n // que lleva ahí se apaga en vez de dejar navegar al vacío.\n const visibleIndex = monthIndex(visibleMonth);\n const canGoBack = !minDate || visibleIndex > monthIndex(minDate);\n const canGoForward = !maxDate || visibleIndex < monthIndex(maxDate);\n\n const monthLabel = `${months[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;\n\n return (\n <View style={[styles.container, style]} testID={testID}>\n <View style={styles.header}>\n <IconButton\n iconName=\"chevron-left\"\n accessibilityLabel=\"Previous month\"\n disabled={!canGoBack}\n onPress={() => shiftMonth(-1)}\n />\n <Text allowFontScaling={allowFontScaling} style={styles.monthLabel} accessibilityRole=\"header\">\n {monthLabel}\n </Text>\n <IconButton\n iconName=\"chevron-right\"\n accessibilityLabel=\"Next month\"\n disabled={!canGoForward}\n onPress={() => shiftMonth(1)}\n />\n </View>\n\n <View style={styles.weekRow}>\n {orderedWeekdays.map((w, i) => (\n <Text allowFontScaling={allowFontScaling} key={`${w}-${i}`} style={styles.weekday}>\n {w}\n </Text>\n ))}\n </View>\n\n {grid.map((row, rowIndex) => (\n <View style={styles.row} key={rowIndex}>\n {row.map((day, cellIndex) => {\n if (!day) return <View style={styles.cell} key={cellIndex} />;\n\n const t = dayTime(day);\n const disabled = (bounds.min !== undefined && t < bounds.min) || (bounds.max !== undefined && t > bounds.max);\n const isStart = isRange && bounds.start === t;\n const isEnd = isRange && bounds.end === t;\n const closed = isRange && bounds.start !== undefined && bounds.end !== undefined;\n const between = closed && t > bounds.start! && t < bounds.end!;\n const selected = isRange ? isStart || isEnd : bounds.selected === t;\n\n return (\n <DayCell\n key={cellIndex}\n day={day}\n label={`${months[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`}\n selected={selected}\n // Un día fuera de los límites no se pinta como parte del\n // rango: se ve deshabilitado, y eso es lo que es.\n inRange={between && !disabled}\n isRangeStart={!!closed && isStart && !disabled}\n isRangeEnd={isEnd && !disabled}\n isToday={t === todayTs}\n disabled={disabled}\n allowFontScaling={allowFontScaling}\n styles={styles}\n onPress={handlePress}\n />\n );\n })}\n </View>\n ))}\n </View>\n );\n }\n);\nDatePicker.displayName = 'DatePicker';\n"
|
|
15
|
+
"content": "import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useLabels } from '../theme/UIProvider';\nimport { IconButton } from '../atoms/IconButton';\n\nexport const DEFAULT_MONTH_NAMES = [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n];\n\n/** Índice 0 = domingo, para alinear con Date.getDay(). */\nexport const DEFAULT_WEEKDAY_NAMES = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];\n\nexport interface DateRange {\n start: Date;\n /** Falta mientras el rango está a medio elegir (sólo se tocó el inicio). */\n end?: Date;\n}\n\nexport interface DatePickerProps {\n /**\n * 'single' picks one date; 'range' picks an interval with two taps.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Selected date in mode=\"single\". */\n value?: Date;\n /** Called in mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Selected range in mode=\"range\". */\n range?: DateRange;\n /** Called in mode=\"range\", both when setting the start and when closing the range. */\n onRangeChange?: (range: DateRange) => void;\n minDate?: Date;\n maxDate?: Date;\n /** Month shown on open if there's no `value`. @default today */\n defaultMonth?: Date;\n /** Month names. @default those from `useLabels()` */\n monthNames?: string[];\n /** Day initials, index 0 = Sunday. @default those from `useLabels()` */\n weekdayNames?: string[];\n /** 0 = Sunday, 1 = Monday. @default 0 */\n firstDayOfWeek?: 0 | 1;\n /** Reports which month became visible when navigating with the arrows. */\n onVisibleMonthChange?: (month: Date) => void;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nfunction startOfDay(d: Date): Date {\n return new Date(d.getFullYear(), d.getMonth(), d.getDate());\n}\n\n/** Timestamp del día, para comparar sin volver a crear Date en cada celda. */\nfunction dayTime(d: Date): number {\n return startOfDay(d).getTime();\n}\n\nfunction dayTimeOf(d: Date | undefined): number | undefined {\n return d ? dayTime(d) : undefined;\n}\n\n/** Milisegundos que faltan para la próxima medianoche local. */\nfunction msUntilNextMidnight(from: Date): number {\n const next = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 1);\n return next.getTime() - from.getTime();\n}\n\n/** Número de mes absoluto (año * 12 + mes), para comparar meses con un `<`. */\nfunction monthIndex(d: Date): number {\n return d.getFullYear() * 12 + d.getMonth();\n}\n\n/**\n * Devuelve la grilla del mes: 6 filas de 7 celdas. Las celdas fuera del mes\n * son null, así el layout no salta de alto entre meses (un mes puede\n * necesitar 4, 5 o 6 filas según en qué día caiga el 1).\n */\nexport function buildMonthGrid(year: number, month: number, firstDayOfWeek: 0 | 1): (Date | null)[][] {\n const first = new Date(year, month, 1);\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n const leading = (first.getDay() - firstDayOfWeek + 7) % 7;\n\n const cells: (Date | null)[] = [];\n for (let i = 0; i < leading; i++) cells.push(null);\n for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));\n while (cells.length < 42) cells.push(null);\n\n const rows: (Date | null)[][] = [];\n for (let r = 0; r < 6; r++) rows.push(cells.slice(r * 7, r * 7 + 7));\n return rows;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: {\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n padding: theme.tokens.spacing.md,\n },\n header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: theme.tokens.spacing.sm },\n monthLabel: { fontSize: theme.tokens.fontSize.lg, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n weekRow: { flexDirection: 'row' },\n weekday: {\n flex: 1,\n textAlign: 'center',\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.textLight,\n paddingVertical: theme.tokens.spacing.sm,\n },\n row: { flexDirection: 'row' },\n cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },\n dayButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },\n dayText: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n daySelected: { backgroundColor: theme.colors.primary },\n dayTextSelected: { color: theme.colors.onPrimary, fontWeight: theme.tokens.fontWeight.bold },\n // El resaltado del tramo intermedio va en la celda (cuadrada, sin gaps) y\n // no en el botón redondo, así la barra del rango se ve continua.\n cellInRange: { backgroundColor: theme.colors.primarySoft },\n cellRangeStart: { borderTopLeftRadius: 18, borderBottomLeftRadius: 18 },\n cellRangeEnd: { borderTopRightRadius: 18, borderBottomRightRadius: 18 },\n dayTextInRange: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n dayToday: { borderWidth: 1, borderColor: theme.colors.primary },\n dayTextDisabled: { color: theme.colors.textLight, opacity: 0.4 },\n}));\n\ntype Styles = ReturnType<typeof useStyles>;\n\ninterface DayCellProps {\n day: Date;\n label: string;\n selected: boolean;\n inRange: boolean;\n isRangeStart: boolean;\n isRangeEnd: boolean;\n isToday: boolean;\n disabled: boolean;\n allowFontScaling: boolean;\n styles: Styles;\n onPress: (day: Date) => void;\n}\n\n/**\n * Una celda del calendario. Va memoizada aparte porque el mes entero son 42\n * celdas: sin esto, cualquier render del padre reconcilia las 42.\n */\nconst DayCell = memo(\n ({\n day,\n label,\n selected,\n inRange,\n isRangeStart,\n isRangeEnd,\n isToday,\n disabled,\n allowFontScaling,\n styles,\n onPress,\n }: DayCellProps) => (\n <View\n style={[\n styles.cell,\n (inRange || isRangeStart || isRangeEnd) && styles.cellInRange,\n isRangeStart && styles.cellRangeStart,\n isRangeEnd && styles.cellRangeEnd,\n ]}\n >\n <TouchableOpacity\n style={[styles.dayButton, selected && styles.daySelected, !selected && isToday && styles.dayToday]}\n onPress={() => onPress(day)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={label}\n accessibilityState={{ selected, disabled }}\n >\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.dayText,\n inRange && styles.dayTextInRange,\n selected && styles.dayTextSelected,\n disabled && styles.dayTextDisabled,\n ]}\n >\n {day.getDate()}\n </Text>\n </TouchableOpacity>\n </View>\n )\n);\nDayCell.displayName = 'DayCell';\n\n/**\n * DatePicker\n * Calendario mensual dibujado con View/Text puros — sin\n * @react-native-community/datetimepicker ni ningún picker nativo, para\n * mantener la librería en cero dependencias y verse igual en iOS y Android.\n */\nexport const DatePicker = memo(\n ({\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n minDate,\n maxDate,\n defaultMonth,\n monthNames,\n weekdayNames,\n firstDayOfWeek = 0,\n onVisibleMonthChange,\n style,\n testID,\n }: DatePickerProps) => {\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const isRange = mode === 'range';\n\n const months = monthNames ?? labels.months;\n const weekdays = weekdayNames ?? labels.weekdaysShort;\n\n const handlePress = useCallback(\n (day: Date) => {\n if (!isRange) {\n onChange?.(day);\n return;\n }\n // Primer toque (o rango ya cerrado, o toque antes del inicio): arranca\n // un rango nuevo. Segundo toque en adelante: lo cierra.\n if (!range?.start || range.end || dayTime(day) < dayTime(range.start)) {\n onRangeChange?.({ start: day });\n } else {\n onRangeChange?.({ start: range.start, end: day });\n }\n },\n [isRange, onChange, onRangeChange, range?.start, range?.end]\n );\n\n const anchor = value ?? range?.start ?? defaultMonth;\n const [visibleMonth, setVisibleMonth] = useState(\n () => new Date((anchor ?? new Date()).getFullYear(), (anchor ?? new Date()).getMonth(), 1)\n );\n\n // La fecha elegida puede cambiar desde afuera (uso controlado): sin esto,\n // el calendario se quedaba clavado en el mes con el que montó.\n const anchorMonth = anchor ? monthIndex(anchor) : undefined;\n const lastAnchorMonth = useRef(anchorMonth);\n useEffect(() => {\n if (anchorMonth === undefined || anchorMonth === lastAnchorMonth.current) return;\n lastAnchorMonth.current = anchorMonth;\n setVisibleMonth(new Date(Math.floor(anchorMonth / 12), anchorMonth % 12, 1));\n }, [anchorMonth]);\n\n // \"Hoy\" se recalcula en la próxima medianoche: una app que queda abierta\n // toda la noche marcaba el día anterior.\n const [todayTs, setTodayTs] = useState(() => dayTime(new Date()));\n useEffect(() => {\n const timer = setTimeout(() => setTodayTs(dayTime(new Date())), msUntilNextMidnight(new Date()));\n return () => clearTimeout(timer);\n }, [todayTs]);\n\n const grid = useMemo(\n () => buildMonthGrid(visibleMonth.getFullYear(), visibleMonth.getMonth(), firstDayOfWeek),\n [visibleMonth, firstDayOfWeek]\n );\n\n const orderedWeekdays = useMemo(\n () => (firstDayOfWeek === 0 ? weekdays : [...weekdays.slice(1), weekdays[0]!]),\n [weekdays, firstDayOfWeek]\n );\n\n // Los límites se resuelven una vez y no 42 veces por render: comparar\n // timestamps sale gratis, crear un Date por celda no.\n const bounds = useMemo(\n () => ({\n min: dayTimeOf(minDate),\n max: dayTimeOf(maxDate),\n start: dayTimeOf(range?.start),\n end: dayTimeOf(range?.end),\n selected: dayTimeOf(value),\n }),\n [minDate, maxDate, range?.start, range?.end, value]\n );\n\n const shiftMonth = (delta: number) =>\n setVisibleMonth((m) => {\n const next = new Date(m.getFullYear(), m.getMonth() + delta, 1);\n onVisibleMonthChange?.(next);\n return next;\n });\n\n // Un mes entero fuera de los límites no tiene nada que elegir: la flecha\n // que lleva ahí se apaga en vez de dejar navegar al vacío.\n const visibleIndex = monthIndex(visibleMonth);\n const canGoBack = !minDate || visibleIndex > monthIndex(minDate);\n const canGoForward = !maxDate || visibleIndex < monthIndex(maxDate);\n\n const monthLabel = `${months[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;\n\n return (\n <View style={[styles.container, style]} testID={testID}>\n <View style={styles.header}>\n <IconButton\n iconName=\"chevron-left\"\n accessibilityLabel=\"Previous month\"\n disabled={!canGoBack}\n onPress={() => shiftMonth(-1)}\n />\n <Text allowFontScaling={allowFontScaling} style={styles.monthLabel} accessibilityRole=\"header\">\n {monthLabel}\n </Text>\n <IconButton\n iconName=\"chevron-right\"\n accessibilityLabel=\"Next month\"\n disabled={!canGoForward}\n onPress={() => shiftMonth(1)}\n />\n </View>\n\n <View style={styles.weekRow}>\n {orderedWeekdays.map((w, i) => (\n <Text allowFontScaling={allowFontScaling} key={`${w}-${i}`} style={styles.weekday}>\n {w}\n </Text>\n ))}\n </View>\n\n {grid.map((row, rowIndex) => (\n <View style={styles.row} key={rowIndex}>\n {row.map((day, cellIndex) => {\n if (!day) return <View style={styles.cell} key={cellIndex} />;\n\n const t = dayTime(day);\n const disabled = (bounds.min !== undefined && t < bounds.min) || (bounds.max !== undefined && t > bounds.max);\n const isStart = isRange && bounds.start === t;\n const isEnd = isRange && bounds.end === t;\n const closed = isRange && bounds.start !== undefined && bounds.end !== undefined;\n const between = closed && t > bounds.start! && t < bounds.end!;\n const selected = isRange ? isStart || isEnd : bounds.selected === t;\n\n return (\n <DayCell\n key={cellIndex}\n day={day}\n label={`${months[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`}\n selected={selected}\n // Un día fuera de los límites no se pinta como parte del\n // rango: se ve deshabilitado, y eso es lo que es.\n inRange={between && !disabled}\n isRangeStart={!!closed && isStart && !disabled}\n isRangeEnd={isEnd && !disabled}\n isToday={t === todayTs}\n disabled={disabled}\n allowFontScaling={allowFontScaling}\n styles={styles}\n onPress={handlePress}\n />\n );\n })}\n </View>\n ))}\n </View>\n );\n }\n);\nDatePicker.displayName = 'DatePicker';\n"
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
18
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/FormActions.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { Button } from '../atoms/Button';\nimport type { IconName } from '../icons/types';\nimport { useLabels } from '../theme/UIProvider';\n\nexport interface FormActionsProps {\n /**
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { Button } from '../atoms/Button';\nimport type { IconName } from '../icons/types';\nimport { useLabels } from '../theme/UIProvider';\n\nexport interface FormActionsProps {\n /** If omitted, only the secondary button is shown. */\n primaryLabel?: string;\n onPrimaryPress?: () => void;\n primaryDisabled?: boolean;\n primaryLoading?: boolean;\n primaryLeftIconName?: IconName;\n secondaryLabel?: string;\n onSecondaryPress: () => void;\n style?: StyleProp<ViewStyle>;\n}\n\n/**\n * FormActions\n * Par de botones: uno primario (guardar/editar) y uno ghost (cerrar/cancelar).\n * Uso típico: al final de un form, o en el footer de un Modal.\n */\nexport const FormActions = memo(\n ({\n primaryLabel,\n onPrimaryPress,\n primaryDisabled = false,\n primaryLoading = false,\n primaryLeftIconName,\n secondaryLabel,\n onSecondaryPress,\n style,\n }: FormActionsProps) => {\n const labels = useLabels();\n return (\n <View style={[{ gap: 12 }, style]}>\n {primaryLabel && onPrimaryPress && (\n <Button\n title={primaryLabel}\n onPress={onPrimaryPress}\n disabled={primaryDisabled}\n loading={primaryLoading}\n leftIconName={primaryLeftIconName}\n />\n )}\n <Button title={secondaryLabel ?? labels.close} onPress={onSecondaryPress} variant=\"ghost\" />\n </View>\n );\n }\n);\nFormActions.displayName = 'FormActions';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/image.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "atoms/Image.tsx",
|
|
13
|
-
"content": "import React, { memo, useCallback, useEffect, useState } from 'react';\nimport {\n Image as RNImage,\n Platform,\n StyleSheet,\n View,\n type ImageErrorEventData,\n type ImageResizeMode,\n type ImageSourcePropType,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Spinner } from './Spinner';\n\nexport interface ImageProps {\n source: ImageSourcePropType;\n width?: number;\n height?: number;\n /** @default 'cover' */\n resizeMode?: ImageResizeMode;\n /**
|
|
13
|
+
"content": "import React, { memo, useCallback, useEffect, useState } from 'react';\nimport {\n Image as RNImage,\n Platform,\n StyleSheet,\n View,\n type ImageErrorEventData,\n type ImageResizeMode,\n type ImageSourcePropType,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Spinner } from './Spinner';\n\nexport interface ImageProps {\n source: ImageSourcePropType;\n width?: number;\n height?: number;\n /** @default 'cover' */\n resizeMode?: ImageResizeMode;\n /** Corner radius, same pattern as Avatar/Card. @default 0 */\n radius?: number;\n /** What to show while loading. `false` to show nothing. @default true (small spinner) */\n loading?: React.ReactNode | boolean;\n /** What to show if loading fails. @default a gray placeholder, no content */\n fallback?: React.ReactNode;\n /**\n * 'high' triggers Image.prefetch() on mount, before <Image> starts\n * its own load cycle — meant for the above-the-fold image of a\n * screen (the first one the user sees). No cost if it's already cached.\n * @default 'normal'\n */\n priority?: 'low' | 'normal' | 'high';\n style?: StyleProp<ViewStyle>;\n onLoadEnd?: () => void;\n onError?: (error: NativeSyntheticEvent<ImageErrorEventData>) => void;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { overflow: 'hidden', backgroundColor: theme.colors.skeleton },\n overlay: { alignItems: 'center', justifyContent: 'center' },\n fallback: { flex: 1, backgroundColor: theme.colors.skeleton },\n}));\n\nfunction sourceUri(source: ImageSourcePropType): string | undefined {\n return typeof source === 'object' && source !== null && 'uri' in source ? source.uri : undefined;\n}\n\n/**\n * Image\n * Wrapper cero-dependencias sobre <Image> de RN: agrega estado de carga y\n * error (con los mismos placeholders que el resto de la librería), y expone\n * `priority=\"high\"` para adelantar la descarga vía Image.prefetch(). El\n * cache real (disco/memoria) lo maneja el motor de imágenes nativo de RN —\n * esto no lo reimplementa, solo no lo deja apagado por accidente: en iOS\n * fuerza `cache=\"force-cache\"` (usa el disco aunque el header HTTP no lo\n * pida); en Android el cache de disco de la imagen nativa ya está siempre\n * activo, no hay knob equivalente que tocar.\n */\nexport const Image = memo(\n ({\n source,\n width,\n height,\n resizeMode = 'cover',\n radius = 0,\n loading = true,\n fallback,\n priority = 'normal',\n style,\n onLoadEnd,\n onError,\n testID,\n }: ImageProps) => {\n const styles = useStyles();\n const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading');\n const uri = sourceUri(source);\n\n useEffect(() => {\n // Resetea el estado si cambia la imagen (misma instancia de Image,\n // source distinto: p.ej. avatar de un usuario a otro en la misma fila).\n setStatus('loading');\n }, [uri ?? source]);\n\n useEffect(() => {\n if (priority === 'high' && uri) {\n RNImage.prefetch(uri).catch(() => {});\n }\n }, [priority, uri]);\n\n const handleError = useCallback(\n (e: NativeSyntheticEvent<ImageErrorEventData>) => {\n setStatus('error');\n onError?.(e);\n },\n [onError]\n );\n\n const handleLoadEnd = useCallback(() => {\n setStatus('loaded');\n onLoadEnd?.();\n }, [onLoadEnd]);\n\n return (\n <View style={[styles.container, { width, height, borderRadius: radius }, style]} testID={testID}>\n {status !== 'error' && (\n <RNImage\n source={source}\n resizeMode={resizeMode}\n style={StyleSheet.absoluteFill}\n fadeDuration={Platform.OS === 'android' ? 150 : undefined}\n {...(Platform.OS === 'ios' ? { cache: 'force-cache' as const } : null)}\n onLoadEnd={handleLoadEnd}\n onError={handleError}\n />\n )}\n {status === 'loading' && loading !== false && (\n <View style={[StyleSheet.absoluteFill, styles.overlay]}>\n {loading === true ? <Spinner size=\"small\" fullscreen={false} /> : loading}\n </View>\n )}\n {status === 'error' && (fallback ?? <View style={styles.fallback} />)}\n </View>\n );\n }\n);\nImage.displayName = 'Image';\n\n/**\n * Precarga una URL antes de montar el componente (p.ej. al recibir la\n * respuesta de un listado, antes de navegar al detalle). Re-exporta\n * Image.prefetch de RN — no hay nada zero-dep que agregarle encima.\n */\nexport const prefetchImage = RNImage.prefetch;\n"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"registryDependencies": [
|
package/registry/index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"core": {
|
|
3
|
-
"bytes":
|
|
3
|
+
"bytes": 37332
|
|
4
4
|
},
|
|
5
5
|
"components": [
|
|
6
6
|
{
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"registryDependencies": [
|
|
54
54
|
"core"
|
|
55
55
|
],
|
|
56
|
-
"bytes":
|
|
56
|
+
"bytes": 5455
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"name": "Checkbox",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"registryDependencies": [
|
|
78
78
|
"core"
|
|
79
79
|
],
|
|
80
|
-
"bytes":
|
|
80
|
+
"bytes": 2183
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
"name": "Card",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"core",
|
|
128
128
|
"spinner"
|
|
129
129
|
],
|
|
130
|
-
"bytes":
|
|
130
|
+
"bytes": 4566
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"name": "Spinner",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"registryDependencies": [
|
|
140
140
|
"core"
|
|
141
141
|
],
|
|
142
|
-
"bytes":
|
|
142
|
+
"bytes": 4640
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
"name": "Skeleton",
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
"registryDependencies": [
|
|
152
152
|
"core"
|
|
153
153
|
],
|
|
154
|
-
"bytes":
|
|
154
|
+
"bytes": 3550
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
"name": "Transition",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"registryDependencies": [
|
|
165
165
|
"core"
|
|
166
166
|
],
|
|
167
|
-
"bytes":
|
|
167
|
+
"bytes": 6569
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
"name": "EmptyState",
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
"registryDependencies": [
|
|
214
214
|
"core"
|
|
215
215
|
],
|
|
216
|
-
"bytes":
|
|
216
|
+
"bytes": 1706
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
"name": "Stepper",
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
"registryDependencies": [
|
|
227
227
|
"core"
|
|
228
228
|
],
|
|
229
|
-
"bytes":
|
|
229
|
+
"bytes": 6745
|
|
230
230
|
},
|
|
231
231
|
{
|
|
232
232
|
"name": "OptionCard",
|
|
@@ -239,7 +239,7 @@
|
|
|
239
239
|
"core",
|
|
240
240
|
"pressable-scale"
|
|
241
241
|
],
|
|
242
|
-
"bytes":
|
|
242
|
+
"bytes": 4152
|
|
243
243
|
},
|
|
244
244
|
{
|
|
245
245
|
"name": "InfoRow",
|
|
@@ -265,7 +265,7 @@
|
|
|
265
265
|
"core",
|
|
266
266
|
"button"
|
|
267
267
|
],
|
|
268
|
-
"bytes":
|
|
268
|
+
"bytes": 1524
|
|
269
269
|
},
|
|
270
270
|
{
|
|
271
271
|
"name": "AvatarHeader",
|
|
@@ -277,7 +277,7 @@
|
|
|
277
277
|
"registryDependencies": [
|
|
278
278
|
"core"
|
|
279
279
|
],
|
|
280
|
-
"bytes":
|
|
280
|
+
"bytes": 2492
|
|
281
281
|
},
|
|
282
282
|
{
|
|
283
283
|
"name": "SegmentedControl",
|
|
@@ -301,7 +301,7 @@
|
|
|
301
301
|
"registryDependencies": [
|
|
302
302
|
"core"
|
|
303
303
|
],
|
|
304
|
-
"bytes":
|
|
304
|
+
"bytes": 8628
|
|
305
305
|
},
|
|
306
306
|
{
|
|
307
307
|
"name": "SymmetricGrid",
|
|
@@ -313,7 +313,7 @@
|
|
|
313
313
|
"registryDependencies": [
|
|
314
314
|
"core"
|
|
315
315
|
],
|
|
316
|
-
"bytes":
|
|
316
|
+
"bytes": 2773
|
|
317
317
|
},
|
|
318
318
|
{
|
|
319
319
|
"name": "Modal",
|
|
@@ -345,7 +345,7 @@
|
|
|
345
345
|
"pressable-scale",
|
|
346
346
|
"transition"
|
|
347
347
|
],
|
|
348
|
-
"bytes":
|
|
348
|
+
"bytes": 9678
|
|
349
349
|
},
|
|
350
350
|
{
|
|
351
351
|
"name": "Select",
|
|
@@ -357,7 +357,7 @@
|
|
|
357
357
|
"registryDependencies": [
|
|
358
358
|
"core"
|
|
359
359
|
],
|
|
360
|
-
"bytes":
|
|
360
|
+
"bytes": 12117
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
"name": "Alert",
|
|
@@ -400,7 +400,7 @@
|
|
|
400
400
|
"registryDependencies": [
|
|
401
401
|
"core"
|
|
402
402
|
],
|
|
403
|
-
"bytes":
|
|
403
|
+
"bytes": 3227
|
|
404
404
|
},
|
|
405
405
|
{
|
|
406
406
|
"name": "List",
|
|
@@ -415,7 +415,7 @@
|
|
|
415
415
|
"skeleton",
|
|
416
416
|
"spinner"
|
|
417
417
|
],
|
|
418
|
-
"bytes":
|
|
418
|
+
"bytes": 5999
|
|
419
419
|
},
|
|
420
420
|
{
|
|
421
421
|
"name": "SearchList",
|
|
@@ -433,7 +433,7 @@
|
|
|
433
433
|
"icon-button",
|
|
434
434
|
"pressable-scale"
|
|
435
435
|
],
|
|
436
|
-
"bytes":
|
|
436
|
+
"bytes": 9245
|
|
437
437
|
},
|
|
438
438
|
{
|
|
439
439
|
"name": "Toast",
|
|
@@ -446,7 +446,7 @@
|
|
|
446
446
|
"core",
|
|
447
447
|
"transition"
|
|
448
448
|
],
|
|
449
|
-
"bytes":
|
|
449
|
+
"bytes": 4405
|
|
450
450
|
},
|
|
451
451
|
{
|
|
452
452
|
"name": "ToastProvider",
|
|
@@ -464,7 +464,7 @@
|
|
|
464
464
|
"toast",
|
|
465
465
|
"transition"
|
|
466
466
|
],
|
|
467
|
-
"bytes":
|
|
467
|
+
"bytes": 6359
|
|
468
468
|
},
|
|
469
469
|
{
|
|
470
470
|
"name": "DatePicker",
|
|
@@ -481,7 +481,7 @@
|
|
|
481
481
|
"icon-button",
|
|
482
482
|
"pressable-scale"
|
|
483
483
|
],
|
|
484
|
-
"bytes":
|
|
484
|
+
"bytes": 13397
|
|
485
485
|
},
|
|
486
486
|
{
|
|
487
487
|
"name": "DateField",
|
|
@@ -500,7 +500,7 @@
|
|
|
500
500
|
"date-picker",
|
|
501
501
|
"button"
|
|
502
502
|
],
|
|
503
|
-
"bytes":
|
|
503
|
+
"bytes": 6476
|
|
504
504
|
},
|
|
505
505
|
{
|
|
506
506
|
"name": "Wizard",
|
|
@@ -515,7 +515,7 @@
|
|
|
515
515
|
"button",
|
|
516
516
|
"transition"
|
|
517
517
|
],
|
|
518
|
-
"bytes":
|
|
518
|
+
"bytes": 5599
|
|
519
519
|
},
|
|
520
520
|
{
|
|
521
521
|
"name": "ThemeToggle",
|
|
@@ -540,7 +540,7 @@
|
|
|
540
540
|
"registryDependencies": [
|
|
541
541
|
"core"
|
|
542
542
|
],
|
|
543
|
-
"bytes":
|
|
543
|
+
"bytes": 7867
|
|
544
544
|
},
|
|
545
545
|
{
|
|
546
546
|
"name": "ReorderableList",
|
|
@@ -552,7 +552,7 @@
|
|
|
552
552
|
"registryDependencies": [
|
|
553
553
|
"core"
|
|
554
554
|
],
|
|
555
|
-
"bytes":
|
|
555
|
+
"bytes": 7324
|
|
556
556
|
}
|
|
557
557
|
]
|
|
558
558
|
}
|
package/registry/input.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Input.tsx",
|
|
12
|
-
"content": "import React, { memo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Animated,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type StyleProp,\n type TextInputProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface InputProps extends Omit<TextInputProps, 'style'> {\n label?: string;\n /**
|
|
12
|
+
"content": "import React, { memo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Animated,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type StyleProp,\n type TextInputProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface InputProps extends Omit<TextInputProps, 'style'> {\n label?: string;\n /** Shows an asterisk next to the label. Purely visual, doesn't validate anything. */\n required?: boolean;\n error?: string;\n containerStyle?: StyleProp<ViewStyle>;\n style?: TextInputProps['style'];\n leftIconName?: IconName;\n rightIconName?: IconName;\n onRightIconPress?: () => void;\n isPassword?: boolean;\n isLoading?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%' },\n label: {\n marginBottom: theme.tokens.spacing.sm,\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.medium,\n color: theme.colors.text,\n },\n required: { color: theme.colors.errorText },\n inputWrapper: {\n flexDirection: 'row',\n alignItems: 'center',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 2,\n minHeight: 50,\n },\n input: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.lg,\n fontSize: theme.tokens.fontSize.lg,\n color: theme.colors.text,\n },\n leftIcon: { marginLeft: theme.tokens.spacing.lg, marginRight: -theme.tokens.spacing.xs },\n rightIcon: { padding: theme.tokens.spacing.sm + 2, marginRight: theme.tokens.spacing.xs },\n errorText: { marginTop: theme.tokens.spacing.xs, fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * Input\n * El borde anima color entre border/primary/error con Animated (no\n * useNativeDriver: los navegadores de color en RN sin Fabric solo son\n * confiables en el hilo de JS; el costo es despreciable, se anima un borde).\n */\nexport const Input = memo(\n ({\n label,\n required = false,\n error,\n containerStyle,\n leftIconName,\n rightIconName,\n onRightIconPress,\n isPassword = false,\n isLoading = false,\n style,\n onFocus,\n onBlur,\n testID,\n ...props\n }: InputProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [isFocused, setIsFocused] = useState(false);\n const [showPassword, setShowPassword] = useState(false);\n const focusProgress = useRef(new Animated.Value(0)).current;\n\n const handleFocus: TextInputProps['onFocus'] = (e) => {\n setIsFocused(true);\n Animated.timing(focusProgress, { toValue: 1, duration: 200, useNativeDriver: false }).start();\n onFocus?.(e);\n };\n\n const handleBlur: TextInputProps['onBlur'] = (e) => {\n setIsFocused(false);\n Animated.timing(focusProgress, { toValue: 0, duration: 200, useNativeDriver: false }).start();\n onBlur?.(e);\n };\n\n const borderColor = error\n ? colors.error\n : focusProgress.interpolate({ outputRange: [colors.border, colors.primary], inputRange: [0, 1] });\n\n return (\n <View style={[styles.container, containerStyle]} testID={testID}>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <Animated.View style={[styles.inputWrapper, { borderColor }]}>\n {leftIconName && <Icon name={leftIconName} size={20} color={colors.textLight} style={styles.leftIcon} />}\n\n <TextInput\n style={[styles.input, style]}\n placeholderTextColor={colors.textLight}\n onFocus={handleFocus}\n onBlur={handleBlur}\n secureTextEntry={isPassword && !showPassword}\n allowFontScaling={allowFontScaling}\n textAlignVertical=\"center\"\n accessibilityLabel={label ?? props.placeholder}\n {...props}\n />\n\n {isLoading ? (\n <View style={styles.rightIcon}>\n <ActivityIndicator size=\"small\" color={colors.primary} />\n </View>\n ) : isPassword ? (\n <TouchableOpacity\n onPress={() => setShowPassword((v) => !v)}\n style={styles.rightIcon}\n accessibilityRole=\"button\"\n accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}\n >\n <Icon name={showPassword ? 'eye-off' : 'eye'} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : rightIconName ? (\n <TouchableOpacity\n onPress={onRightIconPress}\n style={styles.rightIcon}\n disabled={!onRightIconPress}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Clear\"\n >\n <Icon name={rightIconName} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : null}\n </Animated.View>\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n </View>\n );\n }\n);\nInput.displayName = 'Input';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/list.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/List.tsx",
|
|
12
|
-
"content": "import React, { useCallback } from 'react';\nimport { FlatList, RefreshControl, View, type FlatListProps, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport type { IconName } from '../icons/types';\n\nexport interface ListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /**
|
|
12
|
+
"content": "import React, { useCallback } from 'react';\nimport { FlatList, RefreshControl, View, type FlatListProps, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport type { IconName } from '../icons/types';\n\nexport interface ListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Initial load: while true and there's no data, placeholders are shown. */\n isLoading: boolean;\n /** Reload with data already on screen (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlled by the consumer: while false, a fullscreen spinner is shown. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n\n /** Ghost rows for the initial load. @default 6 */\n skeletonCount?: number;\n /** Custom placeholder, to match the real shape of the item. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: ViewStyle;\n containerStyle?: ViewStyle;\n\n /** List component to use (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, backgroundColor: theme.colors.background },\n listContent: { padding: theme.tokens.spacing.md },\n footerLoader: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center' },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * List\n * Lista con estados de carga/paginación/vacío resueltos, sin atarse a\n * expo-router ni a FlashList: `ListComponent` permite inyectar cualquiera\n * (por defecto FlatList del propio React Native).\n */\nexport function List<T>({\n data,\n keyExtractor,\n renderItem,\n isLoading,\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: ListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const isInitialLoading = isLoading && data.length === 0;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footerLoader}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n </>\n );\n }, [isLoadingMore, resolvedLoadingMoreText, ListFooterComponent, styles.footerLoader]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={styles.container}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n if (isInitialLoading) {\n return (\n <View style={[styles.container, containerStyle]} testID=\"list-skeleton\">\n <View style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n </View>\n );\n }\n\n return (\n <View style={[styles.container, containerStyle]}>\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n renderItem={renderItem}\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={onEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/NavigationBar.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Contador sobre el ícono. `true` pinta sólo el punto. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n items: NavigationBarItem[];\n activeKey: string;\n onChange: (key: string) => void;\n /**
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Contador sobre el ícono. `true` pinta sólo el punto. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n items: NavigationBarItem[];\n activeKey: string;\n onChange: (key: string) => void;\n /** Screen edge where the bar lives. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** @default true */\n showLabels?: boolean;\n /** Adds the safe area inset for the edge where the bar lives. @default true */\n safeArea?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n bar: {\n flexDirection: 'row',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.border,\n paddingHorizontal: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.sm,\n },\n barBottom: { borderTopWidth: StyleSheet.hairlineWidth },\n barTop: { borderBottomWidth: StyleSheet.hairlineWidth },\n indicator: {\n position: 'absolute',\n top: theme.tokens.spacing.xs,\n bottom: theme.tokens.spacing.xs,\n // El indicador se posiciona contra el borde de la barra, no contra su caja\n // interna: al `left` hay que sumarle el padding horizontal para que quede\n // centrado sobre la pestaña y no corrido hacia la izquierda.\n left: theme.tokens.spacing.xs + theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -4,\n left: '50%',\n marginLeft: 4,\n minWidth: 16,\n height: 16,\n paddingHorizontal: 4,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },\n badgeText: {\n fontSize: 10,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Barra de navegación por pestañas con la misma apariencia y las mismas\n * transiciones en iOS y Android: el indicador se desliza hasta la pestaña\n * activa y su contenido crece y toma el color de acento con un cross-fade.\n * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n\n const [barWidth, setBarWidth] = useState(0);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // En el primer layout el indicador aparece en su lugar; animarlo desde 0\n // sería un deslizamiento que el usuario no pidió.\n if (!placed.current) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();\n }, [activeIndex, itemWidth, slide]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n testID=\"navigation-bar-indicator\"\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n safeArea && position === 'bottom' && { bottom: theme.tokens.spacing.xs + insets.bottom },\n safeArea && position === 'top' && { top: theme.tokens.spacing.xs + insets.top },\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/OptionCard.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport { PressableScale } from '../atoms/PressableScale';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n label: string;\n /**
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport { PressableScale } from '../atoms/PressableScale';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n label: string;\n /** Secondary line: the detail that decides the choice (\"arrives in 3 days\"). */\n description?: string;\n iconName?: IconName;\n isSelected: boolean;\n onPress: () => void;\n disabled?: boolean;\n /** 'vertical': icon on top, text centered below. 'horizontal': in a row. @default 'horizontal' */\n layout?: 'vertical' | 'horizontal';\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n card: {\n // Las tarjetas se reparten la fila que las contenga, sean dos o cinco.\n flex: 1,\n alignItems: 'center',\n justifyContent: 'center',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.md,\n borderWidth: 1,\n borderColor: theme.colors.border,\n ...theme.tokens.shadow.sm,\n },\n cardHorizontal: { flexDirection: 'row', padding: theme.tokens.spacing.md - 2, gap: theme.tokens.spacing.sm },\n cardVertical: { padding: theme.tokens.spacing.md - 2, gap: 6, minHeight: 100 },\n cardSelected: {\n borderColor: theme.colors.primary,\n backgroundColor: theme.colors.primarySoft,\n borderWidth: 1.5,\n elevation: 0,\n shadowOpacity: 0,\n },\n cardDisabled: { opacity: 0.5 },\n text: { flexShrink: 1 },\n textVertical: { alignItems: 'center' },\n label: { color: theme.colors.textLight, fontWeight: theme.tokens.fontWeight.medium },\n labelVertical: { fontSize: theme.tokens.fontSize.xs, textAlign: 'center' },\n labelSelected: { color: theme.colors.primaryText },\n description: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.textLight, marginTop: 2 },\n descriptionVertical: { textAlign: 'center' },\n}));\n\n/**\n * OptionCard\n * Opción excluyente con ícono y label que resalta cuando `isSelected`. Es un\n * radio con cara de tarjeta, así que se anuncia como tal: envolver el grupo en\n * una View con `accessibilityRole=\"radiogroup\"` para que el lector de pantalla\n * lea \"1 de 3\".\n */\nexport const OptionCard = memo(\n ({\n label,\n description,\n iconName,\n isSelected,\n onPress,\n disabled = false,\n layout = 'horizontal',\n style,\n testID,\n }: OptionCardProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = layout === 'vertical';\n\n // Feedback por transform y no por opacity: con elevation, animar la opacity\n // dispara un bug de Android que redibuja la sombra como un borde oscuro.\n return (\n <PressableScale\n style={[\n styles.card,\n isVertical ? styles.cardVertical : styles.cardHorizontal,\n isSelected && styles.cardSelected,\n disabled && styles.cardDisabled,\n style,\n ]}\n onPress={onPress}\n disabled={disabled}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}\n accessibilityLabel={label}\n accessibilityHint={description}\n testID={testID}\n >\n {!!iconName && <Icon name={iconName} size={24} color={isSelected ? colors.primary : colors.textLight} />}\n\n <View style={[styles.text, isVertical && styles.textVertical]}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.label, isVertical && styles.labelVertical, isSelected && styles.labelSelected]}\n >\n {label}\n </Text>\n {!!description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, isVertical && styles.descriptionVertical]}\n >\n {description}\n </Text>\n )}\n </View>\n </PressableScale>\n );\n }\n);\nOptionCard.displayName = 'OptionCard';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/PressableScale.tsx",
|
|
12
|
-
"content": "import React, { memo, useRef } from 'react';\nimport {\n Animated,\n Pressable,\n type GestureResponderEvent,\n type PressableProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\n\nexport interface PressableScaleProps extends Omit<PressableProps, 'style'> {\n /**
|
|
12
|
+
"content": "import React, { memo, useRef } from 'react';\nimport {\n Animated,\n Pressable,\n type GestureResponderEvent,\n type PressableProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\n\nexport interface PressableScaleProps extends Omit<PressableProps, 'style'> {\n /** Target scale when pressed. @default 0.96 */\n scaleTo?: number;\n style?: StyleProp<ViewStyle>;\n children: React.ReactNode;\n}\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable);\n\n/**\n * PressableScale\n * Feedback táctil de \"achicarse\" al presionar, con Animated.spring de RN\n * (useNativeDriver: true — corre en el hilo nativo, sin coste de JS por frame).\n * No depende de reanimated: la librería entera es cero-dependencias.\n */\nexport const PressableScale = memo(\n ({ scaleTo = 0.96, style, children, onPressIn, onPressOut, disabled, ...props }: PressableScaleProps) => {\n const scale = useRef(new Animated.Value(1)).current;\n\n const animateTo = (value: number) => {\n Animated.spring(scale, {\n toValue: value,\n useNativeDriver: true,\n speed: 50,\n bounciness: 6,\n }).start();\n };\n\n const handlePressIn = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(scaleTo);\n onPressIn?.(e);\n };\n\n const handlePressOut = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(1);\n onPressOut?.(e);\n };\n\n return (\n <AnimatedPressable\n {...props}\n disabled={disabled}\n onPressIn={handlePressIn}\n onPressOut={handlePressOut}\n style={[style, { transform: [{ scale }] }]}\n >\n {children}\n </AnimatedPressable>\n );\n }\n);\nPressableScale.displayName = 'PressableScale';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/ReorderableList.tsx",
|
|
12
|
-
"content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ReorderableListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /**
|
|
12
|
+
"content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ReorderableListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /** Fixed height of each row: needed to calculate the target index without measuring layout async. */\n itemHeight: number;\n renderItem: (item: T, index: number, dragging: boolean) => React.ReactElement;\n /** Called on release, with the array already reordered. Does not mutate `data`. */\n onReorder: (data: T[]) => void;\n /**\n * Starts dragging row `index`. Use it to turn off the container's scroll\n * (`scrollEnabled={false}`) while it lasts: on iOS the parent\n * `UIScrollView` cancels touches on the content as soon as its\n * vertical gesture starts, and without this the drag dies within a few pixels.\n */\n onDragStart?: (index: number) => void;\n /** Ends the drag — on release or cancellation. Always runs if `onDragStart` ran. */\n onDragEnd?: () => void;\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\n/** Reordena moviendo el ítem de `from` a `to`, sin mutar `data`. Exportada aparte por ser testeable sin simular gestos. */\nexport function move<T>(data: T[], from: number, to: number): T[] {\n const next = data.slice();\n const [item] = next.splice(from, 1) as [T];\n next.splice(to, 0, item);\n return next;\n}\n\nconst useStyles = createStyles(() => ({\n row: { width: '100%' },\n}));\n\n/**\n * ReorderableList\n * Ley de destino común (common fate): al arrastrar una fila, las filas que\n * tiene que atravesar se desplazan juntas en el mismo sentido — eso es lo\n * que comunica \"van a intercambiar lugar\" antes de soltar el dedo, no un\n * simple resaltado del ítem activo.\n *\n * Sin dependencias de gestos/animación externas (reanimated, gesture-handler):\n * PanResponder + Animated alcanzan porque el cálculo de índice destino\n * necesita el valor de arrastre en JS en cada movimiento (no algo que\n * useNativeDriver pueda resolver solo).\n *\n * Dentro de un scroll vertical hay que cablear `onDragStart`/`onDragEnd` a\n * `scrollEnabled`, o en iOS el arrastre no llega a empezar:\n *\n * ```tsx\n * const [dragging, setDragging] = useState(false);\n * <ScrollView scrollEnabled={!dragging}>\n * <ReorderableList onDragStart={() => setDragging(true)} onDragEnd={() => setDragging(false)} … />\n * </ScrollView>\n * ```\n */\nexport function ReorderableList<T>({\n data,\n keyExtractor,\n itemHeight,\n renderItem,\n onReorder,\n onDragStart,\n onDragEnd,\n disabled = false,\n style,\n}: ReorderableListProps<T>) {\n const styles = useStyles();\n const [activeIndex, setActiveIndex] = useState<number | null>(null);\n const [targetIndex, setTargetIndex] = useState<number | null>(null);\n const dy = useRef(new Animated.Value(0)).current;\n const activeIndexRef = useRef<number | null>(null);\n // El PanResponder de cada fila se crea una sola vez (se cachea en el Map de\n // abajo) y su closure queda fijo al render de esa primera vez: leer\n // `targetIndex` (state) directamente ahí siempre daría el valor del\n // montaje. Un ref sí refleja la última escritura sin importar cuándo se\n // creó la closure que lo lee.\n const targetIndexRef = useRef<number | null>(null);\n const dataRef = useRef(data);\n dataRef.current = data;\n // Igual razón que targetIndexRef: el responder de cada fila se crea una\n // sola vez, así que leer `disabled` directo del closure congelaría el\n // valor del momento en que esa fila se creó.\n const disabledRef = useRef(disabled);\n disabledRef.current = disabled;\n // Misma razón: los callbacks se leen desde closures creadas una sola vez.\n const onReorderRef = useRef(onReorder);\n onReorderRef.current = onReorder;\n const onDragStartRef = useRef(onDragStart);\n onDragStartRef.current = onDragStart;\n const onDragEndRef = useRef(onDragEnd);\n onDragEndRef.current = onDragEnd;\n\n const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;\n // Si `data` se achica, los responders de índices que ya no existen quedan\n // colgados en el Map para siempre: se podan en cada render.\n for (const index of panResponders.keys()) {\n if (index >= data.length) panResponders.delete(index);\n }\n\n function panResponderFor(index: number) {\n let responder = panResponders.get(index);\n if (responder) return responder;\n\n const endDrag = () => {\n dy.setValue(0);\n activeIndexRef.current = null;\n targetIndexRef.current = null;\n setActiveIndex(null);\n setTargetIndex(null);\n onDragEndRef.current?.();\n };\n\n responder = PanResponder.create({\n onStartShouldSetPanResponder: () => !disabledRef.current,\n onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,\n // Un scroll padre pide el responder apenas su gesto vertical arranca —\n // el mismo eje que el arrastre. Cederlo cancelaría el drag a mitad de\n // camino, así que la fila se lo queda hasta soltar.\n onPanResponderTerminationRequest: () => false,\n onPanResponderGrant: () => {\n activeIndexRef.current = index;\n targetIndexRef.current = index;\n setActiveIndex(index);\n setTargetIndex(index);\n dy.setValue(0);\n onDragStartRef.current?.(index);\n },\n onPanResponderMove: (_, gesture) => {\n dy.setValue(gesture.dy);\n const from = activeIndexRef.current;\n if (from === null) return;\n const rawTarget = from + Math.round(gesture.dy / itemHeight);\n const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));\n targetIndexRef.current = clamped;\n setTargetIndex((prev) => (prev === clamped ? prev : clamped));\n },\n onPanResponderRelease: () => {\n const from = activeIndexRef.current;\n const to = targetIndexRef.current;\n endDrag();\n if (from === null || to === null || from === to) return;\n onReorderRef.current(move(dataRef.current, from, to));\n },\n onPanResponderTerminate: endDrag,\n });\n panResponders.set(index, responder);\n return responder;\n }\n\n return (\n <View style={style}>\n {data.map((item, index) => {\n const isActive = activeIndex === index;\n // Filas entre el origen y el destino se corren un lugar entero en el\n // sentido del arrastre: es el efecto de \"grupo que se mueve junto\".\n let shift = 0;\n if (activeIndex !== null && targetIndex !== null && !isActive) {\n if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;\n else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;\n }\n\n return (\n <Animated.View\n key={keyExtractor(item, index)}\n style={[\n styles.row,\n isActive\n ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }\n : { transform: [{ translateY: shift }] },\n ]}\n {...panResponderFor(index).panHandlers}\n >\n {renderItem(item, index, isActive)}\n </Animated.View>\n );\n })}\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/screen.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Screen.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /**
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /** Which safe area insets to apply. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensates for the keyboard: on iOS with scroll the ScrollView itself does it, in\n * other cases a KeyboardAvoidingView does. Set to false when\n * the content already brings its own scroller that handles the keyboard (Wizard,\n * List, SearchList): the two compensations add up and the focused field\n * ends up pushed off screen.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n // En iOS el ScrollView de abajo ya sube el campo enfocado con\n // automaticallyAdjustKeyboardInsets; envolverlo además en un\n // KeyboardAvoidingView compensa el teclado dos veces y el input termina\n // empujado fuera del borde superior. Android no tiene ese prop, así que ahí\n // el KeyboardAvoidingView sigue siendo el único mecanismo.\n const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');\n\n const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;\n const keyboardProps = avoidWithView\n ? {\n behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),\n keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,\n }\n : {};\n\n return (\n <View style={[styles.wrapper, style]}>\n <KeyboardWrapper style={styles.container} {...keyboardProps}>\n {scrollable ? (\n <ScrollView\n style={styles.container}\n automaticallyAdjustKeyboardInsets\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: applyTop ? insets.top : 0 },\n applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: applyTop ? insets.top : 0, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n )}\n </KeyboardWrapper>\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|