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 ( + + {showX && } + + ); +} + +type ActionButtonProps = { + icon: React.ReactNode; + label: string; + onClick: () => void; +}; + +function ActionButton({ icon, label, onClick }: ActionButtonProps) { + return ( + + {icon} + {label} + + ); +} + +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 */} + + + + } + label="Copy" + onClick={onCopy} + /> + } + label="Share" + onClick={onShare} + /> + + + + + ); +}; diff --git a/packages/ui/src/components/verse.stories.tsx b/packages/ui/src/components/verse.stories.tsx index 6b6066cf..88e5e14b 100644 --- a/packages/ui/src/components/verse.stories.tsx +++ b/packages/ui/src/components/verse.stories.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { type BibleTextViewProps, BibleTextView } from './verse'; import { Button } from './ui/button'; import { XIcon } from '@/components/icons/x'; +import { VerseActionPopover } from './verse-action-popover'; // USFM format: BOOK.CHAPTER or BOOK.CHAPTER.VERSE or BOOK.CHAPTER.VERSE-VERSE const USFM_PATTERN = /^[A-Z1-4]{3}\.\d+(\.\d+(-\d+)?)?$/; @@ -349,13 +350,13 @@ export const FootnotePopoverThemeDark: Story = { function VerseSelectionDemo(props: BibleTextViewProps) { const [selectedVerses, setSelectedVerses] = React.useState([]); - const [highlightedVerses, setHighlightedVerses] = React.useState>({}); + const [highlightedVerses, setHighlightedVerses] = React.useState>({}); const handleHighlight = () => { setHighlightedVerses((prev) => { const next = { ...prev }; for (const verse of selectedVerses) { - next[verse] = true; + next[verse] = 'e6d163'; // yellow } return next; }); @@ -453,3 +454,166 @@ export const VerseSelection: Story = { }, render: (props) => , }; + +function VerseActionPopoverDemo(props: BibleTextViewProps) { + const containerRef = React.useRef(null); + const [selectedVerses, setSelectedVerses] = React.useState([]); + const [highlightedVerses, setHighlightedVerses] = React.useState>({}); + const [popoverOpen, setPopoverOpen] = React.useState(false); + const [anchorElement, setAnchorElement] = React.useState(null); + + const handleVerseSelect = React.useCallback((verses: number[]) => { + setSelectedVerses(verses); + + if (verses.length === 0) { + setPopoverOpen(false); + setAnchorElement(null); + return; + } + + const lastVerse = Math.max(...verses); + const verseEl = containerRef.current?.querySelector(`.yv-v[v="${lastVerse}"]`); + if (verseEl instanceof HTMLElement) { + setAnchorElement(verseEl); + } + setPopoverOpen(true); + }, []); + + // Derive activeHighlights from selected verses + const activeHighlights = React.useMemo(() => { + return new Set( + selectedVerses.map((v) => highlightedVerses[v]).filter((c): c is string => Boolean(c)), + ); + }, [selectedVerses, highlightedVerses]); + + const handleHighlight = React.useCallback( + (color: string) => { + setHighlightedVerses((prev) => { + const next = { ...prev }; + for (const verse of selectedVerses) { + next[verse] = color; + } + return next; + }); + setPopoverOpen(false); + setSelectedVerses([]); + }, + [selectedVerses], + ); + + const handleClearHighlight = React.useCallback( + (color: string) => { + setHighlightedVerses((prev) => { + const next = { ...prev }; + for (const verse of selectedVerses) { + // Only remove if this verse has the specified color + if (next[verse] === color) { + delete next[verse]; + } + } + return next; + }); + // Check if any highlights remain in the selection + const hasRemaining = selectedVerses.some((v) => { + const currentColor = highlightedVerses[v]; + return currentColor && currentColor !== color; + }); + // Dismiss popover only if no highlights remain (AC 8/8a) + if (!hasRemaining) { + setPopoverOpen(false); + setSelectedVerses([]); + } + }, + [selectedVerses, highlightedVerses], + ); + + return ( + + + + Selected: {selectedVerses.length > 0 ? selectedVerses.join(', ') : 'None'} + + { + setSelectedVerses([]); + setPopoverOpen(false); + }} + className="yv:text-primary" + > + + + + + + + + + 0} + onOpenChange={setPopoverOpen} + activeHighlights={activeHighlights} + selectedVerses={selectedVerses} + highlightedVerses={highlightedVerses} + anchorElement={anchorElement} + onHighlight={handleHighlight} + onClearHighlight={handleClearHighlight} + onCopy={() => { + const verseText = selectedVerses + .map((v) => containerRef.current?.querySelector(`.yv-v[v="${v}"]`)?.textContent) + .filter(Boolean) + .join(' '); + const formatted = `"${verseText}" - John 1:${selectedVerses.join('-')} NIV`; + void navigator.clipboard.writeText(formatted); + setPopoverOpen(false); + setSelectedVerses([]); + }} + onShare={() => { + const verseText = selectedVerses + .map((v) => containerRef.current?.querySelector(`.yv-v[v="${v}"]`)?.textContent) + .filter(Boolean) + .join(' '); + const formatted = `"${verseText}" - John 1:${selectedVerses.join('-')} NIV`; + navigator + .share({ text: formatted }) + .then(() => { + setPopoverOpen(false); + setSelectedVerses([]); + }) + .catch(() => { + // User cancelled or share failed - keep popover open per AC4 + }); + }} + /> + + ); +} + +export const VerseActionPopoverStory: Story = { + name: 'Verse Action Popover', + args: { + reference: 'JHN.1', + versionId: 111, + renderNotes: true, + }, + argTypes: { + theme: { table: { disable: true } }, + selectedVerses: { table: { disable: true } }, + onVerseSelect: { table: { disable: true } }, + highlightedVerses: { table: { disable: true } }, + }, + render: (props) => , +}; diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 58383655..db41048a 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -99,7 +99,7 @@ function BibleTextHtml({ theme?: 'light' | 'dark'; selectedVerses?: number[]; onVerseSelect?: (verses: number[]) => void; - highlightedVerses?: Record; + highlightedVerses?: Record; }) { const contentRef = useRef(null); const [placeholders, setPlaceholders] = useState>(new Map()); @@ -131,10 +131,13 @@ function BibleTextHtml({ el.classList.remove('yv-v-selected'); } - if (highlightedVerses[verseNum]) { + const highlightColor = highlightedVerses[verseNum]; + if (highlightColor) { el.classList.add('yv-v-highlighted'); + (el as HTMLElement).style.backgroundColor = `#${highlightColor}`; } else { el.classList.remove('yv-v-highlighted'); + (el as HTMLElement).style.backgroundColor = ''; } }); }, [html, selectedVerses, highlightedVerses]); @@ -295,7 +298,7 @@ type VerseHtmlProps = { theme?: 'light' | 'dark'; selectedVerses?: number[]; onVerseSelect?: (verses: number[]) => void; - highlightedVerses?: Record; + highlightedVerses?: Record; }; /** @@ -355,7 +358,6 @@ export const Verse = { const currentTheme = theme || providerTheme; useEffect(() => { - // Always extract notes to keep DOM stable (visibility controlled via CSS) setTransformedData(yvDomTransformer(html, true)); }, [html]); @@ -401,7 +403,7 @@ export type BibleTextViewProps = { theme?: 'light' | 'dark'; selectedVerses?: number[]; onVerseSelect?: (verses: number[]) => void; - highlightedVerses?: Record; + highlightedVerses?: Record; }; /** diff --git a/packages/ui/src/lib/use-anchor-positioning-polyfill.ts b/packages/ui/src/lib/use-anchor-positioning-polyfill.ts new file mode 100644 index 00000000..d8461e59 --- /dev/null +++ b/packages/ui/src/lib/use-anchor-positioning-polyfill.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +let polyfillPromise: Promise | null = null; + +async function loadPolyfill(): Promise { + if ('anchorName' in document.documentElement.style) { + return; + } + + const { default: polyfill } = await import( + '@oddbird/css-anchor-positioning/fn' + ); + await polyfill(); +} + +export function useAnchorPositioningPolyfill(): void { + useEffect(() => { + if (!polyfillPromise) { + polyfillPromise = loadPolyfill(); + } + }, []); +} diff --git a/packages/ui/src/test/setup.ts b/packages/ui/src/test/setup.ts index c8e953b6..ac957b98 100644 --- a/packages/ui/src/test/setup.ts +++ b/packages/ui/src/test/setup.ts @@ -3,6 +3,35 @@ import { afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; +// Popover API mock for jsdom +if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.showPopover) { + HTMLElement.prototype.showPopover = function () { + this.style.display = 'block'; + const toggleEvent = new Event('toggle', { bubbles: false }); + Object.defineProperty(toggleEvent, 'newState', { value: 'open', writable: false }); + this.dispatchEvent(toggleEvent); + }; + + HTMLElement.prototype.hidePopover = function () { + this.style.display = 'none'; + const toggleEvent = new Event('toggle', { bubbles: false }); + Object.defineProperty(toggleEvent, 'newState', { value: 'closed', writable: false }); + this.dispatchEvent(toggleEvent); + }; + + HTMLElement.prototype.togglePopover = function (force?: boolean): boolean { + const isOpen = this.style.display !== 'none'; + if (force === undefined || force !== isOpen) { + if (isOpen) { + this.hidePopover(); + } else { + this.showPopover(); + } + } + return !isOpen; + }; +} + // Clean up after each test afterEach(() => { cleanup(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4372432f..034faf65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ importers: packages/ui: dependencies: + '@oddbird/css-anchor-positioning': + specifier: ^0.8.0 + version: 0.8.0 '@radix-ui/react-accordion': specifier: 1.2.12 version: 1.2.12(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) @@ -1316,6 +1319,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oddbird/css-anchor-positioning@0.8.0': + resolution: {integrity: sha512-1jsUAUANdwaxQ7eQtQ7MBdv6okLBb6ByG2j4xNyNB/iBxFw7cfpiNIn5mMI2dlWsazK05pQWwQqUxpd4q/ANaQ==} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -2388,6 +2394,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/css-tree@2.3.11': + resolution: {integrity: sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -4350,6 +4359,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.3: resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -6589,6 +6603,13 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oddbird/css-anchor-positioning@0.8.0': + dependencies: + '@floating-ui/dom': 1.7.4 + '@types/css-tree': 2.3.11 + css-tree: 3.1.0 + nanoid: 5.1.6 + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -7534,6 +7555,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/css-tree@2.3.11': {} + '@types/deep-eql@4.0.2': {} '@types/doctrine@0.0.9': {} @@ -10001,6 +10024,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + napi-postinstall@0.3.3: {} natural-compare@1.4.0: {}
+ Selected: {selectedVerses.length > 0 ? selectedVerses.join(', ') : 'None'} +