refactor(know-your-world): extract reusable utilities and components

- Extract regionSizeUtils.ts with size/range conversion functions
- Extract RegionListPanel component for scrollable region lists
- Add responsiveStyles.ts with breakpoint utilities and preventFlexExpansion
- Add comprehensive unit tests for all extracted modules (65 tests)

This improves code organization and reusability for the region selector.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-27 15:55:01 -06:00
parent cc317d2bdd
commit a17e951e7c
6 changed files with 456 additions and 117 deletions

View File

@@ -10,6 +10,7 @@ import {
} from '@/components/Thermometer'
import { useTheme } from '@/contexts/ThemeContext'
import { MapSelectorMap } from './MapSelectorMap'
import { RegionListPanel } from './RegionListPanel'
import {
WORLD_MAP,
calculateContinentViewBox,
@@ -32,6 +33,8 @@ import {
COUNTRY_TO_CONTINENT,
type ContinentId,
} from '../continents'
import { sizesToRange, rangeToSizes } from '../utils/regionSizeUtils'
import { preventFlexExpansion } from '../utils/responsiveStyles'
/**
* Size options for the range thermometer, ordered from largest to smallest
@@ -43,25 +46,6 @@ const SIZE_OPTIONS: ThermometerOption<RegionSize>[] = ALL_REGION_SIZES.map((size
emoji: REGION_SIZE_CONFIG[size].emoji,
}))
/**
* Convert an array of sizes to min/max values for the range thermometer
*/
function sizesToRange(sizes: RegionSize[]): [RegionSize, RegionSize] {
const sorted = [...sizes].sort(
(a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b)
)
return [sorted[0], sorted[sorted.length - 1]]
}
/**
* Convert min/max range values back to an array of sizes
*/
function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
const minIdx = ALL_REGION_SIZES.indexOf(min)
const maxIdx = ALL_REGION_SIZES.indexOf(max)
return ALL_REGION_SIZES.slice(minIdx, maxIdx + 1)
}
/**
* Selection path for drill-down navigation:
* - [] = World level
@@ -1105,45 +1089,16 @@ export function DrillDownMapSelector({
borderColor: isDark ? 'gray.700' : 'gray.300',
marginTop: '2',
paddingTop: '2',
maxHeight: '200px',
/* Prevent this element from expanding the parent - use 0 width + min 100% trick */
width: 0,
minWidth: '100%',
/* Prevent this element from expanding the parent */
...preventFlexExpansion,
})}
>
{/* Scrollable list */}
<div
className={css({
overflowY: 'auto',
flex: 1,
})}
>
{selectedRegionNames
.slice()
.sort((a, b) => a.localeCompare(b))
.map((name) => (
<div
key={name}
onMouseEnter={() => setPreviewRegionName(name)}
onMouseLeave={() => setPreviewRegionName(null)}
className={css({
px: '2',
py: '1',
fontSize: 'xs',
color: isDark ? 'gray.300' : 'gray.600',
cursor: 'pointer',
rounded: 'md',
overflowWrap: 'break-word',
_hover: {
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.100' : 'gray.900',
},
})}
>
{name}
</div>
))}
</div>
<RegionListPanel
regions={selectedRegionNames}
onRegionHover={setPreviewRegionName}
maxHeight="200px"
isDark={isDark}
/>
</div>
)}
</div>

View File

@@ -0,0 +1,95 @@
'use client'
import { memo } from 'react'
import { css } from '@styled/css'
interface RegionListItemProps {
name: string
onHover?: (name: string | null) => void
isDark?: boolean
}
/**
* Individual region list item with hover interaction.
* Memoized to prevent unnecessary re-renders when list is large.
*/
const RegionListItem = memo(function RegionListItem({
name,
onHover,
isDark = false,
}: RegionListItemProps) {
return (
<div
onMouseEnter={() => onHover?.(name)}
onMouseLeave={() => onHover?.(null)}
className={css({
px: '2',
py: '1',
fontSize: 'xs',
color: isDark ? 'gray.300' : 'gray.600',
cursor: onHover ? 'pointer' : 'default',
rounded: 'md',
overflowWrap: 'break-word',
_hover: {
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.100' : 'gray.900',
},
})}
>
{name}
</div>
)
})
interface RegionListPanelProps {
/** List of region names to display */
regions: string[]
/** Callback when hovering over a region name (for map preview) */
onRegionHover?: (name: string | null) => void
/** Maximum height of the scrollable list */
maxHeight?: string
/** Dark mode styling */
isDark?: boolean
/** Sort regions alphabetically (default: true) */
sortAlphabetically?: boolean
}
/**
* Scrollable panel displaying a list of region names.
* Used in DrillDownMapSelector for displaying selected regions on desktop.
*/
export function RegionListPanel({
regions,
onRegionHover,
maxHeight = '200px',
isDark = false,
sortAlphabetically = true,
}: RegionListPanelProps) {
const displayRegions = sortAlphabetically
? [...regions].sort((a, b) => a.localeCompare(b))
: regions
return (
<div
data-element="region-list-panel"
className={css({
display: 'flex',
flexDirection: 'column',
maxHeight,
overflow: 'hidden',
})}
>
{/* Scrollable list */}
<div
className={css({
overflowY: 'auto',
flex: 1,
})}
>
{displayRegions.map((name) => (
<RegionListItem key={name} name={name} onHover={onRegionHover} isDark={isDark} />
))}
</div>
</div>
)
}

View File

@@ -1,32 +1,11 @@
import { describe, it, expect } from 'vitest'
/**
* Utility functions extracted from DrillDownMapSelector for testing.
* These are copied here since they're defined inline in the component.
* In a refactor, these should be moved to a separate utils file.
*/
type RegionSize = 'huge' | 'large' | 'medium' | 'small' | 'tiny'
const ALL_REGION_SIZES: RegionSize[] = ['huge', 'large', 'medium', 'small', 'tiny']
/**
* Convert an array of sizes to min/max values for the range thermometer
*/
function sizesToRange(sizes: RegionSize[]): [RegionSize, RegionSize] {
const sorted = [...sizes].sort(
(a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b)
)
return [sorted[0], sorted[sorted.length - 1]]
}
/**
* Convert min/max range values back to an array of sizes
*/
function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
const minIdx = ALL_REGION_SIZES.indexOf(min)
const maxIdx = ALL_REGION_SIZES.indexOf(max)
return ALL_REGION_SIZES.slice(minIdx, maxIdx + 1)
}
import {
sizesToRange,
rangeToSizes,
calculateExcludedRegions,
calculatePreviewChanges,
} from '../../utils/regionSizeUtils'
import type { RegionSize } from '../../maps'
describe('DrillDownMapSelector utility functions', () => {
describe('sizesToRange', () => {
@@ -205,14 +184,6 @@ describe('Region filtering logic', () => {
describe('excluded regions calculation', () => {
const allRegionIds = ['region-1', 'region-2', 'region-3', 'region-4', 'region-5']
function calculateExcludedRegions(
allRegions: string[],
filteredRegions: string[]
): string[] {
const filteredSet = new Set(filteredRegions)
return allRegions.filter(id => !filteredSet.has(id))
}
it('returns empty when all regions are included', () => {
const filtered = ['region-1', 'region-2', 'region-3', 'region-4', 'region-5']
const excluded = calculateExcludedRegions(allRegionIds, filtered)
@@ -240,31 +211,6 @@ describe('Region filtering logic', () => {
})
describe('Preview add/remove regions calculation', () => {
function calculatePreviewChanges(
currentIncluded: string[],
previewIncluded: string[]
): { addRegions: string[]; removeRegions: string[] } {
const currentSet = new Set(currentIncluded)
const previewSet = new Set(previewIncluded)
const addRegions: string[] = []
const removeRegions: string[] = []
for (const id of previewSet) {
if (!currentSet.has(id)) {
addRegions.push(id)
}
}
for (const id of currentSet) {
if (!previewSet.has(id)) {
removeRegions.push(id)
}
}
return { addRegions, removeRegions }
}
it('detects regions to be added', () => {
const current = ['a', 'b']
const preview = ['a', 'b', 'c', 'd']

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RegionListPanel } from '../RegionListPanel'
// Mock Panda CSS
vi.mock('@styled/css', () => ({
css: vi.fn(() => 'mocked-css-class'),
}))
describe('RegionListPanel', () => {
const defaultRegions = ['France', 'Germany', 'Spain', 'Italy', 'Poland']
describe('rendering', () => {
it('renders all region names', () => {
render(<RegionListPanel regions={defaultRegions} />)
expect(screen.getByText('France')).toBeInTheDocument()
expect(screen.getByText('Germany')).toBeInTheDocument()
expect(screen.getByText('Spain')).toBeInTheDocument()
expect(screen.getByText('Italy')).toBeInTheDocument()
expect(screen.getByText('Poland')).toBeInTheDocument()
})
it('sorts regions alphabetically by default', () => {
const unsorted = ['Zambia', 'Argentina', 'France']
render(<RegionListPanel regions={unsorted} />)
const items = screen.getAllByText(/^(Argentina|France|Zambia)$/)
expect(items[0]).toHaveTextContent('Argentina')
expect(items[1]).toHaveTextContent('France')
expect(items[2]).toHaveTextContent('Zambia')
})
it('preserves order when sortAlphabetically is false', () => {
const unsorted = ['Zambia', 'Argentina', 'France']
render(<RegionListPanel regions={unsorted} sortAlphabetically={false} />)
const items = screen.getAllByText(/^(Argentina|France|Zambia)$/)
expect(items[0]).toHaveTextContent('Zambia')
expect(items[1]).toHaveTextContent('Argentina')
expect(items[2]).toHaveTextContent('France')
})
it('renders empty list when no regions provided', () => {
const { container } = render(<RegionListPanel regions={[]} />)
// Should still render the container
expect(container.querySelector('[data-element="region-list-panel"]')).toBeInTheDocument()
})
it('has correct data-element attribute', () => {
const { container } = render(<RegionListPanel regions={defaultRegions} />)
expect(container.querySelector('[data-element="region-list-panel"]')).toBeInTheDocument()
})
})
describe('hover interactions', () => {
it('calls onRegionHover with region name on mouse enter', () => {
const onRegionHover = vi.fn()
render(<RegionListPanel regions={defaultRegions} onRegionHover={onRegionHover} />)
fireEvent.mouseEnter(screen.getByText('France'))
expect(onRegionHover).toHaveBeenCalledWith('France')
})
it('calls onRegionHover with null on mouse leave', () => {
const onRegionHover = vi.fn()
render(<RegionListPanel regions={defaultRegions} onRegionHover={onRegionHover} />)
fireEvent.mouseLeave(screen.getByText('France'))
expect(onRegionHover).toHaveBeenCalledWith(null)
})
it('does not throw when onRegionHover is not provided', () => {
render(<RegionListPanel regions={defaultRegions} />)
// Should not throw
expect(() => {
fireEvent.mouseEnter(screen.getByText('France'))
fireEvent.mouseLeave(screen.getByText('France'))
}).not.toThrow()
})
})
describe('styling props', () => {
it('accepts isDark prop', () => {
// Just verify it renders without error
const { container } = render(<RegionListPanel regions={defaultRegions} isDark={true} />)
expect(container.querySelector('[data-element="region-list-panel"]')).toBeInTheDocument()
})
it('accepts maxHeight prop', () => {
// Just verify it renders without error
const { container } = render(<RegionListPanel regions={defaultRegions} maxHeight="300px" />)
expect(container.querySelector('[data-element="region-list-panel"]')).toBeInTheDocument()
})
})
describe('large lists', () => {
it('handles many regions efficiently', () => {
const manyRegions = Array.from({ length: 100 }, (_, i) => `Region ${i + 1}`)
render(<RegionListPanel regions={manyRegions} />)
// Should render first and last
expect(screen.getByText('Region 1')).toBeInTheDocument()
expect(screen.getByText('Region 100')).toBeInTheDocument()
})
it('handles long region names', () => {
const longNames = [
'Democratic Republic of the Congo',
'United Kingdom of Great Britain and Northern Ireland',
]
render(<RegionListPanel regions={longNames} />)
expect(screen.getByText('Democratic Republic of the Congo')).toBeInTheDocument()
expect(
screen.getByText('United Kingdom of Great Britain and Northern Ireland')
).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,77 @@
/**
* Utility functions for converting between region size arrays and min/max ranges.
* Used by the DrillDownMapSelector's RangeThermometer component.
*/
import type { RegionSize } from '../maps'
import { ALL_REGION_SIZES } from '../maps'
/**
* Convert an array of sizes to min/max values for the range thermometer.
* Sorts the sizes by their position in ALL_REGION_SIZES and returns the first and last.
*
* @example
* sizesToRange(['medium', 'small', 'large']) // returns ['large', 'small']
* sizesToRange(['medium']) // returns ['medium', 'medium']
*/
export function sizesToRange(sizes: RegionSize[]): [RegionSize, RegionSize] {
const sorted = [...sizes].sort(
(a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b)
)
return [sorted[0], sorted[sorted.length - 1]]
}
/**
* Convert min/max range values back to an array of sizes.
* Returns all sizes between min and max (inclusive) in ALL_REGION_SIZES order.
*
* @example
* rangeToSizes('large', 'small') // returns ['large', 'medium', 'small']
* rangeToSizes('medium', 'medium') // returns ['medium']
*/
export function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
const minIdx = ALL_REGION_SIZES.indexOf(min)
const maxIdx = ALL_REGION_SIZES.indexOf(max)
return ALL_REGION_SIZES.slice(minIdx, maxIdx + 1)
}
/**
* Calculate which regions are excluded based on the current size filter.
* Returns IDs of regions that exist but are filtered out.
*/
export function calculateExcludedRegions(
allRegionIds: string[],
filteredRegionIds: string[]
): string[] {
const filteredSet = new Set(filteredRegionIds)
return allRegionIds.filter((id) => !filteredSet.has(id))
}
/**
* Calculate preview changes when hovering over a different size range.
* Returns which regions would be added or removed if the user clicked.
*/
export function calculatePreviewChanges(
currentIncludedIds: string[],
previewIncludedIds: string[]
): { addRegions: string[]; removeRegions: string[] } {
const currentSet = new Set(currentIncludedIds)
const previewSet = new Set(previewIncludedIds)
const addRegions: string[] = []
const removeRegions: string[] = []
for (const id of previewSet) {
if (!currentSet.has(id)) {
addRegions.push(id)
}
}
for (const id of currentSet) {
if (!previewSet.has(id)) {
removeRegions.push(id)
}
}
return { addRegions, removeRegions }
}

View File

@@ -0,0 +1,139 @@
/**
* Responsive style utilities for consistent breakpoint handling.
* Centralizes responsive patterns used across Know Your World components.
*/
/**
* Breakpoint keys matching Panda CSS convention.
* - base: 0px+ (mobile)
* - sm: 640px+ (small tablets)
* - md: 768px+ (tablets/small desktop)
* - lg: 1024px+ (desktop)
* - xl: 1280px+ (large desktop)
*/
export const BREAKPOINTS = {
mobile: 'base',
tablet: 'sm',
desktop: 'md',
wide: 'lg',
} as const
/**
* Creates responsive display styles for showing/hiding elements.
*
* @example
* // Show only on desktop
* className={css(responsiveDisplay({ showOn: 'desktop' }))}
*
* // Show only on mobile
* className={css(responsiveDisplay({ hideOn: 'desktop' }))}
*/
export function responsiveDisplay(options: {
showOn?: 'mobile' | 'desktop'
hideOn?: 'mobile' | 'desktop'
}): { display: Record<string, string> } {
if (options.showOn === 'desktop') {
return { display: { base: 'none', md: 'flex' } }
}
if (options.showOn === 'mobile') {
return { display: { base: 'flex', md: 'none' } }
}
if (options.hideOn === 'desktop') {
return { display: { base: 'flex', md: 'none' } }
}
if (options.hideOn === 'mobile') {
return { display: { base: 'none', md: 'flex' } }
}
return { display: { base: 'flex' } }
}
/**
* Creates responsive transform scale styles.
* Commonly used for shrinking controls on mobile.
*
* @example
* // 75% on mobile, 100% on tablet+
* className={css(responsiveScale(0.75, 1))}
*/
export function responsiveScale(
mobileScale: number,
desktopScale: number = 1,
origin: string = 'top right'
): {
transform: Record<string, string>
transformOrigin: string
} {
return {
transform: {
base: `scale(${mobileScale})`,
sm: `scale(${desktopScale})`,
},
transformOrigin: origin,
}
}
/**
* Creates responsive spacing/gap styles.
*
* @example
* className={css(responsiveGap(1, 2))}
*/
export function responsiveGap(
mobileGap: number | string,
desktopGap: number | string
): { gap: Record<string, number | string> } {
return {
gap: {
base: mobileGap,
sm: desktopGap,
},
}
}
/**
* Creates responsive flex direction styles.
* Useful for stacking elements vertically on mobile.
*
* @example
* // Stack on mobile, row on desktop
* className={css(responsiveFlexDirection('column', 'row'))}
*/
export function responsiveFlexDirection(
mobileDirection: 'row' | 'column',
desktopDirection: 'row' | 'column'
): { flexDirection: Record<string, 'row' | 'column'> } {
return {
flexDirection: {
base: mobileDirection,
sm: desktopDirection,
},
}
}
/**
* Creates responsive font size styles.
*
* @example
* className={css(responsiveFontSize('sm', 'md'))}
*/
export function responsiveFontSize(
mobileSize: string,
desktopSize: string
): { fontSize: Record<string, string> } {
return {
fontSize: {
base: mobileSize,
sm: desktopSize,
},
}
}
/**
* CSS trick to prevent a flex child from expanding its parent.
* Sets width to 0 but min-width to 100% so it fills available space
* without contributing to parent's width calculation.
*/
export const preventFlexExpansion = {
width: 0,
minWidth: '100%',
} as const