diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 2e838a52..1f5e04b4 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -29,11 +29,14 @@ src/index.ts # Entry point with style injection side effect ✅ Do: Use Radix UI primitives from `components/ui/` for low-level behaviors (modals, popovers, etc.) ✅ Do: Use Tailwind classes with the `yv:` prefix only ✅ Do: Use semantic theme tokens (`yv:text-muted-foreground`, `yv:bg-destructive`) instead of arbitrary colors +✅ Do: Keep components SSR-safe (see SSR SAFETY section below) ❌ Don't: Make raw network requests from UI components ❌ Don't: Import from `@youversion/platform-core` directly (except re-exports in index.ts) ❌ Don't: Add global CSS files; all styling goes through Tailwind build and `injectStyles` ❌ Don't: Use unprefixed Tailwind classes (causes collisions in consumer apps) +❌ Don't: Access `window`, `document`, or browser APIs outside of `useEffect` +❌ Don't: Use `useLayoutEffect` (causes React warnings during SSR) ## CONVENTIONS - React 19+ peer dependency @@ -167,6 +170,26 @@ From repo root, `pnpm build` runs Turbo which builds in order: 2. `@youversion/platform-react-hooks` 3. `@youversion/platform-react-ui` (build:css → build:js → build:types) +## SSR SAFETY +Components must work with SSR frameworks (Next.js, Remix, etc.) without errors or warnings: + +- **All browser API access must be inside `useEffect`** - never access `window`, `document`, `navigator`, `localStorage`, or DOM APIs at module level or during render +- **Use `useEffect` instead of `useLayoutEffect`** - `useLayoutEffect` logs warnings during SSR; since our components typically start hidden/closed, the visual flash concern doesn't apply +- **Guard dynamic imports** - polyfills and browser-only code should be loaded inside `useEffect` +- **Props like `anchorElement` will be `null` during SSR** - always check before accessing + +Example pattern for browser-only code: +```tsx +useEffect(() => { + // Safe: only runs on client after hydration + if ('anchorName' in document.documentElement.style) { + // native support + } else { + // load polyfill + } +}, []); +``` + ## CRITICAL - **Side effect**: importing package injects styles automatically - Never skip build:css step (styles required for __YV_STYLES__ constant) diff --git a/packages/ui/package.json b/packages/ui/package.json index 3607d1d6..8782cb6a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -42,6 +42,7 @@ "test:integration": "vitest run" }, "dependencies": { + "@oddbird/css-anchor-positioning": "^0.8.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-popover": "1.1.15", diff --git a/packages/ui/src/components/icons/box-arrow-up.tsx b/packages/ui/src/components/icons/box-arrow-up.tsx new file mode 100644 index 00000000..c1f9bf5c --- /dev/null +++ b/packages/ui/src/components/icons/box-arrow-up.tsx @@ -0,0 +1,23 @@ +import type { ComponentProps, ReactElement } from 'react'; + +export function BoxArrowUpIcon(props: ComponentProps<'svg'>): ReactElement { + return ( + + + + + ); +} diff --git a/packages/ui/src/components/icons/box-stack.tsx b/packages/ui/src/components/icons/box-stack.tsx new file mode 100644 index 00000000..dcb1ae51 --- /dev/null +++ b/packages/ui/src/components/icons/box-stack.tsx @@ -0,0 +1,25 @@ +import type { ComponentProps, ReactElement } from 'react'; + +export function BoxStackIcon(props: ComponentProps<'svg'>): ReactElement { + return ( + + + + + ); +} diff --git a/packages/ui/src/components/icons/index.ts b/packages/ui/src/components/icons/index.ts new file mode 100644 index 00000000..3fe6b0f8 --- /dev/null +++ b/packages/ui/src/components/icons/index.ts @@ -0,0 +1,16 @@ +export { ArrowLeftIcon } from './arrow-left'; +export { BookOpenIcon } from './book-open'; +export { BoxArrowUpIcon } from './box-arrow-up'; +export { BoxStackIcon } from './box-stack'; +export { ChevronDownIcon } from './chevron-down'; +export { Footnote, Footnote as FootnoteIcon } from './footnote'; +export { GearIcon } from './gear'; +export { GlobeIcon } from './globe'; +export { InfoIcon } from './info'; +export { LoaderIcon } from './loader'; +export { PersonIcon } from './person'; +export { SearchIcon } from './search'; +export { Share, Share as ShareIcon } from './share'; +export { Votd, Votd as VotdIcon } from './votd'; +export { XIcon } from './x'; +export { YouVersionLogo } from './youversion-logo'; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index f4ea9682..2ffe15a6 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -9,3 +9,4 @@ export { YouVersionAuthButton, type YouVersionAuthButtonProps } from './YouVersi export { VerseOfTheDay, type VerseOfTheDayProps } from './verse-of-the-day'; export { BibleTextView, type BibleTextViewProps } from './verse'; export { BibleWidgetView, type BibleWidgetViewProps } from './bible-widget-view'; +export { VerseActionPopover, HIGHLIGHT_COLORS } from './verse-action-popover'; diff --git a/packages/ui/src/components/verse-action-popover.test.tsx b/packages/ui/src/components/verse-action-popover.test.tsx new file mode 100644 index 00000000..b86a387c --- /dev/null +++ b/packages/ui/src/components/verse-action-popover.test.tsx @@ -0,0 +1,469 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { VerseActionPopover, HIGHLIGHT_COLORS, type HighlightColor } from './verse-action-popover'; + +describe('VerseActionPopover', () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + activeHighlights: new Set(), + selectedVerses: [], + highlightedVerses: {}, + anchorElement: null, + onHighlight: vi.fn(), + onClearHighlight: vi.fn(), + onCopy: vi.fn(), + onShare: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('AC1: Basic popover display', () => { + it('should display 5 color circles when verse selected', () => { + render(); + + const colorButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + expect(colorButtons).toHaveLength(5); + }); + + it('should render colors in correct order (yellow, green, blue, orange, pink)', () => { + render(); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + expect(applyButtons).toHaveLength(5); + applyButtons.forEach((btn) => { + const bgColor = btn.style.backgroundColor; + expect(bgColor).toMatch(/^(#[a-fA-F0-9]{6}|rgb\(.*\))$/); + }); + }); + }); + + describe('AC2: Apply highlight', () => { + it('should call onHighlight when color circle clicked', () => { + const onHighlight = vi.fn(); + render(); + + const firstColorButton = screen + .getAllByRole('button') + .find((btn) => btn.getAttribute('aria-label')?.includes('Apply'))!; + + fireEvent.click(firstColorButton); + expect(onHighlight).toHaveBeenCalledWith(HIGHLIGHT_COLORS[0]); + }); + + it('should render popover with color buttons', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeTruthy(); + + const firstColorButton = screen + .getAllByRole('button') + .find((btn) => btn.getAttribute('aria-label')?.includes('Apply'))!; + + expect(firstColorButton).toBeTruthy(); + }); + }); + + describe('AC3: Copy action', () => { + it('should display copy button', () => { + render(); + const copyButton = screen.getByText('Copy'); + expect(copyButton).toBeTruthy(); + }); + + it('should call onCopy when copy button clicked', () => { + const onCopy = vi.fn(); + render(); + + const copyButton = screen.getByText('Copy'); + fireEvent.click(copyButton); + expect(onCopy).toHaveBeenCalled(); + }); + }); + + describe('AC4: Share action', () => { + it('should display share button', () => { + render(); + const shareButton = screen.getByText('Share'); + expect(shareButton).toBeTruthy(); + }); + + it('should call onShare when share button clicked', () => { + const onShare = vi.fn(); + render(); + + const shareButton = screen.getByText('Share'); + fireEvent.click(shareButton); + expect(onShare).toHaveBeenCalled(); + }); + }); + + describe('AC5: Single highlighted verse', () => { + it('should show 5 circles: 1 remove + 4 apply (only inactive colors)', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0]]); + const selectedVerses = [1]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0] }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + expect(removeButtons).toHaveLength(1); + expect(applyButtons).toHaveLength(4); + }); + }); + + describe('AC5a: Ordering of circles', () => { + it('should show X circles leftmost, then apply circles for only inactive colors', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0], HIGHLIGHT_COLORS[2]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0], 2: HIGHLIGHT_COLORS[2] }; + + render( + , + ); + + const colorGroup = screen.getByRole('group', { name: 'Highlight colors' }); + const buttons = Array.from(colorGroup.querySelectorAll('button')); + + // First 2 should be clear (yellow, blue), then 3 apply (green, orange, pink - the inactive ones) + expect(buttons[0]?.getAttribute('aria-label')).toContain('Clear'); + expect(buttons[1]?.getAttribute('aria-label')).toContain('Clear'); + expect(buttons[2]?.getAttribute('aria-label')).toContain('Apply'); + expect(buttons[3]?.getAttribute('aria-label')).toContain('Apply'); + expect(buttons[4]?.getAttribute('aria-label')).toContain('Apply'); + }); + }); + + describe('AC6: Mixed selection (highlighted + unhighlighted)', () => { + it('should show all 5 apply colors when there are unhighlighted verses', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0] }; // verse 2 is unhighlighted + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // 1 yellow remove + all 5 apply (because verse 2 is unhighlighted) + expect(removeButtons).toHaveLength(1); + expect(applyButtons).toHaveLength(5); + }); + }); + + describe('AC7: Multiple different highlights', () => { + it('should show all 5 apply colors when multiple colors are active', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0], HIGHLIGHT_COLORS[1]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0], 2: HIGHLIGHT_COLORS[1] }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // 2 remove (X) + all 5 apply (because multiple colors) + expect(removeButtons).toHaveLength(2); + expect(applyButtons).toHaveLength(5); + }); + + it('should call onClearHighlight with color when X circle clicked', () => { + const onClearHighlight = vi.fn(); + const activeHighlights = new Set([HIGHLIGHT_COLORS[0]]); + const selectedVerses = [1]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0] }; + + render( + , + ); + + const removeButton = screen.getByRole('button', { name: /Clear highlight/ }); + fireEvent.click(removeButton); + expect(onClearHighlight).toHaveBeenCalledWith(HIGHLIGHT_COLORS[0]); + }); + + it('should call onClearHighlight with correct color for each button clicked', () => { + const onClearHighlight = vi.fn(); + const activeHighlights = new Set([HIGHLIGHT_COLORS[0], HIGHLIGHT_COLORS[1]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0], 2: HIGHLIGHT_COLORS[1] }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + expect(removeButtons).toHaveLength(2); + + const firstBtn = removeButtons[0]; + const secondBtn = removeButtons[1]; + if (!firstBtn || !secondBtn) throw new Error('Expected 2 remove buttons'); + + fireEvent.click(firstBtn); + expect(onClearHighlight).toHaveBeenCalledWith(HIGHLIGHT_COLORS[0]); + + fireEvent.click(secondBtn); + expect(onClearHighlight).toHaveBeenCalledWith(HIGHLIGHT_COLORS[1]); + }); + }); + + describe('AC8 & AC8a: Dismiss logic on remove', () => { + it('should show remove buttons for active highlights', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0], HIGHLIGHT_COLORS[1]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0], 2: HIGHLIGHT_COLORS[1] }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + expect(removeButtons).toHaveLength(2); + }); + }); + + describe('Popover visibility', () => { + it('should not render content when open is false', () => { + render(); + + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('should render content when open is true', () => { + render(); + + expect(screen.getByRole('dialog')).toBeTruthy(); + }); + + it('should use Radix popover with portal', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeTruthy(); + }); + }); + + describe('Accessibility', () => { + it('should have proper ARIA labels for all buttons', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0]]); + const selectedVerses = [1]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0] }; + + render( + , + ); + + const colorButtons = screen.getAllByRole('button').filter((btn) => { + const label = btn.getAttribute('aria-label'); + return label?.includes('highlight'); + }); + colorButtons.forEach((btn) => { + const label = btn.getAttribute('aria-label'); + expect(label).toMatch(/^(Apply|Clear) highlight$/); + }); + + expect(screen.getByText('Copy')).toBeTruthy(); + expect(screen.getByText('Share')).toBeTruthy(); + }); + + it('should have dialog role with aria-label', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeTruthy(); + expect(dialog.getAttribute('aria-label')).toBe('Verse actions'); + }); + + it('should have semantic color group', () => { + render(); + + const colorGroup = screen.getByRole('group', { name: 'Highlight colors' }); + expect(colorGroup).toBeTruthy(); + }); + }); + + describe('Styling', () => { + it('should have data-yv-sdk attribute for scoping', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('data-yv-sdk')).not.toBeNull(); + }); + + it('should apply theme attribute', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('data-yv-theme')).toBe('dark'); + }); + + it('should default to light theme', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('data-yv-theme')).toBe('light'); + }); + }); + + describe('Edge cases', () => { + it('should handle empty active highlights', () => { + const activeHighlights = new Set(); + + render(); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // Should still show 5 apply colors + expect(applyButtons).toHaveLength(5); + }); + + it('should handle all 5 colors highlighted', () => { + const activeHighlights = new Set(HIGHLIGHT_COLORS); + const selectedVerses = [1, 2, 3, 4, 5]; + const highlightedVerses = { + 1: HIGHLIGHT_COLORS[0], + 2: HIGHLIGHT_COLORS[1], + 3: HIGHLIGHT_COLORS[2], + 4: HIGHLIGHT_COLORS[3], + 5: HIGHLIGHT_COLORS[4], + }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // 5 remove (X) + 0 apply (all colors already active) = 5 total + expect(removeButtons).toHaveLength(5); + expect(applyButtons).toHaveLength(0); + }); + + it('should show 3 verses with different highlights: Y(x), B(x), G(x), Y, G, B, O, P', () => { + const activeHighlights = new Set([ + HIGHLIGHT_COLORS[0], // yellow + HIGHLIGHT_COLORS[2], // blue + HIGHLIGHT_COLORS[1], // green + ]); + const selectedVerses = [1, 2, 3]; + const highlightedVerses = { + 1: HIGHLIGHT_COLORS[0], // yellow + 2: HIGHLIGHT_COLORS[2], // blue + 3: HIGHLIGHT_COLORS[1], // green + }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // 3 remove (X for Y, B, G) + all 5 apply (yellow, green, blue, orange, pink) = 8 total + expect(removeButtons).toHaveLength(3); + expect(applyButtons).toHaveLength(5); + }); + }); +}); diff --git a/packages/ui/src/components/verse-action-popover.tsx b/packages/ui/src/components/verse-action-popover.tsx new file mode 100644 index 00000000..2d94a76c --- /dev/null +++ b/packages/ui/src/components/verse-action-popover.tsx @@ -0,0 +1,187 @@ +import { useMemo, type FC } from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { cn } from '../lib/utils'; +import { BoxStackIcon } from './icons/box-stack'; +import { BoxArrowUpIcon } from './icons/box-arrow-up'; +import { XIcon } from './icons/x'; + +type Measurable = { getBoundingClientRect: () => DOMRect }; + +// Hex colors (6-digit, no #) matching core schema +export const HIGHLIGHT_COLORS = ['e6d163', '9ec56e', '6eb5c5', 'd4a054', 'd485b2'] as const; + +export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number]; + +type VerseActionPopoverProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + activeHighlights: Set; + selectedVerses: number[]; + highlightedVerses: Record; + anchorElement?: HTMLElement | null; + onHighlight: (color: string) => void; + onClearHighlight: (color: string) => void; + onCopy: () => void; + onShare: () => void; + theme?: 'light' | 'dark'; +}; + +type ColorCircleProps = { + color: string; + showX: boolean; + onClick: () => void; +}; + +function ColorCircle({ color, showX, onClick }: ColorCircleProps) { + return ( + + ); +} + +type ActionButtonProps = { + icon: React.ReactNode; + label: string; + onClick: () => void; +}; + +function ActionButton({ icon, label, onClick }: ActionButtonProps) { + return ( + + ); +} + +export const VerseActionPopover: FC = ({ + open, + onOpenChange, + activeHighlights, + selectedVerses, + highlightedVerses, + anchorElement, + onHighlight, + onClearHighlight, + onCopy, + onShare, + theme = 'light', +}) => { + const virtualRef = useMemo( + () => (anchorElement ? { current: anchorElement as Measurable } : undefined), + [anchorElement], + ); + + const activeColors = HIGHLIGHT_COLORS.filter((c) => activeHighlights.has(c)); + const highlightedVerseCount = selectedVerses.filter((v) => highlightedVerses[v]).length; + const unHighlightedCount = selectedVerses.length - highlightedVerseCount; + const allColorsActive = activeHighlights.size === HIGHLIGHT_COLORS.length; + const showAllApplyColors = + !allColorsActive && (unHighlightedCount > 0 || activeHighlights.size > 1); + const colorsToApply = showAllApplyColors + ? HIGHLIGHT_COLORS + : HIGHLIGHT_COLORS.filter((c) => !activeHighlights.has(c)); + + const colorCircles = [ + ...activeColors.map((color) => ({ color, showX: true, key: `${color}-clear` })), + ...colorsToApply.map((color) => ({ color, showX: false, key: `${color}-apply` })), + ]; + + return ( + + + + + {/* Caret - position absolutely */} + + + + +
+ {colorCircles.map(({ color, showX, key }) => ( + (showX ? onClearHighlight(color) : onHighlight(color))} + /> + ))} +
+ + {/* Separator */} +