diff --git a/docs/manifest.json b/docs/manifest.json index e4eba19d99fa29..09af61c56fe5b0 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1781,6 +1781,12 @@ "markdown_source": "../packages/keycodes/README.md", "parent": "packages" }, + { + "title": "@wordpress/language-chooser", + "slug": "packages-language-chooser", + "markdown_source": "../packages/language-chooser/README.md", + "parent": "packages" + }, { "title": "@wordpress/lazy-import", "slug": "packages-lazy-import", diff --git a/lib/client-assets.php b/lib/client-assets.php index 62e874d6b06c82..0f2d1d610905f2 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -477,6 +477,15 @@ function gutenberg_register_packages_styles( $styles ) { $version ); $styles->add_data( 'wp-preferences', 'rtl', 'replace' ); + + gutenberg_override_style( + $styles, + 'wp-language-chooser', + gutenberg_url( 'build/language-chooser/style.css' ), + array( 'wp-components' ), + $version + ); + $styles->add_data( 'wp-language-chooser', 'rtl', 'replace' ); } add_action( 'wp_default_styles', 'gutenberg_register_packages_styles' ); diff --git a/package-lock.json b/package-lock.json index 49d1ad6b123b76..66901c4674d11f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/keyboard-shortcuts": "file:packages/keyboard-shortcuts", "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/language-chooser": "file:packages/language-chooser", "@wordpress/list-reusable-blocks": "file:packages/list-reusable-blocks", "@wordpress/media-utils": "file:packages/media-utils", "@wordpress/notices": "file:packages/notices", @@ -17214,6 +17215,10 @@ "resolved": "packages/keycodes", "link": true }, + "node_modules/@wordpress/language-chooser": { + "resolved": "packages/language-chooser", + "link": true + }, "node_modules/@wordpress/lazy-import": { "resolved": "packages/lazy-import", "link": true @@ -54490,6 +54495,24 @@ "npm": ">=8.19.2" } }, + "packages/language-chooser": { + "name": "@wordpress/language-chooser", + "version": "1.0.0-prerelease", + "license": "GPL-2.0-or-later", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/a11y": "file:../a11y", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/keycodes": "file:../keycodes" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "packages/lazy-import": { "name": "@wordpress/lazy-import", "version": "2.7.0", @@ -69103,6 +69126,18 @@ "@wordpress/i18n": "file:../i18n" } }, + "@wordpress/language-chooser": { + "version": "file:packages/language-chooser", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/a11y": "file:../a11y", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/keycodes": "file:../keycodes" + } + }, "@wordpress/lazy-import": { "version": "file:packages/lazy-import", "requires": { diff --git a/package.json b/package.json index 46d2b42f267abb..34066de332836a 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/keyboard-shortcuts": "file:packages/keyboard-shortcuts", "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/language-chooser": "file:packages/language-chooser", "@wordpress/list-reusable-blocks": "file:packages/list-reusable-blocks", "@wordpress/media-utils": "file:packages/media-utils", "@wordpress/notices": "file:packages/notices", diff --git a/packages/language-chooser/.npmrc b/packages/language-chooser/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/language-chooser/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/language-chooser/CHANGELOG.md b/packages/language-chooser/CHANGELOG.md new file mode 100644 index 00000000000000..691cb6760320f4 --- /dev/null +++ b/packages/language-chooser/CHANGELOG.md @@ -0,0 +1,7 @@ + + +## Unreleased + +### New Features + +- Initial public release. diff --git a/packages/language-chooser/README.md b/packages/language-chooser/README.md new file mode 100644 index 00000000000000..1eb0f1312807a7 --- /dev/null +++ b/packages/language-chooser/README.md @@ -0,0 +1,23 @@ +# Language Chooser + +Package used for rendering a UI component for choosing preferred languages. + +> This package is meant to be used only with WordPress core. Feel free to use it in your own project but please keep in mind that it might never get fully documented. + +## Installation + +Install the module + +```bash +npm install @wordpress/language-chooser --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/language-chooser/package.json b/packages/language-chooser/package.json new file mode 100644 index 00000000000000..943ec031648386 --- /dev/null +++ b/packages/language-chooser/package.json @@ -0,0 +1,45 @@ +{ + "name": "@wordpress/language-chooser", + "version": "1.0.0-prerelease", + "description": "Component for choosing multiple preferred languages.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "templates", + "reusable blocks" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/language-chooser/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/language-chooser" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "sideEffects": [ + "build-style/**", + "src/**/*.scss" + ], + "types": "build-types", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/a11y": "file:../a11y", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/keycodes": "file:../keycodes" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/language-chooser/src/index.ts b/packages/language-chooser/src/index.ts new file mode 100644 index 00000000000000..4ea4e9059f5e40 --- /dev/null +++ b/packages/language-chooser/src/index.ts @@ -0,0 +1 @@ +export { default as LanguageChooser } from './language-chooser'; diff --git a/packages/language-chooser/src/language-chooser/active-controls.tsx b/packages/language-chooser/src/language-chooser/active-controls.tsx new file mode 100644 index 00000000000000..499777eb923453 --- /dev/null +++ b/packages/language-chooser/src/language-chooser/active-controls.tsx @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Button, ButtonGroup } from '@wordpress/components'; + +interface ActiveControlsProps { + onMoveUp: () => void; + onMoveDown: () => void; + onRemove: () => void; + isMoveUpDisabled: boolean; + isMoveDownDisabled: boolean; + isRemoveDisabled: boolean; +} +function ActiveControls( { + onMoveUp, + onMoveDown, + onRemove, + isMoveUpDisabled, + isMoveDownDisabled, + isRemoveDisabled, +}: ActiveControlsProps ) { + return ( + + + + + + ); +} + +export default ActiveControls; diff --git a/packages/language-chooser/src/language-chooser/active-locales.tsx b/packages/language-chooser/src/language-chooser/active-locales.tsx new file mode 100644 index 00000000000000..4ea75809f6f4cf --- /dev/null +++ b/packages/language-chooser/src/language-chooser/active-locales.tsx @@ -0,0 +1,123 @@ +/** + * WordPress dependencies + */ +import { useLayoutEffect, useRef } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { __experimentalText as Text } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import type { Language } from './types'; +import ActiveControls from './active-controls'; + +interface ActiveLocalesProps { + languages: Language[]; + activeLanguage?: Language; + showOptionSiteDefault?: boolean; + setActiveLanguage: ( language: Language ) => void; + onMoveUp: () => void; + onMoveDown: () => void; + onRemove: () => void; + isEmpty: boolean; + isMoveUpDisabled: boolean; + isMoveDownDisabled: boolean; + isRemoveDisabled: boolean; + labelId: string; +} + +export function ActiveLocales( { + languages, + showOptionSiteDefault = false, + activeLanguage, + setActiveLanguage, + onMoveUp, + onMoveDown, + onRemove, + isEmpty, + isMoveUpDisabled, + isMoveDownDisabled, + isRemoveDisabled, + labelId, +}: ActiveLocalesProps ) { + const listRef = useRef< HTMLUListElement | null >( null ); + + useLayoutEffect( () => { + const selectedEl = listRef.current?.querySelector( + '[aria-selected="true"]' + ); + + if ( ! selectedEl ) { + return; + } + + selectedEl.scrollIntoView( { + behavior: 'smooth', + block: 'nearest', + } ); + }, [ activeLanguage, languages ] ); + + const activeDescendant = isEmpty ? '' : activeLanguage?.locale; + + const className = isEmpty + ? 'language-chooser__active-locales-list language-chooser__active-locales-list--empty' + : 'language-chooser__active-locales-list'; + + let emptyMessage = sprintf( + /* translators: Used in language chooser, indicating fall back to the site's default language. %s: English (United States) */ + __( 'Falling back to %s.' ), + 'English (United States)' + ); + + if ( showOptionSiteDefault ) { + /* translators: Used in language chooser, indicating fall back to the site's default language. */ + emptyMessage = __( 'Falling back to Site Default.' ); + } + + return ( +
+ { isEmpty && ( +
+ { __( 'Nothing set.' ) } + { emptyMessage } +
+ ) } + + +
+ ); +} + +export default ActiveLocales; diff --git a/packages/language-chooser/src/language-chooser/inactive-controls.tsx b/packages/language-chooser/src/language-chooser/inactive-controls.tsx new file mode 100644 index 00000000000000..bc372d724c50be --- /dev/null +++ b/packages/language-chooser/src/language-chooser/inactive-controls.tsx @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { _x, sprintf } from '@wordpress/i18n'; +import { shortcutAriaLabel, displayShortcut } from '@wordpress/keycodes'; +import { Button } from '@wordpress/components'; + +interface InactiveControlsProps { + disabled: boolean; + onAdd: () => void; +} + +function InactiveControls( { disabled, onAdd }: InactiveControlsProps ) { + return ( +
+ +
+ ); +} + +export default InactiveControls; diff --git a/packages/language-chooser/src/language-chooser/inactive-locales-select.tsx b/packages/language-chooser/src/language-chooser/inactive-locales-select.tsx new file mode 100644 index 00000000000000..ea3de1e9062f01 --- /dev/null +++ b/packages/language-chooser/src/language-chooser/inactive-locales-select.tsx @@ -0,0 +1,72 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { SelectControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import type { Language, Locale } from './types'; + +interface InactiveLocalesSelectProps { + installedLanguages: Language[]; + availableLanguages: Language[]; + value: Locale; + onChange: ( value: string ) => void; +} + +function InactiveLocalesSelect( { + installedLanguages, + availableLanguages, + value, + onChange, +}: InactiveLocalesSelectProps ) { + const hasItems = installedLanguages.length || availableLanguages.length; + + return ( + + { installedLanguages.length > 0 && ( + + { installedLanguages.map( + ( { locale, lang, nativeName } ) => ( + + ) + ) } + + ) } + { availableLanguages.length > 0 && ( + + { availableLanguages.map( + ( { locale, lang, nativeName } ) => ( + + ) + ) } + + ) } + + ); +} + +export default InactiveLocalesSelect; diff --git a/packages/language-chooser/src/language-chooser/inactive-locales.tsx b/packages/language-chooser/src/language-chooser/inactive-locales.tsx new file mode 100644 index 00000000000000..58e6c09197cd78 --- /dev/null +++ b/packages/language-chooser/src/language-chooser/inactive-locales.tsx @@ -0,0 +1,48 @@ +/** + * Internal dependencies + */ +import type { Language } from './types'; +import InactiveControls from './inactive-controls'; +import InactiveLocalesSelect from './inactive-locales-select'; + +interface InactiveLocalesProps { + languages: Language[]; + onAdd: () => void; + inactiveLanguage: Language; + setInactiveLanguage: ( locale: Language ) => void; + installedLanguages: Language[]; + availableLanguages: Language[]; +} + +function InactiveLocales( { + languages, + onAdd, + inactiveLanguage, + setInactiveLanguage, + installedLanguages, + availableLanguages, +}: InactiveLocalesProps ) { + const onChange = ( locale: string ) => { + setInactiveLanguage( + languages.find( + ( language ) => locale === language.locale + ) as Language + ); + }; + + return ( +
+
+ +
+ +
+ ); +} + +export default InactiveLocales; diff --git a/packages/language-chooser/src/language-chooser/index.tsx b/packages/language-chooser/src/language-chooser/index.tsx new file mode 100644 index 00000000000000..c7c9fb78132bfe --- /dev/null +++ b/packages/language-chooser/src/language-chooser/index.tsx @@ -0,0 +1,307 @@ +/** + * External dependencies + */ +import type { KeyboardEvent } from 'react'; + +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { Notice, __experimentalText as Text } from '@wordpress/components'; +import { useInstanceId } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import ActiveLocales from './active-locales'; +import InactiveLocales from './inactive-locales'; +import type { Language } from './types'; +import { reorder } from './utils'; + +function MissingTranslationsNotice() { + return ( + + { __( + 'Some of the languages are not installed. Re-save changes to download translations.' + ) } + + ); +} + +interface LanguageChooserProps { + allLanguages: Language[]; + defaultSelectedLanguages?: Language[]; + selectedLanguages?: Language[]; + hasMissingTranslations?: boolean; + showOptionSiteDefault?: boolean; + onChange?: ( languages: Language[] ) => void; +} + +function LanguageChooser( props: LanguageChooserProps ) { + const { + allLanguages, + hasMissingTranslations = false, + showOptionSiteDefault = false, + } = props; + + const selectedLanguages = + props.selectedLanguages || props.defaultSelectedLanguages || []; + + const [ languages, _setLanguages ] = + useState< Language[] >( selectedLanguages ); + + function setLanguages( update: ( prev: Language[] ) => Language[] ) { + if ( props.selectedLanguages !== undefined ) { + const newValues = update( props.selectedLanguages ); + props.onChange?.( newValues ); + } else { + _setLanguages( ( prev ) => { + const newValues = update( prev ); + props.onChange?.( newValues ); + return newValues; + } ); + } + } + + const [ activeLanguage, setActiveLanguage ] = useState< Language >( + selectedLanguages[ 0 ] + ); + + const inactiveLocales = allLanguages.filter( + ( language ) => + ! languages.find( ( { locale } ) => locale === language.locale ) + ); + + const [ inactiveLanguage, setInactiveLanguage ] = useState( + inactiveLocales[ 0 ] + ); + + useEffect( () => { + if ( ! inactiveLanguage ) { + setInactiveLanguage( inactiveLocales[ 0 ] ); + } + }, [ inactiveLanguage, inactiveLocales ] ); + + const installedLanguages = inactiveLocales.filter( ( { installed } ) => + Boolean( installed ) + ); + + const availableLanguages = inactiveLocales.filter( + ( { installed } ) => ! installed + ); + + const onAddLanguage = ( locale: Language ) => { + setLanguages( ( current ) => [ ...current, locale ] ); + setActiveLanguage( locale ); + }; + + const isEmpty = languages.length === 0; + const isMoveUpDisabled = + ! activeLanguage || languages[ 0 ]?.locale === activeLanguage?.locale; + const isMoveDownDisabled = + ! activeLanguage || + languages[ languages.length - 1 ]?.locale === activeLanguage?.locale; + const isRemoveDisabled = ! activeLanguage; + + const activeLanguageIndex = languages.findIndex( + ( { locale } ) => locale === activeLanguage?.locale + ); + + const onAdd = () => { + onAddLanguage( inactiveLanguage ); + + const installedIndex = installedLanguages.findIndex( + ( { locale } ) => locale === inactiveLanguage.locale + ); + + const availableIndex = availableLanguages.findIndex( + ( { locale } ) => locale === inactiveLanguage.locale + ); + + let newSelected: Language | undefined; + + newSelected = installedLanguages[ installedIndex + 1 ]; + + if ( ! newSelected && installedLanguages[ 0 ] !== inactiveLanguage ) { + newSelected = installedLanguages[ 0 ]; + } + + if ( ! newSelected ) { + newSelected = availableLanguages[ availableIndex + 1 ]; + + if ( availableLanguages[ 0 ] !== inactiveLanguage ) { + newSelected = availableLanguages[ 0 ]; + } + } + + setInactiveLanguage( newSelected ); + + speak( __( 'Locale added to list' ) ); + }; + + const onRemove = () => { + setActiveLanguage( + languages[ activeLanguageIndex + 1 ] || + languages[ activeLanguageIndex - 1 ] + ); + + setLanguages( ( prevLanguages ) => + prevLanguages.filter( + ( { locale } ) => locale !== activeLanguage?.locale + ) + ); + _setLanguages( ( prevLanguages ) => + prevLanguages.filter( + ( { locale } ) => locale !== activeLanguage?.locale + ) + ); + + speak( __( 'Locale removed from list' ) ); + + if ( languages.length === 1 ) { + let emptyMessageA11y = sprintf( + /* translators: %s: English (United States) */ + __( 'No languages selected. Falling back to %s.' ), + 'English (United States)' + ); + + if ( showOptionSiteDefault ) { + emptyMessageA11y = __( + 'No languages selected. Falling back to Site Default.' + ); + } + + speak( emptyMessageA11y ); + } + }; + + const onMoveUp = () => { + setLanguages( ( prevLanguages ) => { + const srcIndex = prevLanguages.findIndex( + ( { locale } ) => locale === activeLanguage?.locale + ); + return reorder( + Array.from( prevLanguages ), + srcIndex, + srcIndex - 1 + ); + } ); + + speak( __( 'Locale moved up' ) ); + }; + + const onMoveDown = () => { + setLanguages( ( prevLanguages ) => { + const srcIndex = prevLanguages.findIndex( + ( { locale } ) => locale === activeLanguage?.locale + ); + return reorder< Language[] >( + Array.from( prevLanguages ), + srcIndex, + srcIndex + 1 + ); + } ); + + speak( __( 'Locale moved down' ) ); + }; + + const onKeyDown = ( event: KeyboardEvent< HTMLElement > ) => { + switch ( event.code ) { + // Move item up. + case 'ArrowUp': + if ( ! isMoveUpDisabled ) { + if ( event.altKey ) { + onMoveUp(); + } else { + setActiveLanguage( + languages[ activeLanguageIndex - 1 ] + ); + } + event.preventDefault(); + } + break; + // Move item down. + case 'ArrowDown': + if ( ! isMoveDownDisabled ) { + if ( event.altKey ) { + onMoveDown(); + } else { + setActiveLanguage( + languages[ activeLanguageIndex + 1 ] + ); + } + event.preventDefault(); + } + break; + // Select first item. + case 'Home': + if ( ! isEmpty ) { + setActiveLanguage( languages.at( 0 ) as Language ); + event.preventDefault(); + } + break; + // Select last item. + case 'End': + if ( ! isEmpty ) { + setActiveLanguage( languages.at( -1 ) as Language ); + event.preventDefault(); + } + break; + // Remove item. + case 'Backspace': + if ( ! isRemoveDisabled ) { + onRemove(); + event.preventDefault(); + } + break; + // Add item. + case 'KeyA': + if ( event.altKey && inactiveLanguage ) { + onAdd(); + event.preventDefault(); + } + break; + } + }; + + const instanceId = useInstanceId( LanguageChooser, 'language-chooser' ); + + return ( + // Legit use case as it's capturing events bubbling up from children who have shortcuts defined. + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ + { __( + 'Choose languages for displaying WordPress in, in order of preference.' + ) } + + + + { hasMissingTranslations && } +
+ ); +} + +export default LanguageChooser; diff --git a/packages/language-chooser/src/language-chooser/stories/index.story.tsx b/packages/language-chooser/src/language-chooser/stories/index.story.tsx new file mode 100644 index 00000000000000..77abd6bf782661 --- /dev/null +++ b/packages/language-chooser/src/language-chooser/stories/index.story.tsx @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import type { Meta, StoryObj } from '@storybook/react'; +import type { ComponentProps, KeyboardEvent } from 'react'; + +/** + * Internal dependencies + */ +import LanguageChooser from '../'; +import type { Language } from '../types'; + +function Component( props: ComponentProps< typeof LanguageChooser > ) { + const onKeyDown = ( evt: KeyboardEvent< HTMLElement > ) => { + evt.stopPropagation(); + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ +

+ Note: Typical storybook keyboard shortcuts are disabled for this + story because they clash with the ones used by the component. +

+
+ ); +} + +const meta: Meta< typeof LanguageChooser > = { + title: 'Language Chooser/Language Chooser', + component: Component, + parameters: { + argTypes: { + hasMissingTranslations: { + options: [ false, true ], + control: { type: 'radio' }, + }, + showOptionSiteDefault: { + options: [ false, true ], + control: { type: 'radio' }, + }, + }, + controls: { hideNoControlsWarning: true }, + }, +}; +export default meta; + +/* eslint-disable camelcase */ + +const de_DE: Language = { + locale: 'de_DE', + nativeName: 'Deutsch', + lang: 'de', + installed: true, +}; + +const en_US: Language = { + locale: 'en_US', + nativeName: 'English (US)', + lang: 'en', + installed: true, +}; + +const en_GB: Language = { + locale: 'en_GB', + nativeName: 'English (UK)', + lang: 'en', + installed: true, +}; + +const fr_FR: Language = { + locale: 'fr_FR', + nativeName: 'Français', + lang: 'fr', + installed: true, +}; + +const de_CH: Language = { + locale: 'de_CH', + nativeName: 'Deutsch (Schweiz)', + lang: 'de', + installed: false, +}; + +const it_IT: Language = { + locale: 'it_IT', + nativeName: 'Italiano', + lang: 'it', + installed: false, +}; + +export const Default: StoryObj< typeof LanguageChooser > = { + args: { + defaultSelectedLanguages: [ de_DE, fr_FR ], + allLanguages: [ de_DE, de_CH, it_IT, en_GB, fr_FR, en_US ], + }, +}; + +/* eslint-enable camelcase */ diff --git a/packages/language-chooser/src/language-chooser/test/language-chooser.tsx b/packages/language-chooser/src/language-chooser/test/language-chooser.tsx new file mode 100644 index 00000000000000..ab776a6bc8428d --- /dev/null +++ b/packages/language-chooser/src/language-chooser/test/language-chooser.tsx @@ -0,0 +1,584 @@ +/** + * External dependencies + */ +import { + fireEvent, + render, + screen, + waitFor, + queryByRole, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; + +/** + * Internal dependencies + */ +import type { Language } from '../types'; +import LanguageChooser from '../'; + +jest.mock( '@wordpress/a11y', () => ( { + speak: jest.fn(), +} ) ); + +/* eslint-disable camelcase */ + +const de_DE: Language = { + locale: 'de_DE', + nativeName: 'Deutsch', + lang: 'de', + installed: true, +}; + +const en_GB: Language = { + locale: 'en_GB', + nativeName: 'English (UK)', + lang: 'en', + installed: true, +}; + +const fr_FR: Language = { + locale: 'fr_FR', + nativeName: 'Français', + lang: 'fr', + installed: true, +}; + +const es_ES: Language = { + locale: 'es_ES', + nativeName: 'Español', + lang: 'es', + installed: false, +}; + +const it_IT: Language = { + locale: 'it_IT', + nativeName: 'Italiano', + lang: 'it', + installed: false, +}; + +const scrollIntoView = jest.fn(); +window.HTMLElement.prototype.scrollIntoView = scrollIntoView; + +/** + * Workaround to trigger keyboard events. + * + * @see https://github.com/WordPress/gutenberg/issues/45777 + */ + +function selectPrevious() { + fireEvent.keyDown( screen.getByRole( 'listbox' ), { + key: 'ArrowUp', + code: 'ArrowUp', + } ); +} + +function selectNext() { + fireEvent.keyDown( screen.getByRole( 'listbox' ), { + key: 'ArrowDown', + code: 'ArrowDown', + } ); +} + +function moveUp() { + fireEvent.keyDown( screen.getByRole( 'listbox' ), { + key: 'ArrowUp', + code: 'ArrowUp', + altKey: true, + } ); +} + +function moveDown() { + fireEvent.keyDown( screen.getByRole( 'listbox' ), { + key: 'ArrowDown', + code: 'ArrowDown', + altKey: true, + } ); +} + +function selectFirst() { + fireEvent.keyDown( screen.getByRole( 'listbox' ), { + key: 'Home', + code: 'Home', + } ); +} + +function selectLast() { + fireEvent.keyDown( screen.getByRole( 'listbox' ), { + key: 'End', + code: 'End', + } ); +} + +function removeLocale() { + fireEvent.keyDown( screen.getByRole( 'listbox' ), { + key: 'Backspace', + code: 'Backspace', + } ); +} + +function addLocale() { + fireEvent.keyDown( screen.getByRole( 'listbox' ), { + key: 'a', + code: 'KeyA', + altKey: true, + } ); +} + +describe( 'LanguageChooser', () => { + afterEach( () => { + jest.resetAllMocks(); + } ); + + it( 'shows missing translations notice', () => { + render( + + ); + + // Found multiple times due to aria-live. + expect( + screen.getAllByText( /Some of the languages are not installed/ ) + ).not.toHaveLength( 0 ); + } ); + + it( 'adds language to list', async () => { + render( + + ); + + const dropdown = screen.getByRole( 'combobox' ); + expect( dropdown ).toBeEnabled(); + expect( dropdown ).toHaveValue( 'en_GB' ); + + const add = screen.getByRole( 'button', { name: /Add/ } ); + await userEvent.click( add ); + + await waitFor( () => { + expect( + screen.getByRole( 'option', { name: /English \(UK\)/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + } ); + + expect( speak ).toHaveBeenCalledWith( 'Locale added to list' ); + + await userEvent.click( add ); + + await waitFor( () => { + expect( + screen.getByRole( 'option', { name: /Español/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + } ); + + expect( speak ).toHaveBeenCalledWith( 'Locale added to list' ); + } ); + + it( 're-populates selected locale when empty dropdown is filled again', async () => { + render( + + ); + + const add = screen.getByRole( 'button', { name: /Add/ } ); + const remove = screen.getByRole( 'button', { name: /Remove/ } ); + const dropdown = screen.getByRole( 'combobox' ); + + expect( add ).toBeEnabled(); + expect( dropdown ).toBeEnabled(); + expect( dropdown ).toHaveValue( 'es_ES' ); + + await userEvent.click( add ); + + await waitFor( () => { + expect( + screen.getByRole( 'option', { name: /Español/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + } ); + + expect( dropdown ).not.toHaveValue(); + expect( dropdown ).toBeDisabled(); + expect( screen.getByRole( 'button', { name: /Add/ } ) ).toHaveAttribute( + 'aria-disabled', + 'true' + ); + + await userEvent.click( remove ); + + await waitFor( () => { + expect( dropdown ).toHaveValue( 'es_ES' ); + } ); + expect( dropdown ).toBeEnabled(); + expect( screen.getByRole( 'button', { name: /Add/ } ) ).toBeEnabled(); + } ); + + it( 'supports keyboard shortcuts', async () => { + render( + + ); + + const listbox = screen.getByRole( 'listbox' ); + const dropdown = screen.getByRole( 'combobox' ); + + expect( + screen.getByRole( 'option', { name: /Deutsch/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + + expect( + screen.getByRole( 'button', { name: /Move up/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( + screen.getByRole( 'button', { name: /Move down/ } ) + ).toBeEnabled(); + + listbox.focus(); + + // Move de_DE all the way to the bottom, after fr_FR and it_IT. + + moveDown(); + moveDown(); + + expect( + screen.getByRole( 'button', { name: /Move up/ } ) + ).toBeEnabled(); + expect( + screen.getByRole( 'button', { name: /Move down/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + selectFirst(); + + expect( + screen.getByRole( 'option', { name: /Français/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + + selectFirst(); + + expect( + screen.getByRole( 'option', { name: /Français/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + + expect( + screen.getByRole( 'button', { name: /Move up/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + selectLast(); + + expect( + screen.getByRole( 'option', { name: /Deutsch/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + + selectLast(); + + expect( + screen.getByRole( 'option', { name: /Deutsch/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + + expect( + screen.getByRole( 'button', { name: /Move down/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + // Move de_DE to top again. + + moveUp(); + moveUp(); + + selectNext(); + + expect( + screen.getByRole( 'option', { name: /Français/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + + selectPrevious(); + + expect( + screen.getByRole( 'option', { name: /Deutsch/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + + expect( + screen.getByRole( 'button', { name: /Move down/ } ) + ).toBeEnabled(); + + removeLocale(); + + expect( + // We want to explicitly check if it's within the container. + // eslint-disable-next-line testing-library/prefer-screen-queries + queryByRole( listbox, 'option', { name: /Deutsch/ } ) + ).not.toBeInTheDocument(); + expect( + screen.getByRole( 'option', { name: /Deutsch/ } ) + ).toBeInTheDocument(); + + removeLocale(); + removeLocale(); + + // Now list is empty, none of the following shortcuts will do anything. + + expect( + screen.getByRole( 'button', { name: /Remove/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + removeLocale(); + + expect( + screen.getByRole( 'button', { name: /Remove/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + selectFirst(); + + selectLast(); + + expect( + screen.getByRole( 'button', { name: /Remove/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + // Add en_GB to the list. + + fireEvent.change( dropdown, { target: { value: 'en_GB' } } ); + expect( dropdown ).toHaveValue( 'en_GB' ); + + addLocale(); + + expect( + screen.getByRole( 'option', { name: /English/ } ) + ).toBeInTheDocument(); + + expect( dropdown ).toHaveValue( 'fr_FR' ); + + addLocale(); + + expect( dropdown ).toHaveValue( 'de_DE' ); + + addLocale(); + + expect( dropdown ).toHaveValue( 'es_ES' ); + + addLocale(); + + expect( dropdown ).toHaveValue( 'it_IT' ); + + addLocale(); + + expect( dropdown ).toBeDisabled(); + + addLocale(); + + expect( dropdown ).toBeDisabled(); + } ); + + it( 'announces site default fallback message if list is empty', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole( 'button', { name: /Remove/ } ) + ); + + expect( speak ).toHaveBeenNthCalledWith( + 1, + 'Locale removed from list' + ); + expect( speak ).toHaveBeenNthCalledWith( + 2, + expect.stringMatching( /Falling back to Site Default/ ) + ); + } ); + + it( 'announces locale moving up and down', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole( 'button', { name: /Move down/ } ) + ); + + expect( speak ).toHaveBeenCalledWith( 'Locale moved down' ); + + await userEvent.click( + screen.getByRole( 'button', { name: /Move up/ } ) + ); + + expect( speak ).toHaveBeenCalledWith( 'Locale moved up' ); + } ); + + it( 'prevents selection if list is empty', () => { + render( + + ); + + expect( screen.getByRole( 'button', { name: /Add/ } ) ).toHaveAttribute( + 'aria-disabled', + 'true' + ); + expect( screen.getByRole( 'combobox' ) ).toBeDisabled(); + } ); + + it( 'displays fallback message if list is empty', () => { + render( + + ); + + expect( + screen.getByText( /Falling back to English/ ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: /Move up/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( + screen.getByRole( 'button', { name: /Move down/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( + screen.getByRole( 'button', { name: /Remove/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + } ); + + it( 'displays site default fallback message if list is empty', () => { + render( + + ); + + expect( + screen.getByText( /Falling back to Site Default/ ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: /Move up/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( + screen.getByRole( 'button', { name: /Move down/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( + screen.getByRole( 'button', { name: /Remove/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + } ); + + it( 'prevents moving a single item', () => { + render( + + ); + expect( screen.queryByText( /Falling back/ ) ).not.toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: /Move up/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( + screen.getByRole( 'button', { name: /Move down/ } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( + screen.getByRole( 'button', { name: /Remove/ } ) + ).toBeEnabled(); + } ); + + it( 'selects next locale when removing one', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole( 'button', { name: /Remove/ } ) + ); + + await waitFor( () => { + expect( + screen.getByRole( 'option', { name: /English \(UK\)/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + } ); + + expect( speak ).toHaveBeenCalledWith( 'Locale removed from list' ); + } ); + + it( 'selects previous locale when removing one', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole( 'button', { name: /Move down/ } ) + ); + + await userEvent.click( + screen.getByRole( 'button', { name: /Remove/ } ) + ); + + await waitFor( () => { + expect( + screen.getByRole( 'option', { name: /English \(UK\)/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + } ); + } ); + + it( 'changes selection when clicking on locale', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole( 'option', { name: /Français/ } ) + ); + + expect( + screen.getByRole( 'option', { name: /Français/ } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + } ); + + it( 'scrolls to newly selected locale', async () => { + render( + + ); + + await userEvent.click( + screen.getByRole( 'option', { name: /Français/ } ) + ); + + expect( scrollIntoView ).toHaveBeenCalled(); + } ); +} ); + +/* eslint-enable camelcase */ diff --git a/packages/language-chooser/src/language-chooser/test/reorder.ts b/packages/language-chooser/src/language-chooser/test/reorder.ts new file mode 100644 index 00000000000000..c8bfb91bc64894 --- /dev/null +++ b/packages/language-chooser/src/language-chooser/test/reorder.ts @@ -0,0 +1,15 @@ +/** + * Internal dependencies + */ +import { reorder } from '../utils'; + +describe( 'reorder', () => { + it.each( [ [ [ 1, 2, 3, 4 ], 0, 3, [ 2, 3, 4, 1 ] ] ] )( + 'reorders array', + ( list, srcIndex, destIndex, result ) => { + expect( reorder( list, srcIndex, destIndex ) ).toStrictEqual( + result + ); + } + ); +} ); diff --git a/packages/language-chooser/src/language-chooser/types.ts b/packages/language-chooser/src/language-chooser/types.ts new file mode 100644 index 00000000000000..6614d9f3f07fda --- /dev/null +++ b/packages/language-chooser/src/language-chooser/types.ts @@ -0,0 +1,8 @@ +export type Locale = string; + +export interface Language { + locale: Locale; + nativeName: string; + lang: string; + installed: boolean; +} diff --git a/packages/language-chooser/src/language-chooser/utils.ts b/packages/language-chooser/src/language-chooser/utils.ts new file mode 100644 index 00000000000000..0481ea2483f705 --- /dev/null +++ b/packages/language-chooser/src/language-chooser/utils.ts @@ -0,0 +1,9 @@ +export function reorder< T extends Array< unknown > >( + list: T, + srcIndex: number, + destIndex: number +) { + const item = list.splice( srcIndex, 1 )[ 0 ]; + list.splice( destIndex, 0, item ); + return list; +} diff --git a/packages/language-chooser/src/style.scss b/packages/language-chooser/src/style.scss new file mode 100644 index 00000000000000..740c14d20e6595 --- /dev/null +++ b/packages/language-chooser/src/style.scss @@ -0,0 +1,143 @@ +/** + * Colors + */ + +// WordPress grays. +$black: #000; // Use only when you truly need pure black. For UI, use $gray-900. +$gray-900: #1e1e1e; +$gray-800: #2f2f2f; +$gray-700: #757575; // Meets 4.6:1 text contrast against white. +$gray-600: #949494; // Meets 3:1 UI or large text contrast against white. +$gray-400: #ccc; +$gray-300: #ddd; // Used for most borders. +$gray-200: #e0e0e0; // Used sparingly for light borders. +$gray-100: #f0f0f0; // Used for light gray backgrounds. +$white: #fff; + +.language-chooser__active-locales { + margin: 1em 0; + + &::after { + content: ""; + display: table; + clear: both; + } +} + +.language-chooser__active-locales-list, +.language-chooser__inactive-locales-list { + width: 25em; + clear: both; + float: left; +} + +.language-chooser__active-locales-list { + height: 130px; + overflow-y: scroll; + list-style: none; + margin: 0 1em 1em 0; + padding: 0; + background: #fff; + box-shadow: inset 0 1px 2px rgba($black, 0.07); + border: 1px solid $gray-300; + box-sizing: border-box; +} + +@media screen and (min-width: 510px) { + .language-chooser__active-locales-list { + margin-bottom: 0; + } +} + +.language-chooser__inactive-locales-list { + margin: 0 1em 0 0; +} + +.language-chooser__active-locales-list--empty { + background: rgba($white, 0.5); + border-color: rgba($gray-300, 0.75); + box-shadow: inset 0 1px 2px rgba($black, 0.04); + display: flex; + align-items: center; +} + +.language-chooser__active-locales-list li { + box-sizing: border-box; + width: 100%; + height: 40px; + line-height: 40px; + margin: 0; + padding: 0 0 0 8px; + cursor: pointer; + font-size: 13px; +} + +.language-chooser .components-button-group { + clear: both; +} + +/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ +@media screen and (min-width: 510px) { + .language-chooser .components-button-group { + clear: none; + } +} + +.language-chooser .components-button-group .components-button + .components-button { + margin: 0 0 0 5px; +} + +/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ +@media screen and (min-width: 510px) { + .language-chooser .components-button-group .components-button { + display: block; + } + + .language-chooser .components-button-group .components-button + .components-button { + margin: 5px 0 0; + } +} + +.language-chooser__active-locales-list li:hover { + background: $gray-100; +} + +.language-chooser__active-locales-list li[aria-selected="true"] { + background: $gray-300; +} + +.language-chooser__active-locales-empty-message { + display: flex; + justify-content: center; + flex-direction: column; + position: relative; + text-align: center; + width: 25em; + height: 130px; + margin-bottom: -130px; + box-sizing: border-box; + color: $gray-600; +} + +.language-chooser__active-locales-empty-message.hidden { + display: none; +} + +.language-chooser__inactive-locales { + clear: both; + + &::after { + content: ""; + display: table; + clear: both; + } +} + +.language-chooser__inactive-locales select { + width: 100%; +} + +.language-chooser .components-notice { + display: inline-block; + margin-top: 1em; +} diff --git a/packages/language-chooser/tsconfig.json b/packages/language-chooser/tsconfig.json new file mode 100644 index 00000000000000..051913a3043d32 --- /dev/null +++ b/packages/language-chooser/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types" + }, + "references": [ + { "path": "../a11y" }, + { "path": "../components" }, + { "path": "../compose" }, + { "path": "../element" }, + { "path": "../i18n" }, + { "path": "../keycodes" } + ], + "include": [ "src/**/*" ] +} diff --git a/storybook/main.js b/storybook/main.js index 66e951b26b1de6..fa6d1a8bf9c6d0 100644 --- a/storybook/main.js +++ b/storybook/main.js @@ -34,6 +34,7 @@ const stories = [ '../packages/icons/src/**/stories/*.story.@(js|tsx|mdx)', '../packages/edit-site/src/**/stories/*.story.@(js|tsx|mdx)', '../packages/dataviews/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/language-chooser/src/**/stories/*.story.@(js|tsx|mdx)', ].filter( Boolean ); module.exports = { diff --git a/storybook/package-styles/config.js b/storybook/package-styles/config.js index 21215fcad5c21e..231c4dcfd5c669 100644 --- a/storybook/package-styles/config.js +++ b/storybook/package-styles/config.js @@ -13,6 +13,8 @@ import editSiteLtr from '../package-styles/edit-site-ltr.lazy.scss'; import editSiteRtl from '../package-styles/edit-site-rtl.lazy.scss'; import dataviewsLtr from '../package-styles/dataviews-ltr.lazy.scss'; import dataviewsRtl from '../package-styles/dataviews-rtl.lazy.scss'; +import languageChooserLtr from '../package-styles/language-chooser-ltr.lazy.scss'; +import languageChooserRtl from '../package-styles/language-chooser-rtl.lazy.scss'; /** * Stylesheets to lazy load when the story's context.componentId matches the @@ -58,6 +60,11 @@ const CONFIG = [ ltr: [ dataviewsLtr, componentsLtr ], rtl: [ dataviewsRtl, componentsRtl ], }, + { + componentIdMatcher: /^language-chooser/, + ltr: [ languageChooserLtr, componentsLtr ], + rtl: [ languageChooserRtl, componentsRtl ], + }, ]; export default CONFIG; diff --git a/storybook/package-styles/language-chooser-ltr.lazy.scss b/storybook/package-styles/language-chooser-ltr.lazy.scss new file mode 100644 index 00000000000000..60ba809ad75b78 --- /dev/null +++ b/storybook/package-styles/language-chooser-ltr.lazy.scss @@ -0,0 +1 @@ +@import "../../packages/language-chooser/build-style/style"; diff --git a/storybook/package-styles/language-chooser-rtl.lazy.scss b/storybook/package-styles/language-chooser-rtl.lazy.scss new file mode 100644 index 00000000000000..256fd0b74ef9b6 --- /dev/null +++ b/storybook/package-styles/language-chooser-rtl.lazy.scss @@ -0,0 +1 @@ +@import "../../packages/language-chooser/build-style/style-rtl"; diff --git a/tsconfig.json b/tsconfig.json index 3ab54f66019bca..ef4005e970aabd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,6 +35,7 @@ { "path": "packages/interactivity-router" }, { "path": "packages/is-shallow-equal" }, { "path": "packages/keycodes" }, + { "path": "packages/language-chooser" }, { "path": "packages/lazy-import" }, { "path": "packages/media-utils" }, { "path": "packages/notices" },