├── .prettierignore ├── manager.js ├── preview.js ├── src ├── index.ts ├── manager │ ├── components │ │ ├── Panel.ts │ │ ├── Loading.ts │ │ ├── Warning.ts │ │ ├── LocalePanelContent.tsx │ │ └── LocaleButton.ts │ ├── index.ts │ └── containers │ │ └── LocalePanel.tsx ├── utils │ ├── locale.ts │ └── __tests__ │ │ └── locale.test.ts ├── state.ts ├── preview │ ├── index.ts │ └── decorators │ │ └── withIntl.tsx ├── stories │ ├── 02-Date.stories.tsx │ └── 01-Message.stories.tsx ├── constants.ts ├── types.ts └── config.ts ├── .storybook ├── preview.css ├── local-preset.js ├── main.ts ├── preview.ts └── intl.ts ├── docs └── screenshot.png ├── .vscode └── settings.json ├── jest.config.js ├── vite.config.ts ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .prettierrc.cjs ├── .editorconfig ├── tsconfig.json ├── LICENSE ├── README.md ├── .gitignore ├── package.json └── tsup.config.ts /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /manager.js: -------------------------------------------------------------------------------- 1 | import './dist/manager'; 2 | -------------------------------------------------------------------------------- /preview.js: -------------------------------------------------------------------------------- 1 | import './dist/preview'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // make it work with --isolatedModules 2 | export default {}; 3 | -------------------------------------------------------------------------------- /.storybook/preview.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: sans-serif; 4 | } 5 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/truffls/storybook-addon-intl/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.(ts|tsx)$': 'ts-jest' 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()] 6 | }); 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: 'npm' 5 | directory: '/' 6 | schedule: 7 | interval: 'weekly' 8 | -------------------------------------------------------------------------------- /src/manager/components/Panel.ts: -------------------------------------------------------------------------------- 1 | import { styled } from 'storybook/theming'; 2 | 3 | export const Panel = styled.div({ 4 | flexGrow: 0, 5 | display: 'flex', 6 | alignSelf: 'flex-start', 7 | flexWrap: 'wrap' 8 | }); 9 | -------------------------------------------------------------------------------- /src/manager/components/Loading.ts: -------------------------------------------------------------------------------- 1 | import { styled } from 'storybook/theming'; 2 | 3 | export const Loading = styled.div(({ theme }) => ({ 4 | color: theme.color.darkest, 5 | padding: '10px 15px', 6 | lineHeight: '20px' 7 | })); 8 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // EditorConfig defines the indention and end of line 3 | editorconfig: true, 4 | 5 | trailingComma: 'none', 6 | semi: true, 7 | singleQuote: true, 8 | arrowParens: 'always', 9 | 10 | jsxSingleQuote: false 11 | }; 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_size = 4 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [{package.json,package-lock.json}] 11 | indent_size = 2 12 | 13 | [*.{yml,yaml}] 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /src/manager/components/Warning.ts: -------------------------------------------------------------------------------- 1 | import { styled } from 'storybook/theming'; 2 | 3 | export const Warning = styled.div(({ theme }) => ({ 4 | background: theme.background.warning, 5 | color: theme.color.darkest, 6 | padding: '10px 15px', 7 | lineHeight: '20px', 8 | boxShadow: `${theme.appBorderColor} 0 -1px 0 0 inset` 9 | })); 10 | -------------------------------------------------------------------------------- /src/manager/index.ts: -------------------------------------------------------------------------------- 1 | import { addons, types } from 'storybook/manager-api'; 2 | import { ADDON_ID, PANEL_ID } from '../constants'; 3 | import { LocalePanel } from './containers/LocalePanel'; 4 | 5 | addons.register(ADDON_ID, () => { 6 | addons.add(PANEL_ID, { 7 | type: types.PANEL, 8 | title: 'Locales', 9 | render: LocalePanel 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /.storybook/local-preset.js: -------------------------------------------------------------------------------- 1 | /** 2 | * to load the built addon in this test Storybook 3 | */ 4 | function previewAnnotations(entry = []) { 5 | return [...entry, require.resolve('../dist/preview.js')]; 6 | } 7 | 8 | function managerEntries(entry = []) { 9 | return [...entry, require.resolve('../dist/manager.js')]; 10 | } 11 | 12 | module.exports = { 13 | managerEntries, 14 | previewAnnotations 15 | }; 16 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/stories/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: ['storybook-addon-intl'], 6 | framework: { 7 | name: '@storybook/react-vite', 8 | options: {} 9 | }, 10 | docs: { 11 | autodocs: 'tag' 12 | } 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /src/utils/locale.ts: -------------------------------------------------------------------------------- 1 | export function getActiveLocale( 2 | locales: string[], 3 | activeLocale: string | null | undefined, 4 | defaultLocale: string | null | undefined 5 | ): string | null { 6 | if (!!activeLocale && locales.includes(activeLocale)) { 7 | return activeLocale; 8 | } 9 | 10 | if (!!defaultLocale && locales.includes(defaultLocale)) { 11 | return defaultLocale; 12 | } 13 | 14 | return null; 15 | } 16 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { StorybookAddonIntlState } from './types'; 2 | 3 | export function parseState(state: unknown): StorybookAddonIntlState { 4 | if ( 5 | typeof state !== 'object' || 6 | state === null || 7 | !('activeLocale' in state) || 8 | typeof state.activeLocale !== 'string' 9 | ) { 10 | return { activeLocale: null }; 11 | } 12 | 13 | return { 14 | activeLocale: state.activeLocale 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/preview/index.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | import { GLOBALS_KEY, PARAMETER_KEY } from '../constants'; 3 | import { withIntl } from './decorators/withIntl'; 4 | 5 | const preview: Preview = { 6 | decorators: [withIntl], 7 | parameters: { 8 | [PARAMETER_KEY]: null 9 | }, 10 | initialGlobals: { 11 | [GLOBALS_KEY]: { 12 | activeLocale: null 13 | } 14 | } 15 | }; 16 | 17 | export default preview; 18 | -------------------------------------------------------------------------------- /src/stories/02-Date.stories.tsx: -------------------------------------------------------------------------------- 1 | import { StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { FormattedDate } from 'react-intl'; 4 | 5 | const meta = { 6 | title: 'Example/02 Date' 7 | }; 8 | 9 | export default meta; 10 | 11 | type Story = StoryObj; 12 | 13 | export const Default: Story = { 14 | render: () => { 15 | return ( 16 | 17 | ); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { StorybookAddonIntlConfigCommon } from './types'; 2 | 3 | export const ADDON_ID = 'truffls/storybook-addon-intl'; 4 | export const PANEL_ID = `${ADDON_ID}/panel`; 5 | 6 | export const PARAMETER_KEY = 'intl'; 7 | export const GLOBALS_KEY = 'intl'; 8 | 9 | export const EVENTS = { 10 | CONFIG_STATUS: `${ADDON_ID}/config_status` 11 | }; 12 | export type EventsData = { 13 | CONFIG_STATUS: { 14 | config: StorybookAddonIntlConfigCommon | null; 15 | error: string | null; 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import { defaultLocales, formats, messages } from './intl'; 2 | import { Preview } from '@storybook/react'; 3 | 4 | import './preview.css'; 5 | 6 | const getMessages = (locale) => messages[locale]; 7 | const getFormats = (locale) => formats[locale]; 8 | 9 | const preview: Preview = { 10 | parameters: { 11 | intl: { 12 | locales: defaultLocales, 13 | defaultLocale: 'en', 14 | getMessages, 15 | getFormats 16 | } 17 | } 18 | }; 19 | 20 | export default preview; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "esModuleInterop": true, 5 | "experimentalDecorators": true, 6 | "incremental": false, 7 | "isolatedModules": true, 8 | "jsx": "react", 9 | "lib": ["es2020", "dom"], 10 | "module": "commonjs", 11 | "noImplicitAny": true, 12 | "rootDir": "./src", 13 | "skipLibCheck": true, 14 | "target": "ES2020", 15 | "noEmit": true, 16 | "strict": true 17 | }, 18 | "include": ["src/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm ci 21 | - run: npm run typecheck 22 | - run: npm test 23 | - run: npm run check-format 24 | -------------------------------------------------------------------------------- /src/utils/__tests__/locale.test.ts: -------------------------------------------------------------------------------- 1 | import { getActiveLocale } from '../locale'; 2 | 3 | it(`should return default locale if active locale doesn't have a value`, () => { 4 | expect(getActiveLocale(['en', 'de'], null, 'en')).toBe('en'); 5 | }); 6 | 7 | it(`should return null if default locale is not a valid locale`, () => { 8 | expect(getActiveLocale(['en', 'de'], null, 'es')).toBe(null); 9 | }); 10 | 11 | it(`should return active locale if it's a valid locale`, () => { 12 | expect(getActiveLocale(['en', 'de'], 'de', 'en')).toBe('de'); 13 | }); 14 | 15 | it(`should return default locale if active locale is not a valid locale`, () => { 16 | expect(getActiveLocale(['en', 'de'], 'es', 'en')).toBe('en'); 17 | }); 18 | -------------------------------------------------------------------------------- /.storybook/intl.ts: -------------------------------------------------------------------------------- 1 | export const defaultLocales = ['en', 'de']; 2 | 3 | // Provide your messages 4 | export const messages = { 5 | en: { message: 'Just some text.' }, 6 | de: { message: 'Nur etwas Text.' }, 7 | es: { message: 'Sólo un texto.' } 8 | }; 9 | 10 | // Provide your formats (optional) 11 | export const formats = { 12 | en: { 13 | date: { 14 | custom: { 15 | year: 'numeric', 16 | month: '2-digit', 17 | day: '2-digit' 18 | } 19 | } 20 | }, 21 | de: { 22 | date: { 23 | custom: { 24 | year: '2-digit', 25 | month: 'numeric', 26 | day: 'numeric' 27 | } 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { FormatXMLElementFn } from 'intl-messageformat'; 2 | import type { ReactNode } from 'react'; 3 | import type { CustomFormats, MessageFormatElement } from 'react-intl'; 4 | 5 | type MessageIds = FormatjsIntl.Message extends { 6 | ids: infer T; 7 | } 8 | ? T extends string 9 | ? T 10 | : string 11 | : string; 12 | 13 | export type StorybookAddonIntlConfigCommon = { 14 | locales: string[]; 15 | defaultLocale: string; 16 | }; 17 | 18 | export type StorybookAddonIntlConfig = StorybookAddonIntlConfigCommon & { 19 | getMessages: ( 20 | locale: string 21 | ) => 22 | | Record 23 | | Record; 24 | getFormats: (locale: string) => CustomFormats; 25 | defaultRichTextElements?: Record>; 26 | }; 27 | 28 | export type StorybookAddonIntlState = { 29 | activeLocale: string | null; 30 | }; 31 | -------------------------------------------------------------------------------- /src/manager/components/LocalePanelContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Panel } from './Panel'; 3 | import { LocaleButton } from './LocaleButton'; 4 | 5 | export type LocalePanelContentProps = { 6 | locales: string[]; 7 | activeLocale: string; 8 | onChangeLocale: (nextActiveLocale: string) => void; 9 | }; 10 | 11 | export function LocalePanelContent({ 12 | locales, 13 | activeLocale, 14 | onChangeLocale 15 | }: LocalePanelContentProps) { 16 | return ( 17 | 18 | {locales.map((locale) => { 19 | const handleClick = () => { 20 | onChangeLocale(locale); 21 | }; 22 | 23 | return ( 24 | 29 | {locale} 30 | 31 | ); 32 | })} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Truffls GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/manager/components/LocaleButton.ts: -------------------------------------------------------------------------------- 1 | import { styled } from 'storybook/theming'; 2 | 3 | export const LocaleButton = styled.button<{ active?: boolean }>( 4 | ({ active, theme }) => { 5 | const background = 6 | theme.base === 'light' ? '#ffffff' : theme.boolean.background; 7 | const activeBackground = 8 | theme.base === 'light' 9 | ? '#f7f7f7' 10 | : theme.boolean.selectedBackground; 11 | 12 | return { 13 | height: '50px', 14 | width: '100px', 15 | padding: '5px', 16 | border: 0, 17 | borderRightStyle: 'solid', 18 | borderRightWidth: '1px', 19 | borderBottomStyle: 'solid', 20 | borderBottomWidth: '1px', 21 | lineHeight: '30px', 22 | textAlign: 'center', 23 | textTransform: 'uppercase', 24 | transitionProperty: 'background', 25 | transitionDuration: '100ms', 26 | transitionTimingFunction: 'linear', 27 | borderColor: theme.color.border, 28 | color: active ? theme.color.defaultText : theme.textMutedColor, 29 | background: active ? activeBackground : background, 30 | fontWeight: active ? theme.typography.weight.bold : undefined, 31 | textDecoration: active ? 'underline' : undefined 32 | }; 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /src/stories/01-Message.stories.tsx: -------------------------------------------------------------------------------- 1 | import { StoryObj } from '@storybook/react'; 2 | import React from 'react'; 3 | import { FormattedMessage } from 'react-intl'; 4 | 5 | const meta = { 6 | title: 'Example/01 Message', 7 | render: () => { 8 | return ; 9 | } 10 | }; 11 | export default meta; 12 | 13 | type Story = StoryObj; 14 | 15 | export const Default: Story = {}; 16 | 17 | export const PerStory: Story = { 18 | parameters: { 19 | intl: { 20 | locales: ['en', 'de', 'es'] 21 | } 22 | } 23 | }; 24 | 25 | const messagesRichText = { 26 | en: { 27 | message: 28 | 'Something important.

Something underlined.' 29 | }, 30 | es: { 31 | message: 32 | 'Algo importante.

Algo subrayado.' 33 | } 34 | } as Record; 35 | 36 | const getMessages = (locale: string) => messagesRichText[locale]; 37 | 38 | export const RichTextElements: Story = { 39 | parameters: { 40 | intl: { 41 | locales: ['en', 'es'], 42 | getMessages, 43 | defaultRichTextElements: { 44 | br: () =>
, 45 | strong: (text: any) => {text}, 46 | underline: (text: any) => ( 47 | {text} 48 | ) 49 | } 50 | } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { StorybookAddonIntlConfig } from './types'; 2 | 3 | type ParseConfigResult = { 4 | config: StorybookAddonIntlConfig | null; 5 | error: string | null; 6 | }; 7 | 8 | export function parseConfig(config: unknown): ParseConfigResult { 9 | if (typeof config !== 'object' || config === null) { 10 | return { config: null, error: 'missing' }; 11 | } else if ( 12 | !('locales' in config) || 13 | !Array.isArray(config.locales) || 14 | config.locales.some((value) => typeof value !== 'string') 15 | ) { 16 | return { config: null, error: 'invalid-locales' }; 17 | } else if ( 18 | !('defaultLocale' in config) || 19 | typeof config.defaultLocale !== 'string' 20 | ) { 21 | return { config: null, error: 'invalid-default-locale' }; 22 | } else if ( 23 | !('getMessages' in config) || 24 | typeof config.getMessages !== 'function' 25 | ) { 26 | return { config: null, error: 'invalid-get-messages' }; 27 | } else if ( 28 | 'getFormats' in config && 29 | typeof config.getFormats !== 'function' 30 | ) { 31 | return { config: null, error: 'invalid-get-formats' }; 32 | } else if ( 33 | 'defaultRichTextElements' in config && 34 | (typeof config.defaultRichTextElements !== 'object' || 35 | config.defaultRichTextElements === null || 36 | Array.isArray(config.defaultRichTextElements)) 37 | ) { 38 | return { config: null, error: 'invalid-rich-text-elements' }; 39 | } 40 | return { config: config as StorybookAddonIntlConfig, error: null }; 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storybook Addon Intl 2 | 3 | The Intl addon can be used to provide locale switcher and react-intl. 4 | 5 | ![](docs/screenshot.png) 6 | 7 | ## Getting Started 8 | 9 | First, install the addon 10 | 11 | ```shell 12 | npm install -D storybook-addon-intl 13 | ``` 14 | 15 | Then, add following content to `.storybook/main.js`: 16 | 17 | ```js 18 | export default { 19 | addons: ['storybook-addon-intl'] 20 | }; 21 | ``` 22 | 23 | In `.storybook/preview.js`, add the following: 24 | 25 | ```js 26 | // Provide your messages 27 | const messages = { 28 | en: { message: 'Just some text.' }, 29 | de: { message: 'Nur etwas Text.' }, 30 | es: { message: 'Sólo un texto.' } 31 | }; 32 | 33 | const getMessages = (locale) => messages[locale]; 34 | 35 | export default { 36 | parameters: { 37 | intl: { 38 | locales: defaultLocales, 39 | defaultLocale: 'en', 40 | getMessages 41 | } 42 | } 43 | }; 44 | ``` 45 | 46 | ## Configuration 47 | 48 | Parameter key: `intl` 49 | 50 | ### `locales` 51 | 52 | Type: `string[]` 53 | 54 | Available locales. 55 | 56 | ### `defaultLocale` 57 | 58 | Type: `string` 59 | 60 | Fallback locale. 61 | 62 | ### `getMessages` 63 | 64 | Type: `(locale: string) => object` 65 | 66 | Getter function that takes the active locale as arguments and expects an `object` of messages as a return value. 67 | 68 | (See `messages` in [`IntlProvider` docs](https://formatjs.io/docs/react-intl/components#intlprovider) of react-intl) 69 | 70 | ### `getFormats` 71 | 72 | Type: `(locale: string) => object` 73 | 74 | Getter function that takes the active locale as arguments and expects an `object` of formats as a return value. 75 | 76 | (See `formats` in [`IntlProvider` docs](https://formatjs.io/docs/react-intl/components#intlprovider) of react-intl) 77 | 78 | ### `defaultRichTextElements` 79 | 80 | Type: `object` 81 | 82 | Object of rich text elements. 83 | 84 | (See `defaultRichTextElements` in [`IntlProvider` docs](https://formatjs.io/docs/react-intl/components#intlprovider) of react-intl) 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Linux ### 2 | *~ 3 | 4 | # temporary files which can be created if a process still has a handle open of a deleted file 5 | .fuse_hidden* 6 | 7 | # KDE directory preferences 8 | .directory 9 | 10 | # Linux trash folder which might appear on any partition or disk 11 | .Trash-* 12 | 13 | # .nfs files are created when an open file is removed but is still being accessed 14 | .nfs* 15 | 16 | ### macOS ### 17 | # General 18 | .DS_Store 19 | .AppleDouble 20 | .LSOverride 21 | 22 | # Icon must end with two \r 23 | Icon 24 | 25 | 26 | # Thumbnails 27 | ._* 28 | 29 | # Files that might appear in the root of a volume 30 | .DocumentRevisions-V100 31 | .fseventsd 32 | .Spotlight-V100 33 | .TemporaryItems 34 | .Trashes 35 | .VolumeIcon.icns 36 | .com.apple.timemachine.donotpresent 37 | 38 | # Directories potentially created on remote AFP share 39 | .AppleDB 40 | .AppleDesktop 41 | Network Trash Folder 42 | Temporary Items 43 | .apdisk 44 | 45 | ### macOS Patch ### 46 | # iCloud generated files 47 | *.icloud 48 | 49 | ### Windows ### 50 | # Windows thumbnail cache files 51 | Thumbs.db 52 | Thumbs.db:encryptable 53 | ehthumbs.db 54 | ehthumbs_vista.db 55 | 56 | # Dump file 57 | *.stackdump 58 | 59 | # Folder config file 60 | [Dd]esktop.ini 61 | 62 | # Recycle Bin used on file shares 63 | $RECYCLE.BIN/ 64 | 65 | # Windows Installer files 66 | *.cab 67 | *.msi 68 | *.msix 69 | *.msm 70 | *.msp 71 | 72 | # Windows shortcuts 73 | *.lnk 74 | 75 | ### Node ### 76 | # Logs 77 | logs 78 | *.log 79 | npm-debug.log* 80 | 81 | # Diagnostic reports (https://nodejs.org/api/report.html) 82 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 83 | 84 | # Runtime data 85 | pids 86 | *.pid 87 | *.seed 88 | *.pid.lock 89 | 90 | # Dependency directories 91 | node_modules/ 92 | 93 | # TypeScript cache 94 | *.tsbuildinfo 95 | 96 | # Optional npm cache directory 97 | .npm 98 | 99 | # Optional eslint cache 100 | .eslintcache 101 | 102 | # Optional REPL history 103 | .node_repl_history 104 | 105 | # Output of 'npm pack' 106 | *.tgz 107 | 108 | ### Project ### 109 | 110 | /dist 111 | -------------------------------------------------------------------------------- /src/preview/decorators/withIntl.tsx: -------------------------------------------------------------------------------- 1 | import type { Decorator } from '@storybook/react'; 2 | import { useChannel, useEffect, useGlobals } from 'storybook/preview-api'; 3 | import React, { Fragment } from 'react'; 4 | import { IntlProvider } from 'react-intl'; 5 | import { parseConfig } from '../../config'; 6 | import { 7 | EVENTS, 8 | EventsData, 9 | GLOBALS_KEY, 10 | PARAMETER_KEY 11 | } from '../../constants'; 12 | import { parseState } from '../../state'; 13 | import { getActiveLocale } from '../../utils/locale'; 14 | 15 | export const withIntl: Decorator = (StoryFn, context) => { 16 | const { parameters } = context; 17 | const rawIntlConfig = parameters[PARAMETER_KEY]; 18 | const { config: intlConfig, error: parseError } = 19 | parseConfig(rawIntlConfig); 20 | 21 | const emit = useChannel({}); 22 | 23 | useEffect(() => { 24 | const eventData: EventsData['CONFIG_STATUS'] = { 25 | config: intlConfig 26 | ? { 27 | defaultLocale: intlConfig.defaultLocale, 28 | locales: intlConfig.locales 29 | } 30 | : null, 31 | error: null 32 | }; 33 | emit(EVENTS.CONFIG_STATUS, eventData); 34 | }, [intlConfig, parseError]); 35 | 36 | const [globals] = useGlobals(); 37 | const rawIntlState = globals[GLOBALS_KEY]; 38 | const intlState = parseState(rawIntlState); 39 | 40 | const locales = intlConfig?.locales ?? []; 41 | 42 | const activeLocale = getActiveLocale( 43 | locales, 44 | intlState.activeLocale, 45 | intlConfig?.defaultLocale 46 | ); 47 | 48 | useEffect(() => { 49 | if (!!parseError || !activeLocale) { 50 | return; 51 | } 52 | 53 | document.documentElement.lang = activeLocale; 54 | }, [activeLocale]); 55 | 56 | if (!!parseError || !intlConfig || !activeLocale) { 57 | return ; 58 | } 59 | 60 | const { getMessages, getFormats, defaultRichTextElements } = intlConfig; 61 | 62 | const messages = getMessages(activeLocale); 63 | const formats = 64 | typeof getFormats === 'function' ? getFormats(activeLocale) : undefined; 65 | 66 | return ( 67 | 73 | {StoryFn()} 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storybook-addon-intl", 3 | "version": "5.0.0", 4 | "description": "Addon to provide locale switcher and react-intl for storybook", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "types": "./dist/index.d.ts", 9 | "import": "./dist/index.js", 10 | "require": "./dist/index.cjs" 11 | }, 12 | "./preview": { 13 | "types": "./dist/preview.d.ts", 14 | "import": "./dist/preview.js", 15 | "require": "./dist/preview.cjs" 16 | }, 17 | "./manager": "./dist/manager.js", 18 | "./package.json": "./package.json" 19 | }, 20 | "scripts": { 21 | "build": "tsup", 22 | "build:watch": "npm run build -- --watch", 23 | "start": "run-p build:watch 'storybook --quiet'", 24 | "storybook": "storybook dev --port 9001 --no-open", 25 | "check-format": "prettier -c .", 26 | "typecheck": "tsc", 27 | "test": "jest", 28 | "test:watch": "jest --watch", 29 | "prepack": "npm run build" 30 | }, 31 | "files": [ 32 | "dist", 33 | "preview.js", 34 | "manager.js" 35 | ], 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/truffls/storybook-addon-intl.git" 39 | }, 40 | "keywords": [ 41 | "storybook", 42 | "storybook-addon", 43 | "storybook-addons", 44 | "react-intl", 45 | "i18n", 46 | "l10n" 47 | ], 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/truffls/storybook-addon-intl/issues" 51 | }, 52 | "homepage": "https://github.com/truffls/storybook-addon-intl#readme", 53 | "devDependencies": { 54 | "@storybook/react": "^9.0.3", 55 | "@storybook/react-vite": "^9.0.3", 56 | "@storybook/react-webpack5": "^9.0.3", 57 | "@types/jest": "^29.5.12", 58 | "@types/react": "^18.2.79", 59 | "@types/react-dom": "^18.2.25", 60 | "@vitejs/plugin-react": "^4.2.1", 61 | "jest": "^29.7.0", 62 | "npm-run-all": "^4.1.5", 63 | "prettier": "^3.2.5", 64 | "react": "^18.2.0", 65 | "react-dom": "^18.2.0", 66 | "react-intl": "^7.1.11", 67 | "storybook": "^9.0.3", 68 | "ts-jest": "^29.1.2", 69 | "tsup": "^8.0.2", 70 | "typescript": "^5.4.5", 71 | "vite": "^5.2.9" 72 | }, 73 | "peerDependencies": { 74 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0", 75 | "react-intl": "^6.0.0 || ^7.0.0", 76 | "storybook": "^9.0.0" 77 | }, 78 | "bundler": { 79 | "exportEntry": "src/index.ts", 80 | "managerEntry": "src/manager/index.ts", 81 | "previewEntry": "src/preview/index.ts" 82 | }, 83 | "storybook": { 84 | "displayName": "storybook-addon-intl", 85 | "supportedFrameworks": [ 86 | "react" 87 | ] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/manager/containers/LocalePanel.tsx: -------------------------------------------------------------------------------- 1 | import { useChannel, useGlobals } from 'storybook/manager-api'; 2 | import React, { Fragment, useState } from 'react'; 3 | 4 | import { EVENTS, EventsData, GLOBALS_KEY } from '../../constants'; 5 | import { parseState } from '../../state'; 6 | import { StorybookAddonIntlConfigCommon } from '../../types'; 7 | import { getActiveLocale } from '../../utils/locale'; 8 | import { LocalePanelContent } from '../components/LocalePanelContent'; 9 | import { Warning } from '../components/Warning'; 10 | import { Loading } from '../components/Loading'; 11 | 12 | export type LocalPanelProps = { 13 | active?: boolean; 14 | }; 15 | 16 | export function LocalePanel({ active = false }: LocalPanelProps) { 17 | const [globals, updateGlobals] = useGlobals(); 18 | const rawIntlGlobals = globals[GLOBALS_KEY]; 19 | const intlState = parseState(rawIntlGlobals); 20 | 21 | const [{ config: intlConfig, error: parseError }, setIntlConfigState] = 22 | useState<{ 23 | config: StorybookAddonIntlConfigCommon | null; 24 | error: string | null; 25 | }>({ 26 | config: null, 27 | error: null 28 | }); 29 | 30 | const locales = intlConfig?.locales ?? []; 31 | const activeLocale = getActiveLocale( 32 | locales, 33 | intlState.activeLocale, 34 | intlConfig?.defaultLocale 35 | ); 36 | 37 | useChannel({ 38 | [EVENTS.CONFIG_STATUS]: ({ 39 | config, 40 | error 41 | }: EventsData['CONFIG_STATUS']) => { 42 | setIntlConfigState({ 43 | config, 44 | error 45 | }); 46 | } 47 | }); 48 | 49 | const changeLocale = (nextActiveLocale: string) => { 50 | updateGlobals({ 51 | [GLOBALS_KEY]: { 52 | activeLocale: nextActiveLocale 53 | } 54 | }); 55 | }; 56 | 57 | return ( 58 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type Options } from 'tsup'; 2 | import { readFile } from 'fs/promises'; 3 | import { globalPackages as globalManagerPackages } from 'storybook/internal/manager/globals'; 4 | import { globalPackages as globalPreviewPackages } from 'storybook/internal/preview/globals'; 5 | 6 | const BROWSER_TARGET: Options['target'] = [ 7 | 'chrome100', 8 | 'safari15', 9 | 'firefox91' 10 | ]; 11 | const NODE_TARGET: Options['target'] = ['node18']; 12 | 13 | type BundlerConfig = { 14 | bundler?: { 15 | exportEntry?: string; 16 | nodeEntry?: string; 17 | managerEntry?: string; 18 | previewEntry?: string; 19 | }; 20 | }; 21 | 22 | export default defineConfig(async (options) => { 23 | // reading the three types of entries from package.json, which has the following structure: 24 | // { 25 | // ... 26 | // "bundler": { 27 | // "exportEntry": "./src/index.ts", 28 | // "managerEntry": "./src/manager.ts", 29 | // "previewEntry": "./src/preview.ts" 30 | // "nodeEntry": "./src/preset.ts" 31 | // } 32 | // } 33 | const packageJson = (await readFile('./package.json', 'utf8').then( 34 | JSON.parse 35 | )) as BundlerConfig; 36 | const { 37 | bundler: { exportEntry, managerEntry, previewEntry, nodeEntry } = {} 38 | } = packageJson; 39 | 40 | const commonConfig: Options = { 41 | splitting: false, 42 | minify: !options.watch, 43 | treeshake: true, 44 | sourcemap: true, 45 | clean: true 46 | }; 47 | 48 | const configs: Options[] = []; 49 | 50 | // export entries are entries meant to be manually imported by the user 51 | // they are not meant to be loaded by the manager or preview 52 | // they'll be usable in both node and browser environments, depending on which features and modules they depend on 53 | if (!!exportEntry) { 54 | configs.push({ 55 | ...commonConfig, 56 | entry: { 57 | index: exportEntry 58 | }, 59 | dts: { 60 | resolve: true 61 | }, 62 | format: ['esm', 'cjs'], 63 | target: [...BROWSER_TARGET, ...NODE_TARGET], 64 | platform: 'neutral', 65 | external: [...globalManagerPackages, ...globalPreviewPackages] 66 | }); 67 | } 68 | 69 | // manager entries are entries meant to be loaded into the manager UI 70 | // they'll have manager-specific packages externalized and they won't be usable in node 71 | // they won't have types generated for them as they're usually loaded automatically by Storybook 72 | if (!!managerEntry) { 73 | configs.push({ 74 | ...commonConfig, 75 | 76 | entry: { 77 | manager: managerEntry 78 | }, 79 | format: ['esm'], 80 | target: BROWSER_TARGET, 81 | platform: 'browser', 82 | external: globalManagerPackages 83 | }); 84 | } 85 | 86 | // preview entries are entries meant to be loaded into the preview iframe 87 | // they'll have preview-specific packages externalized and they won't be usable in node 88 | // they'll have types generated for them so they can be imported when setting up Portable Stories 89 | if (!!previewEntry) { 90 | configs.push({ 91 | ...commonConfig, 92 | entry: { 93 | preview: previewEntry 94 | }, 95 | dts: { 96 | resolve: true 97 | }, 98 | format: ['esm', 'cjs'], 99 | target: BROWSER_TARGET, 100 | platform: 'browser', 101 | external: globalPreviewPackages 102 | }); 103 | } 104 | 105 | // node entries are entries meant to be used in node-only 106 | // this is useful for presets, which are loaded by Storybook when setting up configurations 107 | // they won't have types generated for them as they're usually loaded automatically by Storybook 108 | if (!!nodeEntry) { 109 | configs.push({ 110 | ...commonConfig, 111 | entry: { 112 | preset: nodeEntry 113 | }, 114 | format: ['cjs'], 115 | target: NODE_TARGET, 116 | platform: 'node' 117 | }); 118 | } 119 | 120 | return configs; 121 | }); 122 | --------------------------------------------------------------------------------