diff --git a/packages/dev/s2-docs/src/ColorSearchView.tsx b/packages/dev/s2-docs/src/ColorSearchView.tsx new file mode 100644 index 00000000000..27ba1334063 --- /dev/null +++ b/packages/dev/s2-docs/src/ColorSearchView.tsx @@ -0,0 +1,409 @@ +'use client'; + +import {Badge, Content, Heading, IllustratedMessage, Link, pressScale, Text} from '@react-spectrum/s2'; +import Checkmark from '@react-spectrum/s2/icons/Checkmark'; +import CheckmarkCircle from '@react-spectrum/s2/icons/CheckmarkCircle'; +import {colorSwatch, getColorScale} from './color.macro' with {type: 'macro'}; +import {focusRing, iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Header, ListBox, ListBoxItem, ListBoxSection} from 'react-aria-components'; +import InfoCircle from '@react-spectrum/s2/icons/InfoCircle'; +// eslint-disable-next-line monorepo/no-internal-import +import NoSearchResults from '@react-spectrum/s2/illustrations/linear/NoSearchResults'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import Similar from '@react-spectrum/s2/icons/Similar'; + +const itemStyle = style({ + ...focusRing(), + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: 8, + padding: 8, + backgroundColor: { + default: 'gray-50', + isHovered: 'gray-100', + isFocused: 'gray-100', + isSelected: 'neutral' + }, + color: { + default: 'body', + isSelected: 'gray-25' + }, + font: 'ui-sm', + borderRadius: 'default', + transition: 'default', + cursor: 'default', + size: 'full' +}); + +const swatchStyle = style({ + size: 20, + borderRadius: 'sm', + borderWidth: 1, + borderColor: 'gray-1000/15', + borderStyle: 'solid', + flexShrink: 0, + forcedColorAdjust: 'none' +}); + +const listBoxStyle = style({ + width: 'full', + display: 'flex', + flexDirection: 'column', + gap: 24 +}); + +const sectionStyle = style({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', + gap: 32, + padding: 8, + marginBottom: 16 +}); + +const headerStyle = style({ + font: 'heading-sm', + gridColumnStart: 1, + gridColumnEnd: -1, + marginBottom: 4 +}); + +const backgroundSwatches: Record = { + 'black': colorSwatch('black'), + 'white': colorSwatch('white'), + 'base': colorSwatch('base'), + 'layer-1': colorSwatch('layer-1'), + 'layer-2': colorSwatch('layer-2'), + 'pasteboard': colorSwatch('pasteboard'), + 'elevated': colorSwatch('elevated'), + 'accent': colorSwatch('accent'), + 'accent-subtle': colorSwatch('accent-subtle'), + 'neutral': colorSwatch('neutral'), + 'neutral-subdued': colorSwatch('neutral-subdued'), + 'neutral-subtle': colorSwatch('neutral-subtle'), + 'negative': colorSwatch('negative'), + 'negative-subtle': colorSwatch('negative-subtle'), + 'informative': colorSwatch('informative'), + 'informative-subtle': colorSwatch('informative-subtle'), + 'positive': colorSwatch('positive'), + 'positive-subtle': colorSwatch('positive-subtle'), + 'notice': colorSwatch('notice'), + 'notice-subtle': colorSwatch('notice-subtle'), + 'gray': colorSwatch('gray'), + 'gray-subtle': colorSwatch('gray-subtle'), + 'red': colorSwatch('red'), + 'red-subtle': colorSwatch('red-subtle'), + 'orange': colorSwatch('orange'), + 'orange-subtle': colorSwatch('orange-subtle'), + 'yellow': colorSwatch('yellow'), + 'yellow-subtle': colorSwatch('yellow-subtle'), + 'chartreuse': colorSwatch('chartreuse'), + 'chartreuse-subtle': colorSwatch('chartreuse-subtle'), + 'celery': colorSwatch('celery'), + 'celery-subtle': colorSwatch('celery-subtle'), + 'green': colorSwatch('green'), + 'green-subtle': colorSwatch('green-subtle'), + 'seafoam': colorSwatch('seafoam'), + 'seafoam-subtle': colorSwatch('seafoam-subtle'), + 'cyan': colorSwatch('cyan'), + 'cyan-subtle': colorSwatch('cyan-subtle'), + 'blue': colorSwatch('blue'), + 'blue-subtle': colorSwatch('blue-subtle'), + 'indigo': colorSwatch('indigo'), + 'indigo-subtle': colorSwatch('indigo-subtle'), + 'purple': colorSwatch('purple'), + 'purple-subtle': colorSwatch('purple-subtle'), + 'fuchsia': colorSwatch('fuchsia'), + 'fuchsia-subtle': colorSwatch('fuchsia-subtle'), + 'magenta': colorSwatch('magenta'), + 'magenta-subtle': colorSwatch('magenta-subtle'), + 'pink': colorSwatch('pink'), + 'pink-subtle': colorSwatch('pink-subtle'), + 'turquoise': colorSwatch('turquoise'), + 'turquoise-subtle': colorSwatch('turquoise-subtle'), + 'cinnamon': colorSwatch('cinnamon'), + 'cinnamon-subtle': colorSwatch('cinnamon-subtle'), + 'brown': colorSwatch('brown'), + 'brown-subtle': colorSwatch('brown-subtle'), + 'silver': colorSwatch('silver'), + 'silver-subtle': colorSwatch('silver-subtle'), + 'disabled': colorSwatch('disabled') +}; + +const textSwatches: Record = { + 'black': colorSwatch('black', 'color'), + 'white': colorSwatch('white', 'color'), + 'accent': colorSwatch('accent', 'color'), + 'neutral': colorSwatch('neutral', 'color'), + 'neutral-subdued': colorSwatch('neutral-subdued', 'color'), + 'negative': colorSwatch('negative', 'color'), + 'disabled': colorSwatch('disabled', 'color'), + 'heading': colorSwatch('heading', 'color'), + 'title': colorSwatch('title', 'color'), + 'body': colorSwatch('body', 'color'), + 'detail': colorSwatch('detail', 'color'), + 'code': colorSwatch('code', 'color') +}; + +const accentScale = getColorScale('accent-color'); +const informativeScale = getColorScale('informative-color'); +const negativeScale = getColorScale('negative-color'); +const noticeScale = getColorScale('notice-color'); +const positiveScale = getColorScale('positive-color'); +const grayScale = getColorScale('gray'); +const blueScale = getColorScale('blue'); +const redScale = getColorScale('red'); +const orangeScale = getColorScale('orange'); +const yellowScale = getColorScale('yellow'); +const chartreuseScale = getColorScale('chartreuse'); +const celeryScale = getColorScale('celery'); +const greenScale = getColorScale('green'); +const seafoamScale = getColorScale('seafoam'); +const cyanScale = getColorScale('cyan'); +const indigoScale = getColorScale('indigo'); +const purpleScale = getColorScale('purple'); +const fuchsiaScale = getColorScale('fuchsia'); +const magentaScale = getColorScale('magenta'); +const pinkScale = getColorScale('pink'); +const turquoiseScale = getColorScale('turquoise'); +const brownScale = getColorScale('brown'); +const silverScale = getColorScale('silver'); +const cinnamonScale = getColorScale('cinnamon'); + +const scaleSwatches: Record = { + ...Object.fromEntries(accentScale), + ...Object.fromEntries(informativeScale), + ...Object.fromEntries(negativeScale), + ...Object.fromEntries(noticeScale), + ...Object.fromEntries(positiveScale), + ...Object.fromEntries(grayScale), + ...Object.fromEntries(blueScale), + ...Object.fromEntries(redScale), + ...Object.fromEntries(orangeScale), + ...Object.fromEntries(yellowScale), + ...Object.fromEntries(chartreuseScale), + ...Object.fromEntries(celeryScale), + ...Object.fromEntries(greenScale), + ...Object.fromEntries(seafoamScale), + ...Object.fromEntries(cyanScale), + ...Object.fromEntries(indigoScale), + ...Object.fromEntries(purpleScale), + ...Object.fromEntries(fuchsiaScale), + ...Object.fromEntries(magentaScale), + ...Object.fromEntries(pinkScale), + ...Object.fromEntries(turquoiseScale), + ...Object.fromEntries(brownScale), + ...Object.fromEntries(silverScale), + ...Object.fromEntries(cinnamonScale) +}; + + +export function CopyInfoMessage() { + return ( +
+
+ + Press a color to copy its name. +
+ + See styling for more information. + +
+ ); +} + +interface ColorSearchViewProps { + filteredItems: Array<{ + id: string, + name: string, + items: Array<{name: string, section: string, type: string}> + }>, + /** Names of colors that exactly match the searched hex value. */ + exactMatches?: Set, + /** Names of the closest matching colors when no exact matches exist. */ + closestMatches?: Set +} + +export function ColorSearchView({filteredItems, exactMatches = new Set(), closestMatches = new Set()}: ColorSearchViewProps) { + const [copiedId, setCopiedId] = useState(null); + const timeout = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; + }, []); + + const handleCopyColor = useCallback((colorName: string, itemId: string) => { + if (timeout.current) { + clearTimeout(timeout.current); + } + navigator.clipboard.writeText(colorName).then(() => { + setCopiedId(itemId); + timeout.current = setTimeout(() => setCopiedId(null), 2000); + }).catch(() => { + // noop + }); + }, []); + + const sections = filteredItems.map(section => ({ + ...section, + items: section.items.map(item => ({ + ...item, + id: `${section.id}-${item.name}` + })) + })).filter(section => section.items.length > 0); + + if (sections.length === 0) { + return ( + + + No results + Try a different search term. + + ); + } + + return ( +
+ + { + for (const section of sections) { + const item = section.items.find(item => item.id === key.toString()); + if (item) { + handleCopyColor(item.name, item.id); + break; + } + } + }} + layout="grid" + className={listBoxStyle} + dependencies={[copiedId, exactMatches, closestMatches]} + items={sections}> + {section => ( + +
{section.name}
+ {section.items.map(item => ( + + ))} +
+ )} +
+
+ ); +} + +interface ColorItemProps { + item: {id: string, name: string, type?: string, scale?: string}, + sectionId: string, + isCopied?: boolean, + isBestMatch?: boolean, + isExactMatch?: boolean +} + +function ColorItem({item, sectionId, isCopied = false, isBestMatch = false, isExactMatch = false}: ColorItemProps) { + let ref = useRef(null); + + // Look up the pre-generated swatch class for this color + const swatchClass = sectionId === 'text' + ? textSwatches[item.name] + : backgroundSwatches[item.name] || scaleSwatches[item.name] || ''; + + return ( + +
+
+ +
+
+ {isBestMatch && !isCopied ? ( + + {isExactMatch ? : } + {item.name} + + ) : ( +
+ + + {item.name} + + + + Copied! + +
+ )} +
+ ); +} diff --git a/packages/dev/s2-docs/src/MobileSearchMenu.tsx b/packages/dev/s2-docs/src/MobileSearchMenu.tsx index ac95c20f36b..5cf646d8be4 100644 --- a/packages/dev/s2-docs/src/MobileSearchMenu.tsx +++ b/packages/dev/s2-docs/src/MobileSearchMenu.tsx @@ -3,11 +3,14 @@ import {Autocomplete, OverlayTriggerStateContext, Provider, Dialog as RACDialog, DialogProps as RACDialogProps, Tab as RACTab, TabList as RACTabList, TabPanel as RACTabPanel, TabPanelProps as RACTabPanelProps, TabProps as RACTabProps, Tabs as RACTabs, SelectionIndicator, TabRenderProps} from 'react-aria-components'; import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {CloseButton, SearchField, TextContext} from '@react-spectrum/s2'; +import {ColorSearchSkeleton} from './colorSearchData'; import {ComponentCardView} from './ComponentCardView'; import { getResourceTags, + LazyColorSearchView, LazyIconSearchView, SearchEmptyState, + useFilteredColors, useSearchMenuState } from './searchUtils'; import {IconSearchSkeleton, useIconFilter} from './IconSearchView'; @@ -237,6 +240,9 @@ function MobileNav({initialTag}: {initialTag?: string}) { isOpen }); + const filteredColors = useFilteredColors(searchValue); + const isColorsSelected = selectedSection === 'colors'; + let handleSearchFocus = () => { setSearchFocused(true); }; @@ -327,17 +333,47 @@ function MobileNav({initialTag}: {initialTag?: string}) { contentClassName={style({display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 8, marginX: 0})} /> -
- {showIcons ? ( +
+ {showIcons && ( }> - + - ) : ( + )} + {!showIcons && isColorsSelected && library.id === 'react-spectrum' && ( +
+ }> + + +
+ )} + {!showIcons && (!isColorsSelected || library.id !== 'react-spectrum') && ( { + onAction={key => { if (key === currentPage.url) { overlayTriggerState?.close(); } @@ -345,7 +381,11 @@ function MobileNav({initialTag}: {initialTag?: string}) { items={library.id === selectedLibrary ? selectedItems : []} ariaLabel="Pages" size="S" - renderEmptyState={() => } /> + renderEmptyState={() => ( + + )} /> )}
diff --git a/packages/dev/s2-docs/src/SearchMenu.tsx b/packages/dev/s2-docs/src/SearchMenu.tsx index f8390175028..155e9bb0c68 100644 --- a/packages/dev/s2-docs/src/SearchMenu.tsx +++ b/packages/dev/s2-docs/src/SearchMenu.tsx @@ -3,11 +3,14 @@ import {ActionButton, SearchField} from '@react-spectrum/s2'; import {Autocomplete, Dialog, Key, OverlayTriggerStateContext, Provider} from 'react-aria-components'; import Close from '@react-spectrum/s2/icons/Close'; +import {ColorSearchSkeleton} from './colorSearchData'; import {ComponentCardView} from './ComponentCardView'; import { getResourceTags, + LazyColorSearchView, LazyIconSearchView, SearchEmptyState, + useFilteredColors, useSearchMenuState } from './searchUtils'; import {IconSearchSkeleton, useIconFilter} from './IconSearchView'; @@ -88,6 +91,8 @@ export function SearchMenu(props: SearchMenuProps) { isOpen: isSearchOpen }); + const filteredColors = useFilteredColors(searchValue); + // Auto-focus search field when menu opens useEffect(() => { if (isSearchOpen) { @@ -188,7 +193,15 @@ export function SearchMenu(props: SearchMenuProps) { listBoxClassName={style({flexGrow: 1, overflow: 'auto', width: '100%', scrollPaddingY: 4})} />
- ) : ( + ) : null} + {selectedTagId === 'colors' && ( +
+ }> + + +
+ )} + {selectedTagId !== 'icons' && selectedTagId !== 'colors' && ( [k, colorSwatch.call(this, k, 'backgroundColor', size)]); @@ -8,5 +10,187 @@ export function getColorScale(this: MacroContext | void, name: string, size: any export function colorSwatch(this: MacroContext | void, color: string, type = 'backgroundColor', size: any = 20): string { // @ts-ignore - return style.call(this, {'--v': {type, value: color}, backgroundColor: '--v', width: size, aspectRatio: 'square', borderRadius: 'sm', borderWidth: 1, borderColor: 'gray-1000/15', borderStyle: 'solid'}); + return style.call(this, {'--v': {type, value: color}, backgroundColor: '--v', width: size, aspectRatio: 'square', borderRadius: 'sm', borderWidth: 1, borderColor: 'gray-1000/15', borderStyle: 'solid', forcedColorAdjust: 'none'}); +} + +/** + * Gets the RGB values for a color token. + * @param tokenName - The token name to look up. + * @param mode - 'light' or 'dark' mode. + * @returns [r, g, b] or null if not found. + */ +function getColorRgb(tokenName: string, mode: 'light' | 'dark' = 'light'): [number, number, number] | null { + const token = (tokens as any)[tokenName]; + if (!token) {return null;} + + let value: string | undefined; + if (token.sets?.[mode]?.value) { + value = token.sets[mode].value; + } else if (token.sets?.light?.value) { + // Fallback to light + value = token.sets.light.value; + } else if (token.value) { + value = token.value; + } + + if (!value) {return null;} + + // Parse rgb(r, g, b) or rgba(r, g, b, a) + const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (match) { + return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)]; + } + return null; +} + +// Background color token mappings (semantic and base layer colors) +const backgroundColorTokens: Record = { + 'base': 'background-base-color', + 'layer-1': 'background-layer-1-color', + 'layer-2': 'background-layer-2-color', + 'pasteboard': 'gray-100', + 'elevated': 'background-base-color', + 'accent': 'accent-background-color-default', + 'accent-subtle': 'accent-subtle-background-color-default', + 'neutral': 'neutral-background-color-default', + 'neutral-subdued': 'neutral-subdued-background-color-default', + 'neutral-subtle': 'neutral-subtle-background-color-default', + 'negative': 'negative-background-color-default', + 'negative-subtle': 'negative-subtle-background-color-default', + 'informative': 'informative-background-color-default', + 'informative-subtle': 'informative-subtle-background-color-default', + 'positive': 'positive-background-color-default', + 'positive-subtle': 'positive-subtle-background-color-default', + 'notice': 'notice-background-color-default', + 'notice-subtle': 'notice-subtle-background-color-default', + 'gray': 'gray-background-color-default', + 'gray-subtle': 'gray-subtle-background-color-default', + 'disabled': 'disabled-background-color', + // Colored background colors use {color}-background-color-default tokens + 'red': 'red-background-color-default', + 'red-subtle': 'red-subtle-background-color-default', + 'orange': 'orange-background-color-default', + 'orange-subtle': 'orange-subtle-background-color-default', + 'yellow': 'yellow-background-color-default', + 'yellow-subtle': 'yellow-subtle-background-color-default', + 'chartreuse': 'chartreuse-background-color-default', + 'chartreuse-subtle': 'chartreuse-subtle-background-color-default', + 'celery': 'celery-background-color-default', + 'celery-subtle': 'celery-subtle-background-color-default', + 'green': 'green-background-color-default', + 'green-subtle': 'green-subtle-background-color-default', + 'seafoam': 'seafoam-background-color-default', + 'seafoam-subtle': 'seafoam-subtle-background-color-default', + 'cyan': 'cyan-background-color-default', + 'cyan-subtle': 'cyan-subtle-background-color-default', + 'blue': 'blue-background-color-default', + 'blue-subtle': 'blue-subtle-background-color-default', + 'indigo': 'indigo-background-color-default', + 'indigo-subtle': 'indigo-subtle-background-color-default', + 'purple': 'purple-background-color-default', + 'purple-subtle': 'purple-subtle-background-color-default', + 'fuchsia': 'fuchsia-background-color-default', + 'fuchsia-subtle': 'fuchsia-subtle-background-color-default', + 'magenta': 'magenta-background-color-default', + 'magenta-subtle': 'magenta-subtle-background-color-default', + 'pink': 'pink-background-color-default', + 'pink-subtle': 'pink-subtle-background-color-default', + 'turquoise': 'turquoise-background-color-default', + 'turquoise-subtle': 'turquoise-subtle-background-color-default', + 'cinnamon': 'cinnamon-background-color-default', + 'cinnamon-subtle': 'cinnamon-subtle-background-color-default', + 'brown': 'brown-background-color-default', + 'brown-subtle': 'brown-subtle-background-color-default', + 'silver': 'silver-background-color-default', + 'silver-subtle': 'silver-subtle-background-color-default' +}; + +// Text color token mappings +const textColorTokens: Record = { + 'accent': 'accent-content-color-default', + 'neutral': 'neutral-content-color-default', + 'neutral-subdued': 'neutral-subdued-content-color-default', + 'negative': 'negative-content-color-default', + 'disabled': 'disabled-content-color', + 'heading': 'heading-color', + 'title': 'title-color', + 'body': 'body-color', + 'detail': 'detail-color', + 'code': 'code-color' +}; + +// Semantic and global color scale definitions +const semanticScales = ['accent-color', 'informative-color', 'negative-color', 'notice-color', 'positive-color']; +const scaleValues = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600]; +const globalScales = ['gray', 'blue', 'red', 'orange', 'yellow', 'chartreuse', 'celery', 'green', 'seafoam', 'cyan', 'indigo', 'purple', 'fuchsia', 'magenta', 'pink', 'turquoise', 'brown', 'silver', 'cinnamon']; +const grayValues = [25, 50, 75, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]; +const standardValues = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600]; + +/** + * Generates a color map for a specific mode (light or dark). + */ +function buildColorMapForMode(mode: 'light' | 'dark'): Record { + const colorMap: Record = {}; + + // Add static colors + colorMap['black'] = [0, 0, 0]; + colorMap['white'] = [255, 255, 255]; + + // Add background colors + for (const [name, tokenName] of Object.entries(backgroundColorTokens)) { + const rgb = getColorRgb(tokenName, mode); + if (rgb) { + colorMap[name] = rgb; + } + } + + // Add text colors + for (const [name, tokenName] of Object.entries(textColorTokens)) { + const rgb = getColorRgb(tokenName, mode); + if (rgb) { + colorMap[name] = rgb; + } + } + + // Add semantic color scales + for (const scale of semanticScales) { + for (const value of scaleValues) { + const tokenName = `${scale}-${value}`; + const displayName = `${scale.replace('-color', '')}-${value}`; + const rgb = getColorRgb(tokenName, mode); + if (rgb) { + colorMap[displayName] = rgb; + } + } + } + + // Add global color scales + for (const scale of globalScales) { + const values = scale === 'gray' ? grayValues : standardValues; + for (const value of values) { + const tokenName = `${scale}-${value}`; + const rgb = getColorRgb(tokenName, mode); + if (rgb) { + colorMap[tokenName] = rgb; + } + } + } + + return colorMap; +} + +export interface ColorHexMaps { + light: Record, + dark: Record +} + +/** + * Generates mappings of color names to their RGB values for hex code matching. + * Returns both light and dark mode maps for runtime selection. + */ +export function getColorHexMap(): ColorHexMaps { + return { + light: buildColorMapForMode('light'), + dark: buildColorMapForMode('dark') + }; } diff --git a/packages/dev/s2-docs/src/colorSearchData.tsx b/packages/dev/s2-docs/src/colorSearchData.tsx new file mode 100644 index 00000000000..2200a33ffb7 --- /dev/null +++ b/packages/dev/s2-docs/src/colorSearchData.tsx @@ -0,0 +1,226 @@ +'use client'; + +import {CopyInfoMessage} from './ColorSearchView'; +import {getColorHexMap} from './color.macro' with {type: 'macro'}; +import {Header, ListBox, ListBoxItem, ListBoxSection} from 'react-aria-components'; +import React, {useMemo, useRef} from 'react'; +import {Skeleton, Text} from '@react-spectrum/s2'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +export const colorHexMaps = getColorHexMap(); + +const backgroundColors = [ + 'black', 'white', + 'base', 'layer-1', 'layer-2', 'pasteboard', 'elevated', + 'accent', 'accent-subtle', 'neutral', 'neutral-subdued', 'neutral-subtle', + 'negative', 'negative-subtle', 'informative', 'informative-subtle', + 'positive', 'positive-subtle', 'notice', 'notice-subtle', + 'gray', 'gray-subtle', 'red', 'red-subtle', 'orange', 'orange-subtle', + 'yellow', 'yellow-subtle', 'chartreuse', 'chartreuse-subtle', + 'celery', 'celery-subtle', 'green', 'green-subtle', 'seafoam', 'seafoam-subtle', + 'cyan', 'cyan-subtle', 'blue', 'blue-subtle', 'indigo', 'indigo-subtle', + 'purple', 'purple-subtle', 'fuchsia', 'fuchsia-subtle', 'magenta', 'magenta-subtle', + 'pink', 'pink-subtle', 'turquoise', 'turquoise-subtle', + 'cinnamon', 'cinnamon-subtle', 'brown', 'brown-subtle', + 'silver', 'silver-subtle', 'disabled' +]; + +const textColors = [ + 'black', 'white', + 'accent', 'neutral', 'neutral-subdued', 'negative', 'disabled', + 'heading', 'title', 'body', 'detail', 'code' +]; + +const semanticColorRanges: Record = { + 'accent-color': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'informative-color': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'negative-color': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'notice-color': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'positive-color': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600] +}; + +const globalColorRanges: Record = { + 'gray': [25, 50, 75, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + 'blue': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'red': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'orange': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'yellow': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'chartreuse': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'celery': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'green': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'seafoam': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'cyan': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'indigo': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'purple': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'fuchsia': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'magenta': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'pink': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'turquoise': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'brown': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'silver': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600], + 'cinnamon': [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600] +}; + +const semanticColors = Object.entries(semanticColorRanges).flatMap(([scale, ranges]) => + ranges.map(value => ({name: `${scale.replace('-color', '')}-${value}`, section: 'Semantic colors', type: 'backgroundColor'})) +); + +const globalColors = Object.entries(globalColorRanges).flatMap(([scale, ranges]) => + ranges.map(value => ({name: `${scale}-${value}`, section: 'Global colors', type: 'backgroundColor'})) +); + +export const colorSections = [ + { + id: 'background', + name: 'Background colors', + items: backgroundColors.map(name => ({name, section: 'Background colors', type: 'backgroundColor'})) + }, + { + id: 'text', + name: 'Text colors', + items: textColors.map(name => ({name, section: 'Text colors', type: 'color'})) + }, + { + id: 'semantic', + name: 'Semantic colors', + items: semanticColors + }, + { + id: 'global', + name: 'Global colors', + items: globalColors + } +]; + +const skeletonItemStyle = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: 8, + padding: 8, + backgroundColor: { + default: 'gray-50', + isHovered: 'gray-100', + isFocused: 'gray-100', + isSelected: 'neutral' + }, + color: { + default: 'body', + isSelected: 'gray-25' + }, + font: 'ui-sm', + borderRadius: 'default', + transition: 'default', + cursor: 'default', + size: 'full' +}); + +const skeletonSwatchStyle = style({ + size: 20, + borderRadius: 'sm', + borderWidth: 1, + borderColor: 'gray-1000/15', + borderStyle: 'solid', + flexShrink: 0, + forcedColorAdjust: 'none' +}); + +const listBoxStyle = style({ + width: 'full', + display: 'flex', + flexDirection: 'column', + gap: 24 +}); + +const sectionStyle = style({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', + gap: 32, + padding: 8, + marginBottom: 16 +}); + +const headerStyle = style({ + font: 'heading-sm', + gridColumnStart: 1, + gridColumnEnd: -1, + marginBottom: 4 +}); + +function SkeletonColorItem({item}: {item: {id: string}}) { + const ref = useRef(null); + return ( + +
+
+ Color Name +
+ + ); +} + +export function ColorSearchSkeleton() { + const mockSections = useMemo(() => [ + { + id: 'background', + name: 'Background colors', + items: Array.from({length: 59}, (_, i) => ({id: `skeleton-background-${i}`})) + }, + { + id: 'text', + name: 'Text colors', + items: Array.from({length: 12}, (_, i) => ({id: `skeleton-text-${i}`})) + }, + { + id: 'semantic', + name: 'Semantic colors', + items: Array.from({length: 80}, (_, i) => ({id: `skeleton-semantic-${i}`})) + }, + { + id: 'global', + name: 'Global colors', + items: Array.from({length: 301}, (_, i) => ({id: `skeleton-global-${i}`})) + } + ], []); + + return ( +
+ + + + {section => ( + +
{section.name}
+ {section.items.map(item => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/packages/dev/s2-docs/src/searchUtils.tsx b/packages/dev/s2-docs/src/searchUtils.tsx index 6a322fbb0fb..63809f10851 100644 --- a/packages/dev/s2-docs/src/searchUtils.tsx +++ b/packages/dev/s2-docs/src/searchUtils.tsx @@ -1,5 +1,6 @@ 'use client'; +import {colorHexMaps, colorSections} from './colorSearchData'; import {Content, Heading, IllustratedMessage} from '@react-spectrum/s2'; import {getBaseUrl} from './pageUtils'; // @ts-ignore @@ -14,6 +15,91 @@ import NoSearchResults from '@react-spectrum/s2/illustrations/linear/NoSearchRes import {Page} from '@parcel/rsc'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {useSettings} from './SettingsContext'; + +/** + * Parses a hex color string to RGB values. + * Supports #RGB, #RRGGBB formats (with or without #). + */ +function parseHexColor(hex: string): [number, number, number] | null { + // Remove # if present and trim whitespace + const cleanHex = hex.replace(/^#/, '').trim(); + + if (!/^[0-9A-Fa-f]+$/.test(cleanHex)) { + return null; + } + + let r: number, g: number, b: number; + + if (cleanHex.length === 3) { + // #RGB format + r = parseInt(cleanHex[0] + cleanHex[0], 16); + g = parseInt(cleanHex[1] + cleanHex[1], 16); + b = parseInt(cleanHex[2] + cleanHex[2], 16); + } else if (cleanHex.length === 6) { + // #RRGGBB format + r = parseInt(cleanHex.slice(0, 2), 16); + g = parseInt(cleanHex.slice(2, 4), 16); + b = parseInt(cleanHex.slice(4, 6), 16); + } else { + return null; + } + + return [r, g, b]; +} + +/** + * Checks if a search string looks like a hex color code. + */ +function isHexColorSearch(searchValue: string): boolean { + const trimmed = searchValue.trim(); + // Must start with # or be a valid 3 or 6 character hex string + if (trimmed.startsWith('#')) { + const hex = trimmed.slice(1); + return /^[0-9A-Fa-f]{3}$/.test(hex) || /^[0-9A-Fa-f]{6}$/.test(hex); + } + // Also match without # if it's exactly 3 or 6 hex characters + return /^[0-9A-Fa-f]{6}$/.test(trimmed); +} + +/** + * Calculates the color distance between two RGB colors using weighted Euclidean distance. + * Uses weights that better approximate human color perception. + * Reference: https://en.wikipedia.org/wiki/Color_difference. + */ +function colorDistance(rgb1: [number, number, number], rgb2: [number, number, number]): number { + const [r1, g1, b1] = rgb1; + const [r2, g2, b2] = rgb2; + const rMean = (r1 + r2) / 2; + const dr = r1 - r2; + const dg = g1 - g2; + const db = b1 - b2; + const weightR = 2 + rMean / 256; + const weightG = 4; + const weightB = 2 + (255 - rMean) / 256; + + return Math.sqrt(weightR * dr * dr + weightG * dg * dg + weightB * db * db); +} + +/** + * Finds the closest colors to a given hex code from the color map. + * Returns an array of color names sorted by distance (closest first). + */ +function findClosestColors( + targetRgb: [number, number, number], + colorHexMap: Record, + maxResults = 10 +): Array<{name: string, distance: number}> { + const results: Array<{name: string, distance: number}> = []; + + for (const [name, rgb] of Object.entries(colorHexMap)) { + const distance = colorDistance(targetRgb, rgb as [number, number, number]); + results.push({name, distance}); + } + + // Sort by distance and return top results + return results.sort((a, b) => a.distance - b.distance).slice(0, maxResults); +} export interface SearchableItem { name: string, @@ -362,7 +448,11 @@ export function getOrderedLibraries(currentPage: Page) { export function getResourceTags(library: Library): Tag[] { if (library === 'react-spectrum') { - return [{id: 'icons', name: 'Icons'}, {id: 'v3', name: 'React Spectrum v3', href: getBaseUrl('s2') + '/v3/getting-started.html'}]; + return [ + {id: 'icons', name: 'Icons'}, + {id: 'colors', name: 'Colors'}, + {id: 'v3', name: 'React Spectrum v3', href: getBaseUrl('s2') + '/v3/getting-started.html'} + ]; } return []; } @@ -377,6 +467,104 @@ export function useFilteredIcons(searchValue: string) { }, [searchValue, iconFilter]); } +export interface ColorSearchResult { + sections: typeof colorSections, + /** Names of colors that exactly match the searched hex (distance = 0). */ + exactMatches: Set, + /** Names of the closest matching colors when no exact matches exist. */ + closestMatches: Set +} + +export function useFilteredColors(searchValue: string): ColorSearchResult { + const {colorScheme} = useSettings(); + const colorHexMap = colorHexMaps[colorScheme]; + + return useMemo(() => { + if (!searchValue.trim()) { + return {sections: colorSections, exactMatches: new Set(), closestMatches: new Set()}; + } + + // Check if user is searching for a hex color + if (isHexColorSearch(searchValue)) { + const targetRgb = parseHexColor(searchValue); + if (targetRgb) { + const closestColors = findClosestColors(targetRgb, colorHexMap, 20); + const closestColorNames = new Set(closestColors.map(c => c.name)); + + // Find all exact matches (distance === 0) + const exactMatches = new Set( + closestColors.filter(c => c.distance === 0).map(c => c.name) + ); + + // If no exact matches, find all colors with the minimum distance + let closestMatches = new Set(); + if (exactMatches.size === 0 && closestColors.length > 0) { + const minDistance = closestColors[0].distance; + closestMatches = new Set( + closestColors.filter(c => c.distance === minDistance).map(c => c.name) + ); + } + + // Filter sections to only include colors that are in the closest matches + const filteredSections = colorSections.map(section => ({ + ...section, + items: section.items.filter(item => closestColorNames.has(item.name)) + })).filter(section => section.items.length > 0); + + // If we found matches, sort items within each section by distance + // and sort sections by the minimum distance of their items (closest match section first) + if (filteredSections.length > 0) { + const distanceMap = new Map(closestColors.map(c => [c.name, c.distance])); + + // Sort items within each section and calculate section min distance + const sectionsWithSortedItems = filteredSections.map(section => { + const sortedItems = [...section.items].sort((a, b) => { + const distA = distanceMap.get(a.name) ?? Infinity; + const distB = distanceMap.get(b.name) ?? Infinity; + return distA - distB; + }); + // Min distance is the distance of the first (closest) item + const minDistance = sortedItems.length > 0 + ? (distanceMap.get(sortedItems[0].name) ?? Infinity) + : Infinity; + return { + ...section, + items: sortedItems, + minDistance + }; + }); + + // Sort sections by minimum distance (section with closest color first) + sectionsWithSortedItems.sort((a, b) => a.minDistance - b.minDistance); + + // Remove the temporary minDistance property + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const sortedSections = sectionsWithSortedItems.map(({minDistance, ...section}) => section); + + return { + sections: sortedSections, + exactMatches, + closestMatches + }; + } + } + } + + // Default text search + const searchLower = searchValue.toLowerCase(); + return { + sections: colorSections.map(section => ({ + ...section, + items: section.items.filter(item => + item.name.toLowerCase().includes(searchLower) + ) + })).filter(section => section.items.length > 0), + exactMatches: new Set(), + closestMatches: new Set() + }; + }, [searchValue, colorHexMap]); +} + export function useSearchTagSelection( searchValue: string, sectionTags: Tag[], @@ -518,6 +706,10 @@ export const LazyIconSearchView = React.lazy(() => import('./IconSearchView').then(({IconSearchView}) => ({default: IconSearchView})) ); +export const LazyColorSearchView = React.lazy(() => + import('./ColorSearchView').then(({ColorSearchView}) => ({default: ColorSearchView})) +); + export interface SearchMenuStateOptions { pages: Page[], currentPage: Page, @@ -644,6 +836,9 @@ export function useSearchMenuState(options: SearchMenuStateOptions): SearchMenuS // Helper to get placeholder text based on selected resource tag const getPlaceholderText = useCallback((libraryLabel: string) => { const selectedResourceTag = resourceTags.find(tag => tag.id === selectedTagId); + if (selectedTagId === 'colors') { + return 'Search color names or hex values'; + } return selectedResourceTag ? `Search ${selectedResourceTag.name}` : `Search ${libraryLabel}`;