diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c413a556..c009dff3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -39,6 +39,8 @@ body: label: Installation Method options: - npm (global) + - Homebrew + - MacPorts - GitHub Release binary - Built from source validations: diff --git a/pages/src/assets/icons/macports.svg b/pages/src/assets/icons/macports.svg new file mode 100644 index 00000000..d2f83920 --- /dev/null +++ b/pages/src/assets/icons/macports.svg @@ -0,0 +1 @@ +MacPorts \ No newline at end of file diff --git a/pages/src/components/HeroSection.test.tsx b/pages/src/components/HeroSection.test.tsx new file mode 100644 index 00000000..7a15ee78 --- /dev/null +++ b/pages/src/components/HeroSection.test.tsx @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 alibaba/open-code-review Contributors + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { LanguageProvider } from '../i18n'; +import HeroSection from './HeroSection'; + +function renderHero() { + render( + + + + + , + ); +} + +// The panel is found through the id that `aria-controls` already points at, so +// the test leans on the accessibility wiring instead of a test-only hook. +const panel = () => document.getElementById('install-more-panel'); +const trigger = () => screen.getByRole('button', { name: /More|MacPorts/i }); + +describe('HeroSection install channels', () => { + it('starts on the first channel with the panel closed', () => { + renderHero(); + expect(screen.getByText('npm i -g @alibaba-group/open-code-review')).toBeTruthy(); + expect(panel()).toBeNull(); + }); + + it('picking an overflow channel swaps the command and closes the panel', async () => { + const user = userEvent.setup(); + renderHero(); + + await user.click(trigger()); + expect(panel()).not.toBeNull(); + + await user.click(screen.getByRole('button', { name: /MacPorts/i })); + + expect(screen.getByText('sudo port install open-code-review')).toBeTruthy(); + expect(panel()).toBeNull(); + }); + + it('closes when a primary tab is clicked', async () => { + const user = userEvent.setup(); + renderHero(); + + await user.click(trigger()); + await user.click(screen.getByRole('button', { name: /Homebrew/i })); + + expect(screen.getByText('brew install open-code-review')).toBeTruthy(); + expect(panel()).toBeNull(); + }); + + // Keyboard activation dispatches `click` with no preceding `mousedown`, so + // this does not go through the same path as the test above. + it('closes when a primary tab is activated by keyboard', async () => { + const user = userEvent.setup(); + renderHero(); + + await user.click(trigger()); + screen.getByRole('button', { name: /Homebrew/i }).focus(); + await user.keyboard('{Enter}'); + + expect(screen.getByText('brew install open-code-review')).toBeTruthy(); + expect(panel()).toBeNull(); + }); + + it('closes on Escape and on an outside click', async () => { + const user = userEvent.setup(); + renderHero(); + + await user.click(trigger()); + await user.keyboard('{Escape}'); + expect(panel()).toBeNull(); + + await user.click(trigger()); + await user.click(document.body); + expect(panel()).toBeNull(); + }); + + it('reflects the selected overflow channel on the trigger', async () => { + const user = userEvent.setup(); + renderHero(); + expect(screen.getByRole('button', { name: /^More$/i })).toBeTruthy(); + + await user.click(trigger()); + await user.click(screen.getByRole('button', { name: /MacPorts/i })); + + const collapsed = screen.getByRole('button', { name: /MacPorts/i }); + expect(collapsed.getAttribute('aria-expanded')).toBe('false'); + expect(screen.queryByRole('button', { name: /^More$/i })).toBeNull(); + }); +}); diff --git a/pages/src/components/HeroSection.tsx b/pages/src/components/HeroSection.tsx index c3b96962..fe7fae59 100644 --- a/pages/src/components/HeroSection.tsx +++ b/pages/src/components/HeroSection.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 alibaba/open-code-review Contributors -import React, { Suspense, useCallback, useState, useEffect } from 'react'; +import React, { Suspense, useCallback, useState, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import { Link } from 'react-router-dom'; import { useTranslation } from '../i18n'; @@ -9,7 +9,9 @@ import { useResponsive } from '../hooks/useResponsive'; import ErrorBoundary from './ErrorBoundary'; import npmIcon from '../assets/icons/npm.svg'; import brewIcon from '../assets/icons/brew.svg'; +import macportsIcon from '../assets/icons/macports.svg'; import copyIcon from '../assets/icons/icon-copy.svg'; +import chevronDownIcon from '../assets/icons/icon-chevron-down.svg'; const ColorBends = React.lazy(() => import(/* webpackChunkName: "color-bends" */ './ColorBends')); @@ -122,11 +124,23 @@ const terminalLines = [ { num: 12, content: }, // allow-non-english: fullwidth bar renders the terminal cursor ]; -const INSTALL_CHANNELS = [ - { key: 'npm', labelKey: 'hero.installNpm', cmd: 'npm i -g @alibaba-group/open-code-review', icons: [npmIcon] }, - { key: 'brew', labelKey: 'hero.installBrew', cmd: 'brew install open-code-review', icons: [brewIcon] }, +interface InstallChannel { + key: string; + labelKey: string; + cmd: string; + icons: string[]; + primary: boolean; +} + +const INSTALL_CHANNELS: InstallChannel[] = [ + { key: 'npm', labelKey: 'hero.installNpm', cmd: 'npm i -g @alibaba-group/open-code-review', icons: [npmIcon], primary: true }, + { key: 'brew', labelKey: 'hero.installBrew', cmd: 'brew install open-code-review', icons: [brewIcon], primary: true }, + { key: 'macports', labelKey: 'hero.installMacPorts', cmd: 'sudo port install open-code-review', icons: [macportsIcon], primary: false }, ]; +const PRIMARY_CHANNELS = INSTALL_CHANNELS.filter((ch) => ch.primary); +const SECONDARY_CHANNELS = INSTALL_CHANNELS.filter((ch) => !ch.primary); + const HeroSection: React.FC = () => { const { t } = useTranslation(); const { isMobile, isTablet, isDesktop } = useResponsive(); @@ -134,7 +148,12 @@ const HeroSection: React.FC = () => { const [toastVisible, setToastVisible] = useState(false); const [toastMessage, setToastMessage] = useState(''); const [showShaderBackground, setShowShaderBackground] = useState(false); - const [activeChannel, setActiveChannel] = useState(0); + const [activeChannelKey, setActiveChannelKey] = useState(INSTALL_CHANNELS[0].key); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + const activeChannel = INSTALL_CHANNELS.find((ch) => ch.key === activeChannelKey) ?? INSTALL_CHANNELS[0]; + const activeIsSecondary = !activeChannel.primary; const showToast = (message: string) => { setToastMessage(message); @@ -176,6 +195,24 @@ const HeroSection: React.FC = () => { return () => clearTimeout(timer); }, [toastVisible]); + useEffect(() => { + if (!menuOpen) return; + const handlePointerDown = (e: MouseEvent | TouchEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false); + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setMenuOpen(false); + }; + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('touchstart', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('touchstart', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [menuOpen]); + useEffect(() => { // Wait until after the first paint before loading the heavy shader chunk. let secondFrame: number | undefined; @@ -325,33 +362,140 @@ const HeroSection: React.FC = () => { {/* Install channels — tab switcher */} -
-
- {INSTALL_CHANNELS.map((ch, idx) => ( +
+
+ {PRIMARY_CHANNELS.map((ch) => { + const isActive = ch.key === activeChannelKey; + return ( + + ); + })} + + {/* Overflow channels live behind a "More" trigger so the row stops + growing each time a new channel is added. */} +
- ))} + + {menuOpen && ( +
+ {SECONDARY_CHANNELS.map((ch) => { + const isActive = ch.key === activeChannelKey; + return ( + + ); + })} +
+ setMenuOpen(false)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + padding: '8px 12px', + borderRadius: 6, + color: 'rgba(255,255,255,0.6)', + fontSize: 13, + textDecoration: 'none', + whiteSpace: 'nowrap', + }} + > + {t('hero.allInstallOptions')} + + +
+ )} +
{ textOverflow: 'ellipsis', }} > - {INSTALL_CHANNELS[activeChannel].cmd} + {activeChannel.cmd} Copy handleCopy(INSTALL_CHANNELS[activeChannel].cmd)} + onClick={() => handleCopy(activeChannel.cmd)} />
diff --git a/pages/src/i18n/en.ts b/pages/src/i18n/en.ts index 3ab97bad..8b27d51c 100644 --- a/pages/src/i18n/en.ts +++ b/pages/src/i18n/en.ts @@ -20,6 +20,9 @@ export const en = { 'hero.copyFailed': 'Copy failed', 'hero.installNpm': 'npm', 'hero.installBrew': 'Homebrew', + 'hero.installMacPorts': 'MacPorts', + 'hero.installMore': 'More', + 'hero.allInstallOptions': 'All install options', // Error boundary 'error.pageLoadFailed': 'Failed to load this page.', diff --git a/pages/src/i18n/ja.ts b/pages/src/i18n/ja.ts index 0daae22c..4ed19971 100644 --- a/pages/src/i18n/ja.ts +++ b/pages/src/i18n/ja.ts @@ -22,6 +22,9 @@ export const ja: TranslationKeys = { 'hero.copyFailed': 'コピー失敗', 'hero.installNpm': 'npm', 'hero.installBrew': 'Homebrew', + 'hero.installMacPorts': 'MacPorts', + 'hero.installMore': 'その他', + 'hero.allInstallOptions': 'すべてのインストール方法', // Error boundary 'error.pageLoadFailed': 'ページの読み込みに失敗しました。', diff --git a/pages/src/i18n/ru.ts b/pages/src/i18n/ru.ts index f36ba9bd..42a239d7 100644 --- a/pages/src/i18n/ru.ts +++ b/pages/src/i18n/ru.ts @@ -22,6 +22,9 @@ export const ru: TranslationKeys = { 'hero.copyFailed': 'Не удалось скопировать', 'hero.installNpm': 'npm', 'hero.installBrew': 'Homebrew', + 'hero.installMacPorts': 'MacPorts', + 'hero.installMore': 'Ещё', + 'hero.allInstallOptions': 'Все способы установки', // Error boundary 'error.pageLoadFailed': 'Не удалось загрузить страницу.', diff --git a/pages/src/i18n/zh.ts b/pages/src/i18n/zh.ts index efa9d1c1..4ce4756f 100644 --- a/pages/src/i18n/zh.ts +++ b/pages/src/i18n/zh.ts @@ -22,6 +22,9 @@ export const zh: TranslationKeys = { 'hero.copyFailed': '复制失败', 'hero.installNpm': 'npm', 'hero.installBrew': 'Homebrew', + 'hero.installMacPorts': 'MacPorts', + 'hero.installMore': '更多', + 'hero.allInstallOptions': '全部安装方式', // Error boundary 'error.pageLoadFailed': '页面加载失败。',