diff --git a/packages/dev/s2-docs/src/ColorSearchView.tsx b/packages/dev/s2-docs/src/ColorSearchView.tsx new file mode 100644 index 00000000000..42bc8bd3f68 --- /dev/null +++ b/packages/dev/s2-docs/src/ColorSearchView.tsx @@ -0,0 +1,466 @@ +'use client'; + +import CheckmarkCircle from '@react-spectrum/s2/icons/CheckmarkCircle'; +import {colorSwatch, getColorScale} from './color.macro' with {type: 'macro'}; +import {Content, Heading, IllustratedMessage, pressScale, Skeleton, Text} from '@react-spectrum/s2'; +import {focusRing, iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {GridLayout, ListBox, ListBoxItem, Size, Virtualizer} 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, useMemo, useRef, useState} from 'react'; + +const backgroundColors = [ + '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 = [ + '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 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 +}); + +const backgroundSwatches = { + '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 = { + '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 = { + ...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) +}; + + +function CopyInfoMessage() { + return ( +
+ + Press a color to copy its name +
+ ); +} + +interface ColorSearchViewProps { + filteredItems: typeof colorSections +} + +export function ColorSearchView({filteredItems}: 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 ( +
+ + {sections.map(section => ( +
+ {section.name} + + { + const item = section.items.find(item => item.id === key.toString()); + if (item) { + handleCopyColor(item.name, item.id); + } + }} + items={section.items} + layout="grid" + className={style({width: 'full'})} + dependencies={[copiedId]}> + {item => ( + + )} + + +
+ ))} +
+ ); +} + +function ColorItem({item, sectionId, isCopied = false}: {item: {id: string, name: string, type?: string, scale?: string}, sectionId: string, isCopied?: boolean}) { + 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 ( + + {isCopied ? ( +
+ +
+ ) : ( +
+ )} +
+ {isCopied ? 'Copied!' : item.name} +
+ + ); +} + +function SkeletonColorItem({item}: {item: {id: string}}) { + const ref = useRef(null); + + return ( + +
+
+ Name +
+ + ); +} + +export function ColorSearchSkeleton() { + const mockSections = useMemo(() => [ + { + id: 'background', + name: 'Background colors', + items: Array.from({length: 20}, (_, i) => ({id: `skeleton-background-${i}`})) + }, + { + id: 'text', + name: 'Text colors', + items: Array.from({length: 10}, (_, i) => ({id: `skeleton-text-${i}`})) + }, + { + id: 'semantic', + name: 'Semantic colors', + items: Array.from({length: 30}, (_, i) => ({id: `skeleton-semantic-${i}`})) + }, + { + id: 'global', + name: 'Global colors', + items: Array.from({length: 40}, (_, i) => ({id: `skeleton-global-${i}`})) + } + ], []); + + return ( + +
+ {mockSections.map(section => ( +
+ {section.name} + + + {(item) => } + + +
+ ))} +
+
+ ); +} diff --git a/packages/dev/s2-docs/src/SearchMenu.tsx b/packages/dev/s2-docs/src/SearchMenu.tsx index 001a0bdac1e..267b82bda11 100644 --- a/packages/dev/s2-docs/src/SearchMenu.tsx +++ b/packages/dev/s2-docs/src/SearchMenu.tsx @@ -1,8 +1,9 @@ 'use client'; import {ActionButton, Content, Heading, IllustratedMessage, SearchField, Tag, TagGroup} from '@react-spectrum/s2'; -import {Autocomplete, Dialog, Key, OverlayTriggerStateContext, Provider, Separator as RACSeparator} from 'react-aria-components'; +import {Autocomplete, Dialog, Key, OverlayTriggerStateContext, Provider, Separator as RACSeparator, useFilter} from 'react-aria-components'; import Close from '@react-spectrum/s2/icons/Close'; +import {ColorSearchSkeleton, ColorSearchView, colorSections} from './ColorSearchView'; import {ComponentCardView} from './ComponentCardView'; import {getLibraryFromPage, getLibraryFromUrl} from './library'; import {iconList, IconSearchSkeleton, useIconFilter} from './IconSearchView'; @@ -11,7 +12,7 @@ import {type Library, TAB_DEFS} from './constants'; import NoSearchResults from '@react-spectrum/s2/illustrations/linear/NoSearchResults'; // @ts-ignore import {Page} from '@parcel/rsc'; -import React, {CSSProperties, lazy, Suspense, useEffect, useMemo, useRef, useState} from 'react'; +import React, {CSSProperties, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {SelectableCollectionContext} from '../../../react-aria-components/src/RSPContexts'; import {style} from '@react-spectrum/s2/style' with { type: 'macro' }; import {Tab, TabList, TabPanel, Tabs} from './Tabs'; @@ -131,9 +132,14 @@ export function SearchMenu(props: SearchMenuProps) { }, [transformedComponents]); const sectionTags = useMemo(() => sections, [sections]); - const iconTag = useMemo(() => { + + // Resources search configuration (Icons, Colors, etc.) + const resourcesTags = useMemo(() => { if (selectedLibrary === 'react-spectrum') { - return [{id: 'icons', name: 'Icons'}]; + return [ + {id: 'icons', name: 'Icons'}, + {id: 'colors', name: 'Colors'} + ]; } return []; }, [selectedLibrary]); @@ -150,23 +156,46 @@ export function SearchMenu(props: SearchMenuProps) { return iconList.filter(item => iconFilter(item.id, searchValue)); }, [searchValue, iconFilter]); + const {contains} = useFilter({ + sensitivity: 'base' + }); + + const colorFilter = useCallback((textValue, inputValue) => { + return textValue != null && contains(textValue, inputValue); + }, [contains]); + + const filteredColors = useMemo(() => { + if (!searchValue.trim()) { + return colorSections; + } + + const searchLower = searchValue.toLowerCase(); + return colorSections.map(section => ({ + ...section, + items: section.items.filter(item => + item.name.toLowerCase().includes(searchLower) + ) + })).filter(section => section.items.length > 0); + }, [searchValue]); + // Ensure selected section is valid for the current library const baseSectionIds = sectionTags.map(s => s.id); - const baseIconIds = iconTag.map(t => t.id); - const allBaseIds = [...baseSectionIds, ...baseIconIds]; - const sectionIds = searchValue.trim().length > 0 && selectedSectionId !== 'icons' ? ['all', ...allBaseIds] : allBaseIds; + const resourcesIds = resourcesTags.map(t => t.id); + const allBaseIds = [...baseSectionIds, ...resourcesIds]; + const isResourceSelected = selectedSectionId === 'icons' || selectedSectionId === 'colors'; + const sectionIds = searchValue.trim().length > 0 && !isResourceSelected ? ['all', ...allBaseIds] : allBaseIds; if (!selectedSectionId || !sectionIds.includes(selectedSectionId)) { setSelectedSectionId(sectionIds[0] || 'components'); } - // When search starts, auto-select the All tag (unless Icons is selected). + // When search starts, auto-select the All tag (unless a Resource tag is selected) useEffect(() => { const isEmpty = searchValue.trim().length === 0; - if (prevSearchWasEmptyRef.current && !isEmpty && selectedSectionId !== 'icons') { + if (prevSearchWasEmptyRef.current && !isEmpty && !isResourceSelected) { setSelectedSectionId('all'); } prevSearchWasEmptyRef.current = isEmpty; - }, [searchValue, selectedSectionId]); + }, [searchValue, isResourceSelected]); let filteredComponents = useMemo(() => { if (!searchValue) { @@ -228,12 +257,12 @@ export function SearchMenu(props: SearchMenuProps) { }, [sections, searchValue]); const tags = useMemo(() => { - if (searchValue.trim().length > 0 && selectedSectionId !== 'icons') { - // When searching, prepend an All tag (unless Icons is selected) + if (searchValue.trim().length > 0 && !isResourceSelected) { + // When searching, prepend an All tag (unless a Resource tag is selected) return [{id: 'all', name: 'All'}, ...sectionTags]; } return sectionTags; - }, [searchValue, sectionTags, selectedSectionId]); + }, [searchValue, sectionTags, isResourceSelected]); const handleTabSelectionChange = React.useCallback((key: Key) => { if (searchValue) { @@ -257,13 +286,51 @@ export function SearchMenu(props: SearchMenuProps) { } }, []); - const handleIconSelectionChange = React.useCallback((keys: Iterable) => { + const handleResourceSelectionChange = React.useCallback((keys: Iterable) => { const firstKey = Array.from(keys)[0] as string; if (firstKey) { setSelectedSectionId(firstKey); } }, []); + const getFilterFunction = React.useCallback(() => { + if (selectedSectionId === 'icons') { + return iconFilter; + } + if (selectedSectionId === 'colors') { + return colorFilter; + } + return undefined; + }, [selectedSectionId, iconFilter, colorFilter]); + + const getResourceSelectedKeys = React.useCallback(() => { + if (selectedSectionId === 'icons') { + return ['icons']; + } + if (selectedSectionId === 'colors') { + return ['colors']; + } + return []; + }, [selectedSectionId]); + + const renderResourceView = React.useCallback(() => { + if (selectedSectionId === 'icons') { + return ( + }> + + + ); + } + if (selectedSectionId === 'colors') { + return ( + }> + + + ); + } + return null; + }, [selectedSectionId, filteredIcons, filteredColors]); + const selectedItems = useMemo(() => { let items: typeof transformedComponents = []; if (searchValue.trim().length > 0 && selectedSectionId === 'all') { @@ -336,10 +403,11 @@ export function SearchMenu(props: SearchMenuProps) { ))} {orderedTabs.map((tab, i) => { - const tabIconTag = tab.id === 'react-spectrum' ? [{id: 'icons', name: 'Icons'}] : []; + const tabResourcesTags = tab.id === 'react-spectrum' ? resourcesTags : []; + const hasAnyTags = tags.length > 0 || tabResourcesTags.length > 0; return ( - +
- {(tags.length > 0 || tabIconTag.length > 0) && ( + {hasAnyTags && (
{tags.length > 0 && ( @@ -372,16 +440,16 @@ export function SearchMenu(props: SearchMenuProps) { )} )} - {tabIconTag.length > 0 && tags.length > 0 && ( + {tabResourcesTags.length > 0 && tags.length > 0 && ( )} - {tabIconTag.length > 0 && ( + {tabResourcesTags.length > 0 && ( + selectedKeys={getResourceSelectedKeys()} + onSelectionChange={handleResourceSelectionChange} + aria-label="Resources" + items={tabResourcesTags}> {(tag) => ( {tag.name} @@ -393,11 +461,7 @@ export function SearchMenu(props: SearchMenuProps) {
)} - {selectedSectionId === 'icons' ? ( - }> - - - ) : ( + {renderResourceView() || ( { setSearchValue('');