diff --git a/.gitattributes b/.gitattributes index da9d28bb..42530f1a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,7 @@ /.yarn/plugins/**/* binary /.pnp.* binary linguist-generated *.pbxproj -text + +**/*.snap linguist-generated +yarn.lock linguist-generated +ios/Podfile.lock linguist-generated diff --git a/.prettierignore b/.prettierignore index 72079094..b109670a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,8 @@ ios/Pods +ios +.github/workflows .yarn android/app/.cxx -CHANGELOG.md \ No newline at end of file +android/app/build +android/build +CHANGELOG.md diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d4317acb..2147252d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,24 +1,18 @@ import type { Preview } from '@storybook/react' -import { - makeStyles, - ThemeContextProvider, - ThemeVariant, - useChangeTheme, -} from '../src' +import { StyleSheet } from 'react-native-unistyles' +import { ThemeContextProvider, ThemeVariant } from '../src' import { View } from 'react-native' -import React, { type FunctionComponent, type ReactNode, useEffect } from 'react' +import React, { type FunctionComponent, type ReactNode } from 'react' const preview: Preview = { decorators: [ (Story, { args }) => { return ( - - - - - - - + + + + + ) }, ], @@ -41,20 +35,17 @@ const Container: FunctionComponent<{ children: ReactNode theme: ThemeVariant }> = ({ children, theme }) => { - const styles = useStyles() - const changeTheme = useChangeTheme() - - useEffect(() => { - changeTheme(theme) - }, [theme, changeTheme]) - - return {children} + return ( + + {children} + + ) } -const useStyles = makeStyles((theme) => ({ +const styles = StyleSheet.create(({ theme }) => ({ container: { width: '100%', height: '100%', - backgroundColor: theme.theme.Surface['surface-card'], + backgroundColor: theme.Surface['surface-card'], }, })) diff --git a/.storybook/storybook.requires.ts b/.storybook/storybook.requires.ts index 21bd048f..00985b4f 100644 --- a/.storybook/storybook.requires.ts +++ b/.storybook/storybook.requires.ts @@ -1,47 +1,52 @@ /* do not change this file, it is auto generated by storybook. */ +import { start, updateView, View } from '@storybook/react-native'; -import { start, updateView } from '@storybook/react-native' - -import '@storybook/addon-ondevice-notes/register' -import '@storybook/addon-ondevice-controls/register' -import '@storybook/addon-ondevice-actions/register' +import "@storybook/addon-ondevice-notes/register"; +import "@storybook/addon-ondevice-controls/register"; +import "@storybook/addon-ondevice-actions/register"; const normalizedStories = [ { - titlePrefix: '', - directory: './src', - files: '**/*.stories.?(ts|tsx|js|jsx)', - importPathMatcher: - /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + titlePrefix: "", + directory: "./src", + files: "**/*.stories.?(ts|tsx|js|jsx)", + importPathMatcher: /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, // @ts-ignore req: require.context( '../src', true, /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/ ), - }, -] + } +]; + declare global { - var view: ReturnType - var STORIES: typeof normalizedStories + var view: View; + var STORIES: typeof normalizedStories; } + const annotations = [ require('./preview'), - require('@storybook/react-native/dist/preview'), - require('@storybook/addon-actions/preview'), -] + require("@storybook/react-native/preview") +]; -global.STORIES = normalizedStories +global.STORIES = normalizedStories; // @ts-ignore -module?.hot?.accept?.() +module?.hot?.accept?.(); + + if (!global.view) { - global.view = start({ annotations, storyEntries: normalizedStories }) + global.view = start({ + annotations, + storyEntries: normalizedStories, + + }); } else { - updateView(global.view, annotations, normalizedStories) + updateView(global.view, annotations, normalizedStories); } -export const view: ReturnType = global.view +export const view: View = global.view; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad2943af..8d9a8261 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,6 +203,8 @@ yarn test -u **Важные правила:** +- `StyleSheet`, `useUnistyles` и `UnistylesRuntime` **не реэкспортируются** из + кита — импортируй их напрямую из `react-native-unistyles` - Все компоненты должны быть типизированы - `testID` должен быть в формате `UpperPascalCase` (например: `ButtonPrimary`, `InputText`) diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..42303352 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,216 @@ +# Миграция на Unistyles V3 + +Стили переведены на `react-native-unistyles`. `ThemeContextProvider` доступен +как внешний конфигуратор тем и шрифтов, но чтение темы и шрифтов теперь идет +через API `unistyles`. + +## Изменения + +### `ThemeContextProvider` — доступен + +UI kit по-прежнему использует `react-native-unistyles` внутри, но для внешнего +потребителя снова доступен `ThemeContextProvider` как единая точка конфигурации +тем и шрифтов. + +Если приложению нужны кастомные шрифты, настройте их через провайдер: + +```tsx +import { + ThemeContextProvider, + ThemeVariant, +} from '@cdek-it/react-native-ui-kit' + +export const Root = () => ( + + + +) +``` + +Провайдер также принимает `lightTheme` и `darkTheme`, если нужно передать +полностью кастомные темы. + +### `useFonts` + +Используйте `useUnistyles`: + +```tsx +import { useUnistyles } from '@cdek-it/react-native-ui-kit' + +const { theme } = useUnistyles() +theme.fonts +``` + +Или прямо в стилях через `StyleSheet.create(...)`: + +```tsx +import { StyleSheet } from 'react-native-unistyles' + +const styles = StyleSheet.create(({ fonts }) => ({ + title: { fontFamily: fonts.primary }, +})) +``` + +### `useTheme()` / `useChangeTheme()` + +`ThemeContextProvider` больше не является источником `theme/fonts` через React +context. Он только конфигурирует `react-native-unistyles`. + +- `useTheme()` читает `UnistylesRuntime.themeName` +- `useFonts()` читает `useUnistyles().theme.fonts` +- `ThemeContext` остается пустым и имеет значение `null` +- `useChangeTheme()` всегда вызывает `UnistylesRuntime.setTheme(...)` + +### `makeStyles` — removed + +Используйте `StyleSheet.create(...)`: + +```tsx +import { StyleSheet } from '@cdek-it/react-native-ui-kit' + +const styles = StyleSheet.create((theme) => ({ + container: { backgroundColor: theme.Button.Brand.buttonBg }, +})) +``` + +`makeStyles` использует `useUnistyles()`, что вызывает React-ререндеры при смене +темы. `StyleSheet.create(...)` — нативный путь, обновляет стили **без** +ререндеров. + +SDK реэкспортирует `StyleSheet`, `useUnistyles`, `UnistylesRuntime` и +`withUnistyles`, поэтому потребителям не нужно импортировать +`react-native-unistyles` напрямую. + +### `useTheme()` — removed + +```tsx +import { UnistylesRuntime, useUnistyles } from '@cdek-it/react-native-ui-kit' + +const themeName = UnistylesRuntime.themeName // 'light' | 'dark' +``` + +Для реактивного поведения используйте `useUnistyles()`: + +```tsx +const { rt } = useUnistyles() +rt.themeName +``` + +### `useChangeTheme()` — removed + +```tsx +import { UnistylesRuntime } from '@cdek-it/react-native-ui-kit' + +UnistylesRuntime.setTheme('dark') +``` + +## ESLint Правила для Unistyles + +Три обязательных ESLint правила защищают от потери скрытого `unistyles` payload: + +### ⛔ `unistyles/no-spread-unistyles` (error) + +**Проблема**: Spread оператор теряет скрытый payload unistyles, что приводит к +потере темы и реактивности при её смене. + +```typescript +// ❌ Неправильно — payload теряется +const myStyle = { ...styles.button } +const btn = { ...styles.button, marginTop: 10 } +Object.assign({}, styles.button) +const { button, text } = styles + +// ✅ Правильно — payload сохранится +const myStyle = styles.button +style={[styles.button, { marginTop: 10 }]} +style={[styles.button, isActive && styles.buttonActive]} +``` + +### ⛔ `unistyles/no-unistyles-in-worklet` (error) + +**Проблема**: Worklet функции (`useAnimatedStyle`, `runOnJS`, `withSpring`) +передаются в native код и не могут захватить весь unistyles объект. Нужно +вытащить примитивы. + +```typescript +// ❌ Неправильно — styles целиком в worklet +const animStyle = useAnimatedStyle(() => ({ color: styles.text.color })) + +// ✅ Правильно — примитив вытащен перед worklet +const color = styles.text.color +const animStyle = useAnimatedStyle(() => ({ + color, // Теперь это просто строка +})) +``` + +### ⚠️ `unistyles/no-spread-icon-styles` (warn) + +Рекомендуется передавать явные props для Icon компонентов вместо spread. + +```typescript +// ❌ Не рекомендуется + + +// ✅ Рекомендуется +const color = styles.icon.color +const width = styles.icon.width + +``` + +### Почему это важно + +`react-native-unistyles` добавляет скрытый payload в каждый объект из +`StyleSheet.create()`. Этот payload содержит информацию о: + +- **Активной теме** (light/dark) +- **Responsive breakpoint** (размер экрана) +- **Unistyles runtime configuration** + +Если потерять payload, нативная часть больше не сможет: + +- Применить правильную тему +- Обновить стиль при смене темы/breakpoint +- Корректно интерпретировать значения + +Подробнее: +[ESLint Rules for Unistyles](./configs/eslint/rules/unistyles/README.md) + +## Babel конфигурация + +Для получения нативного обновления стилей без React-ререндеров: + +1. Используйте `StyleSheet.create(...)`. +2. Добавьте `autoProcessPaths` в Babel-конфиг вашего приложения. + +Это нужно потому, что UI kit подключается из `node_modules`, а `unistyles` по +умолчанию не обрабатывает такие файлы. + +Пример для приложения-потребителя: + +```js +module.exports = function (api) { + api.cache(true) + + return { + presets: ['babel-preset-expo'], + plugins: [ + [ + 'react-native-unistyles/plugin', + { root: 'src', autoProcessPaths: ['@cdek-it/react-native-ui-kit'] }, + ], + ], + } +} +``` + +Если Babel plugin у вас уже настроен, достаточно добавить путь +`@cdek-it/react-native-ui-kit` в существующий `autoProcessPaths`. + +Документация: + +- [useUnistyles](https://www.unistyl.es/v3/references/use-unistyles/) +- [StyleSheet](https://www.unistyl.es/v3/references/stylesheet/) +- [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin/) diff --git a/README.md b/README.md index 2302dfca..3480315c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,15 @@ UI kit использует следующие виды шрифтов. /> ``` +После этого шрифты доступны через `useUnistyles().theme.fonts` или прямо в +`StyleSheet.create(({ fonts }) => ...)`. + +`ThemeContextProvider` только настраивает темы и шрифты для `unistyles`. +`ThemeContext` остается пустым и всегда имеет значение `null`. + +Провайдер также принимает `lightTheme` и `darkTheme`, если нужно переопределить +темы целиком. + ### Пример подключения шрифтов с помощью expo-fonts через плагин ```ts diff --git a/babel.config.js b/babel.config.js index 46fba684..ae8e3b2f 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,6 +3,20 @@ module.exports = (api) => { return { presets: ['babel-preset-expo'], - plugins: ['@babel/plugin-transform-class-static-block'], + plugins: [ + [ + 'react-native-unistyles/plugin', + { + root: 'src', + autoProcessImports: [ + '../utils', + '../../utils', + '../../../utils', + '../../../../utils', + ], + }, + ], + '@babel/plugin-transform-class-static-block', + ], } } diff --git a/configs/eslint/index.ts b/configs/eslint/index.ts index 52274ad4..b909dcf8 100644 --- a/configs/eslint/index.ts +++ b/configs/eslint/index.ts @@ -9,6 +9,7 @@ import { reactConfig, reactNativeConfig, prettierConfig, + unistylesConfig, } from './rules' export const MobileConfig = defineConfig([ @@ -20,6 +21,7 @@ export const MobileConfig = defineConfig([ ...importConfig, ...reactConfig, ...reactNativeConfig, + ...unistylesConfig, globalIgnores([ 'dist/', '.yarn/', diff --git a/configs/eslint/plugin.ts b/configs/eslint/plugin.ts new file mode 100644 index 00000000..e7351792 --- /dev/null +++ b/configs/eslint/plugin.ts @@ -0,0 +1 @@ +export { unistylesPlugin, unistylesConfig } from './rules/unistyles' diff --git a/configs/eslint/rules/index.ts b/configs/eslint/rules/index.ts index ab304c6b..75a60b25 100644 --- a/configs/eslint/rules/index.ts +++ b/configs/eslint/rules/index.ts @@ -6,3 +6,4 @@ export { reactConfig } from './react' export { reactNativeConfig } from './reactNative' export { stylisticConfig } from './stylistic' export { prettierConfig } from './prettier' +export { unistylesConfig } from './unistyles' diff --git a/configs/eslint/rules/unistyles.ts b/configs/eslint/rules/unistyles.ts new file mode 100644 index 00000000..db9ec8db --- /dev/null +++ b/configs/eslint/rules/unistyles.ts @@ -0,0 +1,8 @@ +/** + * ESLint правила для unistyles + * + * Этот файл реэкспортирует конфиг из папки unistyles/. + * Полная документация и реализация в configs/eslint/rules/unistyles/ + */ + +export { unistylesPlugin, unistylesConfig } from './unistyles/index' diff --git a/configs/eslint/rules/unistyles/README.md b/configs/eslint/rules/unistyles/README.md new file mode 100644 index 00000000..28cc1925 --- /dev/null +++ b/configs/eslint/rules/unistyles/README.md @@ -0,0 +1,313 @@ +# ESLint Rules for Unistyles + +Custom ESLint rules для защиты от потери скрытого `unistyles_*` payload при +работе со стилями из `StyleSheet.create()`. + +## Проблема + +`react-native-unistyles` добавляет скрытый payload в каждый объект из +`StyleSheet.create()`. Этот payload содержит информацию о: + +- **Теме** (light/dark и т.д.) +- **Responsive breakpoint** (текущий размер экрана) +- **Unistyles runtime configuration** + +Когда ты распаковываешь объект через spread или деструктуризируешь его, этот +payload **теряется**. Нативная часть больше не может: + +- Применить правильную тему +- Обновить стиль при смене темы/breakpoint +- Корректно интерпретировать значения + +## Правила + +### 1. `unistyles/no-spread-unistyles` (error) + +**Запрещает** распаковывать объекты из `StyleSheet.create()` через spread +оператор. + +#### ❌ Неправильно + +```typescript +const styles = StyleSheet.create({ button: { padding: 12 } }) + +// Spread теряет скрытый payload +const myStyle = { ...styles.button } +const btn = { ...styles.button, marginTop: 10 } + +// Object.assign тоже теряет payload +Object.assign({}, styles.button) +``` + +#### ✅ Правильно + +```typescript +const styles = StyleSheet.create({ button: { padding: 12 } }) + +// Передавай массив стилей (самый безопасный способ) +style={[styles.button, extraStyle]} + +// Или используй напрямую +style={styles.button} + +// Для динамических стилей — массив +style={[ + styles.button, + isActive && styles.buttonActive, +]} +``` + +--- + +### 2. `unistyles/no-unistyles-in-worklet` (error) + +**Запрещает** захватывать переменную `styles` в worklet closures +(`useAnimatedStyle`, `runOnJS` и т.д.). + +Причина: worklet функции передаются в native код, и весь unistyles объект +потеряет скрытый payload при этой передаче. + +#### ❌ Неправильно + +```typescript +const styles = useStyles() + +// ❌ styles захвачена целиком в worklet +const animStyle = useAnimatedStyle(() => ({ color: styles.text.color })) + +// ❌ styles передана в worklet +withSpring(styles.animConfig) + +// ❌ styles в runOnJS +runOnJS(() => console.log(styles.debug)) +``` + +#### ✅ Правильно + +```typescript +const styles = useStyles() + +// ✅ Вытащи примитив ДО worklet +const color = styles.text.color as string +const animConfig = styles.animConfig + +const animStyle = useAnimatedStyle(() => ({ + color, // Теперь это просто строка +})) + +withSpring(animConfig) // Примитив передан + +// Если нужны разные типы — распакуй явно +const { width, height } = styles.icon +runOnJS(() => { + console.log(width, height) // Примитивы +}) +``` + +--- + +### 3. `unistyles/no-spread-icon-styles` (warn) + +**Предупреждает** о spread unistyles объектов при передаче в Icon компоненты. + +Лучше передавать явные props для понимаемости и безопасности. + +#### ❌ Не рекомендуется + +```typescript +const styles = StyleSheet.create({ + icon: { width: 24, height: 24, color: 'red' } +}) + +// Spread скрывает, какие props передаются + + +``` + +#### ✅ Рекомендуется + +```typescript +const styles = StyleSheet.create({ + icon: { width: 24, height: 24, color: 'red' } +}) + +// Явные props — лучше видна структура + + +// Или если нужны переменные +const color = styles.icon.color +const width = styles.icon.width + +``` + +--- + +## Пример потери payload + +```typescript +const styles = StyleSheet.create({ + text: useColorScheme() === 'dark' + ? { color: '#fff' } + : { color: '#000' } +}) + +// ❌ Потеря payload при spread +const myStyle = { ...styles.text } // payload потерян, цвет не обновится при смене темы + +// ✅ Payload сохранён +const myStyle = styles.text // нет payload потерь +style={styles.text} // payload сохранён +``` + +--- + +## Как исправить существующий код + +### 1. Spread в объектах → используй массив + +```typescript +// ❌ Было +{ ...styles.button, marginTop: 10 } + +// ✅ Стало +[styles.button, { marginTop: 10 }] +``` + +### 2. Worklets → вытащи примитив перед worklet + +```typescript +// ❌ Было +useAnimatedStyle(() => ({ color: styles.text.color })) + +// ✅ Стало +const color = styles.text.color +useAnimatedStyle(() => ({ color })) +``` + +### 3. Icon spreads → явные props + +```typescript +// ❌ Было + + +// ✅ Стало + +``` + +--- + +## Подключение в проекте-потребителе + +Правила экспортируются из пакета через subpath `@cdek-it/react-native-ui-kit/eslint`. +Требуется ESLint ≥ 9 (flat config). + +### 1. Подключи плагин в `eslint.config.mjs` + +```js +// eslint.config.mjs +import { unistylesPlugin } from '@cdek-it/react-native-ui-kit/eslint' + +export default [ + { + files: ['src/**/*.{ts,tsx}'], // ← укажи свои пути + plugins: { unistyles: unistylesPlugin }, + rules: { + 'unistyles/no-spread-unistyles': 'error', + 'unistyles/no-unistyles-in-worklet': 'error', + 'unistyles/no-spread-icon-styles': 'warn', + }, + }, +] +``` + +Если уже используешь `defineConfig`: + +```js +import { defineConfig } from 'eslint/config' +import { unistylesPlugin } from '@cdek-it/react-native-ui-kit/eslint' + +export default defineConfig([ + { + files: ['src/**/*.{ts,tsx}'], + plugins: { unistyles: unistylesPlugin }, + rules: { + 'unistyles/no-spread-unistyles': 'error', + 'unistyles/no-unistyles-in-worklet': 'error', + 'unistyles/no-spread-icon-styles': 'warn', + }, + }, +]) +``` + +### 2. Проверь, что всё работает + +```bash +npx eslint src/ +``` + +--- + +## Использование (внутри репозитория UI kit) + +Правила автоматически включены в конфиг ESLint для всех файлов в +`src/**/*.{ts,tsx}`. + +### Конфигурация + +```typescript +rules: { + 'unistyles/no-spread-unistyles': 'error', // ⛔ Критичная ошибка + 'unistyles/no-unistyles-in-worklet': 'error', // ⛔ Критичная ошибка + 'unistyles/no-spread-icon-styles': 'warn', // ⚠️ Рекомендация +} +``` + +### Проверить нарушения + +```bash +npm run lint:check +``` + +### Автоматическое исправление + +```bash +npm run lint:fix +``` + +--- + +## Как это работает + +Правила используют **ESLint AST (Abstract Syntax Tree)** для отслеживания: + +1. **SpreadElement** — ловит `{ ...styles.foo }` +2. **CallExpression** — ловит `Object.assign({}, styles.foo)` +3. **Identifier / MemberExpression** — проверяет захват `styles` в worklet + closures +4. **JSXSpreadAttribute** — ловит `{...styles}` в JSX + +Это гарантирует, что скрытый `unistyles_*` payload не будет случайно потерян при +refactoring или во время разработки. + +--- + +## Структура + +``` +configs/eslint/rules/unistyles/ +├── index.ts - Все правила и конфиг +├── types.ts - Типы для AST узлов +└── README.md - Эта документация +``` + +--- + +## Ссылки + +- [Unistyles Documentation](https://www.unistyl.es) +- [ESLint Custom Rules Guide](https://eslint.org/docs/developer-guide/working-with-rules) +- [Unistyles with Reanimated](https://www.unistyl.es/v3/guides/reanimated/) diff --git a/configs/eslint/rules/unistyles/index.ts b/configs/eslint/rules/unistyles/index.ts new file mode 100644 index 00000000..21521fdb --- /dev/null +++ b/configs/eslint/rules/unistyles/index.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'eslint/config' + +import { noSpreadIconStyles } from './noSpreadIconStyles' +import { noSpreadUnistyles } from './noSpreadUnistyles' +import { noUnistylesInWorklet } from './noUnistylesInWorklet' + +export const unistylesPlugin = { + rules: { + 'no-spread-unistyles': noSpreadUnistyles, + 'no-unistyles-in-worklet': noUnistylesInWorklet, + 'no-spread-icon-styles': noSpreadIconStyles, + }, +} + +export const unistylesConfig = defineConfig([ + { + files: ['src/**/*.{ts,tsx}'], + plugins: { unistyles: unistylesPlugin }, + rules: { + 'unistyles/no-spread-unistyles': 'error', + 'unistyles/no-unistyles-in-worklet': 'error', + 'unistyles/no-spread-icon-styles': 'warn', + }, + }, +]) diff --git a/configs/eslint/rules/unistyles/noSpreadIconStyles.ts b/configs/eslint/rules/unistyles/noSpreadIconStyles.ts new file mode 100644 index 00000000..bcb7e18e --- /dev/null +++ b/configs/eslint/rules/unistyles/noSpreadIconStyles.ts @@ -0,0 +1,46 @@ +import type { ASTNode, RuleContext } from './types' + +/** + * Правило: не спредить styles в Icon компонентах + * ❌ + * ✅ + */ +export const noSpreadIconStyles = { + meta: { + type: 'suggestion' as const, + docs: { + description: 'Передавай явные props для Icon вместо spread', + category: 'Best Practices', + recommended: 'warn' as const, + }, + messages: { + noSpread: + 'Не спредь styles в Icon. Передавай явные props: width, height, color', + }, + }, + create(context: RuleContext) { + return { + JSXSpreadAttribute(node: ASTNode) { + // Проверяем, что это styles.something + const arg = node.argument + + if ( + arg?.type === 'MemberExpression' && + arg.object?.name && + arg.object.name.includes('styles') + ) { + // Проверяем, что мы в Icon компоненте + const parent = node.parent + + if (parent?.type === 'JSXOpeningElement') { + const tagName = parent.name?.name || '' + + if (tagName.includes('Icon')) { + context.report({ node, messageId: 'noSpread' }) + } + } + } + }, + } + }, +} diff --git a/configs/eslint/rules/unistyles/noSpreadUnistyles.ts b/configs/eslint/rules/unistyles/noSpreadUnistyles.ts new file mode 100644 index 00000000..51e581d8 --- /dev/null +++ b/configs/eslint/rules/unistyles/noSpreadUnistyles.ts @@ -0,0 +1,58 @@ +import type { ASTNode, RuleContext } from './types' + +/** + * Правило: не распаковывать стили из unistyles + * ❌ { ...styles.button } + * ❌ const btn = { ...styles.button, marginTop: 10 } + * ✅ [styles.button, customStyle] + */ +export const noSpreadUnistyles = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Запретить spread операции на стилях из StyleSheet.create() — теряется unistyles metadata', + category: 'Best Practices', + recommended: 'error' as const, + }, + messages: { + noSpread: + 'Не распаковывай стили через spread ({...styles}). Это теряет unistyles metadata. Используй массив: [styles.button, customStyle]', + }, + }, + create(context: RuleContext) { + return { + SpreadElement(node: ASTNode) { + // Проверяем: { ...styles.foo } + const arg = node.argument + + if ( + arg?.type === 'MemberExpression' && + arg.object?.type === 'Identifier' && + arg.object.name?.includes('styles') + ) { + context.report({ node, messageId: 'noSpread' }) + } + }, + + CallExpression(node: ASTNode) { + // Проверяем Object.assign({}, styles.foo) + if ( + node.callee?.object?.name === 'Object' && + node.callee?.property?.name === 'assign' + ) { + for (const arg of node.arguments || []) { + if ( + arg?.type === 'MemberExpression' && + arg.object?.name?.includes('styles') + ) { + context.report({ node, messageId: 'noSpread' }) + + return + } + } + } + }, + } + }, +} diff --git a/configs/eslint/rules/unistyles/noUnistylesInWorklet.ts b/configs/eslint/rules/unistyles/noUnistylesInWorklet.ts new file mode 100644 index 00000000..6a1d7189 --- /dev/null +++ b/configs/eslint/rules/unistyles/noUnistylesInWorklet.ts @@ -0,0 +1,91 @@ +import type { ASTNode, RuleContext } from './types' + +/** + * Правило: не захватывать styles в worklet closures + * ❌ useAnimatedStyle(() => ({ color: styles.text.color })) + */ +export const noUnistylesInWorklet = { + meta: { + type: 'problem' as const, + docs: { + description: 'Запретить захват styles переменных в worklet closures', + category: 'Best Practices', + recommended: 'error' as const, + }, + messages: { + noCapture: + 'Не захватывай styles переменную в worklet. Вытащи примитив перед: const color = styles.text.color', + }, + }, + create(context: RuleContext) { + const workletNames = new Set([ + 'useAnimatedStyle', + 'useAnimatedReaction', + 'runOnJS', + 'runOnUIThread', + 'withTiming', + 'withSpring', + 'withDecay', + 'withDelay', + 'withSequence', + 'withRepeat', + ]) + + const skipKeys = new Set(['parent', 'loc', 'range', 'start', 'end']) + + const hasStylesReference = (node: ASTNode): boolean => { + if (!node) return false + + if (node.type === 'Identifier' && node.name && node.name === 'styles') { + return true + } + + if ( + node.type === 'MemberExpression' && + node.object?.type === 'Identifier' && + node.object.name === 'styles' + ) { + return true + } + + for (const key of Object.keys(node)) { + if (skipKeys.has(key)) { + // eslint-disable-next-line no-continue + continue + } + + const child = node[key] + + if (Array.isArray(child)) { + if (child.some(hasStylesReference)) return true + } else if (child && typeof child === 'object') { + if (hasStylesReference(child)) return true + } + } + + return false + } + + return { + CallExpression(node: ASTNode) { + const funcName = node.callee?.name + + if (!funcName || !workletNames.has(funcName)) return + + const fn = node.arguments?.[0] + + if ( + !fn || + (fn.type !== 'ArrowFunctionExpression' && + fn.type !== 'FunctionExpression') + ) { + return + } + + if (hasStylesReference(fn.body)) { + context.report({ node: fn, messageId: 'noCapture' }) + } + }, + } + }, +} diff --git a/configs/eslint/rules/unistyles/types.ts b/configs/eslint/rules/unistyles/types.ts new file mode 100644 index 00000000..31f4a7a6 --- /dev/null +++ b/configs/eslint/rules/unistyles/types.ts @@ -0,0 +1,10 @@ +import type { Rule } from 'eslint' + +export type RuleContext = Rule.RuleContext + +/** + * ESLint AST узлы имеют сложную типизацию с дискриминированными типами. + * Используем more practical подход с type guards и indexed access. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ASTNode = any diff --git a/eslint.config.ts b/eslint.config.ts index 509fd717..8b9966fb 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -7,7 +7,21 @@ export default defineConfig([ { files: ['configs/eslint/**/*'], rules: { 'max-lines': 'off' } }, { ignores: [ + 'node_modules/**/*', + '.expo/**/*', + '.git/**/*', + '.idea/**/*', 'dist/**/*', + 'build/**/*', + 'coverage/**/*', + '**/*.min.js', + '.gradle/**/*', + 'android/**/*', + 'ios/**/*', + '.yarn/**/*', + '.vscode/**/*', + '.jest/**/*', + '.gemini/**/*', '.storybook/**/*', 'configs/cz-conventional-mobile/**/*', ], diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4f3054a0..69c230d6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -64,6 +64,29 @@ PODS: - hermes-engine (0.81.5): - hermes-engine/Pre-built (= 0.81.5) - hermes-engine/Pre-built (0.81.5) + - NitroModules (0.35.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - RCTDeprecation (0.81.5) - RCTRequired (0.81.5) - RCTTypeSafety (0.81.5): @@ -1896,7 +1919,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNReanimated (4.1.1): + - RNReanimated (4.2.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1918,10 +1941,10 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNReanimated/reanimated (= 4.1.1) + - RNReanimated/reanimated (= 4.2.1) - RNWorklets - Yoga - - RNReanimated/reanimated (4.1.1): + - RNReanimated/reanimated (4.2.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1943,10 +1966,10 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNReanimated/reanimated/apple (= 4.1.1) + - RNReanimated/reanimated/apple (= 4.2.1) - RNWorklets - Yoga - - RNReanimated/reanimated/apple (4.1.1): + - RNReanimated/reanimated/apple (4.2.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2015,7 +2038,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNWorklets (0.5.1): + - RNWorklets (0.7.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2037,9 +2060,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNWorklets/worklets (= 0.5.1) + - RNWorklets/worklets (= 0.7.1) - Yoga - - RNWorklets/worklets (0.5.1): + - RNWorklets/worklets (0.7.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2061,9 +2084,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNWorklets/worklets/apple (= 0.5.1) + - RNWorklets/worklets/apple (= 0.7.1) - Yoga - - RNWorklets/worklets/apple (0.5.1): + - RNWorklets/worklets/apple (0.7.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2086,6 +2109,29 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - Unistyles (3.2.3): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - Yoga (0.0.0) DEPENDENCIES: @@ -2099,6 +2145,7 @@ DEPENDENCIES: - ExpoModulesCore (from `../node_modules/expo-modules-core`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - NitroModules (from `../node_modules/react-native-nitro-modules`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`) - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) @@ -2173,6 +2220,7 @@ DEPENDENCIES: - RNReanimated (from `../node_modules/react-native-reanimated`) - RNSVG (from `../node_modules/react-native-svg`) - RNWorklets (from `../node_modules/react-native-worklets`) + - Unistyles (from `../node_modules/react-native-unistyles`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -2201,6 +2249,8 @@ EXTERNAL SOURCES: hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782 + NitroModules: + :path: "../node_modules/react-native-nitro-modules" RCTDeprecation: :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" RCTRequired: @@ -2347,6 +2397,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-svg" RNWorklets: :path: "../node_modules/react-native-worklets" + Unistyles: + :path: "../node_modules/react-native-unistyles" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -2362,6 +2414,7 @@ SPEC CHECKSUMS: FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 ForkInputMask: 55e3fbab504b22da98483e9f9a6514b98fdd2f3c hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 + NitroModules: 76063cb7bc1a21cf46d11b25abfcf1759bf0be47 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c @@ -2432,9 +2485,10 @@ SPEC CHECKSUMS: RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1 RNGestureHandler: 723f29dac55e25f109d263ed65cecc4b9c4bd46a - RNReanimated: 6e0147e13f8906f63703143f40237f84347e6ca1 + RNReanimated: 8a7182314bb7afc01041a529e409a9112c007a50 RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522 - RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0 + RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd + Unistyles: 6bb7e273c90d75b4f6bcb68fd1b94fb7b246145b Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a PODFILE CHECKSUM: 64cb709f656081a8373e391252a6bd658b57e3c1 diff --git a/jest.config.ts b/jest.config.ts index b5a706d8..f0700547 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -14,7 +14,7 @@ const config: Config.InitialOptions = { coverageReporters: ['text', 'text-summary'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], testRunner: 'jest-circus', - maxWorkers: 4, + maxWorkers: '100%', rootDir: '.', moduleNameMapper: { '\\.svg': '/__mocks__/svgMock.js' }, setupFiles: ['/jest.setup.ts'], diff --git a/jest.d.ts b/jest.d.ts index a86a4041..d41f79d0 100644 --- a/jest.d.ts +++ b/jest.d.ts @@ -3,3 +3,9 @@ type PropertyCombinations = { [K in keyof T]: Array } declare let generatePropsCombinations: ( properties: PropertyCombinations ) => T[] + +declare module '@react-native/normalize-colors' { + const normalizeColors: (color: string | number) => number | null + + export default normalizeColors +} diff --git a/jest.setup.ts b/jest.setup.ts index 5fbb78c5..8c1471d8 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,9 +1,184 @@ import 'jest-extended' +import { createElement } from 'react' import 'react-native-gesture-handler/jestSetup' import { setUpTests } from 'react-native-reanimated' +import 'react-native-unistyles/mocks' setUpTests() +type ThemeName = 'light' | 'dark' + +jest.mock('react-native-worklets', () => + require('react-native-worklets/src/mock') +) + +interface MockedUnistylesModule { + StyleSheet: { create: (...args: unknown[]) => unknown } + UnistylesRuntime: { + miniRuntime: unknown + setTheme: jest.Mock + updateTheme: jest.Mock unknown]> + themeName: ThemeName | undefined + } + useUnistyles: jest.Mock + withUnistyles: jest.Mock +} + +const unistyles = jest.requireMock( + 'react-native-unistyles' +) as MockedUnistylesModule + +// Late-bound holder: темы подгружаются ПОСЛЕ установки моков, чтобы к моменту +// загрузки src/theme (и как следствие — src/utils/SvgUniversal с +// `withUnistyles(...)` на top-level) в модуле уже стояли наши моки, а не +// исходные identity-заглушки. +const themeRef: { + current: { darkTheme: unknown; lightTheme: unknown } | null +} = { current: null } + +const getTheme = (themeName: ThemeName | undefined) => + themeName === 'dark' + ? themeRef.current?.darkTheme + : themeRef.current?.lightTheme + +const runtime = unistyles.UnistylesRuntime + +runtime.themeName = 'light' +runtime.setTheme = jest.fn((themeName: ThemeName) => { + runtime.themeName = themeName +}) +runtime.updateTheme = jest.fn() + +unistyles.useUnistyles = jest.fn(() => ({ + theme: getTheme(runtime.themeName), + rt: runtime, +})) + +const normalizeVariantValue = (value: unknown) => { + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + return value +} + +const resolveStyle = ( + style: Record, + activeVariants: Record +) => { + const { variants, compoundVariants, ...baseStyle } = style as { + variants?: Record>> + compoundVariants?: Array> + } + + const resolvedStyle: Record = { ...baseStyle } + + if (variants) { + for (const [variantName, variantMap] of Object.entries(variants)) { + const activeValue = normalizeVariantValue(activeVariants[variantName]) + + if ( + activeValue !== undefined && + variantMap[activeValue as keyof typeof variantMap] + ) { + Object.assign( + resolvedStyle, + variantMap[activeValue as keyof typeof variantMap] + ) + } + } + } + + if (compoundVariants) { + for (const compoundVariant of compoundVariants) { + const { styles, ...conditions } = compoundVariant + const matches = Object.entries(conditions).every( + ([variantName, expectedValue]) => + normalizeVariantValue(activeVariants[variantName]) === + normalizeVariantValue(expectedValue) + ) + + if (matches && styles && typeof styles === 'object') { + Object.assign(resolvedStyle, styles) + } + } + } + + return resolvedStyle +} + +unistyles.StyleSheet.create = jest.fn( + (stylesheet: ((theme: ReturnType) => unknown) | unknown) => { + const styleDefinitions = + typeof stylesheet === 'function' + ? stylesheet(getTheme(runtime.themeName)) + : stylesheet + + const activeVariants: Record = {} + const resolvedStyles: Record = { + useVariants: (variants: Record) => { + Object.keys(activeVariants).forEach((key) => { + activeVariants[key] = undefined + }) + + Object.assign(activeVariants, variants) + }, + } + + for (const [styleName, styleValue] of Object.entries( + styleDefinitions as Record + )) { + Object.defineProperty(resolvedStyles, styleName, { + enumerable: true, + get() { + if ( + styleValue && + typeof styleValue === 'object' && + !Array.isArray(styleValue) + ) { + return resolveStyle( + styleValue as Record, + activeVariants + ) + } + + return styleValue + }, + }) + } + + return resolvedStyles + } +) + +unistyles.withUnistyles = jest.fn( +

>( + Component: React.ComponentType

+ ): React.ComponentType< + P & { uniProps?: (theme: unknown, rt: unknown) => Partial

} + > => { + const Wrapped = ( + props: P & { + readonly uniProps?: (theme: unknown, rt: unknown) => Partial

+ } + ) => { + const { uniProps, ...rest } = props + const themeProps = uniProps + ? uniProps(getTheme(runtime.themeName), runtime) + : {} + + return createElement(Component, { ...(rest as P), ...themeProps }) + } + + return Wrapped + } +) + +themeRef.current = require('./src/theme') as { + darkTheme: unknown + lightTheme: unknown +} + generatePropsCombinations = (properties: PropertyCombinations): T[] => { const keys = Object.keys(properties) as Array diff --git a/package.json b/package.json index 6016ff80..0a43382d 100644 --- a/package.json +++ b/package.json @@ -24,17 +24,23 @@ "files": [ "dist" ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, "scripts": { "build": "rm -rf dist && tsc -p tsconfig.build.json", "test": "jest", - "start": "expo start --dev-client", + "start": "expo start --dev-client --clear", "android": "expo run:android", "ios": "expo run:ios --no-install", "storybook-generate": "sb-rn-get-stories --config-path .storybook && sed -i -e 's/export const view = global.view/export const view: ReturnType = global.view/' .storybook/storybook.requires.ts && prettier .storybook --write", "doctor": "expo-doctor", "check": "expo install --check", - "lint:check": "eslint .", - "lint:fix": "eslint --fix .", + "lint:check": "eslint --cache .", + "lint:fix": "eslint --fix --cache .", "prettier:check": "prettier . --check", "prettier:fix": "prettier . --write", "prettier:watch": "onchange . -- prettier --write --ignore-unknown \"{{changed}}\"", @@ -106,10 +112,12 @@ "react-native": "0.81.5", "react-native-advanced-input-mask": "1.4.6", "react-native-gesture-handler": "2.29.1", - "react-native-reanimated": "4.1.1", + "react-native-nitro-modules": "0.35.2", + "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "5.6.2", "react-native-svg": "15.15.1", - "react-native-worklets": "0.5.1", + "react-native-unistyles": "3.2.3", + "react-native-worklets": "0.7.1", "release-it": "19.1.0", "standard-version": "9.5.0", "storybook": "10.1.10", @@ -123,8 +131,10 @@ "expo": ">=54.x.x", "react": ">=19.1", "react-native": ">=0.81.5", - "react-native-reanimated": ">=4.1.1", - "react-native-svg": ">=15.15.1" + "react-native-reanimated": ">=4.2.1", + "react-native-svg": ">=15.15.1", + "react-native-unistyles": ">=3.2.3", + "react-native-worklets": ">=0.7.0" }, "peerDependenciesMeta": { "expo": { diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx index 9af5bde7..76ffb39e 100644 --- a/src/components/Accordion/Accordion.tsx +++ b/src/components/Accordion/Accordion.tsx @@ -10,8 +10,9 @@ import Animated, { import type { ViewProps } from 'react-native-svg/lib/typescript/fabric/utils' +import { StyleSheet } from 'react-native-unistyles' + import { type SvgSource, SvgUniversal } from '../../utils/SvgUniversal' -import { makeStyles } from '../../utils/makeStyles' export interface AccordionProps extends ViewProps { /** Иконка слева от заголовка */ @@ -50,8 +51,6 @@ export const Accordion: React.FC = ({ children, ...rest }) => { - const styles = useStyles() - const contentHeight = useSharedValue(0) const contentOpenFraction = useSharedValue(initiallyExpanded ? 1 : 0) const [isExpanded, setIsExpanded] = useState(initiallyExpanded) @@ -107,13 +106,22 @@ export const Accordion: React.FC = ({ style={arrowAnimatedStyle} testID={AccordionTestIds.arrow} > - + ({ + color: theme.Panel.Accordion.accordionHeaderTextColor, + })} + /> {Icon ? ( ({ + color: theme.Panel.Accordion.accordionHeaderTextColor, + })} /> ) : null} {title} @@ -151,7 +159,7 @@ export const AccordionTestIds = { separator: 'Separator', } -const useStyles = makeStyles(({ theme, fonts }) => ({ +const styles = StyleSheet.create(({ theme, fonts }) => ({ component: { width: '100%' }, header: { paddingVertical: theme.Panel.Accordion.accordionHeaderPaddingTopBottom, @@ -160,11 +168,7 @@ const useStyles = makeStyles(({ theme, fonts }) => ({ alignItems: 'center', backgroundColor: theme.Panel.Accordion.accordionHeaderBg, }, - icon: { - width: 17.5, - height: 17.5, - color: theme.Panel.Accordion.accordionHeaderTextColor, - }, + icon: { width: 17.5, height: 17.5 }, title: { fontSize: 15.75, includeFontPadding: false, diff --git a/src/components/Accordion/__tests__/__snapshots__/Accordion.test.tsx.snap b/src/components/Accordion/__tests__/__snapshots__/Accordion.test.tsx.snap index 163abe4c..88883a29 100644 --- a/src/components/Accordion/__tests__/__snapshots__/Accordion.test.tsx.snap +++ b/src/components/Accordion/__tests__/__snapshots__/Accordion.test.tsx.snap @@ -121,6 +121,7 @@ exports[`Accordion Header elements maximal 1`] = ` }, ] } + testID="SvgUniversalComponent" vbHeight={24} vbWidth={24} width={17.5} @@ -168,6 +169,7 @@ exports[`Accordion Header elements maximal 1`] = ` strokeLinecap={1} strokeLinejoin={1} strokeWidth={2} + testID="SvgUniversalComponent" /> @@ -344,7 +346,7 @@ exports[`Accordion Header elements maximal 1`] = ` strokeWidth={2} > diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index bfae1fba..83938627 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -21,8 +21,9 @@ import { type ViewStyle, } from 'react-native' +import { StyleSheet } from 'react-native-unistyles' + import { type SvgSource, SvgUniversal } from '../../utils/SvgUniversal' -import { makeStyles } from '../../utils/makeStyles' export type AvatarSize = 'xlarge' | 'large' | 'normal' @@ -137,7 +138,6 @@ export const Avatar = memo( onError, iconColor, }) => { - const styles = useStyles() const window = useWindowDimensions() const [badgeLayout, setBadgeLayout] = useState() @@ -185,14 +185,16 @@ export const Avatar = memo( return ( ({ + color: iconColor ?? theme.Misc.Avatar.avatarTextColor, + })} width={iconSize} /> ) - }, [Icon, calculatedSize, iconColor, size, styles, type]) + }, [Icon, calculatedSize, iconColor, size, type]) useEffect(() => { if (badge) { @@ -255,7 +257,7 @@ export const Avatar = memo( } ) -const useStyles = makeStyles(({ theme, border, typography, fonts }) => ({ +const styles = StyleSheet.create(({ theme, border, typography, fonts }) => ({ container: { justifyContent: 'center', alignItems: 'center', @@ -277,10 +279,7 @@ const useStyles = makeStyles(({ theme, border, typography, fonts }) => ({ }, badgeContainer: { position: 'absolute', right: 0, top: -7 }, badgeMeasureContainer: { alignSelf: 'flex-start' }, - icon: { - width: typography.Size['text-base'], - color: theme.Misc.Avatar.avatarTextColor, - }, + icon: { width: typography.Size['text-base'] }, iconXLarge: { width: typography.Size['text-2xl'] }, })) diff --git a/src/components/Avatar/__tests__/__snapshots__/Avatar.test.tsx.snap b/src/components/Avatar/__tests__/__snapshots__/Avatar.test.tsx.snap index 734dacdd..5aae92ff 100644 --- a/src/components/Avatar/__tests__/__snapshots__/Avatar.test.tsx.snap +++ b/src/components/Avatar/__tests__/__snapshots__/Avatar.test.tsx.snap @@ -1702,15 +1702,15 @@ exports[`Avatar component tests With badge, showBadge: true 1`] = ` diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 185b14cb..8b0e5f6a 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -11,7 +11,7 @@ import { } from 'react-native' import type { ViewProps } from 'react-native-svg/lib/typescript/fabric/utils' -import { makeStyles } from '../../utils/makeStyles' +import { StyleSheet } from 'react-native-unistyles' export type BadgeSeverity = 'basic' | 'info' | 'success' | 'warning' | 'danger' @@ -52,7 +52,7 @@ export type BadgeProps = BadgeText | BadgeDot */ export const Badge = memo( ({ children, dot, severity = 'basic', style, testID, ...rest }) => { - const styles = useStyles() + badgeStyles.useVariants({ severity }) const [textLayout, setTextLayout] = useState() const onTextLayout = useCallback((e: LayoutChangeEvent) => { @@ -60,18 +60,21 @@ export const Badge = memo( }, []) return ( - + {dot ? ( - + ) : ( <> {children} @@ -81,12 +84,12 @@ export const Badge = memo( {children} @@ -100,41 +103,53 @@ export const Badge = memo( } ) -const useStyles = makeStyles(({ theme, border, typography, fonts }) => ({ - container: { alignItems: 'flex-start' }, - dot: { - width: theme.Misc.Badge.badgeDotSize, - height: theme.Misc.Badge.badgeDotSize, - borderRadius: border.Radius['rounded-full'], - }, - textBadgeContainer: { - height: theme.Misc.Badge.badgeHeight, - paddingHorizontal: theme.Misc.Tag.tagPadding, - justifyContent: 'center', - borderRadius: border.Radius['rounded-full'], - }, - textBadge: { - color: theme.Misc.Badge.badgeTextColor, - fontSize: typography.Size['text-xs'], - includeFontPadding: false, - verticalAlign: 'middle', - fontFamily: fonts.primary, - }, - basic: { backgroundColor: theme.Misc.Badge.badgeBg }, - info: { backgroundColor: theme.Button.Severity.Info.Basic.infoButtonBg }, - success: { - backgroundColor: theme.Button.Severity.Success.Basic.successButtonBg, - }, - warning: { - backgroundColor: theme.Button.Severity.Warning.Basic.warningButtonBg, - }, - danger: { - backgroundColor: theme.Button.Severity.Danger.Basic.dangerButtonBg, - }, - hiddenContainer: { - width: Dimensions.get('window').width, - height: 0, - flexDirection: 'row', - position: 'absolute', - }, -})) +const badgeStyles = StyleSheet.create( + ({ theme, border, typography, fonts }) => ({ + container: { alignItems: 'flex-start' }, + dot: { + variants: { + severity: { + basic: { backgroundColor: theme.Misc.Badge.badgeBg }, + info: { + backgroundColor: theme.Button.Severity.Info.Basic.infoButtonBg, + }, + success: { + backgroundColor: + theme.Button.Severity.Success.Basic.successButtonBg, + }, + warning: { + backgroundColor: + theme.Button.Severity.Warning.Basic.warningButtonBg, + }, + danger: { + backgroundColor: theme.Button.Severity.Danger.Basic.dangerButtonBg, + }, + }, + }, + }, + dotShape: { + width: theme.Misc.Badge.badgeDotSize, + height: theme.Misc.Badge.badgeDotSize, + borderRadius: border.Radius['rounded-full'], + }, + textBadgeContainer: { + height: theme.Misc.Badge.badgeHeight, + paddingHorizontal: theme.Misc.Tag.tagPadding, + justifyContent: 'center', + borderRadius: border.Radius['rounded-full'], + }, + textBadge: { + color: theme.Misc.Badge.badgeTextColor, + fontSize: typography.Size['text-xs'], + includeFontPadding: false, + verticalAlign: 'middle', + fontFamily: fonts.primary, + }, + hiddenContainer: { + width: Dimensions.get('window').width, + height: 0, + flexDirection: 'row', + position: 'absolute', + }, + }) +) diff --git a/src/components/Badge/__tests__/__snapshots__/Badge.test.tsx.snap b/src/components/Badge/__tests__/__snapshots__/Badge.test.tsx.snap index e5b425a8..e4b181ae 100644 --- a/src/components/Badge/__tests__/__snapshots__/Badge.test.tsx.snap +++ b/src/components/Badge/__tests__/__snapshots__/Badge.test.tsx.snap @@ -14,14 +14,14 @@ exports[`Badge component tests dot, severity: basic 1`] = ` @@ -42,14 +42,14 @@ exports[`Badge component tests dot, severity: danger 1`] = ` @@ -70,14 +70,14 @@ exports[`Badge component tests dot, severity: info 1`] = ` @@ -98,14 +98,14 @@ exports[`Badge component tests dot, severity: success 1`] = ` @@ -126,14 +126,14 @@ exports[`Badge component tests dot, severity: warning 1`] = ` @@ -154,15 +154,15 @@ exports[`Badge component tests severity: basic 1`] = ` @@ -235,15 +235,15 @@ exports[`Badge component tests severity: basic 2`] = ` @@ -316,15 +316,15 @@ exports[`Badge component tests severity: basic 3`] = ` @@ -397,15 +397,15 @@ exports[`Badge component tests severity: basic 4`] = ` @@ -478,15 +478,15 @@ exports[`Badge component tests severity: basic 5`] = ` @@ -559,15 +559,15 @@ exports[`Badge component tests severity: default 1`] = ` @@ -642,14 +642,14 @@ exports[`Badge component tests with custom style 1`] = ` diff --git a/src/components/Button/BaseButton.tsx b/src/components/Button/BaseButton.tsx index 4b604e3b..5e752b27 100644 --- a/src/components/Button/BaseButton.tsx +++ b/src/components/Button/BaseButton.tsx @@ -1,21 +1,21 @@ import { useCallback, useState } from 'react' - import type { GestureResponderEvent } from 'react-native' import { genericMemo } from '../../utils/genericMemo' -import type { ButtonProps, ButtonVariant, VariantStyles } from './types' +import type { ButtonProps, ButtonVariant } from './types' import { ButtonLeftArea, ButtonRightArea, ButtonLabel, ButtonContainer, } from './utils' +import { ButtonPressedContext } from './utils/ButtonPressedContext' export type BaseButtonComponentProps = Omit< ButtonProps, 'variant' -> & { readonly variant: Variant } & VariantStyles +> & { readonly variant: Variant } const BaseButtonComponent = ({ size = 'base', @@ -28,15 +28,11 @@ const BaseButtonComponent = ({ Icon, label, style, - containerVariantStyles, - labelVariantStyles, - pressedVariantStyles, - iconVariantStyles, - pressedLabelVariantStyles, onPressIn: onPressInProp, onPressOut: onPressOutProp, ...props }: BaseButtonComponentProps) => { + const isDisabled = !!disabled const [pressed, setPressed] = useState(false) const onPressIn = useCallback( @@ -56,62 +52,31 @@ const BaseButtonComponent = ({ ) return ( - - - - + - + {...props} + > + + + + + ) } diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 1ac8c549..5e2ca5e7 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,8 +1,8 @@ -import { memo } from 'react' +import { memo, useMemo } from 'react' import { BaseButton } from './BaseButton' -import { useBasicButtonStyles } from './styles' import type { ButtonBaseVariant, ButtonProps } from './types' +import { ButtonVariantContext } from './utils/ButtonVariantContext' /** * Button component @@ -20,8 +20,12 @@ import type { ButtonBaseVariant, ButtonProps } from './types' */ export const Button = memo>( ({ variant = 'primary', ...props }) => { - const buttonStyles = useBasicButtonStyles() + const variantContextValue = useMemo(() => ({ variant }), [variant]) - return + return ( + + + + ) } ) diff --git a/src/components/Button/ButtonBadge.tsx b/src/components/Button/ButtonBadge.tsx index 1dfad821..14e10ef6 100644 --- a/src/components/Button/ButtonBadge.tsx +++ b/src/components/Button/ButtonBadge.tsx @@ -6,12 +6,13 @@ import { type ViewStyle, } from 'react-native' -import { makeStyles } from '../../utils/makeStyles' +import { StyleSheet } from 'react-native-unistyles' + import { Badge } from '../Badge' import { BaseButton } from './BaseButton' -import { useBasicButtonStyles } from './styles' import type { ButtonBadgeProps, ButtonBaseVariant, ButtonProps } from './types' +import { ButtonVariantContext } from './utils/ButtonVariantContext' /** * Button component with badge @@ -32,9 +33,8 @@ import type { ButtonBadgeProps, ButtonBaseVariant, ButtonProps } from './types' export const ButtonBadge = memo< ButtonProps & ButtonBadgeProps >(({ badgeLabel, badgeSeverity, variant = 'primary', ...props }) => { - const buttonStyles = useBasicButtonStyles() - const styles = useStyles() const [badgeLayout, setBadgeLayout] = useState() + const variantContextValue = useMemo(() => ({ variant }), [variant]) const badgeContainerStyle = useMemo( () => ({ @@ -56,32 +56,34 @@ export const ButtonBadge = memo< ) return ( - - - + + + + - {badgeLabel ? ( - - {badgeLabel} - - ) : ( - - )} + {badgeLabel ? ( + + {badgeLabel} + + ) : ( + + )} + - + ) }) -const useStyles = makeStyles(() => ({ +const styles = StyleSheet.create(() => ({ root: { flexDirection: 'row' }, contentContainer: { flex: 1 }, iconOnlyContainer: { flex: 0 }, diff --git a/src/components/Button/ButtonSeverity.tsx b/src/components/Button/ButtonSeverity.tsx index 0f017a3d..978588ce 100644 --- a/src/components/Button/ButtonSeverity.tsx +++ b/src/components/Button/ButtonSeverity.tsx @@ -1,12 +1,12 @@ -import { memo } from 'react' +import { memo, useMemo } from 'react' import { BaseButton } from './BaseButton' -import { useSeverityButtonStyles } from './styles' import type { ButtonProps, ButtonSeverityProps, ButtonSeverityVariant, } from './types' +import { ButtonVariantContext } from './utils/ButtonVariantContext' /** * Button component @@ -26,7 +26,14 @@ import type { export const ButtonSeverity = memo< ButtonProps & ButtonSeverityProps >(({ severity, variant = 'basic', ...props }) => { - const buttonStyles = useSeverityButtonStyles(severity) + const variantContextValue = useMemo( + () => ({ variant, severity }), + [severity, variant] + ) - return + return ( + + + + ) }) diff --git a/src/components/Button/__tests__/Button.test.tsx b/src/components/Button/__tests__/Button.test.tsx index 716456ad..c2abe708 100644 --- a/src/components/Button/__tests__/Button.test.tsx +++ b/src/components/Button/__tests__/Button.test.tsx @@ -34,7 +34,7 @@ describe('Button component tests', () => { 'Button with icon on left, size - $size, shape - $shape, variant - $variant, loading - $loading, disabled - $disabled', (props) => { const renderedButton = render( -