diff --git a/components/Common/Dropdown/__tests__/index.test.tsx b/components/Common/Dropdown/__tests__/index.test.tsx new file mode 100644 index 0000000000000..09d973df70b93 --- /dev/null +++ b/components/Common/Dropdown/__tests__/index.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import Dropdown from '..'; +import type { DropdownItem } from '../../../../types'; + +describe('Dropdown component', () => { + const items: DropdownItem[] = [ + { label: 'item1', title: 'Item 1', active: false, onClick: jest.fn() }, + { label: 'item2', title: 'Item 2', active: true, onClick: jest.fn() }, + { label: 'item3', title: 'Item 3', active: false, onClick: jest.fn() }, + ]; + + it('should render the items and apply active styles', () => { + render(); + + items.forEach(item => { + const button = screen.getByText(item.title); + expect(button).toBeInTheDocument(); + + if (item.active) { + expect(button).toHaveStyle('font-weight: bold'); + } else { + expect(button).not.toHaveStyle('font-weight: bold'); + } + }); + }); + + it('should call the onClick function when an item is clicked', () => { + render(); + const button = screen.getByText(items[2].title); + fireEvent.click(button); + expect(items[2].onClick).toHaveBeenCalledTimes(1); + }); + + it('should call the onClick function when Enter or Space is pressed', () => { + render(); + const button = screen.getByText(items[1].title); + fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' }); + fireEvent.keyDown(button, { key: ' ', code: 'Space' }); + expect(items[1].onClick).toHaveBeenCalledTimes(2); + }); + + it('should not render the items when shouldShow prop is false', () => { + render(); + items.forEach(item => { + const button = screen.queryByText(item.title); + expect(button).not.toBeVisible(); + }); + }); + + it('should apply styles passed in the styles prop', () => { + const customStyles: React.CSSProperties = { + backgroundColor: 'green', + padding: '10px', + borderRadius: '5px', + }; + render(); + + const dropdownList = screen.getByRole('list'); + expect(dropdownList).toHaveStyle('background-color: green'); + expect(dropdownList).toHaveStyle('padding: 10px'); + expect(dropdownList).toHaveStyle('border-radius: 5px'); + }); +}); diff --git a/components/Common/Dropdown/index.module.scss b/components/Common/Dropdown/index.module.scss new file mode 100644 index 0000000000000..376988645b40b --- /dev/null +++ b/components/Common/Dropdown/index.module.scss @@ -0,0 +1,30 @@ +.dropdownList { + background-color: var(--color-dropdown-background); + border-radius: 5px; + height: fit-content; + list-style-type: none; + max-height: 200px; + min-width: 150px; + overflow-y: auto; + padding: 0; + position: absolute; + width: fit-content; + + > li { + > button { + background: none; + border: none; + + color: var(--color-text-primary); + cursor: pointer; + font-size: var(--font-size-body1); + padding: var(--space-12); + text-align: center; + width: 100%; + + &:hover { + background-color: var(--color-dropdown-hover); + } + } + } +} diff --git a/components/Common/Dropdown/index.stories.tsx b/components/Common/Dropdown/index.stories.tsx new file mode 100644 index 0000000000000..53fe47251ae32 --- /dev/null +++ b/components/Common/Dropdown/index.stories.tsx @@ -0,0 +1,24 @@ +import type { StoryObj } from '@storybook/react'; +import Dropdown from '.'; + +type Story = StoryObj; + +export default { + component: Dropdown, +}; + +const items = [...Array(10).keys()].map(item => ({ + title: `Item ${item + 1}`, + label: `item-${item + 1}`, + active: false, + onClick: () => {}, +})); + +items[2].active = true; + +export const withItems: Story = { + args: { + items: items, + shouldShow: true, + }, +}; diff --git a/components/Common/Dropdown/index.tsx b/components/Common/Dropdown/index.tsx new file mode 100644 index 0000000000000..0706c353d7f85 --- /dev/null +++ b/components/Common/Dropdown/index.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import type { DropdownItem } from '../../../types'; +import styles from './index.module.scss'; + +export interface DropdownProps { + items: Array; + shouldShow: boolean; + styles: Object; +} + +const Dropdown = ({ items, shouldShow, styles: css }: DropdownProps) => { + const mappedElements = items.map(item => { + const extraStyles = { fontWeight: item.active ? 'bold' : 'normal' }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + item.onClick(); + } + }; + + return ( +
  • + +
  • + ); + }); + + const dropdownStyles = { display: shouldShow ? 'block' : 'none', ...css }; + + return ( +
      + {mappedElements} +
    + ); +}; + +export default Dropdown; diff --git a/components/Common/LanguageSelector/__tests__/index.test.tsx b/components/Common/LanguageSelector/__tests__/index.test.tsx new file mode 100644 index 0000000000000..28901cb0a7b32 --- /dev/null +++ b/components/Common/LanguageSelector/__tests__/index.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import LanguageSelector from '..'; + +jest.mock('../../../../hooks/useLocale', () => ({ + useLocale: () => ({ + availableLocales: [ + { code: 'en', name: 'English', localName: 'English' }, + { code: 'es', name: 'Spanish', localName: 'EspaƱol' }, + ], + currentLocale: { code: 'en', name: 'English', localName: 'English' }, + }), +})); + +describe('LanguageSelector', () => { + test('clicking the language switch button toggles the dropdown display', () => { + render(); + const button = screen.getByRole('button'); + expect(screen.queryByText('English')).not.toBeVisible(); + fireEvent.click(button); + expect(screen.queryByText('English')).toBeVisible(); + fireEvent.click(button); + expect(screen.queryByText('English')).not.toBeVisible(); + }); + + test('renders the Dropdown component with correct style', () => { + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + const dropdown = screen.getByRole('list'); + expect(dropdown).toHaveStyle( + 'position: absolute; top: 60%; right: 0; margin: 0;' + ); + }); +}); diff --git a/components/Common/LanguageSelector/index.module.scss b/components/Common/LanguageSelector/index.module.scss new file mode 100644 index 0000000000000..0db848421aad3 --- /dev/null +++ b/components/Common/LanguageSelector/index.module.scss @@ -0,0 +1,13 @@ +.languageSwitch { + background: none; + border: none; + color: var(--color-text-accent); + cursor: pointer; + line-height: 0; + padding: 0; +} + +.container { + display: inline; + position: relative; +} diff --git a/components/Common/LanguageSelector/index.stories.tsx b/components/Common/LanguageSelector/index.stories.tsx new file mode 100644 index 0000000000000..305b2209712b9 --- /dev/null +++ b/components/Common/LanguageSelector/index.stories.tsx @@ -0,0 +1,14 @@ +import LanguageSelector from '.'; +export default { component: LanguageSelector }; + +export const Default = () => ( +
    + +
    +); diff --git a/components/Common/LanguageSelector/index.tsx b/components/Common/LanguageSelector/index.tsx new file mode 100644 index 0000000000000..5a0493bad515e --- /dev/null +++ b/components/Common/LanguageSelector/index.tsx @@ -0,0 +1,52 @@ +import { useMemo, useState } from 'react'; +import { MdOutlineTranslate } from 'react-icons/md'; +import { useLocale } from '../../../hooks/useLocale'; +import Dropdown from '../Dropdown'; +import styles from './index.module.scss'; + +const dropdownStyle = { + position: 'absolute', + top: '60%', + right: '0', + margin: 0, +}; + +const LanguageSelector = () => { + const [showDropdown, setShowDropdown] = useState(false); + + const { availableLocales, currentLocale } = useLocale(); + + const dropdownItems = useMemo( + () => + availableLocales.map(locale => ({ + title: locale.localName, + label: locale.name, + onClick: () => { + // TODO: "locale changing logic yet to be implemented" + }, + active: currentLocale.code === locale.code, + })), + [availableLocales, currentLocale] + ); + + return ( +
    + + +
    + ); +}; + +export default LanguageSelector; diff --git a/package-lock.json b/package-lock.json index 96276218db863..49e84a61eba9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "npm-run-all": "^4.1.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^4.8.0", "react-intl": "^6.3.2", "remark-gfm": "^3.0.1", "sass": "^1.60.0", @@ -20313,6 +20314,14 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, + "node_modules/react-icons": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.8.0.tgz", + "integrity": "sha512-N6+kOLcihDiAnj5Czu637waJqSnwlMNROzVZMhfX68V/9bu9qHaMIJC4UdozWoOk57gahFCNHwVvWzm0MTzRjg==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-inspector": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.1.tgz", diff --git a/package.json b/package.json index 85ae5ec5aa8a6..562cd7f261019 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "npm-run-all": "^4.1.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^4.8.0", "react-intl": "^6.3.2", "remark-gfm": "^3.0.1", "sass": "^1.60.0", diff --git a/types/dropdown.ts b/types/dropdown.ts new file mode 100644 index 0000000000000..52bc847a4a9de --- /dev/null +++ b/types/dropdown.ts @@ -0,0 +1,6 @@ +export interface DropdownItem { + title: string; + label: string; + onClick: () => void; + active?: boolean; +} diff --git a/types/index.ts b/types/index.ts index 885a99dddac52..517723f71abd6 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,18 +1,19 @@ // @TODO: These types will be splitted on individual files for better organisation in the future import type { AppProps as DefaultAppProps } from 'next/app'; +import type { BlogData } from './blog'; import type { LocaleContext } from './i18n'; import type { NodeVersionData } from './nodeVersions'; -import type { BlogData } from './blog'; +export * from './blog'; export * from './config'; -export * from './frontmatter'; +export * from './dropdown'; export * from './features'; +export * from './frontmatter'; +export * from './i18n'; export * from './layouts'; export * from './navigation'; export * from './nodeVersions'; -export * from './blog'; -export * from './i18n'; export interface AppProps { i18nData: Pick;