diff --git a/.changeset/little-points-ask.md b/.changeset/little-points-ask.md new file mode 100644 index 0000000000..f782552302 --- /dev/null +++ b/.changeset/little-points-ask.md @@ -0,0 +1,13 @@ +--- +'@storybook/react-native-ui-common': patch +'@storybook/addon-ondevice-backgrounds': patch +'@storybook/react-native-theming': patch +'@storybook/react-native-ui-lite': patch +'@storybook/addon-ondevice-controls': patch +'@storybook/addon-ondevice-actions': patch +'@storybook/react-native-ui': patch +'@storybook/addon-ondevice-notes': patch +'@storybook/react-native': patch +--- + +liteui animation changes and select control adjustments diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 02661601f3..aa0ddd0f46 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,7 +25,7 @@ jobs: - name: Set node version uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 26 cache: 'pnpm' - name: Install diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3bf503385d..defe3f2d07 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 26 cache: 'pnpm' registry-url: 'https://registry.npmjs.org' @@ -50,6 +50,10 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Update lockfile (canary) + if: inputs.release_type == 'canary' + run: pnpm install --no-frozen-lockfile + - name: Publish (canary) if: inputs.release_type == 'canary' run: pnpm changeset publish --tag canary diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad8b3dc54f..3687fc1776 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - name: Set node version uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 26 cache: 'pnpm' - name: install and compile run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 32f7564f36..3cdcb860a7 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ examples/expo-example/.maestro/output/* examples/expo-example/components/PerfTesting/*.stories.tsx examples/expo-example/.certs/ +examples/expo-example/android +examples/expo-example/ios diff --git a/.nvmrc b/.nvmrc index f677377056..6f4247a625 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.11.1 \ No newline at end of file +26 diff --git a/.prettierignore b/.prettierignore index 71e29b148d..7b19f79400 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,5 +9,6 @@ examples/repack-example/.rnstorybook/storybook.requires.ts docs/.docusaurus docs/build .claude/ +.zed/ .changeset/ examples/expo-example/assets diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000000..4ae6354a61 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,13 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "code_actions_on_format": { + "source.fixAll.eslint": true, + }, + "format_on_save": "on", + "formatter": "prettier", + + "language_servers": ["!biome", "eslint", "vtsls"], +} diff --git a/docs/package.json b/docs/package.json index 554ee02c7b..ec1f190e3f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -34,16 +34,16 @@ "@mdx-js/react": "^3.1.1", "clsx": "^2.1.1", "prism-react-renderer": "^2.4.1", - "react": "19.2.0", - "react-dom": "19.2.0" + "react": "19.2.3", + "react-dom": "19.2.3" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.10.0", "@docusaurus/tsconfig": "^3.10.0", "@docusaurus/types": "^3.10.0", - "typescript": "~5.9.3" + "typescript": "~6.0.3" }, "engines": { - "node": ">=22.18.0" + "node": ">=26.0.0" } } diff --git a/docs/tsconfig.json b/docs/tsconfig.json index 920d7a6523..fe30ca2a2c 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -1,8 +1,5 @@ { // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@docusaurus/tsconfig", - "compilerOptions": { - "baseUrl": "." - }, "exclude": [".docusaurus", "build"] } diff --git a/examples/expo-example/.rnstorybook/index.tsx b/examples/expo-example/.rnstorybook/index.tsx index 9167ca0436..ec054e0000 100644 --- a/examples/expo-example/.rnstorybook/index.tsx +++ b/examples/expo-example/.rnstorybook/index.tsx @@ -1,7 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { LiteUI } from '@storybook/react-native-ui-lite'; import { StatusBar, View } from 'react-native'; -import { SafeAreaView, SafeAreaProvider } from 'react-native-safe-area-context'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { view } from './storybook.requires'; const isScreenshotTesting = process.env.EXPO_PUBLIC_SCREENSHOT_TESTING === 'true'; diff --git a/examples/expo-example/.rnstorybook/storybook.requires.ts b/examples/expo-example/.rnstorybook/storybook.requires.ts index 3af3167251..6e5a4cada9 100644 --- a/examples/expo-example/.rnstorybook/storybook.requires.ts +++ b/examples/expo-example/.rnstorybook/storybook.requires.ts @@ -65,7 +65,7 @@ const annotations = [ globalThis.STORIES = normalizedStories; globalThis.STORYBOOK_WEBSOCKET = { - host: '192.168.86.21', + host: '192.168.1.171', port: 7007, secured: false, }; diff --git a/examples/expo-example/app.json b/examples/expo-example/app.json index 8f6f1f6595..06db8b5ce5 100644 --- a/examples/expo-example/app.json +++ b/examples/expo-example/app.json @@ -39,5 +39,5 @@ "updates": { "url": "https://u.expo.dev/ebfab020-5dff-44a5-a29e-a0a6e6c175b9" }, - "plugins": ["@react-native-community/datetimepicker"] + "plugins": ["@react-native-community/datetimepicker", "expo-sqlite"] } diff --git a/examples/expo-example/babel.config.js b/examples/expo-example/babel.config.js index 372219dca6..5b75777918 100644 --- a/examples/expo-example/babel.config.js +++ b/examples/expo-example/babel.config.js @@ -5,7 +5,13 @@ module.exports = function (api) { return { presets: [['babel-preset-expo']], plugins: [ - ['babel-plugin-react-docgen-typescript', { exclude: 'node_modules' }], + [ + 'babel-plugin-react-docgen-typescript', + { + include: 'examples/expo-example/.*\\.tsx$', + exclude: 'node_modules', + }, + ], 'react-native-worklets/plugin', ], }; diff --git a/examples/expo-example/components/ControlExamples/Radio/Radio.stories.tsx b/examples/expo-example/components/ControlExamples/Radio/Radio.stories.tsx index cd2abb2d9d..c1b1636f03 100644 --- a/examples/expo-example/components/ControlExamples/Radio/Radio.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Radio/Radio.stories.tsx @@ -23,3 +23,15 @@ export const Basic: Story = { selection: radio_stations[0], }, }; + +export const Inline: Story = { + argTypes: { + selection: { + options: radio_stations, + control: { type: 'inline-radio' }, + }, + }, + args: { + selection: radio_stations[1], + }, +}; diff --git a/examples/expo-example/components/ControlExamples/Radio/Radio.test.tsx b/examples/expo-example/components/ControlExamples/Radio/Radio.test.tsx index 950287581e..19e8d53dec 100644 --- a/examples/expo-example/components/ControlExamples/Radio/Radio.test.tsx +++ b/examples/expo-example/components/ControlExamples/Radio/Radio.test.tsx @@ -2,10 +2,16 @@ import { render, screen } from '@testing-library/react-native'; import { composeStories } from '@storybook/react'; import * as RadioStories from './Radio.stories'; -const { Basic } = composeStories(RadioStories); +const { Basic, Inline } = composeStories(RadioStories); test('radio story renders', async () => { await render(); screen.getByText('104.8MHz'); }); + +test('inline radio story renders', async () => { + await render(); + + screen.getByText('909 kHz'); +}); diff --git a/examples/expo-example/components/ControlExamples/Select/Select.stories.tsx b/examples/expo-example/components/ControlExamples/Select/Select.stories.tsx index 84a6058553..7e6e12af25 100644 --- a/examples/expo-example/components/ControlExamples/Select/Select.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Select/Select.stories.tsx @@ -3,6 +3,8 @@ import { SelectExample } from './Select'; const arrows = { ArrowUp: '⬆', ArrowDown: '⬇', ArrowLeft: '⬅️', ArrowRight: '➡️' }; +const manyArrows = Array.from({ length: 40 }, (_, index) => `Option ${index + 1}`); + const meta = { component: SelectExample, argTypes: { @@ -70,3 +72,33 @@ export const WithMapping: Story = { }, }, }; + +export const ManyOptions: Story = { + args: { + arrow: manyArrows[0], + }, + + argTypes: { + arrow: { + options: manyArrows, + control: { + type: 'select', + }, + }, + }, +}; + +export const MultiSelect: Story = { + args: { + arrow: [manyArrows[0], manyArrows[1], manyArrows[2]], + }, + + argTypes: { + arrow: { + options: manyArrows, + control: { + type: 'multi-select', + }, + }, + }, +}; diff --git a/examples/expo-example/components/ControlExamples/Select/Select.tsx b/examples/expo-example/components/ControlExamples/Select/Select.tsx index 0473320006..7bcdcd1489 100644 --- a/examples/expo-example/components/ControlExamples/Select/Select.tsx +++ b/examples/expo-example/components/ControlExamples/Select/Select.tsx @@ -1,7 +1,9 @@ import { Text } from 'react-native'; export interface Props { - arrow: string; + arrow: string | string[]; } -export const SelectExample = ({ arrow }: Props) => Selected: {arrow}; +export const SelectExample = ({ arrow }: Props) => ( + Selected: {Array.isArray(arrow) ? arrow.join(', ') : arrow} +); diff --git a/examples/expo-example/package.json b/examples/expo-example/package.json index 2fb9383f07..2f63c111d0 100644 --- a/examples/expo-example/package.json +++ b/examples/expo-example/package.json @@ -4,20 +4,21 @@ "private": true, "main": "index.js", "scripts": { - "android": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --android", + "prebuild": "expo prebuild", + "android": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo run:android", "build-web-storybook": "storybook build", "check": "tsc --noEmit", - "disabled-example": "expo start", + "disabled-example": "expo start --dev-client", "e2e": "maestro test .maestro/storybook-screenshots.yaml --test-output-dir .maestro/output", "e2e:baseline": "maestro test .maestro/storybook-screenshots.capture.yaml --test-output-dir .maestro/output", "eas-build-post-install": "cd ../.. && pnpm build", "format": "prettier --write .", "gen-maestro": "npx rn-storybook-test@alpha gen-maestro", - "ios": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --ios", + "ios": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo run:ios", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", - "storybook": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start", - "storybook:lite": "EXPO_PUBLIC_STORYBOOK_ENABLED=true EXPO_PUBLIC_LITE_UI=true expo start", - "storybook:secure": "pnpm storybook:secure:cert && EXPO_PUBLIC_STORYBOOK_ENABLED=true EXPO_PUBLIC_STORYBOOK_WS_SECURED=true expo start", + "storybook": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --dev-client", + "storybook:lite": "EXPO_PUBLIC_STORYBOOK_ENABLED=true EXPO_PUBLIC_LITE_UI=true expo start --dev-client", + "storybook:secure": "pnpm storybook:secure:cert && EXPO_PUBLIC_STORYBOOK_ENABLED=true EXPO_PUBLIC_STORYBOOK_WS_SECURED=true expo start --dev-client", "storybook:secure:cert": "./scripts/generate-dev-cert.sh", "storybook:test": "EXPO_PUBLIC_SCREENSHOT_TESTING=true EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start", "storybook:web": "storybook dev -p 6006", @@ -30,11 +31,12 @@ "web": "EXPO_PUBLIC_STORYBOOK_ENABLED=true expo start --web" }, "dependencies": { - "@expo/metro-runtime": "~55.0.7", + "@expo/dom-webview": "^56.0.4", + "@expo/metro-runtime": "~56.0.5", "@gorhom/bottom-sheet": "^5.2.8", "@react-native-async-storage/async-storage": "2.2.0", - "@react-native-community/datetimepicker": "8.6.0", - "@react-native-community/slider": "5.1.2", + "@react-native-community/datetimepicker": "9.1.0", + "@react-native-community/slider": "5.2.0", "@storybook/addon-ondevice-actions": "^10.4.0", "@storybook/addon-ondevice-backgrounds": "^10.4.0", "@storybook/addon-ondevice-controls": "^10.4.0", @@ -45,18 +47,19 @@ "@storybook/react-native-ui-lite": "^10.4.0", "@storybook/react-native-web-vite": "^10.3.2", "babel-plugin-react-compiler": "^1.0.0", - "expo": "^55.0.14", - "expo-updates": "~55.0.16", - "react": "19.2.0", + "expo": "56.0.0-preview.7", + "expo-sqlite": "~56.0.3", + "expo-updates": "~56.0.6", + "react": "19.2.3", "react-compiler-runtime": "^1.0.0", - "react-dom": "19.2.0", - "react-native": "0.83.4", - "react-native-gesture-handler": "~2.30.0", - "react-native-reanimated": "~4.2.1", - "react-native-safe-area-context": "^5", - "react-native-svg": "15.15.3", + "react-dom": "19.2.3", + "react-native": "0.85.3", + "react-native-gesture-handler": "~2.31.2", + "react-native-reanimated": "~4.3.0", + "react-native-safe-area-context": "^5.7.0", + "react-native-svg": "15.15.4", "react-native-web": "^0.21.2", - "react-native-worklets": "0.7.2", + "react-native-worklets": "0.8.3", "storybook": "^10.3.2", "storybook-addon-deep-controls": "^0.10.0", "ws": "^8.20.0" @@ -66,14 +69,15 @@ "@dannyhw/rozenite-storybook": "0.0.2", "@rozenite/metro": "^1.6.0", "@testing-library/react-native": "14.0.0-beta.0", + "@types/jest": "^29.5.13", "@types/react": "~19.2.14", "@types/ws": "^8.18.1", "babel-plugin-react-docgen-typescript": "^1.5.1", "expo-atlas": "^0.4.3", "jest": "^29.7.0", - "jest-expo": "~55.0.11", + "jest-expo": "~56.0.0", "test-renderer": "^0.15.0", - "typescript": "~5.9.3", + "typescript": "~6.0.3", "vite": "^8.0.5" } } diff --git a/examples/expo-example/tsconfig.json b/examples/expo-example/tsconfig.json index e934dea30a..ff6e5c7112 100644 --- a/examples/expo-example/tsconfig.json +++ b/examples/expo-example/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { "skipLibCheck": true, - "baseUrl": "./", "strict": true, - "esModuleInterop": true + "esModuleInterop": true, + "types": ["jest"] }, "extends": "expo/tsconfig.base", "include": [".rnstorybook/**/*", ".storybook/**/*", "./*"] diff --git a/examples/expo-new-wrapper-example/app.json b/examples/expo-new-wrapper-example/app.json index e16ad4de62..0d28a4de81 100644 --- a/examples/expo-new-wrapper-example/app.json +++ b/examples/expo-new-wrapper-example/app.json @@ -15,5 +15,6 @@ }, "experiments": { "tsconfigPaths": true - } + }, + "plugins": ["@react-native-community/datetimepicker"] } diff --git a/examples/expo-new-wrapper-example/package.json b/examples/expo-new-wrapper-example/package.json index d25b9e4ee5..533478decb 100644 --- a/examples/expo-new-wrapper-example/package.json +++ b/examples/expo-new-wrapper-example/package.json @@ -13,11 +13,11 @@ "check:types": "tsc --noEmit" }, "dependencies": { - "@expo/metro-runtime": "~55.0.7", + "@expo/metro-runtime": "~56.0.5", "@gorhom/bottom-sheet": "^5.2.8", "@react-native-async-storage/async-storage": "2.2.0", - "@react-native-community/datetimepicker": "8.6.0", - "@react-native-community/slider": "5.1.2", + "@react-native-community/datetimepicker": "9.1.0", + "@react-native-community/slider": "5.2.0", "@storybook/addon-ondevice-actions": "^10.4.0", "@storybook/addon-ondevice-backgrounds": "^10.4.0", "@storybook/addon-ondevice-controls": "^10.4.0", @@ -28,18 +28,18 @@ "@storybook/react-native-ui-lite": "^10.4.0", "@storybook/react-native-web-vite": "^10.3.2", "babel-plugin-react-compiler": "^1.0.0", - "expo": "^55.0.14", - "expo-updates": "~55.0.16", - "react": "19.2.0", + "expo": "56.0.0-preview.7", + "expo-updates": "~56.0.6", + "react": "19.2.3", "react-compiler-runtime": "^1.0.0", - "react-dom": "19.2.0", - "react-native": "0.83.4", - "react-native-gesture-handler": "~2.30.0", - "react-native-reanimated": "~4.2.1", - "react-native-safe-area-context": "^5", - "react-native-svg": "15.15.3", + "react-dom": "19.2.3", + "react-native": "0.85.3", + "react-native-gesture-handler": "~2.31.2", + "react-native-reanimated": "~4.3.0", + "react-native-safe-area-context": "^5.7.0", + "react-native-svg": "15.15.4", "react-native-web": "^0.21.2", - "react-native-worklets": "0.7.2", + "react-native-worklets": "0.8.3", "storybook": "^10.3.2", "storybook-addon-deep-controls": "^0.10.0", "ws": "^8.20.0" @@ -54,9 +54,9 @@ "babel-plugin-react-docgen-typescript": "^1.5.1", "expo-atlas": "^0.4.3", "jest": "^29.7.0", - "jest-expo": "~55.0.11", + "jest-expo": "~56.0.0", "test-renderer": "^0.15.0", - "typescript": "~5.9.3", + "typescript": "~6.0.3", "vite": "^8.0.5" } } diff --git a/examples/expo-new-wrapper-example/tsconfig.json b/examples/expo-new-wrapper-example/tsconfig.json index f500ad9fc5..633e757d64 100644 --- a/examples/expo-new-wrapper-example/tsconfig.json +++ b/examples/expo-new-wrapper-example/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "skipLibCheck": true, - "baseUrl": "./", "strict": true, "esModuleInterop": true }, diff --git a/examples/expo-router-example/app.json b/examples/expo-router-example/app.json index 84bc7a9704..1f717d90b0 100644 --- a/examples/expo-router-example/app.json +++ b/examples/expo-router-example/app.json @@ -40,7 +40,8 @@ } ], "expo-image", - "expo-web-browser" + "expo-web-browser", + "expo-font" ], "experiments": { "typedRoutes": true, diff --git a/examples/expo-router-example/package.json b/examples/expo-router-example/package.json index 6ed486eaa1..510f40f012 100644 --- a/examples/expo-router-example/package.json +++ b/examples/expo-router-example/package.json @@ -25,35 +25,35 @@ "@storybook/react-native": "^10.4.0", "@storybook/react-native-ui-lite": "^10.4.0", "@storybook/react-native-web-vite": "^10.3.2", - "expo": "^55.0.14", - "expo-constants": "~55.0.15", - "expo-font": "~55.0.6", - "expo-haptics": "~55.0.14", - "expo-image": "~55.0.9", - "expo-linking": "~55.0.14", - "expo-router": "~55.0.13", - "expo-splash-screen": "~55.0.19", - "expo-status-bar": "~55.0.5", - "expo-symbols": "~55.0.7", - "expo-system-ui": "~55.0.16", - "expo-web-browser": "~55.0.14", - "react": "19.2.0", - "react-dom": "19.2.0", - "react-native": "0.83.4", - "react-native-gesture-handler": "~2.30.0", - "react-native-reanimated": "~4.2.1", - "react-native-safe-area-context": "^5", - "react-native-screens": "~4.23.0", + "expo": "56.0.0-preview.7", + "expo-constants": "~56.0.6", + "expo-font": "~56.0.3", + "expo-haptics": "~56.0.3", + "expo-image": "~56.0.4", + "expo-linking": "~56.0.4", + "expo-router": "~56.1.1", + "expo-splash-screen": "~56.0.4", + "expo-status-bar": "~56.0.4", + "expo-symbols": "~56.0.5", + "expo-system-ui": "~56.0.4", + "expo-web-browser": "~56.0.4", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.85.3", + "react-native-gesture-handler": "~2.31.2", + "react-native-reanimated": "~4.3.0", + "react-native-safe-area-context": "^5.7.0", + "react-native-screens": "4.25.0-beta.3", "react-native-web": "^0.21.2", - "react-native-worklets": "0.7.2", + "react-native-worklets": "0.8.3", "storybook": "^10.3.2", "ws": "^8.20.0" }, "devDependencies": { "@types/react": "~19.2.14", "eslint": "^9.39.4", - "eslint-config-expo": "^55.0.0", - "typescript": "~5.9.3" + "eslint-config-expo": "^56.0.2", + "typescript": "~6.0.3" }, "private": true } diff --git a/examples/repack-example/package.json b/examples/repack-example/package.json index 03e85d6833..abf35bbdc5 100644 --- a/examples/repack-example/package.json +++ b/examples/repack-example/package.json @@ -14,10 +14,10 @@ "pods:update": "(cd ios && bundle install && bundle exec pod update)" }, "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/datetimepicker": "9.1.0", + "@react-native-community/slider": "5.2.0", "@react-native/new-app-screen": "0.83.4", - "react": "19.2.0", - "react-native": "0.83.4", - "react-native-safe-area-context": "^5", "@storybook/addon-ondevice-actions": "^10.4.0", "@storybook/addon-ondevice-backgrounds": "^10.4.0", "@storybook/addon-ondevice-controls": "^10.4.0", @@ -25,10 +25,10 @@ "@storybook/react": "^10.3.2", "@storybook/react-native": "^10.4.0", "@storybook/react-native-ui-lite": "^10.4.0", - "storybook": "^10.3.2", - "@react-native-async-storage/async-storage": "2.2.0", - "@react-native-community/datetimepicker": "8.6.0", - "@react-native-community/slider": "5.1.2" + "react": "19.2.3", + "react-native": "0.85.3", + "react-native-safe-area-context": "^5.7.0", + "storybook": "^10.3.2" }, "devDependencies": { "@babel/core": "^7.26.0", @@ -53,7 +53,7 @@ "prettier": "^3.8.1", "react-native-test-app": "^5.1.4", "react-test-renderer": "19.2.0", - "typescript": "~5.9.3" + "typescript": "~6.0.3" }, "engines": { "node": ">=20" diff --git a/package.json b/package.json index e5a24d9ca2..0cd360b7b2 100644 --- a/package.json +++ b/package.json @@ -49,16 +49,16 @@ "@prettier/plugin-oxc": "^0.1.3", "cross-env": "^10.1.0", "eslint": "^9.39.4", - "eslint-config-expo": "^55.0.0", + "eslint-config-expo": "^56.0.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "7.0.1", "prettier": "^3.8.1", "sherif": "^1.11.0", - "typescript": "~5.9.3" + "typescript": "~6.0.3" }, - "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", + "packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38", "engines": { - "node": ">=22.18.0" + "node": ">=26.0.0" }, "pnpm": { "overrides": { diff --git a/packages/ondevice-actions/package.json b/packages/ondevice-actions/package.json index 9410d6e38d..2f0993193d 100644 --- a/packages/ondevice-actions/package.json +++ b/packages/ondevice-actions/package.json @@ -33,7 +33,7 @@ }, "devDependencies": { "storybook": "^10.3.2", - "typescript": "~5.9.3" + "typescript": "~6.0.3" }, "peerDependencies": { "react": "*", diff --git a/packages/ondevice-actions/src/containers/ActionLogger/index.tsx b/packages/ondevice-actions/src/containers/ActionLogger/index.tsx index 2f953e68ba..491710aa56 100644 --- a/packages/ondevice-actions/src/containers/ActionLogger/index.tsx +++ b/packages/ondevice-actions/src/containers/ActionLogger/index.tsx @@ -6,7 +6,7 @@ import { ActionDisplay, EVENT_ID } from 'storybook/actions'; import { ActionLogger as ActionLoggerComponent } from '../../components/ActionLogger'; interface ActionLoggerProps { - active: boolean; + active?: boolean; } const safeDeepEqual = (a: any, b: any): boolean => { diff --git a/packages/ondevice-backgrounds/package.json b/packages/ondevice-backgrounds/package.json index 9339f30fae..f4fb0cb723 100644 --- a/packages/ondevice-backgrounds/package.json +++ b/packages/ondevice-backgrounds/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "storybook": "^10.3.2", - "typescript": "~5.9.3" + "typescript": "~6.0.3" }, "peerDependencies": { "react": "*", diff --git a/packages/ondevice-backgrounds/src/BackgroundPanel.tsx b/packages/ondevice-backgrounds/src/BackgroundPanel.tsx index 72cd31e4e1..bcbc3acb35 100644 --- a/packages/ondevice-backgrounds/src/BackgroundPanel.tsx +++ b/packages/ondevice-backgrounds/src/BackgroundPanel.tsx @@ -62,7 +62,7 @@ export type Channel = ReturnType; interface BackgroundPanelProps { channel: Channel; api: API; - active: boolean; + active?: boolean; } const BackgroundPanel = ({ active, api, channel }: BackgroundPanelProps) => { diff --git a/packages/ondevice-backgrounds/tsconfig.json b/packages/ondevice-backgrounds/tsconfig.json index f628b09b74..312fa793bd 100644 --- a/packages/ondevice-backgrounds/tsconfig.json +++ b/packages/ondevice-backgrounds/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "rootDir": "./src", "outDir": "dist/" }, "include": ["src/**/*"], diff --git a/packages/ondevice-controls/package.json b/packages/ondevice-controls/package.json index ce4f116eb3..dd085fa90e 100644 --- a/packages/ondevice-controls/package.json +++ b/packages/ondevice-controls/package.json @@ -42,7 +42,7 @@ "@gorhom/bottom-sheet": "*", "cross-env": "^10.1.0", "storybook": "^10.3.2", - "typescript": "~5.9.3" + "typescript": "~6.0.3" }, "peerDependencies": { "@gorhom/bottom-sheet": ">=4", diff --git a/packages/ondevice-controls/src/Panel.tsx b/packages/ondevice-controls/src/Panel.tsx index f3934bba05..c85e55d487 100644 --- a/packages/ondevice-controls/src/Panel.tsx +++ b/packages/ondevice-controls/src/Panel.tsx @@ -1,6 +1,11 @@ -import React from 'react'; +import { ReactNode } from 'react'; -export const AddonPanel = ({ active, children }) => { +interface AddonPanelProps { + active?: boolean; + children: ReactNode; +} + +export const AddonPanel = ({ children }: AddonPanelProps) => { return <>{children}; }; diff --git a/packages/ondevice-controls/src/components/RadioSelect.tsx b/packages/ondevice-controls/src/components/RadioSelect.tsx index 6118e7348b..2aeb628928 100644 --- a/packages/ondevice-controls/src/components/RadioSelect.tsx +++ b/packages/ondevice-controls/src/components/RadioSelect.tsx @@ -7,39 +7,44 @@ interface RadioProps { isInline: boolean; } +const radioHitSlop = { top: 8, right: 12, bottom: 8, left: 12 }; + const RadioContainer = styled.View<{ isInline: boolean }>(({ isInline }) => ({ flexDirection: isInline ? 'row' : 'column', alignItems: isInline ? 'center' : 'flex-start', flexWrap: 'wrap', - gap: 10, + gap: isInline ? 8 : 2, })); -const RadioTouchable = styled.TouchableOpacity(() => ({ +const RadioTouchable = styled.TouchableOpacity<{ isInline: boolean }>(({ isInline }) => ({ alignItems: 'center', flexDirection: 'row', + minHeight: isInline ? 36 : 32, + paddingVertical: isInline ? 4 : 2, + paddingRight: 6, })); const RadioCircle = styled.View(({ theme }) => ({ - width: 16, - height: 16, - borderWidth: 1, + width: 20, + height: 20, + borderWidth: 2, borderColor: theme.appBorderColor, - borderRadius: 8, + borderRadius: 10, backgroundColor: theme.background.content, alignItems: 'center', justifyContent: 'center', })); const RadioInnerCircle = styled.View<{ selected: boolean }>(({ theme, selected }) => ({ - width: 8, - height: 8, - borderRadius: 4, + width: 10, + height: 10, + borderRadius: 5, backgroundColor: selected ? theme.color.positive : theme.background.content, })); const RadioLabel = styled.Text(({ theme }) => ({ - fontSize: theme.typography.size.s1, - paddingStart: 10, + fontSize: theme.typography.size.s1 + 1, + paddingStart: 12, color: theme.color.defaultText, })); @@ -49,7 +54,11 @@ const RadioSelect = ({ data = [], value = '', onChange, isInline }: RadioProps) {data.map((item) => ( { onChange(item); }} diff --git a/packages/ondevice-controls/src/components/SelectModal.tsx b/packages/ondevice-controls/src/components/SelectModal.tsx index 7b77793d6e..fd0bd24ceb 100644 --- a/packages/ondevice-controls/src/components/SelectModal.tsx +++ b/packages/ondevice-controls/src/components/SelectModal.tsx @@ -1,614 +1,266 @@ -// NOTE This is adapted from react-native-modal-selector https://github.com/peacechen/react-native-modal-selector/blob/master/index.js - -import { ComponentType, ReactNode, useCallback, useMemo, useState } from 'react'; - +import { styled } from '@storybook/react-native-theming'; +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { - FlatList, ScrollView, - StyleProp, StyleSheet, - Text, - TextStyle, TouchableOpacity, TouchableWithoutFeedback, + useWindowDimensions, View, - ViewProps, - ViewStyle, } from 'react-native'; import { ModalPortal } from './ModalPortal'; -const PADDING = 8; -const BORDER_RADIUS = 5; -const FONT_SIZE = 16; -const HIGHLIGHT_COLOR = 'rgba(0,118,255,0.9)'; - -type SupportedOrientation = - | 'portrait' - | 'portrait-upside-down' - | 'landscape' - | 'landscape-left' - | 'landscape-right'; +export interface SelectOptionItem { + key: any; + label: string; +} interface SelectModalProps { - data: any[]; - onChange?: (item: any | any[]) => void; - onModalOpen?: () => void; - onModalClose?: () => void; - keyExtractor?: (item: any) => string; - labelExtractor?: (item: any) => string; - visible?: boolean; - closeOnChange?: boolean; - initValue?: string | string[]; - listType?: 'SCROLLVIEW' | 'FLATLIST'; - animationType?: 'none' | 'slide' | 'fade'; - style?: StyleProp; - selectStyle?: StyleProp; - selectTextStyle?: StyleProp; - optionStyle?: StyleProp; - optionTextStyle?: StyleProp; - optionContainerStyle?: StyleProp; - sectionStyle?: StyleProp; - childrenContainerStyle?: StyleProp; - touchableStyle?: StyleProp; - touchableActiveOpacity?: number; - sectionTextStyle?: StyleProp; - selectedItemTextStyle?: StyleProp; - cancelContainerStyle?: StyleProp; - cancelStyle?: StyleProp; - cancelTextStyle?: StyleProp; - overlayStyle?: StyleProp; - initValueTextStyle?: StyleProp; - cancelText?: string; - disabled?: boolean; - supportedOrientations?: SupportedOrientation[]; - keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled'; - backdropPressToClose?: boolean; - openButtonContainerAccessible?: boolean; - listItemAccessible?: boolean; - cancelButtonAccessible?: boolean; - scrollViewAccessible?: boolean; - scrollViewAccessibilityLabel?: string; - cancelButtonAccessibilityLabel?: string; - passThruProps?: any; - selectTextPassThruProps?: any; - optionTextPassThruProps?: any; - cancelTextPassThruProps?: any; - scrollViewPassThruProps?: any; - modalOpenerHitSlop?: any; - customSelector?: ReactNode; - selectedKey?: any; - children?: ReactNode; - header?: ReactNode; - optionsTestIDPrefix?: string; - onEndReached?: () => void; - multiselect?: boolean; - selectedSeparator?: string; - maxSelectedItems?: number; - selectedItemIndicatorStyle?: StyleProp; - selectedItemIndicatorColor?: string; - doneText?: string; - onDone?: (selectedItems: any[]) => void; + options: SelectOptionItem[]; + value: any; + multiple?: boolean; + onChange: (value: any) => void; + children: ReactNode; } const styles = StyleSheet.create({ - overlayStyle: { + overlay: { flex: 1, padding: '5%', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.7)', }, - - optionContainer: { - borderRadius: BORDER_RADIUS, - flexShrink: 1, - marginBottom: 8, - padding: PADDING, - backgroundColor: 'rgba(255,255,255,0.8)', - }, - - cancelContainer: { - alignSelf: 'stretch', - marginTop: 8, - }, - - selectStyle: { - borderColor: '#ccc', - borderWidth: 1, - padding: PADDING, - borderRadius: BORDER_RADIUS, - }, - - selectTextStyle: { - textAlign: 'center', - color: '#333', - fontSize: FONT_SIZE, - }, - - cancelStyle: { - borderRadius: BORDER_RADIUS, - backgroundColor: 'rgba(255,255,255,0.8)', - padding: PADDING, - }, - - cancelTextStyle: { - textAlign: 'center', - color: '#333', - fontSize: FONT_SIZE, + scrollContent: { + padding: 8, + paddingHorizontal: 10, }, - - optionStyle: { - paddingVertical: PADDING, + option: { + minHeight: 34, + paddingVertical: 8, paddingHorizontal: 16, borderBottomWidth: 1, - borderBottomColor: '#ccc', }, - - optionTextStyle: { - fontSize: FONT_SIZE, - color: '#333', - flex: 1, - textAlign: 'left', + lastOption: { + borderBottomWidth: 0, }, - - sectionStyle: { - padding: PADDING * 2, - borderBottomWidth: 1, - borderBottomColor: '#ccc', - }, - - sectionTextStyle: { - textAlign: 'center', - fontSize: FONT_SIZE, + optionContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', }, - - initValueTextStyle: { - textAlign: 'center', - fontSize: FONT_SIZE, - color: '#d3d3d3', + optionText: { + flex: 1, + textAlign: 'left', }, - - selectedItemIndicator: { + indicator: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center', marginLeft: 8, }, - checkmark: { - fontSize: FONT_SIZE, - color: HIGHLIGHT_COLOR, + fontSize: 16, }, - - optionRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + footer: { + padding: 8, }, - - doneContainer: { - alignSelf: 'stretch', - marginTop: 8, + doneFooter: { + paddingBottom: 4, }, - - doneButton: { - borderRadius: BORDER_RADIUS, - backgroundColor: 'rgba(255,255,255,0.8)', - padding: PADDING, + cancelFooter: { + paddingTop: 4, }, - - doneButtonText: { + buttonText: { textAlign: 'center', - fontSize: FONT_SIZE, - color: HIGHLIGHT_COLOR, }, }); -let componentIndex = 0; +const Dialog = styled.View<{ maxHeight: number }>(({ theme, maxHeight }) => ({ + maxHeight, + marginHorizontal: 24, + backgroundColor: theme.background.content, + borderColor: theme.appBorderColor, + borderWidth: 1, + borderRadius: 8, + overflow: 'hidden', + boxShadow: `0px 8px 24px 0px ${theme.color.border}`, + elevation: 10, +})); + +const OptionButton = styled.View<{ selected: boolean }>(({ theme, selected }) => ({ + borderBottomColor: theme.appBorderColor, + backgroundColor: selected ? theme.color.secondary : undefined, +})); + +const OptionLabel = styled.Text<{ selected: boolean }>(({ theme, selected }) => ({ + color: selected ? theme.color.lightest : theme.color.defaultText, + fontSize: theme.typography.size.s2, + fontWeight: selected ? theme.typography.weight.bold : 'normal', +})); + +const Checkmark = styled.Text(({ theme }) => ({ + color: theme.color.lightest, +})); + +const Footer = styled.View(({ theme }) => ({ + backgroundColor: theme.barBg, +})); + +const FooterButton = styled.View<{ primary?: boolean }>(({ theme, primary }) => ({ + minHeight: 32, + borderRadius: theme.input.borderRadius, + backgroundColor: primary ? theme.color.secondary : theme.button.background, + borderColor: primary ? theme.color.secondary : theme.button.border, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 12, +})); + +const FooterButtonText = styled.Text<{ primary?: boolean }>(({ theme, primary }) => ({ + color: primary ? theme.color.lightest : theme.input.color, + fontSize: theme.typography.size.s1, + fontWeight: theme.typography.weight.bold, +})); + +const optionKey = (option: SelectOptionItem) => String(option.key); + +const valueKeys = (value: any, multiple: boolean) => { + if (multiple) { + return Array.isArray(value) ? value.map(String) : []; + } + + return value === undefined || value === null ? [] : [String(value)]; +}; export const SelectModal = ({ - data = [], + options, + value, + multiple = false, onChange, - onModalOpen, - onModalClose, - keyExtractor = (item: any) => String(item.key || item.id || item), - labelExtractor = (item: any) => String(item.label || item.name || item), - closeOnChange = true, - initValue = '', - listType = 'SCROLLVIEW', - animationType = 'slide', - style, - selectStyle: selectStyleProp, - selectTextStyle: selectTextStyleProp, - optionStyle: optionStyleProp, - optionTextStyle: optionTextStyleProp, - optionContainerStyle, - sectionStyle: sectionStyleProp, - childrenContainerStyle, - touchableStyle, - touchableActiveOpacity = 0.2, - sectionTextStyle: sectionTextStyleProp, - cancelContainerStyle, - cancelStyle: cancelStyleProp, - cancelTextStyle: cancelTextStyleProp, - overlayStyle: overlayStyleProp, - initValueTextStyle: initValueTextStyleProp, - cancelText = 'Cancel', - disabled = false, - supportedOrientations = ['portrait'], - keyboardShouldPersistTaps = 'always', - backdropPressToClose = true, - openButtonContainerAccessible = true, - listItemAccessible = true, - cancelButtonAccessible = true, - scrollViewAccessible = true, - scrollViewAccessibilityLabel, - cancelButtonAccessibilityLabel, - passThruProps, - selectTextPassThruProps, - optionTextPassThruProps, - cancelTextPassThruProps, - scrollViewPassThruProps, - modalOpenerHitSlop, - customSelector, children, - header, - optionsTestIDPrefix = 'select-modal', - onEndReached, - multiselect = false, - selectedSeparator = ', ', - maxSelectedItems, - selectedItemIndicatorStyle, - selectedItemIndicatorColor = HIGHLIGHT_COLOR, - doneText = 'Done', - onDone, }: SelectModalProps) => { - const [modalVisible, setModalVisible] = useState(false); - const [selected, setSelected] = useState( - multiselect ? (Array.isArray(initValue) ? initValue : []) : initValue || '' - ); - const [selectedItems, setSelectedItems] = useState(() => { - if (multiselect) { - if (Array.isArray(initValue)) { - return data.filter((item) => initValue.includes(String(keyExtractor(item)))); - } - return []; - } - // For single select, initialize with the item matching initValue - const initialItem = data.find((item) => String(keyExtractor(item)) === String(initValue)); - return initialItem ? [initialItem] : []; - }); - - const selectedItemsMap = useMemo(() => { - if (multiselect) { - return (selectedItems || []).reduce((acc: Record, item) => { - acc[keyExtractor(item)] = true; - return acc; - }, {}); - } else { - // For single select, find the currently selected item - const selectedItem = data.find((item) => labelExtractor(item) === selected); - return selectedItem ? { [keyExtractor(selectedItem)]: true } : {}; - } - }, [selectedItems, keyExtractor, multiselect, selected, data, labelExtractor]); + const { height } = useWindowDimensions(); + const [visible, setVisible] = useState(false); + const [selectedKeys, setSelectedKeys] = useState(() => valueKeys(value, multiple)); - const open = useCallback(() => { - if (!disabled) { - setModalVisible(true); - onModalOpen?.(); - } - }, [disabled, onModalOpen]); + useEffect(() => { + setSelectedKeys(valueKeys(value, multiple)); + }, [multiple, value]); - const handleLongPress = useCallback(() => { - open(); - }, [open]); + const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]); const close = useCallback(() => { - setModalVisible(false); - onModalClose?.(); - }, [onModalClose]); - - const handleDone = useCallback(() => { - if (multiselect) { - onChange?.(selectedItems); - onDone?.(selectedItems); - } - close(); - }, [multiselect, selectedItems, onChange, onDone, close]); + setVisible(false); + }, []); - const handleChange = useCallback( - (item: any) => { - if (multiselect) { - const itemKey = keyExtractor(item); - const isSelected = selectedItemsMap[itemKey]; - - let newSelectedItems; - if (isSelected) { - newSelectedItems = selectedItems.filter((i) => keyExtractor(i) !== itemKey); - } else { - if (maxSelectedItems && selectedItems.length >= maxSelectedItems) { - return; - } - newSelectedItems = [...selectedItems, item]; - } - - setSelectedItems(newSelectedItems); - setSelected(newSelectedItems.map(labelExtractor).join(selectedSeparator)); - - if (!closeOnChange) { - onChange?.(newSelectedItems); - } - } else { - setSelected(labelExtractor(item)); - if (closeOnChange) { - close(); - } - onChange?.(item); + const open = useCallback(() => { + setSelectedKeys(valueKeys(value, multiple)); + setVisible(true); + }, [multiple, value]); + + const selectOption = useCallback( + (option: SelectOptionItem) => { + const key = optionKey(option); + + if (!multiple) { + onChange(option.key); + close(); + return; } - }, - [ - multiselect, - selectedItems, - selectedItemsMap, - keyExtractor, - labelExtractor, - maxSelectedItems, - closeOnChange, - onChange, - close, - selectedSeparator, - ] - ); - - const renderOption = useCallback( - (option: any, isLastItem: boolean, isFirstItem: boolean) => { - const optionLabel = labelExtractor(option); - const optionKey = keyExtractor(option); - const isSelected = selectedItemsMap[optionKey]; - - const content = ( - <> - - {optionLabel} - - - {isSelected && ( - - )} - - - ); - - return ( - handleChange(option)} - activeOpacity={touchableActiveOpacity} - accessible={listItemAccessible} - accessibilityLabel={option.accessibilityLabel} - importantForAccessibility={isFirstItem ? 'yes' : 'no'} - {...passThruProps} - > - - {content} - - - ); - }, - [ - keyExtractor, - labelExtractor, - handleChange, - touchableActiveOpacity, - listItemAccessible, - passThruProps, - optionStyleProp, - optionTextStyleProp, - optionTextPassThruProps, - optionsTestIDPrefix, - selectedItemsMap, - selectedItemIndicatorStyle, - selectedItemIndicatorColor, - ] - ); - - const renderSection = useCallback( - (section: any) => { - const sectionLabel = labelExtractor(section); - return ( - - {sectionLabel} - + setSelectedKeys((current) => + current.includes(key) ? current.filter((item) => item !== key) : [...current, key] ); }, - [keyExtractor, labelExtractor, sectionStyleProp, sectionTextStyleProp] + [close, multiple, onChange] ); - const renderFlatlistOption = useCallback( - ({ item, index }: { item: any; index: number }) => { - if (item.section) { - return renderSection(item); - } - - return renderOption(item, index === data.length - 1, index === 0); - }, - [renderSection, renderOption, data.length] - ); - - const renderOptionList = useCallback(() => { - const OverlayComponent: ComponentType = backdropPressToClose - ? TouchableWithoutFeedback - : View; - - const key = backdropPressToClose ? `modalSelector${componentIndex++}` : undefined; - const overlayProps = backdropPressToClose - ? { - accessible: false, - onPress: close, - } - : { - style: { flex: 1 }, - }; - - const optionsContainerStyle = { - paddingHorizontal: 10, - ...(scrollViewPassThruProps?.horizontal && { flexDirection: 'row' as const }), - }; - - return ( - - - - {header} - {listType === 'FLATLIST' ? ( - - ) : ( - - - {data.map((item, index) => - item.section - ? renderSection(item) - : renderOption(item, index === data.length - 1, index === 0) - )} - - - )} - - - {multiselect && ( - - - {doneText} - - - )} - - - - - - {cancelText} - - - - - - - ); - }, [ - data, - backdropPressToClose, - close, - scrollViewPassThruProps, - overlayStyleProp, - optionContainerStyle, - header, - listType, - keyboardShouldPersistTaps, - scrollViewAccessible, - scrollViewAccessibilityLabel, - keyExtractor, - renderFlatlistOption, - onEndReached, - renderSection, - renderOption, - cancelContainerStyle, - touchableActiveOpacity, - cancelButtonAccessible, - cancelButtonAccessibilityLabel, - cancelStyleProp, - cancelTextStyleProp, - cancelTextPassThruProps, - cancelText, - multiselect, - handleDone, - doneText, - ]); - - const renderChildren = useCallback(() => { - if (children) { - return children; - } - - const initSelectStyle = - selected === initValue - ? [styles.initValueTextStyle, initValueTextStyleProp] - : [styles.selectTextStyle, selectTextStyleProp]; - - return ( - - - {selected} - - + const commitMultiSelect = useCallback(() => { + onChange( + options + .filter((option) => selectedKeys.includes(optionKey(option))) + .map((option) => option.key) ); - }, [ - children, - selected, - initValue, - initValueTextStyleProp, - selectTextStyleProp, - selectStyleProp, - selectTextPassThruProps, - ]); + close(); + }, [close, onChange, options, selectedKeys]); return ( - + selectedItems.length > 0 && onChange?.(selectedItems)} + animationType="none" > - {renderOptionList()} + + + + + + {options.map((option, index) => { + const key = optionKey(option); + const selected = selectedKeySet.has(key); + + return ( + selectOption(option)} + > + + + + {option.label} + + + {selected && } + + + + + ); + })} + + + {multiple && ( +
+ + + + Done + + + +
+ )} + +
+ + + Cancel + + +
+
+
+
+
- {customSelector || ( - - - {renderChildren()} - - - )} + + {children} +
); }; diff --git a/packages/ondevice-controls/src/components/color-picker/HoloColorPicker.tsx b/packages/ondevice-controls/src/components/color-picker/HoloColorPicker.tsx index 5c1db813d1..29421c38dd 100644 --- a/packages/ondevice-controls/src/components/color-picker/HoloColorPicker.tsx +++ b/packages/ondevice-controls/src/components/color-picker/HoloColorPicker.tsx @@ -1,221 +1,131 @@ -import { PureComponent, createRef } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { I18nManager, Image, - InteractionManager, LayoutChangeEvent, StyleSheet, Text, - TouchableOpacity, View, ViewStyle, } from 'react-native'; import tinycolor, { ColorFormats } from 'tinycolor2'; -import { createPanResponder } from './utils'; import SliderWrapper from '../SliderWrapper'; +import { createPanResponder } from './utils'; interface HoloColorPickerProps { - color?: string | ColorFormats.HSV; - defaultColor?: string; + defaultColor: string; oldColor?: string; - onColorChange?: (color: ColorFormats.HSV) => void; - onColorSelected?: (color: string) => void; - onOldColorSelected?: (color: string) => void; - hideSliders?: boolean; + onColorChange: (color: ColorFormats.HSV) => void; style?: ViewStyle; } -interface HoloColorPickerState { - color: ColorFormats.HSV; - pickerSize: number | null; -} - -export class HoloColorPicker extends PureComponent { - private _layout: { width: number; height: number; x: number; y: number }; - private _pageX: number; - private _pageY: number; - private _isRTL: boolean; - private _pickerResponder: any; - private pickerContainer = createRef(); - - constructor(props: HoloColorPickerProps) { - super(props); - const state: HoloColorPickerState = { - color: { h: 0, s: 1, v: 1 }, - pickerSize: null, - }; - if (props.oldColor) { - state.color = tinycolor(props.oldColor).toHsv(); - } - if (props.defaultColor) { - state.color = tinycolor(props.defaultColor).toHsv(); - } - this.state = state; - this._layout = { width: 0, height: 0, x: 0, y: 0 }; - this._pageX = 0; - this._pageY = 0; - this._isRTL = I18nManager.isRTL; - this._pickerResponder = createPanResponder({ - onStart: this._handleColorChange, - onMove: this._handleColorChange, - }); - } - - getColor() { - return tinycolor(this._getColor()).toHexString(); - } - - _handleColorChange = ({ x, y }: { x: number; y: number }) => { - const { s, v } = this._getColor(); - const marginLeft = (this._layout.width - (this.state.pickerSize ?? 0)) / 2; - const marginTop = (this._layout.height - (this.state.pickerSize ?? 0)) / 2; - const relativeX = x - this._pageX - marginLeft; - const relativeY = y - this._pageY - marginTop; - const h = this._computeHValue(relativeX, relativeY); - this._onColorChange({ h, s, v }); - }; - - _onSValueChange = (s: number) => { - const { h, v } = this._getColor(); - this._onColorChange({ h, s, v }); - }; - - _onVValueChange = (v: number) => { - const { h, s } = this._getColor(); - this._onColorChange({ h, s, v }); - }; +const getHsv = (color: string): ColorFormats.HSV => tinycolor(color).toHsv(); - _onColorChange = (color: ColorFormats.HSV) => { - this.setState({ color }); - if (this.props.onColorChange) { - this.props.onColorChange(color); - } - }; +export function HoloColorPicker({ + defaultColor, + oldColor, + onColorChange, + style, +}: HoloColorPickerProps) { + const [color, setColor] = useState(() => getHsv(defaultColor)); + const [pickerSize, setPickerSize] = useState(0); + + useEffect(() => { + setColor(getHsv(defaultColor)); + }, [defaultColor]); + + const updateColor = useCallback( + (nextColor: ColorFormats.HSV) => { + setColor(nextColor); + onColorChange(nextColor); + }, + [onColorChange] + ); - _onLayout = (l: LayoutChangeEvent) => { - this._layout = l.nativeEvent.layout; - const { width, height } = this._layout; - const pickerSize = Math.min(width, height); - if (this.state.pickerSize !== pickerSize) { - this.setState({ pickerSize }); - } - InteractionManager.runAfterInteractions(() => { - if (this.pickerContainer.current) { - this.pickerContainer.current.measure((_x, _y, _width, _height, pageX, pageY) => { - this._pageX = pageX; - this._pageY = pageY; - }); + const handleHueChange = useCallback( + ({ x, y }: { x: number; y: number }) => { + if (!pickerSize) { + return; } - }); - }; - _getColor() { - const passedColor = - typeof this.props.color === 'string' ? tinycolor(this.props.color).toHsv() : this.props.color; - return passedColor || this.state.color; - } - - _onColorSelected = () => { - const { onColorSelected } = this.props; - const color = tinycolor(this._getColor()).toHexString(); - if (onColorSelected) { - onColorSelected(color); - } - }; - - _onOldColorSelected = () => { - const { oldColor, onOldColorSelected } = this.props; - const color = tinycolor(oldColor!); - this.setState({ color: color.toHsv() }); - if (onOldColorSelected) { - onOldColorSelected(color.toHexString()); - } + const h = computeHue(x, y, pickerSize); + updateColor({ ...color, h }); + }, + [color, pickerSize, updateColor] + ); + + const pickerResponder = useMemo( + () => + createPanResponder({ + onStart: handleHueChange, + onMove: handleHueChange, + }), + [handleHueChange] + ); + + const onLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setPickerSize(Math.min(width, height)); }; - _computeHValue(x: number, y: number) { - const mx = (this.state.pickerSize ?? 0) / 2; - const my = (this.state.pickerSize ?? 0) / 2; - const dx = x - mx; - const dy = y - my; - const rad = Math.atan2(dx, dy) + Math.PI + Math.PI / 2; - return ((rad * 180) / Math.PI) % 360; - } - - _hValueToRad(deg: number) { - const rad = (deg * Math.PI) / 180; - return rad - Math.PI - Math.PI / 2; - } - - render() { - const { pickerSize } = this.state; - const { oldColor, style } = this.props; - const color = this._getColor(); - const { h, s, v } = color; - const angle = this._hValueToRad(h); - const selectedColor = tinycolor(color).toHexString(); - const indicatorColor = tinycolor({ h, s: 1, v: 1 }).toHexString(); - const computed = makeComputedStyles({ - pickerSize: pickerSize ?? 0, - selectedColor, - indicatorColor, - oldColor, - angle, - isRTL: this._isRTL, - }); - return ( - - - {!pickerSize ? null : ( - - - - - - {oldColor && ( - - )} - {oldColor && ( - - )} - {!oldColor && ( - - )} - - )} - - {this.props.hideSliders ? null : ( + const selectedColor = tinycolor(color).toHexString(); + const indicatorColor = tinycolor({ h: color.h, s: 1, v: 1 }).toHexString(); + const computed = makeComputedStyles({ + pickerSize, + selectedColor, + indicatorColor, + oldColor, + angle: hueToRad(color.h), + isRTL: I18nManager.isRTL, + }); + + return ( + + + {!pickerSize ? null : ( - Saturation - - Lightness - + + + + + {oldColor ? ( + <> + + + + ) : ( + + )} )} - ); - } + + Saturation + updateColor({ ...color, s })} /> + Lightness + updateColor({ ...color, v })} /> + + + ); } +const computeHue = (x: number, y: number, pickerSize: number) => { + const center = pickerSize / 2; + const dx = x - center; + const dy = y - center; + const rad = Math.atan2(dx, dy) + Math.PI + Math.PI / 2; + return ((rad * 180) / Math.PI) % 360; +}; + +const hueToRad = (deg: number) => { + const rad = (deg * Math.PI) / 180; + return rad - Math.PI - Math.PI / 2; +}; + const makeComputedStyles = ({ indicatorColor, selectedColor, @@ -232,14 +142,13 @@ const makeComputedStyles = ({ isRTL: boolean; }) => { const summarySize = 0.5 * pickerSize; - const indicatorPickerRatio = 42 / 510; - const indicatorSize = indicatorPickerRatio * pickerSize; + const indicatorSize = (42 / 510) * pickerSize; const pickerPadding = indicatorSize / 3; const indicatorRadius = pickerSize / 2 - indicatorSize / 2 - pickerPadding; - const mx = pickerSize / 2; - const my = pickerSize / 2; + const center = pickerSize / 2; const dx = Math.cos(angle) * indicatorRadius; const dy = Math.sin(angle) * indicatorRadius; + return { picker: { padding: pickerPadding, @@ -247,8 +156,8 @@ const makeComputedStyles = ({ height: pickerSize, }, pickerIndicator: { - top: mx + dx - indicatorSize / 2, - [isRTL ? 'right' : 'left']: my + dy - indicatorSize / 2, + top: center + dx - indicatorSize / 2, + [isRTL ? 'right' : 'left']: center + dy - indicatorSize / 2, width: indicatorSize, height: indicatorSize, borderRadius: indicatorSize / 2, @@ -310,7 +219,9 @@ const styles = StyleSheet.create({ selectedFullPreview: { position: 'absolute', }, - pickerAlignment: { - alignItems: 'center', + sliderLabel: { + paddingStart: 4, + color: '#859499', + fontSize: 12, }, }); diff --git a/packages/ondevice-controls/src/components/color-picker/TriangleColorPicker.tsx b/packages/ondevice-controls/src/components/color-picker/TriangleColorPicker.tsx deleted file mode 100644 index e607a8d8cb..0000000000 --- a/packages/ondevice-controls/src/components/color-picker/TriangleColorPicker.tsx +++ /dev/null @@ -1,479 +0,0 @@ -import { PureComponent, createRef } from 'react'; -import { - I18nManager, - Image, - InteractionManager, - LayoutChangeEvent, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native'; -import tinycolor, { ColorFormats } from 'tinycolor2'; -import { createPanResponder, rotatePoint } from './utils'; - -interface TriangleColorPickerProps { - color?: string | ColorFormats.HSV; - defaultColor?: string; - oldColor?: string; - onColorChange?: (color: ColorFormats.HSV) => void; - onColorSelected?: (color: string) => void; - onOldColorSelected?: (color: string) => void; - rotationHackFactor?: number; - style?: object; -} - -interface TriangleColorPickerState { - color: ColorFormats.HSV; - pickerSize: number | null; -} - -function makeRotationKey(props: TriangleColorPickerProps, angle: number) { - const { rotationHackFactor = 100 } = props; - if (rotationHackFactor < 1) { - return undefined; - } - const key = Math.floor(angle * rotationHackFactor); - return `r${key}`; -} - -export class TriangleColorPicker extends PureComponent< - TriangleColorPickerProps, - TriangleColorPickerState -> { - private _layout: { width: number; height: number; x: number; y: number }; - private _pageX: number; - private _pageY: number; - private _changingHColor: boolean; - private _isRTL: boolean; - private _pickerResponder: any; - private pickerContainer = createRef(); - - constructor(props: TriangleColorPickerProps) { - super(props); - const state: TriangleColorPickerState = { - color: { h: 0, s: 1, v: 1 }, - pickerSize: null, - }; - if (props.oldColor) { - state.color = tinycolor(props.oldColor).toHsv(); - } - if (props.defaultColor) { - state.color = tinycolor(props.defaultColor).toHsv(); - } - this.state = state; - this._layout = { width: 0, height: 0, x: 0, y: 0 }; - this._pageX = 0; - this._pageY = 0; - this._isRTL = I18nManager.isRTL; - - this._pickerResponder = createPanResponder({ - onStart: ({ x, y }) => { - const { s, v } = this._computeColorFromTriangle({ x, y }); - this._changingHColor = s > 1 || s < 0 || v > 1 || v < 0; - this._handleColorChange({ x, y }); - }, - onMove: this._handleColorChange, - }); - } - - getColor() { - return tinycolor(this._getColor()).toHexString(); - } - - _handleColorChange = ({ x, y }: { x: number; y: number }) => { - if (this._changingHColor) { - this._handleHColorChange({ x, y }); - } else { - this._handleSVColorChange({ x, y }); - } - }; - - _getColor() { - const passedColor = - typeof this.props.color === 'string' ? tinycolor(this.props.color).toHsv() : this.props.color; - return passedColor || this.state.color; - } - - _onColorSelected = () => { - const { onColorSelected } = this.props; - const color = tinycolor(this._getColor()).toHexString(); - if (onColorSelected) { - onColorSelected(color); - } - }; - - _onOldColorSelected = () => { - const { oldColor, onOldColorSelected } = this.props; - const color = tinycolor(oldColor!); - this.setState({ color: color.toHsv() }); - if (onOldColorSelected) { - onOldColorSelected(color.toHexString()); - } - }; - - _onSValueChange = (s: number) => { - const { h, v } = this._getColor(); - this._onColorChange({ h, s, v }); - }; - - _onVValueChange = (v: number) => { - const { h, s } = this._getColor(); - this._onColorChange({ h, s, v }); - }; - - _onColorChange = (color: ColorFormats.HSV) => { - this.setState({ color }); - if (this.props.onColorChange) { - this.props.onColorChange(color); - } - }; - - _onLayout = (l: LayoutChangeEvent) => { - this._layout = l.nativeEvent.layout; - const { width, height } = this._layout; - const pickerSize = Math.min(width, height); - if (this.state.pickerSize !== pickerSize) { - this.setState({ pickerSize }); - } - InteractionManager.runAfterInteractions(() => { - if (this.pickerContainer.current) { - this.pickerContainer.current.measure((_x, _y, _width, _height, pageX, pageY) => { - this._pageX = pageX; - this._pageY = pageY; - }); - } - }); - }; - - _computeHValue(x: number, y: number) { - const mx = this.state.pickerSize! / 2; - const my = this.state.pickerSize! / 2; - const dx = x - mx; - const dy = y - my; - const rad = Math.atan2(dx, dy) + Math.PI + Math.PI / 2; - return ((rad * 180) / Math.PI) % 360; - } - - _hValueToRad(deg: number) { - const rad = (deg * Math.PI) / 180; - return rad - Math.PI - Math.PI / 2; - } - - _handleHColorChange = ({ x, y }: { x: number; y: number }) => { - const { s, v } = this._getColor(); - const marginLeft = (this._layout.width - this.state.pickerSize!) / 2; - const marginTop = (this._layout.height - this.state.pickerSize!) / 2; - const relativeX = x - this._pageX - marginLeft; - const relativeY = y - this._pageY - marginTop; - const h = this._computeHValue(relativeX, relativeY); - this._onColorChange({ h, s, v }); - }; - - _handleSVColorChange = ({ x, y }: { x: number; y: number }) => { - const { h, s: rawS, v: rawV } = this._computeColorFromTriangle({ x, y }); - const s = Math.min(Math.max(0, rawS), 1); - const v = Math.min(Math.max(0, rawV), 1); - this._onColorChange({ h, s, v }); - }; - - _normalizeTriangleTouch(s: number, v: number, sRatio: number) { - const CORNER_ZONE_SIZE = 0.12; - const NORMAL_MARGIN = 0.1; - const CORNER_MARGIN = 0.05; - let margin = NORMAL_MARGIN; - - const posNS = v > 0 ? 1 - (1 - s) * sRatio : 1 - s * sRatio; - const negNS = v > 0 ? s * sRatio : (1 - s) * sRatio; - const ns = s > 1 ? posNS : negNS; - - const rightCorner = s > 1 - CORNER_ZONE_SIZE && v > 1 - CORNER_ZONE_SIZE; - const leftCorner = ns < 0 + CORNER_ZONE_SIZE && v > 1 - CORNER_ZONE_SIZE; - const topCorner = ns < 0 + CORNER_ZONE_SIZE && v < 0 + CORNER_ZONE_SIZE; - if (rightCorner) { - return { s, v }; - } - if (leftCorner || topCorner) { - margin = CORNER_MARGIN; - } - let s1 = s; - let v1 = v; - s1 = s1 < 0 && ns > 0 - margin ? 0 : s1; - s1 = s1 > 1 && ns < 1 + margin ? 1 : s1; - v1 = v1 < 0 && v1 > 0 - margin ? 0 : v1; - v1 = v1 > 1 && v1 < 1 + margin ? 1 : v1; - return { s: s1, v: v1 }; - } - - _computeColorFromTriangle({ x, y }: { x: number; y: number }) { - const { pickerSize } = this.state; - const { triangleHeight, triangleWidth } = getPickerProperties(pickerSize!); - - const left = pickerSize! / 2 - triangleWidth / 2; - const top = pickerSize! / 2 - (2 * triangleHeight) / 3; - - const marginLeft = (this._layout.width - this.state.pickerSize!) / 2; - const marginTop = (this._layout.height - this.state.pickerSize!) / 2; - const relativeX = x - this._pageX - marginLeft - left; - const relativeY = y - this._pageY - marginTop - top; - - const { h } = this._getColor(); - const deg = (h - 330 + 360) % 360; - const rad = (deg * Math.PI) / 180; - const center = { - x: triangleWidth / 2, - y: (2 * triangleHeight) / 3, - }; - const rotated = rotatePoint({ x: relativeX, y: relativeY }, rad, center); - - const line = (triangleWidth * rotated.y) / triangleHeight; - const margin = triangleWidth / 2 - ((triangleWidth / 2) * rotated.y) / triangleHeight; - const s = (rotated.x - margin) / line; - const v = rotated.y / triangleHeight; - - const normalized = this._normalizeTriangleTouch(s, v, line / triangleHeight); - - return { h, s: normalized.s, v: normalized.v }; - } - - render() { - const { pickerSize } = this.state; - const { oldColor, style } = this.props; - const color = this._getColor(); - const { h } = color; - const angle = this._hValueToRad(h); - const selectedColor = tinycolor(color).toHexString(); - const indicatorColor = tinycolor({ h, s: 1, v: 1 }).toHexString(); - const computed = makeComputedStyles({ - pickerSize: pickerSize!, - // selectedColor, - selectedColorHsv: color, - indicatorColor, - // oldColor, - angle, - isRTL: this._isRTL, - }); - const rotationHack = makeRotationKey(this.props, angle); - return ( - - - {!pickerSize ? null : ( - - - - - - - - - - - - - - )} - - - {oldColor && ( - - )} - - - - ); - } -} - -function getPickerProperties(pickerSize: number) { - const indicatorPickerRatio = 42 / 510; // computed from picker image - const originalIndicatorSize = indicatorPickerRatio * pickerSize; - const indicatorSize = originalIndicatorSize; - const pickerPadding = originalIndicatorSize / 3; - - const triangleSize = pickerSize - 6 * pickerPadding; - const triangleRadius = triangleSize / 2; - const triangleHeight = (triangleRadius * 3) / 2; - const triangleWidth = 2 * triangleRadius * Math.sqrt(3 / 4); - - return { - triangleSize, - triangleRadius, - triangleHeight, - triangleWidth, - indicatorPickerRatio, - indicatorSize, - pickerPadding, - }; -} - -const makeComputedStyles = ({ - indicatorColor, - angle, - pickerSize, - selectedColorHsv, - isRTL, -}: { - indicatorColor: string; - angle: number; - pickerSize: number; - selectedColorHsv: ColorFormats.HSV; - isRTL: boolean; -}) => { - const { triangleSize, triangleHeight, triangleWidth, indicatorSize, pickerPadding } = - getPickerProperties(pickerSize); - - const indicatorRadius = pickerSize / 2 - indicatorSize / 2 - pickerPadding; - const mx = pickerSize / 2; - const my = pickerSize / 2; - const dx = Math.cos(angle) * indicatorRadius; - const dy = Math.sin(angle) * indicatorRadius; - - const triangleTop = pickerPadding * 3; - const triangleLeft = pickerPadding * 3; - const triangleAngle = -angle + Math.PI / 3; - - const markerColor = 'rgba(0,0,0,0.8)'; - const { s, v, h } = selectedColorHsv; - const svIndicatorSize = 18; - const svY = v * triangleHeight; - const margin = triangleWidth / 2 - v * (triangleWidth / 2); - const svX = s * (triangleWidth - 2 * margin) + margin; - const svIndicatorMarginLeft = (pickerSize - triangleWidth) / 2; - const svIndicatorMarginTop = (pickerSize - (4 * triangleHeight) / 3) / 2; - - const deg = (h - 330 + 360) % 360; // starting angle is 330 due to comfortable calculation - const rad = (deg * Math.PI) / 180; - const center = { x: pickerSize / 2, y: pickerSize / 2 }; - const notRotatedPoint = { - x: svIndicatorMarginTop + svY, - y: svIndicatorMarginLeft + svX, - }; - const svIndicatorPoint = rotatePoint(notRotatedPoint, rad, center); - - return { - picker: { - padding: pickerPadding, - width: pickerSize, - height: pickerSize, - }, - pickerIndicator: { - top: mx + dx - indicatorSize / 2, - [isRTL ? 'right' : 'left']: my + dy - indicatorSize / 2, - width: indicatorSize, - height: indicatorSize, - transform: [ - { - rotate: `${-angle}rad`, - }, - ], - }, - pickerIndicatorTick: { - height: indicatorSize / 2, - backgroundColor: markerColor, - }, - svIndicator: { - top: svIndicatorPoint.x - svIndicatorSize / 2, - [isRTL ? 'right' : 'left']: svIndicatorPoint.y - svIndicatorSize / 2, - width: svIndicatorSize, - height: svIndicatorSize, - borderRadius: svIndicatorSize / 2, - borderColor: markerColor, - }, - triangleContainer: { - width: triangleSize, - height: triangleSize, - transform: [ - { - rotate: `${triangleAngle}rad`, - }, - ], - top: triangleTop, - left: triangleLeft, - }, - triangleImage: { - width: triangleWidth, - height: triangleHeight, - }, - triangleUnderlayingColor: { - left: (triangleSize - triangleWidth) / 2, - borderLeftWidth: triangleWidth / 2, - borderRightWidth: triangleWidth / 2, - borderBottomWidth: triangleHeight, - borderBottomColor: indicatorColor, - }, - colorPreviews: { - height: pickerSize * 0.1, - }, - }; -}; - -const styles = StyleSheet.create({ - pickerContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - pickerImage: { - flex: 1, - width: null, - height: null, - }, - pickerIndicator: { - position: 'absolute', - alignItems: 'center', - justifyContent: 'center', - }, - triangleContainer: { - position: 'absolute', - alignItems: 'center', - }, - triangleUnderlayingColor: { - position: 'absolute', - top: 0, - width: 0, - height: 0, - backgroundColor: 'transparent', - borderStyle: 'solid', - borderLeftColor: 'transparent', - borderRightColor: 'transparent', - }, - pickerAlignment: { - alignItems: 'center', - }, - svIndicator: { - position: 'absolute', - borderWidth: 4, - }, - pickerIndicatorTick: { - width: 5, - }, - colorPreviews: { - flexDirection: 'row', - }, - colorPreview: { - flex: 1, - }, -}); diff --git a/packages/ondevice-controls/src/components/color-picker/index.ts b/packages/ondevice-controls/src/components/color-picker/index.ts index b81460e295..ad5417519b 100644 --- a/packages/ondevice-controls/src/components/color-picker/index.ts +++ b/packages/ondevice-controls/src/components/color-picker/index.ts @@ -1,24 +1,3 @@ -// credit to https://github.com/instea/react-native-color-picker export { fromHsv, toHsv } from './utils'; export { HoloColorPicker as ColorPicker } from './HoloColorPicker'; -export { TriangleColorPicker } from './TriangleColorPicker'; export type HsvColor = { h: number; s: number; v: number }; - -export interface IPicker { - color?: string | HsvColor; - defaultColor?: string | HsvColor; - oldColor?: string; - style?: object; - onColorSelected?: (selectedColor: string) => void; - onColorChange?: (selectedColor: HsvColor) => void; - onOldColorSelected?: (oldColor: string) => void; - hideSliders?: boolean; -} - -export interface SliderProps { - onValueChange?: (value: number) => void; - value?: number; -} -export interface IHoloPicker extends IPicker { - sliderComponent?: React.Component; -} diff --git a/packages/ondevice-controls/src/components/color-picker/resources/color-circle.xcf b/packages/ondevice-controls/src/components/color-picker/resources/color-circle.xcf deleted file mode 100644 index f1c0e5124d..0000000000 Binary files a/packages/ondevice-controls/src/components/color-picker/resources/color-circle.xcf and /dev/null differ diff --git a/packages/ondevice-controls/src/components/color-picker/resources/hsv_triangle_mask.png b/packages/ondevice-controls/src/components/color-picker/resources/hsv_triangle_mask.png deleted file mode 100644 index 79dce670cf..0000000000 Binary files a/packages/ondevice-controls/src/components/color-picker/resources/hsv_triangle_mask.png and /dev/null differ diff --git a/packages/ondevice-controls/src/components/color-picker/utils.ts b/packages/ondevice-controls/src/components/color-picker/utils.ts index 44a003dc3f..ea5f2c82b2 100644 --- a/packages/ondevice-controls/src/components/color-picker/utils.ts +++ b/packages/ondevice-controls/src/components/color-picker/utils.ts @@ -56,39 +56,13 @@ export function createPanResponder({ onMoveShouldSetPanResponderCapture: fn, onPanResponderTerminationRequest: fn, onPanResponderGrant: (evt: GestureResponderEvent, state: PanResponderGestureState) => { - return onStart({ x: evt.nativeEvent.pageX, y: evt.nativeEvent.pageY }, evt, state); + return onStart({ x: evt.nativeEvent.locationX, y: evt.nativeEvent.locationY }, evt, state); }, onPanResponderMove: (evt: GestureResponderEvent, state: PanResponderGestureState) => { - return onMove({ x: evt.nativeEvent.pageX, y: evt.nativeEvent.pageY }, evt, state); + return onMove({ x: evt.nativeEvent.locationX, y: evt.nativeEvent.locationY }, evt, state); }, onPanResponderRelease: (evt: GestureResponderEvent, state: PanResponderGestureState) => { - return onEnd({ x: evt.nativeEvent.pageX, y: evt.nativeEvent.pageY }, evt, state); + return onEnd({ x: evt.nativeEvent.locationX, y: evt.nativeEvent.locationY }, evt, state); }, }); } - -/** - * Rotates point around given center in 2d. - * Point is object literal { x: number, y: number } - * @param {point} point to be rotated - * @param {number} angle in radians - * @param {point} center to be rotated around - * @return {point} rotated point - */ -export function rotatePoint(point: Point, angle: number, center: Point = { x: 0, y: 0 }): Point { - // translation to origin - const transOriginX = point.x - center.x; - const transOriginY = point.y - center.y; - - // rotation around origin - const rotatedX = transOriginX * Math.cos(angle) - transOriginY * Math.sin(angle); - const rotatedY = transOriginY * Math.cos(angle) + transOriginX * Math.sin(angle); - - // translate back from origin - const normalizedX = rotatedX + center.x; - const normalizedY = rotatedY + center.y; - return { - x: normalizedX, - y: normalizedY, - }; -} diff --git a/packages/ondevice-controls/src/types/Color.tsx b/packages/ondevice-controls/src/types/Color.tsx index b812e972e8..88d98c0090 100644 --- a/packages/ondevice-controls/src/types/Color.tsx +++ b/packages/ondevice-controls/src/types/Color.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { View, TouchableWithoutFeedback, StyleSheet, Platform, Dimensions } from 'react-native'; import { styled, useTheme } from '@storybook/react-native-theming'; -import { ColorPicker, fromHsv, HsvColor } from '../components/color-picker'; +import { ColorPicker, fromHsv, HsvColor, toHsv } from '../components/color-picker'; import { ModalPortal } from '../components/ModalPortal'; export interface ColorProps { @@ -12,19 +12,21 @@ export interface ColorProps { onChange: (value: string) => void; } -const TouchableContainer = styled.View(({ theme }) => ({ - width: 40, - height: 40, +const TouchableContainer = styled.TouchableOpacity(({ theme }) => ({ + width: 44, + height: 44, borderWidth: 1, borderColor: theme.appBorderColor, borderRadius: 6, padding: 3, backgroundColor: theme.background.content, + justifyContent: 'center', + alignItems: 'center', })); -const Touchable = styled.TouchableOpacity<{ color: string }>(({ color }) => ({ - width: '100%', - height: '100%', +const Touchable = styled.View<{ color: string }>(({ color }) => ({ + width: 38, + height: 38, borderRadius: 4, backgroundColor: color, })); @@ -35,8 +37,9 @@ const ButtonTouchable = styled.TouchableOpacity<{ primary?: boolean }>(({ theme, borderRadius: 6, borderWidth: 1, borderColor: primary ? theme.color.secondary : theme.button.border, - paddingVertical: 6, - paddingHorizontal: 20, + minHeight: 40, + paddingVertical: 10, + paddingHorizontal: 28, justifyContent: 'center', alignItems: 'center', }; @@ -44,18 +47,22 @@ const ButtonTouchable = styled.TouchableOpacity<{ primary?: boolean }>(({ theme, const ButtonText = styled.Text<{ primary?: boolean }>(({ theme, primary }) => { return { - color: primary ? theme.color.inverseText : theme.color.defaultText, + color: primary ? theme.color.lightest : theme.input.color, fontSize: theme.typography.size.s2, fontWeight: theme.typography.weight.bold, }; }); +const controlHitSlop = { top: 16, right: 16, bottom: 16, left: 16 }; +const buttonHitSlop = { top: 10, right: 10, bottom: 10, left: 10 }; + const ColorType = ({ arg, onChange = (value) => value }: ColorProps) => { const [displayColorPicker, setDisplayColorPicker] = useState(false); - const [currentColor, setCurrentColor] = useState(null); + const [currentColor, setCurrentColor] = useState(() => toHsv(arg.value)); const theme = useTheme(); const openColorPicker = () => { + setCurrentColor(toHsv(arg.value)); setDisplayColorPicker(true); }; @@ -63,7 +70,7 @@ const ColorType = ({ arg, onChange = (value) => value }: ColorProps) => { setDisplayColorPicker(false); }; - const onChangeColor = (color) => { + const onChangeColor = (color: HsvColor) => { onChange(fromHsv(color)); }; @@ -72,7 +79,10 @@ const ColorType = ({ arg, onChange = (value) => value }: ColorProps) => { onChange(event.target.value)} + onChange={(event) => { + const target = event.currentTarget as EventTarget & { value: string }; + onChange(target.value); + }} style={{ width: 40, height: 40, @@ -88,14 +98,15 @@ const ColorType = ({ arg, onChange = (value) => value }: ColorProps) => { return ( - - + + @@ -103,19 +114,19 @@ const ColorType = ({ arg, onChange = (value) => value }: ColorProps) => { setCurrentColor(color)} defaultColor={arg.value} oldColor={arg.value} style={styles.picker} /> - + Cancel { onChangeColor(currentColor); closeColorPicker(); diff --git a/packages/ondevice-controls/src/types/Date.tsx b/packages/ondevice-controls/src/types/Date.tsx index 5a821319cd..c58346647c 100644 --- a/packages/ondevice-controls/src/types/Date.tsx +++ b/packages/ondevice-controls/src/types/Date.tsx @@ -75,7 +75,8 @@ const DateType = ({ onChange, arg: { name, value } }: DateProps) => { type="datetime-local" defaultValue={webDateString} onChange={(e) => { - const newDate = new Date(e.target.value); + const target = e.currentTarget as EventTarget & { value: string }; + const newDate = new Date(target.value); onChange(newDate); }} // @ts-ignore diff --git a/packages/ondevice-controls/src/types/Select.tsx b/packages/ondevice-controls/src/types/Select.tsx index 69ea5dd5f0..68eec02800 100644 --- a/packages/ondevice-controls/src/types/Select.tsx +++ b/packages/ondevice-controls/src/types/Select.tsx @@ -1,4 +1,5 @@ import { useTheme } from '@storybook/react-native-theming'; +import { useMemo } from 'react'; import { Platform, View } from 'react-native'; import { SelectModal } from '../components/SelectModal'; import { Input, inputStyle } from './common'; @@ -17,37 +18,42 @@ export interface SelectProps { onChange: (value: any) => void; } -const getOptions = ({ options, control: { labels } }: SelectProps['arg']) => { +const getOptions = (options: SelectProps['arg']['options'], labels?: Record) => { if (Array.isArray(options)) { - if (labels) { - return options.map((val) => ({ key: val, label: labels[val] || val })); - } - return options.map((val) => ({ key: val, label: val })); + return options.map((val) => ({ key: val, label: String(labels?.[val] || val) })); } return Object.keys(options).map((key) => ({ - label: key, + label: String(key), key: options[key], })); }; const SelectType = ({ arg, onChange }: SelectProps) => { const { value } = arg; - const options = getOptions(arg); + const options = useMemo( + () => getOptions(arg.options, arg.control.labels), + [arg.control.labels, arg.options] + ); const theme = useTheme(); const active = options.find(({ key }) => value === key); - const selected = active && active.label; + const selected = active?.label ?? ''; if (Platform.OS === 'web') { const handleChange = (event: React.ChangeEvent) => { + const target = event.currentTarget as EventTarget & { + selectedOptions: ArrayLike<{ value: string }>; + value: string; + }; + if (arg.type === 'multi-select') { - const selectedOptions = Array.from(event.target.selectedOptions); + const selectedOptions = Array.from(target.selectedOptions); const selectedValues = selectedOptions.map((option) => option.value); onChange(selectedValues); } else { - onChange(event.target.value); + onChange(target.value); } }; @@ -71,20 +77,7 @@ const SelectType = ({ arg, onChange }: SelectProps) => { return ( {arg.type === 'multi-select' ? ( - String(v)) : [String(value)]} - onChange={(selectedOptions) => { - if (Array.isArray(selectedOptions)) { - onChange(selectedOptions.map((option) => option.key)); - } - }} - animationType="none" - keyExtractor={({ key, label }) => `${label}-${key}`} - selectedSeparator=", " - closeOnChange={false} - > + { /> ) : ( - onChange(option.key)} - animationType="none" - keyExtractor={({ key, label }) => `${label}-${key}`} - > + diff --git a/packages/ondevice-notes/package.json b/packages/ondevice-notes/package.json index 1637130cbb..8fcdc535b3 100644 --- a/packages/ondevice-notes/package.json +++ b/packages/ondevice-notes/package.json @@ -25,9 +25,9 @@ ], "scripts": { "check:types": "tsc --noEmit", - "dev": "tsup --watch", + "dev": "tsdown --watch", "preprepare": "rm -rf dist/", - "prepare": "tsup" + "prepare": "tsdown" }, "dependencies": { "@storybook/react-native-theming": "^10.4.0" @@ -35,8 +35,8 @@ "devDependencies": { "react-native-markdown-display": "^7.0.2", "storybook": "^10.3.2", - "tsup": "^8.5.0", - "typescript": "~5.9.3" + "tsdown": "^0.22.0", + "typescript": "~6.0.3" }, "peerDependencies": { "@storybook/react": "^10", diff --git a/packages/ondevice-notes/src/ErrorBoundary.tsx b/packages/ondevice-notes/src/ErrorBoundary.tsx index a53e9338c4..5c5a85d894 100644 --- a/packages/ondevice-notes/src/ErrorBoundary.tsx +++ b/packages/ondevice-notes/src/ErrorBoundary.tsx @@ -1,21 +1,21 @@ -import React, { ReactNode } from 'react'; +import React, { ErrorInfo, ReactNode } from 'react'; import { Text } from 'react-native'; export class ErrorBoundary extends React.Component< { children: ReactNode | ReactNode[] }, { hasError: boolean } > { - constructor(props) { + constructor(props: { children: ReactNode | ReactNode[] }) { super(props); this.state = { hasError: false }; } - static getDerivedStateFromError(_error) { + static getDerivedStateFromError(_error: Error) { // Update state so the next render will show the fallback UI. return { hasError: true }; } - componentDidCatch(error, errorInfo) { + componentDidCatch(error: Error, errorInfo: ErrorInfo) { // You can also log the error to an error reporting service console.warn(error, errorInfo); } diff --git a/packages/ondevice-notes/src/components/Notes.tsx b/packages/ondevice-notes/src/components/Notes.tsx index f88485da15..074d5d8c04 100644 --- a/packages/ondevice-notes/src/components/Notes.tsx +++ b/packages/ondevice-notes/src/components/Notes.tsx @@ -11,7 +11,7 @@ import { useTheme } from '@storybook/react-native-theming'; export const PARAM_KEY = 'notes'; interface NotesProps { - active: boolean; + active?: boolean; api: RNAddonApi; } @@ -22,7 +22,7 @@ export const Notes = ({ active, api }: NotesProps) => { useEffect(() => { const selection = api.store().getSelection(); - const handleSetCurrentStory = ({ storyId }) => { + const handleSetCurrentStory = ({ storyId }: { storyId: string }) => { setStory(api.store().fromId(storyId)); }; diff --git a/packages/ondevice-notes/src/register.tsx b/packages/ondevice-notes/src/register.tsx index 54ff1297e5..b20b002109 100644 --- a/packages/ondevice-notes/src/register.tsx +++ b/packages/ondevice-notes/src/register.tsx @@ -22,11 +22,13 @@ type ApiStore = { export type RNAddonApi = API & { store: () => ApiStore }; -addons.register('storybook/notes', (api: RNAddonApi) => { +addons.register('storybook/notes', (api) => { + const rnApi = api as RNAddonApi; + addons.add('storybook/notes/panel', { type: types.PANEL, title: 'Notes', - render: ({ active }) => , + render: ({ active }) => , paramKey: PARAM_KEY, }); }); diff --git a/packages/ondevice-notes/tsup.config.ts b/packages/ondevice-notes/tsdown.config.mts similarity index 73% rename from packages/ondevice-notes/tsup.config.ts rename to packages/ondevice-notes/tsdown.config.mts index d027310116..666d805a70 100644 --- a/packages/ondevice-notes/tsup.config.ts +++ b/packages/ondevice-notes/tsdown.config.mts @@ -1,8 +1,13 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; export default defineConfig((options) => { return { entry: ['src/index.ts', 'src/register.tsx'], + format: ['cjs'], + fixedExtension: false, + deps: { + onlyBundle: false, + }, clean: !options.watch, dts: !options.watch ? { diff --git a/packages/react-native-theming/package.json b/packages/react-native-theming/package.json index 6a0436df78..e967cce6be 100644 --- a/packages/react-native-theming/package.json +++ b/packages/react-native-theming/package.json @@ -26,8 +26,8 @@ ], "scripts": { "check:types": "tsc --noEmit", - "dev": "npx --yes tsx ./scripts/gendtsdev.ts && tsup --watch", - "prepare": "tsup && npx --yes tsx ./scripts/patchdts.ts" + "dev": "node ./scripts/gendtsdev.ts && tsdown --watch", + "prepare": "tsdown && node ./scripts/patchdts.ts" }, "dependencies": { "polished": "^4.3.1" @@ -35,7 +35,7 @@ "devDependencies": { "@emotion/native": "^11.11.0", "@emotion/react": "^11.14.0", - "tsup": "^8.5.0" + "tsdown": "^0.22.0" }, "peerDependencies": { "react": "*", diff --git a/packages/react-native-theming/src/index.ts b/packages/react-native-theming/src/index.ts index 663fad429d..434d17702e 100644 --- a/packages/react-native-theming/src/index.ts +++ b/packages/react-native-theming/src/index.ts @@ -2,6 +2,6 @@ import styled, { type StyledComponent } from '@emotion/native'; import { useTheme, withTheme, ThemeProvider } from '@emotion/react'; -export { theme, darkTheme, StorybookTheme } from './theme'; +export { theme, darkTheme, type StorybookTheme } from './theme'; -export { styled, useTheme, withTheme, ThemeProvider, StyledComponent }; +export { styled, useTheme, withTheme, ThemeProvider, type StyledComponent }; diff --git a/packages/react-native-theming/tsconfig.json b/packages/react-native-theming/tsconfig.json index de5f0b4a6a..6865558c1f 100644 --- a/packages/react-native-theming/tsconfig.json +++ b/packages/react-native-theming/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "baseUrl": ".", "rootDir": "./src", "outDir": "dist/" }, diff --git a/packages/react-native-theming/tsup.config.ts b/packages/react-native-theming/tsdown.config.mts similarity index 67% rename from packages/react-native-theming/tsup.config.ts rename to packages/react-native-theming/tsdown.config.mts index 508a6f04ef..ba427b9a04 100644 --- a/packages/react-native-theming/tsup.config.ts +++ b/packages/react-native-theming/tsdown.config.mts @@ -1,8 +1,13 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; export default defineConfig((options) => { return { entry: ['src/index.ts'], + format: ['cjs'], + fixedExtension: false, + deps: { + onlyBundle: false, + }, // minify: !options.watch, clean: !options.watch, dts: !options.watch diff --git a/packages/react-native-ui-common/package.json b/packages/react-native-ui-common/package.json index 210770074f..a9295080f5 100644 --- a/packages/react-native-ui-common/package.json +++ b/packages/react-native-ui-common/package.json @@ -29,8 +29,8 @@ ], "scripts": { "check:types": "tsc --noEmit", - "dev": "tsup --watch", - "prepare": "tsup" + "dev": "tsdown --watch", + "prepare": "tsdown" }, "dependencies": { "@nozbe/microfuzz": "^1.0.0", @@ -43,8 +43,8 @@ "devDependencies": { "@types/react": "~19.2.14", "storybook": "^10.3.2", - "tsup": "^8.5.0", - "typescript": "~5.9.3" + "tsdown": "^0.22.0", + "typescript": "~6.0.3" }, "peerDependencies": { "react": "*", diff --git a/packages/react-native-ui-common/src/Button.tsx b/packages/react-native-ui-common/src/Button.tsx index 22d7f66392..abc0524953 100644 --- a/packages/react-native-ui-common/src/Button.tsx +++ b/packages/react-native-ui-common/src/Button.tsx @@ -40,7 +40,7 @@ export const Button = forwardRef( const [isAnimating, setIsAnimating] = useState(false); - const handleClick = (event) => { + const handleClick: NonNullable = (event) => { if (onPress) onPress(event); if (animation === 'none') return; setIsAnimating(true); diff --git a/packages/react-native-ui-common/src/StorageProvider.tsx b/packages/react-native-ui-common/src/StorageProvider.tsx index 2c26cf0cff..0cafbcceba 100644 --- a/packages/react-native-ui-common/src/StorageProvider.tsx +++ b/packages/react-native-ui-common/src/StorageProvider.tsx @@ -2,8 +2,8 @@ import type { FC, PropsWithChildren } from 'react'; import { createContext, useContext } from 'react'; export interface Storage { - getItem: (key: string) => Promise; - setItem: (key: string, value: string) => Promise; + getItem: (key: string) => Promise | string | null; + setItem: (key: string, value: string) => Promise | void; } const StorageContext = createContext({ diff --git a/packages/react-native-ui-common/src/hooks/useExpanded.ts b/packages/react-native-ui-common/src/hooks/useExpanded.ts index cfb11506e6..f447072847 100644 --- a/packages/react-native-ui-common/src/hooks/useExpanded.ts +++ b/packages/react-native-ui-common/src/hooks/useExpanded.ts @@ -28,9 +28,11 @@ const initializeExpanded = ({ initialExpanded?: ExpandedState; rootIds: string[]; }) => { - const highlightedAncestors = []; + const highlightedAncestors: string[] = []; + const expandedState = initialExpanded as ExpandedState; + return [...rootIds, ...highlightedAncestors].reduce( - (acc, id) => Object.assign(acc, { [id]: id in initialExpanded ? initialExpanded[id] : true }), + (acc, id) => Object.assign(acc, { [id]: id in expandedState ? expandedState[id] : true }), {} ); }; diff --git a/packages/react-native-ui-common/src/hooks/useLastViewed.ts b/packages/react-native-ui-common/src/hooks/useLastViewed.ts index d4ca9b8c56..bcae1a26c0 100644 --- a/packages/react-native-ui-common/src/hooks/useLastViewed.ts +++ b/packages/react-native-ui-common/src/hooks/useLastViewed.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'; import type { Selection, StoryRef } from '../types'; export const useLastViewed = (selection: Selection) => { - const lastViewedRef = useRef([]); + const lastViewedRef = useRef([]); const updateLastViewed = useCallback( (story: StoryRef) => { diff --git a/packages/react-native-ui-common/src/hooks/useStoreState.ts b/packages/react-native-ui-common/src/hooks/useStoreState.ts index 86465d79af..90c7ab13e4 100644 --- a/packages/react-native-ui-common/src/hooks/useStoreState.ts +++ b/packages/react-native-ui-common/src/hooks/useStoreState.ts @@ -1,16 +1,16 @@ -import { useCallback, useEffect, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; import { useStorage } from '../StorageProvider'; export const useStoreBooleanState = ( key: string, defaultValue: boolean -): ReturnType> => { +): [boolean, Dispatch>] => { const storage = useStorage(); const [val, setVal] = useState(defaultValue); useEffect(() => { - storage.getItem(key).then((newVal) => { + Promise.resolve(storage.getItem(key)).then((newVal) => { if (newVal === null || newVal === undefined) { setVal(defaultValue); } else { @@ -35,7 +35,7 @@ export const useStoreNumberState = ( const [val, setVal] = useState(defaultValue); useEffect(() => { - storage.getItem(key).then((newVal) => { + Promise.resolve(storage.getItem(key)).then((newVal) => { if (newVal === null || newVal === undefined) { setVal(defaultValue); } else { diff --git a/packages/react-native-ui-common/src/util/tree.ts b/packages/react-native-ui-common/src/util/tree.ts index b4de7bad70..661b0a3503 100644 --- a/packages/react-native-ui-common/src/util/tree.ts +++ b/packages/react-native-ui-common/src/util/tree.ts @@ -17,7 +17,7 @@ export const get = memoize(1000)((id: string, dataset: Dataset) => dataset[id]); export const getParent = memoize(1000)((id: string, dataset: Dataset) => { const item = get(id, dataset); - return item && item.type !== 'root' ? get(item.parent, dataset) : undefined; + return item && item.type !== 'root' ? get(item.parent as string, dataset) : undefined; }); export const getParents = memoize(1000)((id: string, dataset: Dataset): Item[] => { @@ -25,8 +25,8 @@ export const getParents = memoize(1000)((id: string, dataset: Dataset): Item[] = return parent ? [parent, ...getParents(parent.id, dataset)] : []; }); -export const getAncestorIds = memoize(1000)((data: IndexHash, id: string): string[] => - getParents(id, data).map((item) => item.id) +export const getAncestorIds = memoize(1000)((data: IndexHash, id: string | null): string[] => + getParents(id as string, data).map((item) => item.id) ); export const getDescendantIds = memoize(1000)(( @@ -34,9 +34,9 @@ export const getDescendantIds = memoize(1000)(( id: string, skipLeafs: boolean ): string[] => { - const entry = data[id]; + const entry = data[id]!; const children = entry.type === 'story' || entry.type === 'docs' ? [] : entry.children; - return children.reduce((acc, childId) => { + return children.reduce((acc, childId) => { const child = data[childId]; if (!child || (skipLeafs && (child.type === 'story' || child.type === 'docs'))) return acc; acc.push(childId, ...getDescendantIds(data, childId, skipLeafs)); @@ -45,7 +45,7 @@ export const getDescendantIds = memoize(1000)(( }); export function getPath(item: Item, ref: RefType): string[] { - const parent = item.type !== 'root' && item.parent ? ref.index[item.parent] : null; + const parent = item.type !== 'root' && item.parent ? ref.index![item.parent] : null; if (parent) return [...getPath(parent, ref), parent.name]; return ref.id === DEFAULT_REF_ID ? [] : [ref.title || ref.id]; } @@ -81,7 +81,12 @@ export const getStateType = ( } }; -export const isAncestor = (element?: Element, maybeAncestor?: Element): boolean => { +type ElementLike = { parentElement?: ElementLike | null }; + +export const isAncestor = ( + element?: ElementLike | null, + maybeAncestor?: ElementLike | null +): boolean => { if (!element || !maybeAncestor) return false; if (element === maybeAncestor) return true; return isAncestor(element.parentElement, maybeAncestor); diff --git a/packages/react-native-ui-common/src/util/useStyle.ts b/packages/react-native-ui-common/src/util/useStyle.ts index 759670971c..c0b5b61b55 100644 --- a/packages/react-native-ui-common/src/util/useStyle.ts +++ b/packages/react-native-ui-common/src/util/useStyle.ts @@ -25,4 +25,4 @@ export const useStyle = < deps?: DependencyList ): TOutput => // eslint-disable-next-line react-hooks/exhaustive-deps - useMemo(styleFactory, deps); + useMemo(styleFactory, deps as DependencyList); diff --git a/packages/react-native-ui-common/tsconfig.json b/packages/react-native-ui-common/tsconfig.json index 453f7395d5..4f7685bd07 100644 --- a/packages/react-native-ui-common/tsconfig.json +++ b/packages/react-native-ui-common/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "baseUrl": ".", "rootDir": "./src", "outDir": "dist/" }, diff --git a/packages/react-native-ui-common/tsup.config.ts b/packages/react-native-ui-common/tsdown.config.mts similarity index 64% rename from packages/react-native-ui-common/tsup.config.ts rename to packages/react-native-ui-common/tsdown.config.mts index 15a1247566..79b5015078 100644 --- a/packages/react-native-ui-common/tsup.config.ts +++ b/packages/react-native-ui-common/tsdown.config.mts @@ -1,8 +1,13 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; export default defineConfig((options) => { return { entry: ['src/index.tsx'], + format: ['cjs'], + fixedExtension: false, + deps: { + onlyBundle: false, + }, clean: !options.watch, dts: !options.watch ? { diff --git a/packages/react-native-ui-lite/package.json b/packages/react-native-ui-lite/package.json index db967a45b1..3d05cdd9f2 100644 --- a/packages/react-native-ui-lite/package.json +++ b/packages/react-native-ui-lite/package.json @@ -29,8 +29,8 @@ ], "scripts": { "check:types": "tsc --noEmit", - "dev": "tsup --watch", - "prepare": "tsup" + "dev": "tsdown --watch", + "prepare": "tsdown" }, "dependencies": { "@gorhom/portal": "^1.0.14", @@ -39,20 +39,20 @@ "@storybook/react": "^10.3.2", "@storybook/react-native-theming": "^10.4.0", "@storybook/react-native-ui-common": "^10.4.0", - "polished": "^4.3.1", - "react-native-safe-area-context": "^5" + "polished": "^4.3.1" }, "devDependencies": { "@types/react": "~19.2.14", "storybook": "^10.3.2", "ts-dedent": "^2.2.0", - "tsup": "^8.5.0", - "typescript": "~5.9.3" + "tsdown": "^0.22.0", + "typescript": "~6.0.3" }, "peerDependencies": { "react": "*", "react-native": ">=0.57.0", - "storybook": ">=10 || ^10" + "storybook": ">=10 || ^10", + "react-native-safe-area-context": "*" }, "engines": { "node": ">=20.0.0" diff --git a/packages/react-native-ui-lite/src/Layout.tsx b/packages/react-native-ui-lite/src/Layout.tsx index d92773e6c1..4580f83b2d 100644 --- a/packages/react-native-ui-lite/src/Layout.tsx +++ b/packages/react-native-ui-lite/src/Layout.tsx @@ -119,6 +119,7 @@ export const Layout = ({ 'desktopPanelState', true ); + const [isMobileSearchActive, setIsMobileSearchActive] = useState(false); const [sidebarWidth, setSidebarWidth] = useStoreNumberState('desktopSidebarWidth', 240); const [addonsPanelHeight, setAddonsPanelHeight] = useStoreNumberState( @@ -371,7 +372,11 @@ export const Layout = ({ ) : null} {isDesktop ? null : ( - + @@ -385,6 +390,7 @@ export const Layout = ({ index={storyHash} storyId={story?.id} refId={DEFAULT_REF_ID} + onSearchActiveChange={setIsMobileSearchActive} /> )} diff --git a/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx b/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx index f1fa12215d..abc7934553 100644 --- a/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx +++ b/packages/react-native-ui-lite/src/MobileAddonsPanel.tsx @@ -5,6 +5,7 @@ import { Animated, Easing, Keyboard, + KeyboardEvent, Platform, ScrollView, StyleProp, @@ -31,106 +32,72 @@ export const MobileAddonsPanel = forwardRef { const theme = useTheme(); const { height } = useWindowDimensions(); - const panelHeight = useAnimatedValue(0); + const defaultPanelHeight = height / 2; const positionBottomAnimation = useAnimatedValue(height / 2); + const [panelHeight, setPanelHeight] = useState(defaultPanelHeight); const [isOpen, setIsOpen] = useState(false); + useEffect(() => { + setPanelHeight(defaultPanelHeight); + }, [defaultPanelHeight]); + const setMobileMenuOpen = useCallback( (open: boolean) => { setIsOpen(open); if (open) { - Animated.parallel([ - Animated.timing(positionBottomAnimation, { - toValue: 0, // Negative to move up - duration: 350, - useNativeDriver: false, - easing: Easing.inOut(Easing.cubic), - }), - - Animated.timing(panelHeight, { - toValue: height / 2, - duration: 350, - useNativeDriver: false, - easing: Easing.inOut(Easing.cubic), - }), - ]).start(); + setPanelHeight(defaultPanelHeight); + positionBottomAnimation.setValue(defaultPanelHeight); + Animated.timing(positionBottomAnimation, { + toValue: 0, + duration: 350, + useNativeDriver: true, + easing: Easing.inOut(Easing.cubic), + }).start(); } else { - Animated.parallel([ - Animated.timing(positionBottomAnimation, { - toValue: height / 2, - duration: 350, - useNativeDriver: false, - easing: Easing.inOut(Easing.cubic), - }), - Animated.timing(panelHeight, { - toValue: 0, - duration: 350, - useNativeDriver: false, - easing: Easing.inOut(Easing.cubic), - }), - ]).start(); + Animated.timing(positionBottomAnimation, { + toValue: defaultPanelHeight, + duration: 350, + useNativeDriver: true, + easing: Easing.inOut(Easing.cubic), + }).start(() => { + setPanelHeight(defaultPanelHeight); + }); } }, - [height, positionBottomAnimation, panelHeight] + [defaultPanelHeight, positionBottomAnimation] ); useEffect(() => { - // Define keyboard show handler - const handleKeyboardShow = ({ endCoordinates, duration, easing }) => { + const handleKeyboardShow = ({ endCoordinates }: KeyboardEvent) => { if (isOpen) { - Animated.parallel([ - Animated.timing(panelHeight, { - toValue: (height - endCoordinates.height) / 2, - duration, - useNativeDriver: false, - easing: Easing[easing] || Easing.out(Easing.ease), - }), - Animated.timing(positionBottomAnimation, { - toValue: -endCoordinates.height, // Negative to move up - duration, - useNativeDriver: false, - easing: Easing[easing] || Easing.out(Easing.ease), - }), - ]).start(); + setPanelHeight((height - endCoordinates.height) / 2); + positionBottomAnimation.setValue(-endCoordinates.height); } }; - // Define keyboard hide handler - const handleKeyboardHide = ({ duration, easing }) => { + const handleKeyboardHide = () => { if (isOpen) { - Animated.parallel([ - Animated.timing(positionBottomAnimation, { - toValue: 0, // Back to original position - duration, - useNativeDriver: false, - easing: Easing[easing] || Easing.out(Easing.ease), - }), - - Animated.timing(panelHeight, { - toValue: height / 2, - duration, - useNativeDriver: false, - easing: Easing[easing] || Easing.out(Easing.ease), - }), - ]).start(); + setPanelHeight(defaultPanelHeight); + positionBottomAnimation.setValue(0); } }; - // Add keyboard event listeners - const showSubscription = Keyboard.addListener('keyboardDidShow', handleKeyboardShow); - const willShowSubscription = Keyboard.addListener('keyboardWillShow', handleKeyboardShow); - const hideSubscription = Keyboard.addListener('keyboardWillHide', handleKeyboardHide); - const didHideSubscription = Keyboard.addListener('keyboardDidHide', handleKeyboardHide); + const showSubscription = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + handleKeyboardShow + ); + const hideSubscription = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + handleKeyboardHide + ); // Clean up subscriptions on unmount return () => { showSubscription.remove(); - willShowSubscription.remove(); hideSubscription.remove(); - didHideSubscription.remove(); }; - }, [height, panelHeight, positionBottomAnimation, isOpen]); + }, [defaultPanelHeight, height, positionBottomAnimation, isOpen]); useImperativeHandle(ref, () => ({ setAddonsPanelOpen: (open: boolean) => { diff --git a/packages/react-native-ui-lite/src/MobileMenuDrawer.tsx b/packages/react-native-ui-lite/src/MobileMenuDrawer.tsx index 00c83aaa88..3d756a6456 100644 --- a/packages/react-native-ui-lite/src/MobileMenuDrawer.tsx +++ b/packages/react-native-ui-lite/src/MobileMenuDrawer.tsx @@ -1,4 +1,5 @@ import { Portal } from '@gorhom/portal'; +import { Button } from '@storybook/react-native-ui-common'; import { useTheme } from '@storybook/react-native-theming'; import { forwardRef, @@ -41,6 +42,7 @@ const portalContainerStyle: ViewStyle = { interface MobileMenuDrawerProps { children: ReactNode | ReactNode[]; onVisibilityChange?: (visible: boolean) => void; + showScrollToSelected?: boolean; } export interface MobileMenuDrawerRef { @@ -49,43 +51,49 @@ export interface MobileMenuDrawerRef { export const useAnimatedModalHeight = () => { const { height } = useWindowDimensions(); - const animatedHeight = useAnimatedValue(0.65 * height); + const modalHeight = 0.65 * height; + const maxModalHeight = 0.85 * height; + const [sheetHeight, setSheetHeight] = useState(modalHeight); + const [keyboardInset, setKeyboardInset] = useState(0); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); useEffect(() => { - const modalHeight = 0.65 * height; - const maxModalHeight = 0.85 * height; - - const expand = (duration: number = 250) => - Animated.timing(animatedHeight, { - toValue: maxModalHeight, - duration, - easing: Easing.out(Easing.quad), - useNativeDriver: false, - }).start(); - - const collapse = (duration: number = 250) => - Animated.timing(animatedHeight, { - toValue: modalHeight, - duration, - easing: Easing.out(Easing.quad), - useNativeDriver: false, - }).start(); + setSheetHeight(modalHeight); + setKeyboardInset(0); + setIsKeyboardVisible(false); + }, [modalHeight]); + + useEffect(() => { + const expand = (keyboardHeight: number = 0) => { + const maxKeyboardOffset = maxModalHeight - modalHeight; + const keyboardAvoidanceOffset = Math.min(keyboardHeight, maxKeyboardOffset); + + setIsKeyboardVisible(true); + setSheetHeight(modalHeight + keyboardAvoidanceOffset); + setKeyboardInset(Math.max(keyboardHeight - keyboardAvoidanceOffset, 0)); + }; + + const collapse = () => { + setSheetHeight(modalHeight); + setKeyboardInset(0); + setIsKeyboardVisible(false); + }; const handleKeyboardWillShow: KeyboardEventListener = (e) => { if (Platform.OS === 'ios') { - expand(e.duration); + expand(e.endCoordinates.height); } }; const handleKeyboardDidShow: KeyboardEventListener = (e) => { if (Platform.OS === 'android') { - expand(); + expand(e.endCoordinates.height); } }; const handleKeyboardWillHide: KeyboardEventListener = (e) => { if (Platform.OS === 'ios') { - collapse(e.duration); + collapse(); } }; @@ -105,19 +113,23 @@ export const useAnimatedModalHeight = () => { return () => { subscriptions.forEach((subscription) => subscription.remove()); }; - }, [animatedHeight, height]); + }, [maxModalHeight, modalHeight]); - return animatedHeight; + return { + height: sheetHeight, + keyboardInset, + isKeyboardVisible, + }; }; export const MobileMenuDrawer = memo( forwardRef( - ({ children, onVisibilityChange }, ref) => { + ({ children, onVisibilityChange, showScrollToSelected = true }, ref) => { const [isVisible, setIsVisible] = useState(false); const { scrollCallback } = useSelectedNode(); const theme = useTheme(); const { height } = useWindowDimensions(); - const animatedHeight = useAnimatedModalHeight(); + const { height: sheetHeight, keyboardInset, isKeyboardVisible } = useAnimatedModalHeight(); // Slide animation for drawer entrance/exit const slideAnim = useAnimatedValue(height); @@ -136,13 +148,12 @@ export const MobileMenuDrawer = memo( duration: 300, easing: Easing.out(Easing.quad), useNativeDriver: true, - }).start(({ finished }) => { - if (finished) { - // go to the selected story and don't animate - scrollCallback({ animated: false, id: undefined }); - } - }); - }, [dragY, height, onVisibilityChange, scrollCallback, slideAnim]); + }).start(); + }, [dragY, height, onVisibilityChange, slideAnim]); + + const scrollToSelectedStory = useCallback(() => { + scrollCallback({ animated: true, id: undefined }); + }, [scrollCallback]); const closeDrawer = useCallback(() => { Keyboard.dismiss(); @@ -176,6 +187,21 @@ export const MobileMenuDrawer = memo( } }, onPanResponderRelease: (_, gestureState) => { + if (isKeyboardVisible) { + if (gestureState.dy > 20) { + Keyboard.dismiss(); + } + + Animated.timing(dragY, { + toValue: 0, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return; + } + if (gestureState.dy > 50) { closeDrawer(); } else { @@ -189,9 +215,11 @@ export const MobileMenuDrawer = memo( } }, }), - [closeDrawer, dragY] + [closeDrawer, dragY, isKeyboardVisible] ); + const sheetTranslateY = useMemo(() => Animated.add(slideAnim, dragY), [dragY, slideAnim]); + useImperativeHandle(ref, () => ({ setMobileMenuOpen: (open: boolean) => { if (open) { @@ -243,14 +271,29 @@ export const MobileMenuDrawer = memo( () => ({ flex: 1, backgroundColor: theme.background.content, + paddingBottom: keyboardInset, }), - [theme.background.content] + [keyboardInset, theme.background.content] + ); + + const scrollToSelectedButtonWrapperStyle = useMemo( + () => + ({ + position: 'absolute', + right: 16, + bottom: keyboardInset + 16, + zIndex: 1, + borderRadius: theme.input.borderRadius, + boxShadow: `0 2px 5px 0 ${theme.color.border}`, + elevation: 1, + }) satisfies ViewStyle, + [keyboardInset, theme.color.border, theme.input.borderRadius] ); return ( - + {/* Drag handle */} {children} + {showScrollToSelected ? ( + +